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.
648 lines
19 KiB
Go
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
|
|
}
|