init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,595 @@
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
}