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()
|
|||
|
|
}
|