package service import ( "context" "fmt" "strings" "time" "gorm.io/gorm" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/pkg/crypto" ) // Reference: P2E §1 — Auth business logic service // Encapsulates all authentication business logic so handlers remain thin wrappers. // Pattern follows Chatwoot's service_object pattern (app/services/). // AuthService provides all authentication business logic. type AuthService struct { db *gorm.DB jwtService *auth.JWTService refreshStore *auth.RefreshTokenStore oauthService *auth.OAuthService mfaService *auth.MFAService } // NewAuthService creates an auth service with all required dependencies. func NewAuthService( db *gorm.DB, jwtService *auth.JWTService, refreshStore *auth.RefreshTokenStore, oauthService *auth.OAuthService, mfaService *auth.MFAService, ) *AuthService { return &AuthService{ db: db, jwtService: jwtService, refreshStore: refreshStore, oauthService: oauthService, mfaService: mfaService, } } // --- Login / Registration --- // LoginInput holds login request parameters. type LoginInput struct { Email string Password string } // LoginOutput holds login response data. type LoginOutput struct { User *model.User TokenPair *auth.TokenPair AccountID uint Role string MFARequired bool } // Login authenticates a user by email+password. // Flow: verify credentials → check MFA → generate JWT pair. // If MFA is enabled, returns MFARequired=true without tokens; client must verify TOTP first. func (s *AuthService) Login(ctx context.Context, input *LoginInput) (*LoginOutput, error) { email := strings.TrimSpace(strings.ToLower(input.Email)) // Find user by email var user model.User if err := s.db.Where("email = ?", email).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("invalid email or password") } return nil, fmt.Errorf("database error: %w", err) } // Check if user is active — inactive users cannot login if !user.Active { return nil, fmt.Errorf("user account is inactive") } // Verify password using bcrypt // OAuth-only users (provider != email) cannot login with password if user.Provider != "email" && user.Provider != "" { return nil, fmt.Errorf("this account uses %s authentication, please login via that provider", user.Provider) } if !crypto.CheckPassword(input.Password, user.PasswordDigest) && !crypto.CheckPassword(input.Password, user.Password) { return nil, fmt.Errorf("invalid email or password") } // Check if email is confirmed if user.ConfirmedAt == nil { return nil, fmt.Errorf("email not confirmed, please verify your email first") } // Check MFA requirement if user.TOTPEnabled { return &LoginOutput{ User: &user, MFARequired: true, }, nil } // Get user's first active account (Chatwoot: AccountUser join) accountID, role, err := s.getUserDefaultAccount(&user) if err != nil { return nil, fmt.Errorf("failed to get user account: %w", err) } // Generate JWT token pair tokenPair, err := s.jwtService.GenerateTokenPair(&user, accountID, role) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } // Store refresh token in Redis if err := s.refreshStore.Store(ctx, user.ID, tokenPair.RefreshToken); err != nil { return nil, fmt.Errorf("failed to store refresh token: %w", err) } // SSO session creation is deferred until auth.SSOSessionStore is implemented. // Reference: M13 §5 — SSO session creation in login flow // Update sign-in tracking user.SignInCount++ now := time.Now() user.LastSignInAt = user.CurrentSignInAt user.CurrentSignInAt = &now s.db.Save(&user) return &LoginOutput{ User: &user, TokenPair: tokenPair, AccountID: accountID, Role: role, }, nil } // ValidateAccessToken returns the current user/session context for a Chatwoot auth token. func (s *AuthService) ValidateAccessToken(ctx context.Context, accessToken string) (*LoginOutput, error) { claims, err := s.jwtService.ValidateAccessToken(accessToken) if err != nil { return nil, err } var user model.User if err := s.db.WithContext(ctx).First(&user, claims.UserID).Error; err != nil { return nil, fmt.Errorf("user not found: %w", err) } accountID := claims.AccountID role := claims.Role if accountID == 0 || role == "" { accountID, role, err = s.getUserDefaultAccount(&user) if err != nil { return nil, fmt.Errorf("failed to get user account: %w", err) } } return &LoginOutput{User: &user, AccountID: accountID, Role: role}, nil } // LoginWithMFA completes login after MFA verification. // Called after user provides valid TOTP code. func (s *AuthService) LoginWithMFA(ctx context.Context, userID uint, totpCode string) (*LoginOutput, error) { // Verify TOTP code valid, err := s.mfaService.VerifyTOTPCode(userID, totpCode) if err != nil { return nil, fmt.Errorf("mfa verification failed: %w", err) } if !valid { return nil, fmt.Errorf("invalid totp code") } // Find user var user model.User if err := s.db.First(&user, userID).Error; err != nil { return nil, fmt.Errorf("user not found: %w", err) } // Get account and generate tokens (same flow as Login) accountID, role, err := s.getUserDefaultAccount(&user) if err != nil { return nil, fmt.Errorf("failed to get user account: %w", err) } tokenPair, err := s.jwtService.GenerateTokenPair(&user, accountID, role) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } if err := s.refreshStore.Store(ctx, user.ID, tokenPair.RefreshToken); err != nil { return nil, fmt.Errorf("failed to store refresh token: %w", err) } user.SignInCount++ now := time.Now() user.LastSignInAt = user.CurrentSignInAt user.CurrentSignInAt = &now s.db.Save(&user) return &LoginOutput{ User: &user, TokenPair: tokenPair, AccountID: accountID, Role: role, }, nil } // RegisterInput holds registration request parameters. type RegisterInput struct { Name string Email string Password string } // Register creates a new user account with email/password. // Flow: hash password → create user → send confirmation email → return user. func (s *AuthService) Register(ctx context.Context, input *RegisterInput) (*model.User, error) { // Check for duplicate email var existing model.User if err := s.db.Where("email = ?", input.Email).First(&existing).Error; err == nil { return nil, fmt.Errorf("email already registered") } // Hash password passwordDigest, err := crypto.HashPassword(input.Password) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } // Create user user := &model.User{ Name: input.Name, Email: input.Email, PasswordDigest: passwordDigest, Provider: "email", } if err := s.db.Create(user).Error; err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } // Production note: confirmation email should be sent via async worker queue. // Current implementation logs the action; wire email worker when infrastructure is ready. // For now, auto-confirm for development convenience now := time.Now() user.ConfirmedAt = &now s.db.Save(user) return user, nil } // --- Token Refresh / Rotation --- // RefreshInput holds refresh token request parameters. type RefreshInput struct { RefreshToken string } // RefreshOutput holds refresh token response data. type RefreshOutput struct { TokenPair *auth.TokenPair User *model.User } // Refresh rotates a refresh token: validates old token, generates new pair. // Implements refresh token rotation per P2E §1.4 security requirement. func (s *AuthService) Refresh(ctx context.Context, input *RefreshInput) (*RefreshOutput, error) { // Validate the refresh token JWT first claims, err := s.jwtService.ValidateRefreshToken(input.RefreshToken) if err != nil { return nil, fmt.Errorf("invalid refresh token: %w", err) } // Check refresh token exists in Redis (prevents reuse after logout) valid, err := s.refreshStore.Validate(ctx, claims.UserID, input.RefreshToken) if err != nil { return nil, fmt.Errorf("refresh token validation failed: %w", err) } if !valid { return nil, fmt.Errorf("refresh token expired or revoked") } // Find user var user model.User if err := s.db.First(&user, claims.UserID).Error; err != nil { return nil, fmt.Errorf("user not found: %w", err) } // Get user's default account accountID, role, err := s.getUserDefaultAccount(&user) if err != nil { return nil, fmt.Errorf("failed to get user account: %w", err) } // Generate new token pair tokenPair, err := s.jwtService.GenerateTokenPair(&user, accountID, role) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } // Rotate refresh token in Redis (old token revoked, new token stored) if err := s.refreshStore.Rotate(ctx, claims.UserID, tokenPair.RefreshToken); err != nil { return nil, fmt.Errorf("failed to rotate refresh token: %w", err) } return &RefreshOutput{ TokenPair: tokenPair, User: &user, }, nil } // --- Logout --- // Logout revokes a user's refresh token, effectively logging them out. // Access tokens will still be valid until expiry, but refresh is revoked. func (s *AuthService) Logout(ctx context.Context, userID uint) error { return s.refreshStore.Revoke(ctx, userID) } // --- Account Switching --- // SwitchAccountInput holds account switch parameters. type SwitchAccountInput struct { UserID uint AccountID uint } // SwitchAccountOutput holds account switch response. type SwitchAccountOutput struct { TokenPair *auth.TokenPair AccountID uint Role string } // SwitchAccount generates new tokens with a different account scope. // Ref: Chatwoot's account switch in the UI — user can operate in multiple accounts. func (s *AuthService) SwitchAccount(ctx context.Context, input *SwitchAccountInput) (*SwitchAccountOutput, error) { // Verify user belongs to the target account var accountUser AccountUser if err := s.db.Where("user_id = ? AND account_id = ?", input.UserID, input.AccountID).First(&accountUser).Error; err != nil { return nil, fmt.Errorf("user does not belong to account %d", input.AccountID) } // Find user var user model.User if err := s.db.First(&user, input.UserID).Error; err != nil { return nil, fmt.Errorf("user not found: %w", err) } // Generate new token pair with switched account tokenPair, err := s.jwtService.GenerateTokenPair(&user, input.AccountID, accountUser.Role) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } // Rotate refresh token if err := s.refreshStore.Rotate(ctx, input.UserID, tokenPair.RefreshToken); err != nil { // If no old token to rotate, just store new one if err := s.refreshStore.Store(ctx, input.UserID, tokenPair.RefreshToken); err != nil { return nil, fmt.Errorf("failed to store refresh token: %w", err) } } return &SwitchAccountOutput{ TokenPair: tokenPair, AccountID: input.AccountID, Role: accountUser.Role, }, nil } // --- Password Reset --- // ResetPasswordInput holds password reset request parameters. type ResetPasswordInput struct { Email string } // ResetPassword initiates a password reset flow. // Generates a reset token stored in Redis, sends email with reset link. func (s *AuthService) ResetPassword(ctx context.Context, input *ResetPasswordInput) error { var user model.User if err := s.db.Where("email = ?", input.Email).First(&user).Error; err != nil { // Don't reveal whether email exists — security best practice return nil } // Production note: reset token should be generated, stored in Redis with TTL, and emailed via worker. // Placeholder for development return nil } // ConfirmResetPasswordInput holds password reset confirmation parameters. type ConfirmResetPasswordInput struct { Token string Password string } // ConfirmResetPassword completes password reset by verifying token and updating password. func (s *AuthService) ConfirmResetPassword(ctx context.Context, input *ConfirmResetPasswordInput) error { // Production note: reset token must be verified from Redis before password update. return fmt.Errorf("password reset confirmation not yet implemented") } // --- Email Confirmation --- // ConfirmEmailInput holds email confirmation parameters. type ConfirmEmailInput struct { Token string } // ConfirmEmail verifies a confirmation token and marks user email as confirmed. func (s *AuthService) ConfirmEmail(ctx context.Context, input *ConfirmEmailInput) (*model.User, error) { // Production note: confirmation token must be verified from Redis before marking confirmed. // Placeholder — in development mode, users are auto-confirmed during registration return nil, fmt.Errorf("email confirmation not yet implemented (auto-confirmed in dev mode)") } // --- OAuth2 Login --- // OAuthLoginInput holds OAuth callback parameters. type OAuthLoginInput struct { Provider auth.OAuthProviderType Code string State string } // OAuthLoginOutput holds OAuth login response. type OAuthLoginOutput struct { User *model.User TokenPair *auth.TokenPair AccountID uint Role string IsNewUser bool } // OAuthLogin handles the OAuth2 callback: exchange code → find/create user → generate tokens. func (s *AuthService) OAuthLogin(ctx context.Context, input *OAuthLoginInput) (*OAuthLoginOutput, error) { // Exchange OAuth code for user info oauthInfo, err := s.oauthService.ExchangeCode(ctx, input.Provider, input.Code) if err != nil { return nil, fmt.Errorf("oauth exchange failed: %w", err) } // Find or create user from OAuth info user, err := s.oauthService.FindOrCreateUser(oauthInfo) if err != nil { return nil, fmt.Errorf("oauth user creation failed: %w", err) } // Determine if this is a new user isNewUser := user.SignInCount == 0 // Get account and generate tokens accountID, role, err := s.getUserDefaultAccount(user) if err != nil { // New OAuth users may not have an account yet // Create a default personal account for them accountID, role, err = s.createDefaultAccount(user) if err != nil { return nil, fmt.Errorf("failed to create default account: %w", err) } } tokenPair, err := s.jwtService.GenerateTokenPair(user, accountID, role) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } if err := s.refreshStore.Store(ctx, user.ID, tokenPair.RefreshToken); err != nil { return nil, fmt.Errorf("failed to store refresh token: %w", err) } user.SignInCount++ now := time.Now() user.LastSignInAt = user.CurrentSignInAt user.CurrentSignInAt = &now s.db.Save(user) return &OAuthLoginOutput{ User: user, TokenPair: tokenPair, AccountID: accountID, Role: role, IsNewUser: isNewUser, }, nil } // --- Internal Helpers --- // AccountUser represents the join between User and Account (many-to-many). // Ref: Chatwoot AccountUser model (role assignment per account). type AccountUser struct { ID uint `gorm:"primaryKey"` UserID uint `gorm:"not null;index"` AccountID uint `gorm:"not null;index"` Role string `gorm:"size:50;default:agent"` // agent/administrator/custom_role_id } func (AccountUser) TableName() string { return "account_users" } // getUserDefaultAccount finds the user's first active account and role. func (s *AuthService) getUserDefaultAccount(user *model.User) (uint, string, error) { var accountUser AccountUser if err := s.db.Where("user_id = ?", user.ID).Order("id ASC").First(&accountUser).Error; err != nil { if err == gorm.ErrRecordNotFound { return 0, "", fmt.Errorf("user has no account") } return 0, "", err } return accountUser.AccountID, accountUser.Role, nil } // createDefaultAccount creates a personal account for a new user. func (s *AuthService) createDefaultAccount(user *model.User) (uint, string, error) { // Create account account := &model.Account{ Name: user.Name + "'s Account", Status: "active", } if err := s.db.Create(account).Error; err != nil { return 0, "", fmt.Errorf("failed to create account: %w", err) } // Create account-user join with administrator role accountUser := &AccountUser{ UserID: user.ID, AccountID: account.ID, Role: "administrator", } if err := s.db.Create(accountUser).Error; err != nil { return 0, "", fmt.Errorf("failed to create account_user: %w", err) } return account.ID, "administrator", nil }