feat(auth): add explicit identity link and safe unlink flow

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 07:02:17 +08:00
parent 6e444cb19f
commit 9557478dd8
92 changed files with 10162 additions and 122 deletions

View file

@ -65,13 +65,33 @@ jobs:
- name: Verify Binding V2 migration and concurrency on PostgreSQL
run: bash scripts/tests/identity-binding-v2-postgres-test.sh
- name: Start loopback identity provider for Identity Link
run: |
python3 scripts/tests/oauth2-mock-provider.py --port 18081 \
> /tmp/skillhub-oauth2-mock.log 2>&1 &
echo "$!" > /tmp/skillhub-oauth2-mock.pid
for attempt in $(seq 1 30); do
if curl --fail --silent \
http://127.0.0.1:18081/health >/dev/null; then
exit 0
fi
sleep 1
done
exit 1
- name: Start full dev stack
env:
OAUTH2_GITLAB_BASE_URI: http://127.0.0.1:18081
OAUTH2_GITLAB_CLIENT_ID: skillhub-e2e
OAUTH2_GITLAB_CLIENT_SECRET: skillhub-e2e-secret
run: make dev-all
- name: Install Playwright browsers
run: cd web && pnpm exec playwright install --with-deps chromium
- name: Run frontend E2E tests
env:
E2E_IDENTITY_LINK_BROWSER_PROVIDER: gitlab
run: make test-e2e-frontend
- name: Upload Playwright HTML report
@ -95,3 +115,10 @@ jobs:
- name: Stop full dev stack
if: ${{ always() }}
run: make dev-all-down
- name: Stop loopback identity provider
if: ${{ always() }}
run: |
if [[ -f /tmp/skillhub-oauth2-mock.pid ]]; then
kill "$(cat /tmp/skillhub-oauth2-mock.pid)" 2>/dev/null || true
fi

View file

@ -1379,15 +1379,24 @@ PRIMARY KEY(user_id, field_name)
```text
id uuid PK
primary_user_id varchar(128) NOT NULL
operation LINK / UNLINK
provider_code varchar(64) NOT NULL
state_hash varchar(128) NOT NULL
status PENDING / COMPLETED / EXPIRED / CANCELLED
target_binding_id bigint NULL
state_hash char(64) NOT NULL
status PENDING_REAUTHENTICATION / READY /
COMPLETED / EXPIRED / CANCELLED
reauthentication_method varchar(96)
reauthenticated_at timestamptz
expires_at timestamptz NOT NULL
created_at timestamptz NOT NULL
updated_at timestamptz NOT NULL
completed_at timestamptz
cancelled_at timestamptz
```
只保存 hash 和流程元数据,不保存 OAuth code、CAS Ticket 或密码。
只保存 hash 和流程元数据,不保存 OAuth code、CAS Ticket 或密码。`provider_code`
保留创建 intent 时的历史值,不对 `identity_provider_state` 建外键:配置已删除或
Authority 不可用的历史 Binding 仍必须能在账号还有其他登录方式时安全解绑。
### 10.6 SCIM 资源绑定
@ -1576,14 +1585,21 @@ PROVIDER_AUTHORITATIVE
9. 写审计并消费 link request。
```
未来 Interface
当前 Interface
```java
public interface ExternalIdentityLinkService {
IdentityLinkOutcome reauthenticate(
IdentityLinkActor actor,
UUID intentId,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result
);
IdentityLinkOutcome link(
AuthenticatedActor actor,
IdentityLinkIntent intent,
IdentityLinkActor actor,
UUID intentId,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result
);
@ -1593,6 +1609,108 @@ public interface ExternalIdentityLinkService {
Link Facade 内部复用同一个 descriptor source、Authority Lock 和 package-private
Assertion FactoryProvider 仍不能构造 `IdentityAssertion`
#### 12.2.1 Identity Link 实现契约
[#655](https://github.com/iflytek/skillhub/issues/655) 实现上述 Link/Unlink 核心。
HTTP、Session 和 Provider 协议 I/O 位于事务外;身份核心拥有 intent 状态检查、
Provider capability 检查、Binding/Subject 唯一性、账号资格和审计规则。
服务端状态机:
| 状态 | 允许操作 | 下一状态 |
|---|---|---|
| `PENDING_REAUTHENTICATION` | 当前账号 fresh reauthentication | `READY` |
| `READY` + `LINK` | 独立认证目标 Provider 并创建 Binding V2 | `COMPLETED` |
| `READY` + `UNLINK` | 检查仍有其他可用登录方式并撤销 Binding/Subjects | `COMPLETED` |
| active intent | 取消 | `CANCELLED` |
| active intent 超过 10 分钟 TTL | 任意读取或消费 | `EXPIRED` |
| `COMPLETED/CANCELLED/EXPIRED` | 重放 | 拒绝,不再改变状态 |
每个 intent 同时绑定:
- 当前 `userId`
- 当前 Platform Session 中 256-bit 随机 nonce数据库只保存 SHA-256
- 操作类型、目标 Provider、可选目标 Binding
- 固定过期时间和一次性状态。
当前账号证明与目标 Provider 证明必须分开。Browser Provider 使用现有 OAuth state
校验并保留主 Platform SessionCredential Provider 只在 Adapter 中校验凭据,
只把 `ProviderAuthenticationResult` 交给核心。密码、OAuth code/token、ticket、
Cookie、原始 Session ID/nonce 和 proof 不进入 DTO、数据库、审计或日志。
公开 API
| 方法 | 路径 | 作用 |
|---|---|---|
| `GET` | `/api/v1/auth/identity-links` | 列出当前账号已绑定和可添加的登录方式;不返回外部 Subject |
| `POST` | `/api/v1/auth/identity-link-intents/link` | 创建 Link intent |
| `POST` | `/api/v1/auth/identity-link-intents/unlink` | 创建 Unlink intent |
| `GET` / `DELETE` | `/api/v1/auth/identity-link-intents/{intentId}` | 查看或取消 intent |
| `POST` | `.../{intentId}/reauthenticate/local` | 用本地密码重新认证当前账号 |
| `POST` | `.../{intentId}/reauthenticate/browser` | 发起 Browser Provider 当前账号证明 |
| `POST` | `.../{intentId}/reauthenticate/credential` | 用 Credential Provider 证明当前账号 |
| `POST` | `.../{intentId}/link/browser` | 发起目标 Browser Provider 认证 |
| `POST` | `.../{intentId}/link/credential` | 认证并绑定目标 Credential Provider |
| `POST` | `.../{intentId}/unlink` | fresh reauthentication 后完成解绑 |
REST 失败响应除 HTTP status 和本地化 `msg` 外,必须返回稳定 `reasonCode`。Browser
回调失败通过 `/settings/security?identityLink=failed&intentId=...&reasonCode=...`
返回同一 allowlist 中的 code。当前 allowlist
```text
INTENT_NOT_FOUND
REAUTHENTICATION_REQUIRED
SESSION_MISMATCH
INTENT_EXPIRED
ALREADY_CONSUMED
ACTIVE_INTENT_EXISTS
ACCOUNT_NOT_ELIGIBLE
PROVIDER_UNAVAILABLE
PROVIDER_AUTHENTICATION_FAILED
ALREADY_LINKED
IDENTITY_IN_USE
FINAL_LOGIN_METHOD
INVALID_OPERATION
```
前端账号安全页只使用生成的 OpenAPI `paths/components``openapi-fetch` 调用这些
端点,并由 TanStack Query 管理服务端状态。Callback 只接受固定格式的 reason code
显示本地化安全提示;不得显示外部 Subject 或原始 Provider 错误。
#### 12.2.2 模块与持久化边界
`IdentityLinkAppService` 只编排 Session context、Provider 协议调用和响应映射。
intent 状态、operation、Provider capability、账号资格、Binding/Subject 唯一性和
最后登录方式保护位于 `skillhub-auth` identity core。
`identity_link_request`、Identity Binding V2 和 Subject 是认证 bounded context 的
安全状态,因此 Entity 和 Spring Data Repository 保留在 `skillhub-auth`。这是相对
通用 domain-port/infra-implementation 规则的显式例外:它们不属于 SkillHub 业务
Domain Aggregate且必须与认证事务、pessimistic lock 和 Provider Authority Lock
共同演进。App 和 Provider Adapter 不得直接写这些表。
#### 12.2.3 升级、滚动发布和回滚
V49 是向后兼容的 expand migration增加 `identity_link_request`,并把旧的全量唯一
约束替换为 ACTIVE-only 部分唯一索引;它不重写已有 Binding。升级测试必须从 V48
数据库执行 V49并验证旧 ACTIVE Binding 仍能登录。
解绑后允许同一 Subject 重新绑定,同时保留 REVOKED Binding/Subject 历史。因此一旦
生产数据发生“解绑后重绑”,会存在同一 `provider + legacy subject` 的一条 ACTIVE 和
一条或多条 REVOKED 记录。V49-aware 代码只读取 ACTIVE 记录V49 之前使用无状态单行
查询的运行时可能得到非唯一结果。
发布约束:
1. 多实例部署必须先让所有认证实例升级到 V49-aware 版本,再向用户开放 Link/Unlink
入口;不能在旧、新认证实例混跑时允许完成解绑或重绑。
2. 部署期间若不能保证上述顺序,应暂时在网关隐藏新增端点和账号安全入口,完成升级后
再开放。
3. V49 migration 本身可与旧数据共存;首次 Link/Unlink 写入之后,安全回滚下限提升为
V49-aware 版本。不得回滚到使用无状态 legacy Binding 单行查询的版本。
4. 回滚只允许代码回滚到 V49-aware 版本,不回滚或删除 V49 表和 REVOKED 历史。
5. 发布前必须在 PostgreSQL 16 上验证 V48 → V49、并发消费、解绑后重绑和旧账号登录。
### 12.3 解绑
解绑前必须:
@ -2366,7 +2484,7 @@ PR 4Provider Registry + Adapter 契约冻结
- disabled/misconfigured Provider 不出现在目录且不联网。
- 旧前端登录目录 API 保持兼容。
#### PR 5显式 Identity Link
#### PR 5显式 Identity Link#655
范围:
@ -2380,6 +2498,10 @@ PR 4Provider Registry + Adapter 契约冻结
- 无法只凭 email 或 username 创建 Binding。
- Link 重放、过期、并发消费和最后登录方式测试通过。
- JSON API 和 Browser callback 都返回 allowlist 内的稳定 reason code。
- PostgreSQL 16 验证同一 intent 并发消费只能成功一次。
- 以准确 `big-main` SHA 构建镜像,在隔离测试环境验证升级、回归和真实浏览器链路后,
才能进入 `main`
#### PR 6安全 Account Merge

View file

@ -130,5 +130,8 @@ run_test() {
run_test IdentityBindingV2MigrationPostgresTest
run_test IdentityBindingV2ContractPostgresTest 47
run_test UserProfileFieldSourceMigrationPostgresTest
run_test IdentityBindingV2PostgresIntegrationTest 48
run_test IdentityProfileProvisioningPostgresIntegrationTest 48
run_test IdentityBindingV2PostgresIntegrationTest
run_test IdentityProfileProvisioningPostgresIntegrationTest
run_test IdentityLinkMigrationPostgresTest
run_test IdentityLinkMigrationLoginPostgresIntegrationTest
run_test IdentityLinkPostgresIntegrationTest

View file

@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Minimal loopback-only GitLab OAuth provider for browser E2E tests."""
from __future__ import annotations
import argparse
import json
import secrets
import threading
import time
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
class OAuthState:
def __init__(self) -> None:
self.lock = threading.Lock()
self.code_subjects: dict[str, str] = {}
self.token_subjects: dict[str, str] = {}
def issue_code(self, subject: str) -> str:
code = secrets.token_urlsafe(24)
with self.lock:
self.code_subjects[code] = subject
return code
def exchange(self, code: str) -> str | None:
with self.lock:
subject = self.code_subjects.pop(code, None)
if subject is None:
return None
token = secrets.token_urlsafe(24)
self.token_subjects[token] = subject
return token
def subject_for_token(self, token: str) -> str | None:
with self.lock:
return self.token_subjects.get(token)
STATE = OAuthState()
class OAuthHandler(BaseHTTPRequestHandler):
server_version = "SkillHubOAuthMock/1.0"
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path == "/health":
self.send_json(HTTPStatus.OK, {"status": "UP"})
return
if parsed.path == "/oauth/authorize":
self.authorize(parse_qs(parsed.query))
return
if parsed.path == "/api/v4/user":
self.user_info()
return
if parsed.path == "/api/v4/user/emails":
self.user_emails()
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None: # noqa: N802
if urlparse(self.path).path != "/oauth/token":
self.send_error(HTTPStatus.NOT_FOUND)
return
length = int(self.headers.get("Content-Length", "0"))
form = parse_qs(
self.rfile.read(length).decode("utf-8"),
keep_blank_values=True,
)
code = first(form, "code")
token = STATE.exchange(code)
if token is None:
self.send_json(
HTTPStatus.BAD_REQUEST,
{
"error": "invalid_grant",
"error_description": "Unknown authorization code",
},
)
return
self.send_json(
HTTPStatus.OK,
{
"access_token": token,
"token_type": "Bearer",
"expires_in": 300,
"created_at": int(time.time()),
},
)
def authorize(self, query: dict[str, list[str]]) -> None:
redirect_uri = first(query, "redirect_uri")
state = first(query, "state")
if not safe_callback(redirect_uri):
self.send_json(
HTTPStatus.BAD_REQUEST,
{"error": "invalid_redirect_uri"},
)
return
subject = self.subject_cookie()
created_subject = subject is None
if subject is None:
subject = str(10**11 + secrets.randbelow(9 * 10**11))
code = STATE.issue_code(subject)
parsed = urlparse(redirect_uri)
callback_query = parse_qs(
parsed.query,
keep_blank_values=True,
)
callback_query["code"] = [code]
callback_query["state"] = [state]
location = urlunparse(
parsed._replace(query=urlencode(callback_query, doseq=True))
)
self.send_response(HTTPStatus.FOUND)
if created_subject:
self.send_header(
"Set-Cookie",
"skillhub_mock_subject="
+ subject
+ "; Path=/; HttpOnly; SameSite=Lax",
)
self.send_header("Location", location)
self.end_headers()
def user_info(self) -> None:
subject = self.authenticated_subject()
if subject is None:
return
self.send_json(
HTTPStatus.OK,
{
"id": int(subject),
"username": "mock_gitlab_" + subject,
"name": "Mock GitLab User",
"email": subject + "@gitlab.example.test",
"confirmed_at": "2026-07-31T00:00:00Z",
"avatar_url": None,
},
)
def user_emails(self) -> None:
subject = self.authenticated_subject()
if subject is None:
return
self.send_json(
HTTPStatus.OK,
[
{
"email": subject + "@gitlab.example.test",
"confirmed_at": "2026-07-31T00:00:00Z",
}
],
)
def authenticated_subject(self) -> str | None:
authorization = self.headers.get("Authorization", "")
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
self.send_json(
HTTPStatus.UNAUTHORIZED,
{"error": "invalid_token"},
)
return None
subject = STATE.subject_for_token(token)
if subject is None:
self.send_json(
HTTPStatus.UNAUTHORIZED,
{"error": "invalid_token"},
)
return subject
def subject_cookie(self) -> str | None:
cookie = SimpleCookie()
cookie.load(self.headers.get("Cookie", ""))
morsel = cookie.get("skillhub_mock_subject")
if morsel is None or not morsel.value.isdecimal():
return None
return morsel.value
def send_json(
self,
status: HTTPStatus,
body: object,
) -> None:
payload = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(
self,
format_string: str,
*args: object,
) -> None:
del format_string, args
def first(values: dict[str, list[str]], key: str) -> str:
candidates = values.get(key)
return candidates[0] if candidates else ""
def safe_callback(value: str) -> bool:
parsed = urlparse(value)
return (
parsed.scheme == "http"
and parsed.hostname in {"127.0.0.1", "localhost"}
and parsed.path == "/login/oauth2/code/gitlab"
and parsed.username is None
and parsed.password is None
and parsed.fragment == ""
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
server = ThreadingHTTPServer(
("127.0.0.1", args.port),
OAuthHandler,
)
print(f"LISTENING_PORT={server.server_port}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()

View file

@ -18,6 +18,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>

View file

@ -0,0 +1,309 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.CreateIdentityLinkRequest;
import com.iflytek.skillhub.dto.CreateIdentityUnlinkRequest;
import com.iflytek.skillhub.dto.IdentityLinkAccountStateResponse;
import com.iflytek.skillhub.dto.IdentityLinkBrowserStartRequest;
import com.iflytek.skillhub.dto.IdentityLinkBrowserStartResponse;
import com.iflytek.skillhub.dto.IdentityLinkCredentialRequest;
import com.iflytek.skillhub.dto.IdentityLinkIntentResponse;
import com.iflytek.skillhub.dto.IdentityLinkLocalReauthenticationRequest;
import com.iflytek.skillhub.dto.IdentityLinkTargetCredentialRequest;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.IdentityLinkAppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import java.util.UUID;
import org.slf4j.MDC;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Transport endpoints for explicit external identity link and safe unlink
* workflows.
*/
@RestController
@RequestMapping("/api/v1/auth")
@Tag(
name = "Identity Link",
description = "Manage login methods with fresh reauthentication")
public class IdentityLinkController extends BaseApiController {
private final IdentityLinkAppService identityLinkAppService;
public IdentityLinkController(
ApiResponseFactory responseFactory,
IdentityLinkAppService identityLinkAppService) {
super(responseFactory);
this.identityLinkAppService = identityLinkAppService;
}
@Operation(
summary = "List linked and available login methods",
description = "Returns active external bindings and providers that can be linked.")
@IdentityLinkMutationResponses
@GetMapping("/identity-links")
public ApiResponse<IdentityLinkAccountStateResponse> accountState(
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) {
requirePrincipal(principal);
return ok(
"response.success.read",
identityLinkAppService.accountState(
principal.userId(),
requireSession(request)));
}
@Operation(summary = "Create an external identity link intent")
@IdentityLinkMutationResponses
@PostMapping("/identity-link-intents/link")
@RateLimit(
category = "identity-link-create",
authenticated = 10,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<IdentityLinkIntentResponse> createLinkIntent(
@Valid @RequestBody CreateIdentityLinkRequest body,
HttpServletRequest request) {
return ok(
"response.success.created",
identityLinkAppService.createLinkIntent(
body.providerCode(),
requireSession(request),
context(request)));
}
@Operation(summary = "Create an external identity unlink intent")
@IdentityLinkMutationResponses
@PostMapping("/identity-link-intents/unlink")
@RateLimit(
category = "identity-unlink-create",
authenticated = 10,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<IdentityLinkIntentResponse> createUnlinkIntent(
@Valid @RequestBody CreateIdentityUnlinkRequest body,
HttpServletRequest request) {
return ok(
"response.success.created",
identityLinkAppService.createUnlinkIntent(
body.bindingId(),
requireSession(request),
context(request)));
}
@Operation(summary = "Get an identity link intent")
@IdentityLinkMutationResponses
@GetMapping("/identity-link-intents/{intentId}")
public ApiResponse<IdentityLinkIntentResponse> getIntent(
@PathVariable UUID intentId,
HttpServletRequest request) {
return ok(
"response.success.read",
identityLinkAppService.getIntent(
intentId,
requireSession(request),
context(request)));
}
@Operation(summary = "Cancel an identity link intent")
@IdentityLinkMutationResponses
@DeleteMapping("/identity-link-intents/{intentId}")
public ApiResponse<IdentityLinkIntentResponse> cancel(
@PathVariable UUID intentId,
HttpServletRequest request) {
return ok(
"response.success.updated",
identityLinkAppService.cancel(
intentId,
requireSession(request),
context(request)));
}
@Operation(summary = "Freshly reauthenticate with the local password")
@IdentityLinkMutationResponses
@PostMapping(
"/identity-link-intents/{intentId}"
+ "/reauthenticate/local")
@RateLimit(
category = "identity-link-local-reauth",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<IdentityLinkIntentResponse> reauthenticateLocal(
@PathVariable UUID intentId,
@Valid @RequestBody
IdentityLinkLocalReauthenticationRequest body,
HttpServletRequest request) {
return ok(
"response.success.updated",
identityLinkAppService.reauthenticateLocal(
intentId,
body.password(),
requireSession(request),
context(request)));
}
@Operation(summary = "Start browser-provider fresh reauthentication")
@IdentityLinkMutationResponses
@PostMapping(
"/identity-link-intents/{intentId}"
+ "/reauthenticate/browser")
@RateLimit(
category = "identity-link-browser-reauth",
authenticated = 10,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<IdentityLinkBrowserStartResponse>
prepareBrowserReauthentication(
@PathVariable UUID intentId,
@Valid @RequestBody
IdentityLinkBrowserStartRequest body,
HttpServletRequest request) {
return ok(
"response.success.created",
new IdentityLinkBrowserStartResponse(
identityLinkAppService
.prepareBrowserReauthentication(
intentId,
body.providerCode(),
requireSession(request),
context(request))));
}
@Operation(summary = "Freshly reauthenticate with a credential provider")
@IdentityLinkMutationResponses
@PostMapping(
"/identity-link-intents/{intentId}"
+ "/reauthenticate/credential")
@RateLimit(
category = "identity-link-credential-reauth",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<IdentityLinkIntentResponse>
reauthenticateCredential(
@PathVariable UUID intentId,
@Valid @RequestBody
IdentityLinkCredentialRequest body,
HttpServletRequest request) {
return ok(
"response.success.updated",
identityLinkAppService.reauthenticateCredential(
intentId,
body.providerCode(),
body.username(),
body.password(),
requireSession(request),
context(request)));
}
@Operation(summary = "Start browser authentication for the target identity")
@IdentityLinkMutationResponses
@PostMapping(
"/identity-link-intents/{intentId}/link/browser")
@RateLimit(
category = "identity-link-browser-target",
authenticated = 10,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<IdentityLinkBrowserStartResponse>
prepareBrowserLink(
@PathVariable UUID intentId,
HttpServletRequest request) {
return ok(
"response.success.created",
new IdentityLinkBrowserStartResponse(
identityLinkAppService.prepareBrowserLink(
intentId,
requireSession(request),
context(request))));
}
@Operation(summary = "Authenticate and link a credential-provider identity")
@IdentityLinkMutationResponses
@PostMapping(
"/identity-link-intents/{intentId}"
+ "/link/credential")
@RateLimit(
category = "identity-link-credential-target",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<IdentityLinkIntentResponse> linkCredential(
@PathVariable UUID intentId,
@Valid @RequestBody
IdentityLinkTargetCredentialRequest body,
HttpServletRequest request) {
return ok(
"response.success.updated",
identityLinkAppService.linkCredential(
intentId,
body.username(),
body.password(),
requireSession(request),
context(request)));
}
@Operation(summary = "Complete unlink after fresh reauthentication")
@IdentityLinkMutationResponses
@PostMapping(
"/identity-link-intents/{intentId}/unlink")
public ApiResponse<IdentityLinkIntentResponse> completeUnlink(
@PathVariable UUID intentId,
HttpServletRequest request) {
return ok(
"response.success.updated",
identityLinkAppService.completeUnlink(
intentId,
requireSession(request),
context(request)));
}
private void requirePrincipal(PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException(
"error.auth.required");
}
}
private HttpSession requireSession(
HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null
|| !(session.getAttribute("platformPrincipal")
instanceof PlatformPrincipal)) {
throw new UnauthorizedException(
"error.auth.required");
}
return session;
}
private IdentityLoginContext context(
HttpServletRequest request) {
return new IdentityLoginContext(
bounded(MDC.get("requestId"), 64),
bounded(request.getRemoteAddr(), 64),
bounded(request.getHeader("User-Agent"), 512));
}
private String bounded(String value, int maximumLength) {
return value == null || value.length() > maximumLength
? null
: value;
}
}

View file

@ -0,0 +1,69 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.dto.IdentityLinkErrorResponse;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "Operation completed"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "Invalid identity link operation",
content = @Content(
schema = @Schema(
implementation =
IdentityLinkErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "Fresh reauthentication required",
content = @Content(
schema = @Schema(
implementation =
IdentityLinkErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "Intent belongs to another session",
content = @Content(
schema = @Schema(
implementation =
IdentityLinkErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "Identity Link intent was not found",
content = @Content(
schema = @Schema(
implementation =
IdentityLinkErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "Identity conflict, consumed intent, or final login method",
content = @Content(
schema = @Schema(
implementation =
IdentityLinkErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "410",
description = "Intent expired",
content = @Content(
schema = @Schema(
implementation =
IdentityLinkErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "503",
description = "Identity provider unavailable",
content = @Content(
schema = @Schema(
implementation =
IdentityLinkErrorResponse.class)))
})
public @interface IdentityLinkMutationResponses {
}

View file

@ -32,4 +32,34 @@ public class ApiResponseFactory {
public ApiResponse<Void> errorMessage(int code, String msg) {
return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId"));
}
public IdentityLinkErrorResponse identityLinkError(
int code,
String messageCode,
String reasonCode,
Object... args) {
String msg = messageSource.getMessage(
messageCode,
args,
messageCode,
LocaleContextHolder.getLocale());
return new IdentityLinkErrorResponse(
code,
msg,
reasonCode,
Instant.now(clock),
MDC.get("requestId"));
}
public IdentityLinkErrorResponse identityLinkErrorMessage(
int code,
String message,
String reasonCode) {
return new IdentityLinkErrorResponse(
code,
message,
reasonCode,
Instant.now(clock),
MDC.get("requestId"));
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateIdentityLinkRequest(
@NotBlank(message = "{validation.auth.identityLink.provider.notBlank}")
@Size(
max = 64,
message = "{validation.auth.identityLink.provider.size}")
@Pattern(
regexp = "[a-z0-9][a-z0-9._-]{0,63}",
message = "{validation.auth.identityLink.provider.invalid}")
String providerCode
) {
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record CreateIdentityUnlinkRequest(
@NotNull(message = "{validation.auth.identityLink.binding.required}")
@Positive(message = "{validation.auth.identityLink.binding.positive}")
Long bindingId
) {
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record IdentityLinkAccountStateResponse(
boolean localPasswordEnabled,
List<IdentityLinkBindingResponse> linkedProviders,
List<IdentityLinkProviderResponse> availableProviders
) {
public IdentityLinkAccountStateResponse {
linkedProviders = List.copyOf(linkedProviders);
availableProviders = List.copyOf(availableProviders);
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
import java.util.Set;
public record IdentityLinkBindingResponse(
long bindingId,
String providerCode,
String displayName,
Set<IdentityProviderLoginMethodType> methodTypes,
boolean usable,
boolean canUnlink
) {
public IdentityLinkBindingResponse {
methodTypes = Set.copyOf(methodTypes);
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record IdentityLinkBrowserStartRequest(
@NotBlank(message = "{validation.auth.identityLink.provider.notBlank}")
@Size(
max = 64,
message = "{validation.auth.identityLink.provider.size}")
@Pattern(
regexp = "[a-z0-9][a-z0-9._-]{0,63}",
message = "{validation.auth.identityLink.provider.invalid}")
String providerCode
) {
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
public record IdentityLinkBrowserStartResponse(
String actionUrl
) {
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record IdentityLinkCredentialRequest(
@NotBlank(message = "{validation.auth.identityLink.provider.notBlank}")
@Size(
max = 64,
message = "{validation.auth.identityLink.provider.size}")
@Pattern(
regexp = "[a-z0-9][a-z0-9._-]{0,63}",
message = "{validation.auth.identityLink.provider.invalid}")
String providerCode,
@NotBlank(message = "{validation.auth.identityLink.username.notBlank}")
String username,
@NotBlank(message = "{validation.auth.identityLink.password.notBlank}")
String password
) {
}

View file

@ -0,0 +1,33 @@
package com.iflytek.skillhub.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
/**
* Stable machine-readable error envelope for Identity Link operations.
*/
public record IdentityLinkErrorResponse(
int code,
String msg,
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {
"INTENT_NOT_FOUND",
"REAUTHENTICATION_REQUIRED",
"SESSION_MISMATCH",
"INTENT_EXPIRED",
"ALREADY_CONSUMED",
"ACTIVE_INTENT_EXISTS",
"ACCOUNT_NOT_ELIGIBLE",
"PROVIDER_UNAVAILABLE",
"PROVIDER_AUTHENTICATION_FAILED",
"ALREADY_LINKED",
"IDENTITY_IN_USE",
"FINAL_LOGIN_METHOD",
"INVALID_OPERATION"
})
String reasonCode,
Instant timestamp,
String requestId
) {
}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
import java.time.Instant;
import java.util.UUID;
public record IdentityLinkIntentResponse(
UUID id,
IdentityLinkOperation operation,
IdentityLinkRequestStatus status,
String providerCode,
Long targetBindingId,
Instant expiresAt
) {
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record IdentityLinkLocalReauthenticationRequest(
@NotBlank(message = "{validation.auth.identityLink.password.notBlank}")
String password
) {
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
import java.util.Set;
public record IdentityLinkProviderResponse(
String providerCode,
String displayName,
Set<IdentityProviderLoginMethodType> methodTypes
) {
public IdentityLinkProviderResponse {
methodTypes = Set.copyOf(methodTypes);
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record IdentityLinkTargetCredentialRequest(
@NotBlank(message = "{validation.auth.identityLink.username.notBlank}")
String username,
@NotBlank(message = "{validation.auth.identityLink.password.notBlank}")
String password
) {
}

View file

@ -1,9 +1,13 @@
package com.iflytek.skillhub.exception;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher;
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.IdentityLinkErrorResponse;
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
@ -44,22 +48,74 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler(LocalizedException.class)
public ResponseEntity<ApiResponse<Void>> handleLocalizedError(LocalizedException ex, HttpServletRequest request) {
public ResponseEntity<?> handleLocalizedError(
LocalizedException ex,
HttpServletRequest request) {
if (IdentityLinkRouteRequestMatcher.matches(request)
&& ex.status() == HttpStatus.UNAUTHORIZED) {
logHandledException(
ex.status(),
ex.messageCode(),
request);
return ResponseEntity.status(ex.status()).body(
apiResponseFactory.identityLinkError(
ex.status().value(),
ex.messageCode(),
IdentityLinkFailureCode
.REAUTHENTICATION_REQUIRED
.name(),
ex.messageArgs()));
}
return renderLocalizedError(ex, ex.status(), request);
}
@ExceptionHandler(AuthFlowException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex, HttpServletRequest request) {
public ResponseEntity<?> handleAuthFlowException(
AuthFlowException ex,
HttpServletRequest request) {
if (IdentityLinkRouteRequestMatcher.matches(request)) {
IdentityLinkFailureCode reasonCode =
ex.getStatus() == HttpStatus.BAD_REQUEST
? IdentityLinkFailureCode
.INVALID_OPERATION
: IdentityLinkFailureCode
.REAUTHENTICATION_REQUIRED;
logHandledException(
ex.getStatus(),
ex.messageCode(),
request);
return ResponseEntity.status(ex.getStatus()).body(
apiResponseFactory.identityLinkError(
ex.getStatus().value(),
ex.messageCode(),
reasonCode.name(),
ex.messageArgs()));
}
return renderLocalizedError(ex, ex.getStatus(), request);
}
@ExceptionHandler(IdentityLinkException.class)
public ResponseEntity<IdentityLinkErrorResponse> handleIdentityLinkException(
IdentityLinkException ex,
HttpServletRequest request) {
logHandledException(ex.getStatus(), ex.messageCode(), request);
return ResponseEntity.status(ex.getStatus()).body(
apiResponseFactory.identityLinkError(
ex.getStatus().value(),
ex.messageCode(),
ex.getReasonCode().name(),
ex.messageArgs()));
}
@ExceptionHandler(LocalizedDomainException.class)
public ResponseEntity<ApiResponse<Void>> handleLocalizedDomainException(LocalizedDomainException ex, HttpServletRequest request) {
return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
public ResponseEntity<?> handleValidation(
MethodArgumentNotValidException ex,
HttpServletRequest request) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(FieldError::getDefaultMessage)
@ -68,6 +124,18 @@ public class GlobalExceptionHandler {
.map(error -> error.getDefaultMessage())
.orElse(null));
logHandledException(HttpStatus.BAD_REQUEST, "validation.request.invalid", request);
if (IdentityLinkRouteRequestMatcher.matches(request)) {
String message = msg == null || msg.isBlank()
? "Invalid identity link operation"
: msg;
return ResponseEntity.badRequest().body(
apiResponseFactory.identityLinkErrorMessage(
400,
message,
IdentityLinkFailureCode
.INVALID_OPERATION
.name()));
}
if (msg == null || msg.isBlank()) {
return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest"));
}
@ -75,8 +143,21 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) {
public ResponseEntity<?> handleBadRequest(
IllegalArgumentException ex,
HttpServletRequest request) {
logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request);
if (IdentityLinkRouteRequestMatcher.matches(request)) {
return ResponseEntity.badRequest().body(
apiResponseFactory.identityLinkError(
400,
IdentityLinkFailureCode
.INVALID_OPERATION
.messageCode(),
IdentityLinkFailureCode
.INVALID_OPERATION
.name()));
}
return ResponseEntity.badRequest().body(
apiResponseFactory.error(400, "error.badRequest"));
}

View file

@ -1,11 +1,13 @@
package com.iflytek.skillhub.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
@ -14,8 +16,6 @@ import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* Converts authorization failures on API routes into the platform's standard JSON error envelope.
*/
@ -51,13 +51,24 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
accessDeniedException.getClass().getSimpleName(),
apiTokenException != null ? apiTokenException.getMessage() : null
);
ApiResponse<Void> body = apiTokenException != null
? apiResponseFactory.error(
403,
apiTokenException.getMessageCode(),
apiTokenException.getMessageArgs()
)
: apiResponseFactory.error(403, "error.forbidden");
Object body;
if (apiTokenException != null) {
body = apiResponseFactory.error(
403,
apiTokenException.getMessageCode(),
apiTokenException.getMessageArgs());
} else if (IdentityLinkRouteRequestMatcher.matches(request)) {
body = apiResponseFactory.identityLinkError(
403,
IdentityLinkFailureCode.SESSION_MISMATCH
.messageCode(),
IdentityLinkFailureCode.SESSION_MISMATCH
.name());
} else {
body = apiResponseFactory.error(
403,
"error.forbidden");
}
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), body);

View file

@ -1,10 +1,12 @@
package com.iflytek.skillhub.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
@ -13,8 +15,6 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* Converts unauthenticated API access attempts into a consistent JSON 401 response.
*/
@ -45,7 +45,18 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
sensitiveLogSanitizer.sanitizeRequestTarget(request),
authException.getClass().getSimpleName()
);
ApiResponse<Void> body = apiResponseFactory.error(401, "error.auth.required");
Object body = IdentityLinkRouteRequestMatcher.matches(request)
? apiResponseFactory.identityLinkError(
401,
IdentityLinkFailureCode
.REAUTHENTICATION_REQUIRED
.messageCode(),
IdentityLinkFailureCode
.REAUTHENTICATION_REQUIRED
.name())
: apiResponseFactory.error(
401,
"error.auth.required");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), body);

View file

@ -0,0 +1,347 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
import com.iflytek.skillhub.auth.identity.IdentityLinkAccountState;
import com.iflytek.skillhub.auth.identity.IdentityLinkActor;
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase;
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityLinkIntent;
import com.iflytek.skillhub.auth.identity.IdentityLinkIntentService;
import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest;
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.IdentityLinkAccountStateResponse;
import com.iflytek.skillhub.dto.IdentityLinkBindingResponse;
import com.iflytek.skillhub.dto.IdentityLinkIntentResponse;
import com.iflytek.skillhub.dto.IdentityLinkProviderResponse;
import jakarta.servlet.http.HttpSession;
import java.util.UUID;
import org.springframework.stereotype.Service;
/**
* Application orchestration for explicit link/unlink workflows. Protocol I/O
* completes before the identity-core transaction is entered.
*/
@Service
public class IdentityLinkAppService {
private final IdentityLinkIntentService intentService;
private final ExternalIdentityLinkService externalLinkService;
private final IdentityProviderRegistry providerRegistry;
private final IdentityLinkSessionManager sessionManager;
public IdentityLinkAppService(
IdentityLinkIntentService intentService,
ExternalIdentityLinkService externalLinkService,
IdentityProviderRegistry providerRegistry,
IdentityLinkSessionManager sessionManager) {
this.intentService = intentService;
this.externalLinkService = externalLinkService;
this.providerRegistry = providerRegistry;
this.sessionManager = sessionManager;
}
public IdentityLinkAccountStateResponse accountState(
String userId,
HttpSession session) {
PlatformPrincipal sessionPrincipal =
(PlatformPrincipal) session
.getAttribute("platformPrincipal");
if (!sessionPrincipal.userId().equals(userId)) {
throw new IdentityLinkException(
IdentityLinkFailureCode.SESSION_MISMATCH);
}
IdentityLinkAccountState state =
intentService.accountState(userId);
return new IdentityLinkAccountStateResponse(
state.localPasswordEnabled(),
state.linkedProviders().stream()
.map(binding ->
new IdentityLinkBindingResponse(
binding.bindingId(),
binding.providerCode(),
binding.displayName(),
binding.methodTypes(),
binding.usable(),
binding.canUnlink()))
.toList(),
state.availableProviders().stream()
.map(provider ->
new IdentityLinkProviderResponse(
provider.providerCode(),
provider.displayName(),
provider.methodTypes()))
.toList());
}
public IdentityLinkIntentResponse createLinkIntent(
String providerCode,
HttpSession session,
IdentityLoginContext context) {
UUID intentId = UUID.randomUUID();
IdentityLinkActor actor = sessionManager.start(
session,
intentId,
context);
try {
return toResponse(intentService.createLinkIntent(
actor,
intentId,
providerCode));
} catch (RuntimeException exception) {
sessionManager.remove(session, intentId);
throw exception;
}
}
public IdentityLinkIntentResponse createUnlinkIntent(
long bindingId,
HttpSession session,
IdentityLoginContext context) {
UUID intentId = UUID.randomUUID();
IdentityLinkActor actor = sessionManager.start(
session,
intentId,
context);
try {
return toResponse(intentService.createUnlinkIntent(
actor,
intentId,
bindingId));
} catch (RuntimeException exception) {
sessionManager.remove(session, intentId);
throw exception;
}
}
public IdentityLinkIntentResponse getIntent(
UUID intentId,
HttpSession session,
IdentityLoginContext context) {
try {
return toResponse(intentService.getIntent(
sessionManager.actor(
session,
intentId,
context),
intentId));
} catch (IdentityLinkException exception) {
if (exception.getReasonCode()
== IdentityLinkFailureCode.INTENT_EXPIRED
|| exception.getReasonCode()
== IdentityLinkFailureCode.ALREADY_CONSUMED) {
sessionManager.remove(session, intentId);
}
throw exception;
}
}
public IdentityLinkIntentResponse cancel(
UUID intentId,
HttpSession session,
IdentityLoginContext context) {
IdentityLinkIntentResponse response = toResponse(
intentService.cancel(
sessionManager.actor(
session,
intentId,
context),
intentId));
sessionManager.remove(session, intentId);
return response;
}
public IdentityLinkIntentResponse reauthenticateLocal(
UUID intentId,
String password,
HttpSession session,
IdentityLoginContext context) {
return toResponse(intentService.reauthenticateLocal(
sessionManager.actor(
session,
intentId,
context),
intentId,
password));
}
public String prepareBrowserReauthentication(
UUID intentId,
String providerCode,
HttpSession session,
IdentityLoginContext context) {
IdentityLinkActor actor = sessionManager.actor(
session,
intentId,
context);
IdentityLinkIntent intent =
intentService.prepareExternalReauthentication(
actor,
intentId,
providerCode,
IdentityProviderLoginMethodType.OAUTH_REDIRECT);
sessionManager.prepareBrowserFlow(
session,
intentId,
IdentityLinkBrowserPhase.REAUTHENTICATE,
providerCode,
context);
return browserAuthorizationUrl(
providerCode,
"/settings/security?identityLink=reauthenticated"
+ "&intentId="
+ intentId);
}
public String prepareBrowserLink(
UUID intentId,
HttpSession session,
IdentityLoginContext context) {
IdentityLinkActor actor = sessionManager.actor(
session,
intentId,
context);
IdentityLinkIntent intent = intentService.prepareExternalLink(
actor,
intentId,
IdentityProviderLoginMethodType.OAUTH_REDIRECT);
sessionManager.prepareBrowserFlow(
session,
intentId,
IdentityLinkBrowserPhase.LINK,
intent.providerCode(),
context);
return browserAuthorizationUrl(
intent.providerCode(),
"/settings/security?identityLink=linked"
+ "&intentId="
+ intentId);
}
public IdentityLinkIntentResponse reauthenticateCredential(
UUID intentId,
String providerCode,
String username,
String password,
HttpSession session,
IdentityLoginContext context) {
IdentityLinkActor actor = sessionManager.actor(
session,
intentId,
context);
intentService.prepareExternalReauthentication(
actor,
intentId,
providerCode,
IdentityProviderLoginMethodType.DIRECT_PASSWORD);
IdentityProviderRegistry.CredentialRoute route =
providerRegistry.requireCredentialRoute(providerCode);
IdentityLinkOutcome outcome = externalLinkService.reauthenticate(
actor,
intentId,
route.provider(),
authenticate(route, username, password));
if (!(outcome instanceof IdentityLinkOutcome.Reauthenticated)) {
throw new IllegalStateException(
"Credential reauthentication returned an invalid outcome");
}
return toResponse(intentService.getIntent(
actor,
intentId));
}
public IdentityLinkIntentResponse linkCredential(
UUID intentId,
String username,
String password,
HttpSession session,
IdentityLoginContext context) {
IdentityLinkActor actor = sessionManager.actor(
session,
intentId,
context);
IdentityLinkIntent intent = intentService.prepareExternalLink(
actor,
intentId,
IdentityProviderLoginMethodType.DIRECT_PASSWORD);
IdentityProviderRegistry.CredentialRoute route =
providerRegistry.requireCredentialRoute(
intent.providerCode());
IdentityLinkOutcome outcome = externalLinkService.link(
actor,
intentId,
route.provider(),
authenticate(route, username, password));
if (!(outcome instanceof IdentityLinkOutcome.Linked)) {
throw new IllegalStateException(
"Credential link returned an invalid outcome");
}
sessionManager.remove(session, intentId);
return new IdentityLinkIntentResponse(
intent.id(),
intent.operation(),
IdentityLinkRequestStatus.COMPLETED,
intent.providerCode(),
intent.targetBindingId(),
intent.expiresAt());
}
public IdentityLinkIntentResponse completeUnlink(
UUID intentId,
HttpSession session,
IdentityLoginContext context) {
IdentityLinkIntentResponse response = toResponse(
intentService.completeUnlink(
sessionManager.actor(
session,
intentId,
context),
intentId));
sessionManager.remove(session, intentId);
return response;
}
private ProviderAuthenticationResult authenticate(
IdentityProviderRegistry.CredentialRoute route,
String username,
String password) {
try {
return route.adapter().authenticate(
new CredentialAuthenticationRequest(
username,
password));
} catch (ProviderAuthenticationException exception) {
throw ProviderAuthenticationFailureMapper.map(exception);
}
}
private String browserAuthorizationUrl(
String providerCode,
String returnTo) {
return "/oauth2/authorization/"
+ providerCode
+ "?returnTo="
+ java.net.URLEncoder.encode(
returnTo,
java.nio.charset.StandardCharsets.UTF_8);
}
private IdentityLinkIntentResponse toResponse(
IdentityLinkIntent intent) {
return new IdentityLinkIntentResponse(
intent.id(),
intent.operation(),
intent.status(),
intent.providerCode(),
intent.targetBindingId(),
intent.expiresAt());
}
}

View file

@ -0,0 +1,91 @@
CREATE TABLE identity_link_request (
id UUID PRIMARY KEY,
primary_user_id VARCHAR(128) NOT NULL,
operation VARCHAR(16) NOT NULL,
provider_code VARCHAR(64) NOT NULL,
target_binding_id BIGINT,
state_hash VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
reauthentication_method VARCHAR(96),
reauthenticated_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
row_version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_identity_link_request_user
FOREIGN KEY (primary_user_id)
REFERENCES user_account(id)
ON DELETE CASCADE,
CONSTRAINT fk_identity_link_request_binding
FOREIGN KEY (target_binding_id)
REFERENCES identity_binding(id),
CONSTRAINT chk_identity_link_request_operation
CHECK (operation IN ('LINK', 'UNLINK')),
CONSTRAINT chk_identity_link_request_state_hash
CHECK (state_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT chk_identity_link_request_status
CHECK (
status IN (
'PENDING_REAUTHENTICATION',
'READY',
'COMPLETED',
'EXPIRED',
'CANCELLED'
)
),
CONSTRAINT chk_identity_link_request_target
CHECK (
(operation = 'LINK' AND target_binding_id IS NULL)
OR
(operation = 'UNLINK' AND target_binding_id IS NOT NULL)
),
CONSTRAINT chk_identity_link_request_reauthentication
CHECK (
(
status = 'PENDING_REAUTHENTICATION'
AND reauthentication_method IS NULL
AND reauthenticated_at IS NULL
)
OR
(
status IN ('READY', 'COMPLETED')
AND reauthentication_method IS NOT NULL
AND reauthenticated_at IS NOT NULL
)
OR
status IN ('EXPIRED', 'CANCELLED')
),
CONSTRAINT chk_identity_link_request_completion
CHECK (
(status = 'COMPLETED' AND completed_at IS NOT NULL)
OR
(status <> 'COMPLETED' AND completed_at IS NULL)
),
CONSTRAINT chk_identity_link_request_cancellation
CHECK (
(status = 'CANCELLED' AND cancelled_at IS NOT NULL)
OR
(status <> 'CANCELLED' AND cancelled_at IS NULL)
)
);
CREATE UNIQUE INDEX uq_identity_link_request_active_user
ON identity_link_request(primary_user_id)
WHERE status IN ('PENDING_REAUTHENTICATION', 'READY');
CREATE INDEX idx_identity_link_request_expiry
ON identity_link_request(expires_at)
WHERE status IN ('PENDING_REAUTHENTICATION', 'READY');
CREATE INDEX idx_identity_link_request_binding
ON identity_link_request(target_binding_id)
WHERE target_binding_id IS NOT NULL;
ALTER TABLE identity_binding
DROP CONSTRAINT identity_binding_provider_code_subject_key;
CREATE UNIQUE INDEX uq_identity_binding_active_provider_subject
ON identity_binding(provider_code, subject)
WHERE status = 'ACTIVE';

View file

@ -191,3 +191,25 @@ promotion.sort.pending_unsupported=Pending promotion requests do not support rev
error.auth.provider.notFound=Identity provider was not found
error.auth.provider.authorityRecoveryMismatch=Provider configuration still points to a different authority
error.auth.provider.authorityRecoveryUnavailable=Provider authority cannot be recovered from its current state
# Identity link and unlink
error.auth.identityLink.intentNotFound=Identity link request was not found
error.auth.identityLink.reauthenticationRequired=Fresh reauthentication is required
error.auth.identityLink.sessionMismatch=Identity link request belongs to another session
error.auth.identityLink.intentExpired=Identity link request has expired
error.auth.identityLink.alreadyConsumed=Identity link request has already been consumed
error.auth.identityLink.activeIntentExists=An identity link request is already active for this account
error.auth.identityLink.accountNotEligible=This account cannot change login methods
error.auth.identityLink.providerUnavailable=The selected identity provider is unavailable
error.auth.identityLink.providerAuthenticationFailed=Identity provider authentication failed or was cancelled
error.auth.identityLink.alreadyLinked=This identity provider is already linked
error.auth.identityLink.identityInUse=This external identity is already linked to an account
error.auth.identityLink.finalLoginMethod=The final usable login method cannot be removed
error.auth.identityLink.invalidOperation=Invalid identity link operation
validation.auth.identityLink.provider.notBlank=Identity provider cannot be blank
validation.auth.identityLink.provider.size=Identity provider must not exceed 64 characters
validation.auth.identityLink.provider.invalid=Identity provider format is invalid
validation.auth.identityLink.binding.positive=Identity binding must be positive
validation.auth.identityLink.binding.required=Identity binding is required
validation.auth.identityLink.username.notBlank=Username cannot be blank
validation.auth.identityLink.password.notBlank=Password cannot be blank

View file

@ -191,3 +191,25 @@ promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间
error.auth.provider.notFound=未找到身份提供方
error.auth.provider.authorityRecoveryMismatch=身份提供方配置仍指向不同的身份域
error.auth.provider.authorityRecoveryUnavailable=身份提供方当前状态不允许恢复身份域
# 身份关联与解绑
error.auth.identityLink.intentNotFound=未找到身份关联请求
error.auth.identityLink.reauthenticationRequired=需要重新验证当前账号
error.auth.identityLink.sessionMismatch=身份关联请求不属于当前会话
error.auth.identityLink.intentExpired=身份关联请求已过期
error.auth.identityLink.alreadyConsumed=身份关联请求已被使用
error.auth.identityLink.activeIntentExists=当前账号已有进行中的身份关联请求
error.auth.identityLink.accountNotEligible=当前账号不能修改登录方式
error.auth.identityLink.providerUnavailable=所选身份提供方当前不可用
error.auth.identityLink.providerAuthenticationFailed=身份提供方认证失败或已取消
error.auth.identityLink.alreadyLinked=当前账号已关联该身份提供方
error.auth.identityLink.identityInUse=该外部身份已关联其他账号
error.auth.identityLink.finalLoginMethod=不能移除最后一种可用登录方式
error.auth.identityLink.invalidOperation=身份关联操作无效
validation.auth.identityLink.provider.notBlank=身份提供方不能为空
validation.auth.identityLink.provider.size=身份提供方不能超过 64 个字符
validation.auth.identityLink.provider.invalid=身份提供方格式不正确
validation.auth.identityLink.binding.positive=身份绑定编号必须为正数
validation.auth.identityLink.binding.required=身份绑定编号不能为空
validation.auth.identityLink.username.notBlank=用户名不能为空
validation.auth.identityLink.password.notBlank=密码不能为空

View file

@ -0,0 +1,325 @@
package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@EnabledIfEnvironmentVariable(
named = "IDENTITY_BINDING_V2_POSTGRES_URL",
matches = "jdbc:postgresql:.*")
class IdentityLinkMigrationLoginPostgresIntegrationTest {
private static final String SCHEMA =
"identity_link_v49_login";
private static final String USER_ID =
"identity-link-v49-login-user";
private static final String SUBJECT =
"6554902";
@Autowired
private ExternalIdentityLoginService loginService;
@Autowired
private TrustedProviderRouteResolver routeResolver;
@Autowired
private ClientRegistrationRepository registrationRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
@DynamicPropertySource
static void postgresProperties(
DynamicPropertyRegistry registry) {
String url = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_URL");
String username = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_USERNAME");
String password = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_PASSWORD");
prepareV48DatabaseAndUpgrade(
url,
username,
password);
registry.add(
"spring.datasource.url",
() -> withCurrentSchema(url));
registry.add(
"spring.datasource.username",
() -> username);
registry.add(
"spring.datasource.password",
() -> password);
registry.add(
"spring.datasource.driver-class-name",
() -> "org.postgresql.Driver");
registry.add(
"spring.jpa.database-platform",
() -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add(
"spring.jpa.hibernate.ddl-auto",
() -> "validate");
registry.add(
"spring.flyway.enabled",
() -> "false");
registry.add(
"skillhub.builtin-skills.enabled",
() -> "false");
}
@AfterAll
static void dropSchema() {
String url = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_URL");
String username = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_USERNAME");
String password = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_PASSWORD");
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"DROP SCHEMA IF EXISTS "
+ SCHEMA
+ " CASCADE");
} catch (Exception exception) {
throw new IllegalStateException(
"Failed to remove V49 login test schema",
exception);
}
}
@Test
void activeV48BindingStillAuthenticatesThroughUnifiedLogin()
throws Exception {
ClientRegistration registration =
registrationRepository.findByRegistrationId(
"github");
assertThat(registration).isNotNull();
ResolvedProviderHandle provider =
routeResolver.resolve(registration);
IdentityLoginOutcome outcome = loginService.authenticate(
provider,
providerResult(),
IdentityLoginContext.empty());
assertThat(outcome)
.isInstanceOf(
IdentityLoginOutcome.Authenticated.class);
IdentityLoginOutcome.Authenticated authenticated =
(IdentityLoginOutcome.Authenticated) outcome;
assertThat(authenticated.principal().userId())
.isEqualTo(USER_ID);
assertThat(authenticated.accountCreated()).isFalse();
assertThat(authenticated.bindingCreated()).isFalse();
assertThat(jdbcTemplate.queryForObject(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE user_id = ?
AND provider_code = 'github'
AND subject = ?
AND status = 'ACTIVE'
""",
Long.class,
USER_ID,
SUBJECT)).isEqualTo(1L);
assertThat(jdbcTemplate.queryForObject(
"""
SELECT COUNT(*)
FROM identity_link_request
""",
Long.class)).isZero();
}
private static void prepareV48DatabaseAndUpgrade(
String url,
String username,
String password) {
dropAndCreateSchema(url, username, password);
Flyway.configure()
.dataSource(url, username, password)
.locations("classpath:db/migration")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.createSchemas(true)
.target(MigrationVersion.fromVersion("48"))
.load()
.migrate();
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute("SET search_path TO " + SCHEMA);
connection.setAutoCommit(false);
statement.executeUpdate("""
INSERT INTO user_account (
id,
display_name,
email,
status,
created_at,
updated_at
) VALUES (
'identity-link-v49-login-user',
'Identity Link V49 Login User',
'identity-link-v49-login@example.com',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
statement.executeUpdate("""
INSERT INTO identity_binding (
user_id,
provider_code,
subject,
login_name,
status,
created_at,
updated_at
) VALUES (
'identity-link-v49-login-user',
'github',
'6554902',
'identity-link-v49-login',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
statement.executeUpdate("""
INSERT INTO identity_binding_subject (
binding_id,
provider_code,
subject_type,
subject_value,
is_primary,
status,
created_at,
last_seen_at
)
SELECT
id,
provider_code,
'github_user_id',
subject,
TRUE,
'ACTIVE',
created_at,
updated_at
FROM identity_binding
WHERE user_id =
'identity-link-v49-login-user'
""");
connection.commit();
} catch (Exception exception) {
throw new IllegalStateException(
"Failed to seed V48 login fixture",
exception);
}
Flyway.configure()
.dataSource(url, username, password)
.locations("classpath:db/migration")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.createSchemas(true)
.target(MigrationVersion.fromVersion("49"))
.load()
.migrate();
}
private static ProviderAuthenticationResult providerResult() {
return new ProviderAuthenticationResult(
new SubjectCandidate(
"github_user_id",
SUBJECT),
List.of(),
Map.of(
"login",
List.of(new ProviderAttributeValue(
"identity-link-v49-login",
ProviderAttributeTrust.ASSERTED)),
"email",
List.of(new ProviderAttributeValue(
"identity-link-v49-login@example.com",
ProviderAttributeTrust.VERIFIED))),
new ProtocolAuthenticationEvidence(
"oauth2-github",
Instant.now(),
Set.of("oauth2_authorization_code")));
}
private static void dropAndCreateSchema(
String url,
String username,
String password) {
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"DROP SCHEMA IF EXISTS "
+ SCHEMA
+ " CASCADE");
statement.execute(
"CREATE SCHEMA " + SCHEMA);
} catch (Exception exception) {
throw new IllegalStateException(
"Failed to prepare V49 login test schema",
exception);
}
}
private static String withCurrentSchema(String url) {
String separator = url.contains("?") ? "&" : "?";
return url
+ separator
+ "currentSchema="
+ SCHEMA;
}
private static String requiredEnvironment(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"Missing required environment variable "
+ name);
}
return value;
}
}

View file

@ -0,0 +1,303 @@
package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
@EnabledIfEnvironmentVariable(
named = "IDENTITY_BINDING_V2_POSTGRES_URL",
matches = "jdbc:postgresql:.*")
class IdentityLinkMigrationPostgresTest {
private static final String SCHEMA =
"identity_link_v49_migration";
@Test
void upgradesExistingBindingsAndAllowsRelinkAfterRevocation()
throws Exception {
String url = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_URL");
String username = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_USERNAME");
String password = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_PASSWORD");
dropSchema(url, username, password);
try {
Flyway.configure()
.dataSource(url, username, password)
.locations("classpath:db/migration")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.createSchemas(true)
.target(MigrationVersion.fromVersion("48"))
.load()
.migrate();
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute("SET search_path TO " + SCHEMA);
statement.executeUpdate("""
INSERT INTO user_account (
id,
display_name,
email,
status,
created_at,
updated_at
) VALUES (
'identity-link-upgrade-user',
'Identity Link Upgrade User',
'identity-link-upgrade@example.com',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
statement.executeUpdate("""
INSERT INTO identity_provider_state (
provider_code,
protocol,
authority,
authority_fingerprint,
state
) VALUES (
'github',
'oauth2-github',
'https://github.com',
repeat('a', 64),
'READY'
)
""");
connection.setAutoCommit(false);
statement.executeUpdate("""
INSERT INTO identity_binding (
user_id,
provider_code,
subject,
login_name,
status,
created_at,
updated_at
) VALUES (
'identity-link-upgrade-user',
'github',
'6554901',
'identity-link-upgrade',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
statement.executeUpdate("""
INSERT INTO identity_binding_subject (
binding_id,
provider_code,
subject_type,
subject_value,
is_primary,
status,
created_at,
last_seen_at
)
SELECT
id,
provider_code,
'github_user_id',
subject,
TRUE,
'ACTIVE',
created_at,
updated_at
FROM identity_binding
WHERE user_id = 'identity-link-upgrade-user'
""");
connection.commit();
}
Flyway.configure()
.dataSource(url, username, password)
.locations("classpath:db/migration")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.createSchemas(true)
.target(MigrationVersion.fromVersion("49"))
.load()
.migrate();
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute("SET search_path TO " + SCHEMA);
assertThat(singleLong(
statement,
"""
SELECT COUNT(*)
FROM identity_binding
WHERE user_id = 'identity-link-upgrade-user'
AND provider_code = 'github'
AND subject = '6554901'
AND status = 'ACTIVE'
""")).isEqualTo(1L);
assertThat(singleLong(
statement,
"""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = 'identity_link_v49_migration'
AND table_name = 'identity_link_request'
""")).isEqualTo(1L);
assertThat(singleLong(
statement,
"""
SELECT COUNT(*)
FROM pg_indexes
WHERE schemaname = 'identity_link_v49_migration'
AND indexname =
'uq_identity_binding_active_provider_subject'
""")).isEqualTo(1L);
connection.setAutoCommit(false);
statement.executeUpdate("""
UPDATE identity_binding_subject
SET
is_primary = FALSE,
status = 'REVOKED',
revoked_at = CURRENT_TIMESTAMP
WHERE provider_code = 'github'
AND subject_value = '6554901'
AND status = 'ACTIVE'
""");
statement.executeUpdate("""
UPDATE identity_binding
SET
status = 'REVOKED',
revoked_at = CURRENT_TIMESTAMP,
revoked_by = 'identity-link-upgrade-user',
revocation_reason = 'migration test'
WHERE provider_code = 'github'
AND subject = '6554901'
AND status = 'ACTIVE'
""");
statement.executeUpdate("""
INSERT INTO identity_binding (
user_id,
provider_code,
subject,
login_name,
status,
created_at,
updated_at
) VALUES (
'identity-link-upgrade-user',
'github',
'6554901',
'identity-link-upgrade',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
statement.executeUpdate("""
INSERT INTO identity_binding_subject (
binding_id,
provider_code,
subject_type,
subject_value,
is_primary,
status,
created_at,
last_seen_at
)
SELECT
id,
provider_code,
'github_user_id',
subject,
TRUE,
'ACTIVE',
created_at,
updated_at
FROM identity_binding
WHERE user_id = 'identity-link-upgrade-user'
AND provider_code = 'github'
AND subject = '6554901'
AND status = 'ACTIVE'
""");
connection.commit();
assertThat(singleLong(
statement,
"""
SELECT COUNT(*)
FROM identity_binding
WHERE provider_code = 'github'
AND subject = '6554901'
AND status = 'ACTIVE'
""")).isEqualTo(1L);
assertThat(singleLong(
statement,
"""
SELECT COUNT(*)
FROM identity_binding
WHERE provider_code = 'github'
AND subject = '6554901'
AND status = 'REVOKED'
""")).isEqualTo(1L);
}
} finally {
dropSchema(url, username, password);
}
}
private static long singleLong(
Statement statement,
String sql) throws Exception {
try (ResultSet result = statement.executeQuery(sql)) {
assertThat(result.next()).isTrue();
return result.getLong(1);
}
}
private static String requiredEnvironment(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"Missing required environment variable " + name);
}
return value;
}
private static void dropSchema(
String url,
String username,
String password) throws Exception {
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"DROP SCHEMA IF EXISTS "
+ SCHEMA
+ " CASCADE");
}
}
}

View file

@ -0,0 +1,877 @@
package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@EnabledIfEnvironmentVariable(
named = "IDENTITY_BINDING_V2_POSTGRES_URL",
matches = "jdbc:postgresql:.*")
class IdentityLinkPostgresIntegrationTest {
private static final String SCHEMA =
"identity_link_pr655_integration";
private static final String PASSWORD =
"IdentityLinkTest!2026";
private static final IdentityLoginContext CONTEXT =
new IdentityLoginContext(
"req-pr655",
"203.0.113.9",
"Identity Link Integration Test");
@Autowired
private IdentityLinkIntentService intentService;
@Autowired
private ExternalIdentityLinkService externalLinkService;
@Autowired
private TrustedProviderRouteResolver routeResolver;
@Autowired
private ClientRegistrationRepository registrationRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private PlatformTransactionManager transactionManager;
@DynamicPropertySource
static void postgresProperties(
DynamicPropertyRegistry registry) {
String url = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_URL");
String username = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_USERNAME");
String password = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_PASSWORD");
createSchema(url, username, password);
registry.add(
"spring.datasource.url",
() -> withCurrentSchema(url));
registry.add(
"spring.datasource.username",
() -> username);
registry.add(
"spring.datasource.password",
() -> password);
registry.add(
"spring.datasource.driver-class-name",
() -> "org.postgresql.Driver");
registry.add(
"spring.jpa.database-platform",
() -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add(
"spring.jpa.hibernate.ddl-auto",
() -> "validate");
registry.add(
"spring.flyway.enabled",
() -> "true");
registry.add(
"spring.flyway.default-schema",
() -> SCHEMA);
registry.add(
"spring.flyway.schemas",
() -> SCHEMA);
registry.add(
"skillhub.builtin-skills.enabled",
() -> "false");
}
@AfterAll
static void dropSchema() {
String url = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_URL");
String username = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_USERNAME");
String password = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_PASSWORD");
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"DROP SCHEMA IF EXISTS "
+ SCHEMA
+ " CASCADE");
} catch (Exception exception) {
throw new IllegalStateException(
"Failed to remove identity link test schema",
exception);
}
}
@Test
void linkRequiresFreshReauthenticationAndCannotBeReplayed() {
String userId = "identity-link-user";
String subject = "6551001";
seedLocalUser(userId, "identity_link_user");
IdentityLinkActor actor = actor(
userId,
"nonce-link-user");
UUID intentId = UUID.randomUUID();
intentService.createLinkIntent(
actor,
intentId,
"github");
intentService.reauthenticateLocal(
actor,
intentId,
PASSWORD);
IdentityLinkOutcome outcome = externalLinkService.link(
actor,
intentId,
githubProvider(),
githubResult(subject));
assertThat(outcome)
.isInstanceOf(IdentityLinkOutcome.Linked.class);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE user_id = ?
AND provider_code = 'github'
AND subject = ?
AND status = 'ACTIVE'
""",
userId,
subject)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding_subject
WHERE provider_code = 'github'
AND subject_value = ?
AND status = 'ACTIVE'
AND is_primary = TRUE
""",
subject)).isEqualTo(1L);
assertThatThrownBy(() ->
externalLinkService.link(
actor,
intentId,
githubProvider(),
githubResult(subject)))
.isInstanceOfSatisfying(
IdentityLinkException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
IdentityLinkFailureCode
.ALREADY_CONSUMED));
assertThat(count(
"""
SELECT COUNT(*)
FROM audit_log
WHERE action = 'IDENTITY_LINK_INTENT_REJECTED'
AND detail_json ->> 'intentId' = ?
AND detail_json ->> 'result' = 'already_consumed'
""",
intentId.toString())).isEqualTo(1L);
}
@Test
void concurrentConsumptionOfSameIntentCompletesExactlyOnce()
throws Exception {
String userId = "identity-link-intent-race";
String subject = "6551501";
seedLocalUser(
userId,
"identity_link_intent_race");
IdentityLinkActor actor =
actor(userId, "nonce-intent-race");
UUID intentId = readyLinkIntent(
actor,
PASSWORD);
CountDownLatch start = new CountDownLatch(1);
List<Future<Object>> futures = new ArrayList<>();
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
for (int attempt = 0; attempt < 2; attempt++) {
futures.add(executor.submit(() -> {
start.await();
return linkOrFailure(
actor,
intentId,
subject);
}));
}
start.countDown();
List<Object> results = List.of(
futures.get(0).get(),
futures.get(1).get());
assertThat(results.stream()
.filter(IdentityLinkOutcome.Linked.class::isInstance)
.count()).isEqualTo(1L);
assertThat(results.stream()
.filter(IdentityLinkException.class::isInstance)
.map(IdentityLinkException.class::cast)
.map(IdentityLinkException::getReasonCode))
.containsExactly(
IdentityLinkFailureCode.ALREADY_CONSUMED);
}
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE user_id = ?
AND provider_code = 'github'
AND subject = ?
AND status = 'ACTIVE'
""",
userId,
subject)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_link_request
WHERE id = ?
AND status = 'COMPLETED'
""",
intentId)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM audit_log
WHERE action = 'IDENTITY_LINK_INTENT_REJECTED'
AND detail_json ->> 'intentId' = ?
AND detail_json ->> 'result' = 'already_consumed'
""",
intentId.toString())).isEqualTo(1L);
}
@Test
void concurrentSubjectConflictLeavesOneBindingAndReadyLosingIntent()
throws Exception {
String firstUser = "identity-link-race-a";
String secondUser = "identity-link-race-b";
String subject = "6552001";
seedLocalUser(firstUser, "identity_link_race_a");
seedLocalUser(secondUser, "identity_link_race_b");
IdentityLinkActor firstActor =
actor(firstUser, "nonce-race-a");
IdentityLinkActor secondActor =
actor(secondUser, "nonce-race-b");
UUID firstIntent = readyLinkIntent(
firstActor,
PASSWORD);
UUID secondIntent = readyLinkIntent(
secondActor,
PASSWORD);
CountDownLatch start = new CountDownLatch(1);
List<Future<Object>> futures = new ArrayList<>();
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
futures.add(executor.submit(() -> {
start.await();
return linkOrFailure(
firstActor,
firstIntent,
subject);
}));
futures.add(executor.submit(() -> {
start.await();
return linkOrFailure(
secondActor,
secondIntent,
subject);
}));
start.countDown();
List<Object> results = List.of(
futures.get(0).get(),
futures.get(1).get());
assertThat(results.stream()
.filter(IdentityLinkOutcome.Linked.class::isInstance)
.count()).isEqualTo(1L);
assertThat(results.stream()
.filter(IdentityLinkException.class::isInstance)
.map(IdentityLinkException.class::cast)
.map(IdentityLinkException::getReasonCode))
.containsExactly(
IdentityLinkFailureCode.IDENTITY_IN_USE);
}
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE provider_code = 'github'
AND subject = ?
AND status = 'ACTIVE'
""",
subject)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_link_request
WHERE id IN (?, ?)
AND status = 'READY'
""",
firstIntent,
secondIntent)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_link_request
WHERE id IN (?, ?)
AND status = 'COMPLETED'
""",
firstIntent,
secondIntent)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM audit_log
WHERE action = 'IDENTITY_LINK_INTENT_REJECTED'
AND detail_json ->> 'intentId' IN (?, ?)
AND detail_json ->> 'result' = 'identity_in_use'
""",
firstIntent.toString(),
secondIntent.toString())).isEqualTo(1L);
}
@Test
void unlinkRejectsFinalMethodThenRevokesBindingAndSubjectsAtomically() {
String userId = "identity-unlink-user";
String subject = "6553001";
String username = "identity_unlink_user";
seedLocalUser(userId, username);
IdentityLinkActor actor = actor(
userId,
"nonce-unlink-user");
UUID linkIntent = readyLinkIntent(actor, PASSWORD);
IdentityLinkOutcome.Linked linked =
(IdentityLinkOutcome.Linked)
externalLinkService.link(
actor,
linkIntent,
githubProvider(),
githubResult(subject));
jdbcTemplate.update(
"DELETE FROM local_credential WHERE user_id = ?",
userId);
UUID unlinkIntent = UUID.randomUUID();
intentService.createUnlinkIntent(
actor,
unlinkIntent,
linked.bindingId());
externalLinkService.reauthenticate(
actor,
unlinkIntent,
githubProvider(),
githubResult(subject));
assertThatThrownBy(() ->
intentService.completeUnlink(
actor,
unlinkIntent))
.isInstanceOfSatisfying(
IdentityLinkException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
IdentityLinkFailureCode
.FINAL_LOGIN_METHOD));
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE id = ?
AND status = 'ACTIVE'
""",
linked.bindingId())).isEqualTo(1L);
insertLocalCredential(userId, username);
intentService.completeUnlink(actor, unlinkIntent);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE id = ?
AND status = 'REVOKED'
AND revoked_by = ?
""",
linked.bindingId(),
userId)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding_subject
WHERE binding_id = ?
AND status = 'REVOKED'
AND is_primary = FALSE
""",
linked.bindingId())).isEqualTo(1L);
}
@Test
void unavailableLegacyProviderCanStillBeSafelyUnlinked() {
String userId = "identity-unlink-legacy-provider";
String username = "identity_unlink_legacy_provider";
String providerCode = "legacy-missing";
String subject = "legacy-missing-655";
seedLocalUser(userId, username);
long bindingId = insertLegacyBinding(
userId,
providerCode,
subject,
username);
IdentityLinkBindingView legacyBinding =
intentService.accountState(userId)
.linkedProviders()
.stream()
.filter(binding ->
binding.bindingId()
== bindingId)
.findFirst()
.orElseThrow();
assertThat(legacyBinding.usable()).isFalse();
assertThat(legacyBinding.canUnlink()).isTrue();
IdentityLinkActor actor = actor(
userId,
"nonce-unlink-legacy-provider");
UUID intentId = UUID.randomUUID();
intentService.createUnlinkIntent(
actor,
intentId,
bindingId);
intentService.reauthenticateLocal(
actor,
intentId,
PASSWORD);
intentService.completeUnlink(actor, intentId);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE id = ?
AND status = 'REVOKED'
""",
bindingId)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_provider_state
WHERE provider_code = ?
""",
providerCode)).isZero();
}
@Test
void unlinkedIdentityCanBeLinkedAgainWhileRevocationHistoryIsPreserved() {
String userId = "identity-relink-user";
String subject = "6554001";
seedLocalUser(userId, "identity_relink_user");
IdentityLinkActor actor = actor(
userId,
"nonce-relink-user");
UUID firstLinkIntent = readyLinkIntent(actor, PASSWORD);
IdentityLinkOutcome.Linked firstLink =
(IdentityLinkOutcome.Linked)
externalLinkService.link(
actor,
firstLinkIntent,
githubProvider(),
githubResult(subject));
UUID unlinkIntent = UUID.randomUUID();
intentService.createUnlinkIntent(
actor,
unlinkIntent,
firstLink.bindingId());
intentService.reauthenticateLocal(
actor,
unlinkIntent,
PASSWORD);
intentService.completeUnlink(actor, unlinkIntent);
UUID secondLinkIntent = readyLinkIntent(actor, PASSWORD);
IdentityLinkOutcome.Linked secondLink =
(IdentityLinkOutcome.Linked)
externalLinkService.link(
actor,
secondLinkIntent,
githubProvider(),
githubResult(subject));
assertThat(secondLink.bindingId())
.isNotEqualTo(firstLink.bindingId());
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE provider_code = 'github'
AND subject = ?
AND status = 'ACTIVE'
""",
subject)).isEqualTo(1L);
assertThat(count(
"""
SELECT COUNT(*)
FROM identity_binding
WHERE provider_code = 'github'
AND subject = ?
AND status = 'REVOKED'
""",
subject)).isEqualTo(1L);
}
@Test
void intentExpiryCommitsAndSessionNonceMismatchFailsClosed() {
String userId = "identity-link-expiry-user";
seedLocalUser(
userId,
"identity_link_expiry_user");
String rawNonce = "raw-session-nonce-expiry";
IdentityLinkActor actor = actor(userId, rawNonce);
UUID expiredIntent = UUID.randomUUID();
intentService.createLinkIntent(
actor,
expiredIntent,
"github");
String stateHash = jdbcTemplate.queryForObject(
"""
SELECT state_hash
FROM identity_link_request
WHERE id = ?
""",
String.class,
expiredIntent);
assertThat(stateHash)
.hasSize(64)
.isNotEqualTo(rawNonce);
jdbcTemplate.update(
"""
UPDATE identity_link_request
SET expires_at = CURRENT_TIMESTAMP - INTERVAL '1 second'
WHERE id = ?
""",
expiredIntent);
UUID replacementIntent = UUID.randomUUID();
intentService.createLinkIntent(
actor,
replacementIntent,
"github");
assertThatThrownBy(() ->
intentService.getIntent(
actor,
expiredIntent))
.isInstanceOfSatisfying(
IdentityLinkException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
IdentityLinkFailureCode
.INTENT_EXPIRED));
assertThat(jdbcTemplate.queryForObject(
"""
SELECT status
FROM identity_link_request
WHERE id = ?
""",
String.class,
expiredIntent)).isEqualTo("EXPIRED");
assertThatThrownBy(() ->
intentService.getIntent(
actor(
userId,
"another-session-nonce"),
replacementIntent))
.isInstanceOfSatisfying(
IdentityLinkException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
IdentityLinkFailureCode
.SESSION_MISMATCH));
assertThatThrownBy(() ->
intentService.createLinkIntent(
actor,
UUID.randomUUID(),
"github"))
.isInstanceOfSatisfying(
IdentityLinkException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
IdentityLinkFailureCode
.ACTIVE_INTENT_EXISTS));
}
private UUID readyLinkIntent(
IdentityLinkActor actor,
String password) {
UUID intentId = UUID.randomUUID();
intentService.createLinkIntent(
actor,
intentId,
"github");
intentService.reauthenticateLocal(
actor,
intentId,
password);
return intentId;
}
private Object linkOrFailure(
IdentityLinkActor actor,
UUID intentId,
String subject) {
try {
return externalLinkService.link(
actor,
intentId,
githubProvider(),
githubResult(subject));
} catch (IdentityLinkException exception) {
return exception;
}
}
private IdentityLinkActor actor(
String userId,
String nonce) {
return new IdentityLinkActor(
userId,
"local",
nonce,
CONTEXT);
}
private void seedLocalUser(
String userId,
String username) {
jdbcTemplate.update(
"""
INSERT INTO user_account (
id,
display_name,
email,
status,
system_account,
created_at,
updated_at
) VALUES (?, ?, ?, 'ACTIVE', FALSE,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
userId,
username,
username + "@example.com");
insertLocalCredential(userId, username);
}
private void insertLocalCredential(
String userId,
String username) {
jdbcTemplate.update(
"""
INSERT INTO local_credential (
user_id,
username,
password_hash,
failed_attempts,
created_at,
updated_at
) VALUES (?, ?, ?, 0,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
userId,
username,
passwordEncoder.encode(PASSWORD));
}
private long insertLegacyBinding(
String userId,
String providerCode,
String subject,
String loginName) {
Long bindingId = new TransactionTemplate(
transactionManager).execute(status -> {
jdbcTemplate.update(
"""
INSERT INTO identity_binding (
user_id,
provider_code,
subject,
login_name,
status,
created_at,
updated_at
) VALUES (?, ?, ?, ?, 'ACTIVE',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
userId,
providerCode,
subject,
loginName);
Long createdBindingId =
jdbcTemplate.queryForObject(
"""
SELECT id
FROM identity_binding
WHERE user_id = ?
AND provider_code = ?
AND subject = ?
AND status = 'ACTIVE'
""",
Long.class,
userId,
providerCode,
subject);
assertThat(createdBindingId).isNotNull();
jdbcTemplate.update(
"""
INSERT INTO identity_binding_subject (
binding_id,
provider_code,
subject_type,
subject_value,
is_primary,
status,
created_at,
last_seen_at
) VALUES (?, ?, 'legacy_subject', ?, TRUE,
'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
createdBindingId,
providerCode,
subject);
return createdBindingId;
});
if (bindingId == null) {
throw new IllegalStateException(
"Legacy binding transaction returned no id");
}
return bindingId;
}
private ResolvedProviderHandle githubProvider() {
ClientRegistration registration =
registrationRepository.findByRegistrationId(
"github");
assertThat(registration).isNotNull();
return routeResolver.resolve(registration);
}
private ProviderAuthenticationResult githubResult(
String subject) {
return new ProviderAuthenticationResult(
new SubjectCandidate(
"github_user_id",
subject),
List.of(),
Map.of(
"login",
List.of(new ProviderAttributeValue(
"identity-link-test",
ProviderAttributeTrust.ASSERTED)),
"email",
List.of(new ProviderAttributeValue(
subject + "@example.com",
ProviderAttributeTrust.VERIFIED))),
new ProtocolAuthenticationEvidence(
"oauth2-github",
Instant.now(),
Set.of(
"oauth2_authorization_code")));
}
private long count(String sql, Object... arguments) {
Long count = jdbcTemplate.queryForObject(
sql,
Long.class,
arguments);
return count == null ? 0L : count;
}
private static void createSchema(
String url,
String username,
String password) {
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"DROP SCHEMA IF EXISTS "
+ SCHEMA
+ " CASCADE");
statement.execute(
"CREATE SCHEMA " + SCHEMA);
} catch (Exception exception) {
throw new IllegalStateException(
"Failed to prepare identity link test schema",
exception);
}
}
private static String withCurrentSchema(String url) {
String separator = url.contains("?") ? "&" : "?";
return url
+ separator
+ "currentSchema="
+ SCHEMA;
}
private static String requiredEnvironment(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"Missing required environment variable " + name);
}
return value;
}
}

View file

@ -0,0 +1,261 @@
package com.iflytek.skillhub.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.IdentityLinkIntentResponse;
import com.iflytek.skillhub.service.IdentityLinkAppService;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;
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.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class IdentityLinkControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private IdentityLinkAppService appService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void createLinkIntentRequiresAuthenticatedSessionAndCsrf()
throws Exception {
UUID intentId = UUID.randomUUID();
given(appService.createLinkIntent(
eq("github"),
any(),
any()))
.willReturn(new IdentityLinkIntentResponse(
intentId,
IdentityLinkOperation.LINK,
IdentityLinkRequestStatus
.PENDING_REAUTHENTICATION,
"github",
null,
Instant.parse(
"2026-07-31T08:10:00Z")));
mockMvc.perform(
post("/api/v1/auth/identity-link-intents/link")
.with(authentication(currentAuthentication()))
.with(csrf())
.session(session())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"providerCode":"github"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id")
.value(intentId.toString()))
.andExpect(jsonPath("$.data.status")
.value("PENDING_REAUTHENTICATION"));
verify(appService).createLinkIntent(
eq("github"),
any(),
any());
}
@Test
void createLinkIntentWithoutCsrfIsRejected() throws Exception {
mockMvc.perform(
post("/api/v1/auth/identity-link-intents/link")
.with(authentication(currentAuthentication()))
.session(session())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"providerCode":"github"}
"""))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.reasonCode")
.value("REAUTHENTICATION_REQUIRED"));
verify(appService, never()).createLinkIntent(
any(),
any(),
any());
}
@Test
void createLinkIntentWithoutAuthenticationIsRejected()
throws Exception {
mockMvc.perform(
post("/api/v1/auth/identity-link-intents/link")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"providerCode":"github"}
"""))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.reasonCode")
.value("REAUTHENTICATION_REQUIRED"));
verify(appService, never()).createLinkIntent(
any(),
any(),
any());
}
@Test
void invalidProviderCodeIsRejectedBeforeAppService()
throws Exception {
mockMvc.perform(
post("/api/v1/auth/identity-link-intents/link")
.with(authentication(currentAuthentication()))
.with(csrf())
.session(session())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"providerCode":"../../other"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.reasonCode")
.value("INVALID_OPERATION"));
verify(appService, never()).createLinkIntent(
any(),
any(),
any());
}
@Test
void missingBindingIdUsesIdentityLinkErrorContract()
throws Exception {
mockMvc.perform(
post("/api/v1/auth/identity-link-intents/unlink")
.with(authentication(currentAuthentication()))
.with(csrf())
.session(session())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.reasonCode")
.value("INVALID_OPERATION"));
verify(appService, never()).createUnlinkIntent(
anyLong(),
any(),
any());
}
@Test
void localReauthenticationDoesNotCreateANewSession()
throws Exception {
UUID intentId = UUID.randomUUID();
MockHttpSession session = session();
String sessionId = session.getId();
given(appService.reauthenticateLocal(
eq(intentId),
eq("IdentityLinkTest!2026"),
any(),
any()))
.willReturn(new IdentityLinkIntentResponse(
intentId,
IdentityLinkOperation.UNLINK,
IdentityLinkRequestStatus.READY,
"github",
42L,
Instant.parse(
"2026-07-31T08:10:00Z")));
mockMvc.perform(post(
"/api/v1/auth/identity-link-intents/"
+ intentId
+ "/reauthenticate/local")
.with(authentication(currentAuthentication()))
.with(csrf())
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"password":"IdentityLinkTest!2026"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status")
.value("READY"));
org.assertj.core.api.Assertions.assertThat(
session.getId()).isEqualTo(sessionId);
}
@Test
void identityLinkFailureIncludesStableReasonCode()
throws Exception {
UUID intentId = UUID.randomUUID();
given(appService.completeUnlink(
eq(intentId),
any(),
any()))
.willThrow(new IdentityLinkException(
IdentityLinkFailureCode
.FINAL_LOGIN_METHOD));
mockMvc.perform(post(
"/api/v1/auth/identity-link-intents/"
+ intentId
+ "/unlink")
.with(authentication(currentAuthentication()))
.with(csrf())
.session(session()))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(409))
.andExpect(jsonPath("$.reasonCode")
.value("FINAL_LOGIN_METHOD"));
}
private MockHttpSession session() {
MockHttpSession session = new MockHttpSession();
session.setAttribute(
"platformPrincipal",
principal());
return session;
}
private UsernamePasswordAuthenticationToken currentAuthentication() {
return new UsernamePasswordAuthenticationToken(
principal(),
null,
List.of());
}
private PlatformPrincipal principal() {
return new PlatformPrincipal(
"usr_1",
"Alice",
"alice@example.com",
null,
"local",
Set.of("USER"));
}
}

View file

@ -1,7 +1,6 @@
package com.iflytek.skillhub.controller;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
@ -116,9 +115,6 @@ class LocalAuthControllerTest {
@Test
void register_rejectsInvalidEmailFormat() throws Exception {
given(localAuthService.register("bob", "Abcd123!", "not-an-email"))
.willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"));
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.header("Accept-Language", "zh-CN")
@ -129,14 +125,11 @@ class LocalAuthControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(localAuthService).register("bob", "Abcd123!", "not-an-email");
verify(localAuthService, never()).register("bob", "Abcd123!", "not-an-email");
}
@Test
void register_rejectsBlankEmail() throws Exception {
given(localAuthService.register("bob", "Abcd123!", " "))
.willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank"));
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@ -146,7 +139,7 @@ class LocalAuthControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(localAuthService).register("bob", "Abcd123!", " ");
verify(localAuthService, never()).register("bob", "Abcd123!", " ");
}
@Test
@ -277,9 +270,6 @@ class LocalAuthControllerTest {
@Test
void requestPasswordReset_rejectsInvalidEmailFormat() throws Exception {
willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"))
.given(passwordResetService).requestPasswordReset("alice");
mockMvc.perform(post("/api/v1/auth/local/password-reset/request")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@ -289,7 +279,7 @@ class LocalAuthControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(passwordResetService).requestPasswordReset("alice");
verify(passwordResetService, never()).requestPasswordReset("alice");
}
@Test
@ -308,9 +298,6 @@ class LocalAuthControllerTest {
@Test
void confirmPasswordReset_rejectsInvalidEmailFormat() throws Exception {
willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"))
.given(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!");
mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@ -320,7 +307,7 @@ class LocalAuthControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!");
verify(passwordResetService, never()).confirmPasswordReset("alice", "123456", "Abcd123!");
}
}

View file

@ -187,9 +187,6 @@ class NamespaceBatchMemberControllerTest {
@Test
void batchAddMembers_emptyArray_returnsError() throws Exception {
// @NotEmpty on BatchMemberRequest.members triggers validation error
// Spring Boot 3.2+ raises HandlerMethodValidationException (500) rather than
// MethodArgumentNotValidException (400) for record-based @RequestBody validation
mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch")
.with(csrf())
.with(auth("owner-1"))
@ -198,7 +195,7 @@ class NamespaceBatchMemberControllerTest {
.content("""
{"members":[]}
"""))
.andExpect(status().isInternalServerError());
.andExpect(status().isBadRequest());
}
@Test

View file

@ -6,6 +6,8 @@ import static org.mockito.Mockito.when;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.IdentityLinkErrorResponse;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import jakarta.servlet.http.HttpServletRequest;
@ -40,6 +42,10 @@ class GlobalExceptionHandlerTest {
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out");
messageSource.addMessage(
"error.auth.local.invalidCredentials",
java.util.Locale.getDefault(),
"Invalid username or password");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC)
@ -92,4 +98,33 @@ class GlobalExceptionHandlerTest {
assertThatThrownBy(() -> handler.handleSessionInvalidated(ex, request))
.isSameAs(ex);
}
@Test
void handleAuthFailure_shouldUseIdentityLinkReasonCode() {
String path =
"/api/v1/auth/identity-link-intents/test"
+ "/reauthenticate/local";
when(request.getRequestURI()).thenReturn(path);
when(request.getMethod()).thenReturn("POST");
when(sensitiveLogSanitizer
.sanitizeRequestTarget(request))
.thenReturn(path);
ResponseEntity<?> response =
handler.handleAuthFlowException(
new AuthFlowException(
HttpStatus.UNAUTHORIZED,
"error.auth.local.invalidCredentials"),
request);
assertThat(response.getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody())
.isInstanceOf(
IdentityLinkErrorResponse.class);
IdentityLinkErrorResponse body =
(IdentityLinkErrorResponse) response.getBody();
assertThat(body.reasonCode())
.isEqualTo("REAUTHENTICATION_REQUIRED");
}
}

View file

@ -134,4 +134,27 @@ class ApiAccessDeniedHandlerTest {
assertThat(body.path("msg").asText()).isEqualTo("Forbidden");
assertThat(response.getContentAsString()).doesNotContain("internal authorization detail");
}
@Test
void shouldUseStableIdentityLinkReasonForCsrfOrSessionDenial()
throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(
"POST",
"/api/v1/auth/identity-link-intents/link");
MockHttpServletResponse response =
new MockHttpServletResponse();
handler.handle(
request,
response,
new AccessDeniedException("csrf detail"));
JsonNode body = objectMapper.readTree(
response.getContentAsByteArray());
assertThat(response.getStatus()).isEqualTo(403);
assertThat(body.path("reasonCode").asText())
.isEqualTo("SESSION_MISMATCH");
assertThat(response.getContentAsString())
.doesNotContain("csrf detail");
}
}

View file

@ -0,0 +1,94 @@
package com.iflytek.skillhub.security;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Locale;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
class ApiAuthenticationEntryPointTest {
private final ObjectMapper objectMapper =
new ObjectMapper().findAndRegisterModules();
private ApiAuthenticationEntryPoint entryPoint;
@BeforeEach
void setUp() {
ResourceBundleMessageSource messageSource =
new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
entryPoint = new ApiAuthenticationEntryPoint(
objectMapper,
new ApiResponseFactory(
messageSource,
Clock.fixed(
Instant.parse(
"2026-07-31T00:00:00Z"),
ZoneOffset.UTC)),
new SensitiveLogSanitizer());
LocaleContextHolder.setLocale(Locale.ENGLISH);
}
@AfterEach
void tearDown() {
LocaleContextHolder.resetLocaleContext();
}
@Test
void identityLinkRouteUsesStableReauthenticationReason()
throws Exception {
MockHttpServletRequest request =
new MockHttpServletRequest(
"POST",
"/api/v1/auth/identity-link-intents/link");
MockHttpServletResponse response =
new MockHttpServletResponse();
entryPoint.commence(
request,
response,
new AuthenticationCredentialsNotFoundException(
"missing"));
JsonNode body = objectMapper.readTree(
response.getContentAsByteArray());
assertThat(response.getStatus()).isEqualTo(401);
assertThat(body.path("reasonCode").asText())
.isEqualTo("REAUTHENTICATION_REQUIRED");
}
@Test
void unrelatedApiRouteKeepsGenericEnvelope()
throws Exception {
MockHttpServletRequest request =
new MockHttpServletRequest(
"GET",
"/api/v1/skills");
MockHttpServletResponse response =
new MockHttpServletResponse();
entryPoint.commence(
request,
response,
new AuthenticationCredentialsNotFoundException(
"missing"));
JsonNode body = objectMapper.readTree(
response.getContentAsByteArray());
assertThat(response.getStatus()).isEqualTo(401);
assertThat(body.has("reasonCode")).isFalse();
}
}

View file

@ -0,0 +1,26 @@
package com.iflytek.skillhub.auth.config;
import jakarta.servlet.http.HttpServletRequest;
/**
* Identifies the HTTP surface whose failures use the stable Identity Link
* error contract.
*/
public final class IdentityLinkRouteRequestMatcher {
private static final String ACCOUNT_STATE_PATH =
"/api/v1/auth/identity-links";
private static final String INTENT_PATH_PREFIX =
"/api/v1/auth/identity-link-intents";
private IdentityLinkRouteRequestMatcher() {
}
public static boolean matches(HttpServletRequest request) {
String path = request.getRequestURI();
return ACCOUNT_STATE_PATH.equals(path)
|| (path != null
&& (path.equals(INTENT_PATH_PREFIX)
|| path.startsWith(INTENT_PATH_PREFIX + "/")));
}
}

View file

@ -150,7 +150,18 @@ public class SecurityConfig {
.invalidSessionStrategy((request, response) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"code\":401,\"msg\":\"Session expired\"}");
if (IdentityLinkRouteRequestMatcher
.matches(request)) {
response.getWriter().write(
"{\"code\":401,"
+ "\"msg\":\"Session expired\","
+ "\"reasonCode\":"
+ "\"REAUTHENTICATION_REQUIRED\"}");
} else {
response.getWriter().write(
"{\"code\":401,"
+ "\"msg\":\"Session expired\"}");
}
})
)
.exceptionHandling(exceptions -> exceptions
@ -169,7 +180,8 @@ public class SecurityConfig {
.addFilterBefore(
new IdentityProviderRouteReadinessFilter(
clientRegistrationRepository,
providerReadinessService),
providerReadinessService,
failureHandler),
OAuth2AuthorizationRequestRedirectFilter.class)
.addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(apiTokenScopeFilter, ApiTokenAuthenticationFilter.class);

View file

@ -16,11 +16,9 @@ import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(name = "identity_binding",
uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"}))
@Table(name = "identity_binding")
public class IdentityBinding {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -123,4 +121,32 @@ public class IdentityBinding {
lastSynchronizedAt = synchronizedAt;
}
}
public void revoke(
String actorUserId,
String reason,
Instant revokedAt) {
if (status != IdentityBindingStatus.ACTIVE) {
throw new IllegalStateException(
"Only an active identity binding can be revoked");
}
if (actorUserId == null
|| actorUserId.isBlank()
|| actorUserId.length() > 128) {
throw new IllegalArgumentException(
"Invalid identity binding revocation actor");
}
if (reason == null
|| reason.isBlank()
|| reason.length() > 256) {
throw new IllegalArgumentException(
"Invalid identity binding revocation reason");
}
status = IdentityBindingStatus.REVOKED;
this.revokedAt = java.util.Objects.requireNonNull(
revokedAt,
"revokedAt");
revokedBy = actorUserId;
revocationReason = reason;
}
}

View file

@ -101,6 +101,15 @@ public class IdentityBindingSubject {
}
}
public void revoke(Instant revokedAt) {
requireActive();
primary = false;
status = IdentityBindingSubjectStatus.REVOKED;
this.revokedAt = java.util.Objects.requireNonNull(
revokedAt,
"revokedAt");
}
private void requireActive() {
if (status != IdentityBindingSubjectStatus.ACTIVE) {
throw new IllegalStateException(

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.auth.entity;
public enum IdentityLinkOperation {
LINK,
UNLINK
}

View file

@ -0,0 +1,232 @@
package com.iflytek.skillhub.auth.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern;
@Entity
@Table(name = "identity_link_request")
public class IdentityLinkRequest {
private static final Pattern STATE_HASH_PATTERN =
Pattern.compile("[0-9a-f]{64}");
@Id
private UUID id;
@Column(name = "primary_user_id", nullable = false, length = 128)
private String primaryUserId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private IdentityLinkOperation operation;
@Column(name = "provider_code", nullable = false, length = 64)
private String providerCode;
@Column(name = "target_binding_id")
private Long targetBindingId;
@Column(name = "state_hash", nullable = false, length = 64)
private String stateHash;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private IdentityLinkRequestStatus status;
@Column(name = "reauthentication_method", length = 96)
private String reauthenticationMethod;
@Column(name = "reauthenticated_at")
private Instant reauthenticatedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "cancelled_at")
private Instant cancelledAt;
@Version
@Column(name = "row_version", nullable = false)
private long rowVersion;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected IdentityLinkRequest() {
}
public IdentityLinkRequest(
UUID id,
String primaryUserId,
IdentityLinkOperation operation,
String providerCode,
Long targetBindingId,
String stateHash,
Instant expiresAt,
Instant createdAt) {
this.id = Objects.requireNonNull(id, "id");
this.primaryUserId = requireText(primaryUserId, "primaryUserId", 128);
this.operation = Objects.requireNonNull(operation, "operation");
this.providerCode = requireText(providerCode, "providerCode", 64);
this.targetBindingId = targetBindingId;
this.stateHash = requireStateHash(stateHash);
this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt");
this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
if (!expiresAt.isAfter(createdAt)) {
throw new IllegalArgumentException(
"Identity link request expiry must be in the future");
}
if ((operation == IdentityLinkOperation.LINK && targetBindingId != null)
|| (operation == IdentityLinkOperation.UNLINK
&& targetBindingId == null)) {
throw new IllegalArgumentException(
"Identity link request target does not match operation");
}
this.status = IdentityLinkRequestStatus.PENDING_REAUTHENTICATION;
this.updatedAt = createdAt;
}
public UUID getId() {
return id;
}
public String getPrimaryUserId() {
return primaryUserId;
}
public IdentityLinkOperation getOperation() {
return operation;
}
public String getProviderCode() {
return providerCode;
}
public Long getTargetBindingId() {
return targetBindingId;
}
public String getStateHash() {
return stateHash;
}
public IdentityLinkRequestStatus getStatus() {
return status;
}
public String getReauthenticationMethod() {
return reauthenticationMethod;
}
public Instant getReauthenticatedAt() {
return reauthenticatedAt;
}
public Instant getExpiresAt() {
return expiresAt;
}
public Instant getCompletedAt() {
return completedAt;
}
public Instant getCancelledAt() {
return cancelledAt;
}
public long getRowVersion() {
return rowVersion;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public boolean isExpiredAt(Instant now) {
return !now.isBefore(expiresAt);
}
public void markReauthenticated(String method, Instant now) {
requireStatus(IdentityLinkRequestStatus.PENDING_REAUTHENTICATION);
reauthenticationMethod = requireText(
method,
"reauthenticationMethod",
96);
reauthenticatedAt = Objects.requireNonNull(now, "now");
status = IdentityLinkRequestStatus.READY;
updatedAt = now;
}
public void complete(Instant now) {
requireStatus(IdentityLinkRequestStatus.READY);
completedAt = Objects.requireNonNull(now, "now");
status = IdentityLinkRequestStatus.COMPLETED;
updatedAt = now;
}
public void expire(Instant now) {
if (!status.isActive()) {
throw new IllegalStateException(
"Only an active identity link request can expire");
}
status = IdentityLinkRequestStatus.EXPIRED;
updatedAt = Objects.requireNonNull(now, "now");
}
public void cancel(Instant now) {
if (!status.isActive()) {
throw new IllegalStateException(
"Only an active identity link request can be cancelled");
}
cancelledAt = Objects.requireNonNull(now, "now");
status = IdentityLinkRequestStatus.CANCELLED;
updatedAt = now;
}
private void requireStatus(IdentityLinkRequestStatus required) {
if (status != required) {
throw new IllegalStateException(
"Identity link request is not in state " + required);
}
}
private static String requireStateHash(String value) {
if (value == null || !STATE_HASH_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException(
"Invalid identity link state hash");
}
return value;
}
private static String requireText(
String value,
String fieldName,
int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength) {
throw new IllegalArgumentException(
"Invalid identity link " + fieldName);
}
return value;
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.auth.entity;
public enum IdentityLinkRequestStatus {
PENDING_REAUTHENTICATION,
READY,
COMPLETED,
EXPIRED,
CANCELLED;
public boolean isActive() {
return this == PENDING_REAUTHENTICATION || this == READY;
}
}

View file

@ -0,0 +1,132 @@
package com.iflytek.skillhub.auth.identity;
import java.sql.SQLException;
import java.util.Objects;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
/**
* Core-owned Identity Link facade. Protocol adapters can only provide verified
* external facts; trusted provider identity and assertion construction remain
* inside the identity core.
*/
@Service
class DefaultExternalIdentityLinkService
implements ExternalIdentityLinkService {
private static final Logger log = LoggerFactory.getLogger(
DefaultExternalIdentityLinkService.class);
private final TrustedProviderDescriptorSource descriptorSource;
private final ProviderAuthorityLockService authorityLockService;
private final IdentityAssertionFactory assertionFactory;
private final IdentityLinkTransaction transaction;
DefaultExternalIdentityLinkService(
TrustedProviderDescriptorSource descriptorSource,
ProviderAuthorityLockService authorityLockService,
IdentityAssertionFactory assertionFactory,
IdentityLinkTransaction transaction) {
this.descriptorSource = descriptorSource;
this.authorityLockService = authorityLockService;
this.assertionFactory = assertionFactory;
this.transaction = transaction;
}
@Override
public IdentityLinkOutcome reauthenticate(
IdentityLinkActor actor,
UUID intentId,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result) {
PreparedAssertion prepared = prepare(provider, result);
return new IdentityLinkOutcome.Reauthenticated(
transaction.markExternalReauthenticated(
actor,
intentId,
prepared.assertion(),
prepared.descriptor()));
}
@Override
public IdentityLinkOutcome link(
IdentityLinkActor actor,
UUID intentId,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result) {
PreparedAssertion prepared = prepare(provider, result);
try {
IdentityLinkTransaction.LinkedBinding linked =
transaction.link(
actor,
intentId,
prepared.assertion(),
prepared.descriptor());
return new IdentityLinkOutcome.Linked(
linked.principal(),
linked.bindingId());
} catch (DataIntegrityViolationException exception) {
if (isUniqueConstraintViolation(exception)) {
recordIdentityInUseDenial(actor, intentId);
throw new IdentityLinkException(
IdentityLinkFailureCode.IDENTITY_IN_USE,
exception);
}
throw exception;
}
}
private void recordIdentityInUseDenial(
IdentityLinkActor actor,
UUID intentId) {
try {
transaction.recordRejectedAfterRollback(
actor,
intentId,
IdentityLinkFailureCode.IDENTITY_IN_USE);
} catch (RuntimeException auditFailure) {
log.error(
"Identity Link denial audit failed for intent '{}' and reason '{}'",
intentId,
IdentityLinkFailureCode.IDENTITY_IN_USE,
auditFailure);
}
}
private PreparedAssertion prepare(
ResolvedProviderHandle provider,
ProviderAuthenticationResult result) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(result, "result");
ProviderDescriptor descriptor =
descriptorSource.require(provider);
authorityLockService.requirePinnedAuthority(descriptor);
return new PreparedAssertion(
descriptor,
assertionFactory.create(descriptor, result));
}
private boolean isUniqueConstraintViolation(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof SQLException sqlException
&& "23505".equals(sqlException.getSQLState())) {
return true;
}
Throwable cause = current.getCause();
if (cause == current) {
break;
}
current = cause;
}
return false;
}
private record PreparedAssertion(
ProviderDescriptor descriptor,
IdentityAssertion assertion) {
}
}

View file

@ -0,0 +1,22 @@
package com.iflytek.skillhub.auth.identity;
import java.util.UUID;
/**
* Unified facade for external fresh reauthentication and explicit Identity
* Link. Protocol adapters provide verified external facts only.
*/
public interface ExternalIdentityLinkService {
IdentityLinkOutcome reauthenticate(
IdentityLinkActor actor,
UUID intentId,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result);
IdentityLinkOutcome link(
IdentityLinkActor actor,
UUID intentId,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result);
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.auth.identity;
import java.util.List;
public record IdentityLinkAccountState(
boolean localPasswordEnabled,
List<IdentityLinkBindingView> linkedProviders,
List<IdentityLinkProviderView> availableProviders
) {
public IdentityLinkAccountState {
linkedProviders = List.copyOf(linkedProviders);
availableProviders = List.copyOf(availableProviders);
}
}

View file

@ -0,0 +1,73 @@
package com.iflytek.skillhub.auth.identity;
import java.util.Objects;
/**
* Server-owned account and session proof used by Identity Link workflows.
*
* <p>The raw session nonce is intentionally omitted from {@link #toString()}.
*/
public final class IdentityLinkActor {
private final String userId;
private final String authenticationProvider;
private final String sessionNonce;
private final IdentityLoginContext auditContext;
public IdentityLinkActor(
String userId,
String authenticationProvider,
String sessionNonce,
IdentityLoginContext auditContext) {
this.userId = requireText(userId, "userId", 128);
this.authenticationProvider = requireText(
authenticationProvider,
"authenticationProvider",
64);
this.sessionNonce = requireText(
sessionNonce,
"sessionNonce",
256);
this.auditContext = Objects.requireNonNull(
auditContext,
"auditContext");
}
public String userId() {
return userId;
}
String authenticationProvider() {
return authenticationProvider;
}
String sessionNonce() {
return sessionNonce;
}
public IdentityLoginContext auditContext() {
return auditContext;
}
@Override
public String toString() {
return "IdentityLinkActor[userId="
+ userId
+ ", authenticationProvider="
+ authenticationProvider
+ "]";
}
private static String requireText(
String value,
String fieldName,
int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength) {
throw new IllegalArgumentException(
"Invalid identity link actor " + fieldName);
}
return value;
}
}

View file

@ -0,0 +1,29 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
import java.util.Set;
public record IdentityLinkBindingView(
long bindingId,
String providerCode,
String displayName,
Set<IdentityProviderLoginMethodType> methodTypes,
boolean usable,
boolean canUnlink
) {
public IdentityLinkBindingView {
if (bindingId <= 0) {
throw new IllegalArgumentException(
"Identity binding id must be positive");
}
if (providerCode == null || providerCode.isBlank()) {
throw new IllegalArgumentException(
"Identity provider code is required");
}
if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException(
"Identity provider display name is required");
}
methodTypes = Set.copyOf(methodTypes);
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.auth.identity;
import java.util.UUID;
public record IdentityLinkBrowserFlow(
UUID intentId,
IdentityLinkBrowserPhase phase,
IdentityLinkActor actor
) {
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.auth.identity;
public enum IdentityLinkBrowserPhase {
REAUTHENTICATE,
LINK
}

View file

@ -0,0 +1,26 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
public final class IdentityLinkException extends AuthFlowException {
private final IdentityLinkFailureCode reasonCode;
public IdentityLinkException(IdentityLinkFailureCode reasonCode) {
this(reasonCode, null);
}
public IdentityLinkException(
IdentityLinkFailureCode reasonCode,
Throwable cause) {
super(reasonCode.status(), reasonCode.messageCode());
this.reasonCode = reasonCode;
if (cause != null) {
initCause(cause);
}
}
public IdentityLinkFailureCode getReasonCode() {
return reasonCode;
}
}

View file

@ -0,0 +1,63 @@
package com.iflytek.skillhub.auth.identity;
import org.springframework.http.HttpStatus;
public enum IdentityLinkFailureCode {
INTENT_NOT_FOUND(
HttpStatus.NOT_FOUND,
"error.auth.identityLink.intentNotFound"),
REAUTHENTICATION_REQUIRED(
HttpStatus.UNAUTHORIZED,
"error.auth.identityLink.reauthenticationRequired"),
SESSION_MISMATCH(
HttpStatus.FORBIDDEN,
"error.auth.identityLink.sessionMismatch"),
INTENT_EXPIRED(
HttpStatus.GONE,
"error.auth.identityLink.intentExpired"),
ALREADY_CONSUMED(
HttpStatus.CONFLICT,
"error.auth.identityLink.alreadyConsumed"),
ACTIVE_INTENT_EXISTS(
HttpStatus.CONFLICT,
"error.auth.identityLink.activeIntentExists"),
ACCOUNT_NOT_ELIGIBLE(
HttpStatus.CONFLICT,
"error.auth.identityLink.accountNotEligible"),
PROVIDER_UNAVAILABLE(
HttpStatus.SERVICE_UNAVAILABLE,
"error.auth.identityLink.providerUnavailable"),
PROVIDER_AUTHENTICATION_FAILED(
HttpStatus.UNAUTHORIZED,
"error.auth.identityLink.providerAuthenticationFailed"),
ALREADY_LINKED(
HttpStatus.CONFLICT,
"error.auth.identityLink.alreadyLinked"),
IDENTITY_IN_USE(
HttpStatus.CONFLICT,
"error.auth.identityLink.identityInUse"),
FINAL_LOGIN_METHOD(
HttpStatus.CONFLICT,
"error.auth.identityLink.finalLoginMethod"),
INVALID_OPERATION(
HttpStatus.BAD_REQUEST,
"error.auth.identityLink.invalidOperation");
private final HttpStatus status;
private final String messageCode;
IdentityLinkFailureCode(
HttpStatus status,
String messageCode) {
this.status = status;
this.messageCode = messageCode;
}
public HttpStatus status() {
return status;
}
public String messageCode() {
return messageCode;
}
}

View file

@ -0,0 +1,24 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
public record IdentityLinkIntent(
UUID id,
IdentityLinkOperation operation,
IdentityLinkRequestStatus status,
String providerCode,
Long targetBindingId,
Instant expiresAt
) {
public IdentityLinkIntent {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(status, "status");
Objects.requireNonNull(providerCode, "providerCode");
Objects.requireNonNull(expiresAt, "expiresAt");
}
}

View file

@ -0,0 +1,152 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import java.sql.SQLException;
import java.util.Objects;
import java.util.UUID;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
/**
* Public workflow facade for creating, inspecting, reauthenticating, and
* consuming Identity Link intents.
*
* <p>Raw session nonces are supplied by the HTTP session boundary and are
* never persisted by this service.
*/
@Service
public class IdentityLinkIntentService {
private final IdentityLinkTransaction transaction;
private final LocalAuthService localAuthService;
IdentityLinkIntentService(
IdentityLinkTransaction transaction,
LocalAuthService localAuthService) {
this.transaction = transaction;
this.localAuthService = localAuthService;
}
public IdentityLinkIntent createLinkIntent(
IdentityLinkActor actor,
UUID intentId,
String providerCode) {
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(intentId, "intentId");
try {
return transaction.createLinkIntent(
actor,
intentId,
providerCode);
} catch (DataIntegrityViolationException exception) {
if (isUniqueConstraintViolation(exception)) {
throw new IdentityLinkException(
IdentityLinkFailureCode.ACTIVE_INTENT_EXISTS,
exception);
}
throw exception;
}
}
public IdentityLinkIntent createUnlinkIntent(
IdentityLinkActor actor,
UUID intentId,
long bindingId) {
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(intentId, "intentId");
try {
return transaction.createUnlinkIntent(
actor,
intentId,
bindingId);
} catch (DataIntegrityViolationException exception) {
if (isUniqueConstraintViolation(exception)) {
throw new IdentityLinkException(
IdentityLinkFailureCode.ACTIVE_INTENT_EXISTS,
exception);
}
throw exception;
}
}
public IdentityLinkIntent getIntent(
IdentityLinkActor actor,
UUID intentId) {
return transaction.getIntent(actor, intentId);
}
public IdentityLinkIntent cancel(
IdentityLinkActor actor,
UUID intentId) {
return transaction.cancel(actor, intentId);
}
public IdentityLinkIntent reauthenticateLocal(
IdentityLinkActor actor,
UUID intentId,
String password) {
IdentityLinkIntent intent = transaction.getIntent(
actor,
intentId);
if (intent.status()
!= IdentityLinkRequestStatus.PENDING_REAUTHENTICATION) {
throw new IdentityLinkException(
IdentityLinkFailureCode.ALREADY_CONSUMED);
}
localAuthService.reauthenticate(
actor.userId(),
password);
return transaction.markLocalReauthenticated(
actor,
intentId);
}
public IdentityLinkIntent prepareExternalReauthentication(
IdentityLinkActor actor,
UUID intentId,
String providerCode,
IdentityProviderLoginMethodType methodType) {
return transaction.prepareExternalReauthentication(
actor,
intentId,
providerCode,
methodType);
}
public IdentityLinkIntent prepareExternalLink(
IdentityLinkActor actor,
UUID intentId,
IdentityProviderLoginMethodType methodType) {
return transaction.prepareExternalLink(
actor,
intentId,
methodType);
}
public IdentityLinkIntent completeUnlink(
IdentityLinkActor actor,
UUID intentId) {
return transaction.completeUnlink(actor, intentId);
}
public IdentityLinkAccountState accountState(String userId) {
return transaction.accountState(userId);
}
private boolean isUniqueConstraintViolation(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof SQLException sqlException
&& "23505".equals(sqlException.getSQLState())) {
return true;
}
Throwable cause = current.getCause();
if (cause == current) {
break;
}
current = cause;
}
return false;
}
}

View file

@ -0,0 +1,28 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import java.util.Objects;
public sealed interface IdentityLinkOutcome {
record Reauthenticated(
PlatformPrincipal principal
) implements IdentityLinkOutcome {
public Reauthenticated {
Objects.requireNonNull(principal, "principal");
}
}
record Linked(
PlatformPrincipal principal,
long bindingId
) implements IdentityLinkOutcome {
public Linked {
Objects.requireNonNull(principal, "principal");
if (bindingId <= 0) {
throw new IllegalArgumentException(
"Identity binding id must be positive");
}
}
}
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.auth.identity;
import java.util.Set;
public record IdentityLinkProviderView(
String providerCode,
String displayName,
Set<IdentityProviderLoginMethodType> methodTypes
) {
public IdentityLinkProviderView {
if (providerCode == null || providerCode.isBlank()) {
throw new IllegalArgumentException(
"Identity provider code is required");
}
if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException(
"Identity provider display name is required");
}
methodTypes = Set.copyOf(methodTypes);
}
}

View file

@ -0,0 +1,286 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.io.Serial;
import java.io.Serializable;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Owns the raw, high-entropy session state used by Identity Link workflows.
*
* <p>Only a SHA-256 digest is stored outside the session. Raw nonces and OAuth
* state values are never returned by API DTOs or written to the database.
*/
@Component
public class IdentityLinkSessionManager {
private static final String NONCE_ATTRIBUTE_PREFIX =
"skillhub.identityLink.nonce.";
private static final String PENDING_BROWSER_FLOW_ATTRIBUTE =
"skillhub.identityLink.browser.pending";
private static final String ACTIVE_BROWSER_FLOW_ATTRIBUTE =
"skillhub.identityLink.browser.active";
private static final Duration BROWSER_FLOW_TTL =
Duration.ofMinutes(5);
private final SecureRandom secureRandom;
private final IdentityLinkStateHasher stateHasher;
private final Clock clock;
@Autowired
public IdentityLinkSessionManager(
IdentityLinkStateHasher stateHasher,
Clock clock) {
this(
new SecureRandom(),
stateHasher,
clock);
}
IdentityLinkSessionManager(
SecureRandom secureRandom,
IdentityLinkStateHasher stateHasher,
Clock clock) {
this.secureRandom = secureRandom;
this.stateHasher = stateHasher;
this.clock = clock;
}
public IdentityLinkActor start(
HttpSession session,
UUID intentId,
IdentityLoginContext context) {
Objects.requireNonNull(session, "session");
Objects.requireNonNull(intentId, "intentId");
byte[] nonceBytes = new byte[32];
secureRandom.nextBytes(nonceBytes);
String nonce = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(nonceBytes);
session.setAttribute(
nonceAttribute(intentId),
nonce);
return actor(session, intentId, context);
}
public IdentityLinkActor actor(
HttpSession session,
UUID intentId,
IdentityLoginContext context) {
Objects.requireNonNull(session, "session");
Objects.requireNonNull(intentId, "intentId");
Object principalValue =
session.getAttribute("platformPrincipal");
if (!(principalValue instanceof PlatformPrincipal principal)) {
throw new IdentityLinkException(
IdentityLinkFailureCode.SESSION_MISMATCH);
}
Object nonceValue = session.getAttribute(
nonceAttribute(intentId));
if (!(nonceValue instanceof String nonce)
|| nonce.isBlank()) {
throw new IdentityLinkException(
IdentityLinkFailureCode.SESSION_MISMATCH);
}
String authenticationProvider =
principal.oauthProvider() == null
|| principal.oauthProvider().isBlank()
? "session"
: principal.oauthProvider();
return new IdentityLinkActor(
principal.userId(),
authenticationProvider,
nonce,
context);
}
public void remove(HttpSession session, UUID intentId) {
if (session == null || intentId == null) {
return;
}
session.removeAttribute(nonceAttribute(intentId));
clearBrowserFlowForIntent(session, intentId);
}
public void prepareBrowserFlow(
HttpSession session,
UUID intentId,
IdentityLinkBrowserPhase phase,
String providerCode,
IdentityLoginContext context) {
actor(session, intentId, context);
PendingBrowserFlow pending = new PendingBrowserFlow(
intentId,
Objects.requireNonNull(phase, "phase"),
requireProviderCode(providerCode),
now().plus(BROWSER_FLOW_TTL));
session.setAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE,
pending);
session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
}
/**
* Binds a prepared link flow to the OAuth authorization request generated
* by Spring Security. The raw OAuth state remains in Spring Security's
* authorization request repository; only its digest is copied here.
*/
public void activateBrowserFlow(
HttpSession session,
String providerCode,
String oauthState) {
if (session == null) {
return;
}
Object value = session.getAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE);
session.removeAttribute(PENDING_BROWSER_FLOW_ATTRIBUTE);
if (!(value instanceof PendingBrowserFlow pending)
|| pending.expiresAt().isBefore(now())
|| !pending.providerCode().equals(providerCode)
|| oauthState == null
|| oauthState.isBlank()) {
return;
}
session.setAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE,
new ActiveBrowserFlow(
pending.intentId(),
pending.phase(),
pending.providerCode(),
stateHasher.hash(oauthState),
pending.expiresAt()));
}
public Optional<IdentityLinkBrowserFlow> consumeBrowserFlow(
HttpServletRequest request,
String providerCode,
IdentityLoginContext context) {
HttpSession session = request.getSession(false);
if (session == null) {
return Optional.empty();
}
Object value = session.getAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE);
if (!(value instanceof ActiveBrowserFlow active)) {
return Optional.empty();
}
session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
String callbackState = request.getParameter("state");
if (active.expiresAt().isBefore(now())
|| !active.providerCode().equals(providerCode)
|| !stateHasher.matches(
callbackState,
active.oauthStateHash())) {
throw new IdentityLinkException(
IdentityLinkFailureCode.SESSION_MISMATCH);
}
return Optional.of(new IdentityLinkBrowserFlow(
active.intentId(),
active.phase(),
actor(
session,
active.intentId(),
context)));
}
public void clearBrowserFlow(HttpSession session) {
if (session == null) {
return;
}
session.removeAttribute(PENDING_BROWSER_FLOW_ATTRIBUTE);
session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
}
/**
* Clears a failed browser flow while retaining the session-bound intent
* nonce so the account-security UI can safely resume or cancel it.
*/
public Optional<UUID> consumeFailedBrowserFlow(
HttpSession session) {
if (session == null) {
return Optional.empty();
}
Object active = session.getAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE);
Object pending = session.getAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE);
clearBrowserFlow(session);
if (active instanceof ActiveBrowserFlow flow) {
return Optional.of(flow.intentId());
}
if (pending instanceof PendingBrowserFlow flow) {
return Optional.of(flow.intentId());
}
return Optional.empty();
}
private void clearBrowserFlowForIntent(
HttpSession session,
UUID intentId) {
Object pending = session.getAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE);
if (pending instanceof PendingBrowserFlow flow
&& flow.intentId().equals(intentId)) {
session.removeAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE);
}
Object active = session.getAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE);
if (active instanceof ActiveBrowserFlow flow
&& flow.intentId().equals(intentId)) {
session.removeAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE);
}
}
private String nonceAttribute(UUID intentId) {
return NONCE_ATTRIBUTE_PREFIX + intentId;
}
private String requireProviderCode(String providerCode) {
if (providerCode == null
|| providerCode.isBlank()
|| providerCode.length() > 64) {
throw new IdentityLinkException(
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
}
return providerCode;
}
private Instant now() {
return Instant.now(clock);
}
private record PendingBrowserFlow(
UUID intentId,
IdentityLinkBrowserPhase phase,
String providerCode,
Instant expiresAt
) implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
}
private record ActiveBrowserFlow(
UUID intentId,
IdentityLinkBrowserPhase phase,
String providerCode,
String oauthStateHash,
Instant expiresAt
) implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
}
}

View file

@ -0,0 +1,44 @@
package com.iflytek.skillhub.auth.identity;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import org.springframework.stereotype.Component;
@Component
final class IdentityLinkStateHasher {
String hash(String rawState) {
if (rawState == null || rawState.isBlank()) {
throw new IllegalArgumentException(
"Identity link state must not be blank");
}
return HexFormat.of().formatHex(
sha256(rawState.getBytes(StandardCharsets.UTF_8)));
}
boolean matches(String rawState, String expectedHash) {
if (rawState == null || expectedHash == null) {
return false;
}
byte[] actual = sha256(rawState.getBytes(StandardCharsets.UTF_8));
byte[] expected;
try {
expected = HexFormat.of().parseHex(expectedHash);
} catch (IllegalArgumentException exception) {
return false;
}
return MessageDigest.isEqual(actual, expected);
}
private byte[] sha256(byte[] value) {
try {
return MessageDigest.getInstance("SHA-256").digest(value);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(
"SHA-256 is unavailable",
exception);
}
}
}

View file

@ -0,0 +1,917 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.entity.IdentityBindingStatus;
import com.iflytek.skillhub.auth.entity.IdentityBindingSubject;
import com.iflytek.skillhub.auth.entity.IdentityBindingSubjectStatus;
import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequest;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.IdentityBindingSubjectRepository;
import com.iflytek.skillhub.auth.repository.IdentityLinkRequestRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* Short PostgreSQL transaction for Identity Link request state, Binding V2
* creation, and safe revocation. Protocol I/O and credential verification run
* before these methods are invoked.
*/
@Service
class IdentityLinkTransaction {
static final Duration INTENT_TTL = Duration.ofMinutes(10);
private static final String USER_UNLINK_REASON =
"User removed linked login method";
private final IdentityLinkRequestRepository requestRepository;
private final IdentityBindingRepository bindingRepository;
private final IdentityBindingSubjectRepository subjectRepository;
private final LocalCredentialRepository credentialRepository;
private final UserAccountRepository userRepository;
private final IdentityProviderRegistry providerRegistry;
private final IdentityLinkStateHasher stateHasher;
private final AccountLoginGuard accountLoginGuard;
private final PlatformPrincipalFactory principalFactory;
private final AuditLogService auditLogService;
private final Clock clock;
IdentityLinkTransaction(
IdentityLinkRequestRepository requestRepository,
IdentityBindingRepository bindingRepository,
IdentityBindingSubjectRepository subjectRepository,
LocalCredentialRepository credentialRepository,
UserAccountRepository userRepository,
IdentityProviderRegistry providerRegistry,
IdentityLinkStateHasher stateHasher,
AccountLoginGuard accountLoginGuard,
PlatformPrincipalFactory principalFactory,
AuditLogService auditLogService,
Clock clock) {
this.requestRepository = requestRepository;
this.bindingRepository = bindingRepository;
this.subjectRepository = subjectRepository;
this.credentialRepository = credentialRepository;
this.userRepository = userRepository;
this.providerRegistry = providerRegistry;
this.stateHasher = stateHasher;
this.accountLoginGuard = accountLoginGuard;
this.principalFactory = principalFactory;
this.auditLogService = auditLogService;
this.clock = clock;
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public IdentityLinkIntent createLinkIntent(
IdentityLinkActor actor,
UUID intentId,
String providerCode) {
Instant now = now();
requireEligibleAccount(actor.userId());
requireReadyLinkProvider(providerCode);
boolean alreadyLinked = bindingRepository
.findByUserIdAndStatus(
actor.userId(),
IdentityBindingStatus.ACTIVE)
.stream()
.anyMatch(binding -> binding.getProviderCode()
.equals(providerCode));
if (alreadyLinked) {
throw failure(IdentityLinkFailureCode.ALREADY_LINKED);
}
requireNoActiveRequest(actor, now);
IdentityLinkRequest request = new IdentityLinkRequest(
intentId,
actor.userId(),
IdentityLinkOperation.LINK,
providerCode,
null,
stateHasher.hash(actor.sessionNonce()),
now.plus(INTENT_TTL),
now);
requestRepository.saveAndFlush(request);
recordAudit(
actor,
"IDENTITY_LINK_INTENT_CREATED",
request,
"pending_reauthentication");
return toIntent(request);
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public IdentityLinkIntent createUnlinkIntent(
IdentityLinkActor actor,
UUID intentId,
long bindingId) {
Instant now = now();
requireEligibleAccount(actor.userId());
requireNoActiveRequest(actor, now);
IdentityBinding binding = bindingRepository
.findByIdAndStatusForUpdate(
bindingId,
IdentityBindingStatus.ACTIVE)
.filter(candidate -> candidate.getUserId()
.equals(actor.userId()))
.orElseThrow(() ->
failure(IdentityLinkFailureCode.INTENT_NOT_FOUND));
IdentityLinkRequest request = new IdentityLinkRequest(
intentId,
actor.userId(),
IdentityLinkOperation.UNLINK,
binding.getProviderCode(),
binding.getId(),
stateHasher.hash(actor.sessionNonce()),
now.plus(INTENT_TTL),
now);
requestRepository.saveAndFlush(request);
recordAudit(
actor,
"IDENTITY_UNLINK_INTENT_CREATED",
request,
"pending_reauthentication");
return toIntent(request);
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public IdentityLinkIntent getIntent(
IdentityLinkActor actor,
UUID intentId) {
IdentityLinkRequest request = requireRequest(
actor,
intentId);
requireActive(request, actor);
return toIntent(request);
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public IdentityLinkIntent cancel(
IdentityLinkActor actor,
UUID intentId) {
IdentityLinkRequest request = requireRequest(
actor,
intentId);
requireActive(request, actor);
request.cancel(now());
recordAudit(
actor,
"IDENTITY_LINK_INTENT_CANCELLED",
request,
"cancelled");
return toIntent(request);
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public IdentityLinkIntent markLocalReauthenticated(
IdentityLinkActor actor,
UUID intentId) {
IdentityLinkRequest request = requireRequest(
actor,
intentId);
requirePendingReauthentication(request, actor);
requireEligibleAccount(actor.userId());
request.markReauthenticated("local-password", now());
recordAudit(
actor,
"IDENTITY_LINK_ACCOUNT_REAUTHENTICATED",
request,
"local-password");
return toIntent(request);
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public IdentityLinkIntent prepareExternalReauthentication(
IdentityLinkActor actor,
UUID intentId,
String providerCode,
IdentityProviderLoginMethodType methodType) {
IdentityLinkRequest request = requireRequest(
actor,
intentId);
requirePendingReauthentication(request, actor);
requireEligibleAccount(actor.userId());
requireProviderCapability(
actor,
request,
providerCode,
methodType);
boolean linked = bindingRepository
.findByUserIdAndStatus(
actor.userId(),
IdentityBindingStatus.ACTIVE)
.stream()
.anyMatch(binding -> binding.getProviderCode()
.equals(providerCode));
if (!linked) {
throw reject(
actor,
request,
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
}
return toIntent(request);
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public IdentityLinkIntent prepareExternalLink(
IdentityLinkActor actor,
UUID intentId,
IdentityProviderLoginMethodType methodType) {
IdentityLinkRequest request = requireRequest(
actor,
intentId);
requireReady(
request,
actor,
IdentityLinkOperation.LINK);
requireEligibleAccount(actor.userId());
requireProviderCapability(
actor,
request,
request.getProviderCode(),
methodType);
boolean alreadyLinked = bindingRepository
.findByUserIdAndStatus(
actor.userId(),
IdentityBindingStatus.ACTIVE)
.stream()
.anyMatch(binding -> binding.getProviderCode()
.equals(request.getProviderCode()));
if (alreadyLinked) {
throw reject(
actor,
request,
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
}
return toIntent(request);
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public PlatformPrincipal markExternalReauthenticated(
IdentityLinkActor actor,
UUID intentId,
IdentityAssertion assertion,
ProviderDescriptor descriptor) {
IdentityLinkRequest request = requireRequest(
actor,
intentId);
requirePendingReauthentication(request, actor);
UserAccount user = requireEligibleAccount(actor.userId());
IdentityBinding binding = resolveAuthenticatedBinding(
assertion,
descriptor);
if (!binding.getUserId().equals(actor.userId())) {
throw reject(
actor,
request,
IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE);
}
request.markReauthenticated(
"provider:" + assertion.provider().providerCode(),
now());
recordAudit(
actor,
"IDENTITY_LINK_ACCOUNT_REAUTHENTICATED",
request,
assertion.provider().providerCode());
return principalFactory.create(
user,
actor.authenticationProvider());
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public LinkedBinding link(
IdentityLinkActor actor,
UUID intentId,
IdentityAssertion assertion,
ProviderDescriptor descriptor) {
IdentityLinkRequest request = requireRequest(
actor,
intentId);
requireReady(request, actor, IdentityLinkOperation.LINK);
if (!request.getProviderCode().equals(
assertion.provider().providerCode())
|| !request.getProviderCode().equals(
descriptor.providerCode())) {
throw reject(
actor,
request,
IdentityLinkFailureCode.INVALID_OPERATION);
}
UserAccount user = requireEligibleAccount(actor.userId());
boolean alreadyLinked = bindingRepository
.findByUserIdAndStatus(
actor.userId(),
IdentityBindingStatus.ACTIVE)
.stream()
.anyMatch(binding -> binding.getProviderCode()
.equals(descriptor.providerCode()));
if (alreadyLinked) {
throw reject(
actor,
request,
IdentityLinkFailureCode.ALREADY_LINKED);
}
requireSubjectsUnbound(
actor,
request,
assertion,
descriptor);
ExternalSubject legacySubject = assertion.requireUniqueSubject(
descriptor.legacyPrimarySubjectType());
IdentityBinding binding = new IdentityBinding(
actor.userId(),
descriptor.providerCode(),
legacySubject.value(),
assertion.profile().displayName());
binding.recordAuthentication(
assertion.evidence().authenticatedAt());
IdentityBinding savedBinding =
bindingRepository.saveAndFlush(binding);
if (savedBinding.getId() == null) {
throw new IllegalStateException(
"Identity binding id was not assigned");
}
List<IdentityBindingSubject> subjects =
assertion.allSubjects().stream()
.map(subject -> new IdentityBindingSubject(
savedBinding.getId(),
savedBinding.getProviderCode(),
subject.type(),
subject.value(),
subject.equals(
assertion.primarySubject()),
assertion.evidence()
.authenticatedAt()))
.toList();
subjectRepository.saveAllAndFlush(subjects);
request.complete(now());
recordAudit(
actor,
"IDENTITY_BINDING_LINKED",
request,
descriptor.providerCode());
return new LinkedBinding(
principalFactory.create(
user,
actor.authenticationProvider()),
savedBinding.getId());
}
@Transactional(noRollbackFor = IdentityLinkException.class)
public IdentityLinkIntent completeUnlink(
IdentityLinkActor actor,
UUID intentId) {
IdentityLinkRequest request = requireRequest(
actor,
intentId);
requireReady(
request,
actor,
IdentityLinkOperation.UNLINK);
requireEligibleAccount(actor.userId());
IdentityBinding binding = bindingRepository
.findByIdAndStatusForUpdate(
request.getTargetBindingId(),
IdentityBindingStatus.ACTIVE)
.filter(candidate -> candidate.getUserId()
.equals(actor.userId()))
.orElseThrow(() ->
reject(
actor,
request,
IdentityLinkFailureCode.ALREADY_CONSUMED));
if (!hasOtherUsableLoginMethod(
actor.userId(),
binding.getId())) {
throw reject(
actor,
request,
IdentityLinkFailureCode.FINAL_LOGIN_METHOD);
}
Instant revokedAt = now();
List<IdentityBindingSubject> activeSubjects =
subjectRepository.findByBindingIdAndStatusForUpdate(
binding.getId(),
IdentityBindingSubjectStatus.ACTIVE);
if (activeSubjects.isEmpty()) {
throw reject(
actor,
request,
IdentityLinkFailureCode.ALREADY_CONSUMED);
}
activeSubjects.forEach(subject ->
subject.revoke(revokedAt));
subjectRepository.saveAll(activeSubjects);
binding.revoke(
actor.userId(),
USER_UNLINK_REASON,
revokedAt);
bindingRepository.save(binding);
request.complete(revokedAt);
recordAudit(
actor,
"IDENTITY_BINDING_REVOKED",
request,
binding.getProviderCode());
return toIntent(request);
}
@Transactional(readOnly = true)
public IdentityLinkAccountState accountState(String userId) {
requireEligibleAccountForRead(userId);
List<IdentityBinding> bindings = bindingRepository
.findByUserIdAndStatus(
userId,
IdentityBindingStatus.ACTIVE)
.stream()
.sorted(Comparator.comparing(
IdentityBinding::getProviderCode))
.toList();
Map<String, ReadyProvider> readyProviders =
readyProviders();
boolean localPasswordEnabled =
credentialRepository.existsByUserId(userId);
long usableMethodCount =
localPasswordEnabled ? 1 : 0;
usableMethodCount += bindings.stream()
.filter(binding -> readyProviders.containsKey(
binding.getProviderCode()))
.count();
long totalUsableMethodCount = usableMethodCount;
List<IdentityLinkBindingView> linked =
bindings.stream()
.map(binding -> {
ReadyProvider provider =
readyProviders.get(
binding.getProviderCode());
boolean usable = provider != null;
boolean anotherUsableMethod =
totalUsableMethodCount
- (usable ? 1 : 0)
> 0;
return new IdentityLinkBindingView(
binding.getId(),
binding.getProviderCode(),
provider == null
? binding.getProviderCode()
: provider.displayName(),
provider == null
? Set.of()
: provider.methodTypes(),
usable,
anotherUsableMethod);
})
.toList();
Set<String> linkedProviderCodes = bindings.stream()
.map(IdentityBinding::getProviderCode)
.collect(Collectors.toSet());
List<IdentityLinkProviderView> available =
readyProviders.values()
.stream()
.filter(provider ->
!linkedProviderCodes.contains(
provider.providerCode()))
.filter(provider ->
provider.methodTypes().contains(
IdentityProviderLoginMethodType
.OAUTH_REDIRECT)
|| provider.methodTypes().contains(
IdentityProviderLoginMethodType
.DIRECT_PASSWORD))
.sorted(Comparator.comparing(
ReadyProvider::providerCode))
.map(provider ->
new IdentityLinkProviderView(
provider.providerCode(),
provider.displayName(),
provider.methodTypes()))
.toList();
return new IdentityLinkAccountState(
localPasswordEnabled,
linked,
available);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordRejectedAfterRollback(
IdentityLinkActor actor,
UUID intentId,
IdentityLinkFailureCode code) {
requestRepository.findById(intentId)
.filter(request -> request.getPrimaryUserId()
.equals(actor.userId()))
.filter(request -> stateHasher.matches(
actor.sessionNonce(),
request.getStateHash()))
.ifPresent(request -> recordAudit(
actor,
"IDENTITY_LINK_INTENT_REJECTED",
request,
code.name().toLowerCase(Locale.ROOT)));
}
private IdentityLinkRequest requireRequest(
IdentityLinkActor actor,
UUID intentId) {
IdentityLinkRequest request = requestRepository
.findByIdForUpdate(intentId)
.orElseThrow(() ->
failure(
IdentityLinkFailureCode
.INTENT_NOT_FOUND));
if (!request.getPrimaryUserId().equals(actor.userId())) {
throw reject(
actor,
request,
IdentityLinkFailureCode.INTENT_NOT_FOUND);
}
if (!stateHasher.matches(
actor.sessionNonce(),
request.getStateHash())) {
throw reject(
actor,
request,
IdentityLinkFailureCode.SESSION_MISMATCH);
}
return request;
}
private void requireActive(
IdentityLinkRequest request,
IdentityLinkActor actor) {
if (request.getStatus() == IdentityLinkRequestStatus.EXPIRED) {
throw reject(
actor,
request,
IdentityLinkFailureCode.INTENT_EXPIRED);
}
if (!request.getStatus().isActive()) {
throw reject(
actor,
request,
IdentityLinkFailureCode.ALREADY_CONSUMED);
}
if (request.isExpiredAt(now())) {
request.expire(now());
recordAudit(
actor,
"IDENTITY_LINK_INTENT_EXPIRED",
request,
"expired");
throw failure(
IdentityLinkFailureCode.INTENT_EXPIRED);
}
}
private void requirePendingReauthentication(
IdentityLinkRequest request,
IdentityLinkActor actor) {
requireActive(request, actor);
if (request.getStatus()
!= IdentityLinkRequestStatus
.PENDING_REAUTHENTICATION) {
throw reject(
actor,
request,
IdentityLinkFailureCode.ALREADY_CONSUMED);
}
}
private void requireReady(
IdentityLinkRequest request,
IdentityLinkActor actor,
IdentityLinkOperation operation) {
requireActive(request, actor);
if (request.getOperation() != operation) {
throw reject(
actor,
request,
IdentityLinkFailureCode.INVALID_OPERATION);
}
if (request.getStatus() != IdentityLinkRequestStatus.READY) {
throw reject(
actor,
request,
IdentityLinkFailureCode.REAUTHENTICATION_REQUIRED);
}
}
private UserAccount requireEligibleAccount(String userId) {
UserAccount user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() ->
failure(
IdentityLinkFailureCode
.ACCOUNT_NOT_ELIGIBLE));
if (accountLoginGuard.evaluateInteractive(user)
!= AccountLoginDecision.ALLOWED) {
throw failure(
IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE);
}
return user;
}
private UserAccount requireEligibleAccountForRead(String userId) {
UserAccount user = userRepository.findById(userId)
.orElseThrow(() ->
failure(
IdentityLinkFailureCode
.ACCOUNT_NOT_ELIGIBLE));
if (accountLoginGuard.evaluateInteractive(user)
!= AccountLoginDecision.ALLOWED) {
throw failure(
IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE);
}
return user;
}
private void requireReadyLinkProvider(String providerCode) {
if (providerCode == null || providerCode.isBlank()) {
throw failure(
IdentityLinkFailureCode.INVALID_OPERATION);
}
ReadyProvider provider = readyProviders().get(providerCode);
if (provider == null
|| (provider.methodTypes().stream().noneMatch(type ->
type == IdentityProviderLoginMethodType.OAUTH_REDIRECT
|| type == IdentityProviderLoginMethodType
.DIRECT_PASSWORD))) {
throw failure(
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
}
}
private void requireProviderCapability(
IdentityLinkActor actor,
IdentityLinkRequest request,
String providerCode,
IdentityProviderLoginMethodType methodType) {
ReadyProvider provider =
readyProviders().get(providerCode);
if (provider == null
|| !provider.methodTypes().contains(methodType)) {
throw reject(
actor,
request,
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
}
}
private Map<String, ReadyProvider> readyProviders() {
Map<String, ProviderAccumulator> accumulated =
new LinkedHashMap<>();
for (IdentityProviderLoginMethod method
: providerRegistry.listReadyLoginMethods()) {
accumulated.computeIfAbsent(
method.providerCode(),
ignored -> new ProviderAccumulator(
method.providerCode(),
method.displayName()))
.methodTypes()
.add(method.methodType());
}
LinkedHashMap<String, ReadyProvider> providers =
new LinkedHashMap<>();
accumulated.values().forEach(provider ->
providers.put(
provider.providerCode(),
new ReadyProvider(
provider.providerCode(),
provider.displayName(),
Set.copyOf(
provider.methodTypes()))));
return Map.copyOf(providers);
}
private IdentityBinding resolveAuthenticatedBinding(
IdentityAssertion assertion,
ProviderDescriptor descriptor) {
List<IdentityBindingSubject> typedMatches =
subjectRepository.findMatchingSubjects(
assertion.provider().providerCode(),
subjectValuesByType(assertion.allSubjects()));
ExternalSubject legacySubject = assertion.requireUniqueSubject(
descriptor.legacyPrimarySubjectType());
IdentityBinding legacyMatch = bindingRepository
.findByProviderCodeAndSubjectAndStatus(
assertion.provider().providerCode(),
legacySubject.value(),
IdentityBindingStatus.ACTIVE)
.orElse(null);
LinkedHashSet<Long> activeBindingIds = typedMatches.stream()
.filter(subject -> subject.getStatus()
== IdentityBindingSubjectStatus.ACTIVE)
.map(IdentityBindingSubject::getBindingId)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (legacyMatch != null
&& legacyMatch.getStatus()
== IdentityBindingStatus.ACTIVE) {
activeBindingIds.add(legacyMatch.getId());
}
if (activeBindingIds.size() != 1) {
throw failure(
IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE);
}
return bindingRepository
.findByIdAndStatusForUpdate(
activeBindingIds.getFirst(),
IdentityBindingStatus.ACTIVE)
.orElseThrow(() ->
failure(
IdentityLinkFailureCode
.ACCOUNT_NOT_ELIGIBLE));
}
private void requireSubjectsUnbound(
IdentityLinkActor actor,
IdentityLinkRequest request,
IdentityAssertion assertion,
ProviderDescriptor descriptor) {
List<IdentityBindingSubject> matches =
subjectRepository.findMatchingSubjects(
assertion.provider().providerCode(),
subjectValuesByType(assertion.allSubjects()));
ExternalSubject legacySubject = assertion.requireUniqueSubject(
descriptor.legacyPrimarySubjectType());
boolean activeSubjectExists = matches.stream()
.anyMatch(subject -> subject.getStatus()
== IdentityBindingSubjectStatus.ACTIVE);
boolean activeLegacyBindingExists = bindingRepository
.findByProviderCodeAndSubjectAndStatus(
assertion.provider().providerCode(),
legacySubject.value(),
IdentityBindingStatus.ACTIVE)
.isPresent();
if (activeSubjectExists || activeLegacyBindingExists) {
throw reject(
actor,
request,
IdentityLinkFailureCode.IDENTITY_IN_USE);
}
}
private void requireNoActiveRequest(
IdentityLinkActor actor,
Instant now) {
requestRepository
.findActiveByPrimaryUserIdForUpdate(
actor.userId(),
Set.of(
IdentityLinkRequestStatus
.PENDING_REAUTHENTICATION,
IdentityLinkRequestStatus.READY))
.ifPresent(active -> {
if (!active.isExpiredAt(now)) {
throw reject(
actor,
active,
IdentityLinkFailureCode
.ACTIVE_INTENT_EXISTS);
}
active.expire(now);
recordAudit(
actor,
"IDENTITY_LINK_INTENT_EXPIRED",
active,
"expired");
requestRepository.flush();
});
}
private boolean hasOtherUsableLoginMethod(
String userId,
long excludedBindingId) {
if (credentialRepository.existsByUserId(userId)) {
return true;
}
Set<String> readyProviderCodes =
readyProviders().keySet();
return bindingRepository
.findByUserIdAndStatus(
userId,
IdentityBindingStatus.ACTIVE)
.stream()
.filter(binding -> binding.getId()
!= excludedBindingId)
.map(IdentityBinding::getProviderCode)
.anyMatch(readyProviderCodes::contains);
}
private Map<String, Set<String>> subjectValuesByType(
Set<ExternalSubject> subjects) {
LinkedHashMap<String, Set<String>> valuesByType =
new LinkedHashMap<>();
for (ExternalSubject subject : subjects) {
valuesByType.computeIfAbsent(
subject.type(),
ignored -> new LinkedHashSet<>())
.add(subject.value());
}
return Map.copyOf(valuesByType);
}
private void recordAudit(
IdentityLinkActor actor,
String action,
IdentityLinkRequest request,
String result) {
IdentityLoginContext context = actor.auditContext();
auditLogService.record(
actor.userId(),
action,
"IDENTITY_LINK_REQUEST",
null,
context.requestId(),
context.clientIp(),
context.userAgent(),
"{\"intentId\":\""
+ request.getId()
+ "\",\"operation\":\""
+ request.getOperation()
+ "\",\"providerCode\":\""
+ request.getProviderCode()
+ "\",\"result\":\""
+ result
+ "\"}");
}
private IdentityLinkIntent toIntent(IdentityLinkRequest request) {
return new IdentityLinkIntent(
request.getId(),
request.getOperation(),
request.getStatus(),
request.getProviderCode(),
request.getTargetBindingId(),
request.getExpiresAt());
}
private Instant now() {
return Instant.now(clock);
}
private IdentityLinkException failure(
IdentityLinkFailureCode code) {
return new IdentityLinkException(code);
}
private IdentityLinkException reject(
IdentityLinkActor actor,
IdentityLinkRequest request,
IdentityLinkFailureCode code) {
recordAudit(
actor,
"IDENTITY_LINK_INTENT_REJECTED",
request,
code.name().toLowerCase(Locale.ROOT));
return failure(code);
}
record LinkedBinding(
PlatformPrincipal principal,
long bindingId) {
}
private record ReadyProvider(
String providerCode,
String displayName,
Set<IdentityProviderLoginMethodType> methodTypes) {
}
private record ProviderAccumulator(
String providerCode,
String displayName,
Set<IdentityProviderLoginMethodType> methodTypes) {
private ProviderAccumulator(
String providerCode,
String displayName) {
this(
providerCode,
displayName,
new LinkedHashSet<>());
}
}
}

View file

@ -131,9 +131,10 @@ class IdentityResolutionTransaction {
assertion.provider().providerCode(),
subjectValuesByType(assertion.allSubjects()));
IdentityBinding legacyMatch = bindingRepository
.findByProviderCodeAndSubject(
.findByProviderCodeAndSubjectAndStatus(
assertion.provider().providerCode(),
legacySubject.value())
legacySubject.value(),
IdentityBindingStatus.ACTIVE)
.orElse(null);
LinkedHashSet<Long> activeBindingIds = typedMatches.stream()

View file

@ -110,10 +110,11 @@ public class LocalAuthService {
* Authenticates a local account and returns the principal snapshot used to
* establish a web session.
*/
@Transactional
@Transactional(noRollbackFor = AuthFlowException.class)
public PlatformPrincipal login(String username, String password) {
String normalizedUsername = normalizeUsername(username);
LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername)
LocalCredential credential = credentialRepository
.findByUsernameIgnoreCaseForUpdate(normalizedUsername)
.orElse(null);
if (credential == null) {
@ -139,6 +140,39 @@ public class LocalAuthService {
return principalFactory.create(user, "local");
}
/**
* Reauthenticates the already authenticated account without creating,
* replacing, or rotating its web session.
*/
@Transactional(noRollbackFor = AuthFlowException.class)
public PlatformPrincipal reauthenticate(
String userId,
String password) {
LocalCredential credential = credentialRepository
.findByUserIdForUpdate(userId)
.orElseThrow(() -> new AuthFlowException(
HttpStatus.BAD_REQUEST,
"error.auth.local.notEnabled"));
UserAccount user = userAccountRepository.findById(userId)
.orElseThrow(() -> new IllegalStateException(
"User not found for local credential"));
requireLocalLoginAllowed(
accountLoginGuard.evaluateInteractive(user));
ensureNotLocked(credential);
if (!passwordEncoder.matches(
password == null ? "" : password,
credential.getPasswordHash())) {
handleFailedLogin(credential);
throw invalidCredentials();
}
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
return principalFactory.create(user, "local");
}
/**
* Changes the stored password for an already authenticated local account.
*/

View file

@ -1,7 +1,11 @@
package com.iflytek.skillhub.auth.local;
import jakarta.persistence.LockModeType;
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;
import org.springframework.stereotype.Repository;
/**
@ -14,6 +18,24 @@ public interface LocalCredentialRepository extends JpaRepository<LocalCredential
Optional<LocalCredential> findByUserId(String userId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select credential
from LocalCredential credential
where lower(credential.username) = lower(:username)
""")
Optional<LocalCredential> findByUsernameIgnoreCaseForUpdate(
@Param("username") String username);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select credential
from LocalCredential credential
where credential.userId = :userId
""")
Optional<LocalCredential> findByUserIdForUpdate(
@Param("userId") String userId);
boolean existsByUsernameIgnoreCase(String username);
boolean existsByUserId(String userId);

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@ -29,12 +30,15 @@ public final class IdentityProviderRouteReadinessFilter
"/login/oauth2/code/";
private final ClientRegistrationRepository registrationRepository;
private final IdentityProviderReadinessService readinessService;
private final OAuth2LoginFailureHandler failureHandler;
public IdentityProviderRouteReadinessFilter(
ClientRegistrationRepository registrationRepository,
IdentityProviderReadinessService readinessService) {
IdentityProviderReadinessService readinessService,
OAuth2LoginFailureHandler failureHandler) {
this.registrationRepository = registrationRepository;
this.readinessService = readinessService;
this.failureHandler = failureHandler;
}
@Override
@ -55,6 +59,9 @@ public final class IdentityProviderRouteReadinessFilter
? null
: registrationRepository.findByRegistrationId(registrationId);
if (registration == null) {
if (redirectIdentityLinkFailure(request, response)) {
return;
}
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
@ -62,6 +69,9 @@ public final class IdentityProviderRouteReadinessFilter
try {
readinessService.requireReady(registration);
} catch (IdentityCoreException exception) {
if (redirectIdentityLinkFailure(request, response)) {
return;
}
int status = exception.getReasonCode()
== IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH
? HttpServletResponse.SC_SERVICE_UNAVAILABLE
@ -77,6 +87,9 @@ public final class IdentityProviderRouteReadinessFilter
"Identity provider route '{}' readiness check failed",
registration.getRegistrationId(),
exception);
if (redirectIdentityLinkFailure(request, response)) {
return;
}
response.setStatus(
HttpServletResponse.SC_SERVICE_UNAVAILABLE);
return;
@ -84,6 +97,16 @@ public final class IdentityProviderRouteReadinessFilter
filterChain.doFilter(request, response);
}
private boolean redirectIdentityLinkFailure(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
return failureHandler.redirectIdentityLinkRouteFailure(
request,
response,
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
}
private String registrationId(HttpServletRequest request) {
String path = pathWithinApplication(request);
String value = pathSegment(path, AUTHORIZATION_PREFIX);

View file

@ -1,33 +1,88 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* Failure handler for OAuth logins that normalizes policy and account-state
* failures into predictable user-facing redirects.
*/
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
public class OAuth2LoginFailureHandler
extends SimpleUrlAuthenticationFailureHandler {
private final OAuthLoginFlowService oauthLoginFlowService;
private final IdentityLinkSessionManager identityLinkSessionManager;
public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) {
public OAuth2LoginFailureHandler(
OAuthLoginFlowService oauthLoginFlowService,
IdentityLinkSessionManager identityLinkSessionManager) {
this.oauthLoginFlowService = oauthLoginFlowService;
this.identityLinkSessionManager = identityLinkSessionManager;
}
/**
* Converts a pre-upstream route failure into an Identity Link callback
* result only when this session actually owns a pending browser flow.
* Normal OAuth login readiness failures retain their existing HTTP
* status behavior.
*/
public boolean redirectIdentityLinkRouteFailure(
HttpServletRequest request,
HttpServletResponse response,
IdentityLinkFailureCode reasonCode)
throws IOException {
var session = request.getSession(false);
var intentId = identityLinkSessionManager
.consumeFailedBrowserFlow(session);
if (intentId.isEmpty()) {
return false;
}
oauthLoginFlowService.consumeReturnTo(session);
getRedirectStrategy().sendRedirect(
request,
response,
"/settings/security?identityLink=failed"
+ "&intentId="
+ intentId.get()
+ "&reasonCode="
+ reasonCode.name());
return true;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception)
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false));
String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo);
var session = request.getSession(false);
String returnTo = oauthLoginFlowService.consumeReturnTo(session);
String reasonCode = oauthLoginFlowService
.identityLinkFailureReasonCode(exception)
.orElse(
IdentityLinkFailureCode
.PROVIDER_AUTHENTICATION_FAILED
.name());
String redirectTarget = identityLinkSessionManager
.consumeFailedBrowserFlow(session)
.map(intentId ->
"/settings/security?identityLink=failed"
+ "&intentId="
+ intentId
+ "&reasonCode="
+ reasonCode)
.orElseGet(() ->
oauthLoginFlowService.resolveFailureRedirect(
exception,
returnTo));
if (redirectTarget != null) {
getRedirectStrategy().sendRedirect(request, response, redirectTarget);
return;

View file

@ -1,8 +1,15 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow;
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase;
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
@ -16,6 +23,8 @@ import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
@ -28,6 +37,8 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* Flow owner for browser OAuth login. It centralizes the stages of remembering
@ -41,15 +52,21 @@ public class OAuthLoginFlowService {
private final Map<String, OAuthClaimsExtractor> extractors;
private final TrustedProviderRouteResolver providerRouteResolver;
private final ExternalIdentityLoginService identityLoginService;
private final ExternalIdentityLinkService identityLinkService;
private final IdentityLinkSessionManager identityLinkSessionManager;
@Autowired
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
TrustedProviderRouteResolver providerRouteResolver,
ExternalIdentityLoginService identityLoginService) {
ExternalIdentityLoginService identityLoginService,
ExternalIdentityLinkService identityLinkService,
IdentityLinkSessionManager identityLinkSessionManager) {
this(
extractorList,
providerRouteResolver,
identityLoginService,
identityLinkService,
identityLinkSessionManager,
new DefaultOAuth2UserService());
}
@ -57,6 +74,8 @@ public class OAuthLoginFlowService {
List<OAuthClaimsExtractor> extractorList,
TrustedProviderRouteResolver providerRouteResolver,
ExternalIdentityLoginService identityLoginService,
ExternalIdentityLinkService identityLinkService,
IdentityLinkSessionManager identityLinkSessionManager,
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate) {
this.extractors = extractorList.stream()
.collect(Collectors.toMap(
@ -64,6 +83,8 @@ public class OAuthLoginFlowService {
Function.identity()));
this.providerRouteResolver = providerRouteResolver;
this.identityLoginService = identityLoginService;
this.identityLinkService = identityLinkService;
this.identityLinkSessionManager = identityLinkSessionManager;
this.delegate = Objects.requireNonNull(delegate, "delegate");
}
@ -116,6 +137,14 @@ public class OAuthLoginFlowService {
ProviderAuthenticationResult result,
IdentityLoginContext context) {
try {
Optional<IdentityLinkBrowserFlow> identityLinkFlow =
consumeIdentityLinkFlow(provider, context);
if (identityLinkFlow.isPresent()) {
return authenticateIdentityLinkFlow(
identityLinkFlow.get(),
provider,
result);
}
IdentityLoginOutcome outcome = identityLoginService.authenticate(
provider,
result,
@ -132,9 +161,69 @@ public class OAuthLoginFlowService {
null));
} catch (IdentityCoreException exception) {
throw mapIdentityFailure(exception);
} catch (IdentityLinkException exception) {
throw oauthFailure(
"identity_link_failed",
exception.getReasonCode().name(),
exception);
}
}
private Optional<IdentityLinkBrowserFlow> consumeIdentityLinkFlow(
ResolvedProviderHandle provider,
IdentityLoginContext context) {
if (!(RequestContextHolder.getRequestAttributes()
instanceof ServletRequestAttributes attributes)) {
return Optional.empty();
}
return identityLinkSessionManager.consumeBrowserFlow(
attributes.getRequest(),
provider.providerCode(),
context);
}
private PlatformPrincipal authenticateIdentityLinkFlow(
IdentityLinkBrowserFlow flow,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result) {
IdentityLinkOutcome outcome;
if (flow.phase()
== IdentityLinkBrowserPhase.REAUTHENTICATE) {
outcome = identityLinkService.reauthenticate(
flow.actor(),
flow.intentId(),
provider,
result);
} else {
outcome = identityLinkService.link(
flow.actor(),
flow.intentId(),
provider,
result);
}
if (outcome
instanceof IdentityLinkOutcome.Reauthenticated completed) {
return completed.principal();
}
if (outcome instanceof IdentityLinkOutcome.Linked linked) {
currentRequest().ifPresent(request ->
identityLinkSessionManager.remove(
request.getSession(false),
flow.intentId()));
return linked.principal();
}
throw new IllegalStateException(
"Unsupported identity link outcome");
}
private Optional<HttpServletRequest> currentRequest() {
if (RequestContextHolder.getRequestAttributes()
instanceof ServletRequestAttributes attributes) {
return Optional.of(attributes.getRequest());
}
return Optional.empty();
}
public void rememberReturnTo(HttpServletRequest request) {
String returnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(request.getParameter("returnTo"));
HttpSession session = request.getSession();
@ -178,12 +267,78 @@ public class OAuthLoginFlowService {
oauth2Exception.getError().getErrorCode()))) {
return "/access-denied";
}
if (exception instanceof OAuth2AuthenticationException oauth2Exception
&& "identity_link_failed".equals(
oauth2Exception.getError().getErrorCode())) {
return identityLinkFailureRedirect(
returnTo,
identityLinkFailureReasonCode(exception)
.orElse(null));
}
if (returnTo != null) {
return "/login?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8);
}
return null;
}
public Optional<String> identityLinkFailureReasonCode(
AuthenticationException exception) {
if (!(exception
instanceof OAuth2AuthenticationException oauth2Exception)
|| !"identity_link_failed".equals(
oauth2Exception.getError().getErrorCode())) {
return Optional.empty();
}
String description =
oauth2Exception.getError().getDescription();
try {
return Optional.of(
IdentityLinkFailureCode.valueOf(
description).name());
} catch (IllegalArgumentException | NullPointerException ignored) {
return Optional.empty();
}
}
private String identityLinkFailureRedirect(
String returnTo,
String reasonCode) {
Optional<UUID> intentId = identityLinkIntentId(returnTo);
return "/settings/security?identityLink=failed"
+ intentId.map(id -> "&intentId=" + id)
.orElse("")
+ (reasonCode == null
? ""
: "&reasonCode="
+ URLEncoder.encode(
reasonCode,
StandardCharsets.UTF_8));
}
private Optional<UUID> identityLinkIntentId(String returnTo) {
if (returnTo == null
|| !returnTo.startsWith("/settings/security?")) {
return Optional.empty();
}
String query = returnTo.substring(
returnTo.indexOf('?') + 1);
for (String parameter : query.split("&")) {
int separator = parameter.indexOf('=');
if (separator <= 0
|| !"intentId".equals(
parameter.substring(0, separator))) {
continue;
}
try {
return Optional.of(UUID.fromString(
parameter.substring(separator + 1)));
} catch (IllegalArgumentException ignored) {
return Optional.empty();
}
}
return Optional.empty();
}
public record AuthenticatedLoginContext(OAuth2User upstreamUser, PlatformPrincipal principal) {
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
@ -16,27 +17,45 @@ public class SkillHubOAuth2AuthorizationRequestResolver
private final DefaultOAuth2AuthorizationRequestResolver delegate;
private final OAuthLoginFlowService oauthLoginFlowService;
private final IdentityLinkSessionManager identityLinkSessionManager;
public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
OAuthLoginFlowService oauthLoginFlowService) {
OAuthLoginFlowService oauthLoginFlowService,
IdentityLinkSessionManager identityLinkSessionManager) {
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
this.oauthLoginFlowService = oauthLoginFlowService;
this.identityLinkSessionManager = identityLinkSessionManager;
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request);
oauthLoginFlowService.rememberReturnTo(request);
rememberAuthorizationFlow(request, authorizationRequest);
return authorizationRequest;
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId);
oauthLoginFlowService.rememberReturnTo(request);
rememberAuthorizationFlow(request, authorizationRequest);
return authorizationRequest;
}
private void rememberAuthorizationFlow(
HttpServletRequest request,
OAuth2AuthorizationRequest authorizationRequest) {
if (authorizationRequest == null) {
return;
}
oauthLoginFlowService.rememberReturnTo(request);
String registrationId = authorizationRequest.getAttribute(
"registration_id");
identityLinkSessionManager.activateBrowserFlow(
request.getSession(false),
registrationId,
authorizationRequest.getState());
}
}

View file

@ -16,7 +16,10 @@ import org.springframework.stereotype.Repository;
*/
@Repository
public interface IdentityBindingRepository extends JpaRepository<IdentityBinding, Long> {
Optional<IdentityBinding> findByProviderCodeAndSubject(String providerCode, String subject);
Optional<IdentityBinding> findByProviderCodeAndSubjectAndStatus(
String providerCode,
String subject,
IdentityBindingStatus status);
@Query("""
select distinct binding.providerCode
@ -39,4 +42,8 @@ public interface IdentityBindingRepository extends JpaRepository<IdentityBinding
boolean existsByProviderCode(String providerCode);
List<IdentityBinding> findByUserId(String userId);
List<IdentityBinding> findByUserIdAndStatus(
String userId,
IdentityBindingStatus status);
}

View file

@ -0,0 +1,39 @@
package com.iflytek.skillhub.auth.repository;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequest;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
import jakarta.persistence.LockModeType;
import java.util.Collection;
import java.util.Optional;
import java.util.UUID;
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;
import org.springframework.stereotype.Repository;
@Repository
public interface IdentityLinkRequestRepository
extends JpaRepository<IdentityLinkRequest, UUID> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select request
from IdentityLinkRequest request
where request.id = :requestId
""")
Optional<IdentityLinkRequest> findByIdForUpdate(
@Param("requestId") UUID requestId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select request
from IdentityLinkRequest request
where request.primaryUserId = :userId
and request.status in :statuses
""")
Optional<IdentityLinkRequest> findActiveByPrimaryUserIdForUpdate(
@Param("userId") String userId,
@Param("statuses")
Collection<IdentityLinkRequestStatus> statuses);
}

View file

@ -0,0 +1,95 @@
package com.iflytek.skillhub.auth.entity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class IdentityLinkRequestTest {
private static final Instant CREATED_AT =
Instant.parse("2026-07-31T08:00:00Z");
private static final String STATE_HASH = "a".repeat(64);
@Test
void linkRequestRequiresNoTargetBindingAndStartsPending() {
IdentityLinkRequest request = new IdentityLinkRequest(
UUID.randomUUID(),
"usr_1",
IdentityLinkOperation.LINK,
"github",
null,
STATE_HASH,
CREATED_AT.plusSeconds(600),
CREATED_AT);
assertThat(request.getStatus())
.isEqualTo(
IdentityLinkRequestStatus
.PENDING_REAUTHENTICATION);
assertThat(request.getTargetBindingId()).isNull();
}
@Test
void unlinkRequestRequiresTargetBinding() {
assertThatThrownBy(() ->
new IdentityLinkRequest(
UUID.randomUUID(),
"usr_1",
IdentityLinkOperation.UNLINK,
"github",
null,
STATE_HASH,
CREATED_AT.plusSeconds(600),
CREATED_AT))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void requestCanOnlyBeReauthenticatedAndCompletedOnce() {
IdentityLinkRequest request = request();
request.markReauthenticated(
"local-password",
CREATED_AT.plusSeconds(10));
request.complete(CREATED_AT.plusSeconds(20));
assertThat(request.getStatus())
.isEqualTo(IdentityLinkRequestStatus.COMPLETED);
assertThatThrownBy(() ->
request.complete(CREATED_AT.plusSeconds(30)))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() ->
request.markReauthenticated(
"local-password",
CREATED_AT.plusSeconds(30)))
.isInstanceOf(IllegalStateException.class);
}
@Test
void expiredRequestCannotReturnToAnActiveState() {
IdentityLinkRequest request = request();
request.expire(CREATED_AT.plusSeconds(600));
assertThat(request.getStatus())
.isEqualTo(IdentityLinkRequestStatus.EXPIRED);
assertThatThrownBy(() ->
request.markReauthenticated(
"local-password",
CREATED_AT.plusSeconds(601)))
.isInstanceOf(IllegalStateException.class);
}
private IdentityLinkRequest request() {
return new IdentityLinkRequest(
UUID.randomUUID(),
"usr_1",
IdentityLinkOperation.LINK,
"github",
null,
STATE_HASH,
CREATED_AT.plusSeconds(600),
CREATED_AT);
}
}

View file

@ -0,0 +1,123 @@
package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class IdentityLinkIntentServiceTest {
@Mock
private IdentityLinkTransaction transaction;
@Mock
private LocalAuthService localAuthService;
@Test
void localReauthenticationValidatesIntentBeforeCheckingPassword() {
IdentityLinkIntentService service =
new IdentityLinkIntentService(
transaction,
localAuthService);
IdentityLinkActor actor = actor();
UUID intentId = UUID.randomUUID();
IdentityLinkIntent pending = intent(
intentId,
IdentityLinkRequestStatus.PENDING_REAUTHENTICATION);
IdentityLinkIntent ready = intent(
intentId,
IdentityLinkRequestStatus.READY);
when(transaction.getIntent(actor, intentId))
.thenReturn(pending);
when(transaction.markLocalReauthenticated(
actor,
intentId))
.thenReturn(ready);
IdentityLinkIntent result = service.reauthenticateLocal(
actor,
intentId,
"current-password");
assertThat(result).isSameAs(ready);
InOrder order = inOrder(
transaction,
localAuthService);
order.verify(transaction).getIntent(actor, intentId);
order.verify(localAuthService).reauthenticate(
actor.userId(),
"current-password");
order.verify(transaction).markLocalReauthenticated(
actor,
intentId);
}
@Test
void consumedIntentDoesNotCheckPassword() {
IdentityLinkIntentService service =
new IdentityLinkIntentService(
transaction,
localAuthService);
IdentityLinkActor actor = actor();
UUID intentId = UUID.randomUUID();
when(transaction.getIntent(actor, intentId))
.thenReturn(intent(
intentId,
IdentityLinkRequestStatus.READY));
assertThatThrownBy(() ->
service.reauthenticateLocal(
actor,
intentId,
"current-password"))
.isInstanceOfSatisfying(
IdentityLinkException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
IdentityLinkFailureCode
.ALREADY_CONSUMED));
verifyNoInteractions(localAuthService);
verify(transaction, never())
.markLocalReauthenticated(actor, intentId);
}
private IdentityLinkActor actor() {
return new IdentityLinkActor(
"usr_1",
"local",
"session-nonce",
new IdentityLoginContext(
"req-1",
"203.0.113.9",
"Identity Link Test"));
}
private IdentityLinkIntent intent(
UUID intentId,
IdentityLinkRequestStatus status) {
return new IdentityLinkIntent(
intentId,
IdentityLinkOperation.LINK,
status,
"github",
null,
Instant.parse("2026-07-31T08:10:00Z"));
}
}

View file

@ -0,0 +1,209 @@
package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
class IdentityLinkSessionManagerTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-07-31T08:00:00Z"),
ZoneOffset.UTC);
private static final IdentityLoginContext CONTEXT =
new IdentityLoginContext(
"req-1",
"203.0.113.9",
"Browser");
private IdentityLinkSessionManager manager;
private MockHttpSession session;
@BeforeEach
void setUp() {
manager = new IdentityLinkSessionManager(
new SecureRandom(),
new IdentityLinkStateHasher(),
CLOCK);
session = new MockHttpSession();
session.setAttribute(
"platformPrincipal",
new PlatformPrincipal(
"usr_1",
"Alice",
"alice@example.com",
null,
"local",
Set.of("USER")));
}
@Test
void generatedNonceStaysInSessionAndIsOmittedFromActorString() {
UUID intentId = UUID.randomUUID();
IdentityLinkActor actor = manager.start(
session,
intentId,
CONTEXT);
assertThat(actor.userId()).isEqualTo("usr_1");
assertThat(actor.toString())
.contains("usr_1")
.doesNotContain("nonce");
assertThat(manager.actor(session, intentId, CONTEXT).userId())
.isEqualTo("usr_1");
}
@Test
void anotherSessionCannotResumeIntent() {
UUID intentId = UUID.randomUUID();
manager.start(session, intentId, CONTEXT);
MockHttpSession otherSession = new MockHttpSession();
otherSession.setAttribute(
"platformPrincipal",
session.getAttribute("platformPrincipal"));
assertThatThrownBy(() ->
manager.actor(otherSession, intentId, CONTEXT))
.isInstanceOfSatisfying(
IdentityLinkException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
IdentityLinkFailureCode
.SESSION_MISMATCH));
}
@Test
void browserFlowIsBoundToProviderOAuthStateAndConsumedOnce() {
UUID intentId = UUID.randomUUID();
manager.start(session, intentId, CONTEXT);
manager.prepareBrowserFlow(
session,
intentId,
IdentityLinkBrowserPhase.LINK,
"github",
CONTEXT);
manager.activateBrowserFlow(
session,
"github",
"oauth-state");
MockHttpServletRequest callback =
new MockHttpServletRequest(
"GET",
"/login/oauth2/code/github");
callback.setSession(session);
callback.setParameter("state", "oauth-state");
IdentityLinkBrowserFlow flow =
manager.consumeBrowserFlow(
callback,
"github",
CONTEXT)
.orElseThrow();
assertThat(flow.intentId()).isEqualTo(intentId);
assertThat(flow.phase())
.isEqualTo(IdentityLinkBrowserPhase.LINK);
assertThat(manager.consumeBrowserFlow(
callback,
"github",
CONTEXT)).isEmpty();
}
@Test
void mismatchedOAuthStateFailsClosedAndCannotBeRetried() {
UUID intentId = UUID.randomUUID();
manager.start(session, intentId, CONTEXT);
manager.prepareBrowserFlow(
session,
intentId,
IdentityLinkBrowserPhase.REAUTHENTICATE,
"github",
CONTEXT);
manager.activateBrowserFlow(
session,
"github",
"expected-state");
MockHttpServletRequest callback =
new MockHttpServletRequest(
"GET",
"/login/oauth2/code/github");
callback.setSession(session);
callback.setParameter("state", "different-state");
assertThatThrownBy(() ->
manager.consumeBrowserFlow(
callback,
"github",
CONTEXT))
.isInstanceOfSatisfying(
IdentityLinkException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
IdentityLinkFailureCode
.SESSION_MISMATCH));
assertThat(manager.consumeBrowserFlow(
callback,
"github",
CONTEXT)).isEmpty();
}
@Test
void failedBrowserFlowKeepsIntentAndCanBeRetried() {
UUID intentId = UUID.randomUUID();
manager.start(session, intentId, CONTEXT);
manager.prepareBrowserFlow(
session,
intentId,
IdentityLinkBrowserPhase.LINK,
"github",
CONTEXT);
manager.activateBrowserFlow(
session,
"github",
"oauth-state");
assertThat(manager.consumeFailedBrowserFlow(session))
.contains(intentId);
assertThat(manager.consumeFailedBrowserFlow(session))
.isEmpty();
assertThat(manager.actor(session, intentId, CONTEXT).userId())
.isEqualTo("usr_1");
manager.prepareBrowserFlow(
session,
intentId,
IdentityLinkBrowserPhase.LINK,
"github",
CONTEXT);
manager.activateBrowserFlow(
session,
"github",
"retry-state");
MockHttpServletRequest retryCallback =
new MockHttpServletRequest(
"GET",
"/login/oauth2/code/github");
retryCallback.setSession(session);
retryCallback.setParameter("state", "retry-state");
assertThat(manager.consumeBrowserFlow(
retryCallback,
"github",
CONTEXT))
.map(IdentityLinkBrowserFlow::intentId)
.contains(intentId);
}
}

View file

@ -80,7 +80,8 @@ class IdentityResolutionTransactionTest {
auditLogService);
when(subjectRepository.findMatchingSubjects(any(), any()))
.thenReturn(List.of());
when(bindingRepository.findByProviderCodeAndSubject(
when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
any(),
any(),
any())).thenReturn(Optional.empty());
when(userRepository.save(any(UserAccount.class)))
@ -309,9 +310,11 @@ class IdentityResolutionTransactionTest {
"123456",
true);
UserAccount user = user("usr_1", UserStatus.ACTIVE, false);
when(bindingRepository.findByProviderCodeAndSubject(
when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"github",
"123456")).thenReturn(Optional.of(binding));
"123456",
IdentityBindingStatus.ACTIVE))
.thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
@ -394,9 +397,11 @@ class IdentityResolutionTransactionTest {
when(subjectRepository.findMatchingSubjects(
org.mockito.ArgumentMatchers.eq("provider"),
any())).thenReturn(List.of(alias, stable));
when(bindingRepository.findByProviderCodeAndSubject(
when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"provider",
"legacy-123")).thenReturn(Optional.of(binding));
"legacy-123",
IdentityBindingStatus.ACTIVE))
.thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
@ -507,9 +512,11 @@ class IdentityResolutionTransactionTest {
"github",
"123456");
UserAccount user = user("usr_1", UserStatus.PENDING, false);
when(bindingRepository.findByProviderCodeAndSubject(
when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"github",
"123456")).thenReturn(Optional.of(binding));
"123456",
IdentityBindingStatus.ACTIVE))
.thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
@ -554,9 +561,11 @@ class IdentityResolutionTransactionTest {
"github",
"123456");
UserAccount user = user("usr_1", UserStatus.PENDING, false);
when(bindingRepository.findByProviderCodeAndSubject(
when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"github",
"123456")).thenReturn(Optional.of(binding));
"123456",
IdentityBindingStatus.ACTIVE))
.thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
@ -614,9 +623,11 @@ class IdentityResolutionTransactionTest {
"github",
"123456");
UserAccount user = user("usr_blocked", status, system);
when(bindingRepository.findByProviderCodeAndSubject(
when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"github",
"123456")).thenReturn(Optional.of(binding));
"123456",
IdentityBindingStatus.ACTIVE))
.thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.identity.AccountLoginGuard;
import com.iflytek.skillhub.auth.identity.PlatformPrincipalFactory;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
@ -99,7 +100,7 @@ class LocalAuthServiceTest {
given(role.getCode()).willReturn("USER_ADMIN");
UserRoleBinding binding = new UserRoleBinding("usr_1", role);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true);
given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of(binding));
@ -116,7 +117,7 @@ class LocalAuthServiceTest {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);
@ -135,7 +136,7 @@ class LocalAuthServiceTest {
credential.setFailedAttempts(4);
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);
@ -153,7 +154,7 @@ class LocalAuthServiceTest {
credential.setLockedUntil(Instant.now(CLOCK).plusSeconds(5 * 60));
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
@ -163,7 +164,7 @@ class LocalAuthServiceTest {
@Test
void login_withUnknownUsername_stillPerformsDummyPasswordCheck() {
given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty());
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("ghost")).willReturn(Optional.empty());
given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu")))
.willReturn(false);
@ -182,7 +183,7 @@ class LocalAuthServiceTest {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.DISABLED);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
@ -196,7 +197,7 @@ class LocalAuthServiceTest {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.PENDING);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
@ -210,7 +211,7 @@ class LocalAuthServiceTest {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.MERGED);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
@ -225,7 +226,7 @@ class LocalAuthServiceTest {
UserAccount user = UserAccount.systemAccount(
"system_1", "system", null, null);
given(credentialRepository.findByUsernameIgnoreCase("system"))
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("system"))
.willReturn(Optional.of(credential));
given(userAccountRepository.findById("system_1"))
.willReturn(Optional.of(user));
@ -242,7 +243,7 @@ class LocalAuthServiceTest {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true);
given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of());
@ -252,6 +253,77 @@ class LocalAuthServiceTest {
assertThat(principal.platformRoles()).containsExactly("USER");
}
@Test
void reauthenticate_withCurrentUsersPassword_returnsPrincipal() {
LocalCredential credential =
new LocalCredential("usr_1", "alice", "encoded");
UserAccount user =
new UserAccount(
"usr_1",
"alice",
"alice@example.com",
null);
given(credentialRepository.findByUserIdForUpdate("usr_1"))
.willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1"))
.willReturn(Optional.of(user));
given(passwordEncoder.matches("Abcd123!", "encoded"))
.willReturn(true);
given(userRoleBindingRepository.findByUserId("usr_1"))
.willReturn(List.of());
PlatformPrincipal principal = service.reauthenticate(
"usr_1",
"Abcd123!");
assertThat(principal.userId()).isEqualTo("usr_1");
assertThat(principal.oauthProvider()).isEqualTo("local");
verify(credentialRepository).save(credential);
}
@Test
void reauthenticate_withInvalidPassword_updatesLockCounters() {
LocalCredential credential =
new LocalCredential("usr_1", "alice", "encoded");
credential.setFailedAttempts(4);
UserAccount user =
new UserAccount(
"usr_1",
"alice",
"alice@example.com",
null);
given(credentialRepository.findByUserIdForUpdate("usr_1"))
.willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1"))
.willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded"))
.willReturn(false);
assertThatThrownBy(() ->
service.reauthenticate("usr_1", "bad"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(credential.getFailedAttempts()).isEqualTo(5);
assertThat(credential.getLockedUntil())
.isEqualTo(Instant.now(CLOCK).plusSeconds(15 * 60));
verify(credentialRepository).save(credential);
}
@Test
void reauthenticate_withoutLocalCredential_doesNotCheckPassword() {
given(credentialRepository.findByUserIdForUpdate("oauth-only"))
.willReturn(Optional.empty());
assertThatThrownBy(() ->
service.reauthenticate("oauth-only", "secret"))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("error.auth.local.notEnabled");
verify(passwordEncoder, never()).matches(any(), any());
}
@Test
void changePassword_withoutLocalCredential_rejectsRequest() {
given(credentialRepository.findByUserId("oauth-only")).willReturn(Optional.empty());

View file

@ -11,6 +11,7 @@ import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -25,6 +26,7 @@ class IdentityProviderRouteReadinessFilterTest {
private ClientRegistrationRepository registrationRepository;
private IdentityProviderReadinessService readinessService;
private ClientRegistration registration;
private OAuth2LoginFailureHandler failureHandler;
private IdentityProviderRouteReadinessFilter filter;
@BeforeEach
@ -33,12 +35,15 @@ class IdentityProviderRouteReadinessFilterTest {
ClientRegistrationRepository.class);
readinessService = mock(
IdentityProviderReadinessService.class);
failureHandler = mock(
OAuth2LoginFailureHandler.class);
registration = registration();
when(registrationRepository.findByRegistrationId("github"))
.thenReturn(registration);
filter = new IdentityProviderRouteReadinessFilter(
registrationRepository,
readinessService);
readinessService,
failureHandler);
}
@Test
@ -75,6 +80,32 @@ class IdentityProviderRouteReadinessFilterTest {
verify(chain, never()).doFilter(request, response);
}
@Test
void mismatchCallbackRedirectsOwnedIdentityLinkFlow()
throws Exception {
FilterChain chain = mock(FilterChain.class);
doThrow(new IdentityCoreException(
IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH))
.when(readinessService).requireReady(registration);
MockHttpServletRequest request = request(
"/login/oauth2/code/github");
MockHttpServletResponse response =
new MockHttpServletResponse();
when(failureHandler.redirectIdentityLinkRouteFailure(
request,
response,
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE))
.thenReturn(true);
filter.doFilter(request, response, chain);
verify(failureHandler).redirectIdentityLinkRouteFailure(
request,
response,
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
verify(chain, never()).doFilter(request, response);
}
@Test
void disabledAuthorizationRouteIsRejectedBeforeRedirect()
throws Exception {

View file

@ -1,13 +1,25 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import jakarta.servlet.http.HttpSession;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@ -15,6 +27,7 @@ import static org.mockito.Mockito.mock;
class OAuth2AuthorizationRequestResolverTest {
private SkillHubOAuth2AuthorizationRequestResolver resolver;
private OAuthLoginFlowService oauthLoginFlowService;
@BeforeEach
void setUp() {
@ -30,17 +43,64 @@ class OAuth2AuthorizationRequestResolverTest {
.scope("read:user")
.clientName("GitHub")
.build();
OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService(
oauthLoginFlowService = new OAuthLoginFlowService(
java.util.List.of(),
mock(TrustedProviderRouteResolver.class),
mock(ExternalIdentityLoginService.class)
mock(ExternalIdentityLoginService.class),
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class)
);
resolver = new SkillHubOAuth2AuthorizationRequestResolver(
new InMemoryClientRegistrationRepository(github),
oauthLoginFlowService
oauthLoginFlowService,
mock(IdentityLinkSessionManager.class)
);
}
@Test
void resolve_preservesReturnToAcrossCallbackUntilSuccessHandler()
throws Exception {
String returnTo =
"/settings/security?identityLink=linked"
+ "&intentId=7d26c414-6040-48b5-b025-53a16b8aa6b9";
MockHttpServletRequest authorizationRequest =
oauthRequest("/oauth2/authorization/github");
authorizationRequest.setParameter("returnTo", returnTo);
assertThat(resolver.resolve(authorizationRequest)).isNotNull();
MockHttpSession session =
(MockHttpSession) authorizationRequest.getSession(false);
assertThat(session).isNotNull();
assertThat(session.getAttribute(
OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE))
.isEqualTo(returnTo);
MockHttpServletRequest callbackRequest =
oauthRequest("/login/oauth2/code/github");
callbackRequest.setSession(session);
assertThat(resolver.resolve(callbackRequest)).isNull();
assertThat(session.getAttribute(
OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE))
.isEqualTo(returnTo);
OAuth2LoginSuccessHandler successHandler =
new OAuth2LoginSuccessHandler(
new PlatformSessionService(),
oauthLoginFlowService);
MockHttpServletResponse response =
new MockHttpServletResponse();
successHandler.onAuthenticationSuccess(
callbackRequest,
response,
oauthAuthentication());
assertThat(response.getRedirectedUrl()).isEqualTo(returnTo);
assertThat(session.getAttribute(
OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE))
.isNull();
}
@Test
void resolve_storesSanitizedReturnToInSession() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github");
@ -65,4 +125,41 @@ class OAuth2AuthorizationRequestResolverTest {
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
@Test
void resolve_nonAuthorizationRequestDoesNotCreateSession() {
MockHttpServletRequest request =
oauthRequest("/login/oauth2/code/github");
assertThat(resolver.resolve(request)).isNull();
assertThat(request.getSession(false)).isNull();
}
private MockHttpServletRequest oauthRequest(String path) {
MockHttpServletRequest request =
new MockHttpServletRequest("GET", path);
request.setServletPath(path);
return request;
}
private Authentication oauthAuthentication() {
PlatformPrincipal principal = new PlatformPrincipal(
"user-1",
"User",
"user@example.com",
null,
"github",
Set.of());
return new UsernamePasswordAuthenticationToken(
new DefaultOAuth2User(
List.of(),
Map.of(
"platformPrincipal",
principal,
"login",
"user"),
"login"),
null,
List.of());
}
}

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import jakarta.servlet.http.HttpSession;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
@ -15,7 +17,9 @@ import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@ -104,7 +108,9 @@ class OAuth2LoginHandlersTest {
@Test
void failureHandler_redirectsBackToLoginWithReturnTo() throws Exception {
OAuthLoginFlowService oauthLoginFlowService = mock(OAuthLoginFlowService.class);
OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(oauthLoginFlowService);
OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(
oauthLoginFlowService,
mock(IdentityLinkSessionManager.class));
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HttpSession session = request.getSession(true);
@ -130,4 +136,129 @@ class OAuth2LoginHandlersTest {
assertThat(response.getRedirectedUrl()).isEqualTo("/login?returnTo=%2Fsettings%2Faccounts");
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
@Test
void failureHandler_preservesIdentityLinkIntentForRetry()
throws Exception {
OAuthLoginFlowService oauthLoginFlowService =
mock(OAuthLoginFlowService.class);
IdentityLinkSessionManager sessionManager =
mock(IdentityLinkSessionManager.class);
OAuth2LoginFailureHandler handler =
new OAuth2LoginFailureHandler(
oauthLoginFlowService,
sessionManager);
MockHttpServletRequest request =
new MockHttpServletRequest();
MockHttpServletResponse response =
new MockHttpServletResponse();
HttpSession session = request.getSession(true);
UUID intentId = UUID.randomUUID();
org.mockito.Mockito.when(
sessionManager.consumeFailedBrowserFlow(session))
.thenReturn(Optional.of(intentId));
handler.onAuthenticationFailure(
request,
response,
new OAuth2AuthenticationException(
new OAuth2Error("access_denied")));
assertThat(response.getRedirectedUrl())
.isEqualTo(
"/settings/security?identityLink=failed"
+ "&intentId="
+ intentId
+ "&reasonCode="
+ "PROVIDER_AUTHENTICATION_FAILED");
org.mockito.Mockito.verify(
oauthLoginFlowService,
org.mockito.Mockito.never())
.resolveFailureRedirect(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any());
}
@Test
void failureHandler_preservesStableIdentityLinkReasonCode()
throws Exception {
OAuthLoginFlowService oauthLoginFlowService =
mock(OAuthLoginFlowService.class);
IdentityLinkSessionManager sessionManager =
mock(IdentityLinkSessionManager.class);
OAuth2LoginFailureHandler handler =
new OAuth2LoginFailureHandler(
oauthLoginFlowService,
sessionManager);
MockHttpServletRequest request =
new MockHttpServletRequest();
MockHttpServletResponse response =
new MockHttpServletResponse();
HttpSession session = request.getSession(true);
UUID intentId = UUID.randomUUID();
OAuth2AuthenticationException failure =
new OAuth2AuthenticationException(
new OAuth2Error(
"identity_link_failed",
"PROVIDER_UNAVAILABLE",
null));
org.mockito.Mockito.when(
sessionManager.consumeFailedBrowserFlow(session))
.thenReturn(Optional.of(intentId));
org.mockito.Mockito.when(
oauthLoginFlowService
.identityLinkFailureReasonCode(failure))
.thenReturn(Optional.of(
"PROVIDER_UNAVAILABLE"));
handler.onAuthenticationFailure(
request,
response,
failure);
assertThat(response.getRedirectedUrl())
.isEqualTo(
"/settings/security?identityLink=failed"
+ "&intentId="
+ intentId
+ "&reasonCode=PROVIDER_UNAVAILABLE");
}
@Test
void routeFailureRedirectsOnlyAnOwnedIdentityLinkFlow()
throws Exception {
OAuthLoginFlowService oauthLoginFlowService =
mock(OAuthLoginFlowService.class);
IdentityLinkSessionManager sessionManager =
mock(IdentityLinkSessionManager.class);
OAuth2LoginFailureHandler handler =
new OAuth2LoginFailureHandler(
oauthLoginFlowService,
sessionManager);
MockHttpServletRequest request =
new MockHttpServletRequest();
MockHttpServletResponse response =
new MockHttpServletResponse();
HttpSession session = request.getSession(true);
UUID intentId = UUID.randomUUID();
org.mockito.Mockito.when(
sessionManager.consumeFailedBrowserFlow(session))
.thenReturn(Optional.of(intentId));
boolean redirected =
handler.redirectIdentityLinkRouteFailure(
request,
response,
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
assertThat(redirected).isTrue();
assertThat(response.getRedirectedUrl())
.isEqualTo(
"/settings/security?identityLink=failed"
+ "&intentId="
+ intentId
+ "&reasonCode=PROVIDER_UNAVAILABLE");
org.mockito.Mockito.verify(oauthLoginFlowService)
.consumeReturnTo(session);
}
}

View file

@ -13,10 +13,16 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityLinkActor;
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow;
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase;
import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
@ -29,6 +35,9 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.mock.web.MockHttpServletRequest;
@ -39,9 +48,16 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
class OAuthLoginFlowServiceTest {
@AfterEach
void resetRequestContext() {
RequestContextHolder.resetRequestAttributes();
}
@Test
void resolvesReadyRouteBeforeOAuthUpstreamAndAdapterCalls() {
OAuthClaimsExtractor extractor = mock(OAuthClaimsExtractor.class);
@ -74,6 +90,8 @@ class OAuthLoginFlowServiceTest {
List.of(extractor),
resolver,
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class),
delegate);
clearInvocations(extractor);
@ -114,6 +132,8 @@ class OAuthLoginFlowServiceTest {
List.of(extractor),
resolver,
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class),
delegate);
clearInvocations(extractor);
@ -139,7 +159,9 @@ class OAuthLoginFlowServiceTest {
new OAuthLoginFlowService(
List.of(),
resolver,
identityLoginService);
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
PlatformPrincipal principal = principal();
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.Authenticated(
@ -170,7 +192,9 @@ class OAuthLoginFlowServiceTest {
new OAuthLoginFlowService(
List.of(),
resolver,
identityLoginService);
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.PendingApproval(
"ACCOUNT_PENDING"));
@ -193,7 +217,9 @@ class OAuthLoginFlowServiceTest {
new OAuthLoginFlowService(
List.of(),
resolver,
identityLoginService);
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.LinkRequired(
"EMAIL_COLLISION"));
@ -228,7 +254,9 @@ class OAuthLoginFlowServiceTest {
new OAuthLoginFlowService(
List.of(),
resolver,
identityLoginService);
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenThrow(new IdentityCoreException(
IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH));
@ -246,6 +274,68 @@ class OAuthLoginFlowServiceTest {
"provider_authority_mismatch"));
}
@Test
void browserLinkFlowDoesNotRunNormalLoginOrReplacePrimaryAccount() {
TrustedProviderRouteResolver resolver =
mock(TrustedProviderRouteResolver.class);
ExternalIdentityLoginService identityLoginService =
mock(ExternalIdentityLoginService.class);
ExternalIdentityLinkService identityLinkService =
mock(ExternalIdentityLinkService.class);
IdentityLinkSessionManager sessionManager =
mock(IdentityLinkSessionManager.class);
OAuthLoginFlowService service =
new OAuthLoginFlowService(
List.of(),
resolver,
identityLoginService,
identityLinkService,
sessionManager);
ResolvedProviderHandle provider =
ResolvedProviderHandleTestFixture.handle("github");
UUID intentId = UUID.randomUUID();
IdentityLinkActor actor = new IdentityLinkActor(
"usr_1",
"local",
"high-entropy-session-nonce",
context());
MockHttpServletRequest request =
new MockHttpServletRequest(
"GET",
"/login/oauth2/code/github");
request.getSession(true);
RequestContextHolder.setRequestAttributes(
new ServletRequestAttributes(request));
when(sessionManager.consumeBrowserFlow(
request,
"github",
context()))
.thenReturn(Optional.of(
new IdentityLinkBrowserFlow(
intentId,
IdentityLinkBrowserPhase.LINK,
actor)));
when(identityLinkService.link(
actor,
intentId,
provider,
result()))
.thenReturn(new IdentityLinkOutcome.Linked(
principal(),
42L));
PlatformPrincipal linked = service.authenticate(
provider,
result(),
context());
assertThat(linked.userId()).isEqualTo("usr_1");
verifyNoInteractions(identityLoginService);
verify(sessionManager).remove(
request.getSession(false),
intentId);
}
@Test
void rememberReturnToStoresSanitizedReturnTarget() {
OAuthLoginFlowService service = service();
@ -273,6 +363,46 @@ class OAuthLoginFlowServiceTest {
assertThat(redirect).isEqualTo("/access-denied");
}
@Test
void identityLinkFailureRedirectPreservesResumableIntent() {
UUID intentId = UUID.randomUUID();
String redirect = service().resolveFailureRedirect(
new OAuth2AuthenticationException(
new OAuth2Error("identity_link_failed")),
"/settings/security?identityLink=linked"
+ "&intentId="
+ intentId);
assertThat(redirect)
.isEqualTo(
"/settings/security?identityLink=failed"
+ "&intentId="
+ intentId);
}
@Test
void identityLinkFailureRedirectIncludesStableReasonCode() {
UUID intentId = UUID.randomUUID();
String redirect = service().resolveFailureRedirect(
new OAuth2AuthenticationException(
new OAuth2Error(
"identity_link_failed",
"PROVIDER_UNAVAILABLE",
null)),
"/settings/security?identityLink=linked"
+ "&intentId="
+ intentId);
assertThat(redirect)
.isEqualTo(
"/settings/security?identityLink=failed"
+ "&intentId="
+ intentId
+ "&reasonCode=PROVIDER_UNAVAILABLE");
}
@Test
void resolveFailureRedirectMapsMergedAccountToAccessDenied() {
assertThat(service().resolveFailureRedirect(
@ -308,7 +438,9 @@ class OAuthLoginFlowServiceTest {
return new OAuthLoginFlowService(
List.of(),
mock(TrustedProviderRouteResolver.class),
mock(ExternalIdentityLoginService.class));
mock(ExternalIdentityLoginService.class),
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
}
private static ProviderAuthenticationResult result() {

View file

@ -1,7 +1,7 @@
import { expect, test, type Page } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { csrfHeaders } from './helpers/csrf'
import { loginWithCredentials } from './helpers/session'
import { createFreshSession, loginWithCredentials } from './helpers/session'
function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim()
@ -15,6 +15,20 @@ function adminCredentials() {
}
}
function gitLabIdentityLinkE2EEnabled(): boolean {
return getOptionalEnv('E2E_IDENTITY_LINK_BROWSER_PROVIDER') === 'gitlab'
}
function requireGitLabIdentityLinkE2E(): void {
const enabled = gitLabIdentityLinkE2EEnabled()
if (!enabled && process.env.CI) {
throw new Error(
'E2E_IDENTITY_LINK_BROWSER_PROVIDER=gitlab is required in CI',
)
}
test.skip(!enabled, 'requires the dedicated GitLab identity-link test provider')
}
async function currentDisplayName(page: Page, headers?: Record<string, string>): Promise<string> {
const response = await page.context().request.get('/api/v1/auth/me', { headers })
expect(response.ok()).toBeTruthy()
@ -23,7 +37,9 @@ async function currentDisplayName(page: Page, headers?: Record<string, string>):
}
test.describe('Security Settings capability (Real API)', () => {
test.use({ baseURL: 'http://127.0.0.1:3000' })
test.use({
baseURL: getOptionalEnv('E2E_BASE_URL') ?? 'http://127.0.0.1:3000',
})
test('shows the security menu entry and password form for local admin accounts', async ({ page }, testInfo) => {
await setEnglishLocale(page)
@ -32,6 +48,8 @@ test.describe('Security Settings capability (Real API)', () => {
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Login Methods', exact: true })).toBeVisible()
await expect(page.getByText('Local password', { exact: true }).first()).toBeVisible()
await expect(page.getByLabel('Current Password')).toBeVisible()
await expect(page.getByLabel('New Password')).toBeVisible()
@ -39,6 +57,125 @@ test.describe('Security Settings capability (Real API)', () => {
await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible()
})
test('requires fresh local reauthentication before linking an external provider', async ({ page }, testInfo) => {
requireGitLabIdentityLinkE2E()
await setEnglishLocale(page)
const credentials = await createFreshSession(page, testInfo)
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Login Methods', exact: true })).toBeVisible()
const addButton = page.getByRole('button', { name: 'Add' }).first()
await expect(addButton).toBeVisible()
await addButton.click()
const dialog = page.getByRole('dialog', { name: 'Verify account control' })
await expect(dialog).toBeVisible()
await dialog.getByLabel('Local password').fill(credentials.password)
await dialog.getByRole('button', { name: 'Verify password' }).click()
await expect(dialog.getByText(
'Current account verified. Now authenticate the login method you want to link.',
)).toBeVisible()
await expect(dialog.getByRole('button', { name: /^Continue with / })).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toBeHidden()
})
test('links, unlinks, and relinks a browser identity through the deployed stack', async ({ page }, testInfo) => {
requireGitLabIdentityLinkE2E()
await setEnglishLocale(page)
const credentials = await createFreshSession(page, testInfo)
await page.goto('/settings/security')
const availableMethods = page.locator(
'section[aria-labelledby="available-login-methods"]',
)
const linkedMethods = page.locator(
'section[aria-labelledby="linked-login-methods"]',
)
async function linkGitLab() {
await expect(availableMethods.getByText('GitLab', { exact: true })).toBeVisible()
await availableMethods.getByRole('button', { name: 'Add' }).click()
const dialog = page.getByRole('dialog', { name: 'Verify account control' })
await dialog.getByLabel('Local password').fill(credentials.password)
await dialog.getByRole('button', { name: 'Verify password' }).click()
await expect(dialog.getByRole('button', { name: 'Continue with GitLab' })).toBeVisible()
await dialog.getByRole('button', { name: 'Continue with GitLab' }).click()
await page.waitForURL(/identityLink=linked/)
await expect(page.getByText('The login method was linked successfully.')).toBeVisible()
await expect(linkedMethods.getByText('GitLab', { exact: true })).toBeVisible()
}
await linkGitLab()
await linkedMethods.getByRole('button', { name: 'Remove' }).click()
const unlinkDialog = page.getByRole('dialog', { name: 'Verify account control' })
await unlinkDialog.getByLabel('Local password').fill(credentials.password)
await unlinkDialog.getByRole('button', { name: 'Verify password' }).click()
await expect(
unlinkDialog.getByRole('button', { name: 'Remove login method' }),
).toBeVisible()
await unlinkDialog.getByRole('button', { name: 'Remove login method' }).click()
await expect(unlinkDialog).toBeHidden()
await expect(availableMethods.getByText('GitLab', { exact: true })).toBeVisible()
await linkGitLab()
})
test('redirects an unavailable identity-link provider with a stable reason code', async ({ page }, testInfo) => {
requireGitLabIdentityLinkE2E()
await setEnglishLocale(page)
const credentials = await createFreshSession(page, testInfo)
const request = page.context().request
const createIntent = await request.post('/api/v1/auth/identity-link-intents/link', {
data: { providerCode: 'gitlab' },
headers: await csrfHeaders(page),
})
expect(createIntent.ok()).toBeTruthy()
const createBody = await createIntent.json() as { data: { id: string } }
const intentId = createBody.data.id
const reauthenticate = await request.post(
`/api/v1/auth/identity-link-intents/${intentId}/reauthenticate/local`,
{
data: { password: credentials.password },
headers: await csrfHeaders(page),
},
)
expect(reauthenticate.ok()).toBeTruthy()
const prepareLink = await request.post(
`/api/v1/auth/identity-link-intents/${intentId}/link/browser`,
{ headers: await csrfHeaders(page) },
)
expect(prepareLink.ok()).toBeTruthy()
const prepareBody = await prepareLink.json() as { data: { actionUrl: string } }
const unavailableActionUrl = prepareBody.data.actionUrl.replace(
'/oauth2/authorization/gitlab',
'/oauth2/authorization/missing-provider',
)
expect(unavailableActionUrl).not.toBe(prepareBody.data.actionUrl)
const failure = await request.get(unavailableActionUrl, { maxRedirects: 0 })
expect(failure.status()).toBe(302)
const location = failure.headers().location
expect(location).toBeTruthy()
const redirect = new URL(location, 'http://127.0.0.1')
expect(redirect.pathname).toBe('/settings/security')
expect(redirect.searchParams.get('identityLink')).toBe('failed')
expect(redirect.searchParams.get('intentId')).toBe(intentId)
expect(redirect.searchParams.get('reasonCode')).toBe('PROVIDER_UNAVAILABLE')
const ordinaryOAuth = await request.get(
'/oauth2/authorization/missing-provider',
{ maxRedirects: 0 },
)
expect(ordinaryOAuth.status()).toBe(403)
})
test('hides the security menu entry and rejects password changes without a local credential', async ({ page }) => {
await setEnglishLocale(page)
await page.context().setExtraHTTPHeaders({

View file

@ -29,10 +29,12 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort',
url: 'http://127.0.0.1:3000',
reuseExistingServer: true,
timeout: 120000,
},
webServer: process.env.E2E_BASE_URL
? undefined
: {
command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort',
url: 'http://127.0.0.1:3000',
reuseExistingServer: true,
timeout: 120000,
},
})

View file

@ -24,11 +24,19 @@ vi.mock('@/shared/lib/api-error', () => ({
status: number
serverMessage?: string
serverMessageKey?: string
constructor(message: string, status: number, serverMessage?: string, serverMessageKey?: string) {
reasonCode?: string
constructor(
message: string,
status: number,
serverMessage?: string,
serverMessageKey?: string,
reasonCode?: string,
) {
super(message)
this.status = status
this.serverMessage = serverMessage
this.serverMessageKey = serverMessageKey
this.reasonCode = reasonCode
}
},
handleApiError: vi.fn(),
@ -40,6 +48,7 @@ import {
fetchText,
getDirectAuthRuntimeConfig,
getSessionBootstrapRuntimeConfig,
identityLinkApi,
namespaceApi,
} from './client'
@ -163,6 +172,131 @@ describe('namespaceApi.delete', () => {
})
})
describe('identityLinkApi', () => {
it('normalizes the login-method account state', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
code: 0,
msg: 'ok',
data: {
localPasswordEnabled: true,
linkedProviders: [{
bindingId: 41,
providerCode: 'github',
displayName: 'GitHub',
methodTypes: ['OAUTH_REDIRECT'],
usable: true,
canUnlink: true,
}],
availableProviders: [{
providerCode: 'oidc',
displayName: 'Company OIDC',
methodTypes: ['OAUTH_REDIRECT'],
}],
},
timestamp: '2026-07-31T00:00:00Z',
requestId: 'req-identity-link',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
await expect(identityLinkApi.getAccountState()).resolves.toEqual({
localPasswordEnabled: true,
linkedProviders: [{
bindingId: 41,
providerCode: 'github',
displayName: 'GitHub',
methodTypes: ['OAUTH_REDIRECT'],
usable: true,
canUnlink: true,
}],
availableProviders: [{
providerCode: 'oidc',
displayName: 'Company OIDC',
methodTypes: ['OAUTH_REDIRECT'],
}],
})
})
it('creates a session-bound link intent with CSRF protection', async () => {
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
cookie: 'XSRF-TOKEN=identity-link-csrf',
},
})
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
code: 0,
msg: 'ok',
data: {
id: 'a0b89f51-a892-4b73-bdac-63df2cb14691',
operation: 'LINK',
status: 'PENDING_REAUTHENTICATION',
providerCode: 'github',
expiresAt: '2026-07-31T00:10:00Z',
},
timestamp: '2026-07-31T00:00:00Z',
requestId: 'req-identity-link',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
const intent = await identityLinkApi.createLinkIntent('github')
expect(intent.status).toBe('PENDING_REAUTHENTICATION')
const request = fetchMock.mock.calls[0]?.[0] as Request
expect(request.url).toBe(
'http://localhost/api/v1/auth/identity-link-intents/link',
)
expect(request.method).toBe('POST')
await expect(request.clone().json()).resolves.toEqual({
providerCode: 'github',
})
expect(request.headers.get('X-XSRF-TOKEN'))
.toBe('identity-link-csrf')
})
it('preserves stable identity-link failure reason codes', async () => {
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
cookie: 'XSRF-TOKEN=identity-link-csrf',
},
})
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
code: 409,
msg: 'Keep another login method.',
reasonCode: 'FINAL_LOGIN_METHOD',
timestamp: '2026-07-31T00:00:00Z',
requestId: 'req-identity-link-error',
}), {
status: 409,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
await expect(
identityLinkApi.completeUnlink(
'a0b89f51-a892-4b73-bdac-63df2cb14691',
),
).rejects.toMatchObject({
status: 409,
reasonCode: 'FINAL_LOGIN_METHOD',
})
})
})
describe('getDirectAuthRuntimeConfig', () => {
it('returns disabled when no runtime config is present', () => {
const config = getDirectAuthRuntimeConfig()

View file

@ -1,5 +1,5 @@
import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
import type { components, paths } from './generated/schema'
import type {
ChangePasswordRequest,
PasswordResetConfirmRequest,
@ -29,6 +29,12 @@ import type {
PagedResponse,
ReportDisposition,
AuthMethod,
IdentityLinkAccountState,
IdentityLinkBinding,
IdentityLinkCredentialRequest,
IdentityLinkIntent,
IdentityLinkProvider,
IdentityProviderLoginMethodType,
OAuthProvider,
User,
ManagedNamespace,
@ -85,6 +91,22 @@ function getApiBaseUrl(): string {
return getRuntimeConfig().apiBaseUrl ?? ''
}
function getOpenApiBaseUrl(): string {
const configured = getApiBaseUrl()
if (/^https?:\/\//i.test(configured)) {
return configured
}
if (
typeof window !== 'undefined'
&& typeof window.location?.origin === 'string'
) {
return configured
? prependApiBaseUrl(window.location.origin, configured)
: window.location.origin
}
return configured || 'http://localhost'
}
function parseBooleanFlag(value: string | undefined): boolean {
if (!value) {
return false
@ -92,7 +114,10 @@ function parseBooleanFlag(value: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
}
const client = createClient<paths>({ baseUrl: getApiBaseUrl() })
const client = createClient<paths>({
baseUrl: getOpenApiBaseUrl(),
fetch: (request) => globalThis.fetch(request),
})
function getCsrfToken(): string | null {
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
@ -170,6 +195,7 @@ type ApiEnvelope<T> = {
code: number
msg: string
data: T
reasonCode?: string
timestamp: string
requestId: string
}
@ -415,6 +441,352 @@ export const authApi = {
},
}
type IdentityLinkIntentSchema = components['schemas']['IdentityLinkIntentResponse']
type IdentityLinkAccountStateSchema =
components['schemas']['IdentityLinkAccountStateResponse']
type IdentityLinkBindingSchema = components['schemas']['IdentityLinkBindingResponse']
type IdentityLinkProviderSchema = components['schemas']['IdentityLinkProviderResponse']
type IdentityLinkBrowserStartSchema =
components['schemas']['IdentityLinkBrowserStartResponse']
type IdentityLinkErrorSchema =
components['schemas']['IdentityLinkErrorResponse']
function normalizeIdentityLinkMethodTypes(
methodTypes: IdentityLinkBindingSchema['methodTypes']
| IdentityLinkProviderSchema['methodTypes'],
): IdentityProviderLoginMethodType[] {
return methodTypes ? [...methodTypes] : []
}
function normalizeIdentityLinkBinding(
binding: IdentityLinkBindingSchema,
): IdentityLinkBinding {
if (
binding.bindingId === undefined
|| !binding.providerCode
|| !binding.displayName
|| binding.usable === undefined
|| binding.canUnlink === undefined
) {
throw new ApiError('apiError.invalidResponse', 500)
}
return {
...binding,
bindingId: binding.bindingId,
providerCode: binding.providerCode,
displayName: binding.displayName,
methodTypes: normalizeIdentityLinkMethodTypes(binding.methodTypes),
usable: binding.usable,
canUnlink: binding.canUnlink,
}
}
function normalizeIdentityLinkProvider(
provider: IdentityLinkProviderSchema,
): IdentityLinkProvider {
if (!provider.providerCode || !provider.displayName) {
throw new ApiError('apiError.invalidResponse', 500)
}
return {
...provider,
providerCode: provider.providerCode,
displayName: provider.displayName,
methodTypes: normalizeIdentityLinkMethodTypes(provider.methodTypes),
}
}
function normalizeIdentityLinkIntent(
intent: IdentityLinkIntentSchema,
): IdentityLinkIntent {
if (
!intent.id
|| !intent.operation
|| !intent.status
|| !intent.providerCode
|| !intent.expiresAt
) {
throw new ApiError('apiError.invalidResponse', 500)
}
return {
...intent,
id: intent.id,
operation: intent.operation,
status: intent.status,
providerCode: intent.providerCode,
targetBindingId: intent.targetBindingId,
expiresAt: intent.expiresAt,
}
}
function normalizeIdentityLinkAccountState(
state: IdentityLinkAccountStateSchema,
): IdentityLinkAccountState {
return {
localPasswordEnabled: state.localPasswordEnabled === true,
linkedProviders: (state.linkedProviders ?? []).map(
normalizeIdentityLinkBinding,
),
availableProviders: (state.availableProviders ?? []).map(
normalizeIdentityLinkProvider,
),
}
}
function requireIdentityLinkActionUrl(
response: IdentityLinkBrowserStartSchema,
): string {
if (!response.actionUrl) {
throw new ApiError('apiError.invalidResponse', 500)
}
return response.actionUrl
}
type GeneratedApiEnvelope<T> = {
code?: number
msg?: string
data?: T
}
type OpenApiEnvelopeResult<T> = {
data?: GeneratedApiEnvelope<T>
error?: unknown
response: Response
}
function isApiFailureEnvelope(
value: unknown,
): value is IdentityLinkErrorSchema {
return typeof value === 'object'
&& value !== null
&& ('msg' in value || 'reasonCode' in value)
}
function unwrapOpenApiEnvelope<T>(
result: OpenApiEnvelopeResult<T>,
): T {
const failure = isApiFailureEnvelope(result.error)
? result.error
: undefined
const envelope = result.data
if (
!result.response.ok
|| result.error !== undefined
|| envelope?.code !== 0
|| envelope?.data === undefined
) {
const message = failure?.msg
|| envelope?.msg
|| `HTTP ${result.response.status}`
throw new ApiError(
message,
result.response.status,
failure?.msg,
failure?.msg,
failure?.reasonCode,
)
}
return envelope.data
}
export const identityLinkApi = {
async getAccountState(): Promise<IdentityLinkAccountState> {
const result = await client.GET(
'/api/v1/auth/identity-links',
{
headers: withRequestHeaders(),
},
)
return normalizeIdentityLinkAccountState(
unwrapOpenApiEnvelope<IdentityLinkAccountStateSchema>(
result,
),
)
},
async getIntent(intentId: string): Promise<IdentityLinkIntent> {
const result = await client.GET(
'/api/v1/auth/identity-link-intents/{intentId}',
{
params: {
path: { intentId },
},
headers: withRequestHeaders(),
},
)
return normalizeIdentityLinkIntent(
unwrapOpenApiEnvelope<IdentityLinkIntentSchema>(
result,
),
)
},
async createLinkIntent(providerCode: string): Promise<IdentityLinkIntent> {
const result = await client.POST(
'/api/v1/auth/identity-link-intents/link',
{
headers: await ensureCsrfHeaders(),
body: { providerCode },
},
)
return normalizeIdentityLinkIntent(
unwrapOpenApiEnvelope<IdentityLinkIntentSchema>(
result,
),
)
},
async createUnlinkIntent(bindingId: number): Promise<IdentityLinkIntent> {
const result = await client.POST(
'/api/v1/auth/identity-link-intents/unlink',
{
headers: await ensureCsrfHeaders(),
body: { bindingId },
},
)
return normalizeIdentityLinkIntent(
unwrapOpenApiEnvelope<IdentityLinkIntentSchema>(
result,
),
)
},
async cancel(intentId: string): Promise<IdentityLinkIntent> {
const result = await client.DELETE(
'/api/v1/auth/identity-link-intents/{intentId}',
{
params: {
path: { intentId },
},
headers: await ensureCsrfHeaders(),
},
)
return normalizeIdentityLinkIntent(
unwrapOpenApiEnvelope<IdentityLinkIntentSchema>(
result,
),
)
},
async reauthenticateLocal(
intentId: string,
password: string,
): Promise<IdentityLinkIntent> {
const result = await client.POST(
'/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/local',
{
params: {
path: { intentId },
},
headers: await ensureCsrfHeaders(),
body: { password },
},
)
return normalizeIdentityLinkIntent(
unwrapOpenApiEnvelope<IdentityLinkIntentSchema>(
result,
),
)
},
async prepareBrowserReauthentication(
intentId: string,
providerCode: string,
): Promise<string> {
const result = await client.POST(
'/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/browser',
{
params: {
path: { intentId },
},
headers: await ensureCsrfHeaders(),
body: { providerCode },
},
)
return requireIdentityLinkActionUrl(
unwrapOpenApiEnvelope<IdentityLinkBrowserStartSchema>(
result,
),
)
},
async reauthenticateCredential(
intentId: string,
providerCode: string,
credentials: IdentityLinkCredentialRequest,
): Promise<IdentityLinkIntent> {
const result = await client.POST(
'/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/credential',
{
params: {
path: { intentId },
},
headers: await ensureCsrfHeaders(),
body: { providerCode, ...credentials },
},
)
return normalizeIdentityLinkIntent(
unwrapOpenApiEnvelope<IdentityLinkIntentSchema>(
result,
),
)
},
async prepareBrowserLink(intentId: string): Promise<string> {
const result = await client.POST(
'/api/v1/auth/identity-link-intents/{intentId}/link/browser',
{
params: {
path: { intentId },
},
headers: await ensureCsrfHeaders(),
},
)
return requireIdentityLinkActionUrl(
unwrapOpenApiEnvelope<IdentityLinkBrowserStartSchema>(
result,
),
)
},
async linkCredential(
intentId: string,
credentials: IdentityLinkCredentialRequest,
): Promise<IdentityLinkIntent> {
const result = await client.POST(
'/api/v1/auth/identity-link-intents/{intentId}/link/credential',
{
params: {
path: { intentId },
},
headers: await ensureCsrfHeaders(),
body: credentials,
},
)
return normalizeIdentityLinkIntent(
unwrapOpenApiEnvelope<IdentityLinkIntentSchema>(
result,
),
)
},
async completeUnlink(intentId: string): Promise<IdentityLinkIntent> {
const result = await client.POST(
'/api/v1/auth/identity-link-intents/{intentId}/unlink',
{
params: {
path: { intentId },
},
headers: await ensureCsrfHeaders(),
},
)
return normalizeIdentityLinkIntent(
unwrapOpenApiEnvelope<IdentityLinkIntentSchema>(
result,
),
)
},
}
export const accountApi = {
async initiateMerge(request: MergeInitiateRequest): Promise<MergeInitiateResponse> {
return fetchJson<MergeInitiateResponse>('/api/v1/account/merge/initiate', {

File diff suppressed because it is too large Load diff

View file

@ -61,6 +61,57 @@ export interface ChangePasswordRequest {
newPassword: string
}
type IdentityLinkIntentSchema = components['schemas']['IdentityLinkIntentResponse']
type IdentityLinkBindingSchema = components['schemas']['IdentityLinkBindingResponse']
type IdentityLinkProviderSchema = components['schemas']['IdentityLinkProviderResponse']
export type IdentityLinkOperation = NonNullable<IdentityLinkIntentSchema['operation']>
export type IdentityLinkIntentStatus = NonNullable<IdentityLinkIntentSchema['status']>
export type IdentityProviderLoginMethodType =
NonNullable<IdentityLinkBindingSchema['methodTypes']>[number]
export type IdentityLinkIntent = Omit<
IdentityLinkIntentSchema,
'id' | 'operation' | 'status' | 'providerCode' | 'expiresAt'
> & {
id: string
operation: IdentityLinkOperation
status: IdentityLinkIntentStatus
providerCode: string
targetBindingId?: number
expiresAt: string
}
export type IdentityLinkBinding = Omit<
IdentityLinkBindingSchema,
'bindingId' | 'providerCode' | 'displayName' | 'methodTypes' | 'usable' | 'canUnlink'
> & {
bindingId: number
providerCode: string
displayName: string
methodTypes: IdentityProviderLoginMethodType[]
usable: boolean
canUnlink: boolean
}
export type IdentityLinkProvider = Omit<
IdentityLinkProviderSchema,
'providerCode' | 'displayName' | 'methodTypes'
> & {
providerCode: string
displayName: string
methodTypes: IdentityProviderLoginMethodType[]
}
export interface IdentityLinkAccountState {
localPasswordEnabled: boolean
linkedProviders: IdentityLinkBinding[]
availableProviders: IdentityLinkProvider[]
}
export type IdentityLinkCredentialRequest =
components['schemas']['IdentityLinkTargetCredentialRequest']
export interface PasswordResetRequest {
email: string
}

View file

@ -0,0 +1,205 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IdentityLinkAccountState } from '@/api/types'
let accountState: IdentityLinkAccountState
function mutation() {
return {
isPending: false,
mutateAsync: vi.fn(),
}
}
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>(
'react-i18next',
)
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/api/client', () => ({
buildApiUrl: (value: string) => value,
}))
vi.mock('./use-identity-links', () => ({
useIdentityLinkAccountState: () => ({
data: accountState,
isLoading: false,
error: null,
}),
useIdentityLinkIntent: () => ({
data: undefined,
isLoading: false,
error: null,
}),
useIdentityLinkActions: () => ({
createLink: mutation(),
createUnlink: mutation(),
cancel: mutation(),
reauthenticateLocal: mutation(),
prepareBrowserReauthentication: mutation(),
reauthenticateCredential: mutation(),
prepareBrowserLink: mutation(),
linkCredential: mutation(),
completeUnlink: mutation(),
}),
}))
import {
IdentityLinkManager,
parseIdentityLinkCallback,
resumableIdentityLinkIntentId,
} from './identity-link-manager'
beforeEach(() => {
vi.stubGlobal('window', {
location: {
search: '',
assign: vi.fn(),
},
})
accountState = {
localPasswordEnabled: true,
linkedProviders: [
{
bindingId: 41,
providerCode: 'github',
displayName: 'GitHub',
methodTypes: ['OAUTH_REDIRECT'],
usable: true,
canUnlink: true,
},
],
availableProviders: [
{
providerCode: 'oidc',
displayName: 'Company OIDC',
methodTypes: ['OAUTH_REDIRECT'],
},
],
}
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('parseIdentityLinkCallback', () => {
it('accepts only supported callback results', () => {
expect(parseIdentityLinkCallback(
'?identityLink=reauthenticated&intentId=intent-1',
)).toEqual({
result: 'reauthenticated',
intentId: 'intent-1',
})
expect(parseIdentityLinkCallback(
'?identityLink=unexpected&intentId=intent-1',
)).toEqual({})
expect(parseIdentityLinkCallback(
'?identityLink=failed&intentId=intent-1&reasonCode=PROVIDER_UNAVAILABLE',
)).toEqual({
result: 'failed',
intentId: 'intent-1',
reasonCode: 'PROVIDER_UNAVAILABLE',
})
expect(parseIdentityLinkCallback(
'?identityLink=failed&intentId=intent-1&reasonCode=UNKNOWN_UPPERCASE_CODE',
)).toEqual({
result: 'failed',
intentId: 'intent-1',
})
})
it('resumes failed or reauthenticated intents but not completed links', () => {
expect(resumableIdentityLinkIntentId({
result: 'failed',
intentId: 'intent-1',
})).toBe('intent-1')
expect(resumableIdentityLinkIntentId({
result: 'reauthenticated',
intentId: 'intent-2',
})).toBe('intent-2')
expect(resumableIdentityLinkIntentId({
result: 'linked',
intentId: 'intent-3',
})).toBeUndefined()
})
})
describe('IdentityLinkManager', () => {
it('renders local, linked, and available login methods', () => {
const html = renderToStaticMarkup(<IdentityLinkManager />)
expect(html).toContain('security.identityLinks.localPassword')
expect(html).toContain('GitHub')
expect(html).toContain('Company OIDC')
expect(html).toContain('security.identityLinks.remove')
expect(html).toContain('security.identityLinks.add')
})
it('disables removal when the binding is the final login method', () => {
accountState = {
localPasswordEnabled: false,
linkedProviders: [{
bindingId: 42,
providerCode: 'github',
displayName: 'GitHub',
methodTypes: ['OAUTH_REDIRECT'],
usable: true,
canUnlink: false,
}],
availableProviders: [],
}
const html = renderToStaticMarkup(<IdentityLinkManager />)
expect(html).toContain('security.identityLinks.finalMethodHint')
expect(html).toMatch(/<button[^>]*disabled/)
})
it('shows the browser-link success result after callback', () => {
vi.stubGlobal('window', {
location: {
search: '?identityLink=linked&intentId=intent-1',
assign: vi.fn(),
},
})
const html = renderToStaticMarkup(<IdentityLinkManager />)
expect(html).toContain('security.identityLinks.linkSuccess')
expect(html).not.toContain('security.identityLinks.dialogTitle')
})
it('reopens a failed browser flow with its resumable intent', () => {
vi.stubGlobal('window', {
location: {
search: '?identityLink=failed&intentId=intent-1',
assign: vi.fn(),
},
})
const html = renderToStaticMarkup(<IdentityLinkManager />)
expect(html).toContain('security.identityLinks.browserFailed')
})
it('shows a stable browser failure reason when provided', () => {
vi.stubGlobal('window', {
location: {
search: '?identityLink=failed&intentId=intent-1&reasonCode=PROVIDER_UNAVAILABLE',
assign: vi.fn(),
},
})
const html = renderToStaticMarkup(<IdentityLinkManager />)
expect(html).toContain('security.identityLinks.providerUnavailable')
})
})

View file

@ -0,0 +1,781 @@
import { useState } from 'react'
import { AlertTriangle, KeyRound, Link2, Loader2, ShieldCheck, Unlink } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ApiError, buildApiUrl } from '@/api/client'
import type {
IdentityLinkBinding,
IdentityLinkCredentialRequest,
IdentityLinkIntent,
IdentityLinkProvider,
} from '@/api/types'
import { truncateErrorMessage } from '@/shared/lib/error-display'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import {
useIdentityLinkAccountState,
useIdentityLinkActions,
useIdentityLinkIntent,
} from './use-identity-links'
interface IdentityLinkCallback {
result?: 'reauthenticated' | 'linked' | 'failed'
intentId?: string
reasonCode?: string
}
const identityLinkFailureCodes = new Set([
'INTENT_NOT_FOUND',
'REAUTHENTICATION_REQUIRED',
'SESSION_MISMATCH',
'INTENT_EXPIRED',
'ALREADY_CONSUMED',
'ACTIVE_INTENT_EXISTS',
'ACCOUNT_NOT_ELIGIBLE',
'PROVIDER_UNAVAILABLE',
'PROVIDER_AUTHENTICATION_FAILED',
'ALREADY_LINKED',
'IDENTITY_IN_USE',
'FINAL_LOGIN_METHOD',
'INVALID_OPERATION',
])
export function parseIdentityLinkCallback(search: string): IdentityLinkCallback {
const params = new URLSearchParams(search)
const result = params.get('identityLink')
const intentId = params.get('intentId') ?? undefined
const rawReasonCode = params.get('reasonCode')
const reasonCode = rawReasonCode
&& identityLinkFailureCodes.has(rawReasonCode)
? rawReasonCode
: undefined
if (
result !== 'reauthenticated'
&& result !== 'linked'
&& result !== 'failed'
) {
return {}
}
return { result, intentId, ...(reasonCode ? { reasonCode } : {}) }
}
export function resumableIdentityLinkIntentId(
callback: IdentityLinkCallback,
): string | undefined {
return callback.result === 'reauthenticated' || callback.result === 'failed'
? callback.intentId
: undefined
}
function currentIdentityLinkCallback(): IdentityLinkCallback {
return typeof window === 'undefined'
? {}
: parseIdentityLinkCallback(window.location.search)
}
function browserFailureMessageKey(
reasonCode?: string,
): string {
switch (reasonCode) {
case 'INTENT_EXPIRED':
case 'ALREADY_CONSUMED':
case 'SESSION_MISMATCH':
return 'security.identityLinks.intentUnavailable'
case 'PROVIDER_UNAVAILABLE':
return 'security.identityLinks.providerUnavailable'
case 'FINAL_LOGIN_METHOD':
return 'security.identityLinks.finalMethodHint'
default:
return 'security.identityLinks.browserFailed'
}
}
function errorMessage(error: unknown, fallback: string) {
return truncateErrorMessage(
error instanceof Error ? error.message : fallback,
) ?? fallback
}
function hasMethod(
provider: IdentityLinkBinding | IdentityLinkProvider,
method: 'OAUTH_REDIRECT' | 'DIRECT_PASSWORD',
) {
return provider.methodTypes.includes(method)
}
function CredentialFields({
prefix,
value,
disabled,
onChange,
}: {
prefix: string
value: IdentityLinkCredentialRequest
disabled: boolean
onChange: (next: IdentityLinkCredentialRequest) => void
}) {
const { t } = useTranslation()
return (
<>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`${prefix}-username`}>
{t('security.identityLinks.username')}
</label>
<Input
id={`${prefix}-username`}
autoComplete="username"
value={value.username}
disabled={disabled}
onChange={(event) => onChange({
...value,
username: event.target.value,
})}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`${prefix}-password`}>
{t('security.identityLinks.password')}
</label>
<Input
id={`${prefix}-password`}
type="password"
autoComplete="current-password"
value={value.password}
disabled={disabled}
onChange={(event) => onChange({
...value,
password: event.target.value,
})}
/>
</div>
</>
)
}
export function IdentityLinkManager() {
const { t } = useTranslation()
const callback = currentIdentityLinkCallback()
const resumableIntentId = resumableIdentityLinkIntentId(callback)
const [activeIntentId, setActiveIntentId] = useState<string | undefined>(
resumableIntentId,
)
const [dialogOpen, setDialogOpen] = useState(!!resumableIntentId)
const [localPassword, setLocalPassword] = useState('')
const [credentialProviderCode, setCredentialProviderCode] = useState<
string | undefined
>()
const [credentials, setCredentials] = useState<IdentityLinkCredentialRequest>({
username: '',
password: '',
})
const [flowError, setFlowError] = useState(
callback.result === 'failed'
? t(browserFailureMessageKey(callback.reasonCode))
: '',
)
const accountQuery = useIdentityLinkAccountState()
const intentQuery = useIdentityLinkIntent(activeIntentId)
const actions = useIdentityLinkActions()
const account = accountQuery.data
const intent = intentQuery.data
const isPending = Object.values(actions).some(
(mutation) => mutation.isPending,
)
function resetFlowFields() {
setLocalPassword('')
setCredentialProviderCode(undefined)
setCredentials({ username: '', password: '' })
setFlowError('')
}
function openIntent(nextIntent: IdentityLinkIntent) {
resetFlowFields()
setActiveIntentId(nextIntent.id)
setDialogOpen(true)
}
async function startLink(provider: IdentityLinkProvider) {
try {
openIntent(await actions.createLink.mutateAsync(provider.providerCode))
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.defaultError'),
))
}
}
async function startUnlink(binding: IdentityLinkBinding) {
try {
openIntent(await actions.createUnlink.mutateAsync(binding.bindingId))
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.defaultError'),
))
}
}
async function cancelActiveIntent() {
if (!activeIntentId) {
setDialogOpen(false)
return
}
if (
intentQuery.error instanceof ApiError
&& [403, 404, 409, 410].includes(intentQuery.error.status)
) {
setDialogOpen(false)
setActiveIntentId(undefined)
resetFlowFields()
return
}
try {
await actions.cancel.mutateAsync(activeIntentId)
setDialogOpen(false)
setActiveIntentId(undefined)
resetFlowFields()
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.defaultError'),
))
}
}
function handleDialogOpenChange(open: boolean) {
if (open) {
setDialogOpen(true)
return
}
if (intent?.status === 'COMPLETED' || intent?.status === 'CANCELLED') {
setDialogOpen(false)
setActiveIntentId(undefined)
resetFlowFields()
return
}
void cancelActiveIntent()
}
async function reauthenticateLocal(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!activeIntentId || !localPassword) {
setFlowError(t('security.identityLinks.passwordRequired'))
return
}
setFlowError('')
try {
await actions.reauthenticateLocal.mutateAsync({
intentId: activeIntentId,
password: localPassword,
})
setLocalPassword('')
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.reauthenticationFailed'),
))
}
}
async function reauthenticateCredential(
event: React.FormEvent<HTMLFormElement>,
) {
event.preventDefault()
if (
!activeIntentId
|| !credentialProviderCode
|| !credentials.username.trim()
|| !credentials.password
) {
setFlowError(t('security.identityLinks.credentialsRequired'))
return
}
setFlowError('')
try {
await actions.reauthenticateCredential.mutateAsync({
intentId: activeIntentId,
providerCode: credentialProviderCode,
credentials: {
username: credentials.username.trim(),
password: credentials.password,
},
})
setCredentials({ username: '', password: '' })
setCredentialProviderCode(undefined)
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.reauthenticationFailed'),
))
}
}
async function redirectToBrowserReauthentication(providerCode: string) {
if (!activeIntentId) return
setFlowError('')
try {
const actionUrl =
await actions.prepareBrowserReauthentication.mutateAsync({
intentId: activeIntentId,
providerCode,
})
window.location.assign(buildApiUrl(actionUrl))
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.reauthenticationFailed'),
))
}
}
async function redirectToBrowserLink() {
if (!activeIntentId) return
setFlowError('')
try {
const actionUrl = await actions.prepareBrowserLink.mutateAsync(
activeIntentId,
)
window.location.assign(buildApiUrl(actionUrl))
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.linkFailed'),
))
}
}
async function linkCredential(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (
!activeIntentId
|| !credentials.username.trim()
|| !credentials.password
) {
setFlowError(t('security.identityLinks.credentialsRequired'))
return
}
setFlowError('')
try {
await actions.linkCredential.mutateAsync({
intentId: activeIntentId,
credentials: {
username: credentials.username.trim(),
password: credentials.password,
},
})
setDialogOpen(false)
setActiveIntentId(undefined)
resetFlowFields()
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.linkFailed'),
))
}
}
async function completeUnlink() {
if (!activeIntentId) return
setFlowError('')
try {
await actions.completeUnlink.mutateAsync(activeIntentId)
setDialogOpen(false)
setActiveIntentId(undefined)
resetFlowFields()
} catch (error) {
setFlowError(errorMessage(
error,
t('security.identityLinks.unlinkFailed'),
))
}
}
const linkedProviders = account?.linkedProviders ?? []
const targetProvider = intent?.operation === 'LINK'
? account?.availableProviders.find(
(provider) => provider.providerCode === intent.providerCode,
)
: undefined
const targetBinding = intent?.operation === 'UNLINK'
? linkedProviders.find(
(binding) => binding.bindingId === intent.targetBindingId,
)
: undefined
const browserReauthenticationProviders = linkedProviders.filter(
(provider) => provider.usable && hasMethod(provider, 'OAUTH_REDIRECT'),
)
const credentialReauthenticationProviders = linkedProviders.filter(
(provider) => provider.usable && hasMethod(provider, 'DIRECT_PASSWORD'),
)
const hasFreshReauthenticationMethod =
account?.localPasswordEnabled
|| browserReauthenticationProviders.length > 0
|| credentialReauthenticationProviders.length > 0
return (
<>
<Card className="glass-strong">
<CardHeader>
<CardTitle>{t('security.identityLinks.title')}</CardTitle>
<CardDescription>
{t('security.identityLinks.subtitle')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{callback.result === 'linked' ? (
<div className="rounded-lg border border-emerald-300 bg-emerald-50 p-3 text-sm text-emerald-800">
{t('security.identityLinks.linkSuccess')}
</div>
) : null}
{callback.result === 'failed' ? (
<div className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{t(browserFailureMessageKey(callback.reasonCode))}
</div>
) : null}
{flowError && !dialogOpen ? (
<div className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{flowError}
</div>
) : null}
{accountQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('security.identityLinks.loading')}
</div>
) : accountQuery.error ? (
<p className="text-sm text-red-600">
{errorMessage(
accountQuery.error,
t('security.identityLinks.defaultError'),
)}
</p>
) : (
<>
<section className="space-y-3" aria-labelledby="linked-login-methods">
<h3 id="linked-login-methods" className="text-sm font-semibold">
{t('security.identityLinks.linkedTitle')}
</h3>
{account?.localPasswordEnabled ? (
<div className="flex items-center justify-between gap-4 rounded-xl border border-border/70 p-4">
<div className="flex min-w-0 items-center gap-3">
<KeyRound className="h-5 w-5 shrink-0 text-primary" />
<div>
<p className="font-medium">
{t('security.identityLinks.localPassword')}
</p>
<p className="text-sm text-muted-foreground">
{t('security.identityLinks.localPasswordDescription')}
</p>
</div>
</div>
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800">
{t('security.identityLinks.active')}
</span>
</div>
) : null}
{linkedProviders.map((provider) => (
<div
key={provider.bindingId}
className="flex items-center justify-between gap-4 rounded-xl border border-border/70 p-4"
>
<div className="flex min-w-0 items-center gap-3">
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0">
<p className="truncate font-medium">
{provider.displayName}
</p>
<p className="text-sm text-muted-foreground">
{provider.usable
? t('security.identityLinks.externalLogin')
: t('security.identityLinks.providerUnavailable')}
</p>
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={!provider.canUnlink || isPending}
title={!provider.canUnlink
? t('security.identityLinks.finalMethodHint')
: undefined}
onClick={() => void startUnlink(provider)}
>
<Unlink className="mr-2 h-3.5 w-3.5" />
{t('security.identityLinks.remove')}
</Button>
</div>
))}
{!account?.localPasswordEnabled && linkedProviders.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{t('security.identityLinks.noLinkedMethods')}
</p>
) : null}
</section>
<section className="space-y-3" aria-labelledby="available-login-methods">
<h3 id="available-login-methods" className="text-sm font-semibold">
{t('security.identityLinks.availableTitle')}
</h3>
{(account?.availableProviders ?? []).map((provider) => (
<div
key={provider.providerCode}
className="flex items-center justify-between gap-4 rounded-xl border border-border/70 p-4"
>
<div className="flex min-w-0 items-center gap-3">
<Link2 className="h-5 w-5 shrink-0 text-primary" />
<div>
<p className="font-medium">{provider.displayName}</p>
<p className="text-sm text-muted-foreground">
{t('security.identityLinks.availableDescription')}
</p>
</div>
</div>
<Button
type="button"
size="sm"
disabled={isPending}
onClick={() => void startLink(provider)}
>
{t('security.identityLinks.add')}
</Button>
</div>
))}
{account?.availableProviders.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{t('security.identityLinks.noAvailableMethods')}
</p>
) : null}
</section>
</>
)}
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent aria-label={t('security.identityLinks.dialogTitle')}>
<DialogHeader>
<DialogTitle>{t('security.identityLinks.dialogTitle')}</DialogTitle>
<DialogDescription>
{intent?.operation === 'UNLINK'
? t('security.identityLinks.unlinkDialogDescription', {
name: targetBinding?.displayName ?? intent.providerCode,
})
: t('security.identityLinks.linkDialogDescription', {
name: targetProvider?.displayName ?? intent?.providerCode ?? '',
})}
</DialogDescription>
</DialogHeader>
{intentQuery.isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('security.identityLinks.loadingIntent')}
</div>
) : intentQuery.error ? (
<p className="text-sm text-red-600">
{errorMessage(
intentQuery.error,
t('security.identityLinks.defaultError'),
)}
</p>
) : null}
{intent?.status === 'PENDING_REAUTHENTICATION' ? (
<div className="space-y-5">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
{t('security.identityLinks.reauthenticationDescription')}
</div>
{account?.localPasswordEnabled ? (
<form className="space-y-3" onSubmit={reauthenticateLocal}>
<label className="text-sm font-medium" htmlFor="identity-link-local-password">
{t('security.identityLinks.localPassword')}
</label>
<Input
id="identity-link-local-password"
type="password"
autoComplete="current-password"
value={localPassword}
disabled={isPending}
onChange={(event) => setLocalPassword(event.target.value)}
/>
<Button type="submit" size="sm" disabled={isPending}>
{t('security.identityLinks.verifyPassword')}
</Button>
</form>
) : null}
{browserReauthenticationProviders.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium">
{t('security.identityLinks.verifyWithProvider')}
</p>
<div className="flex flex-wrap gap-2">
{browserReauthenticationProviders.map((provider) => (
<Button
key={provider.providerCode}
type="button"
size="sm"
variant="outline"
disabled={isPending}
onClick={() => void redirectToBrowserReauthentication(
provider.providerCode,
)}
>
{provider.displayName}
</Button>
))}
</div>
</div>
) : null}
{credentialReauthenticationProviders.length > 0 ? (
<div className="space-y-3">
<p className="text-sm font-medium">
{t('security.identityLinks.verifyWithCredentials')}
</p>
<div className="flex flex-wrap gap-2">
{credentialReauthenticationProviders.map((provider) => (
<Button
key={provider.providerCode}
type="button"
size="sm"
variant={credentialProviderCode === provider.providerCode
? 'secondary'
: 'outline'}
disabled={isPending}
onClick={() => {
setCredentialProviderCode(provider.providerCode)
setCredentials({ username: '', password: '' })
}}
>
{provider.displayName}
</Button>
))}
</div>
{credentialProviderCode ? (
<form className="space-y-3" onSubmit={reauthenticateCredential}>
<CredentialFields
prefix="identity-link-reauth"
value={credentials}
disabled={isPending}
onChange={setCredentials}
/>
<Button type="submit" size="sm" disabled={isPending}>
{t('security.identityLinks.verifyCredentials')}
</Button>
</form>
) : null}
</div>
) : null}
{!hasFreshReauthenticationMethod ? (
<div className="flex gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
{t('security.identityLinks.noReauthenticationMethod')}
</div>
) : null}
</div>
) : null}
{intent?.status === 'READY' && intent.operation === 'LINK' ? (
<div className="space-y-5">
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
{t('security.identityLinks.reauthenticated')}
</div>
{targetProvider && hasMethod(targetProvider, 'OAUTH_REDIRECT') ? (
<Button
type="button"
className="w-full"
disabled={isPending}
onClick={() => void redirectToBrowserLink()}
>
{t('security.identityLinks.continueWithProvider', {
name: targetProvider.displayName,
})}
</Button>
) : null}
{targetProvider && hasMethod(targetProvider, 'DIRECT_PASSWORD') ? (
<form className="space-y-3" onSubmit={linkCredential}>
<CredentialFields
prefix="identity-link-target"
value={credentials}
disabled={isPending}
onChange={setCredentials}
/>
<Button type="submit" className="w-full" disabled={isPending}>
{t('security.identityLinks.linkProvider', {
name: targetProvider.displayName,
})}
</Button>
</form>
) : null}
{!targetProvider ? (
<p className="text-sm text-red-600">
{t('security.identityLinks.providerUnavailable')}
</p>
) : null}
</div>
) : null}
{intent?.status === 'READY' && intent.operation === 'UNLINK' ? (
<div className="space-y-4">
<div className="flex gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
{t('security.identityLinks.unlinkConfirmation', {
name: targetBinding?.displayName ?? intent.providerCode,
})}
</div>
<Button
type="button"
variant="destructive"
className="w-full"
disabled={isPending}
onClick={() => void completeUnlink()}
>
{t('security.identityLinks.confirmRemove')}
</Button>
</div>
) : null}
{intent && (
intent.status === 'EXPIRED'
|| intent.status === 'CANCELLED'
) ? (
<p className="text-sm text-amber-700">
{t('security.identityLinks.intentUnavailable')}
</p>
) : null}
{flowError && dialogOpen ? (
<p className="text-sm text-red-600">{flowError}</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => void cancelActiveIntent()}
>
{t('security.identityLinks.cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -0,0 +1,132 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { identityLinkApi } from '@/api/client'
import type {
IdentityLinkCredentialRequest,
IdentityLinkIntent,
} from '@/api/types'
export const identityLinkKeys = {
account: ['auth', 'identity-links'] as const,
intent: (intentId: string) =>
['auth', 'identity-link-intent', intentId] as const,
}
export function useIdentityLinkAccountState() {
return useQuery({
queryKey: identityLinkKeys.account,
queryFn: identityLinkApi.getAccountState,
staleTime: 15_000,
})
}
export function useIdentityLinkIntent(intentId?: string) {
return useQuery({
queryKey: identityLinkKeys.intent(intentId ?? ''),
queryFn: () => identityLinkApi.getIntent(intentId ?? ''),
enabled: !!intentId,
retry: false,
})
}
export function useIdentityLinkActions() {
const queryClient = useQueryClient()
function cacheIntent(intent: IdentityLinkIntent) {
queryClient.setQueryData(
identityLinkKeys.intent(intent.id),
intent,
)
}
async function refreshAccountState() {
await queryClient.invalidateQueries({
queryKey: identityLinkKeys.account,
})
}
const createLink = useMutation({
mutationFn: identityLinkApi.createLinkIntent,
onSuccess: cacheIntent,
})
const createUnlink = useMutation({
mutationFn: identityLinkApi.createUnlinkIntent,
onSuccess: cacheIntent,
})
const cancel = useMutation({
mutationFn: identityLinkApi.cancel,
onSuccess: cacheIntent,
})
const reauthenticateLocal = useMutation({
mutationFn: ({
intentId,
password,
}: {
intentId: string
password: string
}) => identityLinkApi.reauthenticateLocal(intentId, password),
onSuccess: cacheIntent,
})
const prepareBrowserReauthentication = useMutation({
mutationFn: ({
intentId,
providerCode,
}: {
intentId: string
providerCode: string
}) => identityLinkApi.prepareBrowserReauthentication(
intentId,
providerCode,
),
})
const reauthenticateCredential = useMutation({
mutationFn: ({
intentId,
providerCode,
credentials,
}: {
intentId: string
providerCode: string
credentials: IdentityLinkCredentialRequest
}) => identityLinkApi.reauthenticateCredential(
intentId,
providerCode,
credentials,
),
onSuccess: cacheIntent,
})
const prepareBrowserLink = useMutation({
mutationFn: identityLinkApi.prepareBrowserLink,
})
const linkCredential = useMutation({
mutationFn: ({
intentId,
credentials,
}: {
intentId: string
credentials: IdentityLinkCredentialRequest
}) => identityLinkApi.linkCredential(intentId, credentials),
onSuccess: async (intent) => {
cacheIntent(intent)
await refreshAccountState()
},
})
const completeUnlink = useMutation({
mutationFn: identityLinkApi.completeUnlink,
onSuccess: async (intent) => {
cacheIntent(intent)
await refreshAccountState()
},
})
return {
createLink,
createUnlink,
cancel,
reauthenticateLocal,
prepareBrowserReauthentication,
reauthenticateCredential,
prepareBrowserLink,
linkCredential,
completeUnlink,
}
}

View file

@ -762,7 +762,52 @@
"unavailableTitle": "Password changes are unavailable for this account.",
"unavailableDescription": "This account signs in through an external identity provider or has no local password credential.",
"submitting": "Submitting...",
"submit": "Update Password"
"submit": "Update Password",
"identityLinks": {
"title": "Login Methods",
"subtitle": "Add or remove ways to sign in. Security-sensitive changes require you to verify your current account again.",
"linkedTitle": "Linked login methods",
"availableTitle": "Available login methods",
"localPassword": "Local password",
"localPasswordDescription": "Sign in with your SkillHub username and password.",
"externalLogin": "External identity provider",
"active": "Active",
"providerUnavailable": "This identity provider is currently unavailable.",
"remove": "Remove",
"add": "Add",
"availableDescription": "Connect this identity provider to your current account.",
"noLinkedMethods": "No usable login method is currently linked.",
"noAvailableMethods": "There are no additional login methods available.",
"finalMethodHint": "You cannot remove the final usable login method.",
"loading": "Loading login methods...",
"loadingIntent": "Loading security verification...",
"dialogTitle": "Verify account control",
"linkDialogDescription": "Verify your current account before linking {{name}}.",
"unlinkDialogDescription": "Verify your current account before removing {{name}}.",
"reauthenticationDescription": "For your security, verify one of the login methods already linked to this account.",
"verifyPassword": "Verify password",
"verifyWithProvider": "Verify in your browser",
"verifyWithCredentials": "Verify with provider credentials",
"verifyCredentials": "Verify credentials",
"username": "Username",
"password": "Password",
"passwordRequired": "Enter your current password.",
"credentialsRequired": "Enter both username and password.",
"reauthenticated": "Current account verified. Now authenticate the login method you want to link.",
"continueWithProvider": "Continue with {{name}}",
"linkProvider": "Link {{name}}",
"unlinkConfirmation": "Removing {{name}} prevents future sign-ins through this identity. Existing platform data is not deleted.",
"confirmRemove": "Remove login method",
"cancel": "Cancel",
"noReauthenticationMethod": "None of the linked login methods currently supports fresh verification. Cancel this request and contact an administrator.",
"intentUnavailable": "This security request has expired or was cancelled. Start again from the login methods list.",
"linkSuccess": "The login method was linked successfully.",
"browserFailed": "Browser verification failed or was cancelled. No login method was changed.",
"reauthenticationFailed": "Could not verify the current account.",
"linkFailed": "Could not link this login method.",
"unlinkFailed": "Could not remove this login method.",
"defaultError": "Could not update login methods. Please try again."
}
},
"accounts": {
"unavailableTitle": "Account merging is temporarily unavailable",
@ -1455,6 +1500,7 @@
"notFound": "The requested resource was not found",
"serverError": "Server error, please try again later",
"networkError": "Network connection failed, please check your network",
"invalidResponse": "The server returned an invalid response",
"unknown": "Operation failed"
},
"routeGuard": {

View file

@ -762,7 +762,52 @@
"unavailableTitle": "此账号暂不可修改密码。",
"unavailableDescription": "此账号通过外部身份提供方登录,或尚未配置本地密码凭据。",
"submitting": "提交中...",
"submit": "更新密码"
"submit": "更新密码",
"identityLinks": {
"title": "登录方式",
"subtitle": "添加或移除账号登录方式。涉及账号安全的变更需要重新验证当前账号。",
"linkedTitle": "已关联的登录方式",
"availableTitle": "可添加的登录方式",
"localPassword": "本地密码",
"localPasswordDescription": "使用 SkillHub 用户名和密码登录。",
"externalLogin": "外部身份提供方",
"active": "可用",
"providerUnavailable": "此身份提供方当前不可用。",
"remove": "移除",
"add": "添加",
"availableDescription": "将此身份提供方关联到当前账号。",
"noLinkedMethods": "当前账号没有可用的登录方式。",
"noAvailableMethods": "当前没有其他可添加的登录方式。",
"finalMethodHint": "不能移除最后一种可用登录方式。",
"loading": "正在加载登录方式……",
"loadingIntent": "正在加载安全验证……",
"dialogTitle": "验证账号控制权",
"linkDialogDescription": "关联 {{name}} 前,请重新验证当前账号。",
"unlinkDialogDescription": "移除 {{name}} 前,请重新验证当前账号。",
"reauthenticationDescription": "为保护账号安全,请使用当前账号已有的一种登录方式重新验证。",
"verifyPassword": "验证密码",
"verifyWithProvider": "通过浏览器验证",
"verifyWithCredentials": "使用身份提供方凭据验证",
"verifyCredentials": "验证凭据",
"username": "用户名",
"password": "密码",
"passwordRequired": "请输入当前密码。",
"credentialsRequired": "请输入用户名和密码。",
"reauthenticated": "当前账号验证成功。现在请认证要关联的目标登录方式。",
"continueWithProvider": "继续使用 {{name}}",
"linkProvider": "关联 {{name}}",
"unlinkConfirmation": "移除 {{name}} 后将不能再通过该身份登录,但不会删除平台内已有数据。",
"confirmRemove": "移除登录方式",
"cancel": "取消",
"noReauthenticationMethod": "当前已关联的登录方式均不支持重新验证。请取消本次操作并联系管理员。",
"intentUnavailable": "本次安全请求已过期或取消,请从登录方式列表重新发起。",
"linkSuccess": "登录方式关联成功。",
"browserFailed": "浏览器验证失败或已取消,登录方式没有发生变更。",
"reauthenticationFailed": "当前账号验证失败。",
"linkFailed": "登录方式关联失败。",
"unlinkFailed": "登录方式移除失败。",
"defaultError": "登录方式更新失败,请稍后重试。"
}
},
"accounts": {
"unavailableTitle": "账号合并暂时不可用",
@ -1456,6 +1501,7 @@
"notFound": "请求的资源不存在",
"serverError": "服务器错误,请稍后重试",
"networkError": "网络连接失败,请检查网络",
"invalidResponse": "服务器返回了无效响应",
"unknown": "操作失败"
},
"routeGuard": {

View file

@ -36,6 +36,10 @@ vi.mock('@/features/auth/use-auth', () => ({
useAuth: useAuthMock,
}))
vi.mock('@/features/auth/identity-link-manager', () => ({
IdentityLinkManager: () => <div>identity-link-manager</div>,
}))
vi.mock('@/shared/lib/error-display', () => ({
truncateErrorMessage: (v: string) => v,
}))
@ -93,6 +97,7 @@ describe('SecuritySettingsPage', () => {
it('renders the password form when password changes are allowed', () => {
const html = renderToStaticMarkup(<SecuritySettingsPage />)
expect(html).toContain('identity-link-manager')
expect(html).toContain('security.currentPassword')
expect(html).toContain('security.newPassword')
expect(html).toContain('security.submit')

View file

@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ApiError, authApi } from '@/api/client'
import { IdentityLinkManager } from '@/features/auth/identity-link-manager'
import { useAuth } from '@/features/auth/use-auth'
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
import { truncateErrorMessage } from '@/shared/lib/error-display'
@ -20,9 +21,9 @@ function canUsePasswordChangeForm(user?: PasswordChangeCapabilityUser | null) {
}
/**
* Security settings page for password changes. After a successful change the
* user is logged out so all existing authenticated state is re-established with
* the new credential.
* Security settings page for login methods and password changes. After a
* successful password change the user is logged out so all existing
* authenticated state is re-established with the new credential.
*/
export function SecuritySettingsPage() {
const { t } = useTranslation()
@ -87,7 +88,8 @@ export function SecuritySettingsPage() {
}
return (
<div className="mx-auto max-w-2xl">
<div className="mx-auto max-w-3xl space-y-6">
<IdentityLinkManager />
<Card className="glass-strong">
<CardHeader>
<CardTitle>{t('security.title')}</CardTitle>

View file

@ -17,6 +17,7 @@ export class ApiError extends Error {
public status: number,
public serverMessage?: string,
public serverMessageKey?: string,
public reasonCode?: string,
) {
super(resolveLocalizedMessage(message) || message)
this.name = 'ApiError'

View file

@ -4,6 +4,7 @@ import path from 'path'
const JS_BUILD_TARGET = 'es2020'
const LEGACY_BROWSER_TARGETS = ['chrome83', 'edge83', 'firefox78', 'safari14']
const DEV_API_TARGET = process.env.SKILLHUB_DEV_API_TARGET?.trim() || 'http://localhost:8080'
export default defineConfig({
plugins: [react()],
@ -34,12 +35,16 @@ export default defineConfig({
},
proxy: {
'/api': {
target: 'http://localhost:8080',
target: DEV_API_TARGET,
changeOrigin: true,
},
'/oauth2': {
target: 'http://localhost:8080',
changeOrigin: true,
target: DEV_API_TARGET,
changeOrigin: false,
},
'/login/oauth2/code': {
target: DEV_API_TARGET,
changeOrigin: false,
},
},
},