From 8b00a5618c4ce5b0bc2ece08d6cca7ce14ee7ba9 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sun, 16 Aug 2026 17:08:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20=E7=99=BB=E5=BD=95=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=20+=20=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5=E6=8C=895?= =?UTF-8?q?=E5=88=86=E9=92=9F=E7=AA=97=E5=8F=A3=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rbac/check_perm.py | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/rbac/check_perm.py b/rbac/check_perm.py index f0711c0..305712c 100644 --- a/rbac/check_perm.py +++ b/rbac/check_perm.py @@ -1,7 +1,7 @@ import time from traceback import format_exc -from datetime import datetime +from datetime import datetime, timedelta from aiohttp import BasicAuth from sqlor.dbpools import DBPools, get_sor_context from appPublic.registerfunction import RegisterFunction @@ -143,6 +143,30 @@ def get_dbname(): return None return f('rbac') +def _req_ip(request): + """安全取客户端 IP(审计用),request 可能是 aiohttp Request 或 dict-like。""" + try: + return request.get('client_ip', '') if hasattr(request, 'get') else '' + except Exception: + return '' + + +async def _audit_login(sor, request, user_id, username, action, result='ok'): + """登录审计:检查系统是否安装审计模块(app_audit),有则写入登录审计日志。 + + 审计是旁路,模块缺失或写入失败都不阻断登录主流程。 + """ + try: + from app_audit import audit_log + except ImportError: + return # 未安装审计模块,跳过 + try: + await audit_log(sor, user_id, username, action, result=result, + client_ip=_req_ip(request)) + except Exception as e: + debug(f'audit_login({action}) failed: {e}') + + async def checkUserPassword(request, username, password): """Authenticate user with password, supporting login lockout mechanism. @@ -159,6 +183,7 @@ async def checkUserPassword(request, username, password): recs = await sor.sqlExe(sql, {'username': username}) if len(recs) < 1: debug(f'User {username} not found') + await _audit_login(sor, request, None, username, 'login_fail', 'fail') return False user = recs[0] @@ -166,6 +191,7 @@ async def checkUserPassword(request, username, password): user_status = getattr(user, 'user_status', '0') or '0' if user_status != '0': debug(f'User {username} is disabled (status={user_status})') + await _audit_login(sor, request, user.id, username, 'login_fail', 'fail') return False fail_count = getattr(user, 'login_fail_count', 0) or 0 last_fail = getattr(user, 'last_login_fail', None) @@ -173,21 +199,24 @@ async def checkUserPassword(request, username, password): # Lockout check in Python (DB-agnostic) if _is_locked(fail_count, last_fail): debug(f'User {username} locked out') + await _audit_login(sor, request, user.id, username, 'login_fail', 'fail') return False # Verify password with standard SQL sql = "select * from users where username=${username}$ and password=${password}$" recs = await sor.sqlExe(sql, {'username': username, 'password': password}) if len(recs) < 1: - # Atomic increment -- standard SQL, works on all databases + # 5分钟窗口:超过5分钟前的失败次数默认为0(原子 SQL,DB 无关) now_str = _now_ts() + stale_before = (datetime.now() - timedelta(seconds=LOGIN_LOCKOUT_DURATION)).strftime('%Y-%m-%d %H:%M:%S') await sor.sqlExe(""" UPDATE users - SET login_fail_count = login_fail_count + 1, + SET login_fail_count = CASE WHEN last_login_fail IS NULL OR last_login_fail < ${stale}$ THEN 1 ELSE login_fail_count + 1 END, last_login_fail = ${now}$ WHERE id = ${id}$ - """, {'id': user.id, 'now': now_str}) + """, {'id': user.id, 'now': now_str, 'stale': stale_before}) debug(f'Login failed for {username}, fail_count incremented') + await _audit_login(sor, request, user.id, username, 'login_fail', 'fail') return False # Login successful -- atomic reset @@ -202,6 +231,7 @@ async def checkUserPassword(request, username, password): await user_login(request, user.id, username=user.username, userorgid=user.orgid) + await _audit_login(sor, request, user.id, user.username, 'login', 'ok') return True return False @@ -215,6 +245,7 @@ async def basic_auth(sor, request): sql = "select * from users where username=${username}$ and password=${password}$" recs = await sor.sqlExe(sql, {'username':username,'password':password}) if len(recs) < 1: + await _audit_login(sor, request, None, username, 'login_fail', 'fail') return None # Check lockout in Python layer (DB-agnostic) user = recs[0] @@ -222,11 +253,13 @@ async def basic_auth(sor, request): user_status = getattr(user, 'user_status', '0') or '0' if user_status != '0': debug(f'User {username} is disabled (status={user_status}) via basic auth') + await _audit_login(sor, request, user.id, username, 'login_fail', 'fail') return None fail_count = getattr(user, 'login_fail_count', 0) or 0 last_fail = getattr(user, 'last_login_fail', None) if _is_locked(fail_count, last_fail): debug(f'User {username} locked out via basic auth') + await _audit_login(sor, request, user.id, username, 'login_fail', 'fail') return None # Update last_login on successful basic auth (standard SQL) now_str = _now_ts() @@ -237,8 +270,9 @@ async def basic_auth(sor, request): WHERE id = ${id}$ """, {'id': recs[0].id, 'now': now_str}) await user_login(request, recs[0].id, - username=recs[0].username, - userorgid=recs[0].orgid) + username=recs[0].username, + userorgid=recs[0].orgid) + await _audit_login(sor, request, recs[0].id, recs[0].username, 'login', 'ok') return recs[0].id async def getAuthenticationUserid(sor, request):