Files
nl-pms-api/internal/service/admin_security.go

175 lines
5.2 KiB
Go
Raw Permalink Normal View History

2026-08-15 17:04:47 +08:00
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()
}