mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge pull request #656 from iflytek/feature/identity-link
feat(auth): add explicit identity link and safe unlink flow
This commit is contained in:
commit
f7433bec51
94 changed files with 10380 additions and 122 deletions
27
.github/workflows/pr-e2e.yml
vendored
27
.github/workflows/pr-e2e.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 Factory;Provider 仍不能构造 `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 Session;Credential 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 4:Provider Registry + Adapter 契约冻结
|
|||
- disabled/misconfigured Provider 不出现在目录且不联网。
|
||||
- 旧前端登录目录 API 保持兼容。
|
||||
|
||||
#### PR 5:显式 Identity Link
|
||||
#### PR 5:显式 Identity Link(#655)
|
||||
|
||||
范围:
|
||||
|
||||
|
|
@ -2380,6 +2498,10 @@ PR 4:Provider Registry + Adapter 契约冻结
|
|||
|
||||
- 无法只凭 email 或 username 创建 Binding。
|
||||
- Link 重放、过期、并发消费和最后登录方式测试通过。
|
||||
- JSON API 和 Browser callback 都返回 allowlist 内的稳定 reason code。
|
||||
- PostgreSQL 16 验证同一 intent 并发消费只能成功一次。
|
||||
- 以准确 `big-main` SHA 构建镜像,在隔离测试环境验证升级、回归和真实浏览器链路后,
|
||||
才能进入 `main`。
|
||||
|
||||
#### PR 6:安全 Account Merge
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
236
scripts/tests/oauth2-mock-provider.py
Normal file
236
scripts/tests/oauth2-mock-provider.py
Normal 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()
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record IdentityLinkBrowserStartResponse(
|
||||
String actionUrl
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
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
|
||||
.mapIdentityLink(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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
|
|
@ -32,6 +34,24 @@ final class ProviderAuthenticationFailureMapper {
|
|||
};
|
||||
}
|
||||
|
||||
static IdentityLinkException mapIdentityLink(
|
||||
ProviderAuthenticationException exception) {
|
||||
IdentityLinkFailureCode reasonCode =
|
||||
switch (exception.getReasonCode()) {
|
||||
case UPSTREAM_INVALID_CREDENTIALS,
|
||||
UPSTREAM_ACCESS_DENIED,
|
||||
REPLAY_DETECTED ->
|
||||
IdentityLinkFailureCode
|
||||
.PROVIDER_AUTHENTICATION_FAILED;
|
||||
case UPSTREAM_UNAVAILABLE,
|
||||
UPSTREAM_MISCONFIGURED,
|
||||
TLS_VALIDATION_FAILED,
|
||||
UPSTREAM_INVALID_RESPONSE ->
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE;
|
||||
};
|
||||
return new IdentityLinkException(reasonCode, exception);
|
||||
}
|
||||
|
||||
private static AuthFlowException failure(
|
||||
HttpStatus status,
|
||||
String messageCode) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=密码不能为空
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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!");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package com.iflytek.skillhub.service;
|
|||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -37,6 +39,33 @@ class ProviderAuthenticationFailureMapperTest {
|
|||
HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsStableProviderFailuresToIdentityLinkReasonCodes() {
|
||||
assertIdentityLinkMapping(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_CREDENTIALS,
|
||||
IdentityLinkFailureCode.PROVIDER_AUTHENTICATION_FAILED);
|
||||
assertIdentityLinkMapping(
|
||||
ProviderAuthenticationFailureCode.UPSTREAM_ACCESS_DENIED,
|
||||
IdentityLinkFailureCode.PROVIDER_AUTHENTICATION_FAILED);
|
||||
assertIdentityLinkMapping(
|
||||
ProviderAuthenticationFailureCode.REPLAY_DETECTED,
|
||||
IdentityLinkFailureCode.PROVIDER_AUTHENTICATION_FAILED);
|
||||
assertIdentityLinkMapping(
|
||||
ProviderAuthenticationFailureCode.UPSTREAM_UNAVAILABLE,
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
|
||||
assertIdentityLinkMapping(
|
||||
ProviderAuthenticationFailureCode.UPSTREAM_MISCONFIGURED,
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
|
||||
assertIdentityLinkMapping(
|
||||
ProviderAuthenticationFailureCode.TLS_VALIDATION_FAILED,
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
|
||||
assertIdentityLinkMapping(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE,
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
|
||||
}
|
||||
|
||||
private void assertMapping(
|
||||
ProviderAuthenticationFailureCode reasonCode,
|
||||
HttpStatus status) {
|
||||
|
|
@ -51,4 +80,22 @@ class ProviderAuthenticationFailureMapperTest {
|
|||
assertThat(mapped.getMessage())
|
||||
.doesNotContain("private upstream detail");
|
||||
}
|
||||
|
||||
private void assertIdentityLinkMapping(
|
||||
ProviderAuthenticationFailureCode providerReasonCode,
|
||||
IdentityLinkFailureCode identityLinkReasonCode) {
|
||||
IdentityLinkException mapped =
|
||||
ProviderAuthenticationFailureMapper.mapIdentityLink(
|
||||
new ProviderAuthenticationException(
|
||||
providerReasonCode,
|
||||
new IllegalStateException(
|
||||
"private upstream detail")));
|
||||
|
||||
assertThat(mapped.getStatus())
|
||||
.isEqualTo(identityLinkReasonCode.status());
|
||||
assertThat(mapped.getReasonCode())
|
||||
.isEqualTo(identityLinkReasonCode);
|
||||
assertThat(mapped.getMessage())
|
||||
.doesNotContain("private upstream detail");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 + "/")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.entity;
|
||||
|
||||
public enum IdentityLinkOperation {
|
||||
LINK,
|
||||
UNLINK
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IdentityLinkBrowserFlow(
|
||||
UUID intentId,
|
||||
IdentityLinkBrowserPhase phase,
|
||||
IdentityLinkActor actor
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
public enum IdentityLinkBrowserPhase {
|
||||
REAUTHENTICATE,
|
||||
LINK
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
1221
web/src/api/generated/schema.d.ts
vendored
1221
web/src/api/generated/schema.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
205
web/src/features/auth/identity-link-manager.test.tsx
Normal file
205
web/src/features/auth/identity-link-manager.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
781
web/src/features/auth/identity-link-manager.tsx
Normal file
781
web/src/features/auth/identity-link-manager.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
132
web/src/features/auth/use-identity-links.ts
Normal file
132
web/src/features/auth/use-identity-links.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue