@@ -68,19 +68,25 @@ def add_membership(cur, user_id: int, tenant_id: int, role: str) -> None:
6868def login (cur , tenant_slug : str , username : str , password : str ) -> dict | None :
6969 """Return a new session for an active member of the tenant, else None.
7070
71- The caller commits. Wrong tenant, unknown user, wrong password and inactive
72- user all return None, so a caller cannot tell which one failed.
71+ The caller commits, including on failure so failed attempts are counted.
72+ Wrong tenant, unknown user, wrong password, inactive user and locked
73+ account (#102) all return None, so a caller cannot tell which one failed.
7374 """
7475 cur .execute (_q (
75- "SELECT u.id, u.password_hash, u.is_active, t.id, m.role "
76+ "SELECT u.id, u.password_hash, u.is_active, t.id, m.role, "
77+ "COALESCE(u.locked_until > now(), false) "
7678 "FROM {c}.users u "
7779 "LEFT JOIN {c}.tenants t ON t.slug = %s "
7880 "LEFT JOIN {c}.memberships m ON m.user_id = u.id AND m.tenant_id = t.id "
7981 "WHERE u.username = %s" ), (tenant_slug , username ))
8082 row = cur .fetchone ()
8183 ok = verify_password (password , row [1 ] if row else _DUMMY_HASH )
82- if not (row and ok and row [2 ] and row [4 ]):
84+ if row and not ok :
85+ _record_failure (cur , row [0 ])
86+ if not (row and ok and row [2 ] and row [4 ]) or row [5 ]:
8387 return None
88+ cur .execute (_q ("UPDATE {c}.users SET failed_logins = 0, first_failed_at = NULL, "
89+ "locked_until = NULL WHERE id = %s" ), (row [0 ],))
8490 user_id , tenant_id , role = row [0 ], row [3 ], row [4 ]
8591 token = secrets .token_urlsafe (32 )
8692 cur .execute (_q (
@@ -92,6 +98,21 @@ def login(cur, tenant_slug: str, username: str, password: str) -> dict | None:
9298 "tenant" : tenant_slug , "role" : role }
9399
94100
101+ def _record_failure (cur , user_id : int ) -> None :
102+ """Count a wrong password; lock the account once the window's limit is reached (#102)."""
103+ cur .execute (_q (
104+ "UPDATE {c}.users SET "
105+ "failed_logins = CASE WHEN first_failed_at > now() - make_interval(mins => %(m)s) "
106+ " THEN failed_logins + 1 ELSE 1 END, "
107+ "first_failed_at = CASE WHEN first_failed_at > now() - make_interval(mins => %(m)s) "
108+ " THEN first_failed_at ELSE now() END "
109+ "WHERE id = %(id)s RETURNING failed_logins" ),
110+ {"m" : settings .LOGIN_LOCKOUT_MINUTES , "id" : user_id })
111+ if cur .fetchone ()[0 ] >= settings .LOGIN_MAX_FAILURES :
112+ cur .execute (_q ("UPDATE {c}.users SET locked_until = now() + make_interval(mins => %s) "
113+ "WHERE id = %s" ), (settings .LOGIN_LOCKOUT_MINUTES , user_id ))
114+
115+
95116def session_for (cur , token : str ) -> dict | None :
96117 """The live session for a token, with its user, tenant and role, else None."""
97118 cur .execute (_q (
0 commit comments