From df9b869d8ee0beed90a0629e65f5a2d1f658bfd3 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:18:46 +0800 Subject: [PATCH] feat(auth): add system login settings and initial role grants Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- charts/skillhub/README.md | 7 + charts/skillhub/templates/configmap.yaml | 2 + charts/skillhub/templates/secret.yaml | 4 + .../skillhub/templates/server-deployment.yaml | 16 + charts/skillhub/values.schema.json | 12 +- charts/skillhub/values.yaml | 5 + docs/923-auth-system-settings.md | 29 ++ .../design.md | 53 +++ .../proposal.md | 28 ++ .../initial-external-role-grants/spec.md | 76 ++++ .../specs/system-auth-settings/spec.md | 65 ++++ .../configure-auth-and-initial-roles/tasks.md | 25 ++ .../InitialAuthSettingsInitializer.java | 113 ++++++ .../InitialAuthSettingsProperties.java | 19 + .../controller/LocalAuthController.java | 15 +- .../admin/SystemAuthSettingsController.java | 93 +++++ .../dto/ExternalRoleGrantCreateRequest.java | 10 + .../dto/ExternalRoleGrantRuleResponse.java | 16 + .../dto/ExternalRoleGrantUpdateRequest.java | 9 + .../dto/LocalAuthCapabilitiesResponse.java | 7 + .../skillhub/dto/PlatformRoleResponse.java | 3 + .../dto/SystemAuthSettingsResponse.java | 10 + .../dto/SystemAuthSettingsUpdateRequest.java | 9 + .../service/AuthMeResponseAssembler.java | 9 +- .../skillhub/service/AuthMethodCatalog.java | 25 +- .../service/SystemAuthSettingsAppService.java | 190 ++++++++++ .../src/main/resources/application.yml | 4 + .../migration/V68__system_auth_settings.sql | 38 ++ .../src/main/resources/messages.properties | 10 + .../src/main/resources/messages_ru.properties | 10 + .../src/main/resources/messages_zh.properties | 10 + .../InitialAuthSettingsInitializerTest.java | 67 ++++ .../controller/LocalAuthControllerTest.java | 11 + .../SystemAuthSettingsControllerTest.java | 82 ++++ .../service/AuthMethodCatalogTest.java | 19 +- .../SystemAuthSettingsAppServiceTest.java | 67 ++++ .../auth/identity/IdentityBindingService.java | 9 +- .../skillhub/auth/local/LocalAuthService.java | 9 +- .../auth/local/PasswordResetService.java | 10 +- .../auth/settings/ExternalRoleGrantRule.java | 120 ++++++ .../ExternalRoleGrantRuleRepository.java | 24 ++ .../InitialExternalRoleGrantService.java | 48 +++ .../auth/settings/LocalAuthSettings.java | 15 + .../settings/LocalAuthSettingsService.java | 54 +++ .../skillhub/auth/settings/SystemSetting.java | 74 ++++ .../settings/SystemSettingRepository.java | 8 + .../identity/IdentityBindingServiceTest.java | 11 +- .../auth/local/LocalAuthServiceTest.java | 32 +- .../auth/local/PasswordResetServiceTest.java | 20 +- .../InitialExternalRoleGrantServiceTest.java | 65 ++++ .../LocalAuthSettingsServiceTest.java | 74 ++++ web/src/api/client.ts | 53 +++ web/src/api/generated/schema.d.ts | 352 ++++++++++++++++++ web/src/api/types.ts | 31 ++ web/src/app/router.tsx | 13 + .../auth/use-local-auth-capabilities.ts | 10 + web/src/i18n/locales/en.json | 30 ++ web/src/i18n/locales/ru.json | 30 ++ web/src/i18n/locales/zh.json | 30 ++ web/src/pages/admin/system-config.tsx | 146 ++++++++ web/src/pages/login.test.tsx | 24 ++ web/src/pages/login.tsx | 23 +- web/src/pages/register.test.tsx | 17 + web/src/pages/register.tsx | 9 +- web/src/pages/reset-password.test.tsx | 4 + web/src/pages/reset-password.tsx | 12 +- web/src/shared/components/user-menu.tsx | 5 + 67 files changed, 2498 insertions(+), 32 deletions(-) create mode 100644 docs/923-auth-system-settings.md create mode 100644 openspec/changes/configure-auth-and-initial-roles/design.md create mode 100644 openspec/changes/configure-auth-and-initial-roles/proposal.md create mode 100644 openspec/changes/configure-auth-and-initial-roles/specs/initial-external-role-grants/spec.md create mode 100644 openspec/changes/configure-auth-and-initial-roles/specs/system-auth-settings/spec.md create mode 100644 openspec/changes/configure-auth-and-initial-roles/tasks.md create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsInitializer.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsProperties.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/SystemAuthSettingsController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantCreateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantRuleResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantUpdateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalAuthCapabilitiesResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PlatformRoleResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SystemAuthSettingsResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SystemAuthSettingsUpdateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SystemAuthSettingsAppService.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V68__system_auth_settings.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsInitializerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/SystemAuthSettingsControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SystemAuthSettingsAppServiceTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/ExternalRoleGrantRule.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/ExternalRoleGrantRuleRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/InitialExternalRoleGrantService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/LocalAuthSettings.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/LocalAuthSettingsService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSetting.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSettingRepository.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/settings/InitialExternalRoleGrantServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/settings/LocalAuthSettingsServiceTest.java create mode 100644 web/src/features/auth/use-local-auth-capabilities.ts create mode 100644 web/src/pages/admin/system-config.tsx diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index d9ffaa59..3a6cdcaa 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -51,6 +51,13 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ `/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 示例中的数组结构。 diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index e82a6376..3130fb62 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -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/) diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 523bc386..4cabd6d8 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -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 }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index c6b99807..564e2836 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -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: diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index ea43f1b1..f6856dbc 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -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" }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 3ec62e94..e3efdf1e 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -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: "" diff --git a/docs/923-auth-system-settings.md b/docs/923-auth-system-settings.md new file mode 100644 index 00000000..1e989d5e --- /dev/null +++ b/docs/923-auth-system-settings.md @@ -0,0 +1,29 @@ +# 登录开关与外部账号首次授权 + +超级管理员在“系统配置”中分别控制本地密码登录和本地账号自行注册,默认都开启。关闭密码登录后,本地注册也暂时不可用,因为当前注册流程会建立本地会话;注册开关的原值会保留。关闭密码登录还会阻止密码重置、密码修改和直接密码认证。已有会话按原有效期结束,不会立刻踢下线。 + +关闭密码登录前,页面会提示可能失去管理入口,但允许继续。部署方应先确认至少有一位超级管理员能通过外部身份登录。若锁定管理入口,使用受控数据库操作恢复 `system_setting` 中 `auth.local` 的 `passwordLoginEnabled`,并记录操作;仅修改启动参数不会覆盖已有数据库设置。 + +## 空库初始化 + +仅在数据库没有对应记录时,启动参数提供初始值: + +| 环境变量 | 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":"feishu","email":"admin@example.com","role":"SUPER_ADMIN"}] +``` + +规则仅在第一次初始化且 `user_account` 为空时写入;初始化标记防止以后删除规则再重启时重复创建。正式环境请通过 Secret 提供 JSON,不要将实际邮箱写进公开的 values 文件。初始化以后,数据库和“系统配置”页面是权威来源。 + +## 授权边界 + +规则按身份来源代码和**已验证邮箱**匹配。只有外部身份通过现有准入策略、并且新账号首次创建为可用状态时,才给新账号授予选定平台角色;规则随后标为“已授权”,不会再次使用。拒绝或待审批的登录不会触发授权,规则不会绕过准入策略。已有账号请在“用户管理”中直接授权。相同邮箱的本地账号不会因此合并或获得角色。 + +后台可以新增规则、调整待匹配规则的角色、停用规则,并查看已使用规则的匹配身份和授权账号。只允许超级管理员操作;修改带版本号,避免两个管理员同时改动时后写覆盖前写。紧急恢复可通过受控数据库操作处理,并保留审计记录。 diff --git a/openspec/changes/configure-auth-and-initial-roles/design.md b/openspec/changes/configure-auth-and-initial-roles/design.md new file mode 100644 index 00000000..48769ca2 --- /dev/null +++ b/openspec/changes/configure-auth-and-initial-roles/design.md @@ -0,0 +1,53 @@ +## 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` 的角色。 + +现有公开 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**:拒绝。逐人规则需要独立生命周期、唯一性、并发消费和审计。 +- **关闭本地登录时强制阻止可能锁定的配置**:拒绝。外部提供方是否实际可用无法可靠静态判断;页面明确提示后允许部署方决定。 diff --git a/openspec/changes/configure-auth-and-initial-roles/proposal.md b/openspec/changes/configure-auth-and-initial-roles/proposal.md new file mode 100644 index 00000000..93c92049 --- /dev/null +++ b/openspec/changes/configure-auth-and-initial-roles/proposal.md @@ -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 + +本变更记录已确认的产品边界和待实现验收条件;尚未实施。 diff --git a/openspec/changes/configure-auth-and-initial-roles/specs/initial-external-role-grants/spec.md b/openspec/changes/configure-auth-and-initial-roles/specs/initial-external-role-grants/spec.md new file mode 100644 index 00000000..1c8e7d9c --- /dev/null +++ b/openspec/changes/configure-auth-and-initial-roles/specs/initial-external-role-grants/spec.md @@ -0,0 +1,76 @@ +## 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** 登录不消费该规则,也不自动改变已有角色 +- **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** 部署方需调整普通准入或规则配置 diff --git a/openspec/changes/configure-auth-and-initial-roles/specs/system-auth-settings/spec.md b/openspec/changes/configure-auth-and-initial-roles/specs/system-auth-settings/spec.md new file mode 100644 index 00000000..d99b0300 --- /dev/null +++ b/openspec/changes/configure-auth-and-initial-roles/specs/system-auth-settings/spec.md @@ -0,0 +1,65 @@ +## 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 不被后续部署参数或重启覆盖。外部服务秘密不得存入设置表。 + +#### Scenario: 后台关闭登录后重启 +- **WHEN** 后台已持久化 `passwordLoginEnabled=false`,部署参数仍写着开启 +- **THEN** 重启后数据库设置仍为关闭 + +#### Scenario: 新版本增加设置键 +- **WHEN** 升级版本新增注册过的设置键 +- **THEN** 系统只为新键补入默认值,不修改既有键 + +### Requirement: REQ-SAS-04 关闭本地登录 SHALL 展示风险但不阻止保存 + +后台页面 SHALL 告知超级管理员:外部登录不可用时可能失去管理入口。服务端 SHALL 接受其关闭本地登录的合法请求,而不根据外部账号是否已绑定进行硬拦截。 + +#### Scenario: 没有已绑定的外部超管 +- **WHEN** 超级管理员确认关闭本地密码登录 +- **THEN** 页面展示管理入口风险,保存仍可完成 +- **AND** 不误称已经验证外部身份提供方可用 diff --git a/openspec/changes/configure-auth-and-initial-roles/tasks.md b/openspec/changes/configure-auth-and-initial-roles/tasks.md new file mode 100644 index 00000000..c0a91f5d --- /dev/null +++ b/openspec/changes/configure-auth-and-initial-roles/tasks.md @@ -0,0 +1,25 @@ +## 1. 持久化与初始化 + +- [x] 增加 `system_setting` 和外部首次角色规则表、约束与审计目标。 +- [x] 实现部署参数一次性初始化与持久化标记,验证重启和清空规则后不会重灌(单测)。 +- [x] 实现设置键注册、类型校验、版本条件更新和数据库读取失败处理。 + +## 2. 认证与授权 + +- [x] 在本地登录、direct 本地认证、注册和密码管理服务端入口执行对应开关;关闭密码登录时一并阻止会直接建会话的本地注册。 +- [x] 在 OAuth 普通准入允许后的首次 ACTIVE 账号创建事务中匹配并消费角色规则,首次主体包含新角色。 +- [ ] 在统一身份核心的 `LEGACY`、`SHADOW`、`ACTIVE` 模式下核对公开 OAuth 接入点;保持旧身份绑定写入权威与同邮箱不自动合并的现有行为。 +- [ ] 保持已有账号、准入拒绝、未验证邮箱、停用规则、并发首次登录和人工角色修改的既定行为。 + +## 3. API 与 Web + +- [x] 增加超级管理员可读写的系统设置与规则 API,增加登录页所需的公开认证能力投影。 +- [x] 建立统一“系统配置”页面,显示两开关、角色规则、消费结果及关闭本地登录的风险提示。 +- [x] 调整登录、注册、重置密码和个人设置入口;直接访问已关闭页面时显示明确结果。 +- [x] 更新 OpenAPI、部署说明和用户操作说明。 + +## 4. 验收 + +- [ ] 验证两个开关的四种组合、直接 API 绕过尝试、已有会话、DB 故障与多实例设置可见性。 +- [ ] 验证首次登录授权、准入优先、已有账号不补授权、同邮箱独立账号、邮箱验证、规则停用、角色人工修改和并发首次登录。 +- [ ] 验证一次性部署初始化、角色规则删除后重启、非超管越权、审计记录和浏览器页面流程。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsInitializer.java new file mode 100644 index 00000000..771449cd --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsInitializer.java @@ -0,0 +1,113 @@ +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) connection -> { + if ("PostgreSQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName())) { + try (Statement statement = connection.createStatement()) { + statement.execute("SELECT pg_advisory_xact_lock(92320261010)"); + } + } + return null; + }); + if (settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY).isEmpty()) { + settings.save(new SystemSetting(LocalAuthSettingsService.SETTING_KEY, Map.of( + LocalAuthSettingsService.PASSWORD_LOGIN_KEY, properties.isPasswordLoginEnabled(), + LocalAuthSettingsService.SELF_REGISTRATION_KEY, properties.isSelfRegistrationEnabled()))); + } + if (settings.findBySettingKey(GRANT_SEED_MARKER).isPresent()) { + return; + } + if (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 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 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 (!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) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsProperties.java new file mode 100644 index 00000000..9996e90c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsProperties.java @@ -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; } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 17e54fbe..b4c1504c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -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 capabilities() { + var current = authSettings.current(); + return ok("response.success.read", new LocalAuthCapabilitiesResponse( + current.passwordLoginEnabled(), current.selfRegistrationEnabled(), current.registrationAvailable())); } @PostMapping("/register") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/SystemAuthSettingsController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/SystemAuthSettingsController.java new file mode 100644 index 00000000..fe4da6fb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/SystemAuthSettingsController.java @@ -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 getLocalSettings() { + return ok("response.success.read", service.getLocalSettings()); + } + + @PutMapping("/auth/local") + public ApiResponse 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> listRoles() { + return ok("response.success.read", service.listRoles()); + } + + @GetMapping("/role-grants") + public ApiResponse> listRoleGrants() { + return ok("response.success.read", service.listRules()); + } + + @PostMapping("/role-grants") + public ApiResponse 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 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 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))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantCreateRequest.java new file mode 100644 index 00000000..d997a9d8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantCreateRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantRuleResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantRuleResponse.java new file mode 100644 index 00000000..5d7055ca --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantRuleResponse.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantUpdateRequest.java new file mode 100644 index 00000000..13b77f54 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ExternalRoleGrantUpdateRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalAuthCapabilitiesResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalAuthCapabilitiesResponse.java new file mode 100644 index 00000000..b1b32d14 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalAuthCapabilitiesResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto; + +public record LocalAuthCapabilitiesResponse( + boolean passwordLoginEnabled, + boolean selfRegistrationEnabled, + boolean registrationAvailable +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PlatformRoleResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PlatformRoleResponse.java new file mode 100644 index 00000000..eca3a36d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PlatformRoleResponse.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record PlatformRoleResponse(String code, String name) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SystemAuthSettingsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SystemAuthSettingsResponse.java new file mode 100644 index 00000000..ea7675f1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SystemAuthSettingsResponse.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record SystemAuthSettingsResponse( + boolean passwordLoginEnabled, + boolean selfRegistrationEnabled, + long version, + Instant updatedAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SystemAuthSettingsUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SystemAuthSettingsUpdateRequest.java new file mode 100644 index 00000000..30f7d74f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SystemAuthSettingsUpdateRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java index 60b0066b..e2188361 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java @@ -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()) ); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 8c63cb9d..b9cf44eb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -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 directAuthProviders; private final List passiveSessionAuthenticators; + private final LocalAuthSettingsService authSettings; public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties, DirectAuthProperties directAuthProperties, AuthSessionBootstrapProperties sessionBootstrapProperties, List directAuthProviders, - List passiveSessionAuthenticators) { + List passiveSessionAuthenticators, + LocalAuthSettingsService authSettings) { this.oAuth2ClientProperties = oAuth2ClientProperties; this.directAuthProperties = directAuthProperties; this.sessionBootstrapProperties = sessionBootstrapProperties; this.directAuthProviders = directAuthProviders; this.passiveSessionAuthenticators = passiveSessionAuthenticators; + this.authSettings = authSettings; } public List listOAuthProviders(String returnTo) { @@ -70,14 +74,17 @@ public class AuthMethodCatalog { public List listMethods(String returnTo) { String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); List 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(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SystemAuthSettingsAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SystemAuthSettingsAppService.java new file mode 100644 index 00000000..ee7cc779 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SystemAuthSettingsAppService.java @@ -0,0 +1,190 @@ +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 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 listRoles() { + return roles.findAll().stream().filter(Role::isSystem) + .sorted(Comparator.comparing(Role::getCode)) + .map(role -> new PlatformRoleResponse(role.getCode(), role.getName())) + .toList(); + } + + public List 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"); + } + 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); + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 6dfbbd42..73660e11 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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: diff --git a/server/skillhub-app/src/main/resources/db/migration/V68__system_auth_settings.sql b/server/skillhub-app/src/main/resources/db/migration/V68__system_auth_settings.sql new file mode 100644 index 00000000..f398e4c3 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V68__system_auth_settings.sql @@ -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); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 337efb13..734fd943 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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,4 @@ 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 diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 6baf1a55..45a953c3 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -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,4 @@ 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=Настройки аутентификации временно недоступны diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 3a6f22a8..838e9943 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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,4 @@ promotion.revocation.not_found=撤销申请 {0} 不存在 promotion.revocation.not_pending=撤销申请 {0} 已不在待审核状态 promotion.revocation.required=已提升的技能须通过审核撤销流程删除 promotion.revocation.page_invalid=页码不能为负数,每页数量须在 1 到 100 之间 +error.auth.settings.unavailable=认证配置暂时不可用 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsInitializerTest.java new file mode 100644 index 00000000..14bf7f0f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/InitialAuthSettingsInitializerTest.java @@ -0,0 +1,67 @@ +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 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 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)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index 50082983..d0fca216 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -13,6 +13,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; @@ -58,6 +60,15 @@ 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)); + } + + @Test void login_returnsCurrentUserEnvelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/SystemAuthSettingsControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/SystemAuthSettingsControllerTest.java new file mode 100644 index 00000000..51ae01bb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/SystemAuthSettingsControllerTest.java @@ -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))); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java index e9ef372f..ba466267 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -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; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SystemAuthSettingsAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SystemAuthSettingsAppServiceTest.java new file mode 100644 index 00000000..e1f39f52 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SystemAuthSettingsAppServiceTest.java @@ -0,0 +1,67 @@ +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.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; + +@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 duplicateActiveGrantIsRejectedBeforeWrite() { + when(rules.existsByProviderCodeAndNormalizedEmailAndStatus( + "feishu", "admin@example.com", ExternalRoleGrantRule.Status.ACTIVE)).thenReturn(true); + + assertThatThrownBy(() -> service.createRule( + new ExternalRoleGrantCreateRequest(" FEISHU ", "Admin@Example.Com", "SUPER_ADMIN"), + "admin", new AuditRequestContext(null, null))) + .isInstanceOf(DomainConflictException.class); + verify(rules, never()).saveAndFlush(any()); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index a195c9e7..2c7308cd 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -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())); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 5d0dad22..d2354de2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -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")); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java index 74653ead..c8578959 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java @@ -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 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) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/ExternalRoleGrantRule.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/ExternalRoleGrantRule.java new file mode 100644 index 00000000..3edc403f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/ExternalRoleGrantRule.java @@ -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(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/ExternalRoleGrantRuleRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/ExternalRoleGrantRuleRepository.java new file mode 100644 index 00000000..85aac48b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/ExternalRoleGrantRuleRepository.java @@ -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 { + List 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 lockByIdentityAndStatus( + @Param("provider") String provider, + @Param("email") String email, + @Param("status") ExternalRoleGrantRule.Status status); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/InitialExternalRoleGrantService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/InitialExternalRoleGrantService.java new file mode 100644 index 00000000..4d8cf360 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/InitialExternalRoleGrantService.java @@ -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); + } + +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/LocalAuthSettings.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/LocalAuthSettings.java new file mode 100644 index 00000000..7730b2c7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/LocalAuthSettings.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/LocalAuthSettingsService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/LocalAuthSettingsService.java new file mode 100644 index 00000000..b5d44a19 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/LocalAuthSettingsService.java @@ -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 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"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSetting.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSetting.java new file mode 100644 index 00000000..c350e519 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSetting.java @@ -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 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 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 getValue() { return value; } + public long getVersion() { return version; } + public Instant getUpdatedAt() { return updatedAt; } + public String getUpdatedBy() { return updatedBy; } + + public void update(Map newValue, String actorUserId) { + this.value = Map.copyOf(newValue); + this.updatedBy = actorUserId; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSettingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSettingRepository.java new file mode 100644 index 00000000..9b4979f1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSettingRepository.java @@ -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 { + Optional findBySettingKey(String settingKey); +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java index 2f5e3b0e..1450e5d1 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java @@ -1,5 +1,7 @@ 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; @@ -56,12 +58,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 +90,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 +131,7 @@ class IdentityBindingServiceTest { service.bindOrCreate(claims, UserStatus.ACTIVE); verify(eventPublisher, never()).publishEvent(any(UserActivatedEvent.class)); + verify(initialRoleGrants, never()).grantForNewUser(any(), any()); } @Test @@ -143,6 +151,7 @@ class IdentityBindingServiceTest { .isInstanceOf(AccountPendingException.class); verify(globalNamespaceMembershipService, never()).ensureMember(any()); + verify(initialRoleGrants, never()).grantForNewUser(any(), any()); } @Test diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index f11b9116..099c2b89 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -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,33 @@ 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 register_createsUserAndCredential() { given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java index 251b7d54..5c3515ae 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java @@ -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,22 @@ 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 requestPasswordReset_withEligibleEmail_savesRequestAndSendsEmail() { UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/settings/InitialExternalRoleGrantServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/settings/InitialExternalRoleGrantServiceTest.java new file mode 100644 index 00000000..c2232671 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/settings/InitialExternalRoleGrantServiceTest.java @@ -0,0 +1,65 @@ +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("feishu", "admin@example.com", role, null); + when(rules.lockByIdentityAndStatus("feishu", "admin@example.com", ExternalRoleGrantRule.Status.ACTIVE)) + .thenReturn(Optional.of(rule)); + when(role.getCode()).thenReturn("SUPER_ADMIN"); + + service.grantForNewUser(new OAuthClaims("FEISHU", "external-42", " Admin@Example.COM ", true, + "admin", Map.of()), "usr_new"); + + ArgumentCaptor 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); + } + + @Test + void unverifiedEmailCannotConsumeRule() { + service.grantForNewUser(new OAuthClaims("feishu", "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()); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/settings/LocalAuthSettingsServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/settings/LocalAuthSettingsServiceTest.java new file mode 100644 index 00000000..c8b6f55c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/settings/LocalAuthSettingsServiceTest.java @@ -0,0 +1,74 @@ +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.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +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.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class LocalAuthSettingsServiceTest { + @Mock private SystemSettingRepository repository; + + @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); + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 01fad2c5..09f2fd9d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { export const authApi = { getMe: getCurrentUser, + getLocalCapabilities(): Promise { + return fetchJson('/api/v1/auth/local/capabilities') + }, + async getProviders(returnTo?: string): Promise { const params = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '' const providers = await fetchJson(`/api/v1/auth/providers${params}`) @@ -450,6 +458,51 @@ export const authApi = { }, } +export const systemConfigApi = { + getLocalAuth(): Promise { + return fetchJson('/api/v1/admin/system-config/auth/local') + }, + + updateLocalAuth(request: Pick): Promise { + return fetchJson('/api/v1/admin/system-config/auth/local', { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(request), + }) + }, + + listRoles(): Promise { + return fetchJson('/api/v1/admin/system-config/roles') + }, + + listRoleGrants(): Promise { + return fetchJson('/api/v1/admin/system-config/role-grants') + }, + + createRoleGrant(request: { providerCode: string; email: string; roleCode: string }): Promise { + return fetchJson('/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 { + return fetchJson(`/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 { + return fetchJson(`/api/v1/admin/system-config/role-grants/${id}?version=${version}`, { + method: 'DELETE', + headers: getCsrfHeaders(), + }) + }, +} + export const accountApi = { async initiateMerge(request: MergeInitiateRequest): Promise { return fetchJson('/api/v1/account/merge/initiate', { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a972eafc..8884f9ca 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -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?: { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b6360ebf..1c999825 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 & { id: number name: string diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 489efd73..54573332 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -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, ]) diff --git a/web/src/features/auth/use-local-auth-capabilities.ts b/web/src/features/auth/use-local-auth-capabilities.ts new file mode 100644 index 00000000..25cdd396 --- /dev/null +++ b/web/src/features/auth/use-local-auth-capabilities.ts @@ -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: 30_000, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 05e2afed..8d269a6f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1674,6 +1674,35 @@ "pageSuffix": "", "goToPage": "Go to page {{page}}" }, + "systemConfig": { + "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 will not be able to sign in again. Continue?", + "initialRoleRules": "Initial external account role rules", + "ruleHelp": "Applies only when a new external account is created after passing the existing access policy. A local account with the same email is not merged. Grant existing users a role in User Management.", + "provider": "Identity provider code", + "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 +1718,7 @@ "reports": "Report Management", "users": "User Management", "labels": "Label Management", + "systemConfig": "System Settings", "auditLog": "Audit Log", "security": "Security Settings", "profile": "Profile Settings", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 1f6a71ff..fdf7affc 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1993,6 +1993,35 @@ "formatHint": "Поддерживается только формат .zip", "folderHint": "Или выберите папку для упаковки и загрузки" }, + "systemConfig": { + "title": "Системные настройки", + "localAuth": "Локальные учётные записи", + "lockoutHelp": "Перед отключением входа по паролю проверьте внешний вход и доступ суперадминистратора.", + "grantedTo": "Назначено учётной записи {{userId}} (внешний идентификатор {{subject}})", + "passwordLogin": "Разрешить вход по локальному паролю", + "selfRegistration": "Разрешить самостоятельную регистрацию", + "registrationRequiresLogin": "Вход по паролю отключён, поэтому регистрация сейчас недоступна. Настройка регистрации сохранена.", + "lockoutWarning": "Администраторы, использующие только пароль, не смогут войти снова. Продолжить?", + "initialRoleRules": "Правила первоначального назначения ролей", + "ruleHelp": "Правило действует только при создании новой внешней учётной записи после проверки доступа. Локальная запись с тем же адресом не объединяется. Существующим пользователям назначайте роль в управлении пользователями.", + "provider": "Код поставщика удостоверений", + "email": "Подтверждённая почта", + "role": "Роль платформы", + "addRule": "Добавить правило", + "disable": "Отключить", + "disableConfirm": "Отключить это правило назначения роли?", + "saved": "Сохранено", + "saveFailed": "Ошибка сохранения", + "loadFailed": "Не удалось загрузить настройки. Обновите страницу.", + "capabilitiesUnavailable": "Не удалось загрузить настройки входа. Обновите страницу.", + "registrationDisabled": "Локальная регистрация отключена. Используйте другой способ входа.", + "passwordDisabled": "Вход по локальному паролю отключён. Используйте другой способ входа.", + "status": { + "ACTIVE": "Ожидание", + "DISABLED": "Отключено", + "CONSUMED": "Назначено" + } + }, "user": { "menu": { "dashboard": "Панель", @@ -2009,6 +2038,7 @@ "reports": "Управление жалобами", "users": "Пользователи", "labels": "Метки", + "systemConfig": "Системные настройки", "auditLog": "Журнал аудита", "security": "Безопасность", "profile": "Профиль", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 7a9869dd..e931cc8e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1673,6 +1673,35 @@ "pageSuffix": "页", "goToPage": "第 {{page}} 页" }, + "systemConfig": { + "title": "系统配置", + "localAuth": "本地账号", + "lockoutHelp": "关闭密码登录前,请先确认外部身份登录和超级管理员权限可用。", + "grantedTo": "已授予账号 {{userId}}(外部身份 {{subject}})", + "passwordLogin": "允许本地密码登录", + "selfRegistration": "允许自行注册本地账号", + "registrationRequiresLogin": "密码登录已关闭,当前无法自行注册;注册设置会保留。", + "lockoutWarning": "关闭密码登录后,原本仅靠密码登录的管理员将无法重新登录。确定继续吗?", + "initialRoleRules": "外部账号首次授权规则", + "ruleHelp": "仅当新外部账号首次创建且通过现有准入策略时生效。相同邮箱的本地账号不会合并;已有账号请在用户管理中授权。", + "provider": "身份来源代码", + "email": "已验证邮箱", + "role": "平台角色", + "addRule": "添加规则", + "disable": "停用", + "disableConfirm": "确定停用这条首次授权规则吗?", + "saved": "已保存", + "saveFailed": "保存失败", + "loadFailed": "配置加载失败,请刷新重试。", + "capabilitiesUnavailable": "暂时无法获取登录配置,请刷新重试。", + "registrationDisabled": "本地账号注册已关闭。请使用其他可用登录方式。", + "passwordDisabled": "本地密码登录已关闭。请使用其他可用登录方式。", + "status": { + "ACTIVE": "待匹配", + "DISABLED": "已停用", + "CONSUMED": "已授权" + } + }, "user": { "menu": { "dashboard": "控制台", @@ -1688,6 +1717,7 @@ "reports": "举报管理", "users": "用户管理", "labels": "标签管理", + "systemConfig": "系统配置", "auditLog": "审计日志", "security": "安全设置", "profile": "个人设置", diff --git a/web/src/pages/admin/system-config.tsx b/web/src/pages/admin/system-config.tsx new file mode 100644 index 00000000..8fafe4ad --- /dev/null +++ b/web/src/pages/admin/system-config.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { 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' + +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 refresh = async () => { + await queryClient.invalidateQueries({ queryKey: ['system-config'] }) + } + const updateSettings = useMutation({ mutationFn: systemConfigApi.updateLocalAuth, onSuccess: 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) { + setError('') + setMessage('') + try { + await action() + setMessage(t('systemConfig.saved')) + } catch (cause) { + setError(cause instanceof Error ? cause.message : t('systemConfig.saveFailed')) + } + } + + function changeSetting(field: 'passwordLoginEnabled' | 'selfRegistrationEnabled', checked: boolean) { + if (!settings.data) return + if (field === 'passwordLoginEnabled' && !checked && !window.confirm(t('systemConfig.lockoutWarning'))) return + void run(() => updateSettings.mutateAsync({ + passwordLoginEnabled: field === 'passwordLoginEnabled' ? checked : settings.data!.passwordLoginEnabled, + selfRegistrationEnabled: field === 'selfRegistrationEnabled' ? checked : settings.data!.selfRegistrationEnabled, + version: settings.data!.version, + })) + } + + function changeRole(rule: ExternalRoleGrantRule, code: string) { + if (code === rule.roleCode) return + void run(() => updateRule.mutateAsync({ id: rule.id, version: rule.version, code })) + } + + return ( +
+

{t('systemConfig.title')}

+ {error ?

{error}

: null} + {message ?

{message}

: null} + + {t('systemConfig.localAuth')} + +

{t('systemConfig.lockoutHelp')}

+ {settings.isError ?

{t('systemConfig.loadFailed')}

: null} + {settings.data ? ( + <> + + + {!settings.data.passwordLoginEnabled && settings.data.selfRegistrationEnabled ? ( +

{t('systemConfig.registrationRequiresLogin')}

+ ) : null} + + ) : null} +
+
+ + {t('systemConfig.initialRoleRules')} + +

{t('systemConfig.ruleHelp')}

+
{ + event.preventDefault() + void run(async () => { + await createRule.mutateAsync({ providerCode, email, roleCode }) + setProviderCode('') + setEmail('') + }) + }}> + setProviderCode(event.target.value)} required /> + setEmail(event.target.value)} required /> + + +
+ {rules.isError || roles.isError ?

{t('systemConfig.loadFailed')}

: null} +
+ {(rules.data ?? []).map((rule) => ( +
+ {rule.providerCode} · {rule.email} + {t(`systemConfig.status.${rule.status}`)} + {rule.status === 'CONSUMED' ? ( + + {t('systemConfig.grantedTo', { userId: rule.grantedUserId, subject: rule.matchedSubject })} + + ) : null} + {rule.status === 'ACTIVE' ? ( + <> + + + + ) : {rule.roleCode}} +
+ ))} +
+
+
+
+ ) +} diff --git a/web/src/pages/login.test.tsx b/web/src/pages/login.test.tsx index 2c35c14a..40a7eabb 100644 --- a/web/src/pages/login.test.tsx +++ b/web/src/pages/login.test.tsx @@ -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() + 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() diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 31ece77d..369e8101 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -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() { ) : null} - {hasOrganizationMethod ? ( + {hasOrganizationMethod && passwordEnabled ? (
) : null} -