Files
nl-pms-api/internal/service/admin_security.go
2026-08-15 17:04:47 +08:00

175 lines
5.2 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 (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/url"
"strings"
"time"
"github.com/pquerna/otp/totp"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
const (
adminStepupTTL = 2 * time.Hour
totpIssuer = "年糕崽崽PMS"
totpPendingPrefix = "pending:"
)
// AdminSecurityService 管理员 TOTP 与敏感操作 stepup。
type AdminSecurityService struct {
DB *gorm.DB
}
type TOTPStatus struct {
Enabled bool `json:"enabled"`
Pending bool `json:"pending"`
OTPAuth string `json:"otpauth,omitempty"`
Secret string `json:"secret,omitempty"`
}
// Status 返回 TOTP 绑定状态;若未启用且无 pending不自动生成。
func (s *AdminSecurityService) Status(userID int64) (*TOTPStatus, error) {
if userID != commonservice.AdminUserID {
return nil, commonservice.Forbidden("FORBIDDEN")
}
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
st := &TOTPStatus{Enabled: u.TOTPEnabled != 0}
if !st.Enabled && strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
secret := strings.TrimPrefix(u.TOTPSecret, totpPendingPrefix)
st.Pending = true
st.Secret = secret
st.OTPAuth = buildOTPAuth(u.Username, secret)
}
return st, nil
}
// SetupBegin 生成待确认的 TOTP 密钥(覆盖未确认的 pending
func (s *AdminSecurityService) SetupBegin(userID int64) (*TOTPStatus, error) {
if userID != commonservice.AdminUserID {
return nil, commonservice.Forbidden("FORBIDDEN")
}
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled != 0 {
return nil, commonservice.Conflict("TOTP_ALREADY_ENABLED")
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: totpIssuer,
AccountName: u.Username,
})
if err != nil {
return nil, commonservice.Internal("INTERNAL")
}
secret := key.Secret()
if err := s.DB.Model(&u).Update("totp_secret", totpPendingPrefix+secret).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
return &TOTPStatus{
Enabled: false,
Pending: true,
Secret: secret,
OTPAuth: key.URL(),
}, nil
}
// SetupConfirm 用动态码确认绑定。
func (s *AdminSecurityService) SetupConfirm(userID int64, code string) error {
if userID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
code = strings.TrimSpace(code)
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled != 0 {
return commonservice.Conflict("TOTP_ALREADY_ENABLED")
}
if !strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
return commonservice.BadRequest("TOTP_SETUP_REQUIRED")
}
secret := strings.TrimPrefix(u.TOTPSecret, totpPendingPrefix)
if !totp.Validate(code, secret) {
return commonservice.BadRequest("TOTP_INVALID")
}
return s.DB.Model(&u).Updates(map[string]any{
"totp_secret": secret,
"totp_enabled": 1,
}).Error
}
// StepUp 校验动态码并签发 2h stepup token绑定 IP
func (s *AdminSecurityService) StepUp(userID int64, code, clientIP string) (string, time.Time, error) {
if userID != commonservice.AdminUserID {
return "", time.Time{}, commonservice.Forbidden("FORBIDDEN")
}
code = strings.TrimSpace(code)
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return "", time.Time{}, commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled == 0 || u.TOTPSecret == "" || strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
return "", time.Time{}, commonservice.BadRequest("TOTP_NOT_ENABLED")
}
if !totp.Validate(code, u.TOTPSecret) {
return "", time.Time{}, commonservice.BadRequest("TOTP_INVALID")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, commonservice.Internal("INTERNAL")
}
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
expires := time.Now().UTC().Add(adminStepupTTL)
row := model.AdminStepup{
UserID: userID,
TokenHash: hex.EncodeToString(sum[:]),
ClientIP: strings.TrimSpace(clientIP),
ExpiresAt: expires.Format(time.RFC3339),
}
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"token_hash", "client_ip", "expires_at"}),
}).Create(&row).Error; err != nil {
return "", time.Time{}, commonservice.Internal("SAVE_FAILED")
}
// 更新管理员最近登录 IP便于审计
_ = s.DB.Model(&u).Updates(map[string]any{
"last_login_ip": row.ClientIP,
"last_seen_at": commonservice.NowRFC(),
}).Error
return token, expires, nil
}
// ClearStepUp 作废二次验证。
func (s *AdminSecurityService) ClearStepUp(userID int64) error {
if userID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
return s.DB.Delete(&model.AdminStepup{}, userID).Error
}
func buildOTPAuth(account, secret string) string {
// otpauth URLissuer/account 需编码
label := url.PathEscape(totpIssuer) + ":" + url.PathEscape(account)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", totpIssuer)
q.Set("algorithm", "SHA1")
q.Set("digits", "6")
q.Set("period", "30")
return "otpauth://totp/" + label + "?" + q.Encode()
}