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
+8 -1
View File
@@ -55,7 +55,7 @@ func recordSessionLoginSuccess(s *Session, rec *auth.Rec, scheme, suppliedAccoun
}
}
func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *auth.Rec, requestID string, cause error, at time.Time) {
func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *auth.Rec, requestID string, cause error, at time.Time, riskActionOverride ...string) {
if s == nil || !s.tenantID.IsValid() {
return
}
@@ -71,6 +71,13 @@ func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *au
}
accountType, account := loginAuditAccount(scheme, suppliedAccount, credential)
failureType, reason, riskLevel, riskAction := classifyLoginFailure(scheme, cause)
for _, override := range riskActionOverride {
if strings.TrimSpace(override) == "" {
continue
}
riskAction = strings.TrimSpace(override)
riskLevel = "high"
}
event := &types.LoginFailureEvent{
UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
FailureType: failureType, Reason: reason, Detail: truncateAuditValue(loginFailureDetail(cause), 500),
+16
View File
@@ -1293,6 +1293,22 @@ func ErrAuthFailed(id, topic string, serverTs, incomingReqTs time.Time) *ServerC
}
}
// ErrAuthLocked account is temporarily locked after too many login failures
// (429); message carries the remaining lock time for the client to display.
func ErrAuthLocked(id string, ts time.Time, message string, params map[string]any) *ServerComMessage {
return &ServerComMessage{
Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusTooManyRequests, // 429
Text: message,
Params: params,
Timestamp: ts,
},
Id: id,
Timestamp: ts,
}
}
// ErrAuthUnknownScheme authentication scheme is unrecognized or invalid (401).
func ErrAuthUnknownScheme(id, topic string, ts time.Time) *ServerComMessage {
return &ServerComMessage{
+10
View File
@@ -285,6 +285,16 @@ type TenantRegistrationInviteAdapter interface {
TenantRegistrationInviteSyncUsage(t.TenantID, int64) error
}
// TenantLoginLockoutAdapter persists per-account login failure counters and
// lock state in each tenant business database.
type TenantLoginLockoutAdapter interface {
TenantLoginLockoutGet(t.TenantID, string) (*t.LoginLockout, error)
TenantLoginLockoutRecordFailure(t.TenantID, string, *int64, time.Time, time.Time) (*t.LoginLockout, error)
TenantLoginLockoutLock(t.TenantID, string, time.Time, time.Time) error
TenantLoginLockoutReset(t.TenantID, string) error
TenantLoginLockoutRelease(t.TenantID, string, time.Time) error
}
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
// It is separate from Adapter while the remaining persistence domains are migrated.
type TenantAuthAdapter interface {
@@ -38,6 +38,16 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
INDEX idx_im_login_failure_type_time(failure_type, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_login_lockouts (
id BIGINT NOT NULL, user_id BIGINT,
account_hash CHAR(64) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
window_started_at DATETIME(3), locked_until DATETIME(3), last_attempt_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_login_lockouts_account(account_hash),
INDEX idx_im_login_lockouts_user(user_id), INDEX idx_im_login_lockouts_locked(locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_message_admin_actions (
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
command_id VARCHAR(96) NOT NULL, conversation_type VARCHAR(16) NOT NULL,
+206
View File
@@ -0,0 +1,206 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"errors"
"time"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) routedLoginLockoutAdapter(id t.TenantID) (*adapter, error) {
if err := validTenant(id); err != nil {
return nil, err
}
if a.tenantScopedDB {
return a, nil
}
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
return tenantAdp, nil
}
type loginLockoutRow struct {
ID int64 `db:"id"`
UserID sql.NullInt64 `db:"user_id"`
AccountHash string `db:"account_hash"`
FailedAttempts int `db:"failed_attempts"`
WindowStartAt sql.NullTime `db:"window_started_at"`
LockedUntil sql.NullTime `db:"locked_until"`
LastAttemptAt sql.NullTime `db:"last_attempt_at"`
UpdatedAt sql.NullTime `db:"updated_at"`
}
func loginLockoutFromRow(id t.TenantID, row loginLockoutRow) *t.LoginLockout {
out := &t.LoginLockout{
TenantID: id,
ID: row.ID,
AccountHash: row.AccountHash,
FailedAttempts: row.FailedAttempts,
}
if row.UserID.Valid {
out.UserID = row.UserID.Int64
}
if row.WindowStartAt.Valid {
v := row.WindowStartAt.Time
out.WindowStartAt = &v
}
if row.LockedUntil.Valid {
v := row.LockedUntil.Time
out.LockedUntil = &v
}
if row.LastAttemptAt.Valid {
v := row.LastAttemptAt.Time
out.LastAttemptAt = &v
}
if row.UpdatedAt.Valid {
v := row.UpdatedAt.Time
out.UpdatedAt = &v
}
return out
}
const loginLockoutColumns = `id,user_id,account_hash,failed_attempts,window_started_at,locked_until,last_attempt_at,updated_at`
// TenantLoginLockoutGet reads the lockout counter for the given account hash.
func (a *adapter) TenantLoginLockoutGet(id t.TenantID, accountHash string) (*t.LoginLockout, error) {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutGet(id, accountHash)
}
if accountHash == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row loginLockoutRow
err = a.db.GetContext(ctx, &row,
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
if errors.Is(err, sql.ErrNoRows) {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return loginLockoutFromRow(id, row), nil
}
// TenantLoginLockoutRecordFailure atomically inserts or increments the failure
// counter. windowCutoff is "now minus window seconds": an older window start
// resets the counter to 1. The updated row is returned to the caller.
func (a *adapter) TenantLoginLockoutRecordFailure(id t.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*t.LoginLockout, error) {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutRecordFailure(id, accountHash, userID, windowCutoff, now)
}
if accountHash == "" || now.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rowID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
// MySQL evaluates ON DUPLICATE KEY UPDATE assignments left to right, so
// failed_attempts is recomputed before window_started_at is overwritten.
_, err = a.db.ExecContext(ctx, `INSERT INTO im_login_lockouts
(id,user_id,account_hash,failed_attempts,window_started_at,last_attempt_at)
VALUES(?,?,?,1,?,?)
ON DUPLICATE KEY UPDATE
failed_attempts = IF(window_started_at IS NULL OR window_started_at < ?, 1, failed_attempts + 1),
window_started_at = IF(window_started_at IS NULL OR window_started_at < ?, ?, window_started_at),
last_attempt_at = ?,
user_id = COALESCE(user_id, ?)`,
rowID, userID, accountHash, now, now,
windowCutoff, windowCutoff, now, now, userID)
if err != nil {
return nil, err
}
var row loginLockoutRow
err = a.db.GetContext(ctx, &row,
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
if err != nil {
return nil, err
}
return loginLockoutFromRow(id, row), nil
}
// TenantLoginLockoutLock sets locked_until unless a newer lock already covers now.
func (a *adapter) TenantLoginLockoutLock(id t.TenantID, accountHash string, lockedUntil, now time.Time) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutLock(id, accountHash, lockedUntil, now)
}
if accountHash == "" || lockedUntil.IsZero() || now.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
`UPDATE im_login_lockouts SET locked_until=?, updated_at=? WHERE account_hash=? AND (locked_until IS NULL OR locked_until < ?)`,
lockedUntil, now, accountHash, now)
return err
}
// TenantLoginLockoutReset removes the counter after a successful login.
func (a *adapter) TenantLoginLockoutReset(id t.TenantID, accountHash string) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutReset(id, accountHash)
}
if accountHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, `DELETE FROM im_login_lockouts WHERE account_hash=?`, accountHash)
return err
}
// TenantLoginLockoutRelease clears the lock and zeroes the counter (admin unlock).
func (a *adapter) TenantLoginLockoutRelease(id t.TenantID, accountHash string, now time.Time) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutRelease(id, accountHash, now)
}
if accountHash == "" || now.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
`UPDATE im_login_lockouts SET failed_attempts=0, window_started_at=NULL, locked_until=NULL, updated_at=? WHERE account_hash=?`,
now, accountHash)
return err
}
+1 -1
View File
@@ -68,7 +68,7 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
},
"login-failure-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,account_hash,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
},
+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)
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"errors"
"fmt"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
// loginAuditRiskActionLocked marks failure-log rows recorded at the moment the
// account lockout is triggered; the admin console uses it to offer unlocking.
const loginAuditRiskActionLocked = "account_locked"
// loginLockoutAccount extracts the account name supplied with a basic-auth secret.
func loginLockoutAccount(secret []byte) string {
parts := strings.SplitN(string(secret), ":", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
return ""
}
return strings.ToLower(strings.TrimSpace(parts[0]))
}
// tenantLoginRateLimitPolicy loads the published login rate limit policy for
// the tenant. Any error or disabled policy yields ok=false (feature off, the
// login flow then behaves exactly as before).
func tenantLoginRateLimitPolicy(tenantCode string) (types.LoginRateLimitPolicy, bool) {
if strings.TrimSpace(tenantCode) == "" {
return types.LoginRateLimitPolicy{}, false
}
policy, err := store.ModerationConfig.SecurityPolicy(tenantCode)
if err != nil {
if !errors.Is(err, types.ErrUnsupported) {
logs.Warn.Printf("login lockout policy load failed tenant:%s err:%v", tenantCode, err)
}
return types.LoginRateLimitPolicy{}, false
}
return policy.LoginRateLimit.Normalized()
}
// loginLockedMessage renders the user-facing lockout message with the rounded
// down remaining minutes; sub-minute remainders are reported as "within 1 minute".
func loginLockedMessage(retryAfter time.Duration) string {
if minutes := int(retryAfter.Minutes()); minutes >= 1 {
return fmt.Sprintf("登录错误次数过多,账号已锁定,请 %d 分钟后再试", minutes)
}
return "登录错误次数过多,账号已锁定,请 1 分钟内再试"
}
// loginLockoutGuard enforces the configured login failure lockout for one
// login attempt of the "basic" scheme. A nil guard means the feature is not
// applicable (non-basic scheme, unknown tenant, or disabled policy).
type loginLockoutGuard struct {
session *Session
account string
accountHash string
policy types.LoginRateLimitPolicy
}
func newLoginLockoutGuard(s *Session, scheme string, secret []byte) *loginLockoutGuard {
if s == nil || !s.tenantID.IsValid() || strings.TrimSpace(s.tenantCode) == "" ||
!strings.EqualFold(strings.TrimSpace(scheme), "basic") {
return nil
}
account := loginLockoutAccount(secret)
if account == "" {
return nil
}
policy, ok := tenantLoginRateLimitPolicy(s.tenantCode)
if !ok {
return nil
}
return &loginLockoutGuard{session: s, account: account, accountHash: hashLoginAccount(account), policy: policy}
}
// preCheck rejects the attempt up front when the account is currently locked,
// without comparing the password.
func (g *loginLockoutGuard) preCheck() (time.Duration, bool) {
rec, err := store.LoginLockouts.Get(g.session.tenantID, g.accountHash)
if err != nil {
if !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
logs.Warn.Printf("login lockout pre-check failed tenant:%d account_hash:%s err:%v", g.session.tenantID, g.accountHash, err)
}
return 0, false
}
now := time.Now()
if rec.LockedUntil == nil || !now.Before(*rec.LockedUntil) {
return 0, false
}
return time.Until(*rec.LockedUntil), true
}
// recordFailure counts one failed password attempt and, when the configured
// threshold is reached, persists the lock. It returns the remaining lock time
// and whether the account is now locked.
func (g *loginLockoutGuard) recordFailure() (time.Duration, bool) {
now := time.Now()
cutoff := now.Add(-time.Duration(g.policy.WindowSeconds) * time.Second)
rec, err := store.LoginLockouts.RecordFailure(g.session.tenantID, g.accountHash, nil, cutoff, now)
if err != nil {
if !errors.Is(err, types.ErrUnsupported) {
logs.Warn.Printf("login lockout record failed tenant:%d account_hash:%s err:%v", g.session.tenantID, g.accountHash, err)
}
return 0, false
}
if rec.FailedAttempts < g.policy.PasswordMaxAttempts {
return 0, false
}
until := now.Add(time.Duration(g.policy.LockSeconds) * time.Second)
if rec.LockedUntil != nil && rec.LockedUntil.After(now) {
// Lock already active (e.g. recorded within the same window): keep it.
until = *rec.LockedUntil
} else if err = store.LoginLockouts.Lock(g.session.tenantID, g.accountHash, until, now); err != nil {
if !errors.Is(err, types.ErrUnsupported) {
logs.Warn.Printf("login lockout persist failed tenant:%d account_hash:%s err:%v", g.session.tenantID, g.accountHash, err)
}
return 0, false
}
return time.Until(until), true
}
// clear resets the counter after a successful password check.
func (g *loginLockoutGuard) clear() {
if err := store.LoginLockouts.Reset(g.session.tenantID, g.accountHash); err != nil &&
!errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
logs.Warn.Printf("login lockout reset failed tenant:%d account_hash:%s err:%v", g.session.tenantID, g.accountHash, err)
}
}
+26
View File
@@ -12,6 +12,7 @@ package main
import (
"container/list"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
@@ -1150,9 +1151,29 @@ func (s *Session) login(msg *ClientComMessage) {
return
}
// Login failure lockout: reject already-locked accounts up front without
// comparing the password. A nil guard means the feature is not applicable.
lockGuard := newLoginLockoutGuard(s, msg.Login.Scheme, msg.Login.Secret)
if lockGuard != nil {
if retryAfter, locked := lockGuard.preCheck(); locked {
logs.Info.Printf("s.login: account locked, rejecting tenant:%d account_hash:%s sid:%s", s.tenantID, lockGuard.accountHash, s.sid)
s.queueOut(ErrAuthLocked(msg.Id, msg.Timestamp, loginLockedMessage(retryAfter),
map[string]any{"what": "auth", "retry_after_seconds": int(retryAfter.Seconds()) + 1}))
return
}
}
rec, challenge, err := handler.Authenticate(
auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}, msg.Login.Secret)
if err != nil {
if lockGuard != nil && errors.Is(err, types.ErrFailed) {
if retryAfter, lockedNow := lockGuard.recordFailure(); lockedNow {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp, loginAuditRiskActionLocked)
s.queueOut(ErrAuthLocked(msg.Id, msg.Timestamp, loginLockedMessage(retryAfter),
map[string]any{"what": "auth", "retry_after_seconds": int(retryAfter.Seconds()) + 1}))
return
}
}
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
if resp.Ctrl.Code >= 500 {
@@ -1170,6 +1191,11 @@ func (s *Session) login(msg *ClientComMessage) {
return
}
// Password accepted: clear the failure counter and any stale lock state.
if lockGuard != nil {
lockGuard.clear()
}
// If authenticator did not check user state, it returns state "undef". If so, check user state here.
if rec.State == types.StateUndefined {
rec.State, err = userGetState(s.tenantID, rec.Uid)
+75
View File
@@ -4438,6 +4438,80 @@ func actionTokenCacheID(uid types.Uid, tokenID, action, target string) string {
return uid.String() + ":" + strings.TrimSpace(tokenID) + ":" + strings.TrimSpace(action) + ":" + strings.TrimSpace(target)
}
// LoginLockoutPersistenceInterface stores per-account login failure counters
// and temporary lock state in each tenant business database.
type LoginLockoutPersistenceInterface interface {
Get(tenantID types.TenantID, accountHash string) (*types.LoginLockout, error)
RecordFailure(tenantID types.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*types.LoginLockout, error)
Lock(tenantID types.TenantID, accountHash string, lockedUntil, now time.Time) error
Reset(tenantID types.TenantID, accountHash string) error
Release(tenantID types.TenantID, accountHash string, now time.Time) error
}
type loginLockoutMapper struct{}
// LoginLockouts is the singleton for login lockout state.
var LoginLockouts LoginLockoutPersistenceInterface
func tenantLoginLockoutAdapter(tenantID types.TenantID) (adapter.TenantLoginLockoutAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
}
if router, ok := adp.(adapter.TenantRoutedAdapter); ok {
business, _, err := router.TenantAdapterFor(tenantID)
if err != nil {
return nil, err
}
if lockAdp, ok := business.(adapter.TenantLoginLockoutAdapter); ok {
return lockAdp, nil
}
}
if lockAdp, ok := adp.(adapter.TenantLoginLockoutAdapter); ok {
return lockAdp, nil
}
return nil, types.ErrUnsupported
}
func (loginLockoutMapper) Get(tenantID types.TenantID, accountHash string) (*types.LoginLockout, error) {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return nil, err
}
return lockAdp.TenantLoginLockoutGet(tenantID, accountHash)
}
func (loginLockoutMapper) RecordFailure(tenantID types.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*types.LoginLockout, error) {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return nil, err
}
return lockAdp.TenantLoginLockoutRecordFailure(tenantID, accountHash, userID, windowCutoff, now)
}
func (loginLockoutMapper) Lock(tenantID types.TenantID, accountHash string, lockedUntil, now time.Time) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return err
}
return lockAdp.TenantLoginLockoutLock(tenantID, accountHash, lockedUntil, now)
}
func (loginLockoutMapper) Reset(tenantID types.TenantID, accountHash string) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return err
}
return lockAdp.TenantLoginLockoutReset(tenantID, accountHash)
}
func (loginLockoutMapper) Release(tenantID types.TenantID, accountHash string, now time.Time) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return err
}
return lockAdp.TenantLoginLockoutRelease(tenantID, accountHash, now)
}
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
@@ -4606,6 +4680,7 @@ func init() {
PCache = pcacheMapper{}
Verifications = verificationMapper{}
RegistrationInvites = registrationInviteMapper{}
LoginLockouts = loginLockoutMapper{}
Security = userSecurityMapper{}
Relations = relationsMapper{}
Favorites = favoritesMapper{}
+17
View File
@@ -0,0 +1,17 @@
package types
import "time"
// LoginLockout is the per-account login failure counter and lock state
// stored in each tenant business database (table im_login_lockouts).
type LoginLockout struct {
TenantID TenantID `json:"tenant_id"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AccountHash string `json:"account_hash"`
FailedAttempts int `json:"failed_attempts"`
WindowStartAt *time.Time `json:"window_started_at"`
LockedUntil *time.Time `json:"locked_until"`
LastAttemptAt *time.Time `json:"last_attempt_at"`
UpdatedAt *time.Time `json:"updated_at"`
}
+28
View File
@@ -7,9 +7,37 @@ type SecurityPolicy struct {
GlobalFriendEnabled bool `json:"global_friend_enabled"`
FriendDisabledPolicy map[string]any `json:"friend_disabled_policy"`
MessageCurfewEnabled bool `json:"message_curfew_enabled"`
LoginRateLimit *LoginRateLimitPolicy `json:"login_rate_limit"`
CurfewRules []*MessageCurfewRule `json:"curfew_rules"`
}
// LoginRateLimitPolicy configures temporary account lockout after repeated
// login password failures. Zero values keep the feature disabled.
type LoginRateLimitPolicy struct {
Enabled bool `json:"enabled"`
PasswordMaxAttempts int `json:"password_max_attempts"`
LockSeconds int `json:"lock_seconds"`
WindowSeconds int `json:"window_seconds"`
}
// Normalized returns defaults-completed limits; ok=false when disabled or invalid.
func (p *LoginRateLimitPolicy) Normalized() (LoginRateLimitPolicy, bool) {
if p == nil || !p.Enabled {
return LoginRateLimitPolicy{}, false
}
normalized := *p
if normalized.PasswordMaxAttempts <= 0 {
normalized.PasswordMaxAttempts = 5
}
if normalized.LockSeconds <= 0 {
normalized.LockSeconds = 900
}
if normalized.WindowSeconds <= 0 {
normalized.WindowSeconds = 1800
}
return normalized, true
}
type MessageCurfewRule struct {
RuleCode string `json:"rule_code"`
RuleName string `json:"rule_name"`