rbac/wwwroot/user/up_login.dspy
yumoqing 2966993088 fix(rbac): 账户锁定场景补 login_fail 审计(result=locked)
up_login.dspy 锁定分支(fail_count>=3 且 5分钟内)之前直接 return,未写审计。
补 _audit_login(..., 'login_fail', 'locked'),补全失败登录审计三路径:
用户不存在/密码错误/账户锁定。
2026-08-16 18:28:12 +08:00

155 lines
4.3 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

debug(f'{params_kw=}')
ns = {
"username":params_kw.username,
"password":password_encode(params_kw.password)
}
async def _audit_login(sor, user_id, username, action, result='ok'):
"""登录审计:检查系统是否有审计模块,有则写入。"""
try:
from app_audit import audit_log
except ImportError:
return
try:
ip = request.get('client_ip', '') if hasattr(request, 'get') else ''
await audit_log(sor, user_id, username, action, result=result, client_ip=ip)
except Exception:
pass
info(f'{ns=}')
db = DBPools()
dbname = get_module_dbname('rbac')
async with db.sqlorContext(dbname) as sor:
# Get user record with lockout fields (standard SQL)
r = await sor.sqlExe('select * from users where username=${username}$', ns.copy())
if len(r) == 0:
await _audit_login(sor, None, ns['username'], 'login_fail', 'fail')
return {
"status": "error",
"data": {
"message": "user name or password error"
},
"widgettype":"Error",
"options":{
"timeout":3,
"title":"Login Error",
"message":"user name or password error"
}
}
user = r[0]
# Lockout check in Python (DB-agnostic)
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:
try:
stored = datetime.datetime.strptime(str(last_fail), '%Y-%m-%d %H:%M:%S')
elapsed = (datetime.datetime.now() - stored).total_seconds()
if elapsed < 300:
await _audit_login(sor, user.id, ns['username'], 'login_fail', 'locked')
return {
"status": "error",
"data": {
"message": "Account locked due to too many failed login attempts. Please try again in 5 minutes."
},
"widgettype":"Error",
"options":{
"timeout":5,
"title":"Account Locked",
"message":"Account locked due to too many failed login attempts. Please try again in 5 minutes."
}
}
else:
# Lockout expired, reset in background (best-effort)
await sor.sqlExe("""
UPDATE users SET login_fail_count = 0, last_login_fail = NULL
WHERE id = ${id}$
""", {'id': user.id})
except (ValueError, TypeError):
pass
# Verify password
r = await sor.sqlExe('select * from users where username=${username}$ and password=${password}$', ns.copy())
if len(r) == 0:
# 5分钟窗口超过5分钟前的失败次数默认为0原子 SQL
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
stale_before = (datetime.datetime.now() - datetime.timedelta(seconds=300)).strftime('%Y-%m-%d %H:%M:%S')
await sor.sqlExe("""
UPDATE users
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, 'stale': stale_before})
await _audit_login(sor, user.id, ns['username'], 'login_fail', 'fail')
new_fail_count = fail_count + 1
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 {
"status": "error",
"data": {
"message": msg
},
"widgettype":"Error",
"options":{
"timeout":3,
"title":"Login Error",
"message": msg
}
}
# Success -- atomic reset
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
await sor.sqlExe("""
UPDATE users
SET login_fail_count = 0, last_login_fail = NULL,
last_login = ${now}$
WHERE id = ${id}$
""", {'id': user.id, 'now': now_str})
await remember_user(r[0].id, username=r[0].username, userorgid=r[0].orgid)
await _audit_login(sor, r[0].id, r[0].username, 'login', 'ok')
return {
"status": "ok",
"data": { "user": r[0]},
"widgettype":"Message",
"options":{
"timeout":3,
"anchor": "cc",
"auto_open":True,
"title":"Login",
"message":f"{r[0].username} Welcome back"
},
"binds":[
{
"wid":"self",
"event":"dismissed",
"actiontype":"urlwidget",
"target":"window",
"options":{
"url":entire_url('/index.ui')
}
},
{
"wid":"self",
"event":"opened",
"actiontype":"script",
"target":"window.login_window",
"script":"this.destroy()"
}
]
}
return {
"status": "error",
"data": {
"message": "system error"
},
"widgettype":"Error",
"options":{
"timeout":3,
"title":"Login Error",
"message":"system error"
}
}