feat: OTP supports unified register+login flow
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Previously SendOTP silently dropped requests for unknown emails, so new
users had no passwordless path in. Now:

- SendOTP: if REGISTRATION_ENABLED and email unknown, generates and
  sends the code anyway (UserID nil until verify)
- VerifyOTP: if email unknown after valid code, auto-registers the user
  (emailVerified=true — OTP delivery proves ownership, name defaults to
  email local-part) then creates a session

REGISTRATION_ENABLED=false continues to block unknown emails at SendOTP,
preserving invite-only / closed-beta behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-22 11:17:42 -07:00
parent 5ac9af018a
commit 4603402b84

View File

@ -277,24 +277,33 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPasswor
return s.users.SetPassword(ctx, uid, newHash)
}
// SendOTP generates and logs a one-time password for the given email.
// In production, this would send an email. In dev mode, the code is logged to stdout.
// SendOTP generates a one-time password for the given email.
// If the email is not registered and registration is enabled, the code is still
// sent — the account will be created when the code is verified. This supports a
// unified register+login flow with a single OTP email.
func (s *AuthService) SendOTP(ctx context.Context, email, ip string) error {
user, err := s.users.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, domain.ErrUserNotFound) {
// Don't reveal whether email exists
s.logger.Info("OTP requested for unknown email", "email", email)
if !errors.Is(err, domain.ErrUserNotFound) {
return err
}
// Unknown email: only proceed if registration is open.
if !s.registrationEnabled {
s.logger.Info("OTP requested for unknown email (registration disabled)", "email", email)
return nil
}
return err
// Registration enabled — send code anyway. UserID will be nil until verify.
user = nil
}
code := generateOTP()
uid := user.ID
var uid *domain.UserID
if user != nil {
uid = &user.ID
}
authCode := &domain.AuthCode{
ID: "acd_" + generateID(),
UserID: &uid,
UserID: uid,
Email: email,
Code: code,
Purpose: domain.PurposeLoginOTP,
@ -315,6 +324,8 @@ func (s *AuthService) SendOTP(ctx context.Context, email, ip string) error {
}
// VerifyOTP verifies a one-time password and returns a login token.
// If the email has no account yet and registration is enabled, the account is
// created automatically — OTP delivery proves email ownership.
func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent string) (*LoginOutput, error) {
authCode, err := s.codes.FindValid(ctx, email, code, domain.PurposeLoginOTP)
if err != nil {
@ -327,7 +338,17 @@ func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent
user, err := s.users.GetByEmail(ctx, email)
if err != nil {
return nil, err
if !errors.Is(err, domain.ErrUserNotFound) {
return nil, err
}
if !s.registrationEnabled {
return nil, domain.ErrRegistrationDisabled
}
// Auto-register: OTP delivery already proved email ownership.
user, err = s.autoRegisterViaOTP(ctx, email)
if err != nil {
return nil, err
}
}
_ = s.users.UpdateLastLogin(ctx, user.ID)
@ -336,6 +357,25 @@ func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent
return s.createSession(ctx, user, ip, userAgent)
}
// autoRegisterViaOTP creates a minimal user account for an email that just
// verified an OTP. Email is considered verified because OTP delivery proved
// ownership. The name defaults to the local part of the email address.
func (s *AuthService) autoRegisterViaOTP(ctx context.Context, email string) (*domain.User, error) {
name := email
if at := strings.IndexByte(email, '@'); at > 0 {
name = email[:at]
}
userID := domain.UserID("usr_" + generateID())
user := domain.NewUser(userID, email, name)
user.EmailVerified = true // OTP delivery proves ownership
if err := s.users.Create(ctx, user); err != nil {
return nil, err
}
s.logger.Info("user auto-registered via OTP", "user_id", string(userID), "email", email)
return user, nil
}
// SendMagicLink generates and logs a magic link token.
func (s *AuthService) SendMagicLink(ctx context.Context, email, ip string) error {
// Magic links can work for existing users.