mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-24 00:55:35 +00:00
feat(auth): add Feishu as a public login provider (R1-A2) (#877)
Some checks are pending
Deploy Docs / build (push) Waiting to run
Deploy Docs / Deploy (push) Blocked by required conditions
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Some checks are pending
Deploy Docs / build (push) Waiting to run
Deploy Docs / Deploy (push) Blocked by required conditions
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
* feat(auth): let providers override OAuth userinfo loading
Some providers do not return a flat, standard userinfo payload, so
DefaultOAuth2UserService cannot read them. Add ProviderOAuth2UserService
so a provider can claim its own registration id and supply the loading
step, while everything after it stays shared.
The override runs inside the RemoteIdentityIoExecutor boundary added in
R1-A, so a provider's HTTP call does not hold the surrounding
transaction open. Registrations without an override keep using the
default user service unchanged.
Part of R1-A2 (public Provider adapters) per
openspec/changes/enterprise-identity-platform/rollout-plan.md.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* feat(auth): add Feishu as a public login provider
Adds Feishu (Lark) as a public sign-in option: it authenticates a
SkillHub platform account and nothing more. No Organization membership,
no directory sync, no Namespace grants.
Feishu deviates from standard OAuth in two ways this handles:
its userinfo response is wrapped in a {code, msg, data} envelope, and it
reports errors with HTTP 200. FeishuOAuth2UserService unwraps that
envelope into flat attributes; FeishuClaimsExtractor maps them to the
shared OAuthClaims, so account decisions still run through the unified
identity core added in R1-A.
Subject and email semantics, which decide whether a login can reach an
existing account:
- open_id is the only subject. union_id stays in extra rather than
acting as a fallback: a subject that can change between logins would
split one person across two platform accounts. Promoting union_id
later needs an explicit alias migration.
- A blank or missing open_id fails the login instead of binding the
literal string "null".
- emailVerified is always false. Feishu emails are imported by an
organization admin and never confirmed with the user, so they carry no
verification signal and cannot be used to join an existing account.
Operational bounds: the userinfo call has connect and read timeouts so an
unresponsive Feishu endpoint cannot hold a login thread, and the
OAuth2Error description carries only the provider error code, because an
upstream message can quote the request URI and with it the access token.
Like the GitHub and GitLab extractors, the claims extractor logs nothing.
The login button follows the existing config-driven catalog: with no
client id configured, /api/v1/auth/methods does not list Feishu and no
button renders. No frontend code change is needed; the icon resolves by
provider name.
Adapted from the implementation in #696 by @yhd4711499, re-extracted onto
current main with the subject, logging and timeout changes above.
Part of R1-A2 (public Provider adapters) per
openspec/changes/enterprise-identity-platform/rollout-plan.md.
Co-authored-by: yhd4711499 <yhd4711499@users.noreply.github.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(auth): bound Feishu userinfo response and stop subject leaking into displayName
Three defects found reviewing this batch against the R1-A2 spec.
Response size limit. The spec's scope line asks for "远程 I/O 超时与响应大小
限制"; only the timeouts were implemented, so a misconfigured or hostile
OAUTH2_FEISHU_BASE_URI could stream an unbounded body into the parser.
Reads at most 64 KB before parsing, mirroring the 10 MB cap the shared
WebClientConfig already applies. Uses InputStream.readNBytes rather than
adding commons-io or guava, neither of which skillhub-auth declares.
Synthesized displayName. Falling back to "feishu-<open_id>" wrote the
external subject into UserAccount.displayName and into
UserActivatedEvent, carrying it somewhere event consumers may log it --
against the R1-A gate that logs must not contain the subject. Now stops
at name -> en_name like the GitHub and GitLab extractors.
Unused mobile attribute. A phone number was extracted into the principal
attributes and read by nothing. It is PII the spec did not ask for and it
widened the redaction surface for free.
Also drops a constructor overload that only passed List.of() through, and
a test that duplicated the blank-subject path.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* docs(auth): document the provider adapter contract and Feishu operator setup
AGENTS.md and CONTRIBUTING.md both require docs updates when auth flows or
deployment config change; this batch changed both and touched no docs.
03-authentication-design.md described adding a provider as "branch on
registrationId inside CustomOAuth2UserService", which the
ProviderOAuth2UserService strategy supersedes. Rewrites that recipe:
register an OAuthClaimsExtractor bean per provider, add a
ProviderOAuth2UserService only when the userinfo response is non-standard,
and note that the login page needs no code change. Also records the
provider-side obligations that are easy to get wrong -- stable subject with
no fallback, emailVerified only on proven ownership, bounded remote calls,
no subject in logs -- and un-comments the config example, which still
listed GitLab as a future possibility.
faq.md told operators to delete "the github and gitlab blocks" to hide SSO
buttons. That advice was already incomplete and gets worse per provider, so
it now explains the config-driven mechanism: an empty client id keeps the
entry off the login page, no file edit needed.
09-deployment.md listed only the GitHub credentials. Adds GitLab and Feishu,
and flags a deployment trap: Feishu emails are admin-imported so
emailVerified is always false, and skillhub.access-policy.mode=EMAIL_DOMAIN
denies every unverified email, which would reject all Feishu logins.
Squares the Feishu logo viewBox. It was 407.87x324.19 while login-button
renders it in a square w-5 h-5 box, so the mark was distorted.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* feat(deploy): wire Feishu credentials into the release surfaces
.env.release.example advertised OAUTH2_FEISHU_* knobs that no deployment
path could actually deliver. compose.release.yml has no env_file, so every
variable must be listed explicitly, and the Helm chart and k8s base only
mapped the GitHub secret keys. Setting the documented variables therefore
did nothing.
Adds Feishu to compose.release.yml, the Helm secret template and values,
the k8s deployment and its secret example. GitLab had the identical gap, so
it is wired at the same time rather than leaving the example file half true.
validate-release-config.sh only checked that GitHub's id and secret appear
together. A half-configured provider renders a login button whose exchange
then fails, so the check now loops over all three providers. Its test gained
both-directions cases per provider plus a fully configured pass; reverting
the loop to GitHub-only makes them fail.
Also adds the provider's only failure log. Nothing downstream records a
Feishu userinfo failure -- OAuth2LoginFailureHandler does not log either --
so the previous code was silent on error. Logs the exception class and
Feishu's own error code, never the upstream msg, which can quote the access
token; a test asserts the code is present and the token is not.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(auth): use JSON token exchange for Feishu OAuth
Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(auth): preserve Feishu OAuth browser redirect
Add safe phase-level OAuth diagnostics and redact callback credentials from request logs.
Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* docs(deploy): clarify Feishu OAuth configuration and validation
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(deploy): pass Feishu redirect URI through releases
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(deploy): pass S3 chunked encoding setting
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(deploy): preserve default Feishu callback derivation
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---------
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: yhd4711499 <yhd4711499@users.noreply.github.com>
This commit is contained in:
parent
f5a58616b7
commit
934cfa6ded
34 changed files with 1925 additions and 31 deletions
|
|
@ -117,6 +117,27 @@ OAUTH2_GITLAB_CLIENT_SECRET=
|
|||
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
|
||||
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
|
||||
|
||||
# Optional: Feishu (Lark) login as a public sign-in provider. Leaving the client id empty keeps
|
||||
# the button off the login page. Grant contact:user.base:readonly and
|
||||
# contact:user.email:readonly on the Feishu open-platform app itself; scopes are not sent here.
|
||||
# Full Feishu endpoints are configurable for Lark international, private deployments, and gateways.
|
||||
# Legacy OAUTH2_FEISHU_AUTHORIZE_URI/OAUTH2_FEISHU_BASE_URI remain supported as base-URI fallbacks.
|
||||
# The token endpoint must accept Feishu's JSON authorization-code exchange contract. Supported
|
||||
# token protocols are v2 and v3; v3 is the default. Selection is explicit and never falls back.
|
||||
# Feishu emails are admin-imported and never confirmed with the user, so emailVerified is always
|
||||
# false. If you set skillhub.access-policy.mode=EMAIL_DOMAIN in application.yml, that policy
|
||||
# denies every unverified email and Feishu login will always fail; keep the default OPEN mode,
|
||||
# or use another policy, when enabling this provider.
|
||||
OAUTH2_FEISHU_CLIENT_ID=
|
||||
OAUTH2_FEISHU_CLIENT_SECRET=
|
||||
OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize
|
||||
OAUTH2_FEISHU_PROTOCOL_VERSION=v3
|
||||
OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token
|
||||
OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info
|
||||
# Optional; defaults to {baseUrl}/login/oauth2/code/feishu. Set explicitly for local previews or reverse proxies.
|
||||
OAUTH2_FEISHU_REDIRECT_URI=
|
||||
OAUTH2_FEISHU_DISPLAY_NAME=飞书
|
||||
|
||||
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
|
||||
# Replace "OIDC" in variable names with your registration id (uppercase).
|
||||
# The registration id becomes identity_binding.provider_code — keep it stable.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,14 @@ stringData:
|
|||
oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret | quote }}
|
||||
{{- end }}
|
||||
|
||||
# OAuth2 Feishu (optional)
|
||||
{{- if .Values.secrets.oauth2FeishuClientId }}
|
||||
oauth2-feishu-client-id: {{ .Values.secrets.oauth2FeishuClientId | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secrets.oauth2FeishuClientSecret }}
|
||||
oauth2-feishu-client-secret: {{ .Values.secrets.oauth2FeishuClientSecret | quote }}
|
||||
{{- end }}
|
||||
|
||||
# Scanner LLM 配置 (optional)
|
||||
{{- if .Values.secrets.scannerLlmApiKey }}
|
||||
skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }}
|
||||
|
|
|
|||
|
|
@ -355,6 +355,32 @@ spec:
|
|||
key: oauth2-github-client-secret
|
||||
optional: true
|
||||
|
||||
# OAuth2 Feishu (optional)
|
||||
- name: OAUTH2_FEISHU_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "skillhub.secretName" . }}
|
||||
key: oauth2-feishu-client-id
|
||||
optional: true
|
||||
- name: OAUTH2_FEISHU_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "skillhub.secretName" . }}
|
||||
key: oauth2-feishu-client-secret
|
||||
optional: true
|
||||
- name: OAUTH2_FEISHU_AUTHORIZATION_URI
|
||||
value: {{ .Values.oauth2.feishu.authorizationUri | default "https://accounts.feishu.cn/open-apis/authen/v1/authorize" | quote }}
|
||||
- name: OAUTH2_FEISHU_PROTOCOL_VERSION
|
||||
value: {{ .Values.oauth2.feishu.protocolVersion | default "v3" | quote }}
|
||||
- name: OAUTH2_FEISHU_TOKEN_URI
|
||||
value: {{ .Values.oauth2.feishu.tokenUri | default "https://accounts.feishu.cn/oauth/v3/token" | quote }}
|
||||
- name: OAUTH2_FEISHU_USER_INFO_URI
|
||||
value: {{ .Values.oauth2.feishu.userInfoUri | default "https://open.feishu.cn/open-apis/authen/v1/user_info" | quote }}
|
||||
{{- with .Values.oauth2.feishu.redirectUri }}
|
||||
- name: OAUTH2_FEISHU_REDIRECT_URI
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.server.javaOpts }}
|
||||
- name: JAVA_OPTS
|
||||
value: {{ .Values.server.javaOpts }}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml"
|
|||
grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml"
|
||||
grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/default.yaml" \
|
||||
| grep -Fq 'value: "false"'
|
||||
if grep -Fq 'name: OAUTH2_FEISHU_REDIRECT_URI' "$TMP_DIR/default.yaml"; then
|
||||
fail "default Helm rendering must omit an empty Feishu redirect URI so Spring can derive baseUrl"
|
||||
fi
|
||||
|
||||
render feishu-redirect "$CHART_DIR" \
|
||||
--set-string oauth2.feishu.redirectUri=https://skills.example.com/login/oauth2/code/feishu \
|
||||
--show-only templates/server-deployment.yaml >"$TMP_DIR/feishu-redirect.yaml"
|
||||
grep -A1 -F 'name: OAUTH2_FEISHU_REDIRECT_URI' "$TMP_DIR/feishu-redirect.yaml" \
|
||||
| grep -Fq 'value: "https://skills.example.com/login/oauth2/code/feishu"' \
|
||||
|| fail "Helm must inject an explicitly configured Feishu redirect URI"
|
||||
|
||||
render suite-review-enabled "$CHART_DIR" \
|
||||
--set server.suiteReviewWritesEnabled=true \
|
||||
|
|
|
|||
|
|
@ -34,6 +34,25 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"oauth2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["feishu"],
|
||||
"properties": {
|
||||
"feishu": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["protocolVersion", "tokenUri"],
|
||||
"properties": {
|
||||
"authorizationUri": { "type": "string", "format": "uri" },
|
||||
"protocolVersion": { "type": "string", "enum": ["v2", "v3"] },
|
||||
"tokenUri": { "type": "string", "format": "uri" },
|
||||
"userInfoUri": { "type": "string", "format": "uri" },
|
||||
"redirectUri": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"builtinSkills": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
|
@ -156,6 +175,8 @@
|
|||
"downloadAnonCookieSecret": { "type": "string" },
|
||||
"oauth2GithubClientId": { "type": "string" },
|
||||
"oauth2GithubClientSecret": { "type": "string" },
|
||||
"oauth2FeishuClientId": { "type": "string" },
|
||||
"oauth2FeishuClientSecret": { "type": "string" },
|
||||
"scannerLlmApiKey": { "type": "string" },
|
||||
"scannerLlmBaseUrl": { "type": "string" },
|
||||
"scannerLlmModel": { "type": "string" }
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ auth:
|
|||
enabled: true
|
||||
provider: local
|
||||
|
||||
oauth2:
|
||||
feishu:
|
||||
authorizationUri: https://accounts.feishu.cn/open-apis/authen/v1/authorize
|
||||
protocolVersion: v3
|
||||
tokenUri: https://accounts.feishu.cn/oauth/v3/token
|
||||
userInfoUri: https://open.feishu.cn/open-apis/authen/v1/user_info
|
||||
redirectUri: ""
|
||||
|
||||
builtinSkills:
|
||||
enabled: true
|
||||
|
||||
|
|
@ -93,6 +101,8 @@ secrets:
|
|||
downloadAnonCookieSecret: ""
|
||||
oauth2GithubClientId: ""
|
||||
oauth2GithubClientSecret: ""
|
||||
oauth2FeishuClientId: ""
|
||||
oauth2FeishuClientSecret: ""
|
||||
scannerLlmApiKey: ""
|
||||
scannerLlmBaseUrl: ""
|
||||
scannerLlmModel: ""
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ services:
|
|||
SKILLHUB_STORAGE_S3_SECRET_KEY: ${SKILLHUB_STORAGE_S3_SECRET_KEY:-}
|
||||
SKILLHUB_STORAGE_S3_REGION: ${SKILLHUB_STORAGE_S3_REGION:-us-east-1}
|
||||
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:-false}
|
||||
SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING: ${SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING:-false}
|
||||
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:-false}
|
||||
SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:-PT10M}
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED: ${SKILLHUB_SECURITY_SCANNER_ENABLED:-true}
|
||||
|
|
@ -115,6 +116,18 @@ services:
|
|||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local}
|
||||
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder}
|
||||
OAUTH2_GITLAB_CLIENT_ID: ${OAUTH2_GITLAB_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_GITLAB_CLIENT_SECRET: ${OAUTH2_GITLAB_CLIENT_SECRET:-local-placeholder}
|
||||
OAUTH2_GITLAB_BASE_URI: ${OAUTH2_GITLAB_BASE_URI:-https://gitlab.com}
|
||||
OAUTH2_GITLAB_DISPLAY_NAME: ${OAUTH2_GITLAB_DISPLAY_NAME:-GitLab}
|
||||
OAUTH2_FEISHU_CLIENT_ID: ${OAUTH2_FEISHU_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_FEISHU_CLIENT_SECRET: ${OAUTH2_FEISHU_CLIENT_SECRET:-local-placeholder}
|
||||
OAUTH2_FEISHU_AUTHORIZATION_URI: ${OAUTH2_FEISHU_AUTHORIZATION_URI:-${OAUTH2_FEISHU_AUTHORIZE_URI:-https://accounts.feishu.cn}/open-apis/authen/v1/authorize}
|
||||
OAUTH2_FEISHU_PROTOCOL_VERSION: ${OAUTH2_FEISHU_PROTOCOL_VERSION:-v3}
|
||||
OAUTH2_FEISHU_TOKEN_URI: ${OAUTH2_FEISHU_TOKEN_URI:-https://accounts.feishu.cn/oauth/v3/token}
|
||||
OAUTH2_FEISHU_USER_INFO_URI: ${OAUTH2_FEISHU_USER_INFO_URI:-${OAUTH2_FEISHU_BASE_URI:-https://open.feishu.cn}/open-apis/authen/v1/user_info}
|
||||
OAUTH2_FEISHU_REDIRECT_URI: ${OAUTH2_FEISHU_REDIRECT_URI:-${SKILLHUB_PUBLIC_BASE_URL:-http://localhost}/login/oauth2/code/feishu}
|
||||
OAUTH2_FEISHU_DISPLAY_NAME: ${OAUTH2_FEISHU_DISPLAY_NAME:-飞书}
|
||||
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
|
||||
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}
|
||||
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-}
|
||||
|
|
|
|||
|
|
@ -227,6 +227,27 @@ spec:
|
|||
key: oauth2-github-client-secret
|
||||
optional: true
|
||||
|
||||
# OAuth2 Feishu (optional)
|
||||
- name: OAUTH2_FEISHU_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: skillhub-secret
|
||||
key: oauth2-feishu-client-id
|
||||
optional: true
|
||||
- name: OAUTH2_FEISHU_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: skillhub-secret
|
||||
key: oauth2-feishu-client-secret
|
||||
optional: true
|
||||
- name: OAUTH2_FEISHU_AUTHORIZATION_URI
|
||||
value: "https://accounts.feishu.cn/open-apis/authen/v1/authorize"
|
||||
- name: OAUTH2_FEISHU_PROTOCOL_VERSION
|
||||
value: "v3"
|
||||
- name: OAUTH2_FEISHU_TOKEN_URI
|
||||
value: "https://accounts.feishu.cn/oauth/v3/token"
|
||||
- name: OAUTH2_FEISHU_USER_INFO_URI
|
||||
value: "https://open.feishu.cn/open-apis/authen/v1/user_info"
|
||||
volumeMounts:
|
||||
- name: skillhub-storage
|
||||
mountPath: /var/lib/skillhub/storage
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ stringData:
|
|||
oauth2-github-client-id: ""
|
||||
oauth2-github-client-secret: ""
|
||||
|
||||
# 飞书 OAuth(可选,用于飞书登录;留空则登录页不展示该入口)
|
||||
oauth2-feishu-client-id: ""
|
||||
oauth2-feishu-client-secret: ""
|
||||
|
||||
# LLM 配置(可选,用于技能扫描)
|
||||
skill-scanner-llm-api-key: ""
|
||||
skill-scanner-llm-base-url: ""
|
||||
|
|
|
|||
|
|
@ -271,18 +271,69 @@ spring:
|
|||
client-id: ${OAUTH2_GITHUB_CLIENT_ID}
|
||||
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET}
|
||||
scope: read:user,user:email
|
||||
# 二期扩展示例:
|
||||
# gitlab:
|
||||
# client-id: ...
|
||||
# authorization-grant-type: authorization_code
|
||||
# google:
|
||||
# client-id: ...
|
||||
gitlab:
|
||||
client-id: ${OAUTH2_GITLAB_CLIENT_ID}
|
||||
client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET}
|
||||
authorization-grant-type: authorization_code
|
||||
feishu:
|
||||
provider: feishu
|
||||
client-id: ${OAUTH2_FEISHU_CLIENT_ID}
|
||||
client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET}
|
||||
# 飞书的 scope 配在开放平台应用上,不在这里传
|
||||
client-authentication-method: client_secret_post
|
||||
authorization-grant-type: authorization_code
|
||||
provider:
|
||||
feishu:
|
||||
# Full endpoints are configurable for Lark, private deployments, and gateways.
|
||||
authorization-uri: ${OAUTH2_FEISHU_AUTHORIZATION_URI:${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.feishu.cn}/open-apis/authen/v1/authorize}
|
||||
# OAUTH2_FEISHU_PROTOCOL_VERSION supports v2 and v3; default is v3.
|
||||
token-uri: ${OAUTH2_FEISHU_TOKEN_URI:https://accounts.feishu.cn/oauth/v3/token}
|
||||
user-info-uri: ${OAUTH2_FEISHU_USER_INFO_URI:${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info}
|
||||
```
|
||||
|
||||
Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需:
|
||||
1. `application.yml` 添加 registration 配置
|
||||
2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射
|
||||
3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现)
|
||||
1. `application.yml` 添加 registration 与 provider 配置
|
||||
2. 实现一个 `OAuthClaimsExtractor`,把该 Provider 的属性映射成统一的 `OAuthClaims`
|
||||
3. 登录页无需改代码:`/api/v1/auth/methods` 只返回配置了真实 client id 的注册,
|
||||
图标按 provider 名解析为 `/{provider}-logo.svg`
|
||||
|
||||
第 2 步是按 Provider 注册一个 Bean,而不是在某个类里按 `registrationId` 分支。
|
||||
账号匹配、建号、资料权威和账号守卫都在 `OAuthClaims` 之后共享,Provider 自己不做这些决策。
|
||||
|
||||
如果该 Provider 的 userinfo 响应不是标准的扁平结构(例如飞书用
|
||||
`{code, msg, data}` 信封,且以 HTTP 200 返回错误),再额外实现一个
|
||||
`ProviderOAuth2UserService`:它声明自己负责哪个 `registrationId`,
|
||||
接管 userinfo 的加载步骤,其余流程不变。该覆盖运行在
|
||||
`RemoteIdentityIoExecutor` 边界内,因此 Provider 的 HTTP 调用不会持有数据库事务。
|
||||
|
||||
Provider 侧还需遵守:subject 必须稳定(不要用可能在两次登录间变化的字段做
|
||||
fallback,否则同一个人会被拆成两个平台账号)、只有在 Provider 真正证明了邮箱
|
||||
所有权时才置 `emailVerified=true`、远程调用要有超时与响应大小上限、
|
||||
claims 提取过程不记录 subject/email/token。
|
||||
|
||||
#### 飞书 token 协议版本
|
||||
|
||||
飞书 token client 支持显式选择 `v2` 或 `v3`,默认值为 `v3`:
|
||||
|
||||
```bash
|
||||
OAUTH2_FEISHU_PROTOCOL_VERSION=v3
|
||||
OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize
|
||||
OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token
|
||||
OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info
|
||||
OAUTH2_FEISHU_REDIRECT_URI=
|
||||
|
||||
# 历史 v2 应用可显式切换:
|
||||
# OAUTH2_FEISHU_PROTOCOL_VERSION=v2
|
||||
# OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/open-apis/authen/v2/oauth/token
|
||||
```
|
||||
|
||||
两个版本都使用 JSON authorization-code exchange,当前实现会根据协议版本
|
||||
选择对应的标准 token endpoint;如需代理、区域或私有化 endpoint,可通过
|
||||
`OAUTH2_FEISHU_TOKEN_URI` 覆盖。授权和 userinfo endpoint 也分别通过
|
||||
`OAUTH2_FEISHU_AUTHORIZATION_URI`、`OAUTH2_FEISHU_USER_INFO_URI` 配置。协议版本不合法
|
||||
时发布配置校验失败,应用也会拒绝启动。不会在 v3 失败后自动使用 v2,因为 authorization code 只能使用一次,
|
||||
自动重试可能造成重复请求并掩盖配置错误。旧的 `OAUTH2_FEISHU_AUTHORIZE_URI` 和
|
||||
`OAUTH2_FEISHU_BASE_URI` 仍作为 base-URI 兼容回退,但新部署应使用完整 endpoint 变量。
|
||||
|
||||
## 4. 核心接口设计
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,8 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se
|
|||
- 使用发布镜像,不在用户机器上执行本地构建
|
||||
- 负责拉起 PostgreSQL、Redis、server、web
|
||||
- PostgreSQL、Redis 默认只绑定到 `127.0.0.1`
|
||||
- Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像
|
||||
- Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像;S3/OSS 的
|
||||
`SKILLHUB_STORAGE_S3_*` 变量会透传到 server
|
||||
- `.env.release.example`
|
||||
- 运行时变量模板
|
||||
- 包含镜像名、镜像版本、端口、数据库凭证、外部 OSS、站点公网地址和首登管理员参数
|
||||
|
|
@ -171,6 +172,18 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se
|
|||
- 在启动前校验 `.env.release`
|
||||
- 可提前拦截占位值、URL 格式错误、缺失的 OSS 凭据、危险的明文默认值
|
||||
|
||||
阿里云 OSS 等不支持 AWS chunked encoding 的对象存储,需要在 `.env.release` 中设置:
|
||||
|
||||
```dotenv
|
||||
SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING=true
|
||||
```
|
||||
|
||||
该变量由 `compose.release.yml` 透传到 server;修改后需要重新创建 server 容器:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate server
|
||||
```
|
||||
|
||||
### 5.5 镜像标签约定
|
||||
|
||||
- `edge`
|
||||
|
|
@ -283,7 +296,61 @@ services:
|
|||
- `SKILLHUB_WEB_API_BASE_URL=/skillhub`
|
||||
- `SKILLHUB_PUBLIC_BASE_URL=https://example.com/skillhub`
|
||||
网关可以在转发到 Web 容器前将该前缀重写掉,但公网 URL 仍必须保留前缀,确保 OAuth、CLI 和 registry 链接正确。
|
||||
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
|
||||
- 如果要开放真实登录,再补充对应 Provider 的 client id/secret:
|
||||
- GitHub:`OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
|
||||
- GitLab:`OAUTH2_GITLAB_CLIENT_ID` / `OAUTH2_GITLAB_CLIENT_SECRET`(自建实例再设 `OAUTH2_GITLAB_BASE_URI`)
|
||||
- 飞书:`OAUTH2_FEISHU_CLIENT_ID` / `OAUTH2_FEISHU_CLIENT_SECRET`。
|
||||
Endpoint 默认配置为:
|
||||
- `OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize`
|
||||
- `OAUTH2_FEISHU_PROTOCOL_VERSION=v3`
|
||||
- `OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token`
|
||||
- `OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info`
|
||||
- `OAUTH2_FEISHU_REDIRECT_URI=`(可选;Compose 默认根据
|
||||
`SKILLHUB_PUBLIC_BASE_URL` 生成 `/login/oauth2/code/feishu`,Helm/K8s 未设置时由
|
||||
Spring 使用 `{baseUrl}`;经过特殊反向代理或本地动态端口时应显式设置完整回调 URL)
|
||||
|
||||
Lark 国际版、私有化部署或企业网关可分别覆盖这三个完整 endpoint;历史的
|
||||
`OAUTH2_FEISHU_AUTHORIZE_URI` / `OAUTH2_FEISHU_BASE_URI` 仍可作为 base-URI
|
||||
兼容回退。`OAUTH2_FEISHU_TOKEN_URI` 必须指向支持 JSON authorization-code
|
||||
exchange 的 endpoint。`OAUTH2_FEISHU_PROTOCOL_VERSION` 只允许 `v2` 或 `v3`,
|
||||
默认 `v3`,不会自动 fallback。
|
||||
|
||||
留空即不展示该入口,无需改配置文件。注意:飞书邮箱由企业管理员导入、未经用户
|
||||
确认,因此 `emailVerified` 恒为 false;若在 `application.yml` 中把
|
||||
`skillhub.access-policy.mode` 设为 `EMAIL_DOMAIN`,该策略会拒绝所有未验证邮箱,
|
||||
飞书登录将一律失败。启用飞书时请保留默认的 `OPEN` 或改用其他准入模式。
|
||||
|
||||
启用飞书前,使用一个测试租户完成一次真实回调验收。不要把真实 client secret
|
||||
写入仓库、报告或聊天记录;只在受控的 `.env.release`、CI Secret 或 Kubernetes
|
||||
Secret 中注入:
|
||||
|
||||
1. 在飞书自建应用中登记
|
||||
`https://<公网域名>/login/oauth2/code/feishu`,并开启用户信息所需权限;如果使用
|
||||
本地预览,则把 `OAUTH2_FEISHU_REDIRECT_URI` 设置为预览 Web 地址对应的完整回调 URL。
|
||||
2. 在受控环境设置 `OAUTH2_FEISHU_CLIENT_ID`、`OAUTH2_FEISHU_CLIENT_SECRET`,确认
|
||||
`OAUTH2_FEISHU_PROTOCOL_VERSION` 与 token endpoint 匹配,然后运行:
|
||||
|
||||
```bash
|
||||
make validate-release-config
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d
|
||||
curl -fsS http://127.0.0.1:8080/actuator/health
|
||||
curl -fsS http://127.0.0.1:8080/api/v1/auth/methods
|
||||
```
|
||||
|
||||
3. 在登录页选择“飞书”,确认浏览器跳转到配置的授权域名;完成授权后应回到
|
||||
`/login/oauth2/code/feishu`,最终进入 `/` 或原始的 root-relative `returnTo`。
|
||||
4. 用同一个飞书账号再次登录,确认仍绑定同一个 SkillHub 账号;再用已禁用的
|
||||
SkillHub 账号登录,预期跳转 `/access-denied`,且不创建新 Session。
|
||||
5. 检查日志中只有 provider、HTTP 状态、错误码和阶段信息,不应出现 client secret、
|
||||
authorization code、access token、`open_id` 或上游错误文本:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.release.yml logs --tail=200 server \
|
||||
| rg -i 'client_secret|authorization code|access[_-]?token|open_id|secret|token'
|
||||
```
|
||||
|
||||
本地 mock 回调只能证明 SkillHub 与协议形状的集成,不能替代上述真实租户验收。
|
||||
没有可用飞书租户时,应将该项记录为“未验证”,不要宣称 Feishu 登录已通过。
|
||||
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`
|
||||
|
||||
## 8 OIDC 登录配置
|
||||
|
|
|
|||
|
|
@ -190,9 +190,14 @@ A: Skill names are generally in English; Chinese names are not currently support
|
|||
|
||||
A: As long as you have permission to view it, it can generally be downloaded.
|
||||
|
||||
## Q: How do I hide or remove the GitHub / GitLab SSO login options on the login page?
|
||||
## Q: How do I hide or remove third-party SSO login options on the login page?
|
||||
|
||||
A: Edit `application.yml` and comment out or delete the `github` and `gitlab` blocks under `spring.security.oauth2.client.registration`, along with their corresponding `provider` sections. Spring Boot then won't create these registrations at startup, and the login page won't show those entries.
|
||||
A: Login entries are config-driven: `/api/v1/auth/methods` only returns registrations that have a real client id. When a client id is empty or contains `placeholder`, that entry never reaches the login page.
|
||||
|
||||
So there are two ways to hide one:
|
||||
|
||||
- Leave the matching environment variable unset (for example, omit `OAUTH2_FEISHU_CLIENT_ID`). No config file change needed.
|
||||
- Or edit `application.yml` and comment out or delete the relevant registration block (`github`, `gitlab`, `feishu`) under `spring.security.oauth2.client.registration`, along with its `provider` section. Spring Boot then won't create that registration at startup.
|
||||
|
||||
## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use?
|
||||
|
||||
|
|
|
|||
|
|
@ -190,9 +190,17 @@ A: skill name 一般使用英文,目前不支持中文名(在 OpenClaw 中
|
|||
|
||||
A: 只要拥有可查看的权限,一般都可以下载。
|
||||
|
||||
## Q: 如何隐藏或删除登录页的 GitHub / GitLab SSO 登录方式?
|
||||
## Q: 如何隐藏或删除登录页的第三方 SSO 登录方式?
|
||||
|
||||
A: 修改 `application.yml`,注释或删除 `spring.security.oauth2.client.registration` 下的 `github` 和 `gitlab` 两块,并删除对应的 `provider` 段。Spring Boot 启动时便不会创建这两个注册,登录页也不会再显示对应入口。
|
||||
A: 登录入口是配置驱动的:`/api/v1/auth/methods` 只返回配置了真实 client id 的
|
||||
注册,client id 为空或包含 `placeholder` 时该入口不会出现在登录页。
|
||||
|
||||
所以隐藏某个入口有两种方式:
|
||||
|
||||
- 留空对应的环境变量即可(例如不设置 `OAUTH2_FEISHU_CLIENT_ID`),无需改动配置文件。
|
||||
- 或修改 `application.yml`,注释/删除 `spring.security.oauth2.client.registration`
|
||||
下对应的注册块(`github`、`gitlab`、`feishu`)以及对应的 `provider` 段,
|
||||
Spring Boot 启动时便不会创建该注册。
|
||||
|
||||
## Q: SkillHub 的安全扫描(Skill Scanner)是讯飞自研的吗?使用什么协议?
|
||||
|
||||
|
|
|
|||
|
|
@ -68,8 +68,45 @@ tmp="$(new_tmp)"
|
|||
|
||||
valid_env="$tmp/valid.env"
|
||||
write_env "$valid_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING=true" >>"$valid_env"
|
||||
"$SCRIPT" "$valid_env" >/dev/null
|
||||
|
||||
compose_default_redirect="$tmp/compose-default-redirect.txt"
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum \
|
||||
SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com \
|
||||
docker compose -f "$REPO_ROOT/compose.release.yml" config \
|
||||
| grep -A1 'OAUTH2_FEISHU_REDIRECT_URI:' >"$compose_default_redirect"
|
||||
grep -Fq 'https://skillhub.example.com/login/oauth2/code/feishu' "$compose_default_redirect" \
|
||||
|| fail "compose must derive the default Feishu redirect URI from SKILLHUB_PUBLIC_BASE_URL"
|
||||
|
||||
valid_feishu_env="$tmp/valid-feishu.env"
|
||||
write_env "$valid_feishu_env" "release-download-secret-32-bytes-minimum"
|
||||
cat >>"$valid_feishu_env" <<'EOF'
|
||||
OAUTH2_FEISHU_CLIENT_ID=cli_test
|
||||
OAUTH2_FEISHU_CLIENT_SECRET=secret_test
|
||||
OAUTH2_FEISHU_PROTOCOL_VERSION=v2
|
||||
OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize
|
||||
OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/open-apis/authen/v2/oauth/token
|
||||
OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info
|
||||
OAUTH2_FEISHU_REDIRECT_URI=http://127.0.0.1:55041/login/oauth2/code/feishu
|
||||
EOF
|
||||
"$SCRIPT" "$valid_feishu_env" >/dev/null
|
||||
|
||||
invalid_feishu_protocol_env="$tmp/invalid-feishu-protocol.env"
|
||||
write_env "$invalid_feishu_protocol_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "OAUTH2_FEISHU_PROTOCOL_VERSION=v1" >>"$invalid_feishu_protocol_env"
|
||||
expect_fail "$invalid_feishu_protocol_env" "OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3"
|
||||
|
||||
invalid_feishu_endpoint_env="$tmp/invalid-feishu-endpoint.env"
|
||||
write_env "$invalid_feishu_endpoint_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/oauth/token?tenant=prod" >>"$invalid_feishu_endpoint_env"
|
||||
expect_fail "$invalid_feishu_endpoint_env" "OAUTH2_FEISHU_TOKEN_URI must not contain a query"
|
||||
|
||||
invalid_feishu_redirect_env="$tmp/invalid-feishu-redirect.env"
|
||||
write_env "$invalid_feishu_redirect_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "OAUTH2_FEISHU_REDIRECT_URI=https://skillhub.example.com/login/oauth2/code/feishu?bad=1" >>"$invalid_feishu_redirect_env"
|
||||
expect_fail "$invalid_feishu_redirect_env" "OAUTH2_FEISHU_REDIRECT_URI must not contain a query"
|
||||
|
||||
disabled_builtin_skills_env="$tmp/disabled-builtin-skills.env"
|
||||
write_env "$disabled_builtin_skills_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "SKILLHUB_BUILTIN_SKILLS_ENABLED=false" >>"$disabled_builtin_skills_env"
|
||||
|
|
@ -253,6 +290,29 @@ write_env "$invalid_redis_sentinel_check_env" "release-download-secret-32-bytes-
|
|||
printf '%s\n' "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=yes" >>"$invalid_redis_sentinel_check_env"
|
||||
expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST must be true or false"
|
||||
|
||||
# An OAuth client id without its secret (or vice versa) leaves the provider half-configured:
|
||||
# the login button renders but the exchange fails. Checked for every supported provider.
|
||||
for provider in GITHUB GITLAB FEISHU; do
|
||||
missing_oauth_secret_env="$tmp/missing-oauth-secret.env"
|
||||
write_env "$missing_oauth_secret_env" "release-download-secret-32-bytes-minimum"
|
||||
printf 'OAUTH2_%s_CLIENT_ID=real-client-id\n' "$provider" >>"$missing_oauth_secret_env"
|
||||
expect_fail "$missing_oauth_secret_env" "OAUTH2_${provider}_CLIENT_SECRET is required"
|
||||
|
||||
missing_oauth_id_env="$tmp/missing-oauth-id.env"
|
||||
write_env "$missing_oauth_id_env" "release-download-secret-32-bytes-minimum"
|
||||
printf 'OAUTH2_%s_CLIENT_SECRET=real-client-secret\n' "$provider" >>"$missing_oauth_id_env"
|
||||
expect_fail "$missing_oauth_id_env" "OAUTH2_${provider}_CLIENT_ID is required"
|
||||
done
|
||||
|
||||
# A fully configured provider pair must pass.
|
||||
valid_oauth_env="$tmp/valid-oauth.env"
|
||||
write_env "$valid_oauth_env" "release-download-secret-32-bytes-minimum"
|
||||
cat >>"$valid_oauth_env" <<'EOF'
|
||||
OAUTH2_FEISHU_CLIENT_ID=cli_release_example
|
||||
OAUTH2_FEISHU_CLIENT_SECRET=release-feishu-secret
|
||||
EOF
|
||||
"$SCRIPT" "$valid_oauth_env" >/dev/null
|
||||
|
||||
draft_env="$tmp/draft.env"
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
case "$line" in
|
||||
|
|
|
|||
|
|
@ -380,14 +380,31 @@ if [ "${REDIS_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then
|
|||
warn "REDIS_BIND_ADDRESS is not 127.0.0.1; confirm Redis exposure is intended"
|
||||
fi
|
||||
|
||||
oauth_id="${OAUTH2_GITHUB_CLIENT_ID:-}"
|
||||
oauth_secret="${OAUTH2_GITHUB_CLIENT_SECRET:-}"
|
||||
if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then
|
||||
error "OAUTH2_GITHUB_CLIENT_SECRET is required when OAUTH2_GITHUB_CLIENT_ID is set"
|
||||
fi
|
||||
if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then
|
||||
error "OAUTH2_GITHUB_CLIENT_ID is required when OAUTH2_GITHUB_CLIENT_SECRET is set"
|
||||
fi
|
||||
for provider in GITHUB GITLAB FEISHU; do
|
||||
eval "oauth_id=\"\${OAUTH2_${provider}_CLIENT_ID:-}\""
|
||||
eval "oauth_secret=\"\${OAUTH2_${provider}_CLIENT_SECRET:-}\""
|
||||
if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then
|
||||
error "OAUTH2_${provider}_CLIENT_SECRET is required when OAUTH2_${provider}_CLIENT_ID is set"
|
||||
fi
|
||||
if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then
|
||||
error "OAUTH2_${provider}_CLIENT_ID is required when OAUTH2_${provider}_CLIENT_SECRET is set"
|
||||
fi
|
||||
done
|
||||
|
||||
feishu_protocol="${OAUTH2_FEISHU_PROTOCOL_VERSION:-v3}"
|
||||
case "$feishu_protocol" in
|
||||
v2|v3) ;;
|
||||
*) error "OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3" ;;
|
||||
esac
|
||||
|
||||
# OAuth endpoints are sent directly to the provider. Validate them here so a
|
||||
# typo fails before the release container starts.
|
||||
for feishu_endpoint in OAUTH2_FEISHU_AUTHORIZATION_URI OAUTH2_FEISHU_TOKEN_URI OAUTH2_FEISHU_USER_INFO_URI OAUTH2_FEISHU_REDIRECT_URI; do
|
||||
eval "feishu_endpoint_value=\${$feishu_endpoint:-}"
|
||||
if [ -n "$feishu_endpoint_value" ]; then
|
||||
validate_url "$feishu_endpoint"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$errors" -gt 0 ]; then
|
||||
echo "Release config validation failed: $errors error(s), $warnings warning(s)." >&2
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import org.springframework.web.util.ContentCachingRequestWrapper;
|
|||
import org.springframework.web.util.ContentCachingResponseWrapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
|
|
@ -54,7 +55,7 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
private void logRequest(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response, long duration) {
|
||||
String requestUri = request.getRequestURI();
|
||||
String queryString = request.getQueryString();
|
||||
String fullUrl = queryString != null ? requestUri + "?" + queryString : requestUri;
|
||||
String fullUrl = queryString != null ? requestUri + "?" + sanitizeQueryString(queryString) : requestUri;
|
||||
|
||||
String contentType = request.getContentType();
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
|
@ -74,6 +75,26 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
log.info(sb.toString());
|
||||
}
|
||||
|
||||
private String sanitizeQueryString(String queryString) {
|
||||
return java.util.Arrays.stream(queryString.split("&", -1))
|
||||
.map(parameter -> {
|
||||
int separator = parameter.indexOf('=');
|
||||
if (separator < 0) {
|
||||
return parameter;
|
||||
}
|
||||
String name = parameter.substring(0, separator).toLowerCase(Locale.ROOT);
|
||||
return isSensitiveQueryParameter(name)
|
||||
? parameter.substring(0, separator) + "=[REDACTED]"
|
||||
: parameter;
|
||||
})
|
||||
.collect(java.util.stream.Collectors.joining("&"));
|
||||
}
|
||||
|
||||
private boolean isSensitiveQueryParameter(String name) {
|
||||
return Set.of("code", "state", "error", "error_description", "error_uri", "access_token",
|
||||
"refresh_token", "id_token", "client_secret").contains(name);
|
||||
}
|
||||
|
||||
private boolean shouldSkip(String uri) {
|
||||
for (String prefix : SKIP_PREFIXES) {
|
||||
if (uri.startsWith(prefix)) {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,16 @@ spring:
|
|||
authorization-grant-type: authorization_code
|
||||
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
|
||||
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
|
||||
feishu:
|
||||
provider: feishu
|
||||
client-id: ${OAUTH2_FEISHU_CLIENT_ID:placeholder}
|
||||
client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET:placeholder}
|
||||
# Feishu scopes are configured on the open platform app itself
|
||||
# (contact:user.base:readonly, contact:user.email:readonly).
|
||||
authorization-grant-type: authorization_code
|
||||
client-authentication-method: client_secret_post
|
||||
redirect-uri: "${OAUTH2_FEISHU_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}"
|
||||
client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书}
|
||||
provider:
|
||||
github:
|
||||
api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com}
|
||||
|
|
@ -79,6 +89,14 @@ spring:
|
|||
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
|
||||
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
|
||||
user-name-attribute: username
|
||||
feishu:
|
||||
# Full endpoints are configurable for Lark, private deployments, and gateways.
|
||||
# The legacy base-URI variables remain as compatibility fallbacks.
|
||||
authorization-uri: ${OAUTH2_FEISHU_AUTHORIZATION_URI:${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.feishu.cn}/open-apis/authen/v1/authorize}
|
||||
# Supported values: v2 and v3. V3 is the default; selection is explicit and never falls back.
|
||||
token-uri: ${OAUTH2_FEISHU_TOKEN_URI:https://accounts.feishu.cn/oauth/v3/token}
|
||||
user-info-uri: ${OAUTH2_FEISHU_USER_INFO_URI:${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info}
|
||||
user-name-attribute: open_id
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
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.mock.web.MockHttpSession;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* Exercises the browser-facing Feishu OAuth flow against a local protocol-compatible provider.
|
||||
* The mock intentionally implements the authorization redirect, JSON token exchange, and wrapped
|
||||
* user-info response rather than mocking Spring Security internals.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class FeishuOAuthBrowserCallbackIntegrationTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final HttpServer PROVIDER_SERVER = startProviderServer();
|
||||
private static final String PROVIDER_BASE_URI = "http://127.0.0.1:" + PROVIDER_SERVER.getAddress().getPort();
|
||||
private static final AtomicReference<String> TOKEN_REQUEST_CONTENT_TYPE = new AtomicReference<>();
|
||||
private static final AtomicReference<String> TOKEN_REQUEST_BODY = new AtomicReference<>();
|
||||
private static final AtomicReference<String> USERINFO_AUTHORIZATION = new AtomicReference<>();
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
||||
@BeforeAll
|
||||
static void startProvider() {
|
||||
PROVIDER_SERVER.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopProvider() {
|
||||
PROVIDER_SERVER.stop(0);
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void feishuProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.security.oauth2.client.registration.feishu.client-id",
|
||||
() -> "mock-feishu-client");
|
||||
registry.add("spring.security.oauth2.client.registration.feishu.client-secret",
|
||||
() -> "mock-feishu-secret");
|
||||
registry.add("spring.security.oauth2.client.provider.feishu.authorization-uri",
|
||||
() -> PROVIDER_BASE_URI + "/authorize");
|
||||
registry.add("spring.security.oauth2.client.provider.feishu.token-uri",
|
||||
() -> PROVIDER_BASE_URI + "/oauth/v3/token");
|
||||
registry.add("spring.security.oauth2.client.provider.feishu.user-info-uri",
|
||||
() -> PROVIDER_BASE_URI + "/open-apis/authen/v1/user_info");
|
||||
registry.add("spring.security.oauth2.client.provider.feishu.user-name-attribute",
|
||||
() -> "open_id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void browserAuthorizationCallbackExchangesJsonTokenLoadsUserAndCreatesSession() throws Exception {
|
||||
TOKEN_REQUEST_CONTENT_TYPE.set(null);
|
||||
TOKEN_REQUEST_BODY.set(null);
|
||||
USERINFO_AUTHORIZATION.set(null);
|
||||
|
||||
MvcResult authorization = mockMvc.perform(get("/oauth2/authorization/feishu")
|
||||
.param("returnTo", "/dashboard"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andReturn();
|
||||
|
||||
URI providerAuthorization = URI.create(authorization.getResponse().getHeader("Location"));
|
||||
assertThat(providerAuthorization.getPath()).isEqualTo("/authorize");
|
||||
Map<String, String> authorizationParameters = queryParameters(providerAuthorization.getRawQuery());
|
||||
assertThat(authorizationParameters.get("client_id")).isEqualTo("mock-feishu-client");
|
||||
assertThat(authorizationParameters.get("redirect_uri"))
|
||||
.isEqualTo("http://localhost/login/oauth2/code/feishu");
|
||||
assertThat(authorizationParameters.get("state")).isNotBlank();
|
||||
|
||||
HttpResponse<Void> providerAuthorizationResponse = HttpClient.newHttpClient().send(
|
||||
HttpRequest.newBuilder(providerAuthorization).GET().build(),
|
||||
HttpResponse.BodyHandlers.discarding());
|
||||
assertThat(providerAuthorizationResponse.statusCode()).isEqualTo(302);
|
||||
URI callback = URI.create(providerAuthorizationResponse.headers().firstValue("Location").orElseThrow());
|
||||
assertThat(queryParameters(callback.getRawQuery()))
|
||||
.containsEntry("code", "mock-authorization-code")
|
||||
.containsEntry("state", authorizationParameters.get("state"));
|
||||
|
||||
MockHttpSession session = (MockHttpSession) authorization.getRequest().getSession(false);
|
||||
MvcResult callbackResult = mockMvc.perform(get(callback.getPath() + "?" + callback.getRawQuery())
|
||||
.session(session))
|
||||
.andExpect(redirectedUrl("/dashboard"))
|
||||
.andReturn();
|
||||
|
||||
assertThat(TOKEN_REQUEST_CONTENT_TYPE).hasValue("application/json;charset=utf-8");
|
||||
JsonNode tokenRequest = OBJECT_MAPPER.readTree(TOKEN_REQUEST_BODY.get());
|
||||
assertThat(tokenRequest.path("grant_type").asText()).isEqualTo("authorization_code");
|
||||
assertThat(tokenRequest.path("client_id").asText()).isEqualTo("mock-feishu-client");
|
||||
assertThat(tokenRequest.path("client_secret").asText()).isEqualTo("mock-feishu-secret");
|
||||
assertThat(tokenRequest.path("code").asText()).isEqualTo("mock-authorization-code");
|
||||
assertThat(USERINFO_AUTHORIZATION).hasValue("Bearer mock-access-token");
|
||||
assertThat(callbackResult.getRequest().getSession(false)).isSameAs(session);
|
||||
}
|
||||
|
||||
private static HttpServer startProviderServer() {
|
||||
try {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/authorize", FeishuOAuthBrowserCallbackIntegrationTest::authorize);
|
||||
server.createContext("/oauth/v3/token", FeishuOAuthBrowserCallbackIntegrationTest::token);
|
||||
server.createContext("/open-apis/authen/v1/user_info", FeishuOAuthBrowserCallbackIntegrationTest::userInfo);
|
||||
return server;
|
||||
} catch (IOException exception) {
|
||||
throw new ExceptionInInitializerError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void authorize(HttpExchange exchange) throws IOException {
|
||||
Map<String, String> parameters = queryParameters(exchange.getRequestURI().getRawQuery());
|
||||
URI redirect = URI.create(parameters.get("redirect_uri"));
|
||||
String separator = redirect.getRawQuery() == null ? "?" : "&";
|
||||
URI callback = URI.create(redirect + separator + "code=mock-authorization-code&state="
|
||||
+ parameters.get("state"));
|
||||
redirect(exchange, callback.toString());
|
||||
}
|
||||
|
||||
private static void token(HttpExchange exchange) throws IOException {
|
||||
TOKEN_REQUEST_CONTENT_TYPE.set(exchange.getRequestHeaders().getFirst("Content-Type"));
|
||||
TOKEN_REQUEST_BODY.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
respond(exchange, 200, """
|
||||
{"code":0,"access_token":"mock-access-token","token_type":"Bearer",\n"expires_in":3600,"scope":"contact:user.base:readonly"}
|
||||
""".replace("\n", ""));
|
||||
}
|
||||
|
||||
private static void userInfo(HttpExchange exchange) throws IOException {
|
||||
USERINFO_AUTHORIZATION.set(exchange.getRequestHeaders().getFirst("Authorization"));
|
||||
respond(exchange, 200, """
|
||||
{"code":0,"msg":"ok","data":{"open_id":"mock-open-id","name":"Mock Feishu User","email":"mock@example.com"}}
|
||||
""");
|
||||
}
|
||||
|
||||
private static void redirect(HttpExchange exchange, String location) throws IOException {
|
||||
exchange.getResponseHeaders().set("Location", location);
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
try (var output = exchange.getResponseBody()) {
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> queryParameters(String rawQuery) {
|
||||
Map<String, String> parameters = new HashMap<>();
|
||||
if (rawQuery == null || rawQuery.isBlank()) {
|
||||
return parameters;
|
||||
}
|
||||
for (String pair : rawQuery.split("&")) {
|
||||
String[] keyValue = pair.split("=", 2);
|
||||
parameters.put(urlDecode(keyValue[0]), keyValue.length == 2 ? urlDecode(keyValue[1]) : "");
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static String urlDecode(String value) {
|
||||
return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +104,28 @@ class RequestLoggingFilterTest {
|
|||
assertThat(loggedMessages()).noneMatch(message -> message.contains("Headers: {"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doFilterInternal_redactsOAuthCallbackQueryParameters() throws Exception {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
attachAppender();
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/login/oauth2/code/feishu");
|
||||
request.setQueryString("code=authorization-code&state=csrf-state&scope=contact:user.base:readonly");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, (req, res) -> {});
|
||||
|
||||
String message = loggedMessages().stream()
|
||||
.filter(entry -> entry.contains("GET /login/oauth2/code/feishu"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(message).contains("code=[REDACTED]");
|
||||
assertThat(message).contains("state=[REDACTED]");
|
||||
assertThat(message).contains("scope=contact:user.base:readonly");
|
||||
assertThat(message).doesNotContain("authorization-code");
|
||||
assertThat(message).doesNotContain("csrf-state");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.config;
|
|||
|
||||
import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
|
||||
import com.iflytek.skillhub.auth.oauth.CustomOidcUserService;
|
||||
import com.iflytek.skillhub.auth.oauth.FeishuOAuth2AccessTokenResponseClient;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
|
||||
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
|
||||
|
|
@ -61,6 +62,7 @@ public class SecurityConfig {
|
|||
|
||||
private final CustomOAuth2UserService customOAuth2UserService;
|
||||
private final CustomOidcUserService customOidcUserService;
|
||||
private final FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient;
|
||||
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
|
||||
private final OAuth2LoginSuccessHandler successHandler;
|
||||
private final OAuth2LoginFailureHandler failureHandler;
|
||||
|
|
@ -75,6 +77,7 @@ public class SecurityConfig {
|
|||
|
||||
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
|
||||
CustomOidcUserService customOidcUserService,
|
||||
FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient,
|
||||
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
|
||||
OAuth2LoginSuccessHandler successHandler,
|
||||
OAuth2LoginFailureHandler failureHandler,
|
||||
|
|
@ -88,6 +91,7 @@ public class SecurityConfig {
|
|||
@Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) {
|
||||
this.customOAuth2UserService = customOAuth2UserService;
|
||||
this.customOidcUserService = customOidcUserService;
|
||||
this.feishuOAuth2AccessTokenResponseClient = feishuOAuth2AccessTokenResponseClient;
|
||||
this.authorizationRequestResolver = authorizationRequestResolver;
|
||||
this.successHandler = successHandler;
|
||||
this.failureHandler = failureHandler;
|
||||
|
|
@ -132,6 +136,8 @@ public class SecurityConfig {
|
|||
})
|
||||
.oauth2Login(oauth2 -> oauth2
|
||||
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
|
||||
.tokenEndpoint(tokenEndpoint -> tokenEndpoint
|
||||
.accessTokenResponseClient(feishuOAuth2AccessTokenResponseClient))
|
||||
.userInfoEndpoint(userInfo -> userInfo
|
||||
.userService(customOAuth2UserService)
|
||||
.oidcUserService(customOidcUserService))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
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.Component;
|
||||
|
||||
/**
|
||||
* Provider-specific claims extractor for Feishu (Lark) OAuth users. Attributes are already
|
||||
* unwrapped from the Feishu response envelope by {@link FeishuOAuth2UserService}.
|
||||
*
|
||||
* <p>Like the GitHub and GitLab extractors, this class logs nothing: the subject, display name
|
||||
* and email it handles are exactly the values that must stay out of the logs.
|
||||
*/
|
||||
@Component
|
||||
public class FeishuClaimsExtractor implements OAuthClaimsExtractor {
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return FeishuOAuth2UserService.PROVIDER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
|
||||
Map<String, Object> attrs = oAuth2User.getAttributes();
|
||||
|
||||
// open_id is the stable primary subject: unique per user within one Feishu app, and it is
|
||||
// what Feishu guarantees to keep across logins. union_id stays in extra rather than acting
|
||||
// as a fallback -- a subject that can silently change identity between logins would bind
|
||||
// the same person to two platform accounts. Promoting union_id later needs an explicit
|
||||
// alias migration, not a fallback here.
|
||||
String subject = requireText(attrs.get("open_id"), "open_id");
|
||||
|
||||
String email = (String) attrs.get("enterprise_email");
|
||||
if (email == null) {
|
||||
email = (String) attrs.get("email");
|
||||
}
|
||||
// Feishu emails are imported by the organization admin and not verified with the user
|
||||
// in real time, so they carry no verification signal; keep emailVerified false.
|
||||
boolean emailVerified = false;
|
||||
|
||||
// name -> en_name and stop, matching the GitHub and GitLab extractors. Falling back to the
|
||||
// subject would write it into UserAccount.displayName and into UserActivatedEvent, pushing
|
||||
// the external subject somewhere event consumers may log it.
|
||||
String username = (String) attrs.get("name");
|
||||
if (username == null || username.isBlank()) {
|
||||
username = (String) attrs.get("en_name");
|
||||
}
|
||||
|
||||
return new OAuthClaims(
|
||||
FeishuOAuth2UserService.PROVIDER,
|
||||
subject,
|
||||
email,
|
||||
emailVerified,
|
||||
username,
|
||||
attrs
|
||||
);
|
||||
}
|
||||
|
||||
private static String requireText(Object value, String attribute) {
|
||||
String text = value == null ? null : String.valueOf(value).trim();
|
||||
if (text == null || text.isEmpty()) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("missing_subject", "Feishu user info is missing " + attribute, null)
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Provider-aware authorization-code token client. Feishu's token endpoint accepts a JSON request
|
||||
* and returns business errors in a HTTP-200 response, unlike the form-based OAuth client used by
|
||||
* the other providers.
|
||||
*/
|
||||
@Component
|
||||
public class FeishuOAuth2AccessTokenResponseClient
|
||||
implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FeishuOAuth2AccessTokenResponseClient.class);
|
||||
private static final String FEISHU_PROVIDER = "feishu";
|
||||
private static final String V2 = "v2";
|
||||
private static final String V3 = "v3";
|
||||
private static final String DEFAULT_V2_TOKEN_URI = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
|
||||
private static final String DEFAULT_V3_TOKEN_URI = "https://accounts.feishu.cn/oauth/v3/token";
|
||||
private static final String INVALID_TOKEN_RESPONSE = "feishu_invalid_token_response";
|
||||
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final RestClient restClient;
|
||||
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> standardClient;
|
||||
private final String protocolVersion;
|
||||
|
||||
@Autowired
|
||||
public FeishuOAuth2AccessTokenResponseClient(
|
||||
@Value("${OAUTH2_FEISHU_PROTOCOL_VERSION:v3}") String protocolVersion) {
|
||||
this(RestClient.builder().requestFactory(defaultRequestFactory()),
|
||||
new DefaultAuthorizationCodeTokenResponseClient(), protocolVersion);
|
||||
}
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient(
|
||||
RestClient.Builder restClientBuilder) {
|
||||
this(restClientBuilder, new DefaultAuthorizationCodeTokenResponseClient(), V3);
|
||||
}
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient(
|
||||
RestClient.Builder restClientBuilder,
|
||||
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> standardClient) {
|
||||
this(restClientBuilder, standardClient, V3);
|
||||
}
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient(
|
||||
RestClient.Builder restClientBuilder,
|
||||
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> standardClient,
|
||||
String protocolVersion) {
|
||||
this.restClient = restClientBuilder.build();
|
||||
this.standardClient = standardClient;
|
||||
this.protocolVersion = normalizeProtocolVersion(protocolVersion);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenResponse getTokenResponse(
|
||||
OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
|
||||
if (!FEISHU_PROVIDER.equals(authorizationCodeGrantRequest.getClientRegistration().getRegistrationId())) {
|
||||
return standardClient.getTokenResponse(authorizationCodeGrantRequest);
|
||||
}
|
||||
|
||||
Map<String, Object> requestBody = new LinkedHashMap<>();
|
||||
requestBody.put("grant_type", "authorization_code");
|
||||
requestBody.put("client_id", authorizationCodeGrantRequest.getClientRegistration().getClientId());
|
||||
requestBody.put("client_secret", authorizationCodeGrantRequest.getClientRegistration().getClientSecret());
|
||||
requestBody.put("code", authorizationCodeGrantRequest.getAuthorizationExchange()
|
||||
.getAuthorizationResponse().getCode());
|
||||
|
||||
String redirectUri = authorizationCodeGrantRequest.getAuthorizationExchange()
|
||||
.getAuthorizationRequest().getRedirectUri();
|
||||
if (redirectUri != null && !redirectUri.isBlank()) {
|
||||
requestBody.put("redirect_uri", redirectUri);
|
||||
}
|
||||
Object codeVerifier = authorizationCodeGrantRequest.getAuthorizationExchange()
|
||||
.getAuthorizationRequest().getAttribute("code_verifier");
|
||||
if (codeVerifier instanceof String verifier && !verifier.isBlank()) {
|
||||
requestBody.put("code_verifier", verifier);
|
||||
}
|
||||
|
||||
String tokenEndpoint = tokenUri(authorizationCodeGrantRequest);
|
||||
log.info("Feishu token exchange started: protocolVersion={}, endpointHost={}, redirectUriPresent={}, pkcePresent={}",
|
||||
protocolVersion,
|
||||
endpointHost(tokenEndpoint),
|
||||
redirectUri != null && !redirectUri.isBlank(),
|
||||
codeVerifier instanceof String verifier && !verifier.isBlank());
|
||||
try {
|
||||
return restClient.post()
|
||||
.uri(tokenEndpoint)
|
||||
.contentType(MediaType.parseMediaType("application/json; charset=utf-8"))
|
||||
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(requestBody)
|
||||
.exchange((request, response) -> {
|
||||
int status = response.getStatusCode().value();
|
||||
log.info("Feishu token exchange response: httpStatus={}", status);
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw tokenError("Feishu token endpoint returned HTTP " + status);
|
||||
}
|
||||
return parseResponse(readBounded(response.getBody()));
|
||||
});
|
||||
} catch (OAuth2AuthorizationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw tokenError("Feishu token exchange failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static OAuth2AccessTokenResponse parseResponse(byte[] responseBytes) {
|
||||
try {
|
||||
JsonNode response = OBJECT_MAPPER.readTree(responseBytes);
|
||||
int code = response.path("code").asInt(-1);
|
||||
if (code != 0) {
|
||||
throw tokenError("Feishu token endpoint returned business error code " + code);
|
||||
}
|
||||
|
||||
String accessToken = text(response, "access_token");
|
||||
if (accessToken == null) {
|
||||
throw tokenError("Feishu token endpoint returned no access token");
|
||||
}
|
||||
|
||||
String tokenType = text(response, "token_type");
|
||||
if (tokenType != null && !"Bearer".equalsIgnoreCase(tokenType)) {
|
||||
throw tokenError("Feishu token endpoint returned unsupported token type");
|
||||
}
|
||||
long expiresIn = response.path("expires_in").asLong(-1);
|
||||
if (expiresIn <= 0) {
|
||||
throw tokenError("Feishu token endpoint returned invalid expires_in");
|
||||
}
|
||||
|
||||
OAuth2AccessTokenResponse.Builder tokenResponse = OAuth2AccessTokenResponse
|
||||
.withToken(accessToken)
|
||||
.tokenType(OAuth2AccessToken.TokenType.BEARER)
|
||||
.expiresIn(expiresIn);
|
||||
String refreshToken = text(response, "refresh_token");
|
||||
if (refreshToken != null) {
|
||||
tokenResponse.refreshToken(refreshToken);
|
||||
}
|
||||
String scope = text(response, "scope");
|
||||
if (scope != null) {
|
||||
tokenResponse.scopes(Set.of(scope.trim().split("\\s+")));
|
||||
}
|
||||
log.info("Feishu token exchange parsed: businessCode=0, accessTokenPresent={}, refreshTokenPresent={}, expiresInSeconds={}, scopePresent={}",
|
||||
accessToken != null,
|
||||
refreshToken != null,
|
||||
expiresIn,
|
||||
scope != null);
|
||||
return tokenResponse.build();
|
||||
} catch (OAuth2AuthorizationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw tokenError("Feishu token endpoint returned an invalid response", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String tokenUri(OAuth2AuthorizationCodeGrantRequest request) {
|
||||
String configuredUri = request.getClientRegistration().getProviderDetails().getTokenUri();
|
||||
if (V2.equals(protocolVersion) && DEFAULT_V3_TOKEN_URI.equals(configuredUri)) {
|
||||
return DEFAULT_V2_TOKEN_URI;
|
||||
}
|
||||
if (V3.equals(protocolVersion) && DEFAULT_V2_TOKEN_URI.equals(configuredUri)) {
|
||||
return DEFAULT_V3_TOKEN_URI;
|
||||
}
|
||||
return configuredUri;
|
||||
}
|
||||
|
||||
private static String normalizeProtocolVersion(String value) {
|
||||
String normalized = value == null ? V3 : value.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (!V2.equals(normalized) && !V3.equals(normalized)) {
|
||||
throw new IllegalArgumentException(
|
||||
"OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String endpointHost(String endpoint) {
|
||||
try {
|
||||
return java.net.URI.create(endpoint).getHost();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
return value != null && value.isTextual() && !value.textValue().isBlank()
|
||||
? value.textValue()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static ClientHttpRequestFactory defaultRequestFactory() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(CONNECT_TIMEOUT);
|
||||
factory.setReadTimeout(READ_TIMEOUT);
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static byte[] readBounded(InputStream body) throws IOException {
|
||||
if (body == null) {
|
||||
throw new IOException("empty response body");
|
||||
}
|
||||
byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1);
|
||||
if (bytes.length > MAX_RESPONSE_BYTES) {
|
||||
throw new IOException("response body exceeds configured limit");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static OAuth2AuthorizationException tokenError(String description) {
|
||||
return tokenError(description, null);
|
||||
}
|
||||
|
||||
private static OAuth2AuthorizationException tokenError(String description, Throwable cause) {
|
||||
OAuth2Error error = new OAuth2Error(INVALID_TOKEN_RESPONSE, description, null);
|
||||
return cause == null ? new OAuth2AuthorizationException(error) : new OAuth2AuthorizationException(error, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Loads Feishu (Lark) user info, which deviates from the standard OAuth format: the response is
|
||||
* wrapped in a {@code {code, msg, data}} envelope and errors are reported with HTTP 200.
|
||||
*/
|
||||
@Component
|
||||
public class FeishuOAuth2UserService implements ProviderOAuth2UserService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FeishuOAuth2UserService.class);
|
||||
|
||||
static final String PROVIDER = "feishu";
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
/** A Feishu user_info payload is well under 1 KB; this only needs to stop an unbounded body. */
|
||||
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Uses an external-service client that is intentionally not customized with application
|
||||
* tracing. Trace context must not be propagated to the external Feishu service.
|
||||
*/
|
||||
@Autowired
|
||||
public FeishuOAuth2UserService() {
|
||||
this(RestClient.builder().requestFactory(defaultRequestFactory()));
|
||||
}
|
||||
|
||||
public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) {
|
||||
this.restClient = restClientBuilder
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the userinfo call so an unresponsive Feishu endpoint cannot hold a login thread. The
|
||||
* timeouts apply to this provider client only and do not change the shared HTTP defaults.
|
||||
*/
|
||||
private static ClientHttpRequestFactory defaultRequestFactory() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(CONNECT_TIMEOUT);
|
||||
factory.setReadTimeout(READ_TIMEOUT);
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads at most {@link #MAX_RESPONSE_BYTES} before parsing, so a misconfigured or hostile
|
||||
* A misconfigured Feishu user-info endpoint cannot stream an unbounded body into the parser. Reading one
|
||||
* byte past the cap is what distinguishes an oversized payload from one that exactly fills it.
|
||||
*/
|
||||
private static FeishuUserResponse readBounded(InputStream body) throws IOException {
|
||||
byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1);
|
||||
if (bytes.length > MAX_RESPONSE_BYTES) {
|
||||
throw new IOException("Feishu user info response exceeds " + MAX_RESPONSE_BYTES + " bytes");
|
||||
}
|
||||
return OBJECT_MAPPER.readValue(bytes, FeishuUserResponse.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return PROVIDER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
|
||||
.getUserInfoEndpoint().getUri();
|
||||
|
||||
log.info("Feishu userinfo started: endpointHost={}, accessTokenPresent={}",
|
||||
endpointHost(userInfoUri),
|
||||
userRequest.getAccessToken().getTokenValue() != null
|
||||
&& !userRequest.getAccessToken().getTokenValue().isBlank());
|
||||
FeishuUserResponse response;
|
||||
try {
|
||||
response = restClient.get()
|
||||
.uri(userInfoUri)
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userRequest.getAccessToken().getTokenValue())
|
||||
.exchange((request, clientResponse) -> {
|
||||
log.info("Feishu userinfo response: httpStatus={}", clientResponse.getStatusCode().value());
|
||||
return readBounded(clientResponse.getBody());
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// Exception class only: the message can quote the request URI, which holds the token.
|
||||
// Nothing downstream logs this failure, so without this line it would be silent.
|
||||
log.warn("Feishu user info request failed with {}", e.getClass().getSimpleName());
|
||||
// The cause carries the detail for operators; the OAuth2Error description stays generic
|
||||
// for the same reason the log line is.
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info", null),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if (response == null || response.code() != 0 || response.data() == null) {
|
||||
// Feishu's own error code is safe to record; its msg text is not.
|
||||
log.warn(
|
||||
"Feishu user info returned error code {}",
|
||||
response == null ? "none" : response.code()
|
||||
);
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error(
|
||||
"feishu_userinfo_error",
|
||||
"Feishu user info error, code " + (response == null ? "none" : response.code()),
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
log.info("Feishu userinfo parsed: businessCode=0, openIdPresent={}, unionIdPresent={}, emailPresent={}, displayNamePresent={}",
|
||||
response.data().openId() != null && !response.data().openId().isBlank(),
|
||||
response.data().unionId() != null && !response.data().unionId().isBlank(),
|
||||
(response.data().enterpriseEmail() != null && !response.data().enterpriseEmail().isBlank())
|
||||
|| (response.data().email() != null && !response.data().email().isBlank()),
|
||||
(response.data().name() != null && !response.data().name().isBlank())
|
||||
|| (response.data().enName() != null && !response.data().enName().isBlank()));
|
||||
|
||||
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
|
||||
.getUserInfoEndpoint().getUserNameAttributeName();
|
||||
|
||||
Map<String, Object> attributes = flatten(response.data(), userNameAttributeName);
|
||||
return new DefaultOAuth2User(
|
||||
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
|
||||
attributes,
|
||||
userNameAttributeName
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, Object> flatten(FeishuUserData data, String userNameAttributeName) {
|
||||
Map<String, Object> attributes = new LinkedHashMap<>();
|
||||
putIfPresent(attributes, "open_id", data.openId());
|
||||
putIfPresent(attributes, "union_id", data.unionId());
|
||||
putIfPresent(attributes, "name", data.name());
|
||||
putIfPresent(attributes, "en_name", data.enName());
|
||||
putIfPresent(attributes, "avatar_url", data.avatarUrl());
|
||||
putIfPresent(attributes, "email", data.email());
|
||||
putIfPresent(attributes, "enterprise_email", data.enterpriseEmail());
|
||||
if (!attributes.containsKey(userNameAttributeName)) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("feishu_userinfo_error", "Feishu user info missing " + userNameAttributeName, null)
|
||||
);
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static String endpointHost(String endpoint) {
|
||||
try {
|
||||
return java.net.URI.create(endpoint).getHost();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
private void putIfPresent(Map<String, Object> attributes, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
attributes.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record FeishuUserResponse(int code, String msg, @JsonProperty("data") FeishuUserData data) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record FeishuUserData(
|
||||
@JsonProperty("open_id") String openId,
|
||||
@JsonProperty("union_id") String unionId,
|
||||
@JsonProperty("name") String name,
|
||||
@JsonProperty("en_name") String enName,
|
||||
@JsonProperty("avatar_url") String avatarUrl,
|
||||
@JsonProperty("email") String email,
|
||||
@JsonProperty("enterprise_email") String enterpriseEmail
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
|
@ -16,6 +18,8 @@ import java.io.IOException;
|
|||
@Component
|
||||
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OAuth2LoginFailureHandler.class);
|
||||
|
||||
private final OAuthLoginFlowService oauthLoginFlowService;
|
||||
|
||||
public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) {
|
||||
|
|
@ -28,6 +32,8 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan
|
|||
throws IOException, ServletException {
|
||||
String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false));
|
||||
String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo);
|
||||
log.warn("OAuth login failed: exceptionType={}, returnToPresent={}, redirectPath={}",
|
||||
exception.getClass().getSimpleName(), returnTo != null, redirectTarget);
|
||||
if (redirectTarget != null) {
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectTarget);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import jakarta.servlet.ServletException;
|
|||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||
|
|
@ -22,6 +24,8 @@ import org.springframework.stereotype.Component;
|
|||
@Component
|
||||
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OAuth2LoginSuccessHandler.class);
|
||||
|
||||
private final PlatformSessionService platformSessionService;
|
||||
private final OAuthLoginFlowService oauthLoginFlowService;
|
||||
|
||||
|
|
@ -43,6 +47,8 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan
|
|||
}
|
||||
String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false));
|
||||
if (returnTo != null) {
|
||||
log.info("OAuth login succeeded: redirectPath={}, returnToPresent=true, sessionAttached=true",
|
||||
returnTo);
|
||||
// returnTo is a root-relative path (web client strips the base path). The redirect
|
||||
// strategy (DefaultRedirectStrategy) already prepends the request context path, which
|
||||
// reflects X-Forwarded-Prefix under forward-headers-strategy=framework — so the browser
|
||||
|
|
@ -52,6 +58,7 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan
|
|||
clearAuthenticationAttributes(request);
|
||||
return;
|
||||
}
|
||||
log.info("OAuth login succeeded: redirectPath={}, returnToPresent=false, sessionAttached=true", "/");
|
||||
super.onAuthenticationSuccess(request, response, authentication);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import java.util.Map;
|
|||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
|
||||
|
|
@ -35,7 +37,10 @@ import org.springframework.stereotype.Service;
|
|||
@Service
|
||||
public class OAuthLoginFlowService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OAuthLoginFlowService.class);
|
||||
|
||||
private final Map<String, OAuthClaimsExtractor> extractors;
|
||||
private final Map<String, ProviderOAuth2UserService> userServiceOverrides;
|
||||
private final AccessPolicy accessPolicy;
|
||||
private final IdentityBindingService identityBindingService;
|
||||
private final LegacyPlatformIdentityCore identityCore;
|
||||
|
|
@ -44,12 +49,14 @@ public class OAuthLoginFlowService {
|
|||
|
||||
@Autowired
|
||||
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
|
||||
List<ProviderOAuth2UserService> userServiceList,
|
||||
AccessPolicy accessPolicy,
|
||||
IdentityBindingService identityBindingService,
|
||||
LegacyPlatformIdentityCore identityCore,
|
||||
RemoteIdentityIoExecutor remoteIdentityIo) {
|
||||
this(
|
||||
extractorList,
|
||||
userServiceList,
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
|
|
@ -59,6 +66,7 @@ public class OAuthLoginFlowService {
|
|||
}
|
||||
|
||||
OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
|
||||
List<ProviderOAuth2UserService> userServiceList,
|
||||
AccessPolicy accessPolicy,
|
||||
IdentityBindingService identityBindingService,
|
||||
LegacyPlatformIdentityCore identityCore,
|
||||
|
|
@ -66,6 +74,8 @@ public class OAuthLoginFlowService {
|
|||
RemoteIdentityIoExecutor remoteIdentityIo) {
|
||||
this.extractors = extractorList.stream()
|
||||
.collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity()));
|
||||
this.userServiceOverrides = userServiceList.stream()
|
||||
.collect(Collectors.toMap(ProviderOAuth2UserService::getProvider, Function.identity()));
|
||||
this.accessPolicy = accessPolicy;
|
||||
this.identityBindingService = identityBindingService;
|
||||
this.identityCore = identityCore;
|
||||
|
|
@ -79,6 +89,7 @@ public class OAuthLoginFlowService {
|
|||
LegacyPlatformIdentityCore identityCore) {
|
||||
this(
|
||||
extractorList,
|
||||
List.of(),
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
|
|
@ -95,27 +106,34 @@ public class OAuthLoginFlowService {
|
|||
|
||||
public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) {
|
||||
LoadedProviderIdentity loadedIdentity = remoteIdentityIo.execute(() -> {
|
||||
OAuth2User upstreamUser = delegate.loadUser(request);
|
||||
String registrationId = request.getClientRegistration().getRegistrationId();
|
||||
ProviderOAuth2UserService override = userServiceOverrides.get(registrationId);
|
||||
OAuth2User upstreamUser = (override != null ? override : delegate).loadUser(request);
|
||||
OAuthClaimsExtractor extractor = extractors.get(registrationId);
|
||||
if (extractor == null) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null)
|
||||
);
|
||||
}
|
||||
return new LoadedProviderIdentity(
|
||||
upstreamUser,
|
||||
extractor.extract(request, upstreamUser)
|
||||
);
|
||||
OAuthClaims claims = extractor.extract(request, upstreamUser);
|
||||
log.info("OAuth provider identity loaded: provider={}, subjectPresent={}, emailPresent={}, displayNamePresent={}",
|
||||
registrationId,
|
||||
claims.subject() != null && !claims.subject().isBlank(),
|
||||
claims.email() != null && !claims.email().isBlank(),
|
||||
claims.providerLogin() != null && !claims.providerLogin().isBlank());
|
||||
return new LoadedProviderIdentity(upstreamUser, claims);
|
||||
});
|
||||
|
||||
PlatformPrincipal principal = authenticate(loadedIdentity.claims());
|
||||
log.info("OAuth identity authenticated: provider={}, principalCreated=true, rolesCount={}",
|
||||
loadedIdentity.claims().provider(), principal.platformRoles().size());
|
||||
return new AuthenticatedLoginContext(loadedIdentity.upstreamUser(), principal);
|
||||
}
|
||||
|
||||
public PlatformPrincipal authenticate(OAuthClaims claims) {
|
||||
AccessDecision decision = accessPolicy.evaluate(claims);
|
||||
|
||||
log.info("OAuth access policy evaluated: provider={}, decision={}", claims.provider(), decision);
|
||||
if (decision == AccessDecision.PENDING_APPROVAL) {
|
||||
LegacyPlatformIdentityDecision identityDecision = identityCore.evaluate(claims);
|
||||
ensureActiveCoreAllowsPlatformLogin(identityDecision);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
/**
|
||||
* Strategy interface for provider-specific OAuth user loading. Implementations override the
|
||||
* default user info loading for providers whose endpoints deviate from the standard
|
||||
* flat-attribute response format.
|
||||
*/
|
||||
public interface ProviderOAuth2UserService extends OAuth2UserService<OAuth2UserRequest, OAuth2User> {
|
||||
String getProvider();
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
|
|
@ -14,6 +16,8 @@ import org.springframework.stereotype.Component;
|
|||
public class SkillHubOAuth2AuthorizationRequestResolver
|
||||
implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillHubOAuth2AuthorizationRequestResolver.class);
|
||||
|
||||
private final DefaultOAuth2AuthorizationRequestResolver delegate;
|
||||
private final OAuthLoginFlowService oauthLoginFlowService;
|
||||
|
||||
|
|
@ -47,6 +51,10 @@ public class SkillHubOAuth2AuthorizationRequestResolver
|
|||
HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) {
|
||||
if (authorizationRequest != null) {
|
||||
oauthLoginFlowService.rememberReturnTo(request);
|
||||
log.info("OAuth authorization started: provider={}, redirectUri={}, returnToPresent={}",
|
||||
authorizationRequest.getAttribute("registration_id"),
|
||||
authorizationRequest.getRedirectUri(),
|
||||
request.getParameter("returnTo") != null);
|
||||
}
|
||||
return authorizationRequest;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
class FeishuClaimsExtractorTest {
|
||||
|
||||
private final FeishuClaimsExtractor extractor = new FeishuClaimsExtractor();
|
||||
|
||||
@Test
|
||||
void extract_prefersEnterpriseEmailOverPersonalEmail() {
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of(
|
||||
"open_id", "ou_123",
|
||||
"name", "张三",
|
||||
"email", "zhangsan@personal.example",
|
||||
"enterprise_email", "zhangsan@corp.example"
|
||||
));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.provider()).isEqualTo("feishu");
|
||||
assertThat(claims.subject()).isEqualTo("ou_123");
|
||||
assertThat(claims.email()).isEqualTo("zhangsan@corp.example");
|
||||
// Feishu emails are admin-imported; the extractor must not claim verification.
|
||||
assertThat(claims.emailVerified()).isFalse();
|
||||
assertThat(claims.providerLogin()).isEqualTo("张三");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_allowsNullEmailAndLeavesDisplayNameUnsetWhenFeishuSendsNoName() {
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of("open_id", "ou_456"));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.subject()).isEqualTo("ou_456");
|
||||
assertThat(claims.email()).isNull();
|
||||
assertThat(claims.emailVerified()).isFalse();
|
||||
// Must not synthesize "feishu-<open_id>": providerLogin is written to displayName and into
|
||||
// UserActivatedEvent, so a synthesized value would carry the subject into event consumers.
|
||||
assertThat(claims.providerLogin()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_fallsBackToEnglishNameWhenChineseNameBlank() {
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of(
|
||||
"open_id", "ou_789",
|
||||
"en_name", "Alice"
|
||||
));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.providerLogin()).isEqualTo("Alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_rejectsBlankOpenId() {
|
||||
// Blank must fail rather than become a subject. DefaultOAuth2User already rejects a
|
||||
// wholly absent open_id, so a permissive OAuth2User is used to test this contract
|
||||
// directly instead of relying on that upstream guard.
|
||||
Map<String, Object> attrs = new HashMap<>();
|
||||
attrs.put("open_id", " ");
|
||||
attrs.put("name", "张三");
|
||||
|
||||
assertThatThrownBy(() -> extractor.extract(userRequest(), permissiveUser(attrs)))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.hasMessageContaining("open_id");
|
||||
}
|
||||
|
||||
/** An {@link OAuth2User} that does not enforce the name attribute, unlike DefaultOAuth2User. */
|
||||
private OAuth2User permissiveUser(Map<String, Object> attrs) {
|
||||
return new OAuth2User() {
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority>
|
||||
getAuthorities() {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return String.valueOf(attrs.get("open_id"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_doesNotPromoteUnionIdToSubject() {
|
||||
// union_id stays in extra: a subject that can change between logins would split one
|
||||
// person across two platform accounts.
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of(
|
||||
"open_id", "ou_abc",
|
||||
"union_id", "on_xyz"
|
||||
));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.subject()).isEqualTo("ou_abc");
|
||||
assertThat(claims.extra()).containsEntry("union_id", "on_xyz");
|
||||
}
|
||||
|
||||
private DefaultOAuth2User user(Map<String, Object> attrs) {
|
||||
return new DefaultOAuth2User(java.util.List.of(), attrs, "open_id");
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
|
||||
.clientId("cli_test123")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize")
|
||||
.tokenUri("https://accounts.feishu.cn/oauth/v3/token")
|
||||
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
|
||||
.userNameAttributeName("open_id")
|
||||
.clientName("飞书")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
class FeishuOAuth2AccessTokenResponseClientTest {
|
||||
|
||||
@Test
|
||||
void getTokenResponse_postsFeishuJsonRequestAndParsesTokenResponse() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
|
||||
.andExpect(method(HttpMethod.POST))
|
||||
.andExpect(header(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf-8"))
|
||||
.andExpect(content().json("""
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": "cli_test",
|
||||
"client_secret": "secret_test",
|
||||
"code": "auth-code",
|
||||
"redirect_uri": "https://skillhub.example.com/login/oauth2/code/feishu"
|
||||
}
|
||||
""", false))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"code": 0,
|
||||
"access_token": "access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 7200,
|
||||
"refresh_token": "refresh-token",
|
||||
"scope": "contact:user.base:readonly offline_access"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
|
||||
|
||||
var response = client.getTokenResponse(grantRequest(false));
|
||||
|
||||
assertThat(response.getAccessToken().getTokenValue()).isEqualTo("access-token");
|
||||
assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER);
|
||||
assertThat(response.getAccessToken().getScopes())
|
||||
.containsExactlyInAnyOrder("contact:user.base:readonly", "offline_access");
|
||||
assertThat(response.getRefreshToken()).isNotNull();
|
||||
assertThat(response.getRefreshToken().getTokenValue()).isEqualTo("refresh-token");
|
||||
assertThat(response.getAccessToken().getExpiresAt()).isAfter(Instant.now());
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_usesV2EndpointWhenConfigured() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v2/oauth/token"))
|
||||
.andExpect(method(HttpMethod.POST))
|
||||
.andExpect(header(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf-8"))
|
||||
.andRespond(withSuccess("{\"code\":0,\"access_token\":\"v2-access-token\","
|
||||
+ "\"token_type\":\"Bearer\",\"expires_in\":3600}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(
|
||||
builder, request -> OAuth2AccessTokenResponse.withToken("unused").build(), "v2");
|
||||
|
||||
assertThat(client.getTokenResponse(grantRequest(false)).getAccessToken().getTokenValue())
|
||||
.isEqualTo("v2-access-token");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorRejectsUnsupportedProtocolVersion() {
|
||||
assertThatThrownBy(() -> new FeishuOAuth2AccessTokenResponseClient(
|
||||
RestClient.builder(), request -> OAuth2AccessTokenResponse.withToken("unused").build(), "v1"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("v2 or v3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_forwardsCodeVerifierWhenAuthorizationRequestContainsIt() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
|
||||
.andExpect(content().json("""
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": "cli_test",
|
||||
"client_secret": "secret_test",
|
||||
"code": "auth-code",
|
||||
"redirect_uri": "https://skillhub.example.com/login/oauth2/code/feishu",
|
||||
"code_verifier": "verifier-value"
|
||||
}
|
||||
""", false))
|
||||
.andRespond(withSuccess("{\"code\":0,\"access_token\":\"access-token\","
|
||||
+ "\"token_type\":\"Bearer\",\"expires_in\":3600}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
|
||||
|
||||
client.getTokenResponse(grantRequest(true));
|
||||
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_rejectsFeishuBusinessErrorReturnedAsHttp200() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
|
||||
.andRespond(withSuccess("""
|
||||
{"code": 20003, "error": "invalid_grant", "error_description": "secret_test rejected auth-code"}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false)))
|
||||
.isInstanceOf(OAuth2AuthorizationException.class)
|
||||
.satisfies(error -> {
|
||||
var oauthError = ((OAuth2AuthorizationException) error).getError();
|
||||
assertThat(oauthError.getErrorCode()).isEqualTo("feishu_invalid_token_response");
|
||||
assertThat(oauthError.getDescription()).doesNotContain("secret_test", "auth-code", "rejected");
|
||||
});
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_rejectsInvalidSuccessfulResponse() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
|
||||
.andRespond(withSuccess("{\"code\":0,\"access_token\":\"access-token\","
|
||||
+ "\"token_type\":\"mac\",\"expires_in\":3600}", MediaType.APPLICATION_JSON));
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false)))
|
||||
.isInstanceOf(OAuth2AuthorizationException.class)
|
||||
.satisfies(error -> assertThat(((OAuth2AuthorizationException) error).getError().getDescription())
|
||||
.contains("unsupported token type"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_rejectsHttpErrorWithoutExposingResponseDetails() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withStatus(org.springframework.http.HttpStatus.BAD_REQUEST)
|
||||
.body("client_secret=secret_test"));
|
||||
|
||||
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false)))
|
||||
.isInstanceOf(OAuth2AuthorizationException.class)
|
||||
.satisfies(error -> assertThat(((OAuth2AuthorizationException) error).getError().getDescription())
|
||||
.doesNotContain("secret_test", "auth-code"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_delegatesNonFeishuRegistrationToStandardClient() {
|
||||
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> delegate = request ->
|
||||
org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse.withToken("github-token")
|
||||
.tokenType(OAuth2AccessToken.TokenType.BEARER)
|
||||
.build();
|
||||
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(
|
||||
RestClient.builder(), delegate);
|
||||
|
||||
var response = client.getTokenResponse(grantRequest("github", false));
|
||||
|
||||
assertThat(response.getAccessToken().getTokenValue()).isEqualTo("github-token");
|
||||
}
|
||||
|
||||
private OAuth2AuthorizationCodeGrantRequest grantRequest(boolean withCodeVerifier) {
|
||||
return grantRequest("feishu", withCodeVerifier);
|
||||
}
|
||||
|
||||
private OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId, boolean withCodeVerifier) {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
|
||||
.clientId("cli_test")
|
||||
.clientSecret("secret_test")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize")
|
||||
.tokenUri("https://accounts.feishu.cn/oauth/v3/token")
|
||||
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
|
||||
.userNameAttributeName("open_id")
|
||||
.clientName("飞书")
|
||||
.build();
|
||||
OAuth2AuthorizationRequest.Builder request = OAuth2AuthorizationRequest.authorizationCode()
|
||||
.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
|
||||
.clientId(registration.getClientId())
|
||||
.redirectUri("https://skillhub.example.com/login/oauth2/code/feishu")
|
||||
.state("state")
|
||||
.attributes(attributes -> {
|
||||
if (withCodeVerifier) {
|
||||
attributes.put("code_verifier", "verifier-value");
|
||||
}
|
||||
});
|
||||
OAuth2AuthorizationResponse response = OAuth2AuthorizationResponse.success("auth-code")
|
||||
.redirectUri("https://skillhub.example.com/login/oauth2/code/feishu")
|
||||
.state("state")
|
||||
.build();
|
||||
return new OAuth2AuthorizationCodeGrantRequest(
|
||||
registration,
|
||||
new OAuth2AuthorizationExchange(request.build(), response));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
class FeishuOAuth2UserServiceTest {
|
||||
|
||||
@Test
|
||||
void loadUser_unwrapsFeishuEnvelopeIntoFlatAttributes() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"open_id": "ou_123",
|
||||
"union_id": "on_456",
|
||||
"name": "张三",
|
||||
"avatar_url": "https://avatar.example/zhangsan.png",
|
||||
"enterprise_email": "zhangsan@corp.example",
|
||||
"email": "zhangsan@personal.example"
|
||||
}
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
OAuth2User user = service.loadUser(userRequest());
|
||||
|
||||
assertThat(user.getName()).isEqualTo("ou_123");
|
||||
assertThat(user.getAttributes())
|
||||
.containsEntry("open_id", "ou_123")
|
||||
.containsEntry("union_id", "on_456")
|
||||
.containsEntry("name", "张三")
|
||||
.containsEntry("avatar_url", "https://avatar.example/zhangsan.png")
|
||||
.containsEntry("enterprise_email", "zhangsan@corp.example")
|
||||
.doesNotContainKey("code")
|
||||
.doesNotContainKey("data");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_throwsWhenFeishuReportsErrorCode() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{"code": 99991663, "msg": "invalid access token"}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
|
||||
.isEqualTo("feishu_userinfo_error"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_rejectsOversizedResponseBody() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
// 64 KB cap; pad a structurally valid envelope past it so the size check fires, not the parser.
|
||||
String padding = "x".repeat(70 * 1024);
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andRespond(withSuccess(
|
||||
"{\"code\":0,\"msg\":\"" + padding + "\",\"data\":{\"open_id\":\"ou_123\"}}",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
|
||||
.isEqualTo("feishu_userinfo_error"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_logsErrorCodeButNeverUpstreamTextOrToken() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{"code": 99991663, "msg": "token token-123 rejected for cli_test123"}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(FeishuOAuth2UserService.class);
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
try {
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
} finally {
|
||||
logger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
|
||||
String logged = appender.list.stream()
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.collect(java.util.stream.Collectors.joining("\n"));
|
||||
// A failure must leave an operator-facing record...
|
||||
assertThat(logged).contains("99991663");
|
||||
// ...but the upstream msg can quote the access token, so it must never be logged.
|
||||
assertThat(logged).doesNotContain("token-123");
|
||||
assertThat(logged).doesNotContain("rejected");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_errorDescriptionDoesNotEchoUpstreamTextOrToken() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{"code": 99991663, "msg": "token token-123 rejected for cli_test123"}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> {
|
||||
String description = ((OAuth2AuthenticationException) ex).getError().getDescription();
|
||||
// The upstream message can quote the access token; only the code may surface.
|
||||
assertThat(description).doesNotContain("token-123");
|
||||
assertThat(description).doesNotContain("rejected");
|
||||
assertThat(description).contains("99991663");
|
||||
});
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
|
||||
.clientId("cli_test123")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize")
|
||||
.tokenUri("https://accounts.feishu.cn/oauth/v3/token")
|
||||
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
|
||||
.userNameAttributeName("open_id")
|
||||
.clientName("飞书")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@ class OAuthLoginFlowServiceTest {
|
|||
};
|
||||
OAuthLoginFlowService service = new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
List.of(),
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
|
|
@ -102,6 +103,148 @@ class OAuthLoginFlowServiceTest {
|
|||
verify(delegate).loadUser(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadLoginContext_prefersProviderUserServiceOverrideInsideRemoteIoBoundary() {
|
||||
OAuthClaims claims = claims("feishu", "ou_1");
|
||||
OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) {
|
||||
return claims;
|
||||
}
|
||||
};
|
||||
OAuth2User overrideUser = new DefaultOAuth2User(
|
||||
List.of(new SimpleGrantedAuthority("OAUTH_USER")),
|
||||
Map.of("open_id", "ou_1"),
|
||||
"open_id"
|
||||
);
|
||||
AtomicInteger boundaryCalls = new AtomicInteger();
|
||||
AtomicInteger overrideCallsInsideBoundary = new AtomicInteger();
|
||||
RemoteIdentityIoExecutor remoteIdentityIo = new RemoteIdentityIoExecutor() {
|
||||
@Override
|
||||
public <T> T execute(java.util.function.Supplier<T> operation) {
|
||||
boundaryCalls.incrementAndGet();
|
||||
return operation.get();
|
||||
}
|
||||
};
|
||||
ProviderOAuth2UserService override = new ProviderOAuth2UserService() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest request) {
|
||||
// Records the boundary state at call time: a provider override must run inside the
|
||||
// remote-IO boundary, otherwise its HTTP call would hold the surrounding transaction.
|
||||
if (boundaryCalls.get() == 1) {
|
||||
overrideCallsInsideBoundary.incrementAndGet();
|
||||
}
|
||||
return overrideUser;
|
||||
}
|
||||
};
|
||||
AccessPolicy accessPolicy = mock(AccessPolicy.class);
|
||||
IdentityBindingService identityBindingService = mock(IdentityBindingService.class);
|
||||
LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class);
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = mock();
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"usr_2", "zhangsan", null, null, "feishu", Set.of("USER")
|
||||
);
|
||||
OAuthLoginFlowService service = new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
List.of(override),
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
delegate,
|
||||
remoteIdentityIo
|
||||
);
|
||||
OAuth2UserRequest request = oauthUserRequest("feishu");
|
||||
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW);
|
||||
when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy());
|
||||
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
|
||||
|
||||
OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request);
|
||||
|
||||
assertThat(result.upstreamUser()).isSameAs(overrideUser);
|
||||
assertThat(result.principal()).isSameAs(principal);
|
||||
assertThat(boundaryCalls).hasValue(1);
|
||||
assertThat(overrideCallsInsideBoundary).hasValue(1);
|
||||
// The default user service must not be consulted when an override claims the registration.
|
||||
verify(delegate, never()).loadUser(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadLoginContext_fallsBackToDefaultUserServiceForUnclaimedProviders() {
|
||||
OAuthClaims claims = claims();
|
||||
OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "github";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) {
|
||||
return claims;
|
||||
}
|
||||
};
|
||||
ProviderOAuth2UserService unrelatedOverride = new ProviderOAuth2UserService() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest request) {
|
||||
throw new AssertionError("Feishu override must not handle a GitHub login");
|
||||
}
|
||||
};
|
||||
AccessPolicy accessPolicy = mock(AccessPolicy.class);
|
||||
IdentityBindingService identityBindingService = mock(IdentityBindingService.class);
|
||||
LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class);
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = mock();
|
||||
OAuth2User upstreamUser = new DefaultOAuth2User(
|
||||
List.of(new SimpleGrantedAuthority("OAUTH_USER")),
|
||||
Map.of("id", "gh_1"),
|
||||
"id"
|
||||
);
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"usr_1", "alice", "alice@example.com", null, "github", Set.of("USER")
|
||||
);
|
||||
OAuthLoginFlowService service = new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
List.of(unrelatedOverride),
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
delegate,
|
||||
directRemoteIo()
|
||||
);
|
||||
OAuth2UserRequest request = oauthUserRequest();
|
||||
when(delegate.loadUser(request)).thenReturn(upstreamUser);
|
||||
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW);
|
||||
when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy());
|
||||
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
|
||||
|
||||
OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request);
|
||||
|
||||
assertThat(result.upstreamUser()).isSameAs(upstreamUser);
|
||||
verify(delegate).loadUser(request);
|
||||
}
|
||||
|
||||
private static RemoteIdentityIoExecutor directRemoteIo() {
|
||||
return new RemoteIdentityIoExecutor() {
|
||||
@Override
|
||||
public <T> T execute(java.util.function.Supplier<T> operation) {
|
||||
return operation.get();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(IdentityCoreMode.class)
|
||||
void authenticate_preservesPrincipalAcrossLegacyShadowAndActiveModes(IdentityCoreMode mode) {
|
||||
|
|
@ -320,12 +463,20 @@ class OAuthLoginFlowServiceTest {
|
|||
);
|
||||
}
|
||||
|
||||
private static OAuthClaims claims(String provider, String subject) {
|
||||
return new OAuthClaims(provider, subject, null, false, subject, Map.of());
|
||||
}
|
||||
|
||||
private static OAuth2UserRequest oauthUserRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("github")
|
||||
return oauthUserRequest("github");
|
||||
}
|
||||
|
||||
private static OAuth2UserRequest oauthUserRequest(String registrationId) {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
|
||||
.clientId("client")
|
||||
.clientSecret("secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("https://skillhub.example/login/oauth2/code/github")
|
||||
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
|
||||
.authorizationUri("https://github.example/oauth/authorize")
|
||||
.tokenUri("https://github.example/oauth/token")
|
||||
.userInfoUri("https://github.example/user")
|
||||
|
|
|
|||
2
web/public/feishu-logo.svg
Normal file
2
web/public/feishu-logo.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Official Feishu/Lark logo, source: homarr-labs/dashboard-icons -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="62.16 52.66 407.87 407.87"><path d="M274.18 264.785q.515-.517 1.03-1.027c.685-.688 1.372-1.258 2.056-1.945l1.37-1.372 4.118-4.113 5.598-5.601 4.8-4.797 4.575-4.457 4.796-4.688 4.344-4.344 6.059-6.054c1.14-1.145 2.285-2.29 3.543-3.317 2.168-2.054 4.457-4 6.855-5.828 2.172-1.715 4.344-3.312 6.516-4.914 3.082-2.172 6.398-4.344 9.71-6.285 3.204-1.941 6.63-3.656 10.06-5.371 3.199-1.602 6.515-2.973 9.827-4.23 1.829-.684 3.774-1.372 5.602-2.055.914-.344 1.941-.688 2.856-.914-8.57-33.715-24.227-64.575-45.258-90.86-4.114-5.14-10.399-8.113-17.028-8.113H130.754c-3.203 0-4.457 4-1.945 5.941 59.543 43.66 109.144 99.887 145.03 164.801 0-.226.227-.34.34-.457m0 0" style="stroke:none;fill-rule:nonzero;fill:#00d6b9;fill-opacity:1"/><path d="M204.79 418.691c90.288 0 169.03-49.828 210.058-123.543 1.488-2.628 2.859-5.257 4.23-7.882q-3.087 6-6.86 11.312l-2.741 3.77c-1.141 1.488-2.399 2.972-3.657 4.457-1.03 1.144-2.058 2.285-3.086 3.316-2.058 2.172-4.343 4.227-6.629 6.172a53 53 0 0 1-3.886 3.2c-1.598 1.144-3.086 2.284-4.684 3.429-1.031.683-2.058 1.371-3.086 1.941-1.144.684-2.172 1.258-3.316 1.942a131 131 0 0 1-6.969 3.543c-2.059.918-4.117 1.828-6.289 2.515-2.285.801-4.57 1.602-6.969 2.285-3.543.914-7.086 1.715-10.742 2.286-2.629.457-5.258.687-8 .914-2.86.23-5.601.23-8.457.23-3.086 0-6.289-.23-9.488-.57a83 83 0 0 1-7.086-1.031c-2.055-.34-4.113-.801-6.168-1.258-1.031-.227-2.176-.57-3.203-.797-2.973-.8-6.055-1.602-9.028-2.516-1.488-.457-2.972-.914-4.457-1.258-2.172-.683-4.457-1.37-6.629-2.058-1.828-.57-3.656-1.14-5.37-1.711q-2.573-.86-5.145-1.715c-1.14-.344-2.285-.8-3.543-1.144-1.371-.457-2.856-1.028-4.227-1.485-1.027-.344-2.058-.687-2.972-1.027-1.942-.688-4-1.488-5.942-2.172-1.144-.457-2.285-.914-3.43-1.258-1.484-.57-3.085-1.144-4.57-1.828-1.601-.687-3.203-1.258-4.8-1.945-1.028-.457-2.06-.797-3.087-1.258-1.257-.57-2.628-1.027-3.886-1.598-1.028-.457-1.942-.8-2.969-1.258l-3.086-1.37c-.914-.344-1.832-.801-2.746-1.145a44 44 0 0 1-2.512-1.14c-.8-.345-1.715-.802-2.515-1.145-.914-.344-1.715-.801-2.512-1.141-1.031-.457-2.172-1.031-3.203-1.484-1.14-.575-2.285-1.032-3.426-1.602-1.258-.574-2.402-1.144-3.66-1.715-1.027-.457-2.055-1.027-3.082-1.484-54.172-26.973-102.172-63.086-143.09-106.746-2.055-2.172-5.71-.684-5.71 2.289l.112 154.398v12.57c0 7.317 3.543 14.06 9.598 18.172 38.172 24.801 83.773 39.543 132.914 39.543m0 0" style="stroke:none;fill-rule:nonzero;fill:#3370ff;fill-opacity:1"/><path d="M414.84 295.188c0 .113-.113.113-.113.226zl.8-1.489c-.343.457-.574 1.028-.8 1.488m3.793-7.05.226-.457.114-.23q-.17.513-.34.687m0 0" style="stroke:none;fill-rule:nonzero;fill:#133c9a;fill-opacity:1"/><path d="M470.035 201.121c-18.285-9.031-38.86-14.059-60.687-14.059-12.914 0-25.485 1.829-37.371 5.141-1.372.344-2.743.8-4.114 1.258-.914.344-1.941.574-2.855.914-1.945.688-3.774 1.375-5.602 2.059-3.316 1.257-6.629 2.742-9.828 4.23-3.43 1.598-6.742 3.426-10.058 5.371a128 128 0 0 0-9.715 6.285c-2.285 1.602-4.457 3.2-6.512 4.914a154 154 0 0 0-6.86 5.828c-1.14 1.141-2.398 2.172-3.542 3.313l-6.055 6.059-4.344 4.343-4.8 4.684-4.57 4.46-4.802 4.798-11.086 11.086c-.687.687-1.37 1.37-2.058 1.945l-1.028 1.027c-.457.457-1.027 1.028-1.601 1.485-.57.57-1.14 1.031-1.711 1.601a244.4 244.4 0 0 1-49.828 35.313c1.027.457 2.168 1.027 3.199 1.488.8.34 1.715.797 2.512 1.14.8.344 1.715.801 2.515 1.145.801.344 1.602.684 2.516 1.14.914.345 1.828.802 2.742 1.145l3.086 1.371c1.027.457 1.942.801 2.969 1.258 1.258.57 2.629 1.028 3.887 1.598 1.03.46 2.058.8 3.086 1.258 1.601.687 3.199 1.258 4.8 1.945 1.485.57 3.086 1.14 4.57 1.828 1.145.457 2.286.914 3.43 1.258 1.946.684 4 1.484 5.946 2.172a81 81 0 0 1 2.968 1.027c1.371.457 2.856 1.028 4.23 1.485 1.141.343 2.286.8 3.544 1.14q2.567.86 5.14 1.719c1.829.57 3.657 1.14 5.372 1.71 2.171.688 4.457 1.376 6.628 2.06 1.489.457 2.973.914 4.457 1.257 2.973.914 5.942 1.715 9.032 2.512 1.027.344 2.168.574 3.199.8 2.055.458 4.113.915 6.172 1.259 2.398.457 4.683.8 7.082 1.03 3.203.34 6.402.571 9.488.571 2.856 0 5.715 0 8.457-.23 2.63-.227 5.371-.457 8-.914 3.656-.57 7.2-1.371 10.742-2.286 2.399-.683 4.688-1.37 6.973-2.285 2.172-.8 4.227-1.601 6.285-2.515 2.399-1.028 4.684-2.285 6.973-3.543 1.14-.57 2.168-1.258 3.312-1.942 1.028-.687 2.059-1.257 3.086-1.945 1.602-1.027 3.2-2.168 4.684-3.426a52 52 0 0 0 3.887-3.203c2.289-1.941 4.457-4 6.628-6.168 1.032-1.031 2.06-2.172 3.086-3.316 1.258-1.485 2.516-2.969 3.657-4.457.918-1.258 1.828-2.512 2.742-3.77 2.515-3.543 4.8-7.316 6.86-11.199l2.284-4.688 21.145-42.171v.113c6.742-14.742 16.226-28.113 27.656-39.426m0 0" style="stroke:none;fill-rule:nonzero;fill:#133c9a;fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
|
@ -95,6 +95,12 @@ export default defineConfig({
|
|||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/login/oauth2': {
|
||||
target: 'http://localhost:8080',
|
||||
// Preserve the browser-facing localhost:3000 host so Spring's
|
||||
// post-login redirect does not send the SPA to localhost:8080.
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue