package service import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" pkgcrypto "github.com/gochat/gochat/pkg/crypto" ) const ChatwootPasswordResetMessage = "Request for password reset is successful. A email with instructions will be sent to your email if it exists." // 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 } // NewAuthService creates an auth service with all required dependencies. func NewAuthService( db *gorm.DB, jwtService *auth.JWTService, refreshStore *auth.RefreshTokenStore, ) *AuthService { return &AuthService{ db: db, jwtService: jwtService, refreshStore: refreshStore, } } // --- 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 ClientID string } func (s *AuthService) TrackChatwootSession(ctx context.Context, output *LoginOutput, requestedClientID, ipAddress, userAgent string) error { if output == nil || output.User == nil { return errors.New("login output is required") } clientID := strings.TrimSpace(requestedClientID) if clientID == "" { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { return fmt.Errorf("generate session client id: %w", err) } clientID = hex.EncodeToString(bytes) } pair, err := s.jwtService.GenerateTokenPairForClient(output.User, output.AccountID, output.Role, clientID) if err != nil { return err } if err := s.refreshStore.StoreForClient(ctx, output.User.ID, clientID, pair.RefreshToken); err != nil { return err } _ = s.refreshStore.Revoke(ctx, output.User.ID) now := time.Now().UTC() browserName, browserVersion, deviceName, platformName, platformVersion := chatwootSessionUserAgent(userAgent) session := model.UserSession{ UserID: output.User.ID, ClientID: clientID, IPAddress: ipAddress, UserAgent: userAgent, BrowserName: browserName, BrowserVersion: browserVersion, DeviceName: deviceName, PlatformName: platformName, PlatformVersion: platformVersion, LastActivityAt: &now, } if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}}, DoUpdates: clause.AssignmentColumns([]string{"ip_address", "user_agent", "browser_name", "browser_version", "device_name", "platform_name", "platform_version", "last_activity_at", "updated_at"}), }).Create(&session).Error; err != nil { return err } output.TokenPair = pair output.ClientID = clientID return nil } func (s *AuthService) RevokeChatwootSession(ctx context.Context, userID uint, clientID string) error { if strings.TrimSpace(clientID) != "" { if err := s.db.WithContext(ctx).Where("user_id = ? AND client_id = ?", userID, clientID).Delete(&model.UserSession{}).Error; err != nil { return err } } return s.refreshStore.RevokeClient(ctx, userID, clientID) } func chatwootSessionUserAgent(userAgent string) (browserName, browserVersion, deviceName, platformName, platformVersion string) { browserName = "Unknown" deviceName = "Desktop" platformName = "Unknown" for _, candidate := range []struct{ marker, name string }{{"Edg/", "Edge"}, {"Chrome/", "Chrome"}, {"Firefox/", "Firefox"}, {"Version/", "Safari"}} { if idx := strings.Index(userAgent, candidate.marker); idx >= 0 { browserName = candidate.name if fields := strings.Fields(userAgent[idx+len(candidate.marker):]); len(fields) > 0 { browserVersion = strings.TrimRight(fields[0], ");") } break } } switch { case strings.Contains(userAgent, "Windows NT"): platformName = "Windows" case strings.Contains(userAgent, "Mac OS X"): platformName = "macOS" case strings.Contains(userAgent, "Android"): platformName, deviceName = "Android", "Mobile" case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad"): platformName, deviceName = "iOS", "Mobile" case strings.Contains(userAgent, "Linux"): platformName = "Linux" } return } // Login authenticates a user by email+password. // Flow: verify credentials → generate JWT pair. 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 !pkgcrypto.CheckPassword(input.Password, user.PasswordDigest) && !pkgcrypto.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") } // 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 } if claims.ClientID != "" { var session model.UserSession if err := s.db.WithContext(ctx).Where("user_id = ? AND client_id = ?", claims.UserID, claims.ClientID).First(&session).Error; err != nil { return nil, errors.New("session revoked") } if session.LastActivityAt == nil || session.LastActivityAt.Before(time.Now().Add(-5*time.Minute)) { now := time.Now().UTC() _ = s.db.WithContext(ctx).Model(&session).Updates(map[string]any{"last_activity_at": now, "updated_at": now}).Error } } 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, ClientID: claims.ClientID}, 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.ValidateForClient(ctx, claims.UserID, claims.ClientID, 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.GenerateTokenPairForClient(&user, accountID, role, claims.ClientID) 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.RotateForClient(ctx, claims.UserID, claims.ClientID, 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 email := strings.TrimSpace(strings.ToLower(input.Email)) if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { // Don't reveal whether email exists — security best practice return nil } token, err := generateAuthToken() if err != nil { return fmt.Errorf("failed to generate reset token: %w", err) } now := time.Now().UTC() return s.db.WithContext(ctx).Model(&user).Updates(map[string]interface{}{ "reset_password_token": digestAuthToken(token), "reset_password_sent_at": now, }).Error } // ConfirmResetPasswordInput holds password reset confirmation parameters. type ConfirmResetPasswordInput struct { Token string Password string PasswordConfirmation string } // ConfirmResetPassword completes password reset by verifying token and updating password. func (s *AuthService) ConfirmResetPassword(ctx context.Context, input *ConfirmResetPasswordInput) (*LoginOutput, error) { token := strings.TrimSpace(input.Token) if token == "" { return nil, fmt.Errorf("Invalid token") } if input.Password == "" || input.Password != input.PasswordConfirmation { return nil, fmt.Errorf("invalid password confirmation") } var user model.User digest := digestAuthToken(token) if err := s.db.WithContext(ctx).Where("reset_password_token IN ?", []string{digest, token}).First(&user).Error; err != nil { return nil, fmt.Errorf("Invalid token") } if user.ResetPasswordSentAt != nil && time.Since(*user.ResetPasswordSentAt) > 6*time.Hour { return nil, fmt.Errorf("Invalid token") } passwordDigest, err := pkgcrypto.HashPassword(input.Password) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } now := time.Now().UTC() updates := map[string]interface{}{ "password": passwordDigest, "password_digest": passwordDigest, "reset_password_token": "", "reset_password_sent_at": nil, "confirmation_token": "", } if user.ConfirmedAt == nil { updates["confirmed_at"] = now user.ConfirmedAt = &now } if err := s.db.WithContext(ctx).Model(&user).Updates(updates).Error; err != nil { return nil, err } user.Password = passwordDigest user.PasswordDigest = passwordDigest user.ResetPasswordToken = "" user.ResetPasswordSentAt = nil user.ConfirmationToken = "" return s.issueLoginOutput(ctx, &user) } // --- 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) (*LoginOutput, error) { token := strings.TrimSpace(input.Token) if token == "" { return nil, fmt.Errorf("Invalid token") } var user model.User if err := s.db.WithContext(ctx).Where("confirmation_token = ?", token).First(&user).Error; err != nil { return nil, fmt.Errorf("Invalid token") } if user.ConfirmedAt != nil { return nil, fmt.Errorf("Already confirmed") } now := time.Now().UTC() if err := s.db.WithContext(ctx).Model(&user).Updates(map[string]interface{}{ "confirmed_at": now, "confirmation_token": "", }).Error; err != nil { return nil, err } user.ConfirmedAt = &now user.ConfirmationToken = "" return s.issueLoginOutput(ctx, &user) } // --- 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 } func (s *AuthService) issueLoginOutput(ctx context.Context, user *model.User) (*LoginOutput, error) { 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) } now := time.Now().UTC() user.SignInCount++ user.LastSignInAt = user.CurrentSignInAt user.CurrentSignInAt = &now if err := s.db.WithContext(ctx).Save(user).Error; err != nil { return nil, err } return &LoginOutput{User: user, TokenPair: tokenPair, AccountID: accountID, Role: role}, nil } func generateAuthToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } func digestAuthToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) }