582 lines
17 KiB
Go
582 lines
17 KiB
Go
package common
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"github.com/qianfree/team-api/internal/consts"
|
|
"github.com/qianfree/team-api/internal/dao"
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
"github.com/qianfree/team-api/internal/model/entity"
|
|
"github.com/qianfree/team-api/internal/utility/crypto"
|
|
"github.com/qianfree/team-api/internal/utility/totp"
|
|
)
|
|
|
|
const provisionalTTL = 5 * time.Minute
|
|
const confirmTokenTTL = 5 * time.Minute
|
|
|
|
// totpReplayTTL 防重放键的存活时间,需覆盖 TOTP 的接受窗口(±1 周期,约 90 秒)。
|
|
// 键过期时验证码本身也已失效,故 90 秒足以杜绝同码重复使用。
|
|
const totpReplayTTL = 90
|
|
|
|
// validateTOTPOnce 在基础 TOTP 校验通过后,借助 Redis 做一次性防重放:
|
|
// 同一用户的同一验证码在其有效窗口内只接受一次,杜绝 ±90 秒内的重复使用。
|
|
// Redis 不可用时放行(fail-open),避免依赖故障导致无法登录/验证。
|
|
func validateTOTPOnce(ctx context.Context, userType string, userID int64, code, secret string) bool {
|
|
if !totp.ValidateCode(code, secret) {
|
|
return false
|
|
}
|
|
key := fmt.Sprintf("totp:used:%s:%d:%s", userType, userID, code)
|
|
res, err := g.Redis().Do(ctx, "SET", key, "1", "NX", "EX", totpReplayTTL)
|
|
if err != nil {
|
|
g.Log().Warningf(ctx, "totp replay guard redis error: %v", err)
|
|
return true
|
|
}
|
|
// res 为 nil 表示键已存在 → 该验证码此前已被使用 → 判定为重放,拒绝
|
|
return !res.IsNil()
|
|
}
|
|
|
|
// ProvisionalClaims is a short-lived JWT for 2FA pending state.
|
|
type ProvisionalClaims struct {
|
|
UserID int64 `json:"user_id"`
|
|
UserType string `json:"user_type"`
|
|
Role string `json:"role"`
|
|
TenantID int64 `json:"tenant_id,omitempty"`
|
|
Purpose string `json:"purpose"` // "totp_verify"
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// ConfirmClaims is a short-lived JWT for high-risk operation confirmation.
|
|
type ConfirmClaims struct {
|
|
UserID int64 `json:"user_id"`
|
|
UserType string `json:"user_type"`
|
|
Purpose string `json:"purpose"` // "high_risk_confirm"
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// GenerateProvisionalToken generates a short-lived token for 2FA verification step.
|
|
func GenerateProvisionalToken(ctx context.Context, userID int64, userType, role string, tenantID int64) (string, error) {
|
|
now := time.Now()
|
|
claims := ProvisionalClaims{
|
|
UserID: userID,
|
|
UserType: userType,
|
|
Role: role,
|
|
TenantID: tenantID,
|
|
Purpose: "totp_verify",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: "team-api",
|
|
Subject: fmt.Sprintf("%d", userID),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(provisionalTTL)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(GetJWTSecret())
|
|
}
|
|
|
|
// ParseProvisionalToken parses and validates a provisional 2FA token.
|
|
func ParseProvisionalToken(tokenStr string) (*ProvisionalClaims, error) {
|
|
token, err := jwt.ParseWithClaims(tokenStr, &ProvisionalClaims{}, func(token *jwt.Token) (any, error) {
|
|
// 校验签名算法,防止 "alg":"none" 或非对称算法伪造令牌
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, gerror.Newf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return GetJWTSecret(), nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
claims, ok := token.Claims.(*ProvisionalClaims)
|
|
if !ok || !token.Valid || claims.Purpose != "totp_verify" {
|
|
return nil, fmt.Errorf("invalid provisional token")
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// GenerateConfirmToken generates a short-lived token for high-risk operation confirmation.
|
|
func GenerateConfirmToken(ctx context.Context, userID int64, userType string) (string, error) {
|
|
now := time.Now()
|
|
claims := ConfirmClaims{
|
|
UserID: userID,
|
|
UserType: userType,
|
|
Purpose: "high_risk_confirm",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: "team-api",
|
|
Subject: fmt.Sprintf("%d", userID),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(confirmTokenTTL)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(GetJWTSecret())
|
|
}
|
|
|
|
// ParseConfirmToken parses and validates a high-risk confirm token.
|
|
func ParseConfirmToken(tokenStr string) (*ConfirmClaims, error) {
|
|
token, err := jwt.ParseWithClaims(tokenStr, &ConfirmClaims{}, func(token *jwt.Token) (any, error) {
|
|
// 校验签名算法,防止 "alg":"none" 或非对称算法伪造令牌
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, gerror.Newf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return GetJWTSecret(), nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
claims, ok := token.Claims.(*ConfirmClaims)
|
|
if !ok || !token.Valid || claims.Purpose != "high_risk_confirm" {
|
|
return nil, fmt.Errorf("invalid confirm token")
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// ConfirmHighRisk 校验高风险操作的二次验证码并签发短期确认令牌。
|
|
func ConfirmHighRisk(ctx context.Context, userType string, userID int64, code string) (string, error) {
|
|
enabled, err := Is2FAEnabled(ctx, userType, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !enabled {
|
|
return "", NewBusinessError(consts.CodeTotpNotEnabled, consts.MsgTotpNotEnabled)
|
|
}
|
|
|
|
valid, err := Verify2FACode(ctx, userType, userID, code)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !valid {
|
|
return "", NewBusinessError(consts.CodeTotpInvalid, consts.MsgTotpInvalid)
|
|
}
|
|
|
|
return GenerateConfirmToken(ctx, userID, userType)
|
|
}
|
|
|
|
// Setup2FA generates a new TOTP secret for a user. Returns the secret and otpauth URI.
|
|
// Callers must still persist the secret after the user confirms with a valid code.
|
|
func Setup2FA(ctx context.Context, userType string, userID int64) (secret, uri string, err error) {
|
|
var accountName string
|
|
if userType == "admin" {
|
|
var user *entity.SysAdminUsers
|
|
err = dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if user == nil {
|
|
return "", "", NewBusinessError(10024, "用户不存在")
|
|
}
|
|
accountName = user.Username
|
|
} else {
|
|
var user *entity.TntUsers
|
|
err = dao.TntUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if user == nil {
|
|
return "", "", NewBusinessError(10024, "用户不存在")
|
|
}
|
|
accountName = fmt.Sprintf("%s@%d", user.Username, user.TenantId)
|
|
}
|
|
|
|
secret, uri, err = totp.GenerateSecret(accountName)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
return secret, uri, nil
|
|
}
|
|
|
|
// Enable2FA enables 2FA for a user after verifying the TOTP code and password.
|
|
// Stores the encrypted secret and hashed backup codes.
|
|
func Enable2FA(ctx context.Context, userType string, userID int64, secret, code, password string) ([]string, error) {
|
|
// Verify TOTP code
|
|
if !validateTOTPOnce(ctx, userType, userID, code, secret) {
|
|
return nil, NewBusinessError(10048, "验证码错误")
|
|
}
|
|
|
|
// Verify current password
|
|
var passwordHash string
|
|
if userType == "admin" {
|
|
var user *entity.SysAdminUsers
|
|
err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, NewBusinessError(10024, "用户不存在")
|
|
}
|
|
passwordHash = user.PasswordHash
|
|
} else {
|
|
var user *entity.TntUsers
|
|
err := dao.TntUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, NewBusinessError(10024, "用户不存在")
|
|
}
|
|
passwordHash = user.PasswordHash
|
|
}
|
|
|
|
if !crypto.VerifyPassword(password, passwordHash) {
|
|
return nil, NewBusinessError(10023, "密码错误")
|
|
}
|
|
|
|
// Generate backup codes
|
|
plainCodes, err := totp.GenerateBackupCodes(8)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 恢复码是高熵、一次性随机值,使用 SHA-256 可避免每次失败尝试触发多轮 bcrypt。
|
|
hashedCodes := make([]string, len(plainCodes))
|
|
for i, code := range plainCodes {
|
|
hashedCodes[i] = hashBackupCode(code)
|
|
}
|
|
codesJSON, jerr := json.Marshal(hashedCodes)
|
|
if jerr != nil {
|
|
return nil, gerror.Wrapf(jerr, "marshal backup codes failed")
|
|
}
|
|
|
|
// Encrypt TOTP secret
|
|
encKey := getEncryptionKey(ctx)
|
|
encryptedSecret, err := crypto.EncryptString(encKey, secret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Persist
|
|
if userType == "admin" {
|
|
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Data(do.SysAdminUsers{
|
|
TotpSecret: encryptedSecret,
|
|
TotpEnabled: true,
|
|
BackupCodes: string(codesJSON),
|
|
}).Update()
|
|
} else {
|
|
_, err = dao.TntUsers.Ctx(ctx).Where("id", userID).Data(do.TntUsers{
|
|
TotpSecret: encryptedSecret,
|
|
TotpEnabled: true,
|
|
BackupCodes: string(codesJSON),
|
|
}).Update()
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return plainCodes, nil
|
|
}
|
|
|
|
// Disable2FA disables 2FA for a user after verifying the provided code.
|
|
func Disable2FA(ctx context.Context, userType string, userID int64, code string) error {
|
|
secret, _, err := GetUserTOTPSecret(ctx, userType, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Verify TOTP code or backup code
|
|
if !validateTOTPOnce(ctx, userType, userID, code, secret) {
|
|
matched, err := verifyAndConsumeBackupCode(ctx, userType, userID, code)
|
|
if err != nil || !matched {
|
|
return NewBusinessError(10048, "验证码或恢复码错误")
|
|
}
|
|
}
|
|
|
|
if userType == "admin" {
|
|
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Data(do.SysAdminUsers{
|
|
TotpSecret: "",
|
|
TotpEnabled: false,
|
|
BackupCodes: "",
|
|
}).Update()
|
|
} else {
|
|
_, err = dao.TntUsers.Ctx(ctx).Where("id", userID).Data(do.TntUsers{
|
|
TotpSecret: "",
|
|
TotpEnabled: false,
|
|
BackupCodes: "",
|
|
}).Update()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Verify2FACode verifies a TOTP code or backup code for a user.
|
|
// Returns true if valid. If a backup code was used, it's consumed (removed).
|
|
func Verify2FACode(ctx context.Context, userType string, userID int64, code string) (bool, error) {
|
|
secret, backupCodes, err := GetUserTOTPSecret(ctx, userType, userID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Try TOTP code first
|
|
if validateTOTPOnce(ctx, userType, userID, code, secret) {
|
|
return true, nil
|
|
}
|
|
|
|
// Try backup codes
|
|
if backupCodes != nil {
|
|
for i, hashedCode := range backupCodes {
|
|
if verifyBackupCode(code, hashedCode) {
|
|
// Consume this backup code
|
|
_ = consumeBackupCode(ctx, userType, userID, i, backupCodes)
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// GetUserTOTPSecret decrypts and returns the TOTP secret and backup codes for a user.
|
|
func GetUserTOTPSecret(ctx context.Context, userType string, userID int64) (secret string, backupCodes []string, err error) {
|
|
encKey := getEncryptionKey(ctx)
|
|
|
|
if userType == "admin" {
|
|
var user *entity.SysAdminUsers
|
|
err = dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if user == nil {
|
|
return "", nil, NewBusinessError(10024, "用户不存在")
|
|
}
|
|
if user.TotpSecret != "" {
|
|
secret, err = crypto.DecryptString(encKey, user.TotpSecret)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
}
|
|
if user.BackupCodes != "" {
|
|
_ = json.Unmarshal([]byte(user.BackupCodes), &backupCodes)
|
|
}
|
|
} else {
|
|
var user *entity.TntUsers
|
|
err = dao.TntUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if user == nil {
|
|
return "", nil, NewBusinessError(10024, "用户不存在")
|
|
}
|
|
if user.TotpSecret != "" {
|
|
secret, err = crypto.DecryptString(encKey, user.TotpSecret)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
}
|
|
if user.BackupCodes != "" {
|
|
_ = json.Unmarshal([]byte(user.BackupCodes), &backupCodes)
|
|
}
|
|
}
|
|
return secret, backupCodes, nil
|
|
}
|
|
|
|
// RegenerateBackupCodes generates new backup codes for a user after verifying TOTP.
|
|
func RegenerateBackupCodes(ctx context.Context, userType string, userID int64, code string) ([]string, error) {
|
|
secret, _, err := GetUserTOTPSecret(ctx, userType, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !validateTOTPOnce(ctx, userType, userID, code, secret) {
|
|
return nil, NewBusinessError(10048, "验证码错误")
|
|
}
|
|
|
|
plainCodes, err := totp.GenerateBackupCodes(8)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hashedCodes := make([]string, len(plainCodes))
|
|
for i, c := range plainCodes {
|
|
hashedCodes[i] = hashBackupCode(c)
|
|
}
|
|
codesJSON, jerr := json.Marshal(hashedCodes)
|
|
if jerr != nil {
|
|
return nil, gerror.Wrapf(jerr, "marshal backup codes failed")
|
|
}
|
|
|
|
if userType == "admin" {
|
|
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Data(do.SysAdminUsers{
|
|
BackupCodes: string(codesJSON),
|
|
}).Update()
|
|
} else {
|
|
_, err = dao.TntUsers.Ctx(ctx).Where("id", userID).Data(do.TntUsers{
|
|
BackupCodes: string(codesJSON),
|
|
}).Update()
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return plainCodes, nil
|
|
}
|
|
|
|
// Is2FAEnabled checks if 2FA is enabled for a user.
|
|
func Is2FAEnabled(ctx context.Context, userType string, userID int64) (bool, error) {
|
|
if userType == "admin" {
|
|
var user *entity.SysAdminUsers
|
|
err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if user == nil {
|
|
return false, nil
|
|
}
|
|
return user.TotpEnabled, nil
|
|
}
|
|
var user *entity.TntUsers
|
|
err := dao.TntUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if user == nil {
|
|
return false, nil
|
|
}
|
|
return user.TotpEnabled, nil
|
|
}
|
|
|
|
// RecordLoginHistory records a login attempt in aud_login_history.
|
|
func RecordLoginHistory(ctx context.Context, userType string, userID, tenantID int64, method, ip, ua, deviceFP string, success bool, failReason string) error {
|
|
r := g.RequestFromCtx(ctx)
|
|
isNewDevice := false
|
|
|
|
// Check if this device has been seen before
|
|
if deviceFP != "" && success {
|
|
count, err := AuditModelCtx(ctx, "aud_login_history").
|
|
Where("user_type", userType).
|
|
Where("user_id", userID).
|
|
Where("device_fingerprint", deviceFP).
|
|
Where("success", true).
|
|
Count()
|
|
if err == nil && count == 0 {
|
|
isNewDevice = true
|
|
}
|
|
}
|
|
|
|
if r != nil {
|
|
_, err := AuditModelCtx(ctx, "aud_login_history").Data(do.AudLoginHistory{
|
|
UserType: userType,
|
|
UserId: userID,
|
|
TenantId: tenantID,
|
|
LoginMethod: method,
|
|
IpAddress: ip,
|
|
UserAgent: ua,
|
|
DeviceFingerprint: deviceFP,
|
|
IsNewDevice: isNewDevice,
|
|
Success: success,
|
|
FailReason: failReason,
|
|
}).Insert()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeviceFingerprint generates a simple device fingerprint from User-Agent + IP.
|
|
func DeviceFingerprint(ua, ip string) string {
|
|
// Simple fingerprint: hash of normalized UA + IP prefix
|
|
normalized := strings.ToLower(ua)
|
|
if len(normalized) > 200 {
|
|
normalized = normalized[:200]
|
|
}
|
|
data := normalized + "|" + ip
|
|
hash := sha256Sum(data)
|
|
return hash[:32]
|
|
}
|
|
|
|
// ExtractDeviceInfo 从请求上下文提取会话设备信息并编码为合法 JSON。
|
|
func ExtractDeviceInfo(ctx context.Context) string {
|
|
r := g.RequestFromCtx(ctx)
|
|
if r == nil {
|
|
return buildDeviceInfo("")
|
|
}
|
|
return buildDeviceInfo(r.Header.Get("User-Agent"))
|
|
}
|
|
|
|
func buildDeviceInfo(userAgent string) string {
|
|
if len(userAgent) > 500 {
|
|
userAgent = userAgent[:500]
|
|
}
|
|
if userAgent == "" {
|
|
userAgent = "unknown"
|
|
}
|
|
data, _ := json.Marshal(map[string]string{"user_agent": userAgent})
|
|
return string(data)
|
|
}
|
|
|
|
// verifyAndConsumeBackupCode checks if code matches any backup code and marks it consumed.
|
|
func verifyAndConsumeBackupCode(ctx context.Context, userType string, userID int64, code string) (bool, error) {
|
|
_, backupCodes, err := GetUserTOTPSecret(ctx, userType, userID)
|
|
if err != nil || backupCodes == nil {
|
|
return false, err
|
|
}
|
|
|
|
for i, hashedCode := range backupCodes {
|
|
if verifyBackupCode(code, hashedCode) {
|
|
_ = consumeBackupCode(ctx, userType, userID, i, backupCodes)
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
const backupCodeSHA256Prefix = "sha256:"
|
|
|
|
func hashBackupCode(code string) string {
|
|
return backupCodeSHA256Prefix + sha256Sum(code)
|
|
}
|
|
|
|
func verifyBackupCode(code, storedHash string) bool {
|
|
if strings.HasPrefix(storedHash, backupCodeSHA256Prefix) {
|
|
expected := hashBackupCode(code)
|
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(storedHash)) == 1
|
|
}
|
|
// 兼容升级前已签发的 bcrypt 恢复码;用户重新生成后会自动切换到 SHA-256。
|
|
return crypto.VerifyPassword(code, storedHash)
|
|
}
|
|
|
|
// consumeBackupCode removes a used backup code from the list.
|
|
func consumeBackupCode(ctx context.Context, userType string, userID int64, index int, codes []string) error {
|
|
newCodes := make([]string, 0, len(codes)-1)
|
|
for i, c := range codes {
|
|
if i != index {
|
|
newCodes = append(newCodes, c)
|
|
}
|
|
}
|
|
codesJSON, merr := json.Marshal(newCodes)
|
|
if merr != nil {
|
|
return gerror.Wrapf(merr, "marshal backup codes failed")
|
|
}
|
|
|
|
var err error
|
|
if userType == "admin" {
|
|
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Data(do.SysAdminUsers{
|
|
BackupCodes: string(codesJSON),
|
|
}).Update()
|
|
} else {
|
|
_, err = dao.TntUsers.Ctx(ctx).Where("id", userID).Data(do.TntUsers{
|
|
BackupCodes: string(codesJSON),
|
|
}).Update()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func getEncryptionKey(ctx context.Context) []byte {
|
|
hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String()
|
|
if hexKey == "" {
|
|
panic("crypto.encryptionKey is not configured — refusing to start with weak key")
|
|
}
|
|
return crypto.MustGetEncryptionKey(hexKey)
|
|
}
|
|
|
|
func sha256Sum(s string) string {
|
|
h := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(h[:])
|
|
}
|