diff --git a/json/users.json b/json/users.json index a650f30..b8711a6 100644 --- a/json/users.json +++ b/json/users.json @@ -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": [ diff --git a/scripts/load_path.py b/scripts/load_path.py index 1446dcd..bfaeab4 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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 ===") diff --git a/wwwroot/api/admin_reset_password.dspy b/wwwroot/api/admin_reset_password.dspy new file mode 100644 index 0000000..4707401 --- /dev/null +++ b/wwwroot/api/admin_reset_password.dspy @@ -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)}} diff --git a/wwwroot/api/clear_login_fail.dspy b/wwwroot/api/clear_login_fail.dspy new file mode 100644 index 0000000..51d3146 --- /dev/null +++ b/wwwroot/api/clear_login_fail.dspy @@ -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)}}