package service import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "math/big" "net/url" "strings" "time" "git.threesix.ai/jordan/persona-community-2/pkg/auth" "git.threesix.ai/jordan/persona-community-2/pkg/logging" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port" ) const ( // TokenLifetime is the access token duration (short-lived, requires refresh). TokenLifetime = 15 * time.Minute // SessionLifetime is how long a session stays valid before requiring re-login. SessionLifetime = 30 * 24 * time.Hour // 30 days // OTPExpiry is how long a one-time password is valid. OTPExpiry = 10 * time.Minute // MagicLinkExpiry is how long a magic link token is valid. MagicLinkExpiry = 15 * time.Minute // PasswordResetExpiry is how long a password reset token is valid. PasswordResetExpiry = 1 * time.Hour // EmailVerifyExpiry is how long an email verification code is valid. EmailVerifyExpiry = 24 * time.Hour ) // AuthService handles all authentication and identity flows. type AuthService struct { users port.UserRepository sessions port.SessionRepository codes port.AuthCodeRepository email port.EmailSender jwtSecret []byte issuer string registrationEnabled bool logger *logging.Logger } // NewAuthService creates a new auth service. func NewAuthService( users port.UserRepository, sessions port.SessionRepository, codes port.AuthCodeRepository, email port.EmailSender, jwtSecret string, registrationEnabled bool, logger *logging.Logger, ) *AuthService { return &AuthService{ users: users, sessions: sessions, codes: codes, email: email, jwtSecret: []byte(jwtSecret), issuer: "persona-community-2", registrationEnabled: registrationEnabled, logger: logger.WithService("AuthService"), } } // LoginOutput is the result of a successful login or registration. type LoginOutput struct { Token string User *domain.User } // Register creates a new user account with email and password. func (s *AuthService) Register(ctx context.Context, email, password, name, ip, userAgent string) (*LoginOutput, error) { if !s.registrationEnabled { return nil, domain.ErrRegistrationDisabled } if err := auth.ValidatePasswordStrength(password); err != nil { return nil, fmt.Errorf("%w: %w", domain.ErrWeakPassword, err) } name = strings.TrimSpace(name) if len(name) > domain.MaxNameLen { return nil, domain.ErrNameTooLong } if len(email) > domain.MaxEmailLen { return nil, domain.ErrEmailTooLong } exists, err := s.users.ExistsByEmail(ctx, email) if err != nil { return nil, err } if exists { return nil, domain.ErrDuplicateEmail } hash, err := auth.HashPassword(password) if err != nil { return nil, fmt.Errorf("hashing password: %w", err) } userID := domain.UserID("usr_" + generateID()) user := domain.NewUser(userID, email, name) if err := s.users.Create(ctx, user); err != nil { return nil, err } if err := s.users.SetPassword(ctx, userID, hash); err != nil { return nil, err } s.logger.Info("user registered", "user_id", string(userID), "email", email) return s.createSession(ctx, user, ip, userAgent) } // LoginWithPassword authenticates a user with email and password. func (s *AuthService) LoginWithPassword(ctx context.Context, email, password, ip, userAgent string) (*LoginOutput, error) { user, err := s.users.GetByEmail(ctx, email) if err != nil { if errors.Is(err, domain.ErrUserNotFound) { return nil, domain.ErrInvalidCredentials } return nil, err } if user.Status == domain.UserStatusSuspended { return nil, domain.ErrUserSuspended } hash, err := s.users.GetPasswordHash(ctx, user.ID) if err != nil { return nil, err } if hash == "" || !auth.CheckPassword(password, hash) { s.logger.Warn("invalid password attempt", "email", email) return nil, domain.ErrInvalidCredentials } _ = s.users.UpdateLastLogin(ctx, user.ID) s.logger.Info("user logged in", "user_id", string(user.ID), "email", email) return s.createSession(ctx, user, ip, userAgent) } // RefreshToken issues a new access token if the session is still active. func (s *AuthService) RefreshToken(ctx context.Context, sessionID string, userID string) (*LoginOutput, error) { sid := domain.SessionID(sessionID) session, err := s.sessions.Get(ctx, sid) if err != nil { return nil, domain.ErrSessionNotFound } if !session.IsActive() { return nil, domain.ErrSessionRevoked } user, err := s.users.Get(ctx, domain.UserID(userID)) if err != nil { return nil, err } if user.Status == domain.UserStatusSuspended { return nil, domain.ErrUserSuspended } _ = s.sessions.UpdateLastActive(ctx, sid) token, err := s.generateToken(user, sessionID) if err != nil { return nil, err } return &LoginOutput{Token: token, User: user}, nil } // Logout revokes the current session. func (s *AuthService) Logout(ctx context.Context, sessionID string) error { if sessionID == "" { return nil } return s.sessions.Revoke(ctx, domain.SessionID(sessionID)) } // LogoutAll revokes all sessions for a user, optionally keeping one. func (s *AuthService) LogoutAll(ctx context.Context, userID string, exceptSessionID *string) error { var except *domain.SessionID if exceptSessionID != nil { sid := domain.SessionID(*exceptSessionID) except = &sid } return s.sessions.RevokeAllForUser(ctx, domain.UserID(userID), except) } // CheckSession returns whether a session is active (not revoked, not expired). // Used as auth.SessionChecker for the SessionCheck middleware. func (s *AuthService) CheckSession(ctx context.Context, sessionID string) (bool, error) { session, err := s.sessions.Get(ctx, domain.SessionID(sessionID)) if err != nil { return false, nil } return session.IsActive(), nil } // ListSessions returns all active sessions for a user. func (s *AuthService) ListSessions(ctx context.Context, userID string) ([]domain.Session, error) { return s.sessions.ListByUser(ctx, domain.UserID(userID)) } // RevokeSession revokes a specific session for a user. func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID string) error { session, err := s.sessions.Get(ctx, domain.SessionID(sessionID)) if err != nil { return err } if session.UserID != domain.UserID(userID) { return domain.ErrSessionNotFound } return s.sessions.Revoke(ctx, domain.SessionID(sessionID)) } // GetCurrentUser returns the full user for the given ID. func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*domain.User, error) { return s.users.Get(ctx, domain.UserID(userID)) } // UpdateProfile updates a user's name and avatar. func (s *AuthService) UpdateProfile(ctx context.Context, userID, name, avatarURL string) (*domain.User, error) { user, err := s.users.Get(ctx, domain.UserID(userID)) if err != nil { return nil, err } if name != "" { name = strings.TrimSpace(name) if len(name) > domain.MaxNameLen { return nil, domain.ErrNameTooLong } user.Name = name } if avatarURL != "" { if err := validateAvatarURL(avatarURL); err != nil { return nil, err } user.AvatarURL = avatarURL } if err := s.users.Update(ctx, user); err != nil { return nil, err } return user, nil } // ChangePassword changes a user's password after verifying the current one. func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error { uid := domain.UserID(userID) hash, err := s.users.GetPasswordHash(ctx, uid) if err != nil { return err } if hash == "" || !auth.CheckPassword(currentPassword, hash) { return domain.ErrInvalidCredentials } if err := auth.ValidatePasswordStrength(newPassword); err != nil { return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err) } newHash, err := auth.HashPassword(newPassword) if err != nil { return fmt.Errorf("hashing password: %w", err) } return s.users.SetPassword(ctx, uid, newHash) } // 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) { 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 } // Registration enabled — send code anyway. UserID will be nil until verify. user = nil } code := generateOTP() var uid *domain.UserID if user != nil { uid = &user.ID } authCode := &domain.AuthCode{ ID: "acd_" + generateID(), UserID: uid, Email: email, Code: code, Purpose: domain.PurposeLoginOTP, ExpiresAt: time.Now().Add(OTPExpiry), IPAddress: ip, CreatedAt: time.Now(), } if err := s.codes.Create(ctx, authCode); err != nil { return err } s.logger.Info("auth code created", "purpose", "login_otp", "email", email, "code_id", authCode.ID) if err := s.email.SendAuthCode(ctx, email, code, string(domain.PurposeLoginOTP)); err != nil { s.logger.Error("failed to send OTP email", "email", email, "error", err) } return nil } // 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 { return nil, domain.ErrInvalidAuthCode } if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil { return nil, err } user, err := s.users.GetByEmail(ctx, email) if err != nil { 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) s.logger.Info("user logged in via OTP", "user_id", string(user.ID), "email", email) 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. // Don't reveal whether email exists — but propagate infrastructure errors. user, err := s.users.GetByEmail(ctx, email) if err != nil && !errors.Is(err, domain.ErrUserNotFound) { return err } token := generateHexToken() var uid *domain.UserID if user != nil { uid = &user.ID } authCode := &domain.AuthCode{ ID: "acd_" + generateID(), UserID: uid, Email: email, Code: token, Purpose: domain.PurposeMagicLink, ExpiresAt: time.Now().Add(MagicLinkExpiry), IPAddress: ip, CreatedAt: time.Now(), } if err := s.codes.Create(ctx, authCode); err != nil { return err } s.logger.Info("auth code created", "purpose", "magic_link", "email", email, "code_id", authCode.ID) if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposeMagicLink)); err != nil { s.logger.Error("failed to send magic link email", "email", email, "error", err) } return nil } // VerifyMagicLink verifies a magic link token and returns a login token. func (s *AuthService) VerifyMagicLink(ctx context.Context, email, token, ip, userAgent string) (*LoginOutput, error) { authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposeMagicLink) if err != nil { return nil, domain.ErrInvalidAuthCode } if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil { return nil, err } user, err := s.users.GetByEmail(ctx, email) if err != nil { return nil, err } _ = s.users.UpdateLastLogin(ctx, user.ID) s.logger.Info("user logged in via magic link", "user_id", string(user.ID), "email", email) return s.createSession(ctx, user, ip, userAgent) } // ForgotPassword generates a password reset token. func (s *AuthService) ForgotPassword(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("password reset requested for unknown email", "email", email) return nil } return err } token := generateHexToken() uid := user.ID authCode := &domain.AuthCode{ ID: "acd_" + generateID(), UserID: &uid, Email: email, Code: token, Purpose: domain.PurposePasswordReset, ExpiresAt: time.Now().Add(PasswordResetExpiry), IPAddress: ip, CreatedAt: time.Now(), } if err := s.codes.Create(ctx, authCode); err != nil { return err } s.logger.Info("auth code created", "purpose", "password_reset", "email", email, "code_id", authCode.ID) if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposePasswordReset)); err != nil { s.logger.Error("failed to send password reset email", "email", email, "error", err) } return nil } // ResetPassword sets a new password using a reset token and revokes all sessions. func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPassword string) error { authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposePasswordReset) if err != nil { return domain.ErrInvalidAuthCode } if err := auth.ValidatePasswordStrength(newPassword); err != nil { return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err) } user, err := s.users.GetByEmail(ctx, email) if err != nil { return err } hash, err := auth.HashPassword(newPassword) if err != nil { return fmt.Errorf("hashing password: %w", err) } if err := s.users.SetPassword(ctx, user.ID, hash); err != nil { return err } if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil { return err } // Revoke all sessions — user must re-login with new password. _ = s.sessions.RevokeAllForUser(ctx, user.ID, nil) s.logger.Info("password reset completed", "user_id", string(user.ID), "email", email) return nil } // SendVerifyEmail generates an email verification code. func (s *AuthService) SendVerifyEmail(ctx context.Context, userID string) error { user, err := s.users.Get(ctx, domain.UserID(userID)) if err != nil { return err } if user.EmailVerified { return nil } code := generateOTP() uid := user.ID authCode := &domain.AuthCode{ ID: "acd_" + generateID(), UserID: &uid, Email: user.Email, Code: code, Purpose: domain.PurposeEmailVerify, ExpiresAt: time.Now().Add(EmailVerifyExpiry), CreatedAt: time.Now(), } if err := s.codes.Create(ctx, authCode); err != nil { return err } s.logger.Info("auth code created", "purpose", "email_verify", "email", user.Email, "code_id", authCode.ID) if err := s.email.SendAuthCode(ctx, user.Email, code, string(domain.PurposeEmailVerify)); err != nil { s.logger.Error("failed to send email verification", "email", user.Email, "error", err) } return nil } // VerifyEmail marks the user's email as verified. func (s *AuthService) VerifyEmail(ctx context.Context, userID, code string) error { user, err := s.users.Get(ctx, domain.UserID(userID)) if err != nil { return err } authCode, err := s.codes.FindValid(ctx, user.Email, code, domain.PurposeEmailVerify) if err != nil { return domain.ErrInvalidAuthCode } if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil { return err } user.EmailVerified = true if err := s.users.Update(ctx, user); err != nil { return err } s.logger.Info("email verified", "user_id", userID, "email", user.Email) return nil } // createSession creates a session record and generates a JWT. func (s *AuthService) createSession(ctx context.Context, user *domain.User, ip, userAgent string) (*LoginOutput, error) { sessionID := "ses_" + generateID() now := time.Now() session := &domain.Session{ ID: domain.SessionID(sessionID), UserID: user.ID, IPAddress: ip, UserAgent: userAgent, DeviceLabel: auth.ParseDeviceLabel(userAgent), LastActiveAt: now, ExpiresAt: now.Add(SessionLifetime), CreatedAt: now, } if err := s.sessions.Create(ctx, session); err != nil { return nil, err } token, err := s.generateToken(user, sessionID) if err != nil { return nil, err } return &LoginOutput{Token: token, User: user}, nil } // generateToken creates a JWT for the user with the given session ID. func (s *AuthService) generateToken(user *domain.User, sessionID string) (string, error) { authUser := &auth.User{ ID: string(user.ID), Email: user.Email, Roles: user.Roles, } return auth.GenerateTokenWithSession( s.jwtSecret, authUser, TokenLifetime, s.issuer, s.issuer, sessionID, ) } // generateID returns a random hex string suitable for entity IDs. func generateID() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { panic("crypto/rand failed: " + err.Error()) } return hex.EncodeToString(b) } // generateOTP returns a 6-digit numeric one-time password. func generateOTP() string { n, err := rand.Int(rand.Reader, big.NewInt(1000000)) if err != nil { panic("crypto/rand failed: " + err.Error()) } return fmt.Sprintf("%06d", n.Int64()) } // validateAvatarURL checks that the URL uses http or https. func validateAvatarURL(rawURL string) error { parsed, err := url.Parse(rawURL) if err != nil { return domain.ErrInvalidAvatarURL } if parsed.Scheme != "http" && parsed.Scheme != "https" { return domain.ErrInvalidAvatarURL } return nil } // generateHexToken returns a 32-character hex token for magic links and resets. func generateHexToken() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { panic("crypto/rand failed: " + err.Error()) } return hex.EncodeToString(b) }