Files
JuYou/API/internal/service/auth.go
T
2026-08-25 17:59:42 +08:00

180 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"errors"
"strings"
"time"
"juhe-factory/api/internal/model"
"juhe-factory/api/internal/security"
"github.com/google/uuid"
"gorm.io/gorm"
)
var ErrUnauthorized = errors.New("账号或密码错误")
var ErrReauthFailed = errors.New("当前管理员密码错误")
var ErrAdminPasswordConfirmation = errors.New("两次输入的新密码不一致")
var ErrAdminPasswordLength = errors.New("新密码长度必须为 820 位")
type Auth struct {
db *gorm.DB
passwords security.PasswordHasher
tokens security.TokenService
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
Admin map[string]any `json:"admin"`
}
func NewAuth(db *gorm.DB, passwords security.PasswordHasher, tokens security.TokenService) *Auth {
return &Auth{db: db, passwords: passwords, tokens: tokens}
}
func (s *Auth) Bootstrap(username, password string) error {
if strings.TrimSpace(password) == "" {
return nil
}
username = strings.TrimSpace(username)
var admin model.AdminUser
err := s.db.Where("username = ? AND deleted_at IS NULL", username).First(&admin).Error
if err == nil {
if s.passwords.Verify(admin.PasswordHash, password) {
return nil
}
hash, hashErr := s.passwords.Hash(password)
if hashErr != nil {
return hashErr
}
return s.db.Model(&admin).Update("password_hash", hash).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var count int64
if err := s.db.Model(&model.AdminUser{}).Count(&count).Error; err != nil || count > 0 {
return err
}
hash, err := s.passwords.Hash(password)
if err != nil {
return err
}
return s.db.Create(&model.AdminUser{Username: username, PasswordHash: hash, Enabled: true}).Error
}
func (s *Auth) Login(username, password string) (*TokenPair, error) {
var admin model.AdminUser
if err := s.db.Where("username = ? AND deleted_at IS NULL", strings.TrimSpace(username)).First(&admin).Error; err != nil || !admin.Enabled || !s.passwords.Verify(admin.PasswordHash, password) {
return nil, ErrUnauthorized
}
return s.issue(&admin, true)
}
func (s *Auth) Refresh(refreshToken string) (*TokenPair, error) {
hash := security.HashToken(refreshToken)
var record model.AdminRefreshToken
if err := s.db.Where("token_hash = ? AND revoked_at IS NULL AND expires_at > ?", hash, time.Now()).First(&record).Error; err != nil {
return nil, ErrUnauthorized
}
var admin model.AdminUser
if err := s.db.First(&admin, "id = ? AND enabled = true", record.AdminID).Error; err != nil {
return nil, ErrUnauthorized
}
now := time.Now()
if err := s.db.Model(&record).Update("revoked_at", now).Error; err != nil {
return nil, err
}
return s.issue(&admin, false)
}
func (s *Auth) Logout(refreshToken string) error {
if refreshToken == "" {
return nil
}
now := time.Now()
return s.db.Model(&model.AdminRefreshToken{}).Where("token_hash = ? AND revoked_at IS NULL", security.HashToken(refreshToken)).Update("revoked_at", now).Error
}
func (s *Auth) Authenticate(raw string) (*model.AdminUser, error) {
claims, err := s.tokens.ParseAccessToken(raw)
if err != nil {
return nil, ErrUnauthorized
}
id, err := uuid.Parse(claims.AdminID)
if err != nil {
return nil, ErrUnauthorized
}
var admin model.AdminUser
if err := s.db.First(&admin, "id = ? AND enabled = true", id).Error; err != nil {
return nil, ErrUnauthorized
}
return &admin, nil
}
func (s *Auth) ChangePassword(adminID uuid.UUID, currentPassword, newPassword, confirmPassword string) error {
if newPassword != confirmPassword {
return ErrAdminPasswordConfirmation
}
if len(newPassword) < 8 || len(newPassword) > 20 {
return ErrAdminPasswordLength
}
var admin model.AdminUser
if err := s.db.Where("id = ? AND enabled = true AND deleted_at IS NULL", adminID).First(&admin).Error; err != nil {
return ErrUnauthorized
}
if !s.passwords.Verify(admin.PasswordHash, currentPassword) {
return ErrReauthFailed
}
hash, err := s.passwords.Hash(newPassword)
if err != nil {
return err
}
now := time.Now()
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&admin).Update("password_hash", hash).Error; err != nil {
return err
}
return tx.Model(&model.AdminRefreshToken{}).
Where("admin_id = ? AND revoked_at IS NULL", adminID).
Update("revoked_at", now).Error
})
}
func (s *Auth) Audit(admin *model.AdminUser, action, resource, resourceID, reason, ip, traceID string, detail any) error {
return WriteAudit(s.db, admin, action, resource, resourceID, reason, ip, traceID, detail)
}
func (s *Auth) issue(admin *model.AdminUser, updateLogin bool) (*TokenPair, error) {
access, expiresAt, err := s.tokens.NewAccessToken(admin.ID.String(), admin.Username)
if err != nil {
return nil, err
}
refresh, hash, refreshExpires, err := s.tokens.NewRefreshToken()
if err != nil {
return nil, err
}
record := model.AdminRefreshToken{AdminID: admin.ID, TokenHash: hash, ExpiresAt: refreshExpires}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&record).Error; err != nil {
return err
}
if updateLogin {
return tx.Model(admin).Update("last_login_at", time.Now()).Error
}
return nil
}); err != nil {
return nil, err
}
return &TokenPair{AccessToken: access, RefreshToken: refresh, ExpiresAt: expiresAt, Admin: map[string]any{"id": admin.ID, "username": admin.Username}}, nil
}
func WriteAudit(db *gorm.DB, admin *model.AdminUser, action, resource, resourceID, reason, ip, traceID string, detail any) error {
data, _ := json.Marshal(detail)
log := model.AdminAuditLog{AdminID: &admin.ID, AdminUsername: admin.Username, Action: action, ResourceType: resource, ResourceID: resourceID, Reason: reason, Detail: string(data), IPAddress: ip, TraceID: traceID}
return db.Create(&log).Error
}