Smail/backend/controllers/login.go
smail 4741b641d9
Some checks are pending
Security Checks / security (push) Waiting to run
调整管理员与用户安全恢复为安全码或外部邮箱任选其一
2026-10-02 18:00:51 +08:00

321 lines
10 KiB
Go

package controllers
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/i18n"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/session"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/csrf"
log "github.com/sirupsen/logrus"
)
type loginRequest struct {
Identifier string `json:"identifier"`
Account string `json:"account"`
Password string `json:"password"`
}
type loginAttempt struct {
failures int
windowStart time.Time
blockedUntil time.Time
lastSeen time.Time
}
type loginLimiter struct {
mu sync.Mutex
attempts map[string]loginAttempt
now func() time.Time
failureLimit int
}
const (
loginFailureLimit = 10
loginAccountFailureLimit = 20
loginIPFailureLimit = 100
loginWindow = 5 * time.Minute
loginBlockTime = 15 * time.Minute
loginMaxEntries = 10000
)
var (
loginPairAttempts = newLoginLimiter(loginFailureLimit)
loginAccountAttempts = newLoginLimiter(loginAccountFailureLimit)
loginIPAttempts = newLoginLimiter(loginIPFailureLimit)
)
func newLoginLimiter(failureLimits ...int) *loginLimiter {
failureLimit := loginFailureLimit
if len(failureLimits) > 0 && failureLimits[0] > 0 {
failureLimit = failureLimits[0]
}
return &loginLimiter{attempts: make(map[string]loginAttempt), now: time.Now, failureLimit: failureLimit}
}
func (limiter *loginLimiter) key(req *http.Request, account string) string {
return hashedLoginKey("pair", loginRemoteHost(req), normalizedLoginAccount(account))
}
func (limiter *loginLimiter) accountKey(account string) string {
return hashedLoginKey("account", normalizedLoginAccount(account))
}
func (limiter *loginLimiter) ipKey(req *http.Request) string {
return hashedLoginKey("ip", loginRemoteHost(req))
}
func normalizedLoginAccount(account string) string {
return strings.ToLower(strings.TrimSpace(account))
}
func loginRemoteHost(req *http.Request) string {
if req == nil {
return "unknown"
}
host := remoteIP(req)
if host == "" {
host = "unknown"
}
return host
}
func hashedLoginKey(parts ...string) string {
hash := sha256.New()
for _, part := range parts {
_, _ = hash.Write([]byte(part))
_, _ = hash.Write([]byte{0})
}
return hex.EncodeToString(hash.Sum(nil))
}
// loginSecurityFields 返回登录后安全引导所需的非敏感状态。
func loginSecurityFields(user *models.User) map[string]any {
fields := map[string]any{
"recoveryCodesConfigured": false,
"showRecoverySetupPrompt": false,
"showSecuritySetupPrompt": user != nil,
"externalEmailConfigured": false,
"webAuthnEnabled": webAuthnRuntime() != nil,
"totpConfigured": false,
"securitySetupRequired": false,
}
if user == nil {
return fields
}
if db.Instance != nil && user.ID > 0 {
emailConfigured, err := db.Instance.Where("user_id = ? AND status = ?", user.ID, models.ExternalEmailStatusVerified).Exist(&models.ExternalEmail{})
if err == nil {
fields["externalEmailConfigured"] = emailConfigured
}
}
configured := false
if db.Instance != nil && user.ID > 0 {
configured, _ = db.Instance.Where("user_id = ? AND used_at IS NULL", user.ID).Exist(&models.RecoveryCode{})
enabled, _ := db.Instance.Where("user_id = ? AND enabled = ? AND revoked_at IS NULL", user.ID, true).Exist(&models.TOTPAuthenticator{})
fields["totpConfigured"] = enabled
}
fields["recoveryCodesConfigured"] = configured
needsRecoverySetup := !configured && fields["externalEmailConfigured"] != true
fields["showRecoverySetupPrompt"] = needsRecoverySetup
fields["showSecuritySetupPrompt"] = needsRecoverySetup
if user.EffectiveRole() == access.RoleSuperAdmin {
required := !configured && fields["externalEmailConfigured"] != true && fields["totpConfigured"] != true
fields["securitySetupRequired"] = required
fields["showSecuritySetupPrompt"] = required
fields["showRecoverySetupPrompt"] = false
}
return fields
}
// RequiresSecuritySetup 每次请求按当前凭据状态判断,撤销最后一种验证后立即恢复限制。
func RequiresSecuritySetup(userID int, role access.Role) bool {
return loginSecurityFields(&models.User{ID: userID, Role: string(role)})["securitySetupRequired"] == true
}
func (limiter *loginLimiter) blocked(key string) bool {
now := limiter.now()
limiter.mu.Lock()
defer limiter.mu.Unlock()
limiter.cleanupLocked(now)
attempt, exists := limiter.attempts[key]
return exists && attempt.blockedUntil.After(now)
}
func (limiter *loginLimiter) recordFailure(key string) {
now := limiter.now()
limiter.mu.Lock()
defer limiter.mu.Unlock()
limiter.cleanupLocked(now)
attempt := limiter.attempts[key]
if attempt.windowStart.IsZero() || now.Sub(attempt.windowStart) > loginWindow {
attempt = loginAttempt{windowStart: now}
}
attempt.failures++
attempt.lastSeen = now
if attempt.failures >= limiter.failureLimit {
attempt.blockedUntil = now.Add(loginBlockTime)
}
if _, exists := limiter.attempts[key]; !exists && len(limiter.attempts) >= loginMaxEntries {
limiter.evictOldestLocked()
}
limiter.attempts[key] = attempt
}
func (limiter *loginLimiter) clear(key string) {
limiter.mu.Lock()
delete(limiter.attempts, key)
limiter.mu.Unlock()
}
func (limiter *loginLimiter) cleanupLocked(now time.Time) {
for key, attempt := range limiter.attempts {
windowExpired := !attempt.windowStart.IsZero() && now.Sub(attempt.windowStart) > loginWindow
blockExpired := attempt.blockedUntil.IsZero() || !attempt.blockedUntil.After(now)
if windowExpired && blockExpired {
delete(limiter.attempts, key)
}
}
}
func (limiter *loginLimiter) evictOldestLocked() {
oldestKey := ""
var oldestTime time.Time
for key, attempt := range limiter.attempts {
if oldestKey == "" || attempt.lastSeen.Before(oldestTime) {
oldestKey = key
oldestTime = attempt.lastSeen
}
}
if oldestKey != "" {
delete(limiter.attempts, oldestKey)
}
}
func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var reqData loginRequest
if err := decodeJSONRequest(w, req, &reqData); err != nil {
log.WithContext(ctx).Debug("invalid login request")
response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w)
return
}
identifier := strings.TrimSpace(reqData.Identifier)
if identifier == "" {
identifier = strings.TrimSpace(reqData.Account)
}
pairKey := loginPairAttempts.key(req, identifier)
accountKey := loginAccountAttempts.accountKey(identifier)
ipKey := loginIPAttempts.ipKey(req)
if loginPairAttempts.blocked(pairKey) || loginAccountAttempts.blocked(accountKey) || loginIPAttempts.blocked(ipKey) {
log.WithContext(ctx).Info("login rejected by rate limit")
response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w)
return
}
user, err := auth.AuthenticateIdentifier(identifier, reqData.Password)
if err != nil {
if writePasswordBusy(w, err) {
return
}
loginPairAttempts.recordFailure(pairKey)
loginAccountAttempts.recordFailure(accountKey)
loginIPAttempts.recordFailure(ipKey)
log.WithContext(ctx).Info("login failed")
response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w)
return
}
loginPairAttempts.clear(pairKey)
loginAccountAttempts.clear(accountKey)
if pending, err := beginLoginVerification(user, remoteIP(req)); err != nil {
log.WithContext(ctx).WithError(err).Info("登录二次验证未就绪")
response.NewErrorResponse(response.ParamsError, "登录验证服务暂不可用,请稍后重试", "").FPrint(w)
return
} else if pending != nil {
response.NewSuccessResponse(pending).FPrint(w)
return
}
if err := session.Instance.RenewToken(req.Context()); err != nil {
log.WithContext(ctx).Info("session renewal failed")
response.NewErrorResponse(response.ServerError, "登录失败", "").FPrint(w)
return
}
if _, err := csrf.Issue(w, req, !config.DevelopmentMode); err != nil {
if clearErr := session.Instance.Clear(req.Context()); clearErr != nil {
log.WithContext(ctx).WithError(clearErr).Error("session cleanup after csrf failure failed")
}
log.WithContext(ctx).Info("csrf token issue failed")
response.NewErrorResponse(response.ServerError, "登录失败", "").FPrint(w)
return
}
if err := session.PutUser(req.Context(), user); err != nil {
if destroyErr := session.Instance.Destroy(req.Context()); destroyErr != nil {
log.WithContext(ctx).WithError(destroyErr).Error("session destruction after user persistence failure failed")
}
csrf.Clear(w, req, !config.DevelopmentMode)
log.WithContext(ctx).WithError(err).Error("session user persistence failed")
response.NewErrorResponse(response.ServerError, "登录失败", "").FPrint(w)
return
}
domain, domains, ok := loginDomainSettings()
if !ok {
response.NewErrorResponse(response.ServerError, "登录失败", "").FPrint(w)
return
}
domains = append([]string{domain}, domains...)
result := map[string]any{
"account": user.Account,
"name": user.Name,
"role": user.EffectiveRole(),
"capabilities": access.Capabilities(user.EffectiveRole()),
"is_admin": user.IsAdmin,
"domains": domains,
"must_change_password": user.MustChangePassword != 0,
}
for key, value := range loginSecurityFields(user) {
result[key] = value
}
setLoginAuditActor(ctx, user)
response.NewSuccessResponse(result).FPrint(w)
}
// setLoginAuditActor 只在完整登录成功后补充审计身份,不将待二次验证视为登录完成。
func setLoginAuditActor(ctx *context.Context, user *models.User) {
ctx.UserID = user.ID
ctx.UserAccount = user.Account
ctx.UserName = user.Name
ctx.Role = user.EffectiveRole()
}
func Logout(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
csrf.Clear(w, req, !config.DevelopmentMode)
if err := session.Instance.Destroy(ctx.Context); err != nil {
log.WithContext(ctx).WithError(err).Error("session cleanup during logout failed")
response.NewErrorResponse(response.ServerError, "退出失败", "").FPrint(w)
return
}
response.NewSuccessResponse("Success").FPrint(w)
}