175 lines
5.2 KiB
Go
175 lines
5.2 KiB
Go
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 URL:issuer/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()
|
||
}
|