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() {
|
if s == nil || !s.tenantID.IsValid() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,13 @@ func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *au
|
|||||||
}
|
}
|
||||||
accountType, account := loginAuditAccount(scheme, suppliedAccount, credential)
|
accountType, account := loginAuditAccount(scheme, suppliedAccount, credential)
|
||||||
failureType, reason, riskLevel, riskAction := classifyLoginFailure(scheme, cause)
|
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{
|
event := &types.LoginFailureEvent{
|
||||||
UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
|
UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
|
||||||
FailureType: failureType, Reason: reason, Detail: truncateAuditValue(loginFailureDetail(cause), 500),
|
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).
|
// ErrAuthUnknownScheme authentication scheme is unrecognized or invalid (401).
|
||||||
func ErrAuthUnknownScheme(id, topic string, ts time.Time) *ServerComMessage {
|
func ErrAuthUnknownScheme(id, topic string, ts time.Time) *ServerComMessage {
|
||||||
return &ServerComMessage{
|
return &ServerComMessage{
|
||||||
|
|||||||
@@ -285,6 +285,16 @@ type TenantRegistrationInviteAdapter interface {
|
|||||||
TenantRegistrationInviteSyncUsage(t.TenantID, int64) error
|
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.
|
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
|
||||||
// It is separate from Adapter while the remaining persistence domains are migrated.
|
// It is separate from Adapter while the remaining persistence domains are migrated.
|
||||||
type TenantAuthAdapter interface {
|
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_type_time(failure_type, attempt_at DESC, id DESC),
|
||||||
INDEX idx_im_login_failure_request(request_id)
|
INDEX idx_im_login_failure_request(request_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
) 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 (
|
`CREATE TABLE IF NOT EXISTS im_message_admin_actions (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
|
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
|
||||||
command_id VARCHAR(96) NOT NULL, conversation_type VARCHAR(16) 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"}},
|
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": {
|
"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",
|
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"}},
|
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)
|
serveInternalUserCreate(wrt, req, tenant.ID)
|
||||||
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
|
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
|
||||||
serveInternalUserAction(wrt, req, tenant.ID, parts[3])
|
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":
|
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
|
||||||
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
|
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
|
||||||
case len(parts) == 4 && parts[2] == "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 {
|
func isInternalAdminPostRoute(parts []string) bool {
|
||||||
return (len(parts) == 3 && parts[2] == "users") ||
|
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 {
|
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 {
|
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
|
||||||
if !internalAPI.config.Enabled {
|
if !internalAPI.config.Enabled {
|
||||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
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 (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -1150,9 +1151,29 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
return
|
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(
|
rec, challenge, err := handler.Authenticate(
|
||||||
auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}, msg.Login.Secret)
|
auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}, msg.Login.Secret)
|
||||||
if err != nil {
|
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)
|
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
|
||||||
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
|
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
|
||||||
if resp.Ctrl.Code >= 500 {
|
if resp.Ctrl.Code >= 500 {
|
||||||
@@ -1170,6 +1191,11 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
return
|
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 authenticator did not check user state, it returns state "undef". If so, check user state here.
|
||||||
if rec.State == types.StateUndefined {
|
if rec.State == types.StateUndefined {
|
||||||
rec.State, err = userGetState(s.tenantID, rec.Uid)
|
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)
|
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) {
|
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
|
||||||
if !tenantID.IsValid() {
|
if !tenantID.IsValid() {
|
||||||
return nil, types.ErrMalformed
|
return nil, types.ErrMalformed
|
||||||
@@ -4606,6 +4680,7 @@ func init() {
|
|||||||
PCache = pcacheMapper{}
|
PCache = pcacheMapper{}
|
||||||
Verifications = verificationMapper{}
|
Verifications = verificationMapper{}
|
||||||
RegistrationInvites = registrationInviteMapper{}
|
RegistrationInvites = registrationInviteMapper{}
|
||||||
|
LoginLockouts = loginLockoutMapper{}
|
||||||
Security = userSecurityMapper{}
|
Security = userSecurityMapper{}
|
||||||
Relations = relationsMapper{}
|
Relations = relationsMapper{}
|
||||||
Favorites = favoritesMapper{}
|
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"`
|
GlobalFriendEnabled bool `json:"global_friend_enabled"`
|
||||||
FriendDisabledPolicy map[string]any `json:"friend_disabled_policy"`
|
FriendDisabledPolicy map[string]any `json:"friend_disabled_policy"`
|
||||||
MessageCurfewEnabled bool `json:"message_curfew_enabled"`
|
MessageCurfewEnabled bool `json:"message_curfew_enabled"`
|
||||||
|
LoginRateLimit *LoginRateLimitPolicy `json:"login_rate_limit"`
|
||||||
CurfewRules []*MessageCurfewRule `json:"curfew_rules"`
|
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 {
|
type MessageCurfewRule struct {
|
||||||
RuleCode string `json:"rule_code"`
|
RuleCode string `json:"rule_code"`
|
||||||
RuleName string `json:"rule_name"`
|
RuleName string `json:"rule_name"`
|
||||||
|
|||||||
Reference in New Issue
Block a user