feat: OTP supports unified register+login flow
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
5ac9af018a
commit
4603402b84
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user