diff --git a/server/auth_audit.go b/server/auth_audit.go index 7c64f39..a062eac 100644 --- a/server/auth_audit.go +++ b/server/auth_audit.go @@ -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), diff --git a/server/datamodel.go b/server/datamodel.go index ea952b9..ca2f179 100644 --- a/server/datamodel.go +++ b/server/datamodel.go @@ -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{ diff --git a/server/db/adapter.go b/server/db/adapter.go index fb34db8..7acbdf0 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -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 { diff --git a/server/db/mysql/admin_governance_schema.go b/server/db/mysql/admin_governance_schema.go index f05b503..9ce9a92 100644 --- a/server/db/mysql/admin_governance_schema.go +++ b/server/db/mysql/admin_governance_schema.go @@ -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, diff --git a/server/db/mysql/login_lockouts.go b/server/db/mysql/login_lockouts.go new file mode 100644 index 0000000..8984472 --- /dev/null +++ b/server/db/mysql/login_lockouts.go @@ -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 +} diff --git a/server/internal_admin_query_resources.go b/server/internal_admin_query_resources.go index e5451b7..dc0a9d4 100644 --- a/server/internal_admin_query_resources.go +++ b/server/internal_admin_query_resources.go @@ -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"}}, }, diff --git a/server/internal_api.go b/server/internal_api.go index 4b0a594..ac3d497 100644 --- a/server/internal_api.go +++ b/server/internal_api.go @@ -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) diff --git a/server/login_lockout.go b/server/login_lockout.go new file mode 100644 index 0000000..ecfcbc2 --- /dev/null +++ b/server/login_lockout.go @@ -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) + } +} diff --git a/server/session.go b/server/session.go index cc49c02..c1383fe 100644 --- a/server/session.go +++ b/server/session.go @@ -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) diff --git a/server/store/store.go b/server/store/store.go index 96dea5c..f07ef62 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -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{} diff --git a/server/store/types/login_lockout.go b/server/store/types/login_lockout.go new file mode 100644 index 0000000..d653a5b --- /dev/null +++ b/server/store/types/login_lockout.go @@ -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"` +} diff --git a/server/store/types/security_policy.go b/server/store/types/security_policy.go index f019a19..7ccf0f2 100644 --- a/server/store/types/security_policy.go +++ b/server/store/types/security_policy.go @@ -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"`