feat: 实现登录失败锁定功能

新增了登录失败次数统计和临时锁定机制,包括:
1. 定义登录锁定数据结构和数据库表结构
2. 实现租户级别的锁定状态持久化接口
3. 添加登录速率限制安全策略配置
4. 在登录流程中加入锁定前置检查和失败计数逻辑
5. 提供管理员手动解锁的内部API接口
6. 新增中文本地化的锁定提示文案
This commit is contained in:
Austin
2026-09-20 17:08:38 +08:00
parent 21063d4af0
commit 841c0c77cd
12 changed files with 563 additions and 3 deletions
+35 -1
View File
@@ -117,6 +117,8 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
serveInternalUserCreate(wrt, req, tenant.ID)
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
serveInternalUserAction(wrt, req, tenant.ID, parts[3])
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release":
serveInternalLoginLockoutRelease(wrt, req, tenant.ID)
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
case len(parts) == 4 && parts[2] == "users":
@@ -164,7 +166,8 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
func isInternalAdminPostRoute(parts []string) bool {
return (len(parts) == 3 && parts[2] == "users") ||
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action")
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action") ||
(len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release")
}
type internalUserCreateRequest struct {
@@ -329,6 +332,37 @@ func isInternalUserActionSupported(eventType string) bool {
}
}
type internalLoginLockoutReleaseRequest struct {
AccountHash string `json:"account_hash"`
}
// serveInternalLoginLockoutRelease clears the login failure lockout for one
// account so the user can sign in again immediately.
func serveInternalLoginLockoutRelease(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
var body internalLoginLockoutReleaseRequest
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
accountHash := strings.ToLower(strings.TrimSpace(body.AccountHash))
if len(accountHash) != 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "account_hash 格式不正确", nil)
return
}
rec, err := store.LoginLockouts.Get(tenantID, accountHash)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
writeInternalQueryError(wrt, req, err)
return
}
if err = store.LoginLockouts.Release(tenantID, accountHash, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeInternalQueryError(wrt, req, err)
return
}
logs.Info.Printf("login lockout released tenant:%d account_hash:%s had_lock:%v", tenantID, accountHash,
rec != nil && rec.LockedUntil != nil)
writeIMSuccess(wrt, req, map[string]any{"account_hash": accountHash, "status": "released"})
}
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
if !internalAPI.config.Enabled {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)