feat: 实现登录失败锁定功能
新增了登录失败次数统计和临时锁定机制,包括: 1. 定义登录锁定数据结构和数据库表结构 2. 实现租户级别的锁定状态持久化接口 3. 添加登录速率限制安全策略配置 4. 在登录流程中加入锁定前置检查和失败计数逻辑 5. 提供管理员手动解锁的内部API接口 6. 新增中文本地化的锁定提示文案
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user