feat(users): admin reset password + clear login fail count for other users
- json/users.json: add 2 toolbar tools (selected_row) + script binds, same pattern as enable_user/disable_user; reset_password collects new password via prompt and POSTs JSON body to admin_reset_password.dspy - api/admin_reset_password.dspy: orgid ownership check, min length 8, cfm match, password_encode (RC4), also resets login_fail counters, audit_log(password_reset), new password never logged - api/clear_login_fail.dspy: ownership check, SET login_fail_count=0 last_login_fail=NULL, audit_log(login_fail_clear) - scripts/load_path.py: new PATHS_ADMIN section registered as *.admin (NOT logined - sensitive endpoints must not be open to any logged user)
This commit is contained in:
parent
855057e1d0
commit
5712f4091e
@ -12,6 +12,22 @@
|
||||
"cwidth": {}
|
||||
},
|
||||
"editexclouded": ["id", "nick_name", "orgid", "last_login_fail", "last_login", "sync_from", "login_fail_count", "created_at"],
|
||||
"toolbar": {
|
||||
"tools": [
|
||||
{
|
||||
"selected_row": true,
|
||||
"name": "reset_password",
|
||||
"icon": "{{entire_url('/rbac/imgs/users.svg')}}",
|
||||
"label": "重置密码"
|
||||
},
|
||||
{
|
||||
"selected_row": true,
|
||||
"name": "clear_login_fail",
|
||||
"icon": "{{entire_url('/rbac/imgs/users.svg')}}",
|
||||
"label": "清除登录次数"
|
||||
}
|
||||
]
|
||||
},
|
||||
"record_toolbar": [
|
||||
{
|
||||
"name": "enable_user",
|
||||
@ -50,6 +66,20 @@
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "var tid=bricks.getWidgetById('users_tbl');var d=tid.select_row.user_data;fetch('/rbac/users/disable_user.dspy?id='+d.id,{method:'POST'}).then(function(r){return r.json()}).then(function(r){if(r.widgettype==='Message'){tid.render({})}else{bricks.show_error(r.options)}})"
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "reset_password",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "var tid=bricks.getWidgetById('users_tbl');var d=tid.select_row.user_data;var np=prompt('为用户 '+d.username+' 设置新密码(至少8位):');if(!np){return}var cp=prompt('再次输入以确认:');if(!cp){return}fetch('/rbac/api/admin_reset_password.dspy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({userid:d.id,new_password:np,cfm_password:cp})}).then(function(r){return r.json()}).then(function(r){if(r.widgettype==='Message'){bricks.show_message(r.options);tid.render({})}else{bricks.show_error(r.options)}})"
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "clear_login_fail",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "var tid=bricks.getWidgetById('users_tbl');var d=tid.select_row.user_data;fetch('/rbac/api/clear_login_fail.dspy?id='+d.id,{method:'POST'}).then(function(r){return r.json()}).then(function(r){if(r.widgettype==='Message'){bricks.show_message(r.options);tid.render({})}else{bricks.show_error(r.options)}})"
|
||||
}
|
||||
],
|
||||
"subtables": [
|
||||
|
||||
@ -79,6 +79,14 @@ PATHS_LOGINED = [
|
||||
f"/{MOD}/api/get_search_roleid.dspy",
|
||||
]
|
||||
|
||||
# admin — 仅全局管理员(role: orgtypeid='*' name='admin')。
|
||||
# 管理员对他人的敏感操作端点,绝不进 PATHS_LOGINED(否则任何登录用户可重置他人密码)。
|
||||
# 与存量 /rbac/users/*.dspy(enable/disable/update)的 *.admin 授权口径一致。
|
||||
PATHS_ADMIN = [
|
||||
f"/{MOD}/api/admin_reset_password.dspy",
|
||||
f"/{MOD}/api/clear_login_fail.dspy",
|
||||
]
|
||||
|
||||
|
||||
def register_paths():
|
||||
for path in PATHS_ANY:
|
||||
@ -89,6 +97,10 @@ def register_paths():
|
||||
subprocess.run(["py3/bin/python", "set_role_perm.py", "logined", path])
|
||||
print(f" logined: {path}")
|
||||
|
||||
for path in PATHS_ADMIN:
|
||||
subprocess.run(["py3/bin/python", "set_role_perm.py", "admin", path])
|
||||
print(f" admin: {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"=== {MOD} RBAC registration ===")
|
||||
|
||||
72
wwwroot/api/admin_reset_password.dspy
Normal file
72
wwwroot/api/admin_reset_password.dspy
Normal file
@ -0,0 +1,72 @@
|
||||
ns = params_kw.copy()
|
||||
|
||||
userorgid = await get_userorgid()
|
||||
if not userorgid:
|
||||
return {
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"title":"Authorization Error",
|
||||
"timeout":3,
|
||||
"cwidth":16,
|
||||
"cheight":9,
|
||||
"message":"Please login"
|
||||
}
|
||||
}
|
||||
|
||||
target_id = ns.get('userid')
|
||||
new_password = ns.get('new_password')
|
||||
cfm_password = ns.get('cfm_password')
|
||||
|
||||
if not target_id:
|
||||
return {"widgettype":"Error","options":{"title":"Parameter Error","cwidth":16,"cheight":9,"timeout":3,"message":"missing userid"}}
|
||||
if not new_password or len(new_password) < 8:
|
||||
return {"widgettype":"Error","options":{"title":"Parameter Error","cwidth":16,"cheight":9,"timeout":3,"message":"new password must be at least 8 characters"}}
|
||||
if new_password != cfm_password:
|
||||
return {"widgettype":"Error","options":{"title":"Parameter Error","cwidth":16,"cheight":9,"timeout":3,"message":"two passwords do not match"}}
|
||||
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
try:
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, username, orgid FROM users WHERE id=${id}$",
|
||||
{"id": target_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
return {"widgettype":"Error","options":{"title":"Reset Password Error","cwidth":16,"cheight":9,"timeout":3,"message":"user not exist"}}
|
||||
rec = recs[0]
|
||||
if rec.orgid and rec.orgid != userorgid:
|
||||
return {"widgettype":"Error","options":{"title":"Reset Password Error","cwidth":16,"cheight":9,"timeout":3,"message":"Record no exist or with wrong ownership"}}
|
||||
|
||||
encoded = password_encode(new_password)
|
||||
await sor.sqlExe(
|
||||
"UPDATE users SET password=${pwd}$, login_fail_count = 0, last_login_fail = NULL WHERE id=${id}$",
|
||||
{"pwd": encoded, "id": target_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
|
||||
# 审计:操作留痕(失败不阻断主流程)
|
||||
try:
|
||||
from app_audit import audit_log
|
||||
op_id = await get_user()
|
||||
await audit_log(sor, str(op_id or ''), '', 'password_reset',
|
||||
target=f'users:{rec.username}',
|
||||
detail='admin reset password (new password not logged)',
|
||||
client_ip=request.get('client_ip', '') if hasattr(request, 'get') else '')
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
except Exception as ae:
|
||||
debug(f'admin_reset_password audit failed: {ae}')
|
||||
|
||||
debug(f'admin_reset_password ok: target={rec.username} operator_orgid={userorgid}')
|
||||
return {
|
||||
"widgettype":"Message",
|
||||
"options":{
|
||||
"title":"Reset Password Success",
|
||||
"cwidth":16,
|
||||
"cheight":9,
|
||||
"timeout":3,
|
||||
"message":f"user {rec.username} password has been reset"
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
debug(f'admin_reset_password error: {format_exc()}')
|
||||
return {"widgettype":"Error","options":{"title":"Reset Password Error","cwidth":16,"cheight":9,"timeout":5,"message":str(e)}}
|
||||
64
wwwroot/api/clear_login_fail.dspy
Normal file
64
wwwroot/api/clear_login_fail.dspy
Normal file
@ -0,0 +1,64 @@
|
||||
ns = params_kw.copy()
|
||||
|
||||
userorgid = await get_userorgid()
|
||||
if not userorgid:
|
||||
return {
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"title":"Authorization Error",
|
||||
"timeout":3,
|
||||
"cwidth":16,
|
||||
"cheight":9,
|
||||
"message":"Please login"
|
||||
}
|
||||
}
|
||||
|
||||
target_id = ns.get('id')
|
||||
if not target_id:
|
||||
return {"widgettype":"Error","options":{"title":"Parameter Error","cwidth":16,"cheight":9,"timeout":3,"message":"missing id"}}
|
||||
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
try:
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, username, orgid FROM users WHERE id=${id}$",
|
||||
{"id": target_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
return {"widgettype":"Error","options":{"title":"Clear Login Fail Error","cwidth":16,"cheight":9,"timeout":3,"message":"user not exist"}}
|
||||
rec = recs[0]
|
||||
if rec.orgid and rec.orgid != userorgid:
|
||||
return {"widgettype":"Error","options":{"title":"Clear Login Fail Error","cwidth":16,"cheight":9,"timeout":3,"message":"Record no exist or with wrong ownership"}}
|
||||
|
||||
await sor.sqlExe(
|
||||
"UPDATE users SET login_fail_count = 0, last_login_fail = NULL WHERE id=${id}$",
|
||||
{"id": target_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
|
||||
# 审计:操作留痕(失败不阻断主流程)
|
||||
try:
|
||||
from app_audit import audit_log
|
||||
op_id = await get_user()
|
||||
await audit_log(sor, str(op_id or ''), '', 'login_fail_clear',
|
||||
target=f'users:{rec.username}',
|
||||
detail='admin clear login_fail_count',
|
||||
client_ip=request.get('client_ip', '') if hasattr(request, 'get') else '')
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
except Exception as ae:
|
||||
debug(f'clear_login_fail audit failed: {ae}')
|
||||
|
||||
debug(f'clear_login_fail ok: target={rec.username} operator_orgid={userorgid}')
|
||||
return {
|
||||
"widgettype":"Message",
|
||||
"options":{
|
||||
"title":"Clear Login Fail Success",
|
||||
"cwidth":16,
|
||||
"cheight":9,
|
||||
"timeout":3,
|
||||
"message":f"user {rec.username} login fail count cleared"
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
debug(f'clear_login_fail error: {format_exc()}')
|
||||
return {"widgettype":"Error","options":{"title":"Clear Login Fail Error","cwidth":16,"cheight":9,"timeout":5,"message":str(e)}}
|
||||
Loading…
x
Reference in New Issue
Block a user