Compare commits

..

No commits in common. "main" and "feat/dataviz-users" have entirely different histories.

52 changed files with 959 additions and 2727 deletions

10
.gitignore vendored
View File

@ -23,14 +23,4 @@ ENV/
.env .env
.venv .venv
wwwroot/wwwroot wwwroot/wwwroot
# CRUD auto-generated directories
wwwroot/organization/
wwwroot/orgtypes/
wwwroot/permission/
wwwroot/provider/
wwwroot/reseller/
wwwroot/role/
wwwroot/rolepermission/
wwwroot/userrole/
wwwroot/users/
mysql.ddl.sql mysql.ddl.sql

107
README.md
View File

@ -1,107 +1,2 @@
# rbac — 角色权限模块 # rbac
产线平台/Sage 系应用的基础模块,提供**用户、机构、角色、权限(RBAC)**的完整管理体系,以及登录认证(密码/短信/扫码/Bearer API key 等多种认证方式注册点)、权限校验拦截、审计日志和双层权限缓存。所有宿主应用的鉴权都收敛到本模块的 `objcheckperm`(挂到 `AuthAPI.checkUserPermission`)。
## 模块定位
- 数据模型层:用户/机构/角色/权限四套表(见下)。
- 认证层:`check_perm.py` 实现密码登录(含 3 次失败锁定 5 分钟)、`getAuthenticationUserid` 按 `Authorization` 头前缀分发到已注册的认证方法(内置 `Basic `,其他模块可通过 `register_auth_method` 注册,如 dapi 注册 `Bearer `/`Deerer `)。
- 授权层:`userperm.py` 的 `UserPermissions`(单例)负责"用户→角色→路径权限"的判定,含 LRU+Redis 双层缓存。
- 事件层:`init.py` 通过 DBPools 绑定 role/userrole/permission/rolepermission 四表的 C/U/D after 事件,变更即失效缓存。
## 表清单(models/*.json)
| 表 | 标题 | 关键字段 | 说明 |
|---|---|---|---|
| `users` | 用户 | id, username, password, email, orgid, nick_name, mobile, login_fail_count, last_login_fail, last_login | 密码 RC4(appPublic.rc4)加密存储 |
| `organization` | 机构 | id, orgname, orgabbr, alias_name, contactor, contactor_phone, province_id, city_id | 支持层级(parentid) |
| `orgtypes` | 机构拥有角色 | id, orgid, orgtypeid | orgtypeid 取自 appbase 的 `appcodes_kv`(parentid='org_type') |
| `role` | 角色 | id, orgtypeid(默认'0'), name | 角色全名 = `{orgtypeid}.{name}` |
| `userrole` | 用户角色 | id, userid, roleid | 用户↔角色多对多 |
| `permission` | 权限 | id, name, parentid, path, icon, permtype, need_audit | path 即受保护的 URL 路径 |
| `rolepermission` | 角色权限表 | id, roleid, permid | 角色↔权限多对多 |
| `audit_log` | 审计日志 | id, permid, userid, params_kw, exe_date, exe_timestamp, remote_ip | need_audit 权限调用时由 `audit_log.write_audit_log` 落表 |
`models/` 为 sqlor DDL 源,`json/` 为 bricks CRUD 页面定义(另含 reseller.json、provider.json 等展示用定义)。
## 角色模型
- 判定键为 `{orgtypeid}.{name}`,加载用户角色时自动派生通配角色:`{orgtypeid}.*`、`*.{name}`;每个登录用户固定拥有 `any`、`logined` 两个隐式角色,未登录请求按 `anonymous`/`any` 处理。
- 常用内置角色(由部署脚本/SQL 种子创建):
- `owner.superuser` — 平台超级管理员(rbac 管理页面、`register_rbac_tools_perm.py` 注册的工具权限都挂在此角色)。
- `owner.admin` — 平台管理员。
- `owner.audit` — 审计只读角色(配合 audit_log 模块,审计独立性)。
- `downapp.logined` — dapi 模块在 API key 认证成功后自动创建并授予(见 `ensure_downappuser_role_and_assign`)。
- 路径匹配(`check_roles_path`):精确匹配、`**`/`%` 前缀通配、`/main/xxx` → `/xxx` 归一化。
- 超管创建参考 `pipeline-app/scripts/create_superuser.py`(orgtypeid=owner, roles=[superuser, admin])。
## 缓存机制(重点)
`UserPermissions` 使用**双层缓存**:
- **L1 进程内**:`ur_caches`(用户→角色,LRU maxsize=10000,TTL **300s**);`rp_caches`(角色→路径集合,全量,TTL **600s**,双检锁+原子换入防止加载期间 403)。
- **L2 Redis 共享**:经 `appPublic.share_cache`(`cache_set/cache_get/cache_invalidate`)写入,key 为 `rbac:role_perms`、`rbac:user_roles:{userid}`,TTL 与 L1 相同(600s/300s),供多 worker 进程复用。
- 开关:`config.json` 的 `module_cache.rbac=false` 可整体禁用缓存(调试用)。
- 失效途径:
1. role/userrole/permission/rolepermission 表 C/U/D 事件(`register_rbac_event_listeners`,userrole 变更精确失效单个用户,其余全量失效 rp_caches);
2. `hot_reload` 事件(`on_hot_reload` 清空两层);
3. 手动调用 `ServerEnv().invalidate_user_perm_cache(userid)` / `invalidate_all_perm_caches()` / `invalidate_role_perm_cache()`,或访问 `/rbac/refresh_userperm.dspy`。
> **部署纪律:直接改数据库里的角色/权限数据(绕过事件绑定)后,缓存不会自动失效。必须 `redis-cli FLUSHDB` 清掉 L2 共享缓存并重启应用进程清掉各 worker 的 L1 缓存,否则最长 600 秒内旧权限继续生效(可能放行或误拦)。**
## 对外 API / dspy 端点(wwwroot/)
- 登录认证:`userpassword_login.dspy`、`phone_login.dspy`、`gen_sms_code.dspy`、`user/up_login.dspy`、`user/code_login.dspy`、`user/logout.dspy`、`user/register.dspy`、`user/sms_register.dspy`、`user/reset_password/reset_password.dspy`
- 用户资料:`user/edit_profile.dspy`、`user/save_profile.dspy`
- 管理端:`add_adminuser.dspy`、`add_provider.dspy`、`get_provider.dspy`、`add_reseller.dspy`、`get_reseller.dspy`
- 角色权限查询:`get_all_roles.dspy`、`get_normal_roles.dspy`、`list_path_roles.dspy`、`find_unauth_files.dspy`(扫描 wwwroot 中未注册权限的文件)、`api/get_search_roleid.dspy`
- 用户 CRUD:`api/add_user.dspy`、`api/update_user.dspy`
- 管理员对他人的敏感操作(仅 `* admin` 角色,见 load_path.py 的 PATHS_ADMIN):`api/admin_reset_password.dspy`(重置他人密码,禁止对自己操作,自己改密走自助入口)、`api/clear_login_fail.dspy`(清除连续登录失败次数)、`admin_reset_password.ui`(PopupWindow 表单,隐藏字段 userid 由 `params_kw.get('id')` 服务端注入)
- 缓存刷新:`refresh_userperm.dspy`
- 用户同步:`usersync/index.dspy`(把 rbac 用户同步到 dapi 并生成 API key,接口文档见 `docs/userasync-api.md`)
- UI:`admin_menu.ui`、`list_path_roles.ui`、`user/login.ui`、`user/user_panel.ui`、`stat_*.ui`(用户/机构统计卡片)等
权限注册分三档:`any`(免登录,见 `scripts/load_path.py` 的 PATHS_ANY)、`logined`(登录即可)、具体角色(如 `owner.superuser`,见 `script/register_rbac_tools_perm.py`;全局管理员档 `* admin` 见 PATHS_ADMIN)。`set_role_perm.py`(pipeline-app 根目录)按 `[orgtypeid.]name` 查 role 表取真实 roleid,特殊角色 any/anonymous/logined 用字面 id。
## load 注册函数
入口 `rbac/init.py` 的 **`load_rbac()`**,向 `ServerEnv()` 单例挂载:
```
env.userpermissions # UserPermissions 单例
env.create_org / create_user / register_user / register_auth_method
env.check_user_password / get_user_roles / get_org_users / sor_get_org_users
env.set_role_perm / set_role_perms / sor_add_user_roles
env.get_platform_providers / get_owner_orgid
env.invalidate_user_perm_cache / invalidate_all_perm_caches / invalidate_role_perm_cache
AuthAPI.checkUserPermission = objcheckperm # ahserver 鉴权拦截点
```
并绑定 hot_reload 事件与四张权限表的 DB 事件监听。
辅助模块:`rbac_tools.py`(query_path_roles/scan_unauth_files)、`audit_log.py`(write_audit_log)、`orgs.py`(get_platform_providers)、`user_stats.py`、`set_role_perms.py`、`version.py`。
## 宿主集成
宿主应用在启动入口(如 `sage/app/sage.py`、`pipeline-app/app/pipeline_app.py`、`cpcc/app/cpcc.py`、`filemgr/app/fileMGR.py`)中:
```python
from rbac.init import load_rbac
...
load_appbase() # 先加载 appbase(rbac 注册开关读 params 表)
load_rbac()
load_dapi() # 依赖 rbac 的 register_auth_method,必须在其后
```
依赖:ahserver(AuthAPI/ServerEnv)、sqlor(DBPools/get_sor_context)、appPublic(rc4/share_cache/jsonConfig 等)、appbase(params 表的 `register_open` 控制注册开关)。
数据库名解析:`ServerEnv().get_module_dbname('rbac')`,宿主需在 config.json 中配置模块→库映射(或默认同库)。
## 部署注意
1. **加载顺序**:appbase → rbac → dapi/业务模块;rbac 必须最先完成 `AuthAPI.checkUserPermission` 挂载,否则所有请求无鉴权。
2. **权限注册**:部署新页面/dspy 后运行 `scripts/load_path.py`(any/logined 分档)或 `set_role_perm.py` 注册路径权限,否则会被默认拦截;可用 `find_unauth_files.dspy` 扫描遗漏。
3. **改权限数据后必须 FLUSHDB + 重启**(见上文缓存机制),这是双层缓存(进程内 LRU + Redis,rp TTL 600s)下的硬性运维动作。
4. `script/init.py` 内含示例数据库口令,仅供本地初始化参考,生产以宿主 config.json 为准;密码字段一律 RC4(`appPublic.rc4.password`)加密。
5. 登录防爆破:3 次失败锁定 5 分钟(`LOGIN_LOCKOUT_DURATION`),审计写 `audit_log` 表;`need_audit='1'` 的权限调用也会落审计。
6. usersync 接口把用户下发给 dapi(下位系统 API key 体系),文档见 `docs/userasync-api.md`。

View File

@ -1,137 +0,0 @@
用户管理: User Management
用户名: Username
密码: Password
确认密码: Confirm Password
姓名: Full Name
邮箱: Email
手机号: Phone Number
所属机构: Organization
角色: Role
状态: Status
启用: Enable
停用: Disable
新增用户: Add User
编辑用户: Edit User
删除用户: Delete User
重置密码: Reset Password
角色管理: Role Management
角色名称: Role Name
角色编码: Role Code
角色描述: Role Description
权限: Permission
新增角色: Add Role
编辑角色: Edit Role
删除角色: Delete Role
分配权限: Assign Permission
机构管理: Organization Management
机构名称: Organization Name
机构编码: Organization Code
机构类型: Organization Type
上级机构: Parent Organization
新增机构: Add Organization
编辑机构: Edit Organization
删除机构: Delete Organization
菜单管理: Menu Management
菜单名称: Menu Name
菜单路径: Menu Path
菜单图标: Menu Icon
菜单排序: Menu Sort Order
上级菜单: Parent Menu
新增菜单: Add Menu
编辑菜单: Edit Menu
删除菜单: Delete Menu
权限管理: Permission Management
权限名称: Permission Name
权限编码: Permission Code
权限类型: Permission Type
资源权限: Resource Permission
操作权限: Operation Permission
数据权限: Data Permission
登录日志: Login Log
登录时间: Login Time
登录IP: Login IP
登录状态: Login Status
成功: Success
失败: Failed
操作日志: Operation Log
操作类型: Operation Type
操作时间: Operation Time
操作人: Operator
操作结果: Operation Result
API密钥管理: API Key Management
密钥名称: Key Name
密钥值: Key Value
密钥状态: Key Status
新增密钥: Add Key
编辑密钥: Edit Key
删除密钥: Delete Key
在线用户: Online Users
会话ID: Session ID
最后活动时间: Last Activity Time
强制下线: Force Logout
安全设置: Security Settings
密码策略: Password Policy
密码长度: Password Length
密码复杂度: Password Complexity
密码有效期: Password Expiry
登录失败锁定: Login Failure Lock
最大失败次数: Max Failed Attempts
锁定时长: Lock Duration
双因素认证: Two-Factor Authentication
启用双因素: Enable 2FA
验证码: Verification Code
Token管理: Token Management
Token名称: Token Name
Token过期时间: Token Expiry Time
新增Token: Add Token
编辑Token: Edit Token
删除Token: Delete Token
刷新Token: Refresh Token
用户组: User Group
组名称: Group Name
组描述: Group Description
新增用户组: Add User Group
编辑用户组: Edit User Group
删除用户组: Delete User Group
成员管理: Member Management
添加成员: Add Member
移除成员: Remove Member
搜索: Search
名称或编码: Name or Code
操作: Action
描述: Description
类型: Type
名称: Name
编码: Code
备注: Remarks
创建时间: Created Time
更新时间: Updated Time
新增: Add
保存: Save
取消: Cancel
确认: Confirm
删除: Delete
编辑: Edit
查看: View
导出: Export
打印: Print
刷新: Refresh
返回: Back
提交: Submit
重置: Reset
Conform: Conform
Discard: Discard
Submit: Submit
Reset: Reset
Cancel: Cancel
全部: All
日期: Date
管理员: Administrator
普通用户: Normal User
只读用户: Read-Only User
访问控制: Access Control
最后登录: Last Login
清除登录次数: Clear Login Fail Count
新密码: New Password
确认清除该用户的连续登录失败次数?: Clear the consecutive login failure count of this user?
为选中用户设置新密码(至少8位): Set a new password (at least 8 characters) for the selected user

View File

@ -1,137 +0,0 @@
用户管理: ユーザー管理
用户名: ユーザー名
密码: パスワード
确认密码: パスワード確認
姓名: 氏名
邮箱: メールアドレス
手机号: 電話番号
所属机构: 所属組織
角色: ロール
状态: ステータス
启用: 有効
停用: 無効
新增用户: ユーザー追加
编辑用户: ユーザー編集
删除用户: ユーザー削除
重置密码: パスワードリセット
角色管理: ロール管理
角色名称: ロール名
角色编码: ロールコード
角色描述: ロール説明
权限: 権限
新增角色: ロール追加
编辑角色: ロール編集
删除角色: ロール削除
分配权限: 権限割り当て
机构管理: 組織管理
机构名称: 組織名
机构编码: 組織コード
机构类型: 組織タイプ
上级机构: 上位組織
新增机构: 組織追加
编辑机构: 組織編集
删除机构: 組織削除
菜单管理: メニュー管理
菜单名称: メニュー名
菜单路径: メニューパス
菜单图标: メニューアイコン
菜单排序: メニュー並び順
上级菜单: 上位メニュー
新增菜单: メニュー追加
编辑菜单: メニュー編集
删除菜单: メニュー削除
权限管理: 権限管理
权限名称: 権限名
权限编码: 権限コード
权限类型: 権限タイプ
资源权限: リソース権限
操作权限: 操作権限
数据权限: データ権限
登录日志: ログインログ
登录时间: ログイン時間
登录IP: ログインIP
登录状态: ログインステータス
成功: 成功
失败: 失敗
操作日志: 操作ログ
操作类型: 操作タイプ
操作时间: 操作時間
操作人: 操作者
操作结果: 操作結果
API密钥管理: APIキー管理
密钥名称: キー名
密钥值: キー値
密钥状态: キーステータス
新增密钥: キー追加
编辑密钥: キー編集
删除密钥: キー削除
在线用户: オンラインユーザー
会话ID: セッションID
最后活动时间: 最終アクティビティ時間
强制下线: 強制ログアウト
安全设置: セキュリティ設定
密码策略: パスワードポリシー
密码长度: パスワード長さ
密码复杂度: パスワード複雑度
密码有效期: パスワード有効期限
登录失败锁定: ログイン失敗ロック
最大失败次数: 最大失敗回数
锁定时长: ロック期間
双因素认证: 二要素認証
启用双因素: 二要素認証を有効化
验证码: 認証コード
Token管理: トークン管理
Token名称: トークン名
Token过期时间: トークン有効期限
新增Token: トークン追加
编辑Token: トークン編集
删除Token: トークン削除
刷新Token: トークン更新
用户组: ユーザーグループ
组名称: グループ名
组描述: グループ説明
新增用户组: ユーザーグループ追加
编辑用户组: ユーザーグループ編集
删除用户组: ユーザーグループ削除
成员管理: メンバー管理
添加成员: メンバー追加
移除成员: メンバー削除
搜索: 検索
名称或编码: 名前またはコード
操作: 操作
描述: 説明
类型: タイプ
名称: 名前
编码: コード
备注: 備考
创建时间: 作成日時
更新时间: 更新日時
新增: 追加
保存: 保存
取消: キャンセル
确认: 確認
删除: 削除
编辑: 編集
查看: 表示
导出: エクスポート
打印: 印刷
刷新: 更新
返回: 戻る
提交: 送信
重置: リセット
Conform: 確認
Discard: 破棄
Submit: 送信
Reset: リセット
Cancel: キャンセル
全部: すべて
日期: 日付
管理员: 管理者
普通用户: 一般ユーザー
只读用户: 読み取り専用ユーザー
访问控制: アクセス制御
最后登录: 最終ログイン
清除登录次数: ログイン失敗回数をクリア
新密码: 新しいパスワード
确认清除该用户的连续登录失败次数?: このユーザーの連続ログイン失敗回数をクリアしますか?
为选中用户设置新密码(至少8位): 選択したユーザーに新しいパスワード(8文字以上)を設定

View File

@ -1,137 +0,0 @@
用户管理: 사용자 관리
用户名: 사용자명
密码: 비밀번호
确认密码: 비밀번호 확인
姓名: 이름
邮箱: 이메일
手机号: 전화번호
所属机构: 소속 기관
角色: 역할
状态: 상태
启用: 활성화
停用: 비활성화
新增用户: 사용자 추가
编辑用户: 사용자 편집
删除用户: 사용자 삭제
重置密码: 비밀번호 재설정
角色管理: 역할 관리
角色名称: 역할명
角色编码: 역할 코드
角色描述: 역할 설명
权限: 권한
新增角色: 역할 추가
编辑角色: 역할 편집
删除角色: 역할 삭제
分配权限: 권한 할당
机构管理: 기관 관리
机构名称: 기관명
机构编码: 기관 코드
机构类型: 기관 유형
上级机构: 상위 기관
新增机构: 기관 추가
编辑机构: 기관 편집
删除机构: 기관 삭제
菜单管理: 메뉴 관리
菜单名称: 메뉴명
菜单路径: 메뉴 경로
菜单图标: 메뉴 아이콘
菜单排序: 메뉴 정렬
上级菜单: 상위 메뉴
新增菜单: 메뉴 추가
编辑菜单: 메뉴 편집
删除菜单: 메뉴 삭제
权限管理: 권한 관리
权限名称: 권한명
权限编码: 권한 코드
权限类型: 권한 유형
资源权限: 리소스 권한
操作权限: 작업 권한
数据权限: 데이터 권한
登录日志: 로그인 로그
登录时间: 로그인 시간
登录IP: 로그인 IP
登录状态: 로그인 상태
成功: 성공
失败: 실패
操作日志: 작업 로그
操作类型: 작업 유형
操作时间: 작업 시간
操作人: 작업자
操作结果: 작업 결과
API密钥管理: API 키 관리
密钥名称: 키 이름
密钥值: 키 값
密钥状态: 키 상태
新增密钥: 키 추가
编辑密钥: 키 편집
删除密钥: 키 삭제
在线用户: 온라인 사용자
会话ID: 세션 ID
最后活动时间: 마지막 활동 시간
强制下线: 강제 로그아웃
安全设置: 보안 설정
密码策略: 비밀번호 정책
密码长度: 비밀번호 길이
密码复杂度: 비밀번호 복잡도
密码有效期: 비밀번호 유효기간
登录失败锁定: 로그인 실패 잠금
最大失败次数: 최대 실패 횟수
锁定时长: 잠금 시간
双因素认证: 이중 인증
启用双因素: 이중 인증 활성화
验证码: 인증 코드
Token管理: 토큰 관리
Token名称: 토큰 이름
Token过期时间: 토큰 만료 시간
新增Token: 토큰 추가
编辑Token: 토큰 편집
删除Token: 토큰 삭제
刷新Token: 토큰 갱신
用户组: 사용자 그룹
组名称: 그룹 이름
组描述: 그룹 설명
新增用户组: 사용자 그룹 추가
编辑用户组: 사용자 그룹 편집
删除用户组: 사용자 그룹 삭제
成员管理: 멤버 관리
添加成员: 멤버 추가
移除成员: 멤버 제거
搜索: 검색
名称或编码: 이름 또는 코드
操作: 작업
描述: 설명
类型: 유형
名称: 이름
编码: 코드
备注: 비고
创建时间: 생성 시간
更新时间: 업데이트 시간
新增: 추가
保存: 저장
取消: 취소
确认: 확인
删除: 삭제
编辑: 편집
查看: 보기
导出: 내보내기
打印: 인쇄
刷新: 새로고침
返回: 뒤로
提交: 제출
重置: 초기화
Conform: 확인
Discard: 폐기
Submit: 제출
Reset: 초기화
Cancel: 취소
全部: 전체
日期: 날짜
管理员: 관리자
普通用户: 일반 사용자
只读用户: 읽기 전용 사용자
访问控制: 접근 제어
最后登录: 마지막 로그인
清除登录次数: 로그인 실패 횟수 초기화
新密码: 새 비밀번호
确认清除该用户的连续登录失败次数?: 이 사용자의 연속 로그인 실패 횟수를 초기화하시겠습니까?
为选中用户设置新密码(至少8位): 선택한 사용자에게 새 비밀번호(8자 이상) 설정

View File

@ -1,143 +0,0 @@
Account Locked: Account Locked
Account locked due to too many failed login attempts. Please try again in 5 minutes.: Account locked due to too many failed login attempts. Please try again in 5 minutes.
Add Error: Add Error
Add Success: Add Success
Add reseller Error: Add reseller Error
Cancel: Cancel
Conform: Conform
Discard: Discard
Error: Error
Login: Login
Login Error: Login Error
Reset: Reset
Reset Password: Reset Password
Submit: Submit
Success: Success
apikey: apikey
dappid参数必填: dappid参数必填
failed: failed
id: id
id/orgid必填: id/orgid必填
logout success: logout success
no user selected: no user selected
ok: ok
system error: system error
user disabled: user disabled
user enabled: user enabled
user name or password error: user name or password error
user register: user register
user.id和user.orgid必填: user.id和user.orgid必填
users必填: users必填
you are not owner user: you are not owner user
 角色:  角色
两次输入的密码不一致: 两次输入的密码不一致
个人信息已更新: 个人信息已更新
主营业务描述: 主营业务描述
保存成功: 保存成功
允许IP集: 允许IP集
刷新页面: 刷新页面
发送验证码: 发送验证码
发送验证码出错,请检查短信模板配置和百度API连接: 发送验证码出错,请检查短信模板配置和百度API连接
同步应用id: 同步应用id
名称: 名称
启用日期: 启用日期
图标: 图标
地址: 地址
增加管理员: 增加管理员
失效日期: 失效日期
完善个人信息: 完善个人信息
完善信息: 完善信息
审计日志: 审计日志
密码: 密码
密码登录: 密码登录
应用名称: 应用名称
我的角色: 我的角色
所在地区id: 所在地区id
所在城市id: 所在城市id
所在省id: 所在省id
所属机构: 所属机构
手机: 手机
手机号: 手机号
手机号需短信验证后方可注册: 手机号需短信验证后方可注册
手机登录: 手机登录
手机短信验证码出错: 手机短信验证码出错
扫描未授权文件: 扫描未授权文件
扫码: 扫码
描述: 描述
是否审计: 是否审计
显示名: 显示名
最后登录: 最后登录
最后登录失败时间: 最后登录失败时间
未注册的手机号将自动创建账号: 未注册的手机号将自动创建账号
机构: 机构
机构id: 机构id
机构别名: 机构别名
机构名称: 机构名称
机构拥有角色: 机构拥有角色
机构简称: 机构简称
机构类型: 机构类型
机构编码: 机构编码
权限: 权限
权限id: 权限id
查询路径权限角色: 查询路径权限角色
欢迎登录: 欢迎登录
没有收到手机号: 没有收到手机号
注册失败: 注册失败
注册成功: 注册成功
注册日期: 注册日期
注册账号: 注册账号
父机构id: 父机构id
父权限id: 父权限id
用户: 用户
用户id: 用户id
用户名: 用户名
用户名已被占用: 用户名已被占用
用户应用: 用户应用
用户状态: 用户状态
用户管理: 用户管理
用户角色: 用户角色
用户部门表: 用户部门表
登录成功: 登录成功
短信码已生成: 短信码已生成
短信验证码: 短信验证码
短信验证码错误或已过期,请重新获取: 短信验证码错误或已过期,请重新获取
确认密码: 确认密码
签退: 签退
类型: 类型
系统错误: 系统错误
系统错误,请稍后重试: 系统错误,请稍后重试
组织结构代码: 组织结构代码
网站域名: 网站域名
联系人: 联系人
联系人电话: 联系人电话
营业执照: 营业执照
角色id: 角色id
角色名称: 角色名称
角色权限表: 角色权限表
该手机号已注册,请直接登录: 该手机号已注册,请直接登录
请先发送并输入短信验证码: 请先发送并输入短信验证码
请输入密码: 请输入密码
请输入手机号: 请输入手机号
请输入用户名: 请输入用户名
调用参数: 调用参数
调用日期: 调用日期
调用时间戳: 调用时间戳
账号: 账号
路径: 路径
身份证: 身份证
远程IP: 远程IP
连续失败次数: 连续失败次数
邮件地址: 邮件地址
邮箱: 邮箱
部门id: 部门id
重置密码: 重置密码
错误: 错误
需要短信验证key: 需要短信验证key
需输入手机号: 需输入手机号
需输入验证码: 需输入验证码
验证失败: 验证失败
验证码: 验证码
清除登录次数: 清除登录次数
新密码: 新密码
确认清除该用户的连续登录失败次数?: 确认清除该用户的连续登录失败次数?
为选中用户设置新密码(至少8位): 为选中用户设置新密码(至少8位)

View File

@ -1,23 +0,0 @@
{
"appcodes": [
{
"id": "user_status",
"name": "用户状态",
"hierarchy_flg": "0"
}
],
"appcodes_kv": [
{
"id": "user_status_0",
"parentid": "user_status",
"k": "0",
"v": "可用"
},
{
"id": "user_status_1",
"parentid": "user_status",
"k": "1",
"v": "禁用"
}
]
}

View File

@ -11,15 +11,11 @@
"alters":{} "alters":{}
}, },
"edit_exclouded_fields":[], "edit_exclouded_fields":[],
"parentField":"parentid" "parentField":"parentid",
}, "toolbar":{
"subtables":[ },
{ "binds":[
"field":"permid", ]
"title":"权限角色", }
"subtable":"rolepermission"
}
]
} }

16
json/userapp.json Normal file
View File

@ -0,0 +1,16 @@
{
"tblname": "userapp",
"title":"用户",
"params": {
"confidential_fields":["apikey"],
"sortby":"appname",
"browserfields": {
"exclouded": ["id", "userid"],
"alters": {}
},
"editexclouded": [
"id", "userid"
],
"record_toolbar": null
}
}

14
json/userdepartment.json Normal file
View File

@ -0,0 +1,14 @@
{
"tblname": "userdepartment",
"title":"用户",
"params": {
"browserfields": {
"exclouded": ["id", "userid"],
"cwidth": {}
},
"editexclouded": [
"id", "userid"
],
"record_toolbar": null
}
}

View File

@ -4,14 +4,7 @@
"params": { "params": {
"browserfields": { "browserfields": {
"exclouded": ["id", "orgid"], "exclouded": ["id", "orgid"],
"alters": { "alters": {}
"roleid": {
"uitype": "code",
"dataurl": "{{entire_url('/rbac/api/get_search_roleid.dspy')}}",
"valueField": "roleid",
"textField": "roleid_text"
}
}
}, },
"editexclouded": [ "editexclouded": [
"id", "id",

View File

@ -2,8 +2,6 @@
"tblname": "users", "tblname": "users",
"title":"用户", "title":"用户",
"params": { "params": {
"new_data_url": "{{entire_url('/rbac/api/add_user.dspy')}}",
"update_data_url": "{{entire_url('/rbac/api/update_user.dspy')}}",
"sortby":"username", "sortby":"username",
"confidential_fields":["password"], "confidential_fields":["password"],
"logined_userorgid":"orgid", "logined_userorgid":"orgid",
@ -11,106 +9,19 @@
"exclouded": ["id", "password", "orgid", "nick_name" ], "exclouded": ["id", "password", "orgid", "nick_name" ],
"cwidth": {} "cwidth": {}
}, },
"editexclouded": ["id", "nick_name", "orgid", "last_login_fail", "last_login", "sync_from", "login_fail_count", "created_at"], "editexclouded": [
"toolbar": { "id", "nick_name", "orgid"
"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",
"label": "启用",
"actiontype": "dspy",
"url": "/rbac/users/enable_user.dspy",
"options": {
"icon": "check",
"cwidth": 16,
"cheight": 9
}
},
{
"name": "disable_user",
"label": "禁用",
"actiontype": "dspy",
"url": "/rbac/users/disable_user.dspy",
"options": {
"icon": "block",
"cwidth": 16,
"cheight": 9
}
}
],
"binds": [
{
"wid": "self",
"event": "enable_user",
"actiontype": "script",
"target": "self",
"script": "var tid=bricks.getWidgetById('users_tbl');var d=tid.select_row.user_data;fetch('/rbac/users/enable_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": "disable_user",
"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": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "重置密码",
"resizable": true,
"height": "60%",
"width": "40%"
},
"options": {
"method": "POST",
"params": {},
"url": "{{entire_url('/rbac/admin_reset_password.ui')}}"
}
},
{
"wid": "self",
"event": "clear_login_fail",
"actiontype": "urlwidget",
"target": "self",
"conform": {
"title": "清除登录次数",
"message": "确认清除该用户的连续登录失败次数?"
},
"options": {
"method": "POST",
"params": {},
"url": "{{entire_url('/rbac/api/clear_login_fail.dspy')}}"
},
"params_mapping": {
"mapping": {
"id": "id"
},
"need_others": false
}
}
], ],
"subtables": [ "subtables": [
{ {
"field":"userid", "field":"userid",
"title":"用户角色", "title":"用户角色",
"subtable":"userrole" "subtable":"userrole"
},
{
"field":"userid",
"title":"APIKEY",
"subtable":"userapp"
} }
] ]
} }

View File

@ -1,20 +0,0 @@
-- RBAC users 表结构修正:对齐 models/users.json 定义
-- 修复早期手工建表导致的类型/约束错误。
-- 实测 pipeline 库 users 表 last_login_fail 为 NOT NULL DEFAULT current_timestamp(),
-- 导致 up_login.dspy 成功登录时 SET last_login_fail=NULL 被 MySQL 转成当前时间戳。
-- 对齐后:last_login_fail/last_login 可空,NULL=从未失败。
ALTER TABLE users MODIFY created_at date DEFAULT NULL COMMENT '注册日期';
ALTER TABLE users MODIFY last_login timestamp NULL DEFAULT NULL COMMENT '最后登录时间';
ALTER TABLE users MODIFY login_fail_count smallint NOT NULL DEFAULT 0 COMMENT '连续登录失败次数';
ALTER TABLE users MODIFY last_login_fail timestamp NULL DEFAULT NULL COMMENT '最后登录失败时间';
-- sync_from 列幂等补加(MySQL 8.0 无 ADD COLUMN IF NOT EXISTS)
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'sync_from');
SET @sql = IF(@col = 0,
'ALTER TABLE users ADD COLUMN sync_from varchar(32) DEFAULT NULL COMMENT ''同步应用id''',
'SELECT ''sync_from already exists''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -1,52 +0,0 @@
-- ================================================================
-- RBAC users 表结构对齐(生产执行,幂等可重复跑)
-- 依赖功能:登录审计(login/login_fail) + 账户锁定(5分钟失败窗口计数)
-- 对齐 models/users.json 定义
-- 用法:先 USE 到 users 表所在库(Sage 生产为 sage 库)再执行
-- ================================================================
-- 1) created_at 注册日期(date 可空)
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='created_at');
SET @sql = IF(@col=0,
'ALTER TABLE users ADD COLUMN created_at date DEFAULT NULL COMMENT ''注册日期''',
'ALTER TABLE users MODIFY created_at date DEFAULT NULL COMMENT ''注册日期''');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 2) last_login 最后登录时间(timestamp 可空)
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='last_login');
SET @sql = IF(@col=0,
'ALTER TABLE users ADD COLUMN last_login timestamp NULL DEFAULT NULL COMMENT ''最后登录时间''',
'ALTER TABLE users MODIFY last_login timestamp NULL DEFAULT NULL COMMENT ''最后登录时间''');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 3) login_fail_count 连续登录失败次数(smallint NOT NULL DEFAULT 0)
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='login_fail_count');
SET @sql = IF(@col=0,
'ALTER TABLE users ADD COLUMN login_fail_count smallint NOT NULL DEFAULT 0 COMMENT ''连续登录失败次数''',
'ALTER TABLE users MODIFY login_fail_count smallint NOT NULL DEFAULT 0 COMMENT ''连续登录失败次数''');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 4) last_login_fail 最后登录失败时间(关键修复:必须 NULL 可空)
-- 早期手工建表为 NOT NULL DEFAULT current_timestamp(),
-- 成功登录时 SET last_login_fail=NULL 会被 MySQL 静默转成当前时间戳,
-- 导致 5 分钟失败窗口的 IS NULL 判定永远失效,账户锁定逻辑错误。
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='last_login_fail');
SET @sql = IF(@col=0,
'ALTER TABLE users ADD COLUMN last_login_fail timestamp NULL DEFAULT NULL COMMENT ''最后登录失败时间''',
'ALTER TABLE users MODIFY last_login_fail timestamp NULL DEFAULT NULL COMMENT ''最后登录失败时间''');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 5) sync_from 同步应用id(仅补加,已存在则跳过)
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='sync_from');
SET @sql = IF(@col=0,
'ALTER TABLE users ADD COLUMN sync_from varchar(32) DEFAULT NULL COMMENT ''同步应用id''',
'SELECT ''sync_from already exists''');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 验证结果(应显示 5 行,last_login_fail 的 Null 列为 YES)
SHOW COLUMNS FROM users WHERE Field IN ('created_at','last_login','login_fail_count','last_login_fail','sync_from');

55
models/userapp.json Normal file
View File

@ -0,0 +1,55 @@
{
"summary": [
{
"name": "userapp",
"title": "用户应用",
"primary": [
"id"
],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "id",
"type": "str",
"length": 32
},
{
"name": "userid",
"title": "用户id",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "appname",
"title": "应用名称",
"type": "str",
"length": 99
},
{
"name": "apikey",
"title": "apikey",
"type": "str",
"length": 400
},
{
"name": "enabled_date",
"title": "启用日期",
"type": "date"
},
{
"name": "expired_date",
"title": "失效日期",
"type": "date"
},
{
"name": "allowed_ips",
"title": "允许IP集",
"type": "str",
"length": 200
}
]
}

BIN
models/userapp.xlsx Normal file

Binary file not shown.

View File

@ -0,0 +1,31 @@
{
"summary": [
{
"name": "userdepartment",
"title": "用户部门表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "id",
"type": "str",
"length": 32
},
{
"name": "userid",
"title": "用户id",
"type": "str",
"length": 32
},
{
"name": "depid",
"title": "部门id",
"type": "str",
"length": 32
}
]
}

BIN
models/userdepartment.xlsx Normal file

Binary file not shown.

View File

@ -67,7 +67,7 @@
{ {
"name": "created_at", "name": "created_at",
"title": "注册日期", "title": "注册日期",
"type": "date" "type": "timestamp"
}, },
{ {
"name": "last_login", "name": "last_login",

View File

@ -1,7 +1,7 @@
import time import time
from traceback import format_exc from traceback import format_exc
from datetime import datetime, timedelta from datetime import datetime
from aiohttp import BasicAuth from aiohttp import BasicAuth
from sqlor.dbpools import DBPools, get_sor_context from sqlor.dbpools import DBPools, get_sor_context
from appPublic.registerfunction import RegisterFunction from appPublic.registerfunction import RegisterFunction
@ -55,8 +55,6 @@ async def sor_get_org_users(sor, orgid):
return [] return []
async def create_org(sor, ns, orgtypes=[]): async def create_org(sor, ns, orgtypes=[]):
if not ns.get('orgname'):
ns.orgname = ns.id # fallback: use id as orgname
await sor.C('organization', ns) await sor.C('organization', ns)
if orgtypes == []: if orgtypes == []:
orgtypes = ['customer'] orgtypes = ['customer']
@ -100,20 +98,6 @@ async def create_user(sor, ns, roles=[]):
}) })
async def register_user(sor, ns): async def register_user(sor, ns):
# 注册开关检查:appbase params 表 register_open(1=开放,0=关闭),superuser 可在参数管理界面改
try:
pro = await sor.R('params', {'params_name': 'register_open'})
if pro and str(pro[0].params_value).strip() == '0':
return {
"status": "error",
"data": {
"message": "注册暂未开放,请联系管理员",
"user": None
}
}
except Exception as e:
debug(f'register_user: register_open check skipped: {e}')
if ns.password != ns.cfm_password: if ns.password != ns.cfm_password:
debug('password not match') debug('password not match')
return False return False
@ -134,16 +118,8 @@ async def register_user(sor, ns):
ns.created_at = timestampstr() ns.created_at = timestampstr()
ns.login_fail_count = 0 ns.login_fail_count = 0
ns1 = DictObject(id=id, orgname=ns.username) ns1 = DictObject(id=id, orgname=ns.username)
if ns.get('parentid'):
ns1.parentid = ns.parentid
await create_org(sor, ns1) await create_org(sor, ns1)
roles = [ await create_user(sor, ns)
{
'orgtypeid': 'customer',
'roles': ['customer', 'admin']
}
]
await create_user(sor, ns, roles)
return { return {
"status": "ok", "status": "ok",
"data": { "data": {
@ -157,30 +133,6 @@ def get_dbname():
return None return None
return f('rbac') 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): async def checkUserPassword(request, username, password):
"""Authenticate user with password, supporting login lockout mechanism. """Authenticate user with password, supporting login lockout mechanism.
@ -197,40 +149,30 @@ async def checkUserPassword(request, username, password):
recs = await sor.sqlExe(sql, {'username': username}) recs = await sor.sqlExe(sql, {'username': username})
if len(recs) < 1: if len(recs) < 1:
debug(f'User {username} not found') debug(f'User {username} not found')
await _audit_login(sor, request, None, username, 'login_fail', 'fail')
return False return False
user = recs[0] user = recs[0]
# Check user status (disabled)
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 fail_count = getattr(user, 'login_fail_count', 0) or 0
last_fail = getattr(user, 'last_login_fail', None) last_fail = getattr(user, 'last_login_fail', None)
# Lockout check in Python (DB-agnostic) # Lockout check in Python (DB-agnostic)
if _is_locked(fail_count, last_fail): if _is_locked(fail_count, last_fail):
debug(f'User {username} locked out') debug(f'User {username} locked out')
await _audit_login(sor, request, user.id, username, 'login_fail', 'fail')
return False return False
# Verify password with standard SQL # Verify password with standard SQL
sql = "select * from users where username=${username}$ and password=${password}$" sql = "select * from users where username=${username}$ and password=${password}$"
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:
# 5分钟窗口:超过5分钟前的失败次数默认为0(原子 SQL,DB 无关) # Atomic increment -- standard SQL, works on all databases
now_str = _now_ts() now_str = _now_ts()
stale_before = (datetime.now() - timedelta(seconds=LOGIN_LOCKOUT_DURATION)).strftime('%Y-%m-%d %H:%M:%S')
await sor.sqlExe(""" await sor.sqlExe("""
UPDATE users 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, SET login_fail_count = login_fail_count + 1,
last_login_fail = ${now}$ last_login_fail = ${now}$
WHERE id = ${id}$ WHERE id = ${id}$
""", {'id': user.id, 'now': now_str, 'stale': stale_before}) """, {'id': user.id, 'now': now_str})
debug(f'Login failed for {username}, fail_count incremented') debug(f'Login failed for {username}, fail_count incremented')
await _audit_login(sor, request, user.id, username, 'login_fail', 'fail')
return False return False
# Login successful -- atomic reset # Login successful -- atomic reset
@ -245,7 +187,6 @@ async def checkUserPassword(request, username, password):
await user_login(request, user.id, await user_login(request, user.id,
username=user.username, username=user.username,
userorgid=user.orgid) userorgid=user.orgid)
await _audit_login(sor, request, user.id, user.username, 'login', 'ok')
return True return True
return False return False
@ -259,21 +200,13 @@ async def basic_auth(sor, request):
sql = "select * from users where username=${username}$ and password=${password}$" sql = "select * from users where username=${username}$ and password=${password}$"
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:
await _audit_login(sor, request, None, username, 'login_fail', 'fail')
return None return None
# Check lockout in Python layer (DB-agnostic) # Check lockout in Python layer (DB-agnostic)
user = recs[0] user = recs[0]
# Check user status (disabled)
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 fail_count = getattr(user, 'login_fail_count', 0) or 0
last_fail = getattr(user, 'last_login_fail', None) last_fail = getattr(user, 'last_login_fail', None)
if _is_locked(fail_count, last_fail): if _is_locked(fail_count, last_fail):
debug(f'User {username} locked out via basic auth') debug(f'User {username} locked out via basic auth')
await _audit_login(sor, request, user.id, username, 'login_fail', 'fail')
return None return None
# Update last_login on successful basic auth (standard SQL) # Update last_login on successful basic auth (standard SQL)
now_str = _now_ts() now_str = _now_ts()
@ -284,9 +217,8 @@ async def basic_auth(sor, request):
WHERE id = ${id}$ WHERE id = ${id}$
""", {'id': recs[0].id, 'now': now_str}) """, {'id': recs[0].id, 'now': now_str})
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)
await _audit_login(sor, request, recs[0].id, recs[0].username, 'login', 'ok')
return recs[0].id return recs[0].id
async def getAuthenticationUserid(sor, request): async def getAuthenticationUserid(sor, request):

View File

@ -1,84 +1,32 @@
from ahserver.auth_api import AuthAPI from ahserver.auth_api import AuthAPI
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
from sqlor.dbpools import DBPools
from .orgs import ( from .orgs import (
get_platform_providers get_platform_providers
) )
from .userperm import UserPermissions from .userperm import UserPermissions
from .user_stats import get_user_stats
from .rbac_tools import (
query_path_roles,
scan_unauth_files
)
from rbac.check_perm import ( from rbac.check_perm import (
objcheckperm, objcheckperm,
get_org_users, get_org_users,
sor_get_org_users, sor_get_org_users,
checkUserPassword, checkUserPassword,
register_user, register_user,
register_auth_method, register_auth_method,
create_org, create_org,
create_user create_user
) )
from rbac.set_role_perms import ( from rbac.set_role_perms import (
sor_add_user_roles, sor_add_user_roles,
set_role_perm, set_role_perm,
set_role_perms set_role_perms
) )
from sqlor.dbpools import DBPools from appPublic.log import debug
from ahserver.cache_sync import get_cache_sync
def _get_rbac_dbname():
env = ServerEnv()
return env.get_module_dbname('rbac')
async def on_rbac_role_event(data):
"""role 表变更后,全量失效 rp_caches"""
up = ServerEnv().userpermissions
up.invalidate_rp_cache()
async def on_rbac_userrole_event(data):
"""userrole 表变更后,精确失效对应用户的 ur_caches"""
ns = data.get('ns', {})
userid = ns.get('userid')
up = ServerEnv().userpermissions
if userid:
up.invalidate_user_cache(userid)
else:
up.invalidate_all_user_caches()
async def on_rbac_permission_event(data):
"""permission 表变更后,全量失效 rp_caches"""
up = ServerEnv().userpermissions
up.invalidate_rp_cache()
async def on_rbac_rolepermission_event(data):
"""rolepermission 表变更后,全量失效 rp_caches"""
up = ServerEnv().userpermissions
up.invalidate_rp_cache()
def register_rbac_event_listeners():
db = DBPools()
dbname = _get_rbac_dbname()
# role 表
db.bind(f'{dbname}:role:c:after', on_rbac_role_event)
db.bind(f'{dbname}:role:u:after', on_rbac_role_event)
db.bind(f'{dbname}:role:d:after', on_rbac_role_event)
# userrole 表
db.bind(f'{dbname}:userrole:c:after', on_rbac_userrole_event)
db.bind(f'{dbname}:userrole:u:after', on_rbac_userrole_event)
db.bind(f'{dbname}:userrole:d:after', on_rbac_userrole_event)
# permission 表
db.bind(f'{dbname}:permission:c:after', on_rbac_permission_event)
db.bind(f'{dbname}:permission:u:after', on_rbac_permission_event)
db.bind(f'{dbname}:permission:d:after', on_rbac_permission_event)
# rolepermission 表
db.bind(f'{dbname}:rolepermission:c:after', on_rbac_rolepermission_event)
db.bind(f'{dbname}:rolepermission:u:after', on_rbac_rolepermission_event)
db.bind(f'{dbname}:rolepermission:d:after', on_rbac_rolepermission_event)
async def get_owner_orgid(*args, **kw): async def get_owner_orgid(*args, **kw):
return '0' return '0'
@ -86,6 +34,58 @@ async def get_owner_orgid(*args, **kw):
async def sor_get_owner_orgid(sor, orgid): async def sor_get_owner_orgid(sor, orgid):
return '0' return '0'
def _bind_rbac_events(dbpools, dbname, up):
"""Bind database events to RBAC cache invalidation handlers.
Events are dispatched by sqlor after C/U/D operations.
Format: {dbname}:{tablename}:{c|u|d}:after
"""
bindings = [
# users table: invalidate specific user cache on C/U/D
(f'{dbname}.users:c:after', up.on_user_create),
(f'{dbname}.users:u:after', up.on_user_update),
(f'{dbname}.users:d:after', up.on_user_delete),
# rolepermission table: invalidate role-permission cache on any change
(f'{dbname}.rolepermission:c:after', up.on_rolepermission_change),
(f'{dbname}.rolepermission:u:after', up.on_rolepermission_change),
(f'{dbname}.rolepermission:d:after', up.on_rolepermission_change),
# permission table: invalidate role-permission cache on update
(f'{dbname}.permission:u:after', up.on_permission_change),
# role table: invalidate ALL caches (affects all users)
(f'{dbname}.role:c:after', up.on_role_change),
(f'{dbname}.role:u:after', up.on_role_change),
(f'{dbname}.role:d:after', up.on_role_change),
# userrole table: invalidate specific user cache based on userid
(f'{dbname}.userrole:c:after', up.on_userrole_change),
(f'{dbname}.userrole:u:after', up.on_userrole_change),
(f'{dbname}.userrole:d:after', up.on_userrole_change),
]
for event_name, handler in bindings:
dbpools.bind(event_name, handler)
debug(f'RBAC event bound: {event_name}')
async def start_cache_sync():
"""Start cache_sync and register RBAC reload callbacks."""
env = ServerEnv()
cache_sync = get_cache_sync()
# Get Redis URL from session config
try:
redis_url = env.conf.website.session_redis.url
except AttributeError:
redis_url = "redis://127.0.0.1:6379"
await cache_sync.start(redis_url)
debug(f'RBAC cache_sync started with Redis URL: {redis_url}')
# Register callbacks for cache invalidation messages from other processes
up = env.userpermissions
cache_sync.register('rbac:rp', up.invalidate_rp_cache)
cache_sync.register('rbac:ur:all', up.invalidate_all_user_caches)
# Note: rbac:ur:{userid} callbacks are handled by the invalidate_user_cache method itself
def load_rbac(): def load_rbac():
AuthAPI.checkUserPermission = objcheckperm AuthAPI.checkUserPermission = objcheckperm
env = ServerEnv() env = ServerEnv()
@ -103,11 +103,19 @@ 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
env.get_user_stats = get_user_stats
env.query_path_roles = query_path_roles
env.scan_unauth_files = scan_unauth_files
# Cache invalidation methods for use after role/permission changes # Cache invalidation methods for use after role/permission changes
env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache
env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches
env.invalidate_role_perm_cache = env.userpermissions.invalidate_rp_cache env.invalidate_role_perm_cache = env.userpermissions.invalidate_rp_cache
# Bind hot_reload event — instance method, WeakMethod safe (stored on env)
if hasattr(env, 'event_dispatcher'): # Bind database events for automatic cache invalidation
env.event_dispatcher.bind('hot_reload', env.userpermissions.on_hot_reload) dbpools = DBPools()
register_rbac_event_listeners() dbname = env.get_module_dbname('rbac')
if dbname:
_bind_rbac_events(dbpools, dbname, env.userpermissions)
debug(f'RBAC event listeners bound for database: {dbname}')
else:
debug('RBAC event listeners skipped: no database configured for rbac module')

View File

@ -114,6 +114,31 @@ async def set_role_perms(dbname, module, orgtype, role, items):
for tblname in items: for tblname in items:
await set_role_perm(dbname, module, orgtype, role, tblname) await set_role_perm(dbname, module, orgtype, role, tblname)
async def send_rbac_invalidation():
"""Send cache invalidation message to all processes via Redis Pub/Sub."""
try:
from ahserver.cache_sync import get_cache_sync
cache_sync = get_cache_sync()
# Use default Redis URL for CLI scripts
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
redis_url = env.conf.website.session_redis.url
except (AttributeError, Exception):
redis_url = "redis://127.0.0.1:6379"
await cache_sync.start(redis_url)
# Invalidate both role-permission and all user caches
# (CLI scripts typically change permissions/roles)
await cache_sync.invalidate('rbac:rp')
await cache_sync.invalidate('rbac:ur:all')
debug('RBAC CLI: sent cache invalidation messages')
# Give a moment for the message to be published
await asyncio.sleep(0.1)
await cache_sync.stop()
except Exception as e:
print(f'Warning: Failed to send cache invalidation: {e}')
if __name__ == '__main__': if __name__ == '__main__':
async def main(): async def main():
if len(sys.argv) < 6: if len(sys.argv) < 6:
@ -124,6 +149,8 @@ if __name__ == '__main__':
orgtype = sys.argv[3] orgtype = sys.argv[3]
role = sys.argv[4] role = sys.argv[4]
await set_role_perms(dbname, module, orgtype, role, sys.argv[5:]) await set_role_perms(dbname, module, orgtype, role, sys.argv[5:])
# Send invalidation message to all running Sage processes
await send_rbac_invalidation()
def run(coro): def run(coro):
p = '.' p = '.'

View File

@ -4,21 +4,9 @@ from sqlor.dbpools import 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, error from appPublic.log import debug, error
from appPublic.jsonConfig import getConfig from ahserver.cache_sync import get_cache_sync
from appPublic.share_cache import cache_get, cache_set, cache_invalidate
def _cache_enabled(module_name='rbac'):
"""Check if cache is enabled for the given module in config.json"""
try:
config = getConfig()
module_cache = config.module_cache
if module_cache is None:
return True # Default to enabled if not configured
return getattr(module_cache, module_name, True)
except Exception:
return True # Default to enabled on error
class LRUCache: class LRUCache:
"""Async-safe LRU cache with TTL support. """Async-safe LRU cache with TTL support.
@ -39,8 +27,6 @@ class LRUCache:
def get(self, key): def get(self, key):
import time import time
if not _cache_enabled('rbac'):
return None
if key not in self._cache: if key not in self._cache:
return None return None
value, expire_at = self._cache[key] value, expire_at = self._cache[key]
@ -52,8 +38,6 @@ class LRUCache:
def set(self, key, value): def set(self, key, value):
import time import time
if not _cache_enabled('rbac'):
return
if key in self._cache: if key in self._cache:
self._cache.move_to_end(key) self._cache.move_to_end(key)
self._cache[key] = (value, time.time() + self.ttl) self._cache[key] = (value, time.time() + self.ttl)
@ -99,96 +83,82 @@ class UserPermissions:
# Async lock for rp_caches initialization (lazy init) # Async lock for rp_caches initialization (lazy init)
self._rp_lock = None self._rp_lock = None
def on_hot_reload(self, data=None): async def on_user_update(self, data):
"""Event handler for hot_reload event. Clears all caches."""
from appPublic.log import debug
debug(f'[rbac] on_hot_reload called, clearing caches (data={data})')
self.ur_caches.clear()
self.invalidate_rp_cache()
def on_user_update(self, data):
"""Event handler for users table update. """Event handler for users table update.
Clears the specific user's permission cache. Clears the specific user's permission cache.
""" """
try: try:
userid = getattr(data, 'id', None) userid = getattr(data, 'id', None)
if userid: if userid:
self.invalidate_user_cache(userid) await self.invalidate_user_cache(userid)
asyncio.ensure_future(cache_invalidate('rbac', f'user_roles:{userid}'))
debug(f'RBAC cache invalidated for user id={userid} (users update)') debug(f'RBAC cache invalidated for user id={userid} (users update)')
except Exception as e: except Exception as e:
error(f'RBAC on_user_update handler error: {e}') error(f'RBAC on_user_update handler error: {e}')
def on_user_create(self, data): async def on_user_create(self, data):
"""Event handler for users table insert. """Event handler for users table insert.
Clears the specific user's permission cache. Clears the specific user's permission cache.
""" """
try: try:
userid = getattr(data, 'id', None) userid = getattr(data, 'id', None)
if userid: if userid:
self.invalidate_user_cache(userid) await self.invalidate_user_cache(userid)
asyncio.ensure_future(cache_invalidate('rbac', f'user_roles:{userid}'))
debug(f'RBAC cache invalidated for user id={userid} (users create)') debug(f'RBAC cache invalidated for user id={userid} (users create)')
except Exception as e: except Exception as e:
error(f'RBAC on_user_create handler error: {e}') error(f'RBAC on_user_create handler error: {e}')
def on_user_delete(self, data): async def on_user_delete(self, data):
"""Event handler for users table delete. """Event handler for users table delete.
Clears the specific user's permission cache. Clears the specific user's permission cache.
""" """
try: try:
userid = getattr(data, 'id', None) userid = getattr(data, 'id', None)
if userid: if userid:
self.invalidate_user_cache(userid) await self.invalidate_user_cache(userid)
asyncio.ensure_future(cache_invalidate('rbac', f'user_roles:{userid}'))
debug(f'RBAC cache invalidated for user id={userid} (users delete)') debug(f'RBAC cache invalidated for user id={userid} (users delete)')
except Exception as e: except Exception as e:
error(f'RBAC on_user_delete handler error: {e}') error(f'RBAC on_user_delete handler error: {e}')
def on_rolepermission_change(self, data): async def on_rolepermission_change(self, data):
"""Event handler for rolepermission table C/U/D. """Event handler for rolepermission table C/U/D.
Clears the role-permission cache. Clears the role-permission cache.
""" """
try: try:
self.invalidate_rp_cache() await self.invalidate_rp_cache()
asyncio.ensure_future(cache_invalidate('rbac', 'role_perms'))
debug('RBAC role-permission cache invalidated (rolepermission change)') debug('RBAC role-permission cache invalidated (rolepermission change)')
except Exception as e: except Exception as e:
error(f'RBAC on_rolepermission_change handler error: {e}') error(f'RBAC on_rolepermission_change handler error: {e}')
def on_permission_change(self, data): async def on_permission_change(self, data):
"""Event handler for permission table update. """Event handler for permission table update.
Clears the role-permission cache. Clears the role-permission cache.
""" """
try: try:
self.invalidate_rp_cache() await self.invalidate_rp_cache()
asyncio.ensure_future(cache_invalidate('rbac', 'role_perms'))
debug('RBAC role-permission cache invalidated (permission change)') debug('RBAC role-permission cache invalidated (permission change)')
except Exception as e: except Exception as e:
error(f'RBAC on_permission_change handler error: {e}') error(f'RBAC on_permission_change handler error: {e}')
def on_role_change(self, data): async def on_role_change(self, data):
"""Event handler for role table C/U/D. """Event handler for role table C/U/D.
Clears all user caches and role-permission cache, Clears all user caches and role-permission cache,
since role changes may affect any user. since role changes may affect any user.
""" """
try: try:
self.invalidate_all_user_caches() await self.invalidate_all_user_caches()
self.invalidate_rp_cache() await self.invalidate_rp_cache()
asyncio.ensure_future(cache_invalidate('rbac', 'role_perms'))
debug('RBAC all caches invalidated (role change)') debug('RBAC all caches invalidated (role change)')
except Exception as e: except Exception as e:
error(f'RBAC on_role_change handler error: {e}') error(f'RBAC on_role_change handler error: {e}')
def on_userrole_change(self, data): async def on_userrole_change(self, data):
"""Event handler for userrole table C/U/D. """Event handler for userrole table C/U/D.
Clears the specific user's permission cache based on userid. Clears the specific user's permission cache based on userid.
""" """
try: try:
userid = getattr(data, 'userid', None) userid = getattr(data, 'userid', None)
if userid: if userid:
self.invalidate_user_cache(userid) await self.invalidate_user_cache(userid)
asyncio.ensure_future(cache_invalidate('rbac', f'user_roles:{userid}'))
debug(f'RBAC cache invalidated for user id={userid} (userrole change)') debug(f'RBAC cache invalidated for user id={userid} (userrole change)')
except Exception as e: except Exception as e:
error(f'RBAC on_userrole_change handler error: {e}') error(f'RBAC on_userrole_change handler error: {e}')
@ -208,28 +178,41 @@ class UserPermissions:
return roles return roles
async with get_sor_context(ServerEnv(), 'rbac') as sor: async with get_sor_context(ServerEnv(), 'rbac') as sor:
roles = await self.get_userroles(sor, userid) await self.get_userroles(sor, userid)
# When cache is enabled, get_userroles stored it and we can read back;
# when cache is disabled, get_userroles returns the list directly.
if roles is not None:
return roles
return self.ur_caches.get(userid) return self.ur_caches.get(userid)
return ['any', 'logined'] return None
def invalidate_user_cache(self, userid): async def invalidate_user_cache(self, userid):
"""Invalidate cache for a specific user. """Invalidate cache for a specific user.
Call this after role changes, user creation, etc. Call this after role changes, user creation, etc.
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
""" """
self.ur_caches.invalidate(userid) self.ur_caches.invalidate(userid)
# Broadcast to other processes
cache_sync = get_cache_sync()
if cache_sync.is_running:
await cache_sync.invalidate(f'rbac:ur:{userid}')
def invalidate_all_user_caches(self): async def invalidate_all_user_caches(self):
"""Invalidate all user role caches.""" """Invalidate all user role caches.
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
"""
self.ur_caches.clear() self.ur_caches.clear()
# Broadcast to other processes
cache_sync = get_cache_sync()
if cache_sync.is_running:
await cache_sync.invalidate('rbac:ur:all')
def invalidate_rp_cache(self): async def invalidate_rp_cache(self):
"""Invalidate role-permission cache (after permission changes).""" """Invalidate role-permission cache (after permission changes).
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
"""
self.rp_caches = None self.rp_caches = None
self.rp_cache_loaded_at = 0 self.rp_cache_loaded_at = 0
# Broadcast to other processes
cache_sync = get_cache_sync()
if cache_sync.is_running:
await cache_sync.invalidate('rbac:rp')
async def load_roleperms(self, sor): async def load_roleperms(self, sor):
"""Load all role-permission mappings into cache. """Load all role-permission mappings into cache.
@ -243,28 +226,22 @@ class UserPermissions:
now = time.time() now = time.time()
# Fast path: cache valid, no lock needed # Fast path: cache valid, no lock needed
if _cache_enabled('rbac') and self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl: if self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl:
return return
# Slow path: acquire lock and double-check # Slow path: acquire lock and double-check
async with self._get_rp_lock(): async with self._get_rp_lock():
# Double-check after lock acquisition # Double-check after lock acquisition
if _cache_enabled('rbac') and self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl: if self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl:
return return
# Build in local dict first, assign atomically when complete. self.rp_caches = {}
# Otherwise other coroutines see {} during the await and get 403.
new_caches = {}
sql_all = """select c.id, c.orgtypeid, c.name, b.path 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, {})
if len(recs) == 0 and self.rp_caches:
# DB returned empty — likely a bad connection. Keep previous valid cache.
debug(f'load_roleperms: got 0 records, keeping previous cache ({sum(len(v) for v in self.rp_caches.values())} paths)')
return
for r in recs: for r in recs:
if r.id == 'anonymous': if r.id == 'anonymous':
k = 'anonymous' k = 'anonymous'
@ -274,22 +251,13 @@ order by c.orgtypeid, c.name"""
k = 'logined' k = 'logined'
else: else:
k = f'{r.orgtypeid}.{r.name}' k = f'{r.orgtypeid}.{r.name}'
arr = new_caches.get(k, []) arr = self.rp_caches.get(k, [])
arr.append(r.path) arr.append(r.path)
new_caches[k] = arr self.rp_caches[k] = arr
# Atomic swap: other coroutines see old cache or fully-loaded new cache, never {}
self.rp_caches = {k: set(v) for k, v in new_caches.items()}
self.rp_cache_loaded_at = now self.rp_cache_loaded_at = now
# Share to Redis for cross-worker reuse
try:
await cache_set("rbac", "role_perms", new_caches, ttl=self.rp_cache_ttl)
except Exception:
pass
async def get_userroles(self, sor, userid): async def get_userroles(self, sor, userid):
"""Load user roles from database and cache them. """Load user roles from database and cache them."""
Returns the roles list directly (needed when cache is disabled).
"""
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
@ -300,22 +268,14 @@ 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}')
roles = sorted(list(set(roles))) self.ur_caches.set(userid, sorted(list(set(roles))))
self.ur_caches.set(userid, roles)
# Share to Redis for cross-worker reuse
try:
await cache_set("rbac", f"user_roles:{userid}", roles, ttl=self.cache_ttl)
except Exception:
pass
return 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. """Check if any of the roles has access to the given path.
Supports: Supports:
- Exact match: '/customer_management/index.ui' or '/main/login.ui' - Exact match: '/customer_management/index.ui' or '/main/login.ui'
- Wildcard prefix match: '/customer_management/**' or '/customer_management/%' - Wildcard prefix match: '/customer_management/**' matches any path starting with '/customer_management/'
matches any path starting with '/customer_management/'
- Path normalization: tries both the raw path and path with /main stripped - Path normalization: tries both the raw path and path with /main stripped
""" """
for role in roles: for role in roles:
@ -332,22 +292,16 @@ where a.id = c.userid
return True return True
# Also try wildcard match with normalized path # Also try wildcard match with normalized path
for perm_path in paths: for perm_path in paths:
prefix = None
if perm_path.endswith('**'): if perm_path.endswith('**'):
prefix = perm_path[:-2] prefix = perm_path[:-2]
elif perm_path.endswith('%'): if normalized.startswith(prefix) or path.startswith(prefix):
prefix = perm_path[:-1] return True
if prefix and (normalized.startswith(prefix) or path.startswith(prefix)):
return True
# Wildcard prefix match with raw path # Wildcard prefix match with raw path
for perm_path in paths: for perm_path in paths:
prefix = None
if perm_path.endswith('**'): if perm_path.endswith('**'):
prefix = perm_path[:-2] prefix = perm_path[:-2]
elif perm_path.endswith('%'): if path.startswith(prefix):
prefix = perm_path[:-1] return True
if prefix and path.startswith(prefix):
return True
return False return False
async def is_user_has_path_perm(self, userid, path): async def is_user_has_path_perm(self, userid, path):
@ -362,32 +316,13 @@ where a.id = c.userid
if userid is None: if userid is None:
roles = ['any', 'anonymous'] roles = ['any', 'anonymous']
# L2: Try shared Redis cache before opening DB connection if self.rp_caches is None or not roles:
if _cache_enabled('rbac'):
if self.rp_caches is None:
cached_rp = await cache_get('rbac', 'role_perms', ttl=self.rp_cache_ttl)
if cached_rp is not None:
self.rp_caches = {k: set(v) for k, v in cached_rp.items()}
self.rp_cache_loaded_at = __import__('time').time()
if not roles and userid is not None:
cached_roles = await cache_get('rbac', f'user_roles:{userid}', ttl=self.cache_ttl)
if cached_roles is not None:
roles = cached_roles
self.ur_caches.set(userid, roles)
if not _cache_enabled('rbac') or 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 _cache_enabled('rbac') or self.rp_caches is None: if self.rp_caches is None:
await self.load_roleperms(sor) await self.load_roleperms(sor)
if not roles: if not roles:
roles = await self.get_userroles(sor, userid) await self.get_userroles(sor, userid)
# When cache is enabled, fall back to cache read roles = self.ur_caches.get(userid)
if roles is None:
roles = self.ur_caches.get(userid)
# Safety fallback: if roles is still None (shouldn't happen), deny access
if not roles:
return False
return self.check_roles_path(roles, path) return self.check_roles_path(roles, path)

View File

@ -70,7 +70,8 @@ type_roles = {
} }
rbac_tables = [ rbac_tables = [
"organization", "orgtypes", "role", "users", "userrole", "rolepermission", "permission" ] "organization", "orgtypes", "role", "users", "userrole", "rolepermission", "permission",
"userapp", "userdepartment" ]
role_perms = { role_perms = {
'superuser': [ 'superuser': [

View File

@ -1,109 +0,0 @@
#!/usr/bin/env python3
"""rbac 模块 RBAC 权限注册。"""
import subprocess
MOD = "rbac"
# any — 无需登录(登录页、注册、验证码、公共资源等)
PATHS_ANY = [
f"/{MOD}/admin_menu.ui",
f"/{MOD}/gen_sms_code.dspy",
f"/{MOD}/login.css",
f"/{MOD}/phone_login.dspy",
f"/{MOD}/qr_scan.ui",
f"/{MOD}/user/code_login.dspy",
f"/{MOD}/user/login.ui",
f"/{MOD}/user/logout.dspy",
f"/{MOD}/user/register.dspy",
f"/{MOD}/user/register.ui",
f"/{MOD}/user/sms_register.dspy",
f"/{MOD}/user/reset_password/index.ui",
f"/{MOD}/user/reset_password/reset_password.dspy",
f"/{MOD}/user/up_login.dspy",
f"/{MOD}/usermenu.ui",
f"/{MOD}/userpassword_login.dspy",
f"/{MOD}/userpassword_login.ui",
# 公共资源
"/favicon.ico",
"/i18n_getmsgs",
]
# logined — 需要认证的页面和 API
PATHS_LOGINED = [
f"/{MOD}",
f"/{MOD}/add_adminuser.dspy",
f"/{MOD}/add_adminuser.ui",
f"/{MOD}/add_provider.dspy",
f"/{MOD}/add_provider.ui",
f"/{MOD}/add_reseller.dspy",
f"/{MOD}/find_unauth_files.dspy",
f"/{MOD}/get_all_roles.dspy",
f"/{MOD}/get_normal_roles.dspy",
f"/{MOD}/get_provider.dspy",
f"/{MOD}/get_reseller.dspy",
f"/{MOD}/index.ui",
f"/{MOD}/list_path_roles.dspy",
f"/{MOD}/list_path_roles.ui",
f"/{MOD}/organization",
f"/{MOD}/orgtypes",
f"/{MOD}/permission",
f"/{MOD}/provider",
f"/{MOD}/refresh_userperm.dspy",
f"/{MOD}/reseller",
f"/{MOD}/role",
f"/{MOD}/rolepermission",
f"/{MOD}/stat_active_users.ui",
f"/{MOD}/stat_total_orgs.ui",
f"/{MOD}/stat_total_users.ui",
f"/{MOD}/user",
f"/{MOD}/user/myrole.ui",
f"/{MOD}/user/user.ui",
f"/{MOD}/user/user_panel.ui",
f"/{MOD}/user/userapikey",
f"/{MOD}/user/userapikey/add_userapikey.dspy",
f"/{MOD}/user/userapikey/delete_userapikey.dspy",
f"/{MOD}/user/userapikey/get_userapikey.dspy",
f"/{MOD}/user/userapikey/index.ui",
f"/{MOD}/user/userapikey/update_userapikey.dspy",
f"/{MOD}/user/userinfo.ui",
f"/{MOD}/user/edit_profile.dspy",
f"/{MOD}/user/save_profile.dspy",
f"/{MOD}/user/wechat_login.ui",
f"/{MOD}/userrole",
f"/{MOD}/users",
f"/{MOD}/usersync",
f"/{MOD}/usersync/index.dspy",
f"/{MOD}/api/add_user.dspy",
f"/{MOD}/api/update_user.dspy",
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}/admin_reset_password.ui",
f"/{MOD}/api/admin_reset_password.dspy",
f"/{MOD}/api/clear_login_fail.dspy",
]
def register_paths():
for path in PATHS_ANY:
subprocess.run(["py3/bin/python", "set_role_perm.py", "any", path])
print(f" any: {path}")
for path in PATHS_LOGINED:
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 ===")
register_paths()
print("Done.")

View File

@ -0,0 +1,19 @@
debug('/rbac/add_superuser.dspy')
db = DBPools()
dbname = get_module_dbname('rbac')
async with db.sqlorContext(dbname) as sor:
ns = DictObject(**{
"id": uuid(),
"username":"superuser",
"password":password_encode('111111'),
"orgid":"0"
})
roles = [
{
"orgtypeid":"owner",
"roles":["superuser", "admin"]
}
]
await create_user(sor, ns, roles)
return "OK"
return "Error"

View File

@ -1,43 +0,0 @@
{
"widgettype": "Form",
"options": {
"title": "重置密码",
"description": "为选中用户设置新密码(至少8位)",
"fields": [
{
"name": "userid",
"uitype": "hide",
"value": "{{params_kw.get('id','')}}"
},
{
"name": "new_password",
"type": "str",
"length": 255,
"uitype": "password",
"datatype": "str",
"required": true,
"label": "新密码"
},
{
"name": "cfm_password",
"type": "str",
"length": 255,
"uitype": "password",
"datatype": "str",
"required": true,
"label": "确认密码"
}
]
},
"binds": [
{
"wid": "self",
"event": "submit",
"actiontype": "urlwidget",
"target": "self",
"options": {
"url": "{{entire_url('api/admin_reset_password.dspy')}}"
}
}
]
}

View File

@ -1,32 +0,0 @@
ns = params_kw.copy()
for k,v in ns.items():
if v == 'NaN' or v == 'null':
ns[k] = None
for k in list(ns.keys()):
if k.endswith('_text'):
ns.pop(k, None)
id = params_kw.id
if not id or len(id) > 32:
id = uuid()
ns['id'] = id
if params_kw.get('password'):
ns['password'] = password_encode(params_kw.get('password'))
ns['created_at'] = curDateString()
userorgid = await get_userorgid()
if not userorgid:
return {"widgettype":"Error","options":{"title":"Authorization Error","timeout":3,"cwidth":16,"cheight":9,"message":"Please login"}}
ns['orgid'] = userorgid
try:
db = DBPools()
dbname = get_module_dbname('rbac')
async with db.sqlorContext(dbname) as sor:
r = await sor.C('users', ns.copy())
return {"widgettype":"Message","options":{"cwidth":16,"cheight":9,"title":"Add Success","timeout":3,"message":"ok"}}
except Exception as e:
debug(f'add_user error: {format_exc()}')
return {"widgettype":"Error","options":{"title":"Add Error","cwidth":16,"cheight":9,"timeout":5,"message":str(e)}}

View File

@ -1,77 +0,0 @@
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')
# 守卫:管理员不得重置自己的密码(自己改密走 /rbac/user/reset_password 自助入口)
op_id = await get_user()
if op_id and str(op_id) == str(target_id):
return {"widgettype":"Error","options":{"title":"Reset Password Error","cwidth":16,"cheight":9,"timeout":3,"message":"cannot reset your own password, use self-service reset"}}
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)}}

View File

@ -1,64 +0,0 @@
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)}}

View File

@ -1,14 +0,0 @@
"""Return all role options for dropdown and code display.
Used by userrole CRUD form and list view."""
async with get_sor_context(request._run_ns, 'rbac') as sor:
recs = await sor.sqlExe(
"SELECT id, CONCAT(orgtypeid, '.', name) as name FROM role "
"WHERE id NOT IN ('anonymous', 'any', 'logined') "
"AND orgtypeid != '*' AND name != '*' "
"AND orgtypeid IS NOT NULL AND name IS NOT NULL "
"ORDER BY orgtypeid, name", {})
result = [{'roleid': '', 'roleid_text': '全部'}]
for r in recs:
result.append({'roleid': r.id, 'roleid_text': r.name})
return result

View File

@ -1,33 +0,0 @@
ns = params_kw.copy()
for k,v in ns.items():
if v == 'NaN' or v == 'null':
ns[k] = None
for k in list(ns.keys()):
if k.endswith('_text'):
ns.pop(k, None)
userorgid = await get_userorgid()
if not userorgid:
return {"widgettype":"Error","options":{"title":"Authorization Error","timeout":3,"cwidth":16,"cheight":9,"message":"Please login"}}
ns['orgid'] = userorgid
if params_kw.get('password'):
ns['password'] = password_encode(params_kw.get('password'))
else:
ns.pop('password', None)
try:
db = DBPools()
dbname = get_module_dbname('rbac')
async with db.sqlorContext(dbname) as sor:
ns1 = {"orgid": userorgid, "id": params_kw.id}
recs = await sor.R('users', ns1)
if len(recs) < 1:
return {"widgettype":"Error","options":{"title":"Update Error","cwidth":16,"cheight":9,"timeout":3,"message":"Record no exist or with wrong ownership"}}
r = await sor.U('users', ns)
debug('update success')
return {"widgettype":"Message","options":{"title":"Update Success","cwidth":16,"cheight":9,"timeout":3,"message":"ok"}}
except Exception as e:
debug(f'update_user error: {format_exc()}')
return {"widgettype":"Error","options":{"title":"Update Error","cwidth":16,"cheight":9,"timeout":5,"message":str(e)}}

View File

@ -1,8 +1,7 @@
wwwroot = params_kw.get('wwwroot', '').strip() wwwroot = params_kw.get('wwwroot', '').strip()
async with get_sor_context(request._run_ns, 'rbac') as sor: async with get_sor_context(request._run_ns, 'rbac') as sor:
# dspy 运行时无 __file__,用 ahserver 注入的 ref_real_path(本 dspy 文件真实绝对路径) title, message, is_error = await request._run_ns.scan_unauth_files(sor, wwwroot, __file__)
title, message, is_error = await request._run_ns.scan_unauth_files(sor, wwwroot, request._run_ns.ref_real_path)
if is_error: if is_error:
return UiError(title=title, message=message) return UiError(title=title, message=message)

View File

@ -1,35 +1,26 @@
phone = params_kw.cellphone phone = params_kw.cellphone
if phone is None: if phone is None:
return { return {
"status": "error", "status": "error",
"data": { "data":{
"message": "没有收到手机号" "message": "没有收到手机号"
} }
} }
try: # 使用短信模块发布的sms_engine实例生成验证码,参数手机号
result = await sms_engine.generate_sms_code(phone) xx = await sms_engine.generate_sms_code(phone)
if result is None: if xx is None:
return { return {
"status": "error", "status": "error",
"data": { "data": {
"message": "短信发送失败,请稍后重试" "message": "发送验证码出错"
} }
} }
code_id, code = result id, code = xx
debug(f'sms sent: code_id={code_id}, phone={phone}') debug(f'{id=},{code=}')
return { return {
"status": "ok", "status": "ok",
"data": { "data": {
"message": "验证码已发送", "message": "短信码已生成",
"key": code_id "key": id
} }
} }
except Exception as e:
debug(f'gen_sms_code error: {e}')
exception(f'gen_sms_code error for {phone}: {e}')
return {
"status": "error",
"data": {
"message": f"发送验证码出错: {e}"
}
}

212
wwwroot/index.ui Normal file
View File

@ -0,0 +1,212 @@
{% set roles = get_user_roles(get_user()) %}
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "0",
"bgcolor": "#0B1120"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"width": "100%",
"alignItems": "center",
"marginBottom": "24px"
},
"subwidgets": [
{
"widgettype": "Title2",
"options": {
"text": "用户与权限",
"color": "#F1F5F9",
"fontWeight": "700"
}
},
{
"widgettype": "Filler"
},
{
"widgettype": "Text",
"options": {
"text": "用户管理、角色权限与安全审计",
"fontSize": "14px",
"color": "#64748B"
}
}
]
},
{% if 'reseller.admin' in roles or 'owner.superuser' in roles %}
{
"widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "250px",
"marginBottom": "24px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rbac_content",
"options": {
"url": "{{entire_url('/rbac/users')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#60A5FA\" stroke-width=\"1.5\"><path d=\"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.953 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "用户管理",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "管理系统用户、角色分配与账户信息",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rbac_content",
"options": {
"url": "{{entire_url('/rbac/list_path_roles.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#A78BFA\" stroke-width=\"1.5\"><path d=\"M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "路径权限角色",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "查询各路径绑定的角色与权限配置",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rbac_content",
"options": {
"url": "{{entire_url('/rbac/find_unauth_files.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#EF4444\" stroke-width=\"1.5\"><path d=\"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "扫描未授权文件",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "检测未配置RBAC权限的页面文件",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
}
]
},
{% endif %}
{
"widgettype": "VBox",
"id": "rbac_content",
"css": "filler",
"options": {
"width": "100%",
"overflowY": "auto"
}
}
]
}

View File

@ -1,242 +0,0 @@
/* ===== Modern Login Popup Styling ===== */
/* Popup window card */
.login-window {
border-radius: 16px !important;
box-shadow: 0 25px 60px -12px rgba(0, 0, 0, 0.35) !important;
overflow: hidden !important;
border: none !important;
}
/* Title bar - gradient brand header */
.login-window .titlebar {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%) !important;
padding: 8px 16px !important;
border: none !important;
min-height: 48px !important;
}
.login-window .titlebar .text-w,
.login-window .titlebar .bricks-text {
color: #ffffff !important;
font-size: 16px !important;
font-weight: 600 !important;
}
/* Content area padding */
.login-window .flexbox {
padding: 0 !important;
}
/* Tab panel modern styling */
.login-window .tabpanel {
background: transparent !important;
border: none !important;
display: flex !important;
flex-direction: column !important;
}
.login-window .tabpanel-tabs {
display: flex !important;
border-bottom: 2px solid #e2e8f0 !important;
background: #f8fafc !important;
padding: 0 !important;
gap: 0 !important;
}
.login-window .tabpanel-tab {
padding: 14px 24px !important;
cursor: pointer !important;
border-bottom: 3px solid transparent !important;
margin-bottom: -2px !important;
transition: all 0.2s ease !important;
color: #64748b !important;
font-weight: 500 !important;
font-size: 14px !important;
background: transparent !important;
}
.login-window .tabpanel-tab:hover {
color: #6366f1 !important;
background: rgba(99, 102, 241, 0.04) !important;
}
.login-window .tabpanel-tab-active,
.login-window .tabpanel-tab-selected {
color: #6366f1 !important;
border-bottom-color: #6366f1 !important;
background: transparent !important;
}
.login-window .tabpanel-content {
padding: 0 !important;
background: #ffffff !important;
overflow: hidden !important;
flex: 1 !important;
min-height: 0 !important;
}
.login-window .tabpanel-content .scrollpanel {
height: 100% !important;
}
.login-window .tabpanel-content .scrollpanel .vcontainer {
padding: 20px 24px !important;
}
/* Form styling */
.login-window .vcontainer {
gap: 4px !important;
}
.login-window .inputbox {
border-radius: 10px !important;
border: 1.5px solid #e2e8f0 !important;
padding: 10px 14px !important;
transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
background: #f8fafc !important;
font-size: 14px !important;
}
.login-window .inputbox:focus,
.login-window .inputbox:focus-within {
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12) !important;
background: #ffffff !important;
outline: none !important;
}
.login-window input.inputbox {
height: 42px !important;
}
/* Form labels */
.login-window .field-label,
.login-window .bricks-form label {
font-weight: 500 !important;
color: #374151 !important;
font-size: 13px !important;
margin-bottom: 4px !important;
}
/* Toolbar buttons */
.login-window .htoolbar {
padding: 8px 0 !important;
gap: 8px !important;
justify-content: center !important;
}
.login-window .submit_btn {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
color: #ffffff !important;
border: none !important;
border-radius: 10px !important;
padding: 10px 32px !important;
font-weight: 600 !important;
font-size: 14px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3) !important;
}
.login-window .submit_btn:hover {
opacity: 0.92 !important;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4) !important;
transform: translateY(-1px) !important;
}
.login-window .reset_btn,
.login-window .clear_btn {
border-radius: 10px !important;
border: 1.5px solid #e2e8f0 !important;
background: #ffffff !important;
color: #64748b !important;
padding: 10px 20px !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
}
.login-window .reset_btn:hover,
.login-window .clear_btn:hover {
border-color: #cbd5e1 !important;
background: #f8fafc !important;
}
/* SMS send button */
.sms-send-btn {
border-radius: 10px !important;
border: 1.5px solid #6366f1 !important;
background: rgba(99, 102, 241, 0.06) !important;
color: #6366f1 !important;
padding: 8px 20px !important;
font-weight: 600 !important;
font-size: 13px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
}
.sms-send-btn:hover {
background: rgba(99, 102, 241, 0.12) !important;
}
.sms-send-btn:disabled {
opacity: 0.5 !important;
cursor: not-allowed !important;
}
/* Description text */
.login-window .bricks-text {
color: #64748b;
}
/* Form description */
.login-desc {
color: #94a3b8 !important;
font-size: 12px !important;
text-align: center !important;
margin-top: 4px !important;
}
/* Section separator */
.login-separator {
display: flex !important;
align-items: center !important;
gap: 12px !important;
margin: 8px 0 !important;
color: #cbd5e1 !important;
font-size: 12px !important;
}
.login-separator::before,
.login-separator::after {
content: '' !important;
flex: 1 !important;
height: 1px !important;
background: #e2e8f0 !important;
}
/* Dark theme support */
[data-theme="dark"] .login-window {
background: #1e293b !important;
}
[data-theme="dark"] .login-window .tabpanel-content {
background: #1e293b !important;
}
[data-theme="dark"] .login-window .tabpanel-tabs {
background: #0f172a !important;
border-bottom-color: #334155 !important;
}
[data-theme="dark"] .login-window .tabpanel-tab {
color: #94a3b8 !important;
}
[data-theme="dark"] .login-window .tabpanel-tab-active,
[data-theme="dark"] .login-window .tabpanel-tab-selected {
color: #818cf8 !important;
border-bottom-color: #818cf8 !important;
}
[data-theme="dark"] .login-window .inputbox {
background: #0F172A !important;
border-color: #475569 !important;
color: #e2e8f0 !important;
}
[data-theme="dark"] .login-window .inputbox:focus,
[data-theme="dark"] .login-window .inputbox:focus-within {
border-color: #818cf8 !important;
box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.15) !important;
}
[data-theme="dark"] .login-window .field-label {
color: #cbd5e1 !important;
}
[data-theme="dark"] .login-window .reset_btn,
[data-theme="dark"] .login-window .clear_btn {
background: #334155 !important;
border-color: #475569 !important;
color: #cbd5e1 !important;
}
[data-theme="dark"] .sms-send-btn {
border-color: #818cf8 !important;
color: #818cf8 !important;
background: rgba(129, 140, 248, 0.1) !important;
}

View File

@ -96,17 +96,7 @@ try:
await openCustomerAccounts(sor, ownerid, orgid) await openCustomerAccounts(sor, ownerid, orgid)
except Exception as e: except Exception as e:
exception(f'{e}') exception(f'{e}')
# 检查是否有促销码,如果有则绑定客户归属关系
promo_code_id = params_kw.get('promo_code_id')
if promo_code_id:
try:
_new_orgid = d['data']['user'].orgid
await bind_customer(request, DictObject(promo_code_id=promo_code_id, customerid=_new_orgid))
debug(f'phone_login: customer {_new_orgid} bound via promo_code {promo_code_id}')
except Exception as e:
exception(f'phone_login: bind_customer failed: {e}')
r = d['data']['user'] r = d['data']['user']
await remember_user(r.id, username=r.username, userorgid=r.orgid) await remember_user(r.id, username=r.username, userorgid=r.orgid)
debug(f'here') debug(f'here')

View File

@ -5,9 +5,11 @@
{ {
"wid": "self", "wid": "self",
"event": "recognized", "event": "recognized",
"actiontype": "script", "actiontype": "urlwidget",
"target": "self", "target": "root.page_center",
"script": "if(event && event.params && event.params.text) window.open(event.params.text, '_blank');" "options": {
"url": "${text}"
}
}, },
{ {
"wid": "self", "wid": "self",
@ -17,4 +19,4 @@
"script": "this.dismiss()" "script": "this.dismiss()"
} }
] ]
} }

View File

@ -1,207 +0,0 @@
# 手机验证码登录 - 接收前端表单参数(cell_no, codeid, check_code)
# 调用sms_engine验证后完成登录或自动注册
debug(f'code_login.dspy: {params_kw=}')
def _login_msg(username, welcome):
# 登录成功 Message;非用户主动点击登录(login_source=auto,如401自动弹窗)时不刷新页面
msg = {
"widgettype": "Message",
"options": {
"timeout": 3,
"auto_open": True,
"title": "登录成功",
"message": welcome
},
"binds": [
{
"wid": "self",
"event": "dismissed",
"actiontype": "urlwidget",
"target": "window.user_container",
"options": {"url": entire_url('/rbac/user/userinfo.ui')}
},
{
"wid": "self",
"event": "dismissed",
"actiontype": "script",
"target": "body.login_window",
"script": "this.destroy()"
}
]
}
if params_kw.get('login_source', 'manual') == 'auto':
msg["options"]["refresh"] = False
else:
msg["binds"].append({
"wid": "self",
"event": "dismissed",
"actiontype": "script",
"target": "self",
"script": "if(bricks.app && bricks.app.dispatch) bricks.app.dispatch('user_logined')"
})
return msg
cellphone = params_kw.cell_no
key = params_kw.codeid
sms_code = params_kw.check_code
if not cellphone:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "错误",
"message": "需输入手机号"
}
}
if not sms_code:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "错误",
"message": "需输入验证码"
}
}
if not key:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "错误",
"message": "需要短信验证key"
}
}
# 验证短信码
ok = await sms_engine.check_sms_code(key, sms_code)
if not ok:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "验证失败",
"message": "手机短信验证码出错"
}
}
# 验证通过,查找或注册用户
ns = {
"username": cellphone,
"password": "^&%UHI",
"cfm_password": "^&%UHI",
"mobile": cellphone,
"user_status": "0"
}
udata = DictObject(**ns)
try:
async with get_sor_context(request._run_ns, 'rbac') as sor:
recs = await sor.R('users', {'mobile': cellphone})
if recs:
if len(recs) == 1:
r = recs[0]
now_str = timestampstr()
await sor.sqlExe("""
UPDATE users
SET last_login = ${now}$, login_fail_count = 0,
last_login_fail = NULL
WHERE id = ${id}$
""", {'id': r.id, 'now': now_str})
await remember_user(r.id, username=r.username, userorgid=r.orgid)
return _login_msg(r.username, f"{r.username} 欢迎回来")
# 多个用户绑定同一手机号
if params_kw.selected_id:
for r in recs:
if r.id == params_kw.selected_id:
now_str = timestampstr()
await sor.sqlExe("""
UPDATE users
SET last_login = ${now}$, login_fail_count = 0,
last_login_fail = NULL
WHERE id = ${id}$
""", {'id': r.id, 'now': now_str})
await remember_user(r.id, username=r.username, userorgid=r.orgid)
return _login_msg(r.username, f"{r.username} 欢迎回来")
else:
# 返回用户选择列表
buttons = []
for r in recs:
buttons.append({
"widgettype": "Button",
"options": {
"label": f"{r.username} ({r.id})",
"width": "100%",
"margin": "4px 0"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "self",
"options": {
"url": entire_url('/rbac/user/code_login.dspy'),
"params": {
"cell_no": cellphone,
"codeid": key,
"check_code": sms_code,
"selected_id": r.id
}
}
}]
})
return {
"widgettype": "VBox",
"options": {"padding": "12px"},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "该手机号关联多个账号,请选择:",
"fontSize": "14px",
"margin": "0 0 8px 0"
}
}
] + buttons
}
# 新用户自动注册
d = await register_user(sor, udata)
if d['status'] == 'error':
return {
"widgettype": "Error",
"options": {
"timeout": 5,
"title": "注册失败",
"message": d['data']['message']
}
}
try:
ownerid = await get_owner_orgid(sor, orgid)
await openCustomerAccounts(sor, ownerid, orgid)
except Exception as e:
exception(f'{e}')
# 检查是否有促销码,如果有则绑定客户归属关系
promo_code_id = params_kw.get('promo_code_id')
if promo_code_id:
try:
_new_orgid = d['data']['user'].orgid
await bind_customer(request, DictObject(promo_code_id=promo_code_id, customerid=_new_orgid))
debug(f'code_login: customer {_new_orgid} bound via promo_code {promo_code_id}')
except Exception as e:
exception(f'code_login: bind_customer failed: {e}')
r = d['data']['user']
await remember_user(r.id, username=r.username, userorgid=r.orgid)
return _login_msg(r.username, f"{r.username} 欢迎")
except Exception as e:
exception(f'code_login error: {e}')
return {
"widgettype": "Error",
"options": {
"timeout": 5,
"title": "系统错误",
"message": f"{e}"
}
}

View File

@ -1,79 +0,0 @@
debug(f'edit_profile.dspy: {params_kw=}')
userid = await get_user()
if userid is None:
return UiError(title='Error', message='You need login first')
db = DBPools()
dbname = get_module_dbname('rbac')
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('users', {'id': userid})
if not recs:
return UiError(title='Error', message='User not found')
user = recs[0]
nick_name = user.nick_name or ''
email = user.email or ''
mobile = user.mobile or ''
address = user.address or ''
return {
"widgettype": "Form",
"options": {
"title": "完善个人信息",
"description": "请填写或更新您的个人信息",
"fields": [
{
"name": "nick_name",
"type": "str",
"length": 255,
"uitype": "str",
"datatype": "str",
"required": False,
"label": "显示名",
"value": nick_name
},
{
"name": "email",
"type": "str",
"length": 255,
"uitype": "email",
"datatype": "str",
"required": False,
"label": "邮件地址",
"value": email
},
{
"name": "mobile",
"type": "str",
"length": 255,
"uitype": "tel",
"datatype": "str",
"required": False,
"label": "手机号",
"value": mobile
},
{
"name": "address",
"type": "str",
"length": 255,
"uitype": "str",
"datatype": "str",
"required": False,
"label": "地址",
"value": address
}
]
},
"binds": [
{
"wid": "self",
"event": "submit",
"actiontype": "urlwidget",
"target": "self",
"options": {
"method": "POST",
"url": entire_url('save_profile.dspy')
}
}
]
}

View File

@ -1,22 +1,16 @@
{% set sms_code_url = entire_url('/rbac/gen_sms_code.dspy') %}
{% set code_login_url = entire_url('/rbac/user/code_login.dspy') %}
{% set sms_register_url = entire_url('/rbac/user/sms_register.dspy') %}
{ {
"id": "login_window", "id": "login_window",
"widgettype": "PopupWindow", "widgettype": "PopupWindow",
"options": { "options": {
"title": "欢迎登录", "title": "登录/注册",
"i18n": true,
"css": "login-window",
"auto_open": true, "auto_open": true,
"archor": "cc", "anthor": "cc",
"cwidth": 26, "cwidth": 22,
"cheight": 30 "cheight": 19
}, },
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "TabPanel", "widgettype": "TabPanel",
"id": "login_tabs",
"options": { "options": {
"tab_wide": "auto", "tab_wide": "auto",
"height": "100%", "height": "100%",
@ -25,165 +19,90 @@
"items": [ "items": [
{ {
"name": "userpasswd", "name": "userpasswd",
"label": "密码登录", "label": "用户密码",
"content": { "content": {
"widgettype": "VScrollPanel", "widgettype": "Form",
"options": {"height": "100%"}, "options": {
"subwidgets": [ "cols": 1,
{ "fields": [
"widgettype": "Form", {
"options": { "name": "username",
"cols": 1, "label": "用户名",
"fields": [ "uitype": "str"
{"name": "username", "label": "用户名", "uitype": "str"},
{"name": "password", "label": "密码", "uitype": "password"}
]
}, },
"binds": [ {
{ "name": "password",
"wid": "self", "label": "密码",
"event": "submit", "uitype": "password"
"actiontype": "urlwidget", }
"target": "self", ]
"options": { },
"method": "POST", "binds": [
"url": "{{entire_url('up_login.dspy')}}",
"params": {
"login_source": "{{params_kw.get('login_source', 'manual')}}"
}
}
}
]
}
]
}
},
{
"name": "phonecode",
"label": "手机登录",
"content": {
"widgettype": "VScrollPanel",
"options": {"height": "100%"},
"subwidgets": [
{ {
"widgettype": "VBox", "wid": "self",
"options": {"gap": "8px"}, "event": "submit",
"subwidgets": [ "actiontype": "urlwidget",
{ "target": "self",
"widgettype": "Text",
"options": {
"otext": "未注册的手机号将自动创建账号",
"i18n": true,
"css": "login-desc"
}
},
{
"widgettype": "Form",
"id": "phone_form",
"options": {
"cols": 1,
"fields": [
{"name": "cell_no", "label": "手机号", "uitype": "str"},
{"name": "codeid", "uitype": "hide", "value": ""},
{"name": "check_code", "label": "验证码", "uitype": "str"}
]
},
"binds": [
{
"wid": "self",
"event": "submit",
"actiontype": "urlwidget",
"target": "self",
"options": {
"method": "POST",
"url": "{{code_login_url}}",
"params": {
"login_source": "{{params_kw.get('login_source', 'manual')}}"
}
}
}
]
},
{
"widgettype": "Button",
"id": "gen_code_btn",
"options": {"label": "发送验证码", "i18n": true, "css": "sms-send-btn"},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "var form=bricks.getWidgetById('phone_form',bricks.app);if(!form)return;var cell=form._getValue().cell_no;if(!cell||cell.length<11){alert('请输入正确的手机号');return;}var btn=this;btn.disabled=true;btn.text_w&&btn.text_w.set_otext('发送中...');fetch('{{sms_code_url}}?_webbricks_=1&cellphone='+encodeURIComponent(cell)).then(function(r){return r.json()}).then(function(d){if(d.status==='ok'){var w=form.name_inputs['codeid'];if(w)w.setValue(d.data.key);btn.text_w&&btn.text_w.set_otext('已发送');var s=60;var t=setInterval(function(){s--;if(s<=0){clearInterval(t);btn.disabled=false;btn.text_w&&btn.text_w.set_otext('重新发送')}else btn.text_w&&btn.text_w.set_otext(s+'s')},1000)}else{alert(d.data.message||'发送验证码出错');btn.disabled=false;btn.text_w&&btn.text_w.set_otext('发送验证码')}}).catch(function(e){alert('网络错误: '+e);btn.disabled=false;btn.text_w&&btn.text_w.set_otext('发送验证码')})"
}
]
}
]
}
]
}
},
{
"name": "register",
"label": "注册账号",
"content": {
"widgettype": "VBox",
"options": {"gap": "8px", "height": "100%"},
"subwidgets": [
{
"widgettype": "Text",
"options": { "options": {
"otext": "手机号需短信验证后方可注册", "method": "POST",
"i18n": true, "url": "{{entire_url('up_login.dspy')}}"
"css": "login-desc" }
}
]
}
},
{
"name": "checkcode",
"label": "手机验证码",
"content": {
"widgettype": "Form",
"options": {
"toolbar": {
"tools": [
{
"name": "gen_code",
"label": "发送验证码"
}
]
},
"description": "限中国国内手机",
"fields": [
{
"name": "cell_no",
"label": "手机号",
"uitype": "str"
},
{
"name": "codeid",
"uitype": "hide",
"value": "{{uuid()}}"
},
{
"name": "check_code",
"uitype": "str"
}
]
},
"binds": [
{
"wid": "self",
"event": "gen_code",
"actiontype": "urlwidget",
"datawidget": "self",
"datamethod": "getValue",
"target": "self",
"options": {
"url": "{{entire_url('../gen_sms_code.dspy')}}"
} }
}, },
{ {
"widgettype": "VScrollPanel", "wid": "self",
"options": {"height": "100%", "flex": "1"}, "event": "submit",
"subwidgets": [ "actiontype": "urlwidget",
{ "target": "self",
"widgettype": "Form", "options": {
"id": "register_form", "url": "{{entire_url('code_login.dspy')}}"
"options": { }
"cols": 1,
"fields": [
{"name": "username", "label": "用户名", "uitype": "str"},
{"name": "mobile", "label": "手机号", "uitype": "str"},
{"name": "codeid", "uitype": "hide", "value": ""},
{"name": "check_code", "label": "短信验证码", "uitype": "str"},
{"name": "password", "label": "密码", "uitype": "password"},
{"name": "cfm_password", "label": "确认密码", "uitype": "password"}
]
},
"binds": [
{
"wid": "self",
"event": "submit",
"actiontype": "urlwidget",
"target": "self",
"options": {
"method": "POST",
"url": "{{sms_register_url}}"
}
}
]
}
]
},
{
"widgettype": "Button",
"id": "reg_sms_btn",
"options": {"label": "发送验证码", "i18n": true, "css": "sms-send-btn"},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "var form=bricks.getWidgetById('register_form',bricks.app);if(!form)return;var cell=form._getValue().mobile;if(!cell||cell.length<11){alert('请输入正确的手机号');return;}var btn=this;btn.disabled=true;btn.text_w&&btn.text_w.set_otext('发送中...');fetch('{{sms_code_url}}?_webbricks_=1&cellphone='+encodeURIComponent(cell)).then(function(r){return r.json()}).then(function(d){if(d.status==='ok'){var w=form.name_inputs['codeid'];if(w)w.setValue(d.data.key);btn.text_w&&btn.text_w.set_otext('已发送');var s=60;var t=setInterval(function(){s--;if(s<=0){clearInterval(t);btn.disabled=false;btn.text_w&&btn.text_w.set_otext('重新发送')}else btn.text_w&&btn.text_w.set_otext(s+'s')},1000)}else{alert(d.data.message||'发送验证码出错');btn.disabled=false;btn.text_w&&btn.text_w.set_otext('发送验证码')}}).catch(function(e){alert('网络错误: '+e);btn.disabled=false;btn.text_w&&btn.text_w.set_otext('发送验证码')})"
}
]
} }
] ]
} }
@ -192,4 +111,4 @@
} }
} }
] ]
} }

View File

@ -1,38 +1,9 @@
await forget_user() await forget_user()
return { return {
"widgettype": "VBox", "widgettype":"Text",
"options": { "options":{
"padding": "24px", "otext":"logout success",
"alignItems": "center" "i18n":True,
}, }
"subwidgets": [
{
"widgettype": "Text",
"options": {
"otext": "logout success",
"i18n": True,
"fontSize": "16px",
"marginBottom": "16px"
}
},
{
"widgettype": "Button",
"options": {
"label": "刷新页面",
"bgcolor": "#3B82F6",
"color": "white",
"padding": "8px 24px",
"borderRadius": "6px"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "location.reload()"
}
]
}
]
} }

View File

@ -1,117 +1,18 @@
debug(f'register.dspy: {params_kw=}') debug(f'{params_kw=}')
db = DBPools() db = DBPools()
dbname = get_module_dbname('rbac') dbname = get_module_dbname('rbac')
# 根据注册域名查找所属分销商
parentid = None
try:
host = request.host
env = request._run_ns
domain_info = await env.get_domain_by_host(request, host)
if domain_info:
parentid = domain_info.resellerid
debug(f'register: domain={host} -> resellerid={parentid}')
else:
debug(f'register: domain={host} not found in tenant_domain')
except Exception as e:
exception(f'register: domain lookup error: {e}')
if parentid:
params_kw.parentid = parentid
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
data = await register_user(sor, params_kw) data = await register_user(sor, params_kw)
data = DictObject(**data) data = DictObject(**data)
if data.status == 'error': try:
debug(f"register error: {data.data.message}") if data['status'] == 'error':
return { debug(f"{data.data.message}")
"widgettype": "Error", return UiError(title='Error', message=data.data.message)
"options": { orgid = data.data.user.orgid
"timeout": 5, await openCustomerAccounts(sor, '0', orgid)
"title": "注册失败", debug(f'{orgid} accounts opened')
"message": data.data.message except Exception as e:
} exception(f'{e},{orgid=}')
}
user = data.data.user
orgid = user.orgid
try:
await openCustomerAccounts(sor, '0', orgid)
debug(f'{orgid} accounts opened')
except Exception as e:
exception(f'{e},{orgid=}')
# 检查是否有促销码,如果有则绑定客户归属关系 return UiMessage(title="Success", message=f"register success {orgid}")
promo_code_id = params_kw.get('promo_code_id') return UiError(title='Error', message="register failed")
if promo_code_id:
try:
await bind_customer(request, DictObject(promo_code_id=promo_code_id, customerid=orgid))
debug(f'register: customer {orgid} bound via promo_code {promo_code_id}')
except Exception as e:
exception(f'register: bind_customer failed for {orgid} via {promo_code_id}: {e}')
else:
# 非促销码注册: 创建自由客户归属记录 (bind_type=3 域名注册)
try:
reseller_id = parentid or '0'
async with DBPools().sqlorContext(get_module_dbname('discount')) as disc_sor:
existing = await disc_sor.sqlExe(
'SELECT id FROM discount_customer_bind WHERE customerid = ${cid}$',
{'cid': orgid}
)
if not existing:
await disc_sor.C('discount_customer_bind', {
'id': getID(),
'customerid': orgid,
'resellerid': reseller_id,
'sale_id': '',
'promo_code_id': '',
'bind_type': '3',
})
debug(f'register: free customer {orgid} bind created (reseller={reseller_id})')
except Exception as e:
exception(f'register: create free customer bind failed for {orgid}: {e}')
# 注册成功后自动登录
await remember_user(user.id, username=user.username, userorgid=user.orgid)
return {
"widgettype": "Message",
"options": {
"timeout": 3,
"auto_open": True,
"title": "注册成功",
"message": f"{user.username} 注册成功,已自动登录"
},
"binds": [
{
"wid": "self",
"event": "dismissed",
"actiontype": "urlwidget",
"target": "window.user_container",
"options": {
"url": entire_url('/rbac/user/userinfo.ui')
}
},
{
"wid": "self",
"event": "dismissed",
"actiontype": "script",
"target": "body.login_window",
"script": "if(this.destroy) this.destroy()"
},
{
"wid": "self",
"event": "dismissed",
"actiontype": "script",
"target": "self",
"script": "if(bricks.app && bricks.app.dispatch) bricks.app.dispatch('user_logined')"
}
]
}
return {
"widgettype": "Error",
"options": {
"timeout": 5,
"title": "注册失败",
"message": "系统错误,请稍后重试"
}
}

View File

@ -1,40 +0,0 @@
debug(f'save_profile.dspy: {params_kw=}')
userid = await get_user()
if userid is None:
return UiError(title='Error', message='You need login first')
data = {
'id': userid,
'nick_name': params_kw.nick_name or '',
'email': params_kw.email or '',
'mobile': params_kw.mobile or '',
'address': params_kw.address or ''
}
db = DBPools()
dbname = get_module_dbname('rbac')
try:
async with db.sqlorContext(dbname) as sor:
await sor.U('users', data)
return {
"widgettype": "Message",
"options": {
"timeout": 3,
"auto_open": True,
"title": "保存成功",
"message": "个人信息已更新"
},
"binds": [
{
"wid": "self",
"event": "dismissed",
"actiontype": "script",
"target": "self",
"script": "if(bricks.app && bricks.app.dispatch) bricks.app.dispatch('profile_updated')"
}
]
}
except Exception as e:
exception(f'save_profile error: {e}')
return UiError(title='Error', message=f'保存失败: {e}')

View File

@ -1,201 +0,0 @@
# 短信验证注册 - 接收前端表单参数(username, mobile, codeid, check_code, password, cfm_password)
# 先验证短信码,通过后注册并自动登录
debug(f'sms_register.dspy: {params_kw=}')
username = params_kw.username
mobile = params_kw.mobile
key = params_kw.codeid
sms_code = params_kw.check_code
password = params_kw.password
cfm_password = params_kw.cfm_password
# 基本参数校验
if not username:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "注册失败",
"message": "请输入用户名"
}
}
if not mobile:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "注册失败",
"message": "请输入手机号"
}
}
if not password:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "注册失败",
"message": "请输入密码"
}
}
if password != cfm_password:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "注册失败",
"message": "两次输入的密码不一致"
}
}
if not key or not sms_code:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "注册失败",
"message": "请先发送并输入短信验证码"
}
}
# 验证短信码
try:
ok = await sms_engine.check_sms_code(key, sms_code)
except Exception as e:
exception(f'sms_register sms check error: {e}')
ok = False
if not ok:
return {
"widgettype": "Error",
"options": {
"timeout": 3,
"title": "验证失败",
"message": "短信验证码错误或已过期,请重新获取"
}
}
# 根据注册域名查找所属分销商
parentid = None
try:
host = request.host
env = request._run_ns
domain_info = await env.get_domain_by_host(request, host)
if domain_info:
parentid = domain_info.resellerid
debug(f'sms_register: domain={host} -> resellerid={parentid}')
else:
debug(f'sms_register: domain={host} not found in tenant_domain')
except Exception as e:
exception(f'sms_register: domain lookup error: {e}')
# 短信验证通过,注册用户
db = DBPools()
dbname = get_module_dbname('rbac')
try:
async with db.sqlorContext(dbname) as sor:
# 检查用户名是否已存在
existing_user = await sor.R('users', {'username': username})
if existing_user:
return {
"widgettype": "Error",
"options": {
"timeout": 5,
"title": "注册失败",
"message": "用户名已被占用"
}
}
# 调用注册函数
reg_params = DictObject(
username=username,
mobile=mobile,
password=password,
cfm_password=cfm_password
)
if parentid:
reg_params.parentid = parentid
data = await register_user(sor, reg_params)
data = DictObject(**data)
if data.status == 'error':
debug(f"sms_register error: {data.data.message}")
return {
"widgettype": "Error",
"options": {
"timeout": 5,
"title": "注册失败",
"message": data.data.message
}
}
user = data.data.user
orgid = user.orgid
try:
await openCustomerAccounts(sor, '0', orgid)
debug(f'{orgid} accounts opened')
except Exception as e:
exception(f'{e},{orgid=}')
# 检查是否有促销码,如果有则绑定客户归属关系
promo_code_id = params_kw.get('promo_code_id')
if promo_code_id:
try:
await bind_customer(request, DictObject(promo_code_id=promo_code_id, customerid=orgid))
debug(f'sms_register: customer {orgid} bound via promo_code {promo_code_id}')
except Exception as e:
exception(f'sms_register: bind_customer failed for {orgid} via {promo_code_id}: {e}')
# 注册成功后自动登录
await remember_user(user.id, username=user.username, userorgid=user.orgid)
return {
"widgettype": "Message",
"options": {
"timeout": 3,
"auto_open": True,
"title": "注册成功",
"message": f"{user.username} 注册成功,已自动登录"
},
"binds": [
{
"wid": "self",
"event": "dismissed",
"actiontype": "urlwidget",
"target": "window.user_container",
"options": {
"url": entire_url('/rbac/user/userinfo.ui')
}
},
{
"wid": "self",
"event": "dismissed",
"actiontype": "script",
"target": "body.login_window",
"script": "this.destroy()"
},
{
"wid": "self",
"event": "dismissed",
"actiontype": "script",
"target": "self",
"script": "if(bricks.app && bricks.app.dispatch) bricks.app.dispatch('user_logined')"
}
]
}
except Exception as e:
exception(f'sms_register error: {e}')
return {
"widgettype": "Error",
"options": {
"timeout": 5,
"title": "系统错误",
"message": f"注册失败: {e}"
}
}
return {
"widgettype": "Error",
"options": {
"timeout": 5,
"title": "注册失败",
"message": "系统错误,请稍后重试"
}
}

View File

@ -1,21 +1,10 @@
debug(f'{params_kw=}') debug(f'{params_kw=}')
ns = { ns = {
"username":params_kw.username, "username":params_kw.username,
"password":password_encode(params_kw.password) "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=}') info(f'{ns=}')
db = DBPools() db = DBPools()
dbname = get_module_dbname('rbac') dbname = get_module_dbname('rbac')
@ -23,12 +12,7 @@ async with db.sqlorContext(dbname) as sor:
# Get user record with lockout fields (standard SQL) # Get user record with lockout fields (standard SQL)
r = await sor.sqlExe('select * from users where username=${username}$', ns.copy()) r = await sor.sqlExe('select * from users where username=${username}$', ns.copy())
if len(r) == 0: if len(r) == 0:
await _audit_login(sor, None, ns['username'], 'login_fail', 'fail')
return { return {
"status": "error",
"data": {
"message": "user name or password error"
},
"widgettype":"Error", "widgettype":"Error",
"options":{ "options":{
"timeout":3, "timeout":3,
@ -46,12 +30,7 @@ async with db.sqlorContext(dbname) as sor:
stored = datetime.datetime.strptime(str(last_fail), '%Y-%m-%d %H:%M:%S') stored = datetime.datetime.strptime(str(last_fail), '%Y-%m-%d %H:%M:%S')
elapsed = (datetime.datetime.now() - stored).total_seconds() elapsed = (datetime.datetime.now() - stored).total_seconds()
if elapsed < 300: if elapsed < 300:
await _audit_login(sor, user.id, ns['username'], 'login_fail', 'locked')
return { return {
"status": "error",
"data": {
"message": "Account locked due to too many failed login attempts. Please try again in 5 minutes."
},
"widgettype":"Error", "widgettype":"Error",
"options":{ "options":{
"timeout":5, "timeout":5,
@ -71,16 +50,14 @@ async with db.sqlorContext(dbname) as sor:
# Verify password # Verify password
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}$ and password=${password}$', ns.copy())
if len(r) == 0: if len(r) == 0:
# 5分钟窗口:超过5分钟前的失败次数默认为0(原子 SQL) # Atomic increment -- standard SQL
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 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(""" await sor.sqlExe("""
UPDATE users 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, SET login_fail_count = login_fail_count + 1,
last_login_fail = ${now}$ last_login_fail = ${now}$
WHERE id = ${id}$ WHERE id = ${id}$
""", {'id': user.id, 'now': now_str, 'stale': stale_before}) """, {'id': user.id, 'now': now_str})
await _audit_login(sor, user.id, ns['username'], 'login_fail', 'fail')
new_fail_count = fail_count + 1 new_fail_count = fail_count + 1
if new_fail_count >= 3: if new_fail_count >= 3:
@ -88,10 +65,6 @@ async with db.sqlorContext(dbname) as sor:
else: else:
msg = f"user name or password error ({3 - new_fail_count} attempts remaining)" msg = f"user name or password error ({3 - new_fail_count} attempts remaining)"
return { return {
"status": "error",
"data": {
"message": msg
},
"widgettype":"Error", "widgettype":"Error",
"options":{ "options":{
"timeout":3, "timeout":3,
@ -108,54 +81,34 @@ async with db.sqlorContext(dbname) as sor:
WHERE id = ${id}$ WHERE id = ${id}$
""", {'id': user.id, 'now': now_str}) """, {'id': user.id, 'now': now_str})
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)
await _audit_login(sor, r[0].id, r[0].username, 'login', 'ok') return {
login_source = params_kw.get('login_source', 'manual')
msg = {
"status": "ok",
"data": { "user": r[0]},
"widgettype":"Message", "widgettype":"Message",
"options":{ "options":{
"timeout":3, "timeout":3,
"anchor": "cc",
"auto_open":True, "auto_open":True,
"title":"Login", "title":"Login",
"message":f"{r[0].username} Welcome back" "message":f"{r[0].username} Welcome back"
}, },
"binds":[ "binds":[
{ {
"wid":"self", "wid":"self",
"event":"opened", "event":"dismissed",
"actiontype":"script", "actiontype":"urlwidget",
"target":"window.login_window", "target":"window.user_container",
"script":"this.destroy()" "options":{
} "url":entire_url('/rbac/user/userinfo.ui')
}
},
{
"wid":"self",
"event":"dismissed",
"actiontype":"script",
"target": f'body.login_window',
"script":"this.destroy()"
}
] ]
} }
if login_source == 'auto':
# 非用户主动点击登录(如401自动弹窗):不刷新页面,标记不刷新
msg["options"]["refresh"] = False
else:
# 用户主动点击登录:登录成功后刷新页面(跳转/index.ui)。
# 注意(2026-09-08 修):旧实现用 actiontype=urlwidget target="window" ——
# bricks 里 'window' 解析为 bricks.app 根控件而非浏览器窗口,实际是
# in-place 渲染 index.ui 进当前文档。桌面文档即 shell 页(baseURI 正确)
# 无感;但从 login.ui 文档进入时 baseURI 停留在 /rbac/user/,后续所有
# 根绝对 urlwidget 解析错误 → 反复 401/500 → 登录窗反复打开。
# 故:文档是登录页时做真实浏览器跳转;否则保持原 in-place 语义。
msg["binds"].insert(0, {
"wid":"self",
"event":"dismissed",
"actiontype":"script",
"target":"self",
"script":"if(window.location.pathname.indexOf('login.ui')>=0){window.location.replace('/');return;}var w=bricks.getWidgetById('window',this);if(w){bricks.widgetBuild({widgettype:'urlwidget',options:{url:'/index.ui'}},w).then(function(nw){if(nw){w.clear_widgets();w.add_widget(nw);}});}"
})
return msg
return { return {
"status": "error",
"data": {
"message": "system error"
},
"widgettype":"Error", "widgettype":"Error",
"options":{ "options":{
"timeout":3, "timeout":3,

View File

@ -0,0 +1,25 @@
ns = params_kw.copy()
id = params_kw.id
if not id or len(id) > 32:
id = uuid()
ns['id'] = id
db = DBPools()
async with db.sqlorContext('sage') as sor:
r = await sor.C('userapikey', ns.copy())
return {
"widgettype":"Message",
"options":{
"user_data":ns,
"title":"Add Success",
"message":"ok"
}
}
return {
"widgettype":"Error",
"options":{
"title":"Add Error",
"message":"failed"
}
}

View File

@ -0,0 +1,24 @@
ns = {
'id':params_kw['id'],
}
db = DBPools()
async with db.sqlorContext('sage') as sor:
r = await sor.D('userapikey', ns)
print('delete success');
return {
"widgettype":"Message",
"options":{
"title":"Delete Success",
"message":"ok"
}
}
print('Delete failed');
return {
"widgettype":"Error",
"options":{
"title":"Delete Error",
"message":"failed"
}
}

View File

@ -0,0 +1,74 @@
ns = params_kw.copy()
print(f'get_userapikey.dspy:{ns=}')
if not ns.get('page'):
ns['page'] = 1
if not ns.get('sort'):
ns['sort'] = 'id'
filterjson = params_kw.get('data_filter')
userid = await get_user()
ns['userid'] = userid
if not filterjson:
fields = [ f['name'] for f in [
{
"name": "id",
"title": "id",
"type": "str",
"length": 32
},
{
"name": "providerid",
"title": "\u4f9b\u5e94\u5546id",
"type": "str",
"length": 200
},
{
"name": "customerid",
"title": "\u7528\u6237id",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "apikey",
"title": "api\u5bc6\u94a5",
"type": "str",
"length": 4000,
"default": "0"
},
{
"name": "secretkey",
"title": "\u9644\u5c5e\u5bc6\u94a5",
"type": "str",
"length": 4000
},
{
"name": "rfname",
"title": "\u51fd\u6570\u540d",
"type": "str",
"length": 400
}
] ]
filterjson = default_filterjson(fields, ns)
sql = '''select a.*, b.providerid_text, c.customerid_text
from (select y.* from users x, userapikey y where x.id=${userid}$ and x.orgid = y.customerid) a left join (select id as providerid,
orgname as providerid_text from organization where 1 = 1) b on a.providerid = b.providerid left join (select id as customerid,
orgname as customerid_text from organization where 1 = 1) c on a.customerid = c.customerid'''
if filterjson:
dbf = DBFilter(filterjson)
conds = dbf.gen(ns)
if conds:
ns.update(dbf.consts)
sql += ' and ' + conds
info(f'{ns=},{sql=}')
db = DBPools()
async with db.sqlorContext('sage') as sor:
r = await sor.sqlPaging(sql, ns)
return r
return {
"total":0,
"rows":[]
}

View File

@ -0,0 +1,126 @@
{
"widgettype":"Tabular",
"options":{
"editable":{
"new_data_url":"{{entire_url('add_userapikey.dspy')}}",
"delete_data_url":"{{entire_url('delete_userapikey.dspy')}}",
"update_data_url":"{{entire_url('update_userapikey.dspy')}}"
},
"data_url":"{{entire_url('./get_userapikey.dspy')}}",
"data_method":"GET",
"data_params":{{json.dumps(params_kw, indent=4)}},
"row_options":{
"browserfields":{
"excloud": [],
"cwidth": {}
},
"editexclouded":[
"id"
],
"fields":[
{
"name": "id",
"title": "id",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "id"
},
{
"name": "providerid",
"title": "\u4f9b\u5e94\u5546id",
"type": "str",
"length": 200,
"label": "\u4f9b\u5e94\u5546id",
"cwidth":16,
"uitype": "code",
"cwidth":18,
"valueField": "providerid",
"textField": "providerid_text",
"params": {
"dbname": "sage",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname",
"valueField": "providerid",
"textField": "providerid_text"
},
"dataurl": "\/get_code.dspy"
},
{
"name": "customerid",
"title": "\u7528\u6237id",
"type": "str",
"length": 32,
"cwidth":18,
"default": "0",
"label": "\u7528\u6237id",
"cwidth":16,
"uitype": "code",
"valueField": "customerid",
"textField": "customerid_text",
"params": {
"dbname": "sage",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname",
"valueField": "customerid",
"textField": "customerid_text"
},
"dataurl": "\/get_code.dspy"
},
{
"name": "apikey",
"title": "api\u5bc6\u94a5",
"type": "str",
"length": 4000,
"default": "0",
"cwidth": 18,
"uitype": "text",
"rows": 4,
"datatype": "str",
"label": "api\u5bc6\u94a5"
},
{
"name": "secretkey",
"title": "\u9644\u5c5e\u5bc6\u94a5",
"type": "str",
"length": 4000,
"cwidth": 18,
"uitype": "text",
"rows": 4,
"datatype": "str",
"label": "\u9644\u5c5e\u5bc6\u94a5"
},
{
"name": "rfname",
"title": "\u51fd\u6570\u540d",
"type": "str",
"length": 400,
"cwidth": 18,
"uitype": "text",
"rows": 4,
"datatype": "str",
"label": "\u51fd\u6570\u540d"
}
]
},
"page_rows":160,
"cache_limit":5
}
}

View File

@ -0,0 +1,22 @@
ns = params_kw.copy()
db = DBPools()
async with db.sqlorContext('sage') as sor:
r = await sor.U('userapikey', ns)
print('update success');
return {
"widgettype":"Message",
"options":{
"title":"Update Success",
"message":"ok"
}
}
print('update failed');
return {
"widgettype":"Error",
"options":{
"title":"Update Error",
"message":"failed"
}
}

View File

@ -32,16 +32,6 @@
"label":"我的角色", "label":"我的角色",
"url":"{{entire_url('user/myrole.ui')}}" "url":"{{entire_url('user/myrole.ui')}}"
}, },
{
"name":"editprofile",
"label":"完善信息",
"url":"{{entire_url('user/edit_profile.dspy')}}",
"popup_options":{
"title":"完善个人信息",
"cwidth":22,
"height":"75%"
}
},
{ {
"name":"logout", "name":"logout",
"label":"签退", "label":"签退",