persona-community-1/services/persona-api/internal/service/auth.go
2026-02-23 10:21:29 +00:00

645 lines
19 KiB
Go

package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"math/big"
"net/url"
"strings"
"time"
"git.threesix.ai/jordan/persona-community-1/pkg/auth"
"git.threesix.ai/jordan/persona-community-1/pkg/logging"
"git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain"
"git.threesix.ai/jordan/persona-community-1/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-1",
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)
}