Files
gochat/backend/internal/service/auth_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

648 lines
19 KiB
Go

package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"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
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 !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")
}
// 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 := pkgcrypto.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
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)
}
// --- 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
}
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[:])
}
// 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
}