package mysql import ( "context" "errors" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "wucher/internal/domain/auth" ) type AuthRepository struct { db *gorm.DB } func NewAuthRepository(db *gorm.DB) *AuthRepository { return &AuthRepository{db: db} } // Users func (r *AuthRepository) CreateUser(ctx context.Context, user *auth.User) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if actor := actorUserIDFromContext(ctx); len(actor) > 0 { user.CreatedBy = actor user.UpdatedBy = actor } if err := tx.Create(user).Error; err != nil { return err } return replaceUserRolesTx(ctx, tx, user.ID, user.RoleID, user.RoleIDs) }) } func (r *AuthRepository) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) { var user auth.User err := r.db.WithContext(ctx). Preload("ProfileAttachment"). Preload("ProfileAttachment.File"). First(&user, "id = ?", id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } if err := attachAuthUserRoles(ctx, r.db, &user); err != nil { return nil, err } return &user, nil } func (r *AuthRepository) GetUserByEmail(ctx context.Context, email string) (*auth.User, error) { var user auth.User err := r.db.WithContext(ctx). Preload("ProfileAttachment"). Preload("ProfileAttachment.File"). Where("email = ?", email). First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } if err := attachAuthUserRoles(ctx, r.db, &user); err != nil { return nil, err } return &user, nil } func (r *AuthRepository) GetUserBySSOEmail(ctx context.Context, ssoEmail string) (*auth.User, error) { var user auth.User err := r.db.WithContext(ctx). Preload("ProfileAttachment"). Preload("ProfileAttachment.File"). Where("sso_email = ?", ssoEmail). First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } if err := attachAuthUserRoles(ctx, r.db, &user); err != nil { return nil, err } return &user, nil } func (r *AuthRepository) GetUserByUsername(ctx context.Context, username string) (*auth.User, error) { var user auth.User err := r.db.WithContext(ctx). Preload("ProfileAttachment"). Preload("ProfileAttachment.File"). Where("username = ?", username). First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } if err := attachAuthUserRoles(ctx, r.db, &user); err != nil { return nil, err } return &user, nil } func (r *AuthRepository) UpdateUserTimezone(ctx context.Context, id []byte, timezone string) error { return r.db.WithContext(ctx). Model(&auth.User{}). Where("id = ?", id). Updates(map[string]any{ "timezone": timezone, "updated_at": time.Now().UTC(), }).Error } func (r *AuthRepository) SetUserPassword(ctx context.Context, id []byte, passwordHash, salt []byte) error { return r.db.WithContext(ctx). Model(&auth.User{}). Where("id = ?", id). Updates(map[string]any{ "password_hash": passwordHash, "salt_value": salt, "updated_at": time.Now().UTC(), }).Error } func (r *AuthRepository) SetUserSecurityPIN(ctx context.Context, id []byte, pinHash []byte, setAt time.Time) error { return r.db.WithContext(ctx). Model(&auth.User{}). Where("id = ?", id). Updates(map[string]any{ "security_pin_hash": pinHash, "security_pin_set_at": setAt, "updated_at": setAt, }).Error } func (r *AuthRepository) MarkEmailVerified(ctx context.Context, id []byte) error { now := time.Now().UTC() return r.db.WithContext(ctx). Model(&auth.User{}). Where("id = ?", id). Update("email_verified_at", &now).Error } func (r *AuthRepository) CreatePasswordResetToken(ctx context.Context, token *auth.PasswordResetToken) error { return r.db.WithContext(ctx).Create(token).Error } func (r *AuthRepository) InvalidateActivePasswordResetTokensByUserID(ctx context.Context, userID []byte, usedAt time.Time) error { return r.db.WithContext(ctx). Model(&auth.PasswordResetToken{}). Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, usedAt). Updates(map[string]any{ "used_at": usedAt, "updated_at": usedAt, }).Error } func (r *AuthRepository) ConsumePasswordResetTokenAndSetPassword( ctx context.Context, tokenHash, passwordHash, salt []byte, usedAt time.Time, ) ([]byte, bool, error) { var token auth.PasswordResetToken consumed := false err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := tx. Clauses(clause.Locking{Strength: "UPDATE"}). Where("token_hash = ? AND used_at IS NULL AND expires_at > ?", tokenHash, usedAt). First(&token).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } consumed = true res := tx.Model(&auth.User{}). Where("id = ?", token.UserID). Updates(map[string]any{ "password_hash": passwordHash, "salt_value": salt, "updated_at": usedAt, }) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } if err := tx.Model(&auth.PasswordResetToken{}). Where("id = ?", token.ID). Updates(map[string]any{ "used_at": usedAt, "updated_at": usedAt, }).Error; err != nil { return err } return tx.Model(&auth.PasswordResetToken{}). Where("user_id = ? AND id <> ? AND used_at IS NULL AND expires_at > ?", token.UserID, token.ID, usedAt). Updates(map[string]any{ "used_at": usedAt, "updated_at": usedAt, }).Error }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, nil } return nil, false, err } if !consumed { return nil, false, nil } return token.UserID, true, nil } func (r *AuthRepository) CreateSecurityPINResetToken(ctx context.Context, token *auth.SecurityPINResetToken) error { return r.db.WithContext(ctx).Create(token).Error } func (r *AuthRepository) InvalidateActiveSecurityPINResetTokensByUserID(ctx context.Context, userID []byte, usedAt time.Time) error { return r.db.WithContext(ctx). Model(&auth.SecurityPINResetToken{}). Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, usedAt). Updates(map[string]any{ "used_at": usedAt, "updated_at": usedAt, }).Error } func (r *AuthRepository) GetActiveSecurityPINResetTokenUser(ctx context.Context, tokenHash []byte, now time.Time) (*auth.User, error) { var user auth.User err := r.db.WithContext(ctx). Table("security_pin_reset_tokens AS token"). Select("users.*"). Joins("JOIN users ON users.id = token.user_id"). Where("token.token_hash = ? AND token.used_at IS NULL AND token.expires_at > ?", tokenHash, now). First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &user, err } func (r *AuthRepository) ConsumeSecurityPINResetTokenAndSetPIN( ctx context.Context, tokenHash, pinHash []byte, usedAt time.Time, ) ([]byte, bool, error) { var token auth.SecurityPINResetToken consumed := false err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := tx. Clauses(clause.Locking{Strength: "UPDATE"}). Where("token_hash = ? AND used_at IS NULL AND expires_at > ?", tokenHash, usedAt). First(&token).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } consumed = true res := tx.Model(&auth.User{}). Where("id = ?", token.UserID). Updates(map[string]any{ "security_pin_hash": pinHash, "security_pin_set_at": usedAt, "updated_at": usedAt, }) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } if err := tx.Model(&auth.SecurityPINResetToken{}). Where("id = ?", token.ID). Updates(map[string]any{ "used_at": usedAt, "updated_at": usedAt, }).Error; err != nil { return err } return tx.Model(&auth.SecurityPINResetToken{}). Where("user_id = ? AND id <> ? AND used_at IS NULL AND expires_at > ?", token.UserID, token.ID, usedAt). Updates(map[string]any{ "used_at": usedAt, "updated_at": usedAt, }).Error }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, nil } return nil, false, err } if !consumed { return nil, false, nil } return token.UserID, true, nil } // Identities (SSO) func (r *AuthRepository) UpsertIdentity(ctx context.Context, identity *auth.UserIdentity) error { // Ensure we keep a single row per provider+subject return r.db.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "provider"}, {Name: "provider_subject"}}, DoUpdates: clause.AssignmentColumns([]string{"user_id", "email", "metadata", "updated_at", "updated_by"}), }). Create(identity).Error } func (r *AuthRepository) CreateIdentity(ctx context.Context, identity *auth.UserIdentity) error { return r.db.WithContext(ctx).Create(identity).Error } func (r *AuthRepository) GetIdentityByProviderSubject(ctx context.Context, provider, subject string) (*auth.UserIdentity, error) { var identity auth.UserIdentity tx := r.db.WithContext(ctx). Where("provider = ? AND provider_subject = ?", provider, subject). Limit(1). Find(&identity) if tx.Error != nil { return nil, tx.Error } if tx.RowsAffected == 0 { return nil, nil } return &identity, nil } func (r *AuthRepository) GetIdentityByUserID(ctx context.Context, userID []byte) (*auth.UserIdentity, error) { var identity auth.UserIdentity tx := r.db.WithContext(ctx). Where("user_id = ?", userID). Limit(1). Find(&identity) if tx.Error != nil { return nil, tx.Error } if tx.RowsAffected == 0 { return nil, nil } return &identity, nil } func (r *AuthRepository) DeleteIdentityByUserIDProvider(ctx context.Context, userID []byte, provider string) (bool, error) { tx := r.db.WithContext(ctx). Where("user_id = ? AND provider = ?", userID, provider). Delete(&auth.UserIdentity{}) if tx.Error != nil { return false, tx.Error } return tx.RowsAffected > 0, nil } func (r *AuthRepository) UpsertMicrosoftOAuthToken(ctx context.Context, token *auth.UserMicrosoftOAuthToken) error { return r.db.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}, {Name: "provider"}}, DoUpdates: clause.AssignmentColumns([]string{ "tenant_id", "microsoft_object_id", "microsoft_subject", "refresh_token_encrypted", "access_token_encrypted", "id_token_encrypted", "scope", "token_type", "access_token_expires_at", "refresh_token_expires_at", "refresh_token_rotated_at", "updated_at", "updated_by", }), }). Create(token).Error } func (r *AuthRepository) GetMicrosoftOAuthTokenByUserIDProvider(ctx context.Context, userID []byte, provider string) (*auth.UserMicrosoftOAuthToken, error) { var token auth.UserMicrosoftOAuthToken tx := r.db.WithContext(ctx). Where("user_id = ? AND provider = ?", userID, provider). Limit(1). Find(&token) if tx.Error != nil { return nil, tx.Error } if tx.RowsAffected == 0 { return nil, nil } return &token, nil } func (r *AuthRepository) DeleteMicrosoftOAuthTokenByUserIDProvider(ctx context.Context, userID []byte, provider string) (bool, error) { tx := r.db.WithContext(ctx). Where("user_id = ? AND provider = ?", userID, provider). Delete(&auth.UserMicrosoftOAuthToken{}) if tx.Error != nil { return false, tx.Error } return tx.RowsAffected > 0, nil } // TOTP func (r *AuthRepository) GetUserTOTP(ctx context.Context, userID []byte) (*auth.UserTOTP, error) { var totp auth.UserTOTP tx := r.db.WithContext(ctx).Where("user_id = ?", userID).Limit(1).Find(&totp) if tx.Error != nil { return nil, tx.Error } if tx.RowsAffected == 0 { return nil, nil } return &totp, nil } func (r *AuthRepository) UpsertUserTOTP(ctx context.Context, totp *auth.UserTOTP) error { return r.db.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"secret_encrypted", "enabled", "updated_at", "updated_by"}), }). Create(totp).Error } func (r *AuthRepository) DisableUserTOTP(ctx context.Context, userID []byte) error { return r.db.WithContext(ctx). Model(&auth.UserTOTP{}). Where("user_id = ?", userID). Updates(map[string]any{ "enabled": false, "updated_at": time.Now().UTC(), }).Error } func (r *AuthRepository) DeleteUserTOTPSetup(ctx context.Context, userID []byte) error { return r.db.WithContext(ctx). Where("user_id = ?", userID). Delete(&auth.UserTOTP{}).Error } func (r *AuthRepository) UpdateUserTOTPLastUsed(ctx context.Context, userID []byte) error { return r.db.WithContext(ctx). Model(&auth.UserTOTP{}). Where("user_id = ? AND enabled = 1", userID). Update("last_used_at", time.Now().UTC()).Error } // Email login OTP func (r *AuthRepository) GetActiveUserEmailLoginOTP(ctx context.Context, userID []byte, now time.Time) (*auth.UserEmailLoginOTP, error) { var row auth.UserEmailLoginOTP tx := r.db.WithContext(ctx). Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, now.UTC()). Order("created_at DESC"). Limit(1). Find(&row) if tx.Error != nil { return nil, tx.Error } if tx.RowsAffected == 0 { return nil, nil } return &row, nil } func (r *AuthRepository) CountUserEmailLoginOTPSince(ctx context.Context, userID []byte, since time.Time) (int64, error) { var count int64 err := r.db.WithContext(ctx). Model(&auth.UserEmailLoginOTP{}). Where("user_id = ? AND created_at >= ?", userID, since.UTC()). Count(&count).Error return count, err } func (r *AuthRepository) CreateUserEmailLoginOTP(ctx context.Context, otp *auth.UserEmailLoginOTP) error { return r.db.WithContext(ctx).Create(otp).Error } func (r *AuthRepository) InvalidateActiveUserEmailLoginOTPs(ctx context.Context, userID []byte, usedAt time.Time) error { return r.db.WithContext(ctx). Model(&auth.UserEmailLoginOTP{}). Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, usedAt.UTC()). Updates(map[string]any{ "used_at": usedAt.UTC(), "updated_at": usedAt.UTC(), }).Error } func (r *AuthRepository) ConsumeUserEmailLoginOTP(ctx context.Context, userID, otpHash []byte, now time.Time) (bool, int, error) { success := false attemptsLeft := 0 err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var row auth.UserEmailLoginOTP err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, now.UTC()). Order("created_at DESC"). Limit(1). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } if string(row.OTPHash) == string(otpHash) { success = true attemptsLeft = max(0, row.MaxAttempts-row.AttemptCount) if err := tx.Model(&auth.UserEmailLoginOTP{}). Where("id = ?", row.ID). Updates(map[string]any{ "used_at": now.UTC(), "updated_at": now.UTC(), }).Error; err != nil { return err } return tx.Model(&auth.UserEmailLoginOTP{}). Where("user_id = ? AND id <> ? AND used_at IS NULL AND expires_at > ?", userID, row.ID, now.UTC()). Updates(map[string]any{ "used_at": now.UTC(), "updated_at": now.UTC(), }).Error } nextAttempts := row.AttemptCount + 1 attemptsLeft = max(0, row.MaxAttempts-nextAttempts) updates := map[string]any{ "attempt_count": nextAttempts, "updated_at": now.UTC(), } if nextAttempts >= row.MaxAttempts { updates["used_at"] = now.UTC() } return tx.Model(&auth.UserEmailLoginOTP{}). Where("id = ?", row.ID). Updates(updates).Error }) return success, attemptsLeft, err } // WebAuthn func (r *AuthRepository) ListUserWebAuthnCredentials(ctx context.Context, userID []byte) ([]auth.UserWebAuthnCredential, error) { var credentials []auth.UserWebAuthnCredential if err := r.db.WithContext(ctx). Where("user_id = ?", userID). Order("created_at ASC"). Find(&credentials).Error; err != nil { return nil, err } return credentials, nil } func (r *AuthRepository) CreateUserWebAuthnCredential(ctx context.Context, credential *auth.UserWebAuthnCredential) error { return r.db.WithContext(ctx).Create(credential).Error } func (r *AuthRepository) UpdateUserWebAuthnCredential(ctx context.Context, userID, credentialID, credentialJSON []byte, usedAt time.Time) error { return r.db.WithContext(ctx). Model(&auth.UserWebAuthnCredential{}). Where("user_id = ? AND credential_id = ?", userID, credentialID). Updates(map[string]any{ "credential_json": credentialJSON, "last_used_at": usedAt, "updated_at": usedAt, }).Error } func (r *AuthRepository) DeleteAllUserWebAuthnCredentials(ctx context.Context, userID []byte) error { return r.db.WithContext(ctx). Where("user_id = ?", userID). Delete(&auth.UserWebAuthnCredential{}).Error }