Smail/backend/controllers/user_security.go
smail fcbcf91d66
Some checks are pending
Security Checks / security (push) Waiting to run
检查点:保留账号分组隔离实现,继续权限复审
2026-09-25 21:46:42 +08:00

306 lines
11 KiB
Go

package controllers
import (
"errors"
"net/http"
"strconv"
"strings"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/verification"
"git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
)
type managedUserSecurityRequest struct {
UserID int `json:"user_id"`
Count int `json:"count"`
Email string `json:"email"`
Type string `json:"type"`
ChallengeID string `json:"challenge_id"`
BindingID int `json:"binding_id"`
Code string `json:"code"`
}
func loadManagedSecurityUser(ctx *context.Context, w http.ResponseWriter, userID int) (*models.User, bool) {
if !requireAccountScope(ctx, w, userID) {
return nil, false
}
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")
return nil, false
}
if db.Instance == nil || userID <= 0 {
writeSecurityError(w, response.ParamsError, "目标用户无效")
return nil, false
}
var target models.User
found, err := db.Instance.ID(userID).Get(&target)
if err != nil {
log.WithContext(ctx).WithError(err).Error("load managed security user failed")
writeSecurityError(w, response.ServerError, "读取目标用户失败")
return nil, false
}
if !found || target.ID <= 0 {
writeSecurityError(w, response.ParamsError, "目标用户不存在")
return nil, false
}
if err := authorizeCapability(ctx, access.CapabilityUserSecurityManage, target.EffectiveRole(), false); err != nil {
if access.IsDenied(err, access.CodeRecentAuthRequired) {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired))
return nil, false
}
if access.IsDenied(err, access.CodeRecoveryCodeRequired) {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码", string(access.CodeRecoveryCodeRequired))
return nil, false
}
writeSecurityError(w, response.NoAccessPrivileges, "无权管理该用户的安全信息")
return nil, false
}
return &target, true
}
func managedUserIDFromQuery(req *http.Request) int {
if req == nil || req.URL == nil {
return 0
}
userID, _ := strconv.Atoi(strings.TrimSpace(req.URL.Query().Get("id")))
return userID
}
// GetManagedRecoveryCodeStatus 返回目标用户安全码状态,不返回任何明文或哈希。
func GetManagedRecoveryCodeStatus(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
target, ok := loadManagedSecurityUser(ctx, w, managedUserIDFromQuery(req))
if !ok {
return
}
service := credentialRuntime()
if service == nil {
writeSecurityError(w, response.ServerError, "安全凭据服务不可用")
return
}
configured, err := service.HasValidRecoveryCode(target.ID)
if err != nil {
log.WithContext(ctx).WithError(err).Error("load managed recovery code status failed")
writeSecurityError(w, response.ServerError, "读取安全码状态失败")
return
}
response.NewSuccessResponse(map[string]any{
"user_id": target.ID,
"account": target.Account,
"configured": configured,
}).FPrint(w)
}
// GenerateManagedRecoveryCodes 为目标用户生成只显示一次的安全码。
func GenerateManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var input managedUserSecurityRequest
if err := decodeUserRequest(w, req, &input); err != nil {
writeSecurityError(w, response.ParamsError, "请求无效")
return
}
target, ok := loadManagedSecurityUser(ctx, w, input.UserID)
if !ok {
return
}
service := credentialRuntime()
if service == nil {
writeSecurityError(w, response.ServerError, "安全凭据服务不可用")
return
}
codes, err := service.GenerateRecoveryCodesContext(recoveryRequestContext(req), target.ID, input.Count)
if err != nil {
if writePasswordBusy(w, err) {
return
}
log.WithContext(ctx).WithError(err).Error("generate managed recovery codes failed")
writeSecurityError(w, response.ParamsError, "安全码生成失败")
return
}
InvalidateSecurityRuntimeUser(target.ID, target.Account)
response.NewSuccessResponse(map[string]any{
"user_id": target.ID,
"codes": codes,
"shown_once": true,
}).FPrint(w)
}
// RevokeManagedRecoveryCodes 撤销目标用户现有的全部安全码。
func RevokeManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var input managedUserSecurityRequest
if err := decodeUserRequest(w, req, &input); err != nil {
writeSecurityError(w, response.ParamsError, "请求无效")
return
}
target, ok := loadManagedSecurityUser(ctx, w, input.UserID)
if !ok {
return
}
service := credentialRuntime()
if service == nil {
writeSecurityError(w, response.ServerError, "安全凭据服务不可用")
return
}
if err := service.RevokeRecoveryCodes(target.ID); err != nil {
log.WithContext(ctx).WithError(err).Error("revoke managed recovery codes failed")
writeSecurityError(w, response.ServerError, "撤销安全码失败")
return
}
InvalidateSecurityRuntimeUser(target.ID, target.Account)
response.NewSuccessResponse(map[string]any{"user_id": target.ID, "revoked": true}).FPrint(w)
}
// ListManagedExternalEmails 返回目标用户已验证的外部邮箱掩码。
func ListManagedExternalEmails(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
target, ok := loadManagedSecurityUser(ctx, w, managedUserIDFromQuery(req))
if !ok {
return
}
runtime := currentSecurityRuntime()
if runtime.external == nil {
writeSecurityError(w, response.ServerError, "外部邮箱服务不可用")
return
}
items, err := runtime.external.List(target.ID)
if err != nil {
log.WithContext(ctx).WithError(err).Error("list managed external emails failed")
writeSecurityError(w, response.ServerError, "读取外部邮箱失败")
return
}
result := make([]map[string]any, 0, len(items))
for _, item := range items {
if item.Status != models.ExternalEmailStatusVerified {
continue
}
email, decryptErr := runtime.external.Decrypt(item)
if decryptErr != nil {
continue
}
result = append(result, map[string]any{
"id": item.ID, "type": item.EmailType, "status": item.Status, "email": maskEmail(email),
})
}
response.NewSuccessResponse(map[string]any{
"user_id": target.ID,
"account": target.Account,
"items": result,
}).FPrint(w)
}
// ChangeManagedExternalEmail 发送目标用户外部邮箱变更验证码。
func ChangeManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var input managedUserSecurityRequest
if err := decodeUserRequest(w, req, &input); err != nil {
writeSecurityError(w, response.ParamsError, "请求无效")
return
}
target, ok := loadManagedSecurityUser(ctx, w, input.UserID)
if !ok {
return
}
runtime := currentSecurityRuntime()
if runtime.external == nil || runtime.verification == nil {
writeSecurityError(w, response.ServerError, "外部邮箱验证服务不可用")
return
}
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
if err != nil {
writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址")
return
}
owned, err := currentUserPrimaryEmail(target.ID, normalizedEmail)
if err != nil {
log.WithContext(ctx).WithError(err).Error("check managed user primary email failed")
writeSecurityError(w, response.ServerError, "外部邮箱变更失败")
return
}
if owned {
writeSecurityError(w, response.ParamsError, "不能将该用户自己的邮箱绑定为外部安全邮箱")
return
}
emailType := strings.TrimSpace(input.Type)
if emailType == "" {
emailType = models.ExternalEmailTypePrimary
}
item, err := runtime.external.BeginReplacement(externalemail.BindRequest{
UserID: target.ID,
Email: normalizedEmail,
Type: emailType,
})
if err != nil {
if errors.Is(err, externalemail.ErrBindingExists) {
writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用")
return
}
log.WithContext(ctx).WithError(err).Error("begin managed external email replacement failed")
writeSecurityError(w, response.ParamsError, "外部邮箱变更失败")
return
}
if runtime.verification.Store != nil {
runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailChange)
runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailBind)
}
challenge, err := runtime.verification.Issue(verification.IssueRequest{
Purpose: verification.PurposeExternalEmailChange,
UserID: target.ID,
Account: target.Account,
Email: normalizedEmail,
ResourceID: item.ID,
}, remoteIP(req))
if err != nil {
log.WithContext(ctx).WithError(err).Warn("issue managed external email verification failed")
writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err))
return
}
response.NewSuccessResponse(map[string]any{
"user_id": target.ID, "binding_id": item.ID, "challenge_id": challenge.ID,
"email": maskEmail(normalizedEmail), "expires_at": challenge.ExpiresAt.UTC(),
}).FPrint(w)
}
// VerifyManagedExternalEmail 验证成功后原子替换目标用户同用途的旧邮箱。
func VerifyManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var input managedUserSecurityRequest
if err := decodeUserRequest(w, req, &input); err != nil || input.BindingID <= 0 {
writeSecurityError(w, response.ParamsError, "验证请求无效")
return
}
target, ok := loadManagedSecurityUser(ctx, w, input.UserID)
if !ok {
return
}
runtime := currentSecurityRuntime()
if runtime.external == nil || runtime.verification == nil {
writeSecurityError(w, response.ServerError, "外部邮箱验证服务不可用")
return
}
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
if err != nil {
writeSecurityError(w, response.ParamsError, "验证失败")
return
}
if err := runtime.verification.VerifyBound(
input.ChallengeID,
target.Account,
normalizedEmail,
input.Code,
verification.PurposeExternalEmailChange,
input.BindingID,
remoteIP(req),
); err != nil {
writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err))
return
}
if err := runtime.external.VerifyReplacement(target.ID, input.BindingID); err != nil {
log.WithContext(ctx).WithError(err).Error("verify managed external email replacement failed")
writeSecurityError(w, response.ParamsError, "外部邮箱替换失败")
return
}
InvalidateSecurityRuntimeUser(target.ID, target.Account)
response.NewSuccessResponse(map[string]any{"user_id": target.ID, "verified": true}).FPrint(w)
}