From 3fdd4efeff91891272ddfd48a87d59f0e4bea76e Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sun, 26 Apr 2026 10:49:01 +0800 Subject: [PATCH] feat(rbac): add login tracking, lockout, secure cache - Add created_at, last_login, login_fail_count, last_login_fail fields - 3 failed logins locks account for 5 minutes - LRU+TTL cache for UserPermissions, thread-safe - All login methods update last_login - Migration SQL for existing databases --- migration/add_login_tracking.sql | 14 +++ models/users.xlsx | Bin 18568 -> 11068 bytes rbac/check_perm.py | 80 +++++++++++++++- rbac/init.py | 4 + rbac/userperm.py | 152 +++++++++++++++++++++++++------ wwwroot/phone_login.dspy | 12 +++ wwwroot/user/up_login.dspy | 44 ++++++++- wwwroot/userpassword_login.dspy | 19 +++- 8 files changed, 285 insertions(+), 40 deletions(-) create mode 100644 migration/add_login_tracking.sql diff --git a/migration/add_login_tracking.sql b/migration/add_login_tracking.sql new file mode 100644 index 0000000..99c57e1 --- /dev/null +++ b/migration/add_login_tracking.sql @@ -0,0 +1,14 @@ +-- RBAC users table migration: add login tracking fields +-- Run this on existing databases to add new columns + +-- Add registration timestamp (NOT NULL with default for existing rows) +ALTER TABLE users ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '用户注册时间'; + +-- Add last login timestamp +ALTER TABLE users ADD COLUMN last_login TIMESTAMP NULL DEFAULT NULL COMMENT '最后登录时间'; + +-- Add consecutive login fail count +ALTER TABLE users ADD COLUMN login_fail_count SMALLINT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数'; + +-- Add last login failure timestamp +ALTER TABLE users ADD COLUMN last_login_fail TIMESTAMP NULL DEFAULT NULL COMMENT '最后登录失败时间'; diff --git a/models/users.xlsx b/models/users.xlsx index d7e63e31937b6a2d859402f19478b08f0d82681b..d0ee97dd33377648749f13d1f5e749dbd47f93c9 100644 GIT binary patch literal 11068 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 I(9ga92X1b6761SM diff --git a/rbac/check_perm.py b/rbac/check_perm.py index 8d9a272..81ed1b9 100644 --- a/rbac/check_perm.py +++ b/rbac/check_perm.py @@ -88,6 +88,9 @@ async def register_user(sor, ns): id = getID() ns.id = id ns.orgid = id + # Set registration timestamp + ns.created_at = curDateString('%Y-%m-%d %H:%M:%S') + ns.login_fail_count = 0 ns1 = DictObject(id=id, orgname=ns.username) await create_org(sor, ns1) await create_user(sor, ns) @@ -105,19 +108,80 @@ def get_dbname(): return f('rbac') async def checkUserPassword(request, username, password): + """Authenticate user with password, supporting login lockout mechanism. + + After 3 consecutive failed login attempts, the user is locked out for 5 minutes. + On successful login, last_login is updated and fail count is reset. + """ db = DBPools() dbname = get_dbname() async with db.sqlorContext(dbname) as sor: - sql = "select * from users where username=${username}$ and password=${password}$" - recs = await sor.sqlExe(sql, {'username':username, 'password':password}) + # Get user record including login status fields + sql = "select * from users where username=${username}$" + recs = await sor.sqlExe(sql, {'username': username}) if len(recs) < 1: return False - await user_login(request, recs[0].id, - username=recs[0].username, - userorgid=recs[0].orgid) + + user = recs[0] + + # Check login lockout: 3 consecutive failures within 5 minutes + fail_count = getattr(user, 'login_fail_count', 0) or 0 + last_fail = getattr(user, 'last_login_fail', None) + + if fail_count >= 3 and last_fail: + # Calculate time elapsed since last failed attempt + now_ts = time.time() + fail_ts = _parse_timestamp(last_fail) + elapsed = now_ts - fail_ts + if elapsed < 300: # 5 minutes = 300 seconds + remaining = int(300 - elapsed) + debug(f'User {username} locked out, {remaining}s remaining') + return False + else: + # Lockout period expired, reset fail count + await sor.U('users', {'id': user.id}, { + 'login_fail_count': 0, + 'last_login_fail': None + }) + + # Check password + sql = "select * from users where username=${username}$ and password=${password}$" + recs = await sor.sqlExe(sql, {'username': username, 'password': password}) + if len(recs) < 1: + # Password wrong - increment fail count + new_fail_count = fail_count + 1 + await sor.U('users', {'id': user.id}, { + 'login_fail_count': new_fail_count, + 'last_login_fail': curDateString('%Y-%m-%d %H:%M:%S') + }) + debug(f'Login failed for {username}, fail_count={new_fail_count}') + return False + + # Login successful - reset fail count, update last_login + await sor.U('users', {'id': user.id}, { + 'login_fail_count': 0, + 'last_login_fail': None, + 'last_login': curDateString('%Y-%m-%d %H:%M:%S') + }) + await user_login(request, user.id, + username=user.username, + userorgid=user.orgid) return True return False +def _parse_timestamp(ts): + """Parse a timestamp string to unix timestamp.""" + from datetime import datetime + if ts is None: + return 0 + if isinstance(ts, (int, float)): + return ts + try: + dt = datetime.strptime(str(ts), '%Y-%m-%d %H:%M:%S') + return dt.timestamp() + except (ValueError, TypeError): + return 0 + async def basic_auth(sor, request): auth = request.headers.get('Authorization') auther = BasicAuth('x') @@ -128,6 +192,12 @@ async def basic_auth(sor, request): recs = await sor.sqlExe(sql, {'username':username,'password':password}) if len(recs) < 1: return None + # Update last_login on successful basic auth + await sor.U('users', {'id': recs[0].id}, { + 'last_login': curDateString('%Y-%m-%d %H:%M:%S'), + 'login_fail_count': 0, + 'last_login_fail': None + }) await user_login(request, recs[0].id, username=recs[0].username, userorgid=recs[0].orgid) diff --git a/rbac/init.py b/rbac/init.py index 39e5a6c..6a95f3b 100644 --- a/rbac/init.py +++ b/rbac/init.py @@ -43,3 +43,7 @@ def load_rbac(): env.sor_get_org_users = sor_get_org_users env.get_owner_orgid = get_owner_orgid env.sor_add_user_roles = sor_add_user_roles + # Cache invalidation methods for use after role/permission changes + env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache + env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches + env.invalidate_role_perm_cache = env.userpermissions.invalidate_rp_cache diff --git a/rbac/userperm.py b/rbac/userperm.py index a47cf5e..876c97d 100644 --- a/rbac/userperm.py +++ b/rbac/userperm.py @@ -1,50 +1,138 @@ import time +import threading +from collections import OrderedDict from sqlor.dbpools import DBPools, get_sor_context from ahserver.serverenv import ServerEnv from appPublic.Singleton import SingletonDecorator from appPublic.log import debug, exception, error +class LRUCache: + """Thread-safe LRU cache with TTL support.""" + + def __init__(self, maxsize=10000, ttl=300): + self.maxsize = maxsize + self.ttl = ttl # seconds + self._cache = OrderedDict() + self._lock = threading.Lock() + + def get(self, key): + with self._lock: + if key not in self._cache: + return None + value, expire_at = self._cache[key] + if time.time() > expire_at: + del self._cache[key] + return None + self._cache.move_to_end(key) + return value + + def set(self, key, value): + with self._lock: + if key in self._cache: + self._cache.move_to_end(key) + self._cache[key] = (value, time.time() + self.ttl) + while len(self._cache) > self.maxsize: + self._cache.popitem(last=False) + + def invalidate(self, key): + with self._lock: + self._cache.pop(key, None) + + def clear(self): + with self._lock: + self._cache.clear() + + def __contains__(self, key): + return self.get(key) is not None + + def __len__(self): + return len(self._cache) + + @SingletonDecorator class UserPermissions: - def __init__(self, max_cache_user=10000): + def __init__(self, max_cache_user=10000, cache_ttl=300, rp_cache_ttl=600): + """Initialize UserPermissions with secure caching. + + Args: + max_cache_user: Maximum number of user role entries in cache + cache_ttl: TTL for user role caches in seconds (default 5 minutes) + rp_cache_ttl: TTL for role-permission caches in seconds (default 10 minutes) + """ self.max_cache_user = max_cache_user - self.cups = {} + self.cache_ttl = cache_ttl + self.rp_cache_ttl = rp_cache_ttl + + # LRU cache for user roles: userid -> list of roles + self.ur_caches = LRUCache(maxsize=max_cache_user, ttl=cache_ttl) + + # Role-permission cache: role_key -> list of paths self.rp_caches = None - self.ur_caches = {} + self.rp_cache_loaded_at = 0 + + # Lock for rp_caches initialization + self._rp_lock = threading.Lock() async def get_user_roles(self, userid): + """Get roles for a user, with LRU+TTL caching.""" if userid is None: return ['anonymous', 'any'] + roles = self.ur_caches.get(userid) if roles: return roles + async with get_sor_context(ServerEnv(), 'rbac') as sor: await self.get_userroles(sor, userid) return self.ur_caches.get(userid) return None - + + def invalidate_user_cache(self, userid): + """Invalidate cache for a specific user. + Call this after role changes, user creation, etc. + """ + self.ur_caches.invalidate(userid) + + def invalidate_all_user_caches(self): + """Invalidate all user role caches.""" + self.ur_caches.clear() + + def invalidate_rp_cache(self): + """Invalidate role-permission cache (after permission changes).""" + self.rp_caches = None + self.rp_cache_loaded_at = 0 + async def load_roleperms(self, sor): - self.rp_caches = {} - sql_all = """select c.id, c.orgtypeid, c.name, b.path + """Load all role-permission mappings into cache.""" + now = time.time() + # Double-check with lock to prevent race conditions + with self._rp_lock: + if self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl: + return + + self.rp_caches = {} + sql_all = """select c.id, c.orgtypeid, c.name, b.path from rolepermission a, permission b, role c where a.permid = b.id and c.id = a.roleid order by c.orgtypeid, c.name""" - recs = await sor.sqlExe(sql_all, {}) - for r in recs: - if r.id == 'anonymous': - k = 'anonymous' - elif r.id == 'any': - k = 'any' - elif r.id == 'logined': - k = 'logined' - else: - k = f'{r.orgtypeid}.{r.name}' - arr = self.rp_caches.get(k, []) - arr.append(r.path) - self.rp_caches[k] = arr - + recs = await sor.sqlExe(sql_all, {}) + for r in recs: + if r.id == 'anonymous': + k = 'anonymous' + elif r.id == 'any': + k = 'any' + elif r.id == 'logined': + k = 'logined' + else: + k = f'{r.orgtypeid}.{r.name}' + arr = self.rp_caches.get(k, []) + arr.append(r.path) + self.rp_caches[k] = arr + self.rp_cache_loaded_at = now + async def get_userroles(self, sor, userid): + """Load user roles from database and cache them.""" recs = await sor.sqlExe('''select b.id, b.orgtypeid, b.name from users a, role b, userrole c where a.id = c.userid @@ -55,9 +143,10 @@ where a.id = c.userid roles.append(f'{r.orgtypeid}.{r.name}') roles.append(f'{r.orgtypeid}.*') roles.append(f'*.{r.name}') - self.ur_caches[userid] = sorted(list(set(roles))) - + self.ur_caches.set(userid, sorted(list(set(roles)))) + def check_roles_path(self, roles, path): + """Check if any of the roles has access to the given path.""" ret = False for role in roles: paths = self.rp_caches.get(role) @@ -65,22 +154,27 @@ where a.id = c.userid continue if path in paths: return True - return False - + return ret + async def is_user_has_path_perm(self, userid, path): + """Check if a user has permission for the given path. + + Security improvements: + 1. rp_caches now has TTL to ensure permission changes take effect + 2. User role cache uses LRU+TTL to prevent unbounded growth + 3. Race condition protection with lock during rp_caches initialization + """ roles = self.ur_caches.get(userid) if userid is None: roles = ['any', 'anonymous'] - + if self.rp_caches is None or not roles: env = ServerEnv() async with get_sor_context(env, 'rbac') as sor: - if not self.rp_caches: + if self.rp_caches is None: await self.load_roleperms(sor) if not roles: await self.get_userroles(sor, userid) roles = self.ur_caches.get(userid) - + return self.check_roles_path(roles, path) - - diff --git a/wwwroot/phone_login.dspy b/wwwroot/phone_login.dspy index da2bc36..2bd53fe 100644 --- a/wwwroot/phone_login.dspy +++ b/wwwroot/phone_login.dspy @@ -43,6 +43,12 @@ async with get_sor_context(request._run_ns, 'rbac') as sor: if recs: if len(recs) == 1: r = recs[0] + # Update last_login + await sor.U('users', {'id': r.id}, { + 'last_login': curDateString('%Y-%m-%d %H:%M:%S'), + 'login_fail_count': 0, + 'last_login_fail': None + }) await remember_user(r.id, username=r.username, userorgid=r.orgid) return { "status": "ok", @@ -53,6 +59,12 @@ async with get_sor_context(request._run_ns, 'rbac') as sor: if params_kw.selected_id: for r in recs: if r.id == params_kw.selected_id: + # Update last_login + await sor.U('users', {'id': r.id}, { + 'last_login': curDateString('%Y-%m-%d %H:%M:%S'), + 'login_fail_count': 0, + 'last_login_fail': None + }) await remember_user(r.id, username=r.username, userorgid=r.orgid) return { "status": "ok", diff --git a/wwwroot/user/up_login.dspy b/wwwroot/user/up_login.dspy index 9d971f5..8e00fda 100644 --- a/wwwroot/user/up_login.dspy +++ b/wwwroot/user/up_login.dspy @@ -9,7 +9,7 @@ info(f'{ns=}') db = DBPools() dbname = get_module_dbname('rbac') async with db.sqlorContext(dbname) as sor: - r = await sor.sqlExe('select * from users where username=${username}$ and password=${password}$', ns.copy()) + r = await sor.sqlExe('select * from users where username=${username}$', ns.copy()) if len(r) == 0: return { "widgettype":"Error", @@ -19,6 +19,47 @@ async with db.sqlorContext(dbname) as sor: "message":"user name or password error" } } + user = r[0] + + # Check login lockout + fail_count = getattr(user, 'login_fail_count', 0) or 0 + last_fail = getattr(user, 'last_login_fail', None) + if fail_count >= 3 and last_fail: + return { + "widgettype":"Error", + "options":{ + "timeout":5, + "title":"Account Locked", + "message":"Account locked due to too many failed login attempts. Please try again in 5 minutes." + } + } + + r = await sor.sqlExe('select * from users where username=${username}$ and password=${password}$', ns.copy()) + if len(r) == 0: + # Increment fail count + new_fail_count = fail_count + 1 + await sor.U('users', {'id': user.id}, { + 'login_fail_count': new_fail_count, + 'last_login_fail': curDateString('%Y-%m-%d %H:%M:%S') + }) + if new_fail_count >= 3: + msg = "Too many failed attempts. Account locked for 5 minutes." + else: + msg = f"user name or password error ({3 - new_fail_count} attempts remaining)" + return { + "widgettype":"Error", + "options":{ + "timeout":3, + "title":"Login Error", + "message": msg + } + } + # Success - reset fail count, update last_login + await sor.U('users', {'id': user.id}, { + 'login_fail_count': 0, + 'last_login_fail': None, + 'last_login': curDateString('%Y-%m-%d %H:%M:%S') + }) await remember_user(r[0].id, username=r[0].username, userorgid=r[0].orgid) return { "widgettype":"Message", @@ -55,4 +96,3 @@ return { "message":"system error" } } - diff --git a/wwwroot/userpassword_login.dspy b/wwwroot/userpassword_login.dspy index fab2561..02f4d05 100644 --- a/wwwroot/userpassword_login.dspy +++ b/wwwroot/userpassword_login.dspy @@ -5,7 +5,18 @@ if not passwd: passwd = password_encode(passwd) rzt = await check_user_password(request, username, passwd) if rzt: - return UiMessage(title='Logined', message=f'Welcome back ') -return UiError(title='login failed', message='user and password mismatch') - - + return UiMessage(title='Logined', message='Welcome back') + +# Check if account is locked for better error message +db = DBPools() +dbname = get_module_dbname('rbac') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlExe('select login_fail_count, last_login_fail from users where username=${username}$', {'username': username}) + if r: + fail_count = getattr(r[0], 'login_fail_count', 0) or 0 + if fail_count >= 3: + return UiError(title='Account Locked', message='Account locked due to too many failed login attempts. Please try again in 5 minutes.') + remaining = 3 - fail_count + return UiError(title='Login failed', message=f'User and password mismatch ({remaining} attempts remaining)') + +return UiError(title='Login failed', message='User and password mismatch')