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
This commit is contained in:
yumoqing 2026-04-26 10:49:01 +08:00
parent a8a5199c25
commit 3fdd4efeff
8 changed files with 285 additions and 40 deletions

View File

@ -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 '最后登录失败时间';

Binary file not shown.

View File

@ -88,6 +88,9 @@ async def register_user(sor, ns):
id = getID() id = getID()
ns.id = id ns.id = id
ns.orgid = 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) ns1 = DictObject(id=id, orgname=ns.username)
await create_org(sor, ns1) await create_org(sor, ns1)
await create_user(sor, ns) await create_user(sor, ns)
@ -105,19 +108,80 @@ def get_dbname():
return f('rbac') return f('rbac')
async def checkUserPassword(request, username, password): 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() db = DBPools()
dbname = get_dbname() dbname = get_dbname()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
sql = "select * from users where username=${username}$ and password=${password}$" # Get user record including login status fields
recs = await sor.sqlExe(sql, {'username':username, 'password':password}) sql = "select * from users where username=${username}$"
recs = await sor.sqlExe(sql, {'username': username})
if len(recs) < 1: if len(recs) < 1:
return False return False
await user_login(request, recs[0].id,
username=recs[0].username, user = recs[0]
userorgid=recs[0].orgid)
# 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 True
return False 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): async def basic_auth(sor, request):
auth = request.headers.get('Authorization') auth = request.headers.get('Authorization')
auther = BasicAuth('x') auther = BasicAuth('x')
@ -128,6 +192,12 @@ async def basic_auth(sor, request):
recs = await sor.sqlExe(sql, {'username':username,'password':password}) recs = await sor.sqlExe(sql, {'username':username,'password':password})
if len(recs) < 1: if len(recs) < 1:
return None 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, await user_login(request, recs[0].id,
username=recs[0].username, username=recs[0].username,
userorgid=recs[0].orgid) userorgid=recs[0].orgid)

View File

@ -43,3 +43,7 @@ def load_rbac():
env.sor_get_org_users = sor_get_org_users env.sor_get_org_users = sor_get_org_users
env.get_owner_orgid = get_owner_orgid env.get_owner_orgid = get_owner_orgid
env.sor_add_user_roles = sor_add_user_roles 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

View File

@ -1,50 +1,138 @@
import time import time
import threading
from collections import OrderedDict
from sqlor.dbpools import DBPools, get_sor_context from sqlor.dbpools import DBPools, get_sor_context
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
from appPublic.Singleton import SingletonDecorator from appPublic.Singleton import SingletonDecorator
from appPublic.log import debug, exception, error 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 @SingletonDecorator
class UserPermissions: 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.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.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): async def get_user_roles(self, userid):
"""Get roles for a user, with LRU+TTL caching."""
if userid is None: if userid is None:
return ['anonymous', 'any'] return ['anonymous', 'any']
roles = self.ur_caches.get(userid) roles = self.ur_caches.get(userid)
if roles: if roles:
return roles return roles
async with get_sor_context(ServerEnv(), 'rbac') as sor: async with get_sor_context(ServerEnv(), 'rbac') as sor:
await self.get_userroles(sor, userid) await self.get_userroles(sor, userid)
return self.ur_caches.get(userid) return self.ur_caches.get(userid)
return None 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): async def load_roleperms(self, sor):
self.rp_caches = {} """Load all role-permission mappings into cache."""
sql_all = """select c.id, c.orgtypeid, c.name, b.path 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 from rolepermission a, permission b, role c
where a.permid = b.id where a.permid = b.id
and c.id = a.roleid and c.id = a.roleid
order by c.orgtypeid, c.name""" order by c.orgtypeid, c.name"""
recs = await sor.sqlExe(sql_all, {}) recs = await sor.sqlExe(sql_all, {})
for r in recs: for r in recs:
if r.id == 'anonymous': if r.id == 'anonymous':
k = 'anonymous' k = 'anonymous'
elif r.id == 'any': elif r.id == 'any':
k = 'any' k = 'any'
elif r.id == 'logined': elif r.id == 'logined':
k = 'logined' k = 'logined'
else: else:
k = f'{r.orgtypeid}.{r.name}' k = f'{r.orgtypeid}.{r.name}'
arr = self.rp_caches.get(k, []) arr = self.rp_caches.get(k, [])
arr.append(r.path) arr.append(r.path)
self.rp_caches[k] = arr self.rp_caches[k] = arr
self.rp_cache_loaded_at = now
async def get_userroles(self, sor, userid): 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 recs = await sor.sqlExe('''select b.id, b.orgtypeid, b.name
from users a, role b, userrole c from users a, role b, userrole c
where a.id = c.userid 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}.{r.name}')
roles.append(f'{r.orgtypeid}.*') roles.append(f'{r.orgtypeid}.*')
roles.append(f'*.{r.name}') 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): def check_roles_path(self, roles, path):
"""Check if any of the roles has access to the given path."""
ret = False ret = False
for role in roles: for role in roles:
paths = self.rp_caches.get(role) paths = self.rp_caches.get(role)
@ -65,9 +154,16 @@ where a.id = c.userid
continue continue
if path in paths: if path in paths:
return True return True
return False return ret
async def is_user_has_path_perm(self, userid, path): 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) roles = self.ur_caches.get(userid)
if userid is None: if userid is None:
roles = ['any', 'anonymous'] roles = ['any', 'anonymous']
@ -75,12 +171,10 @@ where a.id = c.userid
if self.rp_caches is None or not roles: if self.rp_caches is None or not roles:
env = ServerEnv() env = ServerEnv()
async with get_sor_context(env, 'rbac') as sor: 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) await self.load_roleperms(sor)
if not roles: if not roles:
await self.get_userroles(sor, userid) await self.get_userroles(sor, userid)
roles = self.ur_caches.get(userid) roles = self.ur_caches.get(userid)
return self.check_roles_path(roles, path) return self.check_roles_path(roles, path)

View File

@ -43,6 +43,12 @@ async with get_sor_context(request._run_ns, 'rbac') as sor:
if recs: if recs:
if len(recs) == 1: if len(recs) == 1:
r = recs[0] 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) await remember_user(r.id, username=r.username, userorgid=r.orgid)
return { return {
"status": "ok", "status": "ok",
@ -53,6 +59,12 @@ async with get_sor_context(request._run_ns, 'rbac') as sor:
if params_kw.selected_id: if params_kw.selected_id:
for r in recs: for r in recs:
if r.id == params_kw.selected_id: 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) await remember_user(r.id, username=r.username, userorgid=r.orgid)
return { return {
"status": "ok", "status": "ok",

View File

@ -9,7 +9,7 @@ info(f'{ns=}')
db = DBPools() db = DBPools()
dbname = get_module_dbname('rbac') dbname = get_module_dbname('rbac')
async with db.sqlorContext(dbname) as sor: 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: if len(r) == 0:
return { return {
"widgettype":"Error", "widgettype":"Error",
@ -19,6 +19,47 @@ async with db.sqlorContext(dbname) as sor:
"message":"user name or password error" "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) await remember_user(r[0].id, username=r[0].username, userorgid=r[0].orgid)
return { return {
"widgettype":"Message", "widgettype":"Message",
@ -55,4 +96,3 @@ return {
"message":"system error" "message":"system error"
} }
} }

View File

@ -5,7 +5,18 @@ if not passwd:
passwd = password_encode(passwd) passwd = password_encode(passwd)
rzt = await check_user_password(request, username, passwd) rzt = await check_user_password(request, username, passwd)
if rzt: if rzt:
return UiMessage(title='Logined', message=f'Welcome back ') return UiMessage(title='Logined', message='Welcome back')
return UiError(title='login failed', message='user and password mismatch')
# 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')