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 d7e63e3..d0ee97d 100644 Binary files a/models/users.xlsx and b/models/users.xlsx differ 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')