mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-20 00:01:21 +00:00
233 lines
6.7 KiB
Go
233 lines
6.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/crypt"
|
|
)
|
|
|
|
// Login and registration keep separate send budgets. They used to share one
|
|
// key, which let anyone lock a known user out of login for the whole window by
|
|
// POSTing /auth/register with that user's address.
|
|
func getEmailVerificationKey(flow, email string) string {
|
|
return "email_verification:" + flow + ":" + crypt.SHA256(email)
|
|
}
|
|
|
|
// getKnownDeviceKey remembers a device that has already completed a full login,
|
|
// so AUTH_LOGIN_CODE=new_device only challenges genuinely new ones.
|
|
func getKnownDeviceKey(userID uuid.UUID, fingerprint string) string {
|
|
return "known_device:" + userID.String() + ":" + fingerprint
|
|
}
|
|
|
|
func getPasswordResetLimitKey(email string) string {
|
|
return "password_reset_limit:" + crypt.SHA256(email)
|
|
}
|
|
|
|
func getLoginSessionKey(sessionID uuid.UUID) string {
|
|
return "login_sess:" + sessionID.String()
|
|
}
|
|
|
|
func getRegistrationSessionKey(sessionID uuid.UUID) string {
|
|
return "registration_sess:" + sessionID.String()
|
|
}
|
|
|
|
func getResetPasswordSessionKey(sessionID uuid.UUID) string {
|
|
return "reset_password:" + sessionID.String()
|
|
}
|
|
|
|
func (s *authService) saveLoginSession(ctx context.Context, sessionID uuid.UUID, session *models.LoginSession, expiresAt time.Time) *errx.Error {
|
|
data, err := json.Marshal(session)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
if err := s.cache.Set(ctx, getLoginSessionKey(sessionID), data, time.Until(expiresAt)).Err(); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *authService) getLoginSession(ctx context.Context, sessionID uuid.UUID) (*models.LoginSession, *errx.Error) {
|
|
data, err := s.cache.Get(ctx, getLoginSessionKey(sessionID)).Bytes()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, nil
|
|
}
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
var session models.LoginSession
|
|
if err := json.Unmarshal(data, &session); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
func (s *authService) saveRegistrationSession(ctx context.Context, sessionID uuid.UUID, session *models.RegistrationSession, expiresAt time.Time) *errx.Error {
|
|
data, err := json.Marshal(session)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
if err := s.cache.Set(ctx, getRegistrationSessionKey(sessionID), data, time.Until(expiresAt)).Err(); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *authService) getRegistrationSession(ctx context.Context, sessionID uuid.UUID) (*models.RegistrationSession, *errx.Error) {
|
|
data, err := s.cache.Get(ctx, getRegistrationSessionKey(sessionID)).Bytes()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, nil
|
|
}
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
var session models.RegistrationSession
|
|
if err := json.Unmarshal(data, &session); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
func (s *authService) canSendEmail(ctx context.Context, flow, email string) *errx.Error {
|
|
key := getEmailVerificationKey(flow, email)
|
|
|
|
count, err := s.cache.Incr(ctx, key).Result()
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
if count == 1 {
|
|
if err := s.cache.Expire(ctx, key, AuthEmailTTL).Err(); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
}
|
|
|
|
if count > AuthEmailLimit {
|
|
return errx.ErrAuthLimit
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *authService) passwordResetLimit(ctx context.Context, email string) *errx.Error {
|
|
key := getPasswordResetLimitKey(email)
|
|
|
|
count, err := s.cache.Incr(ctx, key).Result()
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
if count == 1 {
|
|
if err := s.cache.Expire(ctx, key, PasswordResetLimitTTL).Err(); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
}
|
|
|
|
if count > PasswordResetLimit {
|
|
return errx.ErrAuthLimit
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// saveResetPasswordSession binds the emailed reset JWT to a server-side nonce.
|
|
// The TTL is PasswordResetTTL, the same lifetime the JWT carries and the same
|
|
// one the email quotes: it used to be SessionTTL (10 minutes) against a 1-hour
|
|
// token and a mail that promised 4 hours.
|
|
func (s *authService) saveResetPasswordSession(ctx context.Context, sessionID uuid.UUID, nonce string) *errx.Error {
|
|
if err := s.cache.SetEx(ctx, getResetPasswordSessionKey(sessionID), nonce, PasswordResetTTL).Err(); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *authService) getResetPasswordSession(ctx context.Context, sessionID uuid.UUID) (string, *errx.Error) {
|
|
val, err := s.cache.Get(ctx, getResetPasswordSessionKey(sessionID)).Result()
|
|
if err != nil {
|
|
// An expired or already-used link is ordinary, not an internal fault.
|
|
if errors.Is(err, redis.Nil) {
|
|
return "", errx.ErrToken
|
|
}
|
|
sentry.CaptureException(err)
|
|
return "", errx.InternalError()
|
|
}
|
|
|
|
return val, nil
|
|
}
|
|
|
|
// rememberDevice marks this device as having completed a full login, so
|
|
// AUTH_LOGIN_CODE=new_device stops challenging it.
|
|
func (s *authService) rememberDevice(ctx context.Context, userID uuid.UUID, userAgent string) {
|
|
fp := deviceFingerprint(userAgent)
|
|
if fp == "" {
|
|
return
|
|
}
|
|
_ = s.cache.SetEx(ctx, getKnownDeviceKey(userID, fp), "1", KnownDeviceTTL).Err()
|
|
}
|
|
|
|
// isKnownDevice reports whether this device has completed a login inside the
|
|
// retention window.
|
|
func (s *authService) isKnownDevice(ctx context.Context, userID uuid.UUID, userAgent string) bool {
|
|
fp := deviceFingerprint(userAgent)
|
|
if fp == "" {
|
|
return false
|
|
}
|
|
n, err := s.cache.Exists(ctx, getKnownDeviceKey(userID, fp)).Result()
|
|
return err == nil && n > 0
|
|
}
|
|
|
|
// deviceFingerprint is deliberately weak: a user agent is trivially spoofable,
|
|
// so this is a convenience signal for skipping a code on a device the user has
|
|
// already used, never an authentication factor. An empty agent yields no
|
|
// fingerprint, which fails closed into "new device".
|
|
func deviceFingerprint(userAgent string) string {
|
|
if strings.TrimSpace(userAgent) == "" {
|
|
return ""
|
|
}
|
|
return crypt.SHA256(userAgent)
|
|
}
|
|
|
|
func (s *authService) deletePasswordResetSession(ctx context.Context, sessionID uuid.UUID) *errx.Error {
|
|
val, err := s.cache.Del(ctx, getResetPasswordSessionKey(sessionID)).Result()
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
if val == 0 {
|
|
return errx.ErrToken
|
|
}
|
|
|
|
return nil
|
|
}
|