Merge pull request #936 from iflytek/feat/issue-923-auth-settings

feat(auth): configure local sign-in and initial external roles
This commit is contained in:
XiaoSeS 2026-10-10 17:00:34 +08:00 • committed by GitHub
commit 24608f4d5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 3450 additions and 46 deletions

View file

@ -51,6 +51,13 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \
`<publicBaseUrl>/cli/auth`。所有 values 会先经过 `values.schema.json` 和跨字段校验,
无效的组件、Ingress、HPA 与存储组合会在安装前失败。
首次安装可通过 `auth.initialSettings.passwordLoginEnabled` 和
`auth.initialSettings.selfRegistrationEnabled` 设置本地认证开关的初始值。
若要预置外部账号首次授权规则,在受保护的 values 中设置
`secrets.initialRoleGrantsJson`,或在 `existingSecret` 中提供
`auth-initial-role-grants-json`。这些值仅用于数据库首次初始化;之后在后台
“系统配置”中管理。规则格式及管理入口见 [登录开关与外部账号首次授权](../../docs/923-auth-system-settings.md)。
> **Ingress values 迁移:** 当前版本只支持结构化的 `ingress.hosts[]` 和
> `ingress.tls[]`。旧的 `ingress.host`、`ingress.tls.enabled` 与
> `ingress.tls.secretName` 不再接受,升级前必须改成本文 Ingress 示例中的数组结构。

View file

@ -53,6 +53,8 @@ data:
{{- end }}
device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }}
auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }}
auth-initial-password-login-enabled: {{ .Values.auth.initialSettings.passwordLoginEnabled | quote }}
auth-initial-self-registration-enabled: {{ .Values.auth.initialSettings.selfRegistrationEnabled | quote }}
auth-direct-provider: {{ .Values.auth.direct.provider | quote }}
# Sub-path deployment (empty keeps a fixed-base image's baked base; set e.g. /portal/)

View file

@ -41,6 +41,10 @@ stringData:
{{- end }}
bootstrap-admin-password: {{ $baPwd | quote }}
{{- if .Values.secrets.initialRoleGrantsJson }}
auth-initial-role-grants-json: {{ .Values.secrets.initialRoleGrantsJson | quote }}
{{- end }}
# 匿名下载限流 Cookie 签名密钥
{{- $downloadSecret := .Values.secrets.downloadAnonCookieSecret | default "" }}
{{- if and (not $downloadSecret) $appSecret }}

View file

@ -297,6 +297,22 @@ spec:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-direct-enabled
- name: SKILLHUB_AUTH_INITIAL_PASSWORD_LOGIN_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-initial-password-login-enabled
- name: SKILLHUB_AUTH_INITIAL_SELF_REGISTRATION_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-initial-self-registration-enabled
- name: SKILLHUB_AUTH_INITIAL_ROLE_GRANTS_JSON
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: auth-initial-role-grants-json
optional: true
- name: SKILLHUB_BUILTIN_SKILLS_ENABLED
valueFrom:
configMapKeyRef:

View file

@ -21,7 +21,7 @@
"auth": {
"type": "object",
"additionalProperties": false,
"required": ["direct"],
"required": ["direct", "initialSettings"],
"properties": {
"direct": {
"type": "object",
@ -31,6 +31,15 @@
"enabled": { "type": "boolean" },
"provider": { "type": "string" }
}
},
"initialSettings": {
"type": "object",
"additionalProperties": false,
"required": ["passwordLoginEnabled", "selfRegistrationEnabled"],
"properties": {
"passwordLoginEnabled": { "type": "boolean" },
"selfRegistrationEnabled": { "type": "boolean" }
}
}
}
},
@ -183,6 +192,7 @@
"properties": {
"allowAutoGenerated": { "type": "boolean" },
"bootstrapAdminPassword": { "type": "string" },
"initialRoleGrantsJson": { "type": "string" },
"downloadAnonCookieSecret": { "type": "string" },
"oauth2GithubClientId": { "type": "string" },
"oauth2GithubClientSecret": { "type": "string" },

View file

@ -21,6 +21,9 @@ auth:
direct:
enabled: true
provider: local
initialSettings:
passwordLoginEnabled: true
selfRegistrationEnabled: true
oauth2:
feishu:
@ -103,6 +106,8 @@ secrets:
# 默认禁止随机 Secret;仅在非 GitOps 临时环境中按需启用
allowAutoGenerated: false
bootstrapAdminPassword: ""
# JSON array of {provider,email,role}; used only while the database is empty.
initialRoleGrantsJson: ""
downloadAnonCookieSecret: ""
oauth2GithubClientId: ""
oauth2GithubClientSecret: ""

View file

@ -0,0 +1,31 @@
# 登录开关与外部账号首次授权
超级管理员在“系统配置”中分别控制本地密码登录和本地账号自行注册,默认都开启。关闭密码登录后,本地注册也暂时不可用,因为当前注册流程会建立本地会话;注册开关的原值会保留。关闭密码登录还会阻止密码重置、密码修改和直接密码认证。已有会话按原有效期结束,不会立刻踢下线。
关闭密码登录前,页面会提示可能失去管理入口,但允许继续。部署方应先确认至少有一位超级管理员能通过外部身份登录。若锁定管理入口,使用受控数据库操作恢复 `system_setting` 中 `auth.local` 的 `passwordLoginEnabled`,并记录操作;仅修改启动参数不会覆盖已有数据库设置。
## 空库初始化
全新安装且尚无用户时,启动参数提供初始值。已有用户的实例升级时,若尚无 `auth.local` 记录,首次启动会把两个开关都设为开启,以保持原有登录行为:
| 环境变量 | Helm 值 | 默认值 |
| --- | --- | --- |
| `SKILLHUB_AUTH_INITIAL_PASSWORD_LOGIN_ENABLED` | `auth.initialSettings.passwordLoginEnabled` | `true` |
| `SKILLHUB_AUTH_INITIAL_SELF_REGISTRATION_ENABLED` | `auth.initialSettings.selfRegistrationEnabled` | `true` |
| `SKILLHUB_AUTH_INITIAL_ROLE_GRANTS_JSON` | `secrets.initialRoleGrantsJson` | `[]` |
首次授权规则的 JSON 示例:
```json
[{"provider":"github","email":"admin@example.com","role":"SUPER_ADMIN"}]
```
规则仅在第一次初始化且 `user_account` 为空时写入;初始化标记防止以后删除规则再重启时重复创建。正式环境请通过 Secret 提供 JSON,不要将实际邮箱写进公开的 values 文件。初始化以后,数据库和“系统配置”页面是权威来源。
## 授权边界
规则按身份来源代码和**已验证邮箱**匹配。只有外部身份通过现有准入策略、并且新账号首次创建为可用状态时,才给新账号授予选定平台角色;规则随后标为“已授权”,不会再次使用。拒绝或待审批的登录不会触发授权,规则不会绕过准入策略。已有账号请在“用户管理”中直接授权。相同邮箱的本地账号不会因此合并或获得角色。
当前飞书和钉钉适配器把邮箱标记为未验证:它们的用户资料没有提供可证明用户控制该邮箱的信号。因此后台和初始化均拒绝为 `feishu` 或 `dingtalk` 配置邮箱规则。GitHub 的已验证主邮箱、提供 `email_verified=true` 的 OIDC 身份等可匹配。飞书或钉钉账号首次进入后,可由已有超级管理员在用户管理中授权;如果部署方只允许飞书或钉钉登录,必须先安排其他可用的管理员入口。不能为了触发规则而把未验证邮箱改成已验证。
后台可以新增规则、调整待匹配规则的角色、停用规则,并查看已使用规则的匹配身份和授权账号。只允许超级管理员操作;修改带版本号,避免两个管理员同时改动时后写覆盖前写。紧急恢复可通过受控数据库操作处理,并保留审计记录。

View file

@ -0,0 +1,57 @@
## Context
当前 `OAuthLoginFlowService` 每次外部登录都先调用 `AccessPolicy.evaluate`,只有 `ALLOW` 才进入 `IdentityBindingService.bindOrCreate`。主线新增的统一身份核心在公开 OAuth 路径仍保留旧 `identity_binding` 写入权威;`LegacyPlatformIdentityCoreBridge` 不执行按已验证邮箱关联旧账号,不能把本需求实现成邮箱自动合并。现有四种可配置准入策略实际只返回 `ALLOW` 或 `DENY`;`PENDING_APPROVAL` 是预留分支。外部身份以 `(provider_code, subject)` 绑定用户,已验证邮箱才可作为可信邮箱。平台已有 `SUPER_ADMIN`、`SKILL_ADMIN`、`USER_ADMIN`、`AUDITOR` 角色;后台用户管理一次设置一个平台角色。现有 `BootstrapAdminInitializer` 会在启用时于每次启动检查并补建本地密码超管,它不是本方案的一次性外部角色规则。
## 术语
- **普通准入**:外部身份能否进入 SkillHub 的现有策略判断,与平台角色授予分开。
- **首次角色授权规则**:部署方为指定外部来源和已验证邮箱预设的单次平台角色授予。它只作用于首次创建的 SkillHub 账号。
- **已消费规则**:已绑定一个 `(provider_code, subject)` 和 SkillHub 用户并完成角色写入的规则。停用或删除未消费规则不撤销历史角色。
- **运行设置**:由数据库管理、后台可修改的非秘密系统行为设置;不同于 OAuth 密钥等部署秘密。
## Decisions
### 1. 统一页面、分表持久化
“系统配置”页面分为“本地认证”和“外部账号首次授权”两部分。`system_setting` 保存少量经过代码注册、定型和校验的运行设置,不提供任意键值编辑。首批设置键 `auth.local` 的 JSON 对象包含 `passwordLoginEnabled` 和 `selfRegistrationEnabled`,两者默认均为 `true`。同一行更新这组设置,并使用版本号进行条件更新,避免并发管理操作互相覆盖。
页面内两个开关先形成未保存草稿,由管理员统一保存或放弃。关闭密码登录时,在站内确认框中展示管理员可能无法重新登录的后果,确认后才提交;取消确认保留草稿,不修改服务端。首次授权规则的角色变更也需显式保存,停用规则使用站内确认框。失败时保留可见的错误提示和原编辑内容。
建议表结构:`id BIGSERIAL PRIMARY KEY`、`setting_key VARCHAR(128) UNIQUE NOT NULL`、`value_json JSONB NOT NULL`、`version BIGINT NOT NULL`、`created_at/updated_at TIMESTAMP NOT NULL`、`updated_by VARCHAR(128) NULL`。数据库约束验证 JSON 为对象;应用按设置键验证完整字段、类型和允许值。`id` 可用作现有 `audit_log.target_id`。审计记录操作者、旧值、新值和时间;不得把秘密配置写入设置或审计详情。
认证路径直接读取数据库中已持久化的安全开关。首版不使用单节点内存缓存,以免多副本部署时设置失效不一致;读取失败应返回服务错误,不能以默认“开启”放行。前端只读取可公开的认证能力投影,不暴露通用设置读写接口。
### 2. 两个本地开关保持独立
关闭密码登录后,所有发起新本地密码认证的路径均拒绝,包括现有 direct 本地认证路径;密码重置和密码修改操作也不再提供。由于当前本地注册成功会立即建立会话,即使自行注册开关的存储值仍为开启,本地注册也必须暂时不可用;重新开启密码登录后恢复该开关原有值。登录页不展示本地密码表单、注册和重置密码入口,个人设置不展示密码修改入口,直接访问相关页面得到明确的不可用提示。已有会话不批量失效,按现有会话生命周期结束。
关闭自行注册后,仅本地注册 API 和页面不可用。已有本地账号仍可密码登录、重置密码和修改密码;外部身份首次进入仍遵守现有普通准入策略,不受此开关影响。
超级管理员关闭密码登录时,页面明确提示若外部登录不可用可能失去管理入口;服务端不强制阻止保存。外部身份提供方实际连通性无法由“已配置”推断,部署方负责核验。紧急恢复使用受控数据库操作。
### 3. 普通准入优先,首次创建才授权
外部登录顺序为:验证外部身份 → 执行现有普通准入 → 若为 `ALLOW`,按 `(provider_code, subject)` 查找或创建 SkillHub 账号 → **仅新建 ACTIVE 账号**匹配首次角色规则 → 在同一事务中写入角色、规则消费状态和身份绑定 → 生成包含新角色的登录主体及会话。`DENY` 不创建账号、不消费规则、不授予角色。已存在账号即使后来新增规则,也不在下次登录时自动授权;使用现有用户管理页面人工修改角色。后续每次外部登录仍按现有逻辑重新检查普通准入。
首版规则以 `provider_code` 和规范化的**已验证邮箱**匹配。未返回邮箱、邮箱未验证、来源不匹配或账号已存在都不授予角色。匹配成功后记录实际 `(provider_code, subject)` 和用户 ID;规则不能因邮箱回收而再次授予另一身份。后台为每条规则选择一个现有平台角色,不创建自定义角色或命名空间角色,也不把角色写死为 `SUPER_ADMIN`。规则消费后修改或停用不改动已写入 `user_role_binding` 的角色。
当前飞书和钉钉适配器没有可证明邮箱已验证的资料,明确返回 `emailVerified=false`,因此这两个来源的邮箱规则首版不会被消费。不能为实现首次授权而将它们标记为已验证。部署方须使用已验证邮箱来源,或先保留其他超级管理员入口并在首次登录后人工授权;飞书、钉钉的可信身份依据需要单独设计。
现有公开 OAuth 账号不按邮箱自动合并:即使本地账号已经使用相同邮箱,首次进入的外部身份仍会创建独立账号;若命中规则,角色授予这个新外部账号,不授予本地账号。规则配置页须明确提示“同邮箱不代表同一 SkillHub 账号”,并在消费结果中展示实际获授角色的账号与外部来源,防止管理员误认授权对象。角色授予接入当前公开 OAuth 的账号创建事务,不改变统一身份核心已有的关联或准入决策。
建议独立表保存 `id`、`provider_code`、`normalized_email`、`role_id`、`status`(`ACTIVE`/`DISABLED`/`CONSUMED`)、`matched_subject`、`granted_user_id`、`granted_at`、`created_by`、`updated_by`、时间戳和并发版本。最多允许一条同一来源与规范化邮箱的 ACTIVE 规则;消费记录保留用于审计。首次绑定、角色写入和规则消费必须原子完成,并通过身份唯一约束及规则条件更新处理并发首次登录。若规则在登录时被停用,以事务中读取并确认的当前状态为准。
当前 `PENDING_APPROVAL` 没有实际策略来源。本变更不定义待审批账号的延迟自动授予;未来启用该策略时,需要单独确定审批后的规则消费时点,不能把 PENDING 账号当作已获角色的成功登录。
### 4. 部署参数只初始化一次
首次部署时,部署参数为缺失的 `auth.local` 设置行提供初始值,并可为新安装提供初始外部角色规则。初始化结果及“已初始化”状态必须持久化。此后即使规则表被清空或某设置被后台修改,重启也不能重新灌入旧部署值。新版本新增的设置键可独立补入默认值,但不得覆盖旧键。初始化角色规则必须验证来源、邮箱格式、目标角色与重复项;错误不能静默退化为超管授权。
`BOOTSTRAP_ADMIN_*` 当前用于本地密码管理员,仍按现有独立机制处理;本变更不把它迁入数据库,也不改变其启动行为。OAuth 客户端密钥、数据库凭证等继续放部署秘密配置。新安装若同时关闭本地登录,部署方必须确保外部来源、普通准入策略和初始角色规则相互匹配;规则不会绕过准入。
## Alternatives considered
- **管理员规则绕过普通准入**:拒绝。会改变现有每次登录先检查准入的行为,并引入长期准入例外。
- **每次登录按规则同步角色**:拒绝。会覆盖或重新授予人工调整过的权限,也与“停用只阻止新授权”冲突。
- **所有数据放 `system_setting` JSON**:拒绝。逐人规则需要独立生命周期、唯一性、并发消费和审计。
- **关闭本地登录时强制阻止可能锁定的配置**:拒绝。外部提供方是否实际可用无法可靠静态判断;页面明确提示后允许部署方决定。

View file

@ -0,0 +1,28 @@
## Why
Issue #923 需要让采用外部身份认证的部署方分别关闭本地密码登录和本地自行注册。现有本地入口始终可见,后端也没有对应开关;仅依赖部署参数无法让管理员在后台维护运行设置。纯外部登录部署还需要一种明确指定外部账号首次进入 SkillHub 时所获平台角色的方式,同时保留现有 OAuth 准入优先级。
## What Changes
- 增加统一的后台“系统配置”页面。两个本地认证开关存入 `system_setting`;外部账号首次角色授权规则存入独立表。只有超级管理员可修改,操作进入现有审计日志。
- 本地密码登录和本地自行注册分别控制,默认保持现状。关闭登录时,前后端停止新的密码认证和密码管理操作;已建立的会话按原有效期处理。关闭注册只停止本地自行注册,不影响已有账号的密码登录或外部身份首次进入。
- 外部身份仍先经过现有普通准入策略。仅当准入允许且外部身份首次创建 ACTIVE 账号时,匹配来源与已验证邮箱,授予规则指定的一个平台角色。规则不覆盖准入拒绝,也不在已有账号下次登录时补授权。
- 部署参数仅用于首次初始化缺失的运行设置和空库的初始外部角色规则;初始化状态持久化。之后数据库和后台页面是权威来源,删除规则后重启不得复活。紧急恢复使用受控数据库操作,不增加专用服务器命令。
- 关闭本地登录时展示管理入口锁定风险,但允许超级管理员继续保存。外部身份提供方凭证等秘密继续由部署配置管理,不进入 `system_setting`。
## Capabilities
### New Capabilities
- `system-auth-settings`:数据库持久化的本地认证开关、后台配置、公开能力展示和一次性初始化。
- `initial-external-role-grants`:外部身份首次创建账号时的一次性平台角色授予规则。
## Impact
- 后端认证、OAuth 首次账号创建、数据库迁移、审计和后台管理 API。
- 登录、注册、重置密码、个人设置和后台系统配置页面。
- 部署参数仅增加初始化输入;现有准入策略、已建会话和人工用户角色管理语义保持不变。
## Status
已按本变更实现功能,并完成相关单测、PostgreSQL 并发测试和本地 Compose/浏览器验收。真实第三方 OAuth 登录未在本地预览中接入,准入与角色授予由服务测试覆盖。

View file

@ -0,0 +1,80 @@
## Purpose
允许部署方为外部身份首次创建的 SkillHub 账号预设一个平台角色,同时保留普通准入和人工角色管理的现有行为。
## ADDED Requirements
### Requirement: REQ-IERG-01 外部角色规则 SHALL 只在准入允许后的首次账号创建时生效
系统 SHALL 先执行现有普通准入判断。只有 `ALLOW` 且 `(provider_code, subject)` 尚无 SkillHub 账号时,才可匹配 ACTIVE 规则。规则 SHALL 要求来源和规范化的已验证邮箱一致,并授予所选的一个现有平台角色。授权写入 SHALL 在登录主体和会话建立前完成。
#### Scenario: 新账号匹配规则
- **WHEN** 外部身份通过普通准入,来源和已验证邮箱命中 ACTIVE 规则,且身份首次创建账号
- **THEN** 系统创建 ACTIVE 账号、身份绑定和角色绑定,并消费规则
- **AND** 首次登录主体包含该平台角色
#### Scenario: 普通准入拒绝
- **WHEN** 外部身份虽命中管理员规则,但普通准入结果为 `DENY`
- **THEN** 系统拒绝登录,不创建账号、不消费规则、不授予角色
#### Scenario: 邮箱不可信
- **WHEN** 外部来源未提供邮箱,或邮箱未验证
- **THEN** 系统不通过邮箱规则自动授予角色
#### Scenario: 来源不提供邮箱验证信号
- **WHEN** 管理员尝试为当前始终返回未验证邮箱的飞书或钉钉来源配置首次角色规则
- **THEN** 系统拒绝创建无法生效的规则,并说明该来源不支持已验证邮箱匹配
#### Scenario: 已有普通账号后来出现规则
- **WHEN** 已有身份绑定的普通账号再次登录,后台才新增对应规则
- **THEN** 登录不消费该规则,也不自动改变已有角色
- **AND** 管理员可通过现有用户管理功能人工授权
#### Scenario: 同邮箱的本地账号与外部账号
- **WHEN** 本地账号已使用某邮箱,另一外部身份首次进入且以同一已验证邮箱命中规则
- **THEN** 系统按现有身份绑定逻辑创建独立的外部账号,并把角色授予该新账号
- **AND** 不把角色授予同邮箱的本地账号,也不自动合并两个账号
### Requirement: REQ-IERG-02 规则消费和角色授予 SHALL 原子且一次性
系统 SHALL 在同一数据库事务内完成首次身份绑定、角色写入和规则消费。消费记录 SHALL 绑定实际来源、subject 和用户,防止邮箱再次归属另一人后重复授权。并发首次登录 SHALL 最多授予同一用户一次,不得把规则授予两个账号。
#### Scenario: 并发首次登录
- **WHEN** 同一外部身份几乎同时发起两次首次登录
- **THEN** 最终只有一个账号和身份绑定,规则只消费一次,角色结果一致
#### Scenario: 规则授予后停用
- **WHEN** 规则已经授予角色,管理员随后停用或删除该规则
- **THEN** 已有角色保持,已有账号以后登录仍按普通准入策略判断
- **AND** 规则不能再次授予另一外部身份
#### Scenario: 人工修改角色
- **WHEN** 管理员在用户管理页面修改已获角色的账号
- **THEN** 后续登录不因旧规则重新授予或覆盖角色
### Requirement: REQ-IERG-03 规则管理 SHALL 限定超管并保留审计
系统 SHALL 在统一“系统配置”页面提供规则增改、停用和消费结果查看。只允许超级管理员管理规则。角色只能从已存在的平台角色中选择,每条规则只指定一个角色。规则变更和自动授予 SHALL 记录审计,不得泄漏 OAuth 凭证。
规则页面 SHALL 告知管理员“同邮箱不代表同一 SkillHub 账号”,消费结果 SHALL 展示实际获授角色的账号和外部来源。
#### Scenario: 非超管试图配置超级管理员规则
- **WHEN** 非超级管理员直接调用规则写入 API
- **THEN** 系统拒绝请求,且规则和角色绑定均不改变
#### Scenario: 重复有效规则
- **WHEN** 同一外部来源和规范化邮箱已有 ACTIVE 规则
- **THEN** 系统拒绝第二条同时生效的规则
### Requirement: REQ-IERG-04 部署初始化 SHALL 一次性且不复活已删除规则
新安装可用部署参数提供初始角色规则,但系统 SHALL 持久化初始化状态。后续由数据库和后台页面管理;表为空不构成再次初始化条件。紧急恢复可由受控数据库操作完成。
#### Scenario: 管理员删除全部规则后重启
- **WHEN** 初始化已完成,管理员删除或停用所有规则,而旧部署参数仍存在
- **THEN** 重启不重新创建这些规则
#### Scenario: 初始规则不符合普通准入
- **WHEN** 初始管理员规则指向的身份被现有普通准入策略拒绝
- **THEN** 规则不绕过准入,也不完成授权
- **AND** 部署方需调整普通准入或规则配置

View file

@ -0,0 +1,71 @@
## Purpose
让部署方分别控制本地密码登录和自行注册,并在统一后台页面管理非秘密运行设置。
## ADDED Requirements
### Requirement: REQ-SAS-01 两个本地认证开关 SHALL 独立且默认保持现状
系统 SHALL 提供密码登录与本地自行注册两个独立布尔开关。未配置时两者 SHALL 默认为开启。关闭任一开关 SHALL 同时作用于前端入口和对应后端请求。
#### Scenario: 只关闭自行注册
- **WHEN** `selfRegistrationEnabled=false` 且 `passwordLoginEnabled=true`
- **THEN** 本地注册页面和 API 不可用
- **AND** 现有本地账号仍可登录和使用密码管理功能
- **AND** 外部身份首次进入仍按现有准入策略处理
#### Scenario: 只关闭密码登录
- **WHEN** `passwordLoginEnabled=false` 且 `selfRegistrationEnabled=true`
- **THEN** 密码登录、direct 本地认证、密码重置和密码修改均不可用
- **AND** 本地注册也暂时不可用,因为现有注册成功会直接建立会话
- **AND** 页面不展示上述入口,直接访问页面得到明确提示
- **AND** 注册开关仍保留自身设置值,重新开启密码登录后恢复其原有作用
#### Scenario: 已有会话
- **WHEN** 管理员关闭密码登录,而某本地账号已经建立会话
- **THEN** 该会话按现有有效期和失效机制处理
- **AND** 后续新密码认证被拒绝
### Requirement: REQ-SAS-02 系统设置 SHALL 由数据库管理并受权限及审计保护
系统 SHALL 将注册过的非秘密运行设置持久化在 `system_setting`,只允许超级管理员通过后台修改,使用版本条件防止并发覆盖,并记录现有审计日志。公开认证能力接口 SHALL 只暴露登录页所需的安全字段。
#### Scenario: 并发修改
- **WHEN** 两名管理员基于同一旧版本分别提交修改
- **THEN** 只有先成功提交的修改生效,另一请求收到可识别的版本冲突
#### Scenario: 读取设置失败
- **WHEN** 新认证请求无法读取数据库中的本地认证设置
- **THEN** 请求失败并给出服务不可用结果
- **AND** 不使用默认开启值放行认证
#### Scenario: 非超级管理员修改
- **WHEN** 普通用户或非超管管理员直接调用设置写入 API
- **THEN** 系统拒绝请求且不修改设置
### Requirement: REQ-SAS-03 部署参数 SHALL 只初始化缺失的设置
新安装首次初始化时 SHALL 使用部署参数或代码默认值填充缺失设置,并持久化初始化状态。已经持久化的设置 SHALL 不被后续部署参数或重启覆盖。外部服务秘密不得存入设置表。
已有用户的实例升级且首次缺失 `auth.local` 时 SHALL 初始化为两个开关均开启,保持原有认证行为;部署参数不应在该升级场景关闭本地认证。
#### Scenario: 后台关闭登录后重启
- **WHEN** 后台已持久化 `passwordLoginEnabled=false`,部署参数仍写着开启
- **THEN** 重启后数据库设置仍为关闭
#### Scenario: 已有用户的实例首次升级
- **WHEN** 数据库已有用户,但首次升级时还没有 `auth.local`,且部署参数设为关闭本地密码登录
- **THEN** 系统把两个开关初始化为开启,不因部署参数切断原有登录入口
#### Scenario: 新版本增加设置键
- **WHEN** 升级版本新增注册过的设置键
- **THEN** 系统只为新键补入默认值,不修改既有键
### Requirement: REQ-SAS-04 关闭本地登录 SHALL 展示风险但不阻止保存
后台页面 SHALL 告知超级管理员:外部登录不可用时可能失去管理入口。服务端 SHALL 接受其关闭本地登录的合法请求,而不根据外部账号是否已绑定进行硬拦截。
#### Scenario: 没有已绑定的外部超管
- **WHEN** 超级管理员确认关闭本地密码登录
- **THEN** 页面展示管理入口风险,保存仍可完成
- **AND** 不误称已经验证外部身份提供方可用

View file

@ -0,0 +1,25 @@
## 1. 持久化与初始化
- [x] 增加 `system_setting` 和外部首次角色规则表、约束与审计目标。
- [x] 实现部署参数一次性初始化与持久化标记,验证重启和清空规则后不会重灌(单测)。
- [x] 实现设置键注册、类型校验、版本条件更新和数据库读取失败处理。
## 2. 认证与授权
- [x] 在本地登录、direct 本地认证、注册和密码管理服务端入口执行对应开关;关闭密码登录时一并阻止会直接建会话的本地注册。
- [x] 在 OAuth 普通准入允许后的首次 ACTIVE 账号创建事务中匹配并消费角色规则,首次主体包含新角色。
- [x] 在统一身份核心的 `LEGACY`、`SHADOW`、`ACTIVE` 模式下核对公开 OAuth 接入点;保持旧身份绑定写入权威与同邮箱不自动合并的现有行为(`OAuthLoginFlowServiceTest`、`IdentityBindingServiceTest`)。
- [x] 保持已有账号、准入拒绝、未验证邮箱、停用规则、并发首次登录和人工角色修改的既定行为(OAuth 和授权单测、PostgreSQL 同身份及同邮箱不同身份并发测试与人工撤权测试)。
## 3. API 与 Web
- [x] 增加超级管理员可读写的系统设置与规则 API,增加登录页所需的公开认证能力投影。
- [x] 建立统一“系统配置”页面,显示两开关、角色规则、消费结果及关闭本地登录的风险提示。
- [x] 调整登录、注册、重置密码和个人设置入口;直接访问已关闭页面时显示明确结果。
- [x] 更新 OpenAPI、部署说明和用户操作说明。
## 4. 验收
- [x] 验证两个开关的四种组合、直接 API 绕过尝试、已有会话、DB 故障与多实例设置可见性(本地 Compose HTTP 实测;DB 故障与两个服务实例读取使用 `LocalAuthSettingsServiceTest`)。
- [x] 验证首次登录授权、准入优先、已有账号不补授权、同邮箱独立账号、邮箱验证、规则停用、角色人工修改和并发首次登录(OAuth/授权单测与 PostgreSQL 真实事务测试;本地 Compose 未接入真实外部 IdP)。
- [x] 验证一次性部署初始化、角色规则删除后重启、非超管越权、审计记录和浏览器页面流程(初始化单测、Compose 权限与审计实测、PostgreSQL 自动授权审计断言、Playwright 浏览器操作;常驻 smoke 覆盖公开能力与后台配置读取)。

View file

@ -98,6 +98,7 @@ check_health "Health endpoint" "$ACTUATOR_BASE_URL/actuator/health"
check_protected_actuator "Prometheus metrics requires auth" "$ACTUATOR_BASE_URL/actuator/prometheus"
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
check "Local auth capabilities" "$BASE_URL/api/v1/auth/local/capabilities" "200"
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)"
@ -227,6 +228,26 @@ fi
# Refresh CSRF after login
ADMIN_CSRF="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$ADMIN_COOKIE_JAR" | tail -n 1)"
SYSTEM_CONFIG_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-b "$ADMIN_COOKIE_JAR" "$BASE_URL/api/v1/admin/system-config/auth/local" || true)"
if [[ "$SYSTEM_CONFIG_STATUS" == "200" ]]; then
echo "PASS: Read system auth settings (HTTP $SYSTEM_CONFIG_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Read system auth settings (got $SYSTEM_CONFIG_STATUS)"
FAIL=$((FAIL + 1))
fi
ROLE_GRANTS_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-b "$ADMIN_COOKIE_JAR" "$BASE_URL/api/v1/admin/system-config/role-grants" || true)"
if [[ "$ROLE_GRANTS_STATUS" == "200" ]]; then
echo "PASS: Read initial role grant rules (HTTP $ROLE_GRANTS_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Read initial role grant rules (got $ROLE_GRANTS_STATUS)"
FAIL=$((FAIL + 1))
fi
# Create label definition
CREATE_LABEL_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/admin/labels" \

View file

@ -0,0 +1,122 @@
package com.iflytek.skillhub.bootstrap;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRule;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRuleRepository;
import com.iflytek.skillhub.auth.settings.InitialExternalRoleGrantService;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.settings.SystemSetting;
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.ConnectionCallback;
import java.sql.Statement;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/** Seeds deployment defaults once, before other application runners can create a local admin. */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class InitialAuthSettingsInitializer implements ApplicationRunner {
private static final String GRANT_SEED_MARKER = "auth.initial-role-grants.initialized";
private static final Pattern PROVIDER = Pattern.compile("[a-z0-9][a-z0-9_-]{0,63}");
private static final Pattern EMAIL = Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");
private final InitialAuthSettingsProperties properties;
private final SystemSettingRepository settings;
private final ExternalRoleGrantRuleRepository rules;
private final RoleRepository roles;
private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate;
public InitialAuthSettingsInitializer(InitialAuthSettingsProperties properties,
SystemSettingRepository settings,
ExternalRoleGrantRuleRepository rules,
RoleRepository roles,
ObjectMapper objectMapper,
JdbcTemplate jdbcTemplate) {
this.properties = properties;
this.settings = settings;
this.rules = rules;
this.roles = roles;
this.objectMapper = objectMapper;
this.jdbcTemplate = jdbcTemplate;
}
@Override
@Transactional
public void run(ApplicationArguments args) {
// Serialize initialization across PostgreSQL replicas before checking missing rows.
jdbcTemplate.execute((ConnectionCallback<Void>) connection -> {
if ("PostgreSQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName())) {
try (Statement statement = connection.createStatement()) {
statement.execute("SELECT pg_advisory_xact_lock(92320261010)");
}
}
return null;
});
Long existingUsers = null;
if (settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY).isEmpty()) {
existingUsers = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Long.class);
boolean newInstallation = existingUsers == 0L;
settings.save(new SystemSetting(LocalAuthSettingsService.SETTING_KEY, Map.of(
LocalAuthSettingsService.PASSWORD_LOGIN_KEY,
newInstallation ? properties.isPasswordLoginEnabled() : true,
LocalAuthSettingsService.SELF_REGISTRATION_KEY,
newInstallation ? properties.isSelfRegistrationEnabled() : true)));
}
if (settings.findBySettingKey(GRANT_SEED_MARKER).isPresent()) {
return;
}
if ((existingUsers != null ? existingUsers
: jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Long.class)) == 0L) {
seedInitialRules();
}
settings.save(new SystemSetting(GRANT_SEED_MARKER, Map.of("initialized", true)));
}
private void seedInitialRules() {
List<SeedRule> initialRules;
try {
initialRules = objectMapper.readValue(properties.getRoleGrantsJson(), new TypeReference<>() {});
} catch (IOException | IllegalArgumentException exception) {
throw new IllegalStateException("Invalid initial external role grants JSON", exception);
}
if (initialRules == null) {
throw new IllegalStateException("Initial external role grants must be an array");
}
Set<String> identities = new HashSet<>();
for (SeedRule rule : initialRules) {
if (rule == null) throw new IllegalStateException("Initial role grant may not be null");
String provider = InitialExternalRoleGrantService.normalize(rule.provider());
String email = InitialExternalRoleGrantService.normalize(rule.email());
if (!PROVIDER.matcher(provider).matches() || !EMAIL.matcher(email).matches()) {
throw new IllegalStateException("Invalid initial role grant identity");
}
if ("feishu".equals(provider) || "dingtalk".equals(provider)) {
throw new IllegalStateException("Provider does not attest verified email for initial role grants: " + provider);
}
if (!identities.add(provider + ":" + email)) {
throw new IllegalStateException("Duplicate initial role grant identity");
}
Role role = roles.findByCode(rule.role())
.filter(Role::isSystem)
.orElseThrow(() -> new IllegalStateException("Invalid initial role grant role"));
rules.save(new ExternalRoleGrantRule(provider, email, role, null));
}
}
public record SeedRule(String provider, String email, String role) {}
}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.bootstrap;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "skillhub.auth.initial-settings")
public class InitialAuthSettingsProperties {
private boolean passwordLoginEnabled = true;
private boolean selfRegistrationEnabled = true;
private String roleGrantsJson = "[]";
public boolean isPasswordLoginEnabled() { return passwordLoginEnabled; }
public void setPasswordLoginEnabled(boolean enabled) { this.passwordLoginEnabled = enabled; }
public boolean isSelfRegistrationEnabled() { return selfRegistrationEnabled; }
public void setSelfRegistrationEnabled(boolean enabled) { this.selfRegistrationEnabled = enabled; }
public String getRoleGrantsJson() { return roleGrantsJson; }
public void setRoleGrantsJson(String roleGrantsJson) { this.roleGrantsJson = roleGrantsJson; }
}

View file

@ -5,12 +5,14 @@ import com.iflytek.skillhub.auth.local.PasswordResetService;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AuthMeResponse;
import com.iflytek.skillhub.dto.ChangePasswordRequest;
import com.iflytek.skillhub.dto.LocalLoginRequest;
import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.dto.LocalAuthCapabilitiesResponse;
import com.iflytek.skillhub.dto.PasswordResetConfirmRequest;
import com.iflytek.skillhub.dto.PasswordResetRequestDto;
import com.iflytek.skillhub.exception.UnauthorizedException;
@ -23,6 +25,7 @@ import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@ -40,6 +43,7 @@ public class LocalAuthController extends BaseApiController {
private final AuthFailureThrottleService authFailureThrottleService;
private final PasswordResetService passwordResetService;
private final AuthMeResponseAssembler authMeResponseAssembler;
private final LocalAuthSettingsService authSettings;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
@ -47,7 +51,8 @@ public class LocalAuthController extends BaseApiController {
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService,
PasswordResetService passwordResetService,
AuthMeResponseAssembler authMeResponseAssembler) {
AuthMeResponseAssembler authMeResponseAssembler,
LocalAuthSettingsService authSettings) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
@ -55,6 +60,14 @@ public class LocalAuthController extends BaseApiController {
this.authFailureThrottleService = authFailureThrottleService;
this.passwordResetService = passwordResetService;
this.authMeResponseAssembler = authMeResponseAssembler;
this.authSettings = authSettings;
}
@GetMapping("/capabilities")
public ApiResponse<LocalAuthCapabilitiesResponse> capabilities() {
var current = authSettings.current();
return ok("response.success.read", new LocalAuthCapabilitiesResponse(
current.passwordLoginEnabled(), current.selfRegistrationEnabled(), current.registrationAvailable()));
}
@PostMapping("/register")

View file

@ -0,0 +1,93 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.ExternalRoleGrantCreateRequest;
import com.iflytek.skillhub.dto.ExternalRoleGrantRuleResponse;
import com.iflytek.skillhub.dto.ExternalRoleGrantUpdateRequest;
import com.iflytek.skillhub.dto.PlatformRoleResponse;
import com.iflytek.skillhub.dto.SystemAuthSettingsResponse;
import com.iflytek.skillhub.dto.SystemAuthSettingsUpdateRequest;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SystemAuthSettingsAppService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/admin/system-config")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public class SystemAuthSettingsController extends BaseApiController {
private final SystemAuthSettingsAppService service;
public SystemAuthSettingsController(SystemAuthSettingsAppService service, ApiResponseFactory responseFactory) {
super(responseFactory);
this.service = service;
}
@GetMapping("/auth/local")
public ApiResponse<SystemAuthSettingsResponse> getLocalSettings() {
return ok("response.success.read", service.getLocalSettings());
}
@PutMapping("/auth/local")
public ApiResponse<SystemAuthSettingsResponse> updateLocalSettings(
@Valid @RequestBody SystemAuthSettingsUpdateRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated", service.updateLocalSettings(
request, principal.userId(), AuditRequestContext.from(httpRequest)));
}
@GetMapping("/roles")
public ApiResponse<List<PlatformRoleResponse>> listRoles() {
return ok("response.success.read", service.listRoles());
}
@GetMapping("/role-grants")
public ApiResponse<List<ExternalRoleGrantRuleResponse>> listRoleGrants() {
return ok("response.success.read", service.listRules());
}
@PostMapping("/role-grants")
public ApiResponse<ExternalRoleGrantRuleResponse> createRoleGrant(
@Valid @RequestBody ExternalRoleGrantCreateRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.created", service.createRule(
request, principal.userId(), AuditRequestContext.from(httpRequest)));
}
@PutMapping("/role-grants/{id}")
public ApiResponse<ExternalRoleGrantRuleResponse> updateRoleGrant(
@PathVariable long id,
@Valid @RequestBody ExternalRoleGrantUpdateRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated", service.updateRule(
id, request, principal.userId(), AuditRequestContext.from(httpRequest)));
}
@DeleteMapping("/role-grants/{id}")
public ApiResponse<ExternalRoleGrantRuleResponse> disableRoleGrant(
@PathVariable long id,
@RequestParam long version,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated", service.disableRule(
id, version, principal.userId(), AuditRequestContext.from(httpRequest)));
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ExternalRoleGrantCreateRequest(
@NotBlank @Size(max = 64) String providerCode,
@NotBlank @Size(max = 256) String email,
@NotBlank @Size(max = 64) String roleCode
) {}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record ExternalRoleGrantRuleResponse(
long id,
String providerCode,
String email,
String roleCode,
String status,
String matchedSubject,
String grantedUserId,
Instant grantedAt,
long version,
Instant updatedAt
) {}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record ExternalRoleGrantUpdateRequest(
@NotBlank String roleCode,
@NotNull Long version
) {}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.dto;
public record LocalAuthCapabilitiesResponse(
boolean passwordLoginEnabled,
boolean selfRegistrationEnabled,
boolean registrationAvailable
) {}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.dto;
public record PlatformRoleResponse(String code, String name) {}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record SystemAuthSettingsResponse(
boolean passwordLoginEnabled,
boolean selfRegistrationEnabled,
long version,
Instant updatedAt
) {}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotNull;
public record SystemAuthSettingsUpdateRequest(
@NotNull Boolean passwordLoginEnabled,
@NotNull Boolean selfRegistrationEnabled,
@NotNull Long version
) {}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.dto.AuthMeResponse;
import org.springframework.stereotype.Service;
@ -13,15 +14,19 @@ import org.springframework.stereotype.Service;
public class AuthMeResponseAssembler {
private final LocalCredentialRepository localCredentialRepository;
private final LocalAuthSettingsService authSettings;
public AuthMeResponseAssembler(LocalCredentialRepository localCredentialRepository) {
public AuthMeResponseAssembler(LocalCredentialRepository localCredentialRepository,
LocalAuthSettingsService authSettings) {
this.localCredentialRepository = localCredentialRepository;
this.authSettings = authSettings;
}
public AuthMeResponse from(PlatformPrincipal principal) {
return AuthMeResponse.from(
principal,
localCredentialRepository.existsByUserId(principal.userId())
authSettings.current().passwordLoginEnabled()
&& localCredentialRepository.existsByUserId(principal.userId())
);
}
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
import com.iflytek.skillhub.auth.direct.DirectAuthProvider;
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
@ -28,17 +29,20 @@ public class AuthMethodCatalog {
private final AuthSessionBootstrapProperties sessionBootstrapProperties;
private final List<DirectAuthProvider> directAuthProviders;
private final List<PassiveSessionAuthenticator> passiveSessionAuthenticators;
private final LocalAuthSettingsService authSettings;
public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties,
DirectAuthProperties directAuthProperties,
AuthSessionBootstrapProperties sessionBootstrapProperties,
List<DirectAuthProvider> directAuthProviders,
List<PassiveSessionAuthenticator> passiveSessionAuthenticators) {
List<PassiveSessionAuthenticator> passiveSessionAuthenticators,
LocalAuthSettingsService authSettings) {
this.oAuth2ClientProperties = oAuth2ClientProperties;
this.directAuthProperties = directAuthProperties;
this.sessionBootstrapProperties = sessionBootstrapProperties;
this.directAuthProviders = directAuthProviders;
this.passiveSessionAuthenticators = passiveSessionAuthenticators;
this.authSettings = authSettings;
}
public List<AuthProviderResponse> listOAuthProviders(String returnTo) {
@ -70,14 +74,17 @@ public class AuthMethodCatalog {
public List<AuthMethodResponse> listMethods(String returnTo) {
String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
List<AuthMethodResponse> methods = new ArrayList<>();
boolean passwordEnabled = authSettings.current().passwordLoginEnabled();
methods.add(new AuthMethodResponse(
"local-password",
"PASSWORD",
"local",
"Local Account",
"/api/v1/auth/local/login"
));
if (passwordEnabled) {
methods.add(new AuthMethodResponse(
"local-password",
"PASSWORD",
"local",
"Local Account",
"/api/v1/auth/local/login"
));
}
oAuth2ClientProperties.getRegistration().entrySet().stream()
.filter(entry -> isValidOAuthProvider(entry.getValue()))
@ -94,6 +101,8 @@ public class AuthMethodCatalog {
if (directAuthProperties.isEnabled()) {
directAuthProviders.stream()
.filter(provider -> !"local".equals(provider.providerCode())
|| passwordEnabled)
.sorted(Comparator.comparing(DirectAuthProvider::providerCode))
.forEach(provider -> methods.add(new AuthMethodResponse(
"direct-" + provider.providerCode(),

View file

@ -0,0 +1,193 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRule;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRuleRepository;
import com.iflytek.skillhub.auth.settings.InitialExternalRoleGrantService;
import com.iflytek.skillhub.auth.settings.LocalAuthSettings;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.settings.SystemSetting;
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.ExternalRoleGrantCreateRequest;
import com.iflytek.skillhub.dto.ExternalRoleGrantRuleResponse;
import com.iflytek.skillhub.dto.ExternalRoleGrantUpdateRequest;
import com.iflytek.skillhub.dto.PlatformRoleResponse;
import com.iflytek.skillhub.dto.SystemAuthSettingsResponse;
import com.iflytek.skillhub.dto.SystemAuthSettingsUpdateRequest;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SystemAuthSettingsAppService {
private static final Pattern PROVIDER = Pattern.compile("[a-z0-9][a-z0-9_-]{0,63}");
private static final Pattern EMAIL = Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");
private final LocalAuthSettingsService localSettings;
private final SystemSettingRepository settings;
private final ExternalRoleGrantRuleRepository rules;
private final RoleRepository roles;
private final AuditLogService auditLog;
private final RequestIdAccessor requestIds;
public SystemAuthSettingsAppService(LocalAuthSettingsService localSettings,
SystemSettingRepository settings,
ExternalRoleGrantRuleRepository rules,
RoleRepository roles,
AuditLogService auditLog,
RequestIdAccessor requestIds) {
this.localSettings = localSettings;
this.settings = settings;
this.rules = rules;
this.roles = roles;
this.auditLog = auditLog;
this.requestIds = requestIds;
}
public SystemAuthSettingsResponse getLocalSettings() {
return toResponse(localSettings.current());
}
@Transactional
public SystemAuthSettingsResponse updateLocalSettings(SystemAuthSettingsUpdateRequest request,
String actorUserId,
AuditRequestContext context) {
SystemSetting setting = settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY)
.orElseThrow(() -> new IllegalStateException("Missing required auth.local setting"));
if (setting.getVersion() != request.version()) {
throw new DomainConflictException("error.system.settings.version.conflict");
}
Map<String, Object> previous = setting.getValue();
setting.update(Map.of(
LocalAuthSettingsService.PASSWORD_LOGIN_KEY, request.passwordLoginEnabled(),
LocalAuthSettingsService.SELF_REGISTRATION_KEY, request.selfRegistrationEnabled()), actorUserId);
settings.saveAndFlush(setting);
record(actorUserId, "SYSTEM_AUTH_SETTINGS_UPDATE", "SYSTEM_SETTING", setting.getId(), context,
AuditDetail.builder().put("previous", previous).put("current", setting.getValue()).build());
return getLocalSettings();
}
public List<PlatformRoleResponse> listRoles() {
return roles.findAll().stream().filter(Role::isSystem)
.sorted(Comparator.comparing(Role::getCode))
.map(role -> new PlatformRoleResponse(role.getCode(), role.getName()))
.toList();
}
public List<ExternalRoleGrantRuleResponse> listRules() {
return rules.findAllByOrderByCreatedAtDesc().stream().map(this::toResponse).toList();
}
@Transactional
public ExternalRoleGrantRuleResponse createRule(ExternalRoleGrantCreateRequest request,
String actorUserId,
AuditRequestContext context) {
String provider = validProvider(request.providerCode());
String email = validEmail(request.email());
if (rules.existsByProviderCodeAndNormalizedEmailAndStatus(
provider, email, ExternalRoleGrantRule.Status.ACTIVE)) {
throw new DomainConflictException("error.system.roleGrant.duplicate");
}
Role role = validRole(request.roleCode());
ExternalRoleGrantRule rule;
try {
rule = rules.saveAndFlush(new ExternalRoleGrantRule(provider, email, role, actorUserId));
} catch (DataIntegrityViolationException duplicate) {
throw new DomainConflictException("error.system.roleGrant.duplicate");
}
record(actorUserId, "INITIAL_ROLE_RULE_CREATE", "EXTERNAL_ROLE_GRANT_RULE", rule.getId(), context,
AuditDetail.builder().put("provider", provider).put("roleCode", role.getCode()).build());
return toResponse(rule);
}
@Transactional
public ExternalRoleGrantRuleResponse updateRule(long id, ExternalRoleGrantUpdateRequest request,
String actorUserId,
AuditRequestContext context) {
ExternalRoleGrantRule rule = loadRule(id);
requireActiveVersion(rule, request.version());
String oldRole = rule.getRole().getCode();
Role role = validRole(request.roleCode());
rule.update(role, actorUserId);
rules.saveAndFlush(rule);
record(actorUserId, "INITIAL_ROLE_RULE_UPDATE", "EXTERNAL_ROLE_GRANT_RULE", rule.getId(), context,
AuditDetail.builder().put("oldRole", oldRole).put("newRole", role.getCode()).build());
return toResponse(rule);
}
@Transactional
public ExternalRoleGrantRuleResponse disableRule(long id, long expectedVersion,
String actorUserId,
AuditRequestContext context) {
ExternalRoleGrantRule rule = loadRule(id);
requireActiveVersion(rule, expectedVersion);
rule.disable(actorUserId);
rules.saveAndFlush(rule);
record(actorUserId, "INITIAL_ROLE_RULE_DISABLE", "EXTERNAL_ROLE_GRANT_RULE", rule.getId(), context,
AuditDetail.of("status", rule.getStatus().name()));
return toResponse(rule);
}
private ExternalRoleGrantRule loadRule(long id) {
return rules.findById(id).orElseThrow(() -> new DomainNotFoundException("error.system.roleGrant.notFound"));
}
private void requireActiveVersion(ExternalRoleGrantRule rule, long expectedVersion) {
if (rule.getStatus() != ExternalRoleGrantRule.Status.ACTIVE || rule.getVersion() != expectedVersion) {
throw new DomainConflictException("error.system.roleGrant.conflict");
}
}
private Role validRole(String code) {
return roles.findByCode(code == null ? "" : code.trim().toUpperCase(java.util.Locale.ROOT))
.filter(Role::isSystem)
.orElseThrow(() -> new DomainBadRequestException("error.system.roleGrant.invalidRole"));
}
private String validProvider(String value) {
String normalized = InitialExternalRoleGrantService.normalize(value);
if (!PROVIDER.matcher(normalized).matches()) {
throw new DomainBadRequestException("error.system.roleGrant.invalidProvider");
}
if ("feishu".equals(normalized) || "dingtalk".equals(normalized)) {
throw new DomainBadRequestException("error.system.roleGrant.unverifiedProvider");
}
return normalized;
}
private String validEmail(String value) {
String normalized = InitialExternalRoleGrantService.normalize(value);
if (!EMAIL.matcher(normalized).matches()) {
throw new DomainBadRequestException("error.system.roleGrant.invalidEmail");
}
return normalized;
}
private SystemAuthSettingsResponse toResponse(LocalAuthSettings value) {
return new SystemAuthSettingsResponse(value.passwordLoginEnabled(), value.selfRegistrationEnabled(),
value.version(), value.updatedAt());
}
private ExternalRoleGrantRuleResponse toResponse(ExternalRoleGrantRule rule) {
return new ExternalRoleGrantRuleResponse(rule.getId(), rule.getProviderCode(), rule.getNormalizedEmail(),
rule.getRole().getCode(), rule.getStatus().name(), rule.getMatchedSubject(),
rule.getGrantedUserId(), rule.getGrantedAt(), rule.getVersion(), rule.getUpdatedAt());
}
private void record(String actor, String action, String targetType, Long targetId,
AuditRequestContext context, String detail) {
auditLog.record(actor, action, targetType, targetId, requestIds.current(),
context.clientIp(), context.userAgent(), detail);
}
}

View file

@ -153,6 +153,10 @@ skillhub:
sentinel:
check-sentinels-list: ${SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST:true}
auth:
initial-settings:
password-login-enabled: ${SKILLHUB_AUTH_INITIAL_PASSWORD_LOGIN_ENABLED:true}
self-registration-enabled: ${SKILLHUB_AUTH_INITIAL_SELF_REGISTRATION_ENABLED:true}
role-grants-json: '${SKILLHUB_AUTH_INITIAL_ROLE_GRANTS_JSON:[]}'
mock:
enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false}
direct:

View file

@ -0,0 +1,38 @@
CREATE TABLE system_setting (
id BIGSERIAL PRIMARY KEY,
setting_key VARCHAR(128) NOT NULL UNIQUE,
value_json JSONB NOT NULL CHECK (jsonb_typeof(value_json) = 'object'),
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(128)
);
CREATE TABLE external_role_grant_rule (
id BIGSERIAL PRIMARY KEY,
provider_code VARCHAR(64) NOT NULL,
normalized_email VARCHAR(256) NOT NULL,
role_id BIGINT NOT NULL REFERENCES role(id),
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'DISABLED', 'CONSUMED')),
matched_subject VARCHAR(256),
granted_user_id VARCHAR(128) REFERENCES user_account(id),
granted_at TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0,
created_by VARCHAR(128),
updated_by VARCHAR(128),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_external_role_grant_consumed CHECK (
(status = 'CONSUMED' AND matched_subject IS NOT NULL
AND granted_user_id IS NOT NULL AND granted_at IS NOT NULL)
OR
(status <> 'CONSUMED' AND matched_subject IS NULL
AND granted_user_id IS NULL AND granted_at IS NULL)
)
);
CREATE UNIQUE INDEX uq_external_role_grant_active_identity
ON external_role_grant_rule (provider_code, normalized_email)
WHERE status = 'ACTIVE';
CREATE INDEX idx_external_role_grant_status ON external_role_grant_rule (status);

View file

@ -44,6 +44,15 @@ error.auth.local.notEnabled=Local account login is not enabled for this user
error.auth.local.accountDisabled=This account has been disabled
error.auth.local.accountPending=This account is pending activation
error.auth.local.accountMerged=This account has been merged and can no longer be used to log in
error.auth.local.login.disabled=Local password login is disabled
error.auth.local.registration.disabled=Local account registration is disabled
error.system.settings.version.conflict=System settings changed. Refresh and try again.
error.system.roleGrant.duplicate=An active rule already exists for this provider and email
error.system.roleGrant.notFound=Role grant rule not found
error.system.roleGrant.conflict=Role grant rule changed or is no longer active. Refresh and try again.
error.system.roleGrant.invalidRole=Choose an existing platform role
error.system.roleGrant.invalidProvider=Enter a valid external login provider
error.system.roleGrant.invalidEmail=Enter a valid email address
error.auth.local.locked=Too many failed attempts. Please try again in {0} minute(s)
error.auth.login.throttled=Too many login attempts. Please try again in {0} minute(s)
error.auth.direct.disabled=Direct authentication compatibility is disabled
@ -273,3 +282,5 @@ promotion.revocation.not_found=Revocation request {0} was not found
promotion.revocation.not_pending=Revocation request {0} is no longer pending
promotion.revocation.required=Use the reviewed revocation workflow to remove a promoted skill
promotion.revocation.page_invalid=Page must be nonnegative and size must be between 1 and 100
error.auth.settings.unavailable=Authentication settings are unavailable
error.system.roleGrant.unverifiedProvider=This identity provider does not attest verified email and cannot use initial role rules

View file

@ -42,6 +42,15 @@ error.auth.local.notEnabled=Вход по локальной учётной за
error.auth.local.accountDisabled=Эта учётная запись отключена
error.auth.local.accountPending=Эта учётная запись ожидает активации
error.auth.local.accountMerged=Эта учётная запись объединена и больше не может использоваться для входа
error.auth.local.login.disabled=Вход по локальному паролю отключён
error.auth.local.registration.disabled=Регистрация локальных учётных записей отключена
error.system.settings.version.conflict=Системные настройки изменились. Обновите страницу и повторите попытку.
error.system.roleGrant.duplicate=Для этого источника и email уже есть активное правило
error.system.roleGrant.notFound=Правило назначения роли не найдено
error.system.roleGrant.conflict=Правило изменилось или больше не активно. Обновите страницу.
error.system.roleGrant.invalidRole=Выберите существующую роль платформы
error.system.roleGrant.invalidProvider=Укажите допустимый источник входа
error.system.roleGrant.invalidEmail=Укажите допустимый адрес электронной почты
error.auth.local.locked=Слишком много неудачных попыток. Повторите через {0} мин.
error.auth.login.throttled=Слишком много попыток входа. Повторите через {0} мин.
error.auth.direct.disabled=Совместимость прямой аутентификации отключена
@ -214,3 +223,5 @@ error.suite.bundle.operation.cancel.notAllowed=Эту операцию паке
error.suite.bundle.operation.retry.notAllowed=Повторить можно только заблокированную операцию Skill Suite, допускающую повтор
error.suite.bundle.member.stateChanged=Состояние связанного Skill или версии изменилось; создайте новый предварительный просмотр пакета Skill Suite
error.suite.bundle.actor.inactive=Пользователь операции Skill Suite Bundle больше не активен
error.auth.settings.unavailable=Настройки аутентификации временно недоступны
error.system.roleGrant.unverifiedProvider=Этот поставщик удостоверений не подтверждает адрес почты и не поддерживает правила первоначального назначения роли

View file

@ -44,6 +44,15 @@ error.auth.local.notEnabled=当前用户未启用本地账号登录
error.auth.local.accountDisabled=该账号已被禁用
error.auth.local.accountPending=该账号尚未激活
error.auth.local.accountMerged=该账号已合并,不能再用于登录
error.auth.local.login.disabled=本地密码登录已关闭
error.auth.local.registration.disabled=本地账号注册已关闭
error.system.settings.version.conflict=系统配置已变化,请刷新后重试
error.system.roleGrant.duplicate=该登录来源和邮箱已有生效的授权规则
error.system.roleGrant.notFound=授权规则不存在
error.system.roleGrant.conflict=授权规则已变化或不再生效,请刷新后重试
error.system.roleGrant.invalidRole=请选择已有的平台角色
error.system.roleGrant.invalidProvider=请输入有效的外部登录来源
error.system.roleGrant.invalidEmail=请输入有效的邮箱地址
error.auth.local.locked=连续失败次数过多,请在 {0} 分钟后重试
error.auth.login.throttled=登录尝试过于频繁,请在 {0} 分钟后重试
error.auth.direct.disabled=直连认证兼容层未启用
@ -273,3 +282,5 @@ promotion.revocation.not_found=撤销申请 {0} 不存在
promotion.revocation.not_pending=撤销申请 {0} 已不在待审核状态
promotion.revocation.required=已提升的技能须通过审核撤销流程删除
promotion.revocation.page_invalid=页码不能为负数,每页数量须在 1 到 100 之间
error.auth.settings.unavailable=认证配置暂时不可用
error.system.roleGrant.unverifiedProvider=该身份来源不提供已验证邮箱,不能用于首次角色授权规则

View file

@ -0,0 +1,118 @@
package com.iflytek.skillhub.bootstrap;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRuleRepository;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.settings.SystemSetting;
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.ApplicationArguments;
import org.springframework.jdbc.core.JdbcTemplate;
@ExtendWith(MockitoExtension.class)
class InitialAuthSettingsInitializerTest {
@Mock private SystemSettingRepository settings;
@Mock private ExternalRoleGrantRuleRepository rules;
@Mock private RoleRepository roles;
@Mock private JdbcTemplate jdbc;
@Mock private ApplicationArguments args;
@Test
void emptyDatabaseGetsDefaultSettingsAndPermanentRuleSeedMarker() {
when(settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY)).thenReturn(Optional.empty());
when(settings.findBySettingKey("auth.initial-role-grants.initialized")).thenReturn(Optional.empty());
when(jdbc.queryForObject("SELECT COUNT(*) FROM user_account", Long.class)).thenReturn(0L);
InitialAuthSettingsProperties properties = new InitialAuthSettingsProperties();
new InitialAuthSettingsInitializer(properties, settings, rules, roles, new ObjectMapper(), jdbc).run(args);
verify(settings).save(org.mockito.ArgumentMatchers.argThat(setting ->
setting.getSettingKey().equals(LocalAuthSettingsService.SETTING_KEY)
&& setting.getValue().equals(Map.of("passwordLoginEnabled", true,
"selfRegistrationEnabled", true))));
verify(settings).save(org.mockito.ArgumentMatchers.argThat(setting ->
setting.getSettingKey().equals("auth.initial-role-grants.initialized")));
verify(rules, never()).save(any());
}
@Test
void emptyDatabaseUsesConfiguredLoginFlags() {
when(settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY)).thenReturn(Optional.empty());
when(settings.findBySettingKey("auth.initial-role-grants.initialized")).thenReturn(Optional.empty());
when(jdbc.queryForObject("SELECT COUNT(*) FROM user_account", Long.class)).thenReturn(0L);
InitialAuthSettingsProperties properties = new InitialAuthSettingsProperties();
properties.setPasswordLoginEnabled(false);
properties.setSelfRegistrationEnabled(false);
new InitialAuthSettingsInitializer(properties, settings, rules, roles, new ObjectMapper(), jdbc).run(args);
verify(settings).save(org.mockito.ArgumentMatchers.argThat(setting ->
setting.getSettingKey().equals(LocalAuthSettingsService.SETTING_KEY)
&& setting.getValue().equals(Map.of("passwordLoginEnabled", false,
"selfRegistrationEnabled", false))));
}
@Test
void existingUsersUpgradeWithSafeDefaultsEvenWhenDeploymentFlagsAreDisabled() {
when(settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY)).thenReturn(Optional.empty());
when(settings.findBySettingKey("auth.initial-role-grants.initialized")).thenReturn(Optional.empty());
when(jdbc.queryForObject("SELECT COUNT(*) FROM user_account", Long.class)).thenReturn(2L);
InitialAuthSettingsProperties properties = new InitialAuthSettingsProperties();
properties.setPasswordLoginEnabled(false);
properties.setSelfRegistrationEnabled(false);
new InitialAuthSettingsInitializer(properties, settings, rules, roles, new ObjectMapper(), jdbc).run(args);
verify(settings).save(org.mockito.ArgumentMatchers.argThat(setting ->
setting.getSettingKey().equals(LocalAuthSettingsService.SETTING_KEY)
&& setting.getValue().equals(Map.of("passwordLoginEnabled", true,
"selfRegistrationEnabled", true))));
verify(rules, never()).save(any());
}
@Test
void existingMarkerPreventsDeletedRulesFromBeingSeededAgain() {
when(settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY))
.thenReturn(Optional.of(new SystemSetting(LocalAuthSettingsService.SETTING_KEY,
Map.of("passwordLoginEnabled", false, "selfRegistrationEnabled", true))));
when(settings.findBySettingKey("auth.initial-role-grants.initialized"))
.thenReturn(Optional.of(new SystemSetting("auth.initial-role-grants.initialized",
Map.of("initialized", true))));
InitialAuthSettingsProperties properties = new InitialAuthSettingsProperties();
properties.setRoleGrantsJson("[{\"provider\":\"feishu\",\"email\":\"admin@example.com\",\"role\":\"SUPER_ADMIN\"}]");
new InitialAuthSettingsInitializer(properties, settings, rules, roles, new ObjectMapper(), jdbc).run(args);
verify(settings, never()).save(any());
verify(rules, never()).save(any());
verify(jdbc, never()).queryForObject(eq("SELECT COUNT(*) FROM user_account"), eq(Long.class));
}
@Test
void initialGrantForUnverifiedProviderFailsStartupInsteadOfCreatingDeadRule() {
when(settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY)).thenReturn(Optional.empty());
when(settings.findBySettingKey("auth.initial-role-grants.initialized")).thenReturn(Optional.empty());
when(jdbc.queryForObject("SELECT COUNT(*) FROM user_account", Long.class)).thenReturn(0L);
InitialAuthSettingsProperties properties = new InitialAuthSettingsProperties();
properties.setRoleGrantsJson("[{\"provider\":\"feishu\",\"email\":\"admin@example.com\",\"role\":\"SUPER_ADMIN\"}]");
assertThatThrownBy(() -> new InitialAuthSettingsInitializer(
properties, settings, rules, roles, new ObjectMapper(), jdbc).run(args))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("verified email");
verify(rules, never()).save(any());
}
}

View file

@ -2,6 +2,8 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.settings.LocalAuthSettings;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
@ -68,6 +70,14 @@ class AuthControllerTest {
@MockBean
private LocalCredentialRepository localCredentialRepository;
@MockBean
private LocalAuthSettingsService localAuthSettingsService;
@org.junit.jupiter.api.BeforeEach
void localAuthEnabled() {
given(localAuthSettingsService.current()).willReturn(new LocalAuthSettings(1L, true, true, 0L, null));
}
@Test
void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
mockMvc.perform(get("/api/v1/auth/me"))

View file

@ -3,6 +3,8 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.settings.LocalAuthSettings;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimiter;
@ -49,6 +51,14 @@ class AuthRateLimitControllerTest {
@MockBean
private AuthFailureThrottleService authFailureThrottleService;
@MockBean
private LocalAuthSettingsService localAuthSettingsService;
@org.junit.jupiter.api.BeforeEach
void localAuthEnabled() {
given(localAuthSettingsService.current()).willReturn(new LocalAuthSettings(1L, true, true, 0L, null));
}
@Test
void localLoginShouldReturnTooManyRequestsWhenRateLimitIsExceeded() throws Exception {
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(false);

View file

@ -9,6 +9,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.settings.LocalAuthSettings;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -56,6 +58,14 @@ class DirectAuthControllerTest {
@MockBean
private LocalCredentialRepository localCredentialRepository;
@MockBean
private LocalAuthSettingsService localAuthSettingsService;
@org.junit.jupiter.api.BeforeEach
void localAuthEnabled() {
given(localAuthSettingsService.current()).willReturn(new LocalAuthSettings(1L, true, true, 0L, null));
}
@Test
void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(

View file

@ -6,6 +6,7 @@ import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -13,6 +14,8 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.local.PasswordResetService;
import com.iflytek.skillhub.auth.settings.LocalAuthSettings;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
@ -21,6 +24,8 @@ import jakarta.servlet.http.Cookie;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
@ -58,6 +63,30 @@ class LocalAuthControllerTest {
@MockBean
private LocalCredentialRepository localCredentialRepository;
@MockBean
private LocalAuthSettingsService localAuthSettingsService;
@org.junit.jupiter.api.BeforeEach
void localAuthEnabled() {
given(localAuthSettingsService.current()).willReturn(new LocalAuthSettings(1L, true, true, 0L, null));
}
@ParameterizedTest
@CsvSource({"true,true,true", "true,false,false", "false,true,false", "false,false,false"})
void publicCapabilitiesReflectBothSettingsWithoutExposingInternalFields(
boolean passwordLogin, boolean selfRegistration, boolean registrationAvailable) throws Exception {
given(localAuthSettingsService.current()).willReturn(
new LocalAuthSettings(1L, passwordLogin, selfRegistration, 7L, null));
mockMvc.perform(get("/api/v1/auth/local/capabilities"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.passwordLoginEnabled").value(passwordLogin))
.andExpect(jsonPath("$.data.selfRegistrationEnabled").value(selfRegistration))
.andExpect(jsonPath("$.data.registrationAvailable").value(registrationAvailable))
.andExpect(jsonPath("$.data.version").doesNotExist());
}
@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(

View file

@ -2,6 +2,8 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.settings.LocalAuthSettings;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -52,6 +54,14 @@ class SessionBootstrapControllerTest {
@MockBean
private LocalCredentialRepository localCredentialRepository;
@MockBean
private LocalAuthSettingsService localAuthSettingsService;
@org.junit.jupiter.api.BeforeEach
void localAuthEnabled() {
given(localAuthSettingsService.current()).willReturn(new LocalAuthSettings(1L, true, true, 0L, null));
}
@Test
void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception {
given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of());

View file

@ -0,0 +1,82 @@
package com.iflytek.skillhub.controller.admin;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.ExternalRoleGrantCreateRequest;
import com.iflytek.skillhub.dto.SystemAuthSettingsResponse;
import com.iflytek.skillhub.dto.SystemAuthSettingsUpdateRequest;
import com.iflytek.skillhub.service.SystemAuthSettingsAppService;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SystemAuthSettingsControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private SystemAuthSettingsAppService service;
@MockBean private NamespaceMemberRepository namespaceMemberRepository;
@Test
void superAdminCanChangeSettings() throws Exception {
when(service.updateLocalSettings(any(), eq("admin"), any()))
.thenReturn(new SystemAuthSettingsResponse(false, true, 1L, Instant.now()));
mockMvc.perform(put("/api/v1/admin/system-config/auth/local")
.with(authentication(auth("SUPER_ADMIN"))).with(csrf())
.contentType("application/json")
.content("""
{"passwordLoginEnabled":false,"selfRegistrationEnabled":true,"version":0}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.passwordLoginEnabled").value(false));
}
@Test
void nonSuperAdminCannotChangeSettingsOrCreateGrant() throws Exception {
mockMvc.perform(put("/api/v1/admin/system-config/auth/local")
.with(authentication(auth("USER_ADMIN"))).with(csrf())
.contentType("application/json")
.content("""
{"passwordLoginEnabled":false,"selfRegistrationEnabled":true,"version":0}
"""))
.andExpect(status().isForbidden());
mockMvc.perform(post("/api/v1/admin/system-config/role-grants")
.with(authentication(auth("USER_ADMIN"))).with(csrf())
.contentType("application/json")
.content("""
{"providerCode":"feishu","email":"admin@example.com","roleCode":"SUPER_ADMIN"}
"""))
.andExpect(status().isForbidden());
verify(service, never()).updateLocalSettings(any(SystemAuthSettingsUpdateRequest.class), any(), any());
verify(service, never()).createRule(any(ExternalRoleGrantCreateRequest.class), any(), any());
}
private UsernamePasswordAuthenticationToken auth(String role) {
PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of(role));
return new UsernamePasswordAuthenticationToken(principal, null,
List.of(new SimpleGrantedAuthority("ROLE_" + role)));
}
}

View file

@ -0,0 +1,288 @@
package com.iflytek.skillhub.repository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.bootstrap.InitialAuthSettingsInitializer;
import com.iflytek.skillhub.bootstrap.InitialAuthSettingsProperties;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.local.LocalCredential;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRule;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRuleRepository;
import com.iflytek.skillhub.auth.settings.InitialExternalRoleGrantService;
import com.iflytek.skillhub.auth.settings.SystemSetting;
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.infra.jpa.AuditLogJpaRepository;
import jakarta.persistence.EntityManager;
import java.time.Clock;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Testcontainers
class SystemAuthSettingsPostgresTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add("spring.flyway.enabled", () -> true);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired private SystemSettingRepository settings;
@Autowired private ExternalRoleGrantRuleRepository rules;
@Autowired private RoleRepository roles;
@Autowired private UserAccountRepository users;
@Autowired private IdentityBindingRepository identities;
@Autowired private UserRoleBindingRepository userRoles;
@Autowired private LocalCredentialRepository localCredentials;
@Autowired private PlatformTransactionManager transactionManager;
@Autowired private AuditLogJpaRepository auditLogs;
@Autowired private EntityManager entityManager;
@Autowired private JdbcTemplate jdbc;
@Test
void flywaySchemaStoresSettingsAsJsonObjectWithOptimisticVersion() {
String key = "test.auth." + UUID.randomUUID();
SystemSetting setting = settings.saveAndFlush(new SystemSetting(key,
Map.of("passwordLoginEnabled", true, "selfRegistrationEnabled", true)));
Long id = setting.getId();
entityManager.clear();
SystemSetting reloaded = settings.findBySettingKey(key).orElseThrow();
assertThat(reloaded.getValue().get("passwordLoginEnabled")).isEqualTo(true);
assertThat(jdbc.queryForObject("SELECT jsonb_typeof(value_json) FROM system_setting WHERE id = ?",
String.class, id)).isEqualTo("object");
reloaded.update(Map.of("passwordLoginEnabled", false, "selfRegistrationEnabled", true), "admin");
settings.saveAndFlush(reloaded);
entityManager.clear();
assertThat(settings.findBySettingKey(key).orElseThrow().getVersion()).isEqualTo(1L);
assertThat(settings.findBySettingKey(key).orElseThrow().getValue().get("passwordLoginEnabled"))
.isEqualTo(false);
}
@Test
void consumedRuleKeepsActualExternalSubjectAndUser() {
String suffix = UUID.randomUUID().toString();
String userId = "usr_" + suffix;
users.save(new UserAccount(userId, "new-user", null, null));
entityManager.flush();
var role = roles.findByCode("SUPER_ADMIN").orElseThrow();
ExternalRoleGrantRule rule = rules.saveAndFlush(new ExternalRoleGrantRule(
"github", suffix + "@example.com", role, "admin"));
rule.consume("external-" + suffix, userId);
rules.saveAndFlush(rule);
entityManager.clear();
ExternalRoleGrantRule reloaded = rules.findById(rule.getId()).orElseThrow();
assertThat(reloaded.getStatus()).isEqualTo(ExternalRoleGrantRule.Status.CONSUMED);
assertThat(reloaded.getMatchedSubject()).isEqualTo("external-" + suffix);
assertThat(reloaded.getGrantedUserId()).isEqualTo(userId);
assertThat(reloaded.getGrantedAt()).isNotNull();
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void sameEmailLocalAccountStaysSeparateFromNewExternalGrant() {
String suffix = UUID.randomUUID().toString();
String email = suffix + "@example.com";
String localUserId = "local_" + suffix;
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
Long ruleId = transactions.execute(status -> {
users.save(new UserAccount(localUserId, "local", email, null));
localCredentials.save(new LocalCredential(localUserId, "local-" + suffix, "test-hash"));
return rules.save(new ExternalRoleGrantRule(
"github", email, roles.findByCode("SUPER_ADMIN").orElseThrow(), "admin")).getId();
});
PlatformPrincipal external = bindingService().bindOrCreate(
new OAuthClaims("github", "external-" + suffix, email, true, "external", Map.of()),
UserStatus.ACTIVE);
assertThat(external.userId()).isNotEqualTo(localUserId);
assertThat(external.platformRoles()).contains("SUPER_ADMIN");
assertThat(users.findById(localUserId)).isPresent();
assertThat(localCredentials.findByUserId(localUserId)).isPresent();
assertThat(userRoles.findByUserId(localUserId)).isEmpty();
assertThat(userRoles.findByUserId(external.userId())).hasSize(1);
assertThat(identities.findByProviderCodeAndSubject("github", "external-" + suffix)
.orElseThrow().getUserId()).isEqualTo(external.userId());
assertThat(rules.findById(ruleId).orElseThrow().getGrantedUserId()).isEqualTo(external.userId());
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void deletedInitialRuleDoesNotReturnAfterInitializerRunsAgain() {
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
transactions.executeWithoutResult(status -> {
jdbc.execute("TRUNCATE TABLE user_account, external_role_grant_rule CASCADE");
jdbc.update("DELETE FROM system_setting WHERE setting_key IN (?, ?)",
"auth.local", "auth.initial-role-grants.initialized");
});
InitialAuthSettingsProperties properties = new InitialAuthSettingsProperties();
properties.setRoleGrantsJson("[{\"provider\":\"github\",\"email\":\"admin@example.com\",\"role\":\"SUPER_ADMIN\"}]");
InitialAuthSettingsInitializer initializer = new InitialAuthSettingsInitializer(
properties, settings, rules, roles, new ObjectMapper(), jdbc);
ApplicationArguments args = mock(ApplicationArguments.class);
transactions.executeWithoutResult(status -> initializer.run(args));
Long ruleId = transactions.execute(status -> {
assertThat(rules.findAll()).hasSize(1);
assertThat(settings.findBySettingKey("auth.initial-role-grants.initialized")).isPresent();
return rules.findAll().getFirst().getId();
});
transactions.executeWithoutResult(status -> rules.deleteById(ruleId));
InitialAuthSettingsInitializer restarted = new InitialAuthSettingsInitializer(
properties, settings, rules, roles, new ObjectMapper(), jdbc);
transactions.executeWithoutResult(status -> restarted.run(args));
transactions.executeWithoutResult(status -> {
assertThat(rules.findAll()).isEmpty();
assertThat(settings.findBySettingKey("auth.initial-role-grants.initialized")).isPresent();
});
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void concurrentFirstLoginConsumesRuleOnceAndDoesNotRestoreManuallyRemovedRole() throws Exception {
String suffix = UUID.randomUUID().toString();
String email = suffix + "@example.com";
String subject = "external-" + suffix;
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
Long ruleId = transactions.execute(status -> rules.save(new ExternalRoleGrantRule(
"github", email, roles.findByCode("SUPER_ADMIN").orElseThrow(), "admin")).getId());
IdentityBindingService bindingService = bindingService();
OAuthClaims claims = new OAuthClaims("github", subject, email, true, "admin", Map.of());
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newFixedThreadPool(2)) {
var first = executor.submit(() -> firstLogin(bindingService, claims, ready, start));
var second = executor.submit(() -> firstLogin(bindingService, claims, ready, start));
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
PlatformPrincipal firstPrincipal = first.get(20, TimeUnit.SECONDS);
PlatformPrincipal secondPrincipal = second.get(20, TimeUnit.SECONDS);
assertThat(firstPrincipal.userId()).isEqualTo(secondPrincipal.userId());
assertThat(firstPrincipal.platformRoles()).contains("SUPER_ADMIN");
assertThat(secondPrincipal.platformRoles()).contains("SUPER_ADMIN");
ExternalRoleGrantRule consumed = rules.findById(ruleId).orElseThrow();
assertThat(consumed.getStatus()).isEqualTo(ExternalRoleGrantRule.Status.CONSUMED);
assertThat(consumed.getGrantedUserId()).isEqualTo(firstPrincipal.userId());
assertThat(identities.findByProviderCodeAndSubject("github", subject)).isPresent();
assertThat(userRoles.findByUserId(firstPrincipal.userId())).hasSize(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM identity_binding WHERE provider_code = ? AND subject = ?",
Long.class, "github", subject)).isEqualTo(1L);
OAuthClaims anotherSubject = new OAuthClaims("github", "other-" + suffix, email, true,
"another-admin", Map.of());
PlatformPrincipal independent = bindingService.bindOrCreate(anotherSubject, UserStatus.ACTIVE);
assertThat(independent.userId()).isNotEqualTo(firstPrincipal.userId());
assertThat(independent.platformRoles()).doesNotContain("SUPER_ADMIN");
assertThat(userRoles.findByUserId(independent.userId())).isEmpty();
transactions.executeWithoutResult(status -> userRoles.deleteByUserId(firstPrincipal.userId()));
PlatformPrincipal returning = bindingService.bindOrCreate(claims, UserStatus.ACTIVE);
assertThat(returning.platformRoles()).doesNotContain("SUPER_ADMIN");
assertThat(userRoles.findByUserId(firstPrincipal.userId())).isEmpty();
assertThat(rules.findById(ruleId).orElseThrow().getGrantedUserId()).isEqualTo(firstPrincipal.userId());
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void concurrentDifferentSubjectsSharingEmailCannotBothConsumeRule() throws Exception {
String suffix = UUID.randomUUID().toString();
String email = suffix + "@example.com";
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
Long ruleId = transactions.execute(status -> rules.save(new ExternalRoleGrantRule(
"github", email, roles.findByCode("SUPER_ADMIN").orElseThrow(), "admin")).getId());
IdentityBindingService bindingService = bindingService();
OAuthClaims firstClaims = new OAuthClaims("github", "first-" + suffix, email, true, "first", Map.of());
OAuthClaims secondClaims = new OAuthClaims("github", "second-" + suffix, email, true, "second", Map.of());
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newFixedThreadPool(2)) {
var first = executor.submit(() -> firstLogin(bindingService, firstClaims, ready, start));
var second = executor.submit(() -> firstLogin(bindingService, secondClaims, ready, start));
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
PlatformPrincipal firstPrincipal = first.get(20, TimeUnit.SECONDS);
PlatformPrincipal secondPrincipal = second.get(20, TimeUnit.SECONDS);
assertThat(firstPrincipal.userId()).isNotEqualTo(secondPrincipal.userId());
assertThat(firstPrincipal.platformRoles().contains("SUPER_ADMIN"))
.isNotEqualTo(secondPrincipal.platformRoles().contains("SUPER_ADMIN"));
ExternalRoleGrantRule consumed = rules.findById(ruleId).orElseThrow();
assertThat(consumed.getStatus()).isEqualTo(ExternalRoleGrantRule.Status.CONSUMED);
assertThat(consumed.getGrantedUserId()).isIn(firstPrincipal.userId(), secondPrincipal.userId());
assertThat(consumed.getMatchedSubject()).isIn(firstClaims.subject(), secondClaims.subject());
assertThat(userRoles.findByUserId(firstPrincipal.userId()).size()
+ userRoles.findByUserId(secondPrincipal.userId()).size()).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM audit_log WHERE action = ? AND target_id = ?",
Long.class, "INITIAL_ROLE_GRANTED", ruleId)).isEqualTo(1L);
assertThat(jdbc.queryForObject("SELECT detail_json ->> 'userId' FROM audit_log "
+ "WHERE action = ? AND target_id = ?", String.class, "INITIAL_ROLE_GRANTED", ruleId))
.isEqualTo(consumed.getGrantedUserId());
}
}
private IdentityBindingService bindingService() {
return new IdentityBindingService(identities, users, userRoles,
mock(GlobalNamespaceMembershipService.class), mock(ApplicationEventPublisher.class),
transactionManager, new InitialExternalRoleGrantService(
rules, userRoles, new AuditLogService(auditLogs, Clock.systemUTC())));
}
private static PlatformPrincipal firstLogin(IdentityBindingService service, OAuthClaims claims,
CountDownLatch ready, CountDownLatch start) throws Exception {
ready.countDown();
if (!start.await(10, TimeUnit.SECONDS)) throw new AssertionError("Timed out waiting for first login");
return service.bindOrCreate(claims, UserStatus.ACTIVE);
}
}

View file

@ -2,15 +2,19 @@ package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
import com.iflytek.skillhub.auth.direct.DirectAuthProvider;
import com.iflytek.skillhub.auth.direct.DirectAuthRequest;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.settings.LocalAuthSettings;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.config.AuthSessionBootstrapProperties;
import com.iflytek.skillhub.config.DirectAuthProperties;
import java.util.List;
import java.util.Optional;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
@ -30,7 +34,8 @@ class AuthMethodCatalogTest {
new DirectAuthProperties(),
new AuthSessionBootstrapProperties(),
List.of(),
List.of()
List.of(),
enabledSettings()
);
assertThat(catalog.listOAuthProviders(null))
@ -88,7 +93,8 @@ class AuthMethodCatalogTest {
directAuthProperties,
bootstrapProperties,
List.of(directProvider),
List.of(bootstrapProvider)
List.of(bootstrapProvider),
enabledSettings()
);
assertThat(catalog.listMethods(null))
@ -137,7 +143,8 @@ class AuthMethodCatalogTest {
directAuthProperties,
bootstrapProperties,
List.of(directProvider),
List.of(bootstrapProvider)
List.of(bootstrapProvider),
enabledSettings()
);
assertThat(catalog.listMethods(null))
@ -154,4 +161,10 @@ class AuthMethodCatalogTest {
registration.setClientName(clientName);
return registration;
}
private static LocalAuthSettingsService enabledSettings() {
LocalAuthSettingsService service = mock(LocalAuthSettingsService.class);
when(service.current()).thenReturn(new LocalAuthSettings(1L, true, true, 0L, Instant.EPOCH));
return service;
}
}

View file

@ -0,0 +1,103 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRule;
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRuleRepository;
import com.iflytek.skillhub.auth.settings.LocalAuthSettings;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.settings.SystemSetting;
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.dto.ExternalRoleGrantCreateRequest;
import com.iflytek.skillhub.dto.SystemAuthSettingsUpdateRequest;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class SystemAuthSettingsAppServiceTest {
@Mock private LocalAuthSettingsService localSettings;
@Mock private SystemSettingRepository settings;
@Mock private ExternalRoleGrantRuleRepository rules;
@Mock private RoleRepository roles;
@Mock private AuditLogService audit;
@Mock private RequestIdAccessor requestIds;
private SystemAuthSettingsAppService service;
@BeforeEach
void setUp() {
service = new SystemAuthSettingsAppService(localSettings, settings, rules, roles, audit, requestIds);
}
@Test
void staleSettingsVersionCannotOverwriteCurrentValue() {
SystemSetting current = new SystemSetting("auth.local",
Map.of("passwordLoginEnabled", true, "selfRegistrationEnabled", true));
when(settings.findBySettingKey("auth.local")).thenReturn(Optional.of(current));
assertThatThrownBy(() -> service.updateLocalSettings(
new SystemAuthSettingsUpdateRequest(false, true, 7L), "admin",
new AuditRequestContext(null, null)))
.isInstanceOf(DomainConflictException.class);
verify(settings, never()).saveAndFlush(any());
}
@Test
void successfulSettingsChangeWritesAuditAndReturnsStoredValue() {
SystemSetting current = new SystemSetting("auth.local",
Map.of("passwordLoginEnabled", true, "selfRegistrationEnabled", true));
ReflectionTestUtils.setField(current, "id", 1L);
when(settings.findBySettingKey("auth.local")).thenReturn(Optional.of(current));
when(localSettings.current()).thenReturn(new LocalAuthSettings(1L, false, true, 1L, null));
var response = service.updateLocalSettings(
new SystemAuthSettingsUpdateRequest(false, true, 0L), "admin",
new AuditRequestContext("127.0.0.1", "test"));
org.assertj.core.api.Assertions.assertThat(response.passwordLoginEnabled()).isFalse();
org.assertj.core.api.Assertions.assertThat(current.getValue().get("passwordLoginEnabled")).isEqualTo(false);
verify(settings).saveAndFlush(current);
verify(audit).record(org.mockito.ArgumentMatchers.eq("admin"),
org.mockito.ArgumentMatchers.eq("SYSTEM_AUTH_SETTINGS_UPDATE"),
org.mockito.ArgumentMatchers.eq("SYSTEM_SETTING"),
org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(null),
org.mockito.ArgumentMatchers.eq("127.0.0.1"),
org.mockito.ArgumentMatchers.eq("test"), any());
}
@Test
void duplicateActiveGrantIsRejectedBeforeWrite() {
when(rules.existsByProviderCodeAndNormalizedEmailAndStatus(
"github", "admin@example.com", ExternalRoleGrantRule.Status.ACTIVE)).thenReturn(true);
assertThatThrownBy(() -> service.createRule(
new ExternalRoleGrantCreateRequest(" GITHUB ", "Admin@Example.Com", "SUPER_ADMIN"),
"admin", new AuditRequestContext(null, null)))
.isInstanceOf(DomainConflictException.class);
verify(rules, never()).saveAndFlush(any());
}
@Test
void providerWithoutVerifiedEmailCannotCreateDeadGrantRule() {
for (String provider : new String[] {"feishu", "dingtalk"}) {
assertThatThrownBy(() -> service.createRule(
new ExternalRoleGrantCreateRequest(provider, "admin@example.com", "SUPER_ADMIN"),
"admin", new AuditRequestContext(null, null)))
.isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class);
}
verify(rules, never()).saveAndFlush(any());
}
}

View file

@ -10,6 +10,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.settings.InitialExternalRoleGrantService;
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
@ -40,6 +41,7 @@ public class IdentityBindingService {
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
private final ApplicationEventPublisher eventPublisher;
private final TransactionOperations transactions;
private final InitialExternalRoleGrantService initialRoleGrants;
@Autowired
public IdentityBindingService(IdentityBindingRepository bindingRepo,
@ -47,8 +49,10 @@ public class IdentityBindingService {
UserRoleBindingRepository roleBindingRepo,
GlobalNamespaceMembershipService globalNamespaceMembershipService,
ApplicationEventPublisher eventPublisher,
PlatformTransactionManager transactionManager) {
PlatformTransactionManager transactionManager,
InitialExternalRoleGrantService initialRoleGrants) {
this(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService, eventPublisher,
initialRoleGrants,
requiresNewTransactions(transactionManager));
}
@ -57,6 +61,7 @@ public class IdentityBindingService {
UserRoleBindingRepository roleBindingRepo,
GlobalNamespaceMembershipService globalNamespaceMembershipService,
ApplicationEventPublisher eventPublisher,
InitialExternalRoleGrantService initialRoleGrants,
TransactionOperations transactions) {
this.bindingRepo = bindingRepo;
this.userRepo = userRepo;
@ -64,6 +69,7 @@ public class IdentityBindingService {
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
this.eventPublisher = eventPublisher;
this.transactions = transactions;
this.initialRoleGrants = initialRoleGrants;
}
public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStatus) {
@ -108,6 +114,7 @@ public class IdentityBindingService {
// Force the unique identity coordinate to be checked before membership or events run.
bindingRepo.saveAndFlush(binding);
if (initialStatus == UserStatus.ACTIVE) {
initialRoleGrants.grantForNewUser(claims, user.getId());
globalNamespaceMembershipService.ensureMember(user.getId());
eventPublisher.publishEvent(
new UserActivatedEvent(user.getId(), claims.providerLogin(), claims.email()));

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
@ -47,6 +48,7 @@ public class LocalAuthService {
private final PasswordEncoder passwordEncoder;
private final Clock clock;
private final ApplicationEventPublisher eventPublisher;
private final LocalAuthSettingsService authSettings;
public LocalAuthService(LocalCredentialRepository credentialRepository,
UserAccountRepository userAccountRepository,
@ -55,7 +57,8 @@ public class LocalAuthService {
PasswordPolicyValidator passwordPolicyValidator,
PasswordEncoder passwordEncoder,
Clock clock,
ApplicationEventPublisher eventPublisher) {
ApplicationEventPublisher eventPublisher,
LocalAuthSettingsService authSettings) {
this.credentialRepository = credentialRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
@ -64,6 +67,7 @@ public class LocalAuthService {
this.passwordEncoder = passwordEncoder;
this.clock = clock;
this.eventPublisher = eventPublisher;
this.authSettings = authSettings;
}
/**
@ -72,6 +76,7 @@ public class LocalAuthService {
*/
@Transactional
public PlatformPrincipal register(String username, String password, String email) {
authSettings.requireSelfRegistration();
String normalizedUsername = normalizeUsername(username);
validateUsername(normalizedUsername);
@ -116,6 +121,7 @@ public class LocalAuthService {
*/
@Transactional
public PlatformPrincipal login(String username, String password) {
authSettings.requirePasswordLogin();
String normalizedUsername = normalizeUsername(username);
LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername)
.orElse(null);
@ -147,6 +153,7 @@ public class LocalAuthService {
*/
@Transactional
public void changePassword(String userId, String currentPassword, String newPassword) {
authSettings.requirePasswordLogin();
LocalCredential credential = credentialRepository.findByUserId(userId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.notEnabled"));

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.domain.auth.PasswordResetRequest;
import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository;
@ -41,6 +43,7 @@ public class PasswordResetService {
private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
private final PasswordResetProperties properties;
private final LocalAuthSettingsService authSettings;
public PasswordResetService(PasswordResetRequestRepository resetRequestRepository,
UserAccountRepository userAccountRepository,
@ -48,7 +51,8 @@ public class PasswordResetService {
PasswordPolicyValidator passwordPolicyValidator,
PasswordEncoder passwordEncoder,
JavaMailSender mailSender,
PasswordResetProperties properties) {
PasswordResetProperties properties,
LocalAuthSettingsService authSettings) {
this.resetRequestRepository = resetRequestRepository;
this.userAccountRepository = userAccountRepository;
this.credentialRepository = credentialRepository;
@ -56,6 +60,7 @@ public class PasswordResetService {
this.passwordEncoder = passwordEncoder;
this.mailSender = mailSender;
this.properties = properties;
this.authSettings = authSettings;
}
/**
@ -64,6 +69,7 @@ public class PasswordResetService {
*/
@Transactional
public void requestPasswordReset(String email) {
authSettings.requirePasswordLogin();
String normalizedEmail = normalizeEmail(email);
validateEmail(normalizedEmail);
Optional<UserAccount> userOpt = findEligibleUserByEmail(normalizedEmail);
@ -95,6 +101,7 @@ public class PasswordResetService {
*/
@Transactional
public void adminTriggerPasswordReset(String userId, String adminUserId) {
authSettings.requirePasswordLogin();
UserAccount user = userAccountRepository.findById(userId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.admin.user.notFound", userId));
@ -124,6 +131,7 @@ public class PasswordResetService {
*/
@Transactional
public void confirmPasswordReset(String email, String code, String newPassword) {
authSettings.requirePasswordLogin();
String normalizedEmail = normalizeEmail(email);
validateEmail(normalizedEmail);
UserAccount user = findUserByEmail(normalizedEmail)

View file

@ -0,0 +1,120 @@
package com.iflytek.skillhub.auth.settings;
import com.iflytek.skillhub.auth.entity.Role;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
@Entity
@Table(name = "external_role_grant_rule")
public class ExternalRoleGrantRule {
public enum Status { ACTIVE, DISABLED, CONSUMED }
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "provider_code", nullable = false, length = 64)
private String providerCode;
@Column(name = "normalized_email", nullable = false, length = 256)
private String normalizedEmail;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Status status = Status.ACTIVE;
@Column(name = "matched_subject", length = 256)
private String matchedSubject;
@Column(name = "granted_user_id", length = 128)
private String grantedUserId;
@Column(name = "granted_at")
private Instant grantedAt;
@Version
@Column(nullable = false)
private long version;
@Column(name = "created_by", length = 128)
private String createdBy;
@Column(name = "updated_by", length = 128)
private String updatedBy;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected ExternalRoleGrantRule() {}
public ExternalRoleGrantRule(String providerCode, String normalizedEmail, Role role, String actorUserId) {
this.providerCode = providerCode;
this.normalizedEmail = normalizedEmail;
this.role = role;
this.createdBy = actorUserId;
this.updatedBy = actorUserId;
}
@PrePersist
void prePersist() {
Instant now = Instant.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
void preUpdate() {
updatedAt = Instant.now();
}
public Long getId() { return id; }
public String getProviderCode() { return providerCode; }
public String getNormalizedEmail() { return normalizedEmail; }
public Role getRole() { return role; }
public Status getStatus() { return status; }
public String getMatchedSubject() { return matchedSubject; }
public String getGrantedUserId() { return grantedUserId; }
public Instant getGrantedAt() { return grantedAt; }
public long getVersion() { return version; }
public Instant getUpdatedAt() { return updatedAt; }
public void update(Role nextRole, String actorUserId) {
if (status != Status.ACTIVE) throw new IllegalStateException("Only active rules may be edited");
this.role = nextRole;
this.updatedBy = actorUserId;
}
public void disable(String actorUserId) {
if (status != Status.ACTIVE) throw new IllegalStateException("Only active rules may be disabled");
this.status = Status.DISABLED;
this.updatedBy = actorUserId;
}
public void consume(String subject, String userId) {
if (status != Status.ACTIVE) throw new IllegalStateException("Only active rules may be consumed");
this.status = Status.CONSUMED;
this.matchedSubject = subject;
this.grantedUserId = userId;
this.grantedAt = Instant.now();
}
}

View file

@ -0,0 +1,24 @@
package com.iflytek.skillhub.auth.settings;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ExternalRoleGrantRuleRepository extends JpaRepository<ExternalRoleGrantRule, Long> {
List<ExternalRoleGrantRule> findAllByOrderByCreatedAtDesc();
boolean existsByProviderCodeAndNormalizedEmailAndStatus(
String providerCode, String normalizedEmail, ExternalRoleGrantRule.Status status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select rule from ExternalRoleGrantRule rule where rule.providerCode = :provider "
+ "and rule.normalizedEmail = :email and rule.status = :status")
Optional<ExternalRoleGrantRule> lockByIdentityAndStatus(
@Param("provider") String provider,
@Param("email") String email,
@Param("status") ExternalRoleGrantRule.Status status);
}

View file

@ -0,0 +1,48 @@
package com.iflytek.skillhub.auth.settings;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import java.util.Locale;
import org.springframework.stereotype.Service;
/** Applies a single configured role while the newly-created OAuth account is still in its transaction. */
@Service
public class InitialExternalRoleGrantService {
private final ExternalRoleGrantRuleRepository ruleRepository;
private final UserRoleBindingRepository roleBindingRepository;
private final AuditLogService auditLogService;
public InitialExternalRoleGrantService(ExternalRoleGrantRuleRepository ruleRepository,
UserRoleBindingRepository roleBindingRepository,
AuditLogService auditLogService) {
this.ruleRepository = ruleRepository;
this.roleBindingRepository = roleBindingRepository;
this.auditLogService = auditLogService;
}
public void grantForNewUser(OAuthClaims claims, String userId) {
if (!claims.emailVerified() || claims.email() == null || claims.email().isBlank()) {
return;
}
String provider = normalize(claims.provider());
String email = normalize(claims.email());
ruleRepository.lockByIdentityAndStatus(provider, email, ExternalRoleGrantRule.Status.ACTIVE)
.ifPresent(rule -> {
roleBindingRepository.save(new UserRoleBinding(userId, rule.getRole()));
rule.consume(claims.subject(), userId);
ruleRepository.saveAndFlush(rule);
auditLogService.record(null, "INITIAL_ROLE_GRANTED", "EXTERNAL_ROLE_GRANT_RULE",
rule.getId(), null, null, null,
AuditDetail.builder().put("userId", userId).put("provider", provider)
.put("roleCode", rule.getRole().getCode()).build());
});
}
public static String normalize(String value) {
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
}
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.auth.settings;
import java.time.Instant;
public record LocalAuthSettings(
long id,
boolean passwordLoginEnabled,
boolean selfRegistrationEnabled,
long version,
Instant updatedAt
) {
public boolean registrationAvailable() {
return passwordLoginEnabled && selfRegistrationEnabled;
}
}

View file

@ -0,0 +1,54 @@
package com.iflytek.skillhub.auth.settings;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import java.util.Map;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@Service
public class LocalAuthSettingsService {
public static final String SETTING_KEY = "auth.local";
public static final String PASSWORD_LOGIN_KEY = "passwordLoginEnabled";
public static final String SELF_REGISTRATION_KEY = "selfRegistrationEnabled";
private final SystemSettingRepository repository;
public LocalAuthSettingsService(SystemSettingRepository repository) {
this.repository = repository;
}
public LocalAuthSettings current() {
SystemSetting setting;
try {
setting = repository.findBySettingKey(SETTING_KEY)
.orElseThrow(this::unavailable);
} catch (DataAccessException failure) {
throw unavailable();
}
Map<String, Object> value = setting.getValue();
if (!(value.get(PASSWORD_LOGIN_KEY) instanceof Boolean passwordLoginEnabled)
|| !(value.get(SELF_REGISTRATION_KEY) instanceof Boolean selfRegistrationEnabled)
|| value.size() != 2) {
throw unavailable();
}
return new LocalAuthSettings(setting.getId(), passwordLoginEnabled,
selfRegistrationEnabled, setting.getVersion(), setting.getUpdatedAt());
}
private AuthFlowException unavailable() {
return new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.settings.unavailable");
}
public void requirePasswordLogin() {
if (!current().passwordLoginEnabled()) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.login.disabled");
}
}
public void requireSelfRegistration() {
if (!current().registrationAvailable()) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.registration.disabled");
}
}
}

View file

@ -0,0 +1,74 @@
package com.iflytek.skillhub.auth.settings;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.Map;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Entity
@Table(name = "system_setting")
public class SystemSetting {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "setting_key", nullable = false, unique = true, length = 128)
private String settingKey;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "value_json", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> value;
@Version
@Column(nullable = false)
private long version;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Column(name = "updated_by", length = 128)
private String updatedBy;
protected SystemSetting() {}
public SystemSetting(String settingKey, Map<String, Object> value) {
this.settingKey = settingKey;
this.value = Map.copyOf(value);
}
@PrePersist
void prePersist() {
Instant now = Instant.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
void preUpdate() {
updatedAt = Instant.now();
}
public Long getId() { return id; }
public String getSettingKey() { return settingKey; }
public Map<String, Object> getValue() { return value; }
public long getVersion() { return version; }
public Instant getUpdatedAt() { return updatedAt; }
public String getUpdatedBy() { return updatedBy; }
public void update(Map<String, Object> newValue, String actorUserId) {
this.value = Map.copyOf(newValue);
this.updatedBy = actorUserId;
}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.auth.settings;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SystemSettingRepository extends JpaRepository<SystemSetting, Long> {
Optional<SystemSetting> findBySettingKey(String settingKey);
}

View file

@ -1,11 +1,15 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.settings.InitialExternalRoleGrantService;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.entity.Role;
@ -26,6 +30,7 @@ import com.iflytek.skillhub.domain.user.UserStatus;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -56,12 +61,16 @@ class IdentityBindingServiceTest {
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private InitialExternalRoleGrantService initialRoleGrants;
private IdentityBindingService service;
@BeforeEach
void setUp() {
service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo,
globalNamespaceMembershipService, eventPublisher, new ImmediateTransactionOperations());
globalNamespaceMembershipService, eventPublisher, initialRoleGrants,
new ImmediateTransactionOperations());
}
@Test
@ -84,6 +93,7 @@ class IdentityBindingServiceTest {
verify(userRepo).save(userCaptor.capture());
verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
verify(bindingRepo).saveAndFlush(any(IdentityBinding.class));
verify(initialRoleGrants).grantForNewUser(claims, userCaptor.getValue().getId());
assertThat(principal.displayName()).isEqualTo("alice");
assertThat(principal.oauthProvider()).isEqualTo("github");
}
@ -124,6 +134,7 @@ class IdentityBindingServiceTest {
service.bindOrCreate(claims, UserStatus.ACTIVE);
verify(eventPublisher, never()).publishEvent(any(UserActivatedEvent.class));
verify(initialRoleGrants, never()).grantForNewUser(any(), any());
}
@Test
@ -143,6 +154,7 @@ class IdentityBindingServiceTest {
.isInstanceOf(AccountPendingException.class);
verify(globalNamespaceMembershipService, never()).ensureMember(any());
verify(initialRoleGrants, never()).grantForNewUser(any(), any());
}
@Test
@ -164,6 +176,44 @@ class IdentityBindingServiceTest {
assertThat(principal.platformRoles()).containsExactly("USER");
}
@Test
void bindOrCreate_grantIsVisibleInFirstPrincipalBeforeSessionCreation() {
OAuthClaims claims = new OAuthClaims("github", "external-1", "admin@example.com", true, "admin", Map.of());
Role role = new Role();
ReflectionTestUtils.setField(role, "code", "SUPER_ADMIN");
AtomicBoolean granted = new AtomicBoolean();
when(bindingRepo.findByProviderCodeAndSubject("github", "external-1")).thenReturn(Optional.empty());
when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
doAnswer(invocation -> {
granted.set(true);
return null;
}).when(initialRoleGrants).grantForNewUser(any(), any());
when(roleBindingRepo.findByUserId(any())).thenAnswer(invocation -> granted.get()
? List.of(new UserRoleBinding(invocation.getArgument(0), role)) : List.of());
PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE);
assertThat(principal.platformRoles()).contains("SUPER_ADMIN");
var ordered = inOrder(initialRoleGrants, roleBindingRepo);
ordered.verify(initialRoleGrants).grantForNewUser(claims, principal.userId());
ordered.verify(roleBindingRepo).findByUserId(principal.userId());
}
@Test
void bindOrCreate_doesNotMergeWithLocalAccountSharingEmail() {
OAuthClaims claims = new OAuthClaims("github", "external-1", "shared@example.com", true,
"external-user", Map.of());
when(bindingRepo.findByProviderCodeAndSubject("github", "external-1")).thenReturn(Optional.empty());
when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(roleBindingRepo.findByUserId(any())).thenReturn(List.of());
PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE);
assertThat(principal.userId()).startsWith("usr_");
verify(userRepo, never()).findByEmailIgnoreCase(any());
verify(initialRoleGrants).grantForNewUser(claims, principal.userId());
}
@Test
void bindOrCreate_existingDisabledUser_throwsAccountDisabled() {
OAuthClaims claims = new OAuthClaims(

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@ -8,6 +10,8 @@ import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verifyNoInteractions;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.entity.Role;
@ -56,6 +60,9 @@ class LocalAuthServiceTest {
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private LocalAuthSettingsService authSettings;
private LocalAuthService service;
@BeforeEach
@ -68,10 +75,44 @@ class LocalAuthServiceTest {
new PasswordPolicyValidator(),
passwordEncoder,
CLOCK,
eventPublisher
eventPublisher,
authSettings
);
}
@Test
void disabledPasswordLoginStopsAuthenticationBeforeCredentialLookup() {
doThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.login.disabled"))
.when(authSettings).requirePasswordLogin();
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
.isInstanceOf(AuthFlowException.class)
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(credentialRepository);
}
@Test
void disabledRegistrationStopsAccountCreation() {
doThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.registration.disabled"))
.when(authSettings).requireSelfRegistration();
assertThatThrownBy(() -> service.register("alice", "Abcd123!", "alice@example.com"))
.isInstanceOf(AuthFlowException.class)
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(userAccountRepository, credentialRepository);
}
@Test
void disabledPasswordLoginStopsChangePasswordBeforeCredentialLookup() {
doThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.login.disabled"))
.when(authSettings).requirePasswordLogin();
assertThatThrownBy(() -> service.changePassword("usr_1", "old", "Newpass123!"))
.isInstanceOf(AuthFlowException.class)
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(credentialRepository, passwordEncoder);
}
@Test
void register_createsUserAndCredential() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@ -9,6 +11,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.doThrow;
import static org.mockito.BDDMockito.given;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
@ -50,6 +53,9 @@ class PasswordResetServiceTest {
@Mock
private JavaMailSender mailSender;
@Mock
private LocalAuthSettingsService authSettings;
private PasswordResetService service;
@BeforeEach
@ -65,10 +71,44 @@ class PasswordResetServiceTest {
new PasswordPolicyValidator(),
passwordEncoder,
mailSender,
properties
properties,
authSettings
);
}
@Test
void disabledPasswordLoginBlocksResetBeforeEmailLookup() {
doThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.login.disabled"))
.when(authSettings).requirePasswordLogin();
assertThatThrownBy(() -> service.requestPasswordReset("alice@example.com"))
.isInstanceOf(AuthFlowException.class)
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(userAccountRepository, resetRequestRepository, mailSender);
}
@Test
void disabledPasswordLoginStopsResetConfirmationBeforeCodeLookup() {
doThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.login.disabled"))
.when(authSettings).requirePasswordLogin();
assertThatThrownBy(() -> service.confirmPasswordReset("alice@example.com", "123456", "Abcd123!"))
.isInstanceOf(AuthFlowException.class)
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(userAccountRepository, resetRequestRepository, credentialRepository);
}
@Test
void disabledPasswordLoginStopsAdminResetBeforeAccountLookup() {
doThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.login.disabled"))
.when(authSettings).requirePasswordLogin();
assertThatThrownBy(() -> service.adminTriggerPasswordReset("usr_1", "admin_1"))
.isInstanceOf(AuthFlowException.class)
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(userAccountRepository, resetRequestRepository, credentialRepository);
}
@Test
void requestPasswordReset_withEligibleEmail_savesRequestAndSendsEmail() {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);

View file

@ -0,0 +1,80 @@
package com.iflytek.skillhub.auth.settings;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class InitialExternalRoleGrantServiceTest {
@Mock private ExternalRoleGrantRuleRepository rules;
@Mock private UserRoleBindingRepository bindings;
@Mock private AuditLogService audit;
@Mock private Role role;
private InitialExternalRoleGrantService service;
@BeforeEach
void setUp() {
service = new InitialExternalRoleGrantService(rules, bindings, audit);
}
@Test
void verifiedEmailConsumesMatchingRuleAndGrantsNewUser() {
ExternalRoleGrantRule rule = new ExternalRoleGrantRule("github", "admin@example.com", role, null);
when(rules.lockByIdentityAndStatus("github", "admin@example.com", ExternalRoleGrantRule.Status.ACTIVE))
.thenReturn(Optional.of(rule));
when(role.getCode()).thenReturn("SUPER_ADMIN");
service.grantForNewUser(new OAuthClaims("GITHUB", "external-42", " Admin@Example.COM ", true,
"admin", Map.of()), "usr_new");
ArgumentCaptor<UserRoleBinding> grant = ArgumentCaptor.forClass(UserRoleBinding.class);
verify(bindings).save(grant.capture());
assertThat(grant.getValue().getUserId()).isEqualTo("usr_new");
assertThat(grant.getValue().getRole()).isSameAs(role);
assertThat(rule.getStatus()).isEqualTo(ExternalRoleGrantRule.Status.CONSUMED);
assertThat(rule.getMatchedSubject()).isEqualTo("external-42");
assertThat(rule.getGrantedUserId()).isEqualTo("usr_new");
verify(rules).saveAndFlush(rule);
verify(audit).record(eq(null), eq("INITIAL_ROLE_GRANTED"), eq("EXTERNAL_ROLE_GRANT_RULE"),
eq(rule.getId()), eq(null), eq(null), eq(null), any());
}
@Test
void unverifiedEmailCannotConsumeRule() {
service.grantForNewUser(new OAuthClaims("github", "external-42", "admin@example.com", false,
"admin", Map.of()), "usr_new");
verify(rules, never()).lockByIdentityAndStatus(any(), any(), eq(ExternalRoleGrantRule.Status.ACTIVE));
verify(bindings, never()).save(any());
verify(audit, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void absentActiveRuleDoesNotGrantOrAudit() {
when(rules.lockByIdentityAndStatus("github", "admin@example.com", ExternalRoleGrantRule.Status.ACTIVE))
.thenReturn(Optional.empty());
service.grantForNewUser(new OAuthClaims("github", "external-42", "admin@example.com", true,
"admin", Map.of()), "usr_new");
verify(bindings, never()).save(any());
verify(audit, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
}
}

View file

@ -0,0 +1,109 @@
package com.iflytek.skillhub.auth.settings;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class LocalAuthSettingsServiceTest {
@Mock private SystemSettingRepository repository;
@Test
void separateServiceInstancesObserveCurrentStoredValue() {
SystemSetting initial = new SystemSetting("auth.local",
Map.of("passwordLoginEnabled", true, "selfRegistrationEnabled", true));
ReflectionTestUtils.setField(initial, "id", 1L);
AtomicReference<SystemSetting> stored = new AtomicReference<>(initial);
when(repository.findBySettingKey("auth.local")).thenAnswer(invocation -> Optional.of(stored.get()));
LocalAuthSettingsService firstInstance = new LocalAuthSettingsService(repository);
LocalAuthSettingsService secondInstance = new LocalAuthSettingsService(repository);
assertThat(firstInstance.current().passwordLoginEnabled()).isTrue();
SystemSetting updated = new SystemSetting("auth.local",
Map.of("passwordLoginEnabled", false, "selfRegistrationEnabled", true));
ReflectionTestUtils.setField(updated, "id", 1L);
stored.set(updated);
assertThat(secondInstance.current().passwordLoginEnabled()).isFalse();
assertThat(firstInstance.current().passwordLoginEnabled()).isFalse();
}
@Test
void closedPasswordLoginBlocksPasswordAndRegistration() {
SystemSetting setting = new SystemSetting("auth.local",
Map.of("passwordLoginEnabled", false, "selfRegistrationEnabled", true));
ReflectionTestUtils.setField(setting, "id", 1L);
when(repository.findBySettingKey("auth.local")).thenReturn(Optional.of(setting));
LocalAuthSettingsService service = new LocalAuthSettingsService(repository);
assertThatThrownBy(service::requirePasswordLogin).isInstanceOf(AuthFlowException.class);
assertThatThrownBy(service::requireSelfRegistration).isInstanceOf(AuthFlowException.class);
}
@Test
void closedRegistrationLeavesPasswordLoginAvailable() {
SystemSetting setting = new SystemSetting("auth.local",
Map.of("passwordLoginEnabled", true, "selfRegistrationEnabled", false));
ReflectionTestUtils.setField(setting, "id", 1L);
when(repository.findBySettingKey("auth.local")).thenReturn(Optional.of(setting));
LocalAuthSettingsService service = new LocalAuthSettingsService(repository);
assertThatCode(service::requirePasswordLogin).doesNotThrowAnyException();
assertThatThrownBy(service::requireSelfRegistration).isInstanceOf(AuthFlowException.class);
}
@Test
void bothEnabledAllowBothFlows() {
SystemSetting setting = new SystemSetting("auth.local",
Map.of("passwordLoginEnabled", true, "selfRegistrationEnabled", true));
ReflectionTestUtils.setField(setting, "id", 1L);
when(repository.findBySettingKey("auth.local")).thenReturn(Optional.of(setting));
LocalAuthSettingsService service = new LocalAuthSettingsService(repository);
assertThatCode(service::requirePasswordLogin).doesNotThrowAnyException();
assertThatCode(service::requireSelfRegistration).doesNotThrowAnyException();
}
@Test
void bothDisabledBlockBothFlows() {
SystemSetting setting = new SystemSetting("auth.local",
Map.of("passwordLoginEnabled", false, "selfRegistrationEnabled", false));
ReflectionTestUtils.setField(setting, "id", 1L);
when(repository.findBySettingKey("auth.local")).thenReturn(Optional.of(setting));
LocalAuthSettingsService service = new LocalAuthSettingsService(repository);
assertThatThrownBy(service::requirePasswordLogin).isInstanceOf(AuthFlowException.class);
assertThatThrownBy(service::requireSelfRegistration).isInstanceOf(AuthFlowException.class);
}
@Test
void missingSettingFailsClosed() {
when(repository.findBySettingKey("auth.local")).thenReturn(Optional.empty());
assertThatThrownBy(() -> new LocalAuthSettingsService(repository).requirePasswordLogin())
.isInstanceOf(AuthFlowException.class)
.extracting("status").isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
}
@Test
void databaseFailureFailsClosedWithServiceUnavailable() {
when(repository.findBySettingKey("auth.local"))
.thenThrow(new DataAccessResourceFailureException("database unavailable"));
assertThatThrownBy(() -> new LocalAuthSettingsService(repository).requirePasswordLogin())
.isInstanceOf(AuthFlowException.class)
.extracting("status").isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
}
}

View file

@ -34,6 +34,10 @@ import type {
PagedResponse,
ReportDisposition,
AuthMethod,
LocalAuthCapabilities,
SystemAuthSettings,
ExternalRoleGrantRule,
PlatformRole,
OAuthProvider,
User,
ManagedNamespace,
@ -337,6 +341,10 @@ export async function getCurrentUser(): Promise<User | null> {
export const authApi = {
getMe: getCurrentUser,
getLocalCapabilities(): Promise<LocalAuthCapabilities> {
return fetchJson<LocalAuthCapabilities>('/api/v1/auth/local/capabilities')
},
async getProviders(returnTo?: string): Promise<OAuthProvider[]> {
const params = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''
const providers = await fetchJson<OAuthProvider[]>(`/api/v1/auth/providers${params}`)
@ -450,6 +458,51 @@ export const authApi = {
},
}
export const systemConfigApi = {
getLocalAuth(): Promise<SystemAuthSettings> {
return fetchJson<SystemAuthSettings>('/api/v1/admin/system-config/auth/local')
},
updateLocalAuth(request: Pick<SystemAuthSettings, 'passwordLoginEnabled' | 'selfRegistrationEnabled' | 'version'>): Promise<SystemAuthSettings> {
return fetchJson<SystemAuthSettings>('/api/v1/admin/system-config/auth/local', {
method: 'PUT',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(request),
})
},
listRoles(): Promise<PlatformRole[]> {
return fetchJson<PlatformRole[]>('/api/v1/admin/system-config/roles')
},
listRoleGrants(): Promise<ExternalRoleGrantRule[]> {
return fetchJson<ExternalRoleGrantRule[]>('/api/v1/admin/system-config/role-grants')
},
createRoleGrant(request: { providerCode: string; email: string; roleCode: string }): Promise<ExternalRoleGrantRule> {
return fetchJson<ExternalRoleGrantRule>('/api/v1/admin/system-config/role-grants', {
method: 'POST',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(request),
})
},
updateRoleGrant(id: number, request: { roleCode: string; version: number }): Promise<ExternalRoleGrantRule> {
return fetchJson<ExternalRoleGrantRule>(`/api/v1/admin/system-config/role-grants/${id}`, {
method: 'PUT',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(request),
})
},
disableRoleGrant(id: number, version: number): Promise<ExternalRoleGrantRule> {
return fetchJson<ExternalRoleGrantRule>(`/api/v1/admin/system-config/role-grants/${id}?version=${version}`, {
method: 'DELETE',
headers: getCsrfHeaders(),
})
},
}
export const accountApi = {
async initiateMerge(request: MergeInitiateRequest): Promise<MergeInitiateResponse> {
return fetchJson<MergeInitiateResponse>('/api/v1/account/merge/initiate', {

View file

@ -474,6 +474,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/system-config/role-grants/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateRoleGrant"];
post?: never;
delete: operations["disableRoleGrant"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/system-config/auth/local": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getLocalSettings"];
put: operations["updateLocalSettings"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/namespaces/{slug}/members/{userId}/role": {
parameters: {
query?: never;
@ -2348,6 +2380,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/system-config/role-grants": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listRoleGrants"];
put?: never;
post: operations["createRoleGrant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/skills/{skillId}/unhide": {
parameters: {
query?: never;
@ -4801,6 +4849,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/auth/local/capabilities": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["capabilities"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/users": {
parameters: {
query?: never;
@ -4817,6 +4881,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/system-config/roles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listRoles"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/skill-reports": {
parameters: {
query?: never;
@ -5509,6 +5589,59 @@ export interface components {
AdminUserRoleUpdateRequest: {
role: string;
};
ExternalRoleGrantUpdateRequest: {
roleCode: string;
/** Format: int64 */
version: number;
};
ApiResponseExternalRoleGrantRuleResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["ExternalRoleGrantRuleResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
ExternalRoleGrantRuleResponse: {
/** Format: int64 */
id?: number;
providerCode?: string;
email?: string;
roleCode?: string;
status?: string;
matchedSubject?: string;
grantedUserId?: string;
/** Format: date-time */
grantedAt?: string;
/** Format: int64 */
version?: number;
/** Format: date-time */
updatedAt?: string;
};
SystemAuthSettingsUpdateRequest: {
passwordLoginEnabled: boolean;
selfRegistrationEnabled: boolean;
/** Format: int64 */
version: number;
};
ApiResponseSystemAuthSettingsResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["SystemAuthSettingsResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
SystemAuthSettingsResponse: {
passwordLoginEnabled?: boolean;
selfRegistrationEnabled?: boolean;
/** Format: int64 */
version?: number;
/** Format: date-time */
updatedAt?: string;
};
AdminLabelUpdateRequest: {
/** @enum {string} */
type: "RECOMMENDED" | "PRIVILEGED";
@ -6083,6 +6216,11 @@ export interface components {
/** Format: int32 */
interval?: number;
};
ExternalRoleGrantCreateRequest: {
providerCode: string;
email: string;
roleCode: string;
};
AdminSkillMutationResponse: {
/** Format: int64 */
skillId?: number;
@ -7702,6 +7840,20 @@ export interface components {
displayName?: string;
actionUrl?: string;
};
ApiResponseLocalAuthCapabilitiesResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["LocalAuthCapabilitiesResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
LocalAuthCapabilitiesResponse: {
passwordLoginEnabled?: boolean;
selfRegistrationEnabled?: boolean;
registrationAvailable?: boolean;
};
AdminUserSummaryResponse: {
id?: string;
username?: string;
@ -7729,6 +7881,28 @@ export interface components {
/** Format: int32 */
size?: number;
};
ApiResponseListPlatformRoleResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["PlatformRoleResponse"][];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
PlatformRoleResponse: {
code?: string;
name?: string;
};
ApiResponseListExternalRoleGrantRuleResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["ExternalRoleGrantRuleResponse"][];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
AdminSkillReportSummaryResponse: {
/** Format: int64 */
id?: number;
@ -9314,6 +9488,100 @@ export interface operations {
};
};
};
updateRoleGrant: {
parameters: {
query?: never;
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExternalRoleGrantUpdateRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseExternalRoleGrantRuleResponse"];
};
};
};
};
disableRoleGrant: {
parameters: {
query: {
version: number;
};
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseExternalRoleGrantRuleResponse"];
};
};
};
};
getLocalSettings: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSystemAuthSettingsResponse"];
};
};
};
};
updateLocalSettings: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SystemAuthSettingsUpdateRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSystemAuthSettingsResponse"];
};
};
};
};
updateMemberRole_2: {
parameters: {
query?: never;
@ -12451,6 +12719,50 @@ export interface operations {
};
};
};
listRoleGrants: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseListExternalRoleGrantRuleResponse"];
};
};
};
};
createRoleGrant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExternalRoleGrantCreateRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseExternalRoleGrantRuleResponse"];
};
};
};
};
unhideSkill: {
parameters: {
query?: never;
@ -16223,6 +16535,26 @@ export interface operations {
};
};
};
capabilities: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseLocalAuthCapabilitiesResponse"];
};
};
};
};
listUsers: {
parameters: {
query?: {
@ -16248,6 +16580,26 @@ export interface operations {
};
};
};
listRoles: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseListPlatformRoleResponse"];
};
};
};
};
listReports: {
parameters: {
query?: {

View file

@ -23,6 +23,37 @@ export interface AuthMethod {
actionUrl: string
}
export interface LocalAuthCapabilities {
passwordLoginEnabled: boolean
selfRegistrationEnabled: boolean
registrationAvailable: boolean
}
export interface SystemAuthSettings {
passwordLoginEnabled: boolean
selfRegistrationEnabled: boolean
version: number
updatedAt: string
}
export interface ExternalRoleGrantRule {
id: number
providerCode: string
email: string
roleCode: string
status: 'ACTIVE' | 'DISABLED' | 'CONSUMED'
matchedSubject?: string | null
grantedUserId?: string | null
grantedAt?: string | null
version: number
updatedAt: string
}
export interface PlatformRole {
code: string
name: string
}
export type ApiToken = Omit<components['schemas']['TokenSummaryResponse'], 'id' | 'name' | 'tokenPrefix' | 'createdAt'> & {
id: number
name: string

View file

@ -207,6 +207,11 @@ const AdminLabelsPage = createRoleProtectedRouteComponent(
'AdminLabelsPage',
['SUPER_ADMIN'],
)
const SystemConfigPage = createRoleProtectedRouteComponent(
() => import('@/pages/admin/system-config'),
'SystemConfigPage',
['SUPER_ADMIN'],
)
const AdminNamespacesPage = createRoleProtectedRouteComponent(
() => import('@/pages/admin/namespaces'),
'AdminNamespacesPage',
@ -647,6 +652,13 @@ const adminLabelsRoute = createRoute({
component: AdminLabelsPage,
})
const systemConfigRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'admin/system-config',
beforeLoad: requireAuth,
component: SystemConfigPage,
})
const adminNamespacesRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'admin/namespaces',
@ -700,6 +712,7 @@ const routeTree = rootRoute.addChildren([
adminUsersRoute,
adminAuditLogRoute,
adminLabelsRoute,
systemConfigRoute,
adminNamespacesRoute,
])

View file

@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { authApi } from '@/api/client'
export function useLocalAuthCapabilities() {
return useQuery({
queryKey: ['auth', 'local-capabilities'],
queryFn: authApi.getLocalCapabilities,
staleTime: 0,
})
}

View file

@ -1674,6 +1674,53 @@
"pageSuffix": "",
"goToPage": "Go to page {{page}}"
},
"systemConfig": {
"intro": "Manage sign-in methods and roles granted when new external accounts are created.",
"passwordLoginHelp": "When disabled, local accounts cannot sign in with a password again. Existing sessions remain valid until they expire.",
"selfRegistrationHelp": "When disabled, visitors cannot create local accounts. Existing accounts are unaffected.",
"unsaved": "Unsaved changes",
"changedElsewhere": "Another administrator changed these settings. Review the updated values before saving again.",
"discard": "Discard changes",
"saveChanges": "Save changes",
"saving": "Saving…",
"loading": "Loading…",
"lockoutTitle": "Disable local password sign-in?",
"confirmDisableLogin": "Disable sign-in",
"ruleIntro": "Choose a platform role for a new external account after it passes access checks.",
"ruleApplies": "Applies only to newly created external accounts after access checks. Grant roles to existing users in User Management.",
"ruleNoMerge": "Local and external accounts with the same email are not merged. The role goes only to the new external account.",
"ruleUnavailable": "Feishu and DingTalk do not currently provide verified email, so they cannot use this rule.",
"providerExample": "For example, github",
"existingRules": "Existing rules",
"noRules": "No rules yet. Add one using the form above.",
"disableTitle": "Disable this role rule?",
"title": "System settings",
"localAuth": "Local accounts",
"lockoutHelp": "Before disabling password login, confirm that external sign-in and super admin access work.",
"grantedTo": "Granted to account {{userId}} (external identity {{subject}})",
"passwordLogin": "Allow local password login",
"selfRegistration": "Allow local account registration",
"registrationRequiresLogin": "Password login is off, so registration is currently unavailable. The registration setting is retained.",
"lockoutWarning": "Admins who rely only on passwords cannot sign in again after this change. Confirm external sign-in and super admin access first. Existing sessions remain valid until they expire.",
"initialRoleRules": "Initial external account role rules",
"provider": "External sign-in provider",
"email": "Verified email",
"role": "Platform role",
"addRule": "Add rule",
"disable": "Disable",
"disableConfirm": "Disable this initial role rule?",
"saved": "Saved",
"saveFailed": "Save failed",
"loadFailed": "Could not load settings. Refresh and try again.",
"capabilitiesUnavailable": "Could not load login settings. Refresh and try again.",
"registrationDisabled": "Local registration is disabled. Use another available sign-in method.",
"passwordDisabled": "Local password login is disabled. Use another available sign-in method.",
"status": {
"ACTIVE": "Waiting",
"DISABLED": "Disabled",
"CONSUMED": "Granted"
}
},
"user": {
"menu": {
"dashboard": "Dashboard",
@ -1689,6 +1736,7 @@
"reports": "Report Management",
"users": "User Management",
"labels": "Label Management",
"systemConfig": "System Settings",
"auditLog": "Audit Log",
"security": "Security Settings",
"profile": "Profile Settings",
@ -1767,6 +1815,7 @@
},
"dialog": {
"confirm": "Confirm",
"processing": "Processing…",
"cancel": "Cancel",
"delete": "Delete",
"close": "Close"

View file

@ -1601,6 +1601,7 @@
},
"dialog": {
"confirm": "Подтвердить",
"processing": "Обработка…",
"cancel": "Отмена",
"delete": "Удалить",
"close": "Закрыть"
@ -1993,6 +1994,53 @@
"formatHint": "Поддерживается только формат .zip",
"folderHint": "Или выберите папку для упаковки и загрузки"
},
"systemConfig": {
"intro": "Настройте способы входа и роли для новых внешних учётных записей.",
"passwordLoginHelp": "После отключения локальные учётные записи не смогут снова войти по паролю. Текущие сеансы действуют до истечения срока.",
"selfRegistrationHelp": "После отключения посетители не смогут создавать локальные учётные записи. Существующие записи не затронуты.",
"unsaved": "Есть несохранённые изменения",
"changedElsewhere": "Настройки изменены другим администратором. Проверьте новые значения перед повторным сохранением.",
"discard": "Отменить изменения",
"saveChanges": "Сохранить изменения",
"saving": "Сохранение…",
"loading": "Загрузка…",
"lockoutTitle": "Отключить вход по локальному паролю?",
"confirmDisableLogin": "Отключить вход",
"ruleIntro": "Назначьте роль новой внешней учётной записи после проверки доступа.",
"ruleApplies": "Действует только для новых внешних учётных записей после проверки доступа. Существующим пользователям назначайте роли в управлении пользователями.",
"ruleNoMerge": "Локальные и внешние учётные записи с одинаковой почтой не объединяются. Роль получает только новая внешняя запись.",
"ruleUnavailable": "Feishu и DingTalk пока не предоставляют подтверждённую почту, поэтому правило для них недоступно.",
"providerExample": "Например, github",
"existingRules": "Существующие правила",
"noRules": "Правил пока нет. Добавьте правило с помощью формы выше.",
"disableTitle": "Отключить правило назначения роли?",
"title": "Системные настройки",
"localAuth": "Локальные учётные записи",
"lockoutHelp": "Перед отключением входа по паролю проверьте внешний вход и доступ суперадминистратора.",
"grantedTo": "Назначено учётной записи {{userId}} (внешний идентификатор {{subject}})",
"passwordLogin": "Разрешить вход по локальному паролю",
"selfRegistration": "Разрешить самостоятельную регистрацию",
"registrationRequiresLogin": "Вход по паролю отключён, поэтому регистрация сейчас недоступна. Настройка регистрации сохранена.",
"lockoutWarning": "Администраторы, использующие только пароль, не смогут войти снова. Сначала проверьте внешний вход и доступ суперадминистратора. Текущие сеансы действуют до истечения срока.",
"initialRoleRules": "Правила первоначального назначения ролей",
"provider": "Источник внешнего входа",
"email": "Подтверждённая почта",
"role": "Роль платформы",
"addRule": "Добавить правило",
"disable": "Отключить",
"disableConfirm": "Отключить это правило назначения роли?",
"saved": "Сохранено",
"saveFailed": "Ошибка сохранения",
"loadFailed": "Не удалось загрузить настройки. Обновите страницу.",
"capabilitiesUnavailable": "Не удалось загрузить настройки входа. Обновите страницу.",
"registrationDisabled": "Локальная регистрация отключена. Используйте другой способ входа.",
"passwordDisabled": "Вход по локальному паролю отключён. Используйте другой способ входа.",
"status": {
"ACTIVE": "Ожидание",
"DISABLED": "Отключено",
"CONSUMED": "Назначено"
}
},
"user": {
"menu": {
"dashboard": "Панель",
@ -2009,6 +2057,7 @@
"reports": "Управление жалобами",
"users": "Пользователи",
"labels": "Метки",
"systemConfig": "Системные настройки",
"auditLog": "Журнал аудита",
"security": "Безопасность",
"profile": "Профиль",

View file

@ -1673,6 +1673,53 @@
"pageSuffix": "页",
"goToPage": "第 {{page}} 页"
},
"systemConfig": {
"intro": "管理登录方式,以及新外部账号首次进入时的角色授权。",
"passwordLoginHelp": "关闭后,所有本地账号都不能再用密码重新登录。已有会话按原有效期继续使用。",
"selfRegistrationHelp": "关闭后,访客不能自行创建本地账号;已有账号不受影响。",
"unsaved": "有未保存的修改",
"changedElsewhere": "配置已由其他管理员修改。页面已更新,请核对后重新保存。",
"discard": "放弃修改",
"saveChanges": "保存修改",
"saving": "保存中…",
"loading": "加载中…",
"lockoutTitle": "确认关闭本地密码登录?",
"confirmDisableLogin": "确认关闭",
"ruleIntro": "为通过准入审核的新外部账号,预先指定平台角色。",
"ruleApplies": "仅对通过现有准入策略、首次创建的外部账号生效;已有账号请到用户管理授权。",
"ruleNoMerge": "同邮箱的本地账号与外部账号不会合并,角色只授给新外部账号。",
"ruleUnavailable": "飞书和钉钉目前不提供已验证邮箱,暂不能配置此规则。",
"providerExample": "例如 github",
"existingRules": "已有规则",
"noRules": "暂无规则。填写上方信息后添加。",
"disableTitle": "停用授权规则?",
"title": "系统配置",
"localAuth": "本地账号",
"lockoutHelp": "关闭密码登录前,请先确认外部身份登录和超级管理员权限可用。",
"grantedTo": "已授予账号 {{userId}}(外部身份 {{subject}})",
"passwordLogin": "允许本地密码登录",
"selfRegistration": "允许自行注册本地账号",
"registrationRequiresLogin": "密码登录已关闭,当前无法自行注册;注册设置会保留。",
"lockoutWarning": "关闭后,只靠密码登录的管理员将无法重新登录。请先确认外部登录和超级管理员权限可用;已有会话按原有效期结束。",
"initialRoleRules": "外部账号首次授权规则",
"provider": "外部登录来源",
"email": "已验证邮箱",
"role": "平台角色",
"addRule": "添加规则",
"disable": "停用",
"disableConfirm": "确定停用这条首次授权规则吗?",
"saved": "已保存",
"saveFailed": "保存失败",
"loadFailed": "配置加载失败,请刷新重试。",
"capabilitiesUnavailable": "暂时无法获取登录配置,请刷新重试。",
"registrationDisabled": "本地账号注册已关闭。请使用其他可用登录方式。",
"passwordDisabled": "本地密码登录已关闭。请使用其他可用登录方式。",
"status": {
"ACTIVE": "待匹配",
"DISABLED": "已停用",
"CONSUMED": "已授权"
}
},
"user": {
"menu": {
"dashboard": "控制台",
@ -1688,6 +1735,7 @@
"reports": "举报管理",
"users": "用户管理",
"labels": "标签管理",
"systemConfig": "系统配置",
"auditLog": "审计日志",
"security": "安全设置",
"profile": "个人设置",
@ -1766,6 +1814,7 @@
},
"dialog": {
"confirm": "确认",
"processing": "处理中…",
"cancel": "取消",
"delete": "删除",
"close": "关闭"

View file

@ -0,0 +1,150 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({
getLocalAuth: vi.fn(),
updateLocalAuth: vi.fn(),
listRoles: vi.fn(),
listRoleGrants: vi.fn(),
createRoleGrant: vi.fn(),
updateRoleGrant: vi.fn(),
disableRoleGrant: vi.fn(),
}))
vi.mock('@/api/client', () => ({
systemConfigApi: api,
ApiError: class ApiError extends Error {
constructor(message: string, public status: number) { super(message) }
},
}))
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
import { ApiError } from '@/api/client'
import { SystemConfigPage } from './system-config'
describe('SystemConfigPage', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Object.values(api).forEach((mock) => mock.mockReset())
})
function setup(ruleList: unknown[] = []) {
api.getLocalAuth.mockResolvedValue({ passwordLoginEnabled: true, selfRegistrationEnabled: true, version: 3 })
api.listRoles.mockResolvedValue([{ code: 'SUPER_ADMIN', name: 'Super Admin' }, { code: 'USER', name: 'User' }])
api.listRoleGrants.mockResolvedValue(ruleList)
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><SystemConfigPage /></QueryClientProvider>)
return client
}
it('stages changes, allows discard, and confirms lockout before saving the observed version', async () => {
api.updateLocalAuth.mockResolvedValue({ passwordLoginEnabled: false, selfRegistrationEnabled: true, version: 4 })
const client = setup()
const checkbox = await screen.findByRole('checkbox', { name: 'systemConfig.passwordLogin' })
fireEvent.click(checkbox)
expect(api.updateLocalAuth).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.discard' }))
expect((checkbox as HTMLInputElement).checked).toBe(true)
fireEvent.click(checkbox)
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
const dialog = await screen.findByRole('dialog', { name: 'systemConfig.lockoutTitle' })
expect(api.updateLocalAuth).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.cancel' }))
expect((checkbox as HTMLInputElement).checked).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
await waitFor(() => expect(api.updateLocalAuth.mock.calls[0]?.[0]).toEqual({
passwordLoginEnabled: false,
selfRegistrationEnabled: true,
version: 3,
}))
await waitFor(() => expect(client.getQueryData(['auth', 'local-capabilities'])).toEqual({
passwordLoginEnabled: false,
selfRegistrationEnabled: true,
registrationAvailable: false,
}))
})
it('keeps the confirmation open when saving fails so the administrator can retry', async () => {
api.updateLocalAuth.mockRejectedValueOnce(new Error('Version conflict'))
.mockResolvedValueOnce({ passwordLoginEnabled: false, selfRegistrationEnabled: true, version: 4 })
setup()
const checkbox = await screen.findByRole('checkbox', { name: 'systemConfig.passwordLogin' })
fireEvent.click(checkbox)
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
expect((await within(screen.getByRole('dialog')).findByRole('alert')).textContent).toBe('Version conflict')
expect(screen.getByRole('dialog')).not.toBeNull()
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
await waitFor(() => expect(api.updateLocalAuth).toHaveBeenCalledTimes(2))
})
it('reloads current settings after a concurrent administrator changes them', async () => {
api.getLocalAuth.mockResolvedValueOnce({ passwordLoginEnabled: true, selfRegistrationEnabled: true, version: 3 })
.mockResolvedValueOnce({ passwordLoginEnabled: true, selfRegistrationEnabled: false, version: 4 })
api.updateLocalAuth.mockRejectedValue(new ApiError('Version conflict', 409))
setup()
fireEvent.click(await screen.findByRole('checkbox', { name: 'systemConfig.passwordLogin' }))
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect((await screen.findByRole('alert')).textContent).toBe('systemConfig.changedElsewhere')
expect((screen.getByRole('checkbox', { name: 'systemConfig.passwordLogin' }) as HTMLInputElement).checked).toBe(true)
expect((screen.getByRole('checkbox', { name: 'systemConfig.selfRegistration' }) as HTMLInputElement).checked).toBe(false)
expect(api.updateLocalAuth).toHaveBeenCalledTimes(1)
})
it('saves a registration change only after Save, without a lockout confirmation', async () => {
api.updateLocalAuth.mockResolvedValue({ passwordLoginEnabled: true, selfRegistrationEnabled: false, version: 4 })
setup()
fireEvent.click(await screen.findByRole('checkbox', { name: 'systemConfig.selfRegistration' }))
expect(api.updateLocalAuth).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
await waitFor(() => expect(api.updateLocalAuth.mock.calls[0]?.[0]).toEqual({
passwordLoginEnabled: true, selfRegistrationEnabled: false, version: 3,
}))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('locks the confirmation while a change is being saved', async () => {
let resolveSave!: (value: unknown) => void
api.updateLocalAuth.mockImplementation(() => new Promise((resolve) => { resolveSave = resolve }))
setup()
fireEvent.click(await screen.findByRole('checkbox', { name: 'systemConfig.passwordLogin' }))
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
const dialog = await screen.findByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
const processing = within(dialog).getByRole('button', { name: 'dialog.processing' })
expect((processing as HTMLButtonElement).disabled).toBe(true)
expect((within(dialog).getByRole('button', { name: 'dialog.cancel' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(processing)
await waitFor(() => expect(api.updateLocalAuth).toHaveBeenCalledTimes(1))
resolveSave({ passwordLoginEnabled: false, selfRegistrationEnabled: true, version: 4 })
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
it('requires an explicit save after changing an active rule role', async () => {
setup([{ id: 1, providerCode: 'github', email: 'admin@example.com', roleCode: 'SUPER_ADMIN', status: 'ACTIVE', version: 2 }])
await screen.findByText('admin@example.com')
fireEvent.change(screen.getAllByRole('combobox', { name: 'systemConfig.role' })[1], { target: { value: 'USER' } })
expect(api.updateRoleGrant).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByRole('button', { name: 'systemConfig.saveChanges' })[1])
await waitFor(() => expect(api.updateRoleGrant).toHaveBeenCalledWith(1, { version: 2, roleCode: 'USER' }))
})
it('requires an in-page confirmation before disabling a rule', async () => {
api.disableRoleGrant.mockResolvedValue({})
setup([{ id: 1, providerCode: 'github', email: 'admin@example.com', roleCode: 'SUPER_ADMIN', status: 'ACTIVE', version: 2 }])
await screen.findByText('admin@example.com')
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.disable' }))
const dialog = await screen.findByRole('dialog', { name: 'systemConfig.disableTitle' })
fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.cancel' }))
expect(api.disableRoleGrant).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.disable' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'systemConfig.disable' }))
await waitFor(() => expect(api.disableRoleGrant).toHaveBeenCalledWith(1, 2))
})
})

View file

@ -0,0 +1,248 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ApiError, systemConfigApi } from '@/api/client'
import type { ExternalRoleGrantRule } from '@/api/types'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
export function SystemConfigPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const settings = useQuery({ queryKey: ['system-config', 'local'], queryFn: systemConfigApi.getLocalAuth })
const rules = useQuery({ queryKey: ['system-config', 'rules'], queryFn: systemConfigApi.listRoleGrants })
const roles = useQuery({ queryKey: ['system-config', 'roles'], queryFn: systemConfigApi.listRoles })
const [providerCode, setProviderCode] = useState('')
const [email, setEmail] = useState('')
const [roleCode, setRoleCode] = useState('SUPER_ADMIN')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [draft, setDraft] = useState<{ passwordLoginEnabled: boolean; selfRegistrationEnabled: boolean } | null>(null)
const [confirmLoginOff, setConfirmLoginOff] = useState(false)
const [ruleToDisable, setRuleToDisable] = useState<ExternalRoleGrantRule | null>(null)
const [ruleDrafts, setRuleDrafts] = useState<Record<number, string>>({})
const initializedVersion = useRef<number | null>(null)
useEffect(() => {
if (settings.data && initializedVersion.current !== settings.data.version) {
initializedVersion.current = settings.data.version
setDraft({ passwordLoginEnabled: settings.data.passwordLoginEnabled, selfRegistrationEnabled: settings.data.selfRegistrationEnabled })
}
}, [settings.data])
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ['system-config'] })
}
const updateSettings = useMutation({
mutationFn: systemConfigApi.updateLocalAuth,
onSuccess: async (value) => {
queryClient.setQueryData(['auth', 'local-capabilities'], {
passwordLoginEnabled: value.passwordLoginEnabled,
selfRegistrationEnabled: value.selfRegistrationEnabled,
registrationAvailable: value.passwordLoginEnabled && value.selfRegistrationEnabled,
})
await refresh()
},
})
const createRule = useMutation({ mutationFn: systemConfigApi.createRoleGrant, onSuccess: refresh })
const updateRule = useMutation({
mutationFn: ({ id, version, code }: { id: number; version: number; code: string }) =>
systemConfigApi.updateRoleGrant(id, { version, roleCode: code }),
onSuccess: refresh,
})
const disableRule = useMutation({
mutationFn: ({ id, version }: { id: number; version: number }) => systemConfigApi.disableRoleGrant(id, version),
onSuccess: refresh,
})
async function run(action: () => Promise<unknown>): Promise<boolean> {
setError('')
setMessage('')
try {
await action()
setMessage(t('systemConfig.saved'))
return true
} catch (cause) {
if (cause instanceof ApiError && cause.status === 409) {
setConfirmLoginOff(false)
setRuleToDisable(null)
await refresh()
setError(t('systemConfig.changedElsewhere'))
return false
}
setError(cause instanceof Error ? cause.message : t('systemConfig.saveFailed'))
return false
}
}
async function saveSettings() {
if (!settings.data || !draft || updateSettings.isPending) return false
return run(() => updateSettings.mutateAsync({ ...draft, version: settings.data!.version }))
}
const settingsDirty = !!settings.data && !!draft && (
settings.data.passwordLoginEnabled !== draft.passwordLoginEnabled
|| settings.data.selfRegistrationEnabled !== draft.selfRegistrationEnabled
)
function changeRole(rule: ExternalRoleGrantRule) {
const code = ruleDrafts[rule.id]
if (!code || code === rule.roleCode) return
void run(async () => {
await updateRule.mutateAsync({ id: rule.id, version: rule.version, code })
setRuleDrafts((current) => ({ ...current, [rule.id]: code }))
})
}
return (
<div className="mx-auto max-w-5xl space-y-6 p-4 pb-12 sm:p-8">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">{t('systemConfig.title')}</h1>
<p className="text-sm text-muted-foreground">{t('systemConfig.intro')}</p>
</div>
{error ? <p role="alert" className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</p> : null}
{message ? <p role="status" className="rounded-lg bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700">{message}</p> : null}
<Card>
<CardHeader>
<CardTitle>{t('systemConfig.localAuth')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('systemConfig.lockoutHelp')}</p>
</CardHeader>
<CardContent className="space-y-4">
{settings.isError ? <p role="alert">{t('systemConfig.loadFailed')}</p> : null}
{settings.isPending ? <p className="text-sm text-muted-foreground">{t('systemConfig.loading')}</p> : null}
{settings.data && draft ? (
<>
<div className="divide-y rounded-xl border border-border/60">
{([
['passwordLoginEnabled', 'passwordLogin', 'passwordLoginHelp'],
['selfRegistrationEnabled', 'selfRegistration', 'selfRegistrationHelp'],
] as const).map(([field, title, help]) => (
<label key={field} className="flex cursor-pointer items-center justify-between gap-6 p-4 sm:p-5">
<span className="min-w-0 space-y-1">
<span className="block text-sm font-medium">{t(`systemConfig.${title}`)}</span>
<span className="block text-sm text-muted-foreground">{t(`systemConfig.${help}`)}</span>
</span>
<span className="relative shrink-0">
<input type="checkbox" className="peer sr-only" checked={draft[field]} disabled={updateSettings.isPending}
onChange={(event) => setDraft({ ...draft, [field]: event.target.checked })}
aria-label={t(`systemConfig.${title}`)} />
<span aria-hidden="true" className="block h-6 w-11 rounded-full bg-muted-foreground/35 transition-colors peer-checked:bg-primary peer-focus-visible:ring-4 peer-focus-visible:ring-ring/30 peer-disabled:opacity-50" />
<span aria-hidden="true" className="pointer-events-none absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform peer-checked:translate-x-5" />
</span>
</label>
))}
</div>
{!draft.passwordLoginEnabled && draft.selfRegistrationEnabled ? (
<p className="rounded-lg bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">{t('systemConfig.registrationRequiresLogin')}</p>
) : null}
<div className="flex flex-wrap items-center justify-end gap-3 border-t pt-4">
{settingsDirty ? <span role="status" className="mr-auto text-sm text-muted-foreground">{t('systemConfig.unsaved')}</span> : null}
<Button type="button" variant="outline" disabled={!settingsDirty || updateSettings.isPending}
onClick={() => setDraft({ passwordLoginEnabled: settings.data!.passwordLoginEnabled, selfRegistrationEnabled: settings.data!.selfRegistrationEnabled })}>
{t('systemConfig.discard')}
</Button>
<Button type="button" disabled={!settingsDirty || updateSettings.isPending} onClick={() => {
if (settings.data!.passwordLoginEnabled && !draft.passwordLoginEnabled) setConfirmLoginOff(true)
else void saveSettings()
}}>{updateSettings.isPending ? t('systemConfig.saving') : t('systemConfig.saveChanges')}</Button>
</div>
</>
) : null}
</CardContent>
</Card>
<ConfirmDialog open={confirmLoginOff} onOpenChange={setConfirmLoginOff}
closeOnConfirm={false}
title={t('systemConfig.lockoutTitle')} description={<>{t('systemConfig.lockoutWarning')}{error ? <span role="alert" className="mt-3 block text-destructive">{error}</span> : null}</>}
confirmText={t('systemConfig.confirmDisableLogin')} variant="destructive"
onConfirm={async () => { if (await saveSettings()) setConfirmLoginOff(false) }} />
<Card>
<CardHeader>
<CardTitle>{t('systemConfig.initialRoleRules')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('systemConfig.ruleIntro')}</p>
</CardHeader>
<CardContent className="space-y-5">
<ul className="list-disc space-y-1 rounded-lg bg-muted/60 px-8 py-3 text-sm leading-relaxed text-muted-foreground">
<li>{t('systemConfig.ruleApplies')}</li>
<li>{t('systemConfig.ruleNoMerge')}</li>
<li>{t('systemConfig.ruleUnavailable')}</li>
</ul>
<form className="grid items-end gap-4 rounded-xl border border-border/60 bg-muted/20 p-4 sm:grid-cols-2 lg:grid-cols-[1fr_1.2fr_1fr_auto]" onSubmit={(event) => {
event.preventDefault()
void run(async () => {
await createRule.mutateAsync({ providerCode: providerCode.trim(), email: email.trim(), roleCode })
setProviderCode('')
setEmail('')
})
}}>
<div className="space-y-1.5"><Label htmlFor="role-grant-provider">{t('systemConfig.provider')}</Label>
<Input id="role-grant-provider" placeholder={t('systemConfig.providerExample')} value={providerCode}
onChange={(event) => setProviderCode(event.target.value)} required maxLength={64} /></div>
<div className="space-y-1.5"><Label htmlFor="role-grant-email">{t('systemConfig.email')}</Label>
<Input id="role-grant-email" placeholder="admin@example.com" type="email" value={email}
onChange={(event) => setEmail(event.target.value)} required /></div>
<div className="space-y-1.5"><Label htmlFor="role-grant-role">{t('systemConfig.role')}</Label>
<select id="role-grant-role" className="h-9 w-full rounded-md border border-border/60 bg-background px-3 text-sm" value={roleCode}
onChange={(event) => setRoleCode(event.target.value)}>
{(roles.data ?? []).map((role) => <option key={role.code} value={role.code}>{role.name}</option>)}
</select></div>
<Button type="submit" disabled={createRule.isPending || !roles.data?.length}>{createRule.isPending ? t('systemConfig.saving') : t('systemConfig.addRule')}</Button>
</form>
{rules.isError || roles.isError ? <p role="alert">{t('systemConfig.loadFailed')}</p> : null}
<h3 className="text-sm font-semibold">{t('systemConfig.existingRules')}</h3>
{rules.isPending ? <p className="text-sm text-muted-foreground">{t('systemConfig.loading')}</p> : null}
{rules.data?.length === 0 ? <p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">{t('systemConfig.noRules')}</p> : null}
<div className="space-y-3">
{(rules.data ?? []).map((rule) => (
<div key={rule.id} className="space-y-3 rounded-xl border border-border/60 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-all text-sm font-medium">{rule.email}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('systemConfig.provider')}: {rule.providerCode}</p>
</div>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-secondary-foreground">{t(`systemConfig.status.${rule.status}`)}</span>
</div>
{rule.status === 'CONSUMED' ? (
<p className="break-all text-xs text-muted-foreground">
{t('systemConfig.grantedTo', { userId: rule.grantedUserId, subject: rule.matchedSubject })}
</p>
) : null}
{rule.status === 'ACTIVE' ? (
<div className="flex flex-wrap items-end gap-2 border-t pt-3">
<div className="min-w-40 flex-1 space-y-1.5 sm:flex-none">
<Label htmlFor={`rule-role-${rule.id}`}>{t('systemConfig.role')}</Label>
<select id={`rule-role-${rule.id}`} className="h-9 w-full rounded-md border border-border/60 bg-background px-3 text-sm"
value={ruleDrafts[rule.id] ?? rule.roleCode} disabled={updateRule.isPending}
onChange={(event) => setRuleDrafts({ ...ruleDrafts, [rule.id]: event.target.value })}>
{(roles.data ?? []).map((role) => <option key={role.code} value={role.code}>{role.name}</option>)}
</select>
</div>
{ruleDrafts[rule.id] && ruleDrafts[rule.id] !== rule.roleCode ? (
<>
<Button type="button" size="sm" variant="outline" disabled={updateRule.isPending} onClick={() => setRuleDrafts({ ...ruleDrafts, [rule.id]: rule.roleCode })}>{t('systemConfig.discard')}</Button>
<Button type="button" size="sm" disabled={updateRule.isPending} onClick={() => changeRole(rule)}>{t('systemConfig.saveChanges')}</Button>
</>
) : null}
<Button type="button" size="sm" variant="ghost" className="sm:ml-auto" disabled={disableRule.isPending}
onClick={() => setRuleToDisable(rule)}>{t('systemConfig.disable')}</Button>
</div>
) : <p className="text-xs text-muted-foreground">{t('systemConfig.role')}: {roles.data?.find((role) => role.code === rule.roleCode)?.name ?? rule.roleCode}</p>}
</div>
))}
</div>
</CardContent>
</Card>
<ConfirmDialog open={!!ruleToDisable} onOpenChange={(open) => { if (!open) setRuleToDisable(null) }}
closeOnConfirm={false}
title={t('systemConfig.disableTitle')} description={<>{t('systemConfig.disableConfirm')}{error ? <span role="alert" className="mt-3 block text-destructive">{error}</span> : null}</>}
confirmText={t('systemConfig.disable')} variant="destructive"
onConfirm={async () => {
if (ruleToDisable && await run(() => disableRule.mutateAsync({ id: ruleToDisable.id, version: ruleToDisable.version }))) {
setRuleToDisable(null)
}
}} />
</div>
)
}

View file

@ -11,6 +11,8 @@ const authMethodsFixture = vi.hoisted(() => ({
returnTo: '',
navigate: vi.fn(),
mutateAsync: vi.fn(),
passwordEnabled: true,
registrationAvailable: true,
}))
vi.mock('@tanstack/react-router', () => ({
@ -60,6 +62,17 @@ vi.mock('@/features/auth/use-auth-methods', () => ({
useAuthMethods: () => ({ data: authMethodsFixture.methods, isError: authMethodsFixture.isError }),
}))
vi.mock('@/features/auth/use-local-auth-capabilities', () => ({
useLocalAuthCapabilities: () => ({
data: {
passwordLoginEnabled: authMethodsFixture.passwordEnabled,
selfRegistrationEnabled: authMethodsFixture.registrationAvailable,
registrationAvailable: authMethodsFixture.registrationAvailable,
},
isError: false,
}),
}))
vi.mock('@/features/auth/use-password-login', () => ({
usePasswordLogin: () => ({
mutateAsync: authMethodsFixture.mutateAsync,
@ -86,6 +99,8 @@ describe('LoginPage', () => {
authMethodsFixture.bootstrapEnabled = false
authMethodsFixture.directEnabled = false
authMethodsFixture.isError = false
authMethodsFixture.passwordEnabled = true
authMethodsFixture.registrationAvailable = true
authMethodsFixture.returnTo = ''
authMethodsFixture.navigate.mockClear()
authMethodsFixture.mutateAsync.mockClear()
@ -105,6 +120,15 @@ describe('LoginPage', () => {
expect(html).toContain('login.register')
})
it('hides password and registration entry when local authentication is disabled', () => {
authMethodsFixture.passwordEnabled = false
authMethodsFixture.registrationAvailable = false
const html = renderToStaticMarkup(<LoginPage />)
expect(html).not.toContain('login.submit')
expect(html).not.toContain('login.register')
expect(html).toContain('systemConfig.passwordDisabled')
})
it('does not expose password routing details when direct login is configured', () => {
authMethodsFixture.directEnabled = true
const html = renderToStaticMarkup(<LoginPage />)

View file

@ -7,6 +7,7 @@ import { AuthShell } from '@/features/auth/auth-shell'
import { AuthMethodButtonList } from '@/features/auth/login-button'
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
import { useAuthMethods } from '@/features/auth/use-auth-methods'
import { useLocalAuthCapabilities } from '@/features/auth/use-local-auth-capabilities'
import { usePasswordLogin } from '@/features/auth/use-password-login'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@ -32,6 +33,9 @@ export function LoginPage() {
const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh'
const returnTo = resolveAuthReturnTo(search.returnTo)
const { data: authMethods, isLoading: authMethodsLoading } = useAuthMethods(returnTo)
const { data: localCapabilities, isError: localCapabilitiesError } = useLocalAuthCapabilities()
const passwordEnabled = localCapabilities?.passwordLoginEnabled === true
const effectiveLoginMode = passwordEnabled ? loginMode : 'organization'
const disabledMessage = search.reason === 'accountDisabled' ? t('apiError.auth.accountDisabled') : null
const bootstrapMethod = authMethods?.find((method) => method.methodType === 'SESSION_BOOTSTRAP')
const hasOrganizationMethod = bootstrapConfig.enabled
@ -79,7 +83,7 @@ export function LoginPage() {
</div>
) : null}
{hasOrganizationMethod ? (
{hasOrganizationMethod && passwordEnabled ? (
<div role="group" aria-label={t('login.loginMode')} className="grid grid-cols-2 rounded-lg border border-slate-200 bg-slate-50 p-1 dark:border-slate-700 dark:bg-slate-900">
<button type="button" aria-pressed={loginMode === 'personal'} onClick={() => setLoginMode('personal')} className={`h-11 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 xl:h-10 ${loginMode === 'personal' ? 'bg-sky-100 text-sky-900 dark:bg-sky-900 dark:text-sky-100' : 'text-muted-foreground hover:text-foreground'}`}>
{t('login.tabPersonal')}
@ -90,7 +94,7 @@ export function LoginPage() {
</div>
) : null}
<div hidden={loginMode !== 'personal'}>
{passwordEnabled ? <div hidden={effectiveLoginMode !== 'personal'}>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="username">{t('login.username')}</label>
@ -161,10 +165,17 @@ export function LoginPage() {
<ArrowRight aria-hidden="true" className="ml-2 h-4 w-4" />
</Button>
</form>
</div>
</div> : null}
{localCapabilitiesError ? (
<p className="text-sm text-red-600">{t('systemConfig.capabilitiesUnavailable')}</p>
) : null}
{localCapabilities && !passwordEnabled && !bootstrapConfig.enabled && !hasExternalMethods ? (
<p className="text-sm text-muted-foreground">{t('systemConfig.passwordDisabled')}</p>
) : null}
{bootstrapConfig.enabled ? (
<div hidden={loginMode !== 'organization'} className="space-y-2">
<div hidden={effectiveLoginMode !== 'organization'} className="space-y-2">
<SessionBootstrapEntry
methodDisplayName={bootstrapMethod?.displayName}
onAuthenticated={() => navigate({ to: returnTo })}
@ -183,13 +194,13 @@ export function LoginPage() {
</section>
) : null}
<p className="text-center text-sm text-muted-foreground">
{localCapabilities?.registrationAvailable ? <p className="text-center text-sm text-muted-foreground">
{t('login.noAccount')}
{' '}
<Link to="/register" search={{ returnTo }} className="font-medium text-sky-700 hover:underline dark:text-sky-300">
{t('login.register')}
</Link>
</p>
</p> : null}
<p className="mt-auto text-center text-xs text-muted-foreground">
{t('login.agreementPrefix')}

View file

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
const authMethodsFixture = vi.hoisted(() => ({
methods: [] as Array<{ id: string, methodType: string }>,
isLoading: false,
registrationAvailable: true,
}))
vi.mock('@tanstack/react-router', () => ({
@ -33,6 +34,14 @@ vi.mock('@/features/auth/use-auth-methods', () => ({
useAuthMethods: () => ({ data: authMethodsFixture.methods, isLoading: authMethodsFixture.isLoading }),
}))
vi.mock('@/features/auth/use-local-auth-capabilities', () => ({
useLocalAuthCapabilities: () => ({
data: { passwordLoginEnabled: true, selfRegistrationEnabled: authMethodsFixture.registrationAvailable,
registrationAvailable: authMethodsFixture.registrationAvailable },
isError: false,
}),
}))
vi.mock('@/features/auth/use-local-auth', () => ({
useLocalRegister: () => ({
mutateAsync: vi.fn(),
@ -89,4 +98,12 @@ describe('RegisterPage', () => {
expect(html).toContain('OAuth buttons')
authMethodsFixture.methods = []
})
it('hides local registration when disabled', () => {
authMethodsFixture.registrationAvailable = false
const html = renderToStaticMarkup(<RegisterPage />)
expect(html).not.toContain('register.submit')
expect(html).toContain('systemConfig.registrationDisabled')
authMethodsFixture.registrationAvailable = true
})
})

View file

@ -5,6 +5,7 @@ import { ApiError } from '@/api/client'
import { AuthShell } from '@/features/auth/auth-shell'
import { AuthMethodButtonList } from '@/features/auth/login-button'
import { useAuthMethods } from '@/features/auth/use-auth-methods'
import { useLocalAuthCapabilities } from '@/features/auth/use-local-auth-capabilities'
import { useLocalRegister } from '@/features/auth/use-local-auth'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@ -64,6 +65,7 @@ export function RegisterPage() {
const returnTo = resolveAuthReturnTo(search.returnTo)
const { data: authMethods, isLoading: authMethodsLoading } = useAuthMethods(returnTo)
const { data: capabilities, isError: capabilitiesError } = useLocalAuthCapabilities()
const hasExternalMethods = authMethods?.some((method) => method.methodType === 'OAUTH_REDIRECT')
function validateUsername(value: string) {
@ -142,6 +144,7 @@ export function RegisterPage() {
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!capabilities?.registrationAvailable) return
const trimmedUsername = username.trim()
const trimmedEmail = email.trim().toLowerCase()
const nextFieldErrors: RegisterFieldErrors = {}
@ -178,7 +181,9 @@ export function RegisterPage() {
<p className="text-sm text-muted-foreground sm:text-base">{t('register.subtitle')}</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
{capabilitiesError ? <p role="alert" className="text-sm text-red-600">{t('systemConfig.capabilitiesUnavailable')}</p> : null}
{capabilities && !capabilities.registrationAvailable ? <p className="text-sm text-muted-foreground">{t('systemConfig.registrationDisabled')}</p> : null}
{capabilities?.registrationAvailable ? <form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-username">{t('register.username')}</label>
<Input
@ -263,7 +268,7 @@ export function RegisterPage() {
{t('register.login')}
</Link>
</p>
</form>
</form> : null}
{authMethodsLoading || hasExternalMethods ? (
<div className="space-y-3 border-t border-border/70 pt-5">

View file

@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
const capabilities = vi.hoisted(() => ({ passwordLoginEnabled: true }))
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: unknown }) => children,
}))
@ -21,6 +23,10 @@ vi.mock('@/api/client', () => ({
},
}))
vi.mock('@/features/auth/use-local-auth-capabilities', () => ({
useLocalAuthCapabilities: () => ({ data: { passwordLoginEnabled: capabilities.passwordLoginEnabled }, isError: false }),
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
}))
@ -51,4 +57,12 @@ describe('ResetPasswordPage', () => {
expect(html).toContain('resetPassword.sendCode')
expect(html).toContain('resetPassword.submit')
})
it('hides reset actions when password login is disabled', () => {
capabilities.passwordLoginEnabled = false
const html = renderToStaticMarkup(<ResetPasswordPage />)
expect(html).not.toContain('resetPassword.sendCode')
expect(html).toContain('systemConfig.passwordDisabled')
capabilities.passwordLoginEnabled = true
})
})

View file

@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router'
import { FormEvent, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { authApi } from '@/api/client'
import { useLocalAuthCapabilities } from '@/features/auth/use-local-auth-capabilities'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
@ -11,6 +12,7 @@ import { Input } from '@/shared/ui/input'
*/
export function ResetPasswordPage() {
const { t } = useTranslation()
const { data: capabilities, isError: capabilitiesError } = useLocalAuthCapabilities()
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [newPassword, setNewPassword] = useState('')
@ -23,6 +25,7 @@ export function ResetPasswordPage() {
const emailPattern = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
async function handleSendCode() {
if (!capabilities?.passwordLoginEnabled) return
const normalizedEmail = email.trim().toLowerCase()
if (!normalizedEmail) {
setErrorMessage(t('resetPassword.emailRequired'))
@ -48,6 +51,7 @@ export function ResetPasswordPage() {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!capabilities?.passwordLoginEnabled) return
const normalizedEmail = email.trim().toLowerCase()
if (!normalizedEmail) {
@ -95,7 +99,9 @@ export function ResetPasswordPage() {
<CardDescription>{t('resetPassword.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
{isSuccess ? (
{capabilitiesError ? <p role="alert" className="text-sm text-red-600">{t('systemConfig.capabilitiesUnavailable')}</p> : null}
{capabilities && !capabilities.passwordLoginEnabled ? <p className="text-sm text-muted-foreground">{t('systemConfig.passwordDisabled')}</p> : null}
{capabilities?.passwordLoginEnabled && isSuccess ? (
<div className="space-y-4">
<p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{t('resetPassword.successMessage')}
@ -104,7 +110,7 @@ export function ResetPasswordPage() {
{t('resetPassword.backToLogin')}
</Link>
</div>
) : (
) : capabilities?.passwordLoginEnabled ? (
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="reset-password-email">
@ -179,7 +185,7 @@ export function ResetPasswordPage() {
{isSubmitting ? t('resetPassword.submitting') : t('resetPassword.submit')}
</Button>
</form>
)}
) : null}
</CardContent>
</Card>
</div>

View file

@ -1,4 +1,4 @@
import { ReactNode, useRef } from 'react'
import { ReactNode, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
@ -21,6 +21,7 @@ interface ConfirmDialogProps {
onConfirm: () => void | Promise<void>
contentTestId?: string
confirmButtonTestId?: string
closeOnConfirm?: boolean
}
export function ConfirmDialog({
@ -34,34 +35,43 @@ export function ConfirmDialog({
onConfirm,
contentTestId,
confirmButtonTestId,
closeOnConfirm = true,
}: ConfirmDialogProps) {
const { t } = useTranslation()
const openRef = useRef(open)
const pendingRef = useRef(false)
const [pending, setPending] = useState(false)
openRef.current = open
const resolvedConfirmText = confirmText ?? t('dialog.confirm')
const resolvedCancelText = cancelText ?? t('dialog.cancel')
const handleConfirm = async () => {
await onConfirm()
// Skip close if the caller already closed (e.g. publish success + navigate).
if (openRef.current) {
onOpenChange(false)
if (pendingRef.current) return
pendingRef.current = true
setPending(true)
try {
await onConfirm()
// Skip close if the caller already closed (e.g. publish success + navigate).
if (closeOnConfirm && openRef.current) onOpenChange(false)
} finally {
pendingRef.current = false
setPending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid={contentTestId} aria-label={title}>
<Dialog open={open} onOpenChange={(next) => { if (!pendingRef.current) onOpenChange(next) }}>
<DialogContent data-testid={contentTestId} aria-label={title} hideClose={pending}>
<DialogHeader className="min-w-0 text-center sm:text-center">
<DialogTitle className="text-center">{title}</DialogTitle>
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}
</DialogHeader>
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>
{resolvedCancelText}
</Button>
<Button data-testid={confirmButtonTestId} variant={variant} onClick={handleConfirm}>
{resolvedConfirmText}
<Button data-testid={confirmButtonTestId} variant={variant} disabled={pending} onClick={handleConfirm}>
{pending ? t('dialog.processing') : resolvedConfirmText}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -172,6 +172,11 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
{t('user.menu.labels')}
</Link>
) : null}
{isSuperAdmin ? (
<Link to="/admin/system-config" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.systemConfig')}
</Link>
) : null}
{isSuperAdmin ? (
<Link to="/admin/namespaces" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.namespacesAdmin')}

View file

@ -28,8 +28,8 @@ DialogOverlay.displayName = 'DialogOverlay'
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
>(({ className, children, hideClose = false, ...props }, ref) => (
<DialogPortal container={getPortalContainer()}>
<DialogOverlay />
<DialogPrimitive.Content
@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1.5 text-muted-foreground/60 transition-all duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none disabled:pointer-events-none">
{!hideClose ? <DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1.5 text-muted-foreground/60 transition-all duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none disabled:pointer-events-none">
<span className="sr-only">Close</span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -59,7 +59,7 @@ const DialogContent = React.forwardRef<
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</DialogPrimitive.Close>
</DialogPrimitive.Close> : null}
</DialogPrimitive.Content>
</DialogPortal>
))