From f08cd94924dfda99ad3fb68bca3a4a1247c34172 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 15 Aug 2026 16:37:10 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AFsso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +++ src/Jiaowu.Api/Controllers/SsoController.cs | 29 +++++++++-- .../android/app/src/main/AndroidManifest.xml | 20 ++++++++ web/package-lock.json | 20 ++++++++ web/package.json | 2 + web/public/.well-known/assetlinks.json | 12 +++++ web/src/main.ts | 2 + web/src/services/nativeAppLinks.ts | 50 +++++++++++++++++++ web/src/views/AccountView.vue | 8 ++- web/src/views/LoginView.vue | 15 ++++-- 10 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 web/public/.well-known/assetlinks.json create mode 100644 web/src/services/nativeAppLinks.ts diff --git a/README.md b/README.md index 91d716e..593da9a 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,14 @@ Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak 显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内 有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。 +Android App 使用系统浏览器完成 Keycloak 登录,再通过 `https://eis.biss.click/sso/callback` +或 `/sso/bind` 的 Android App Link 回到应用;Keycloak 的 Valid redirect URI 仍然只配置 +`Sso__CallbackUrl`(即 `/signin-keycloak`),不要配置 `mingxu://`。正式发布前,将 Play +App Signing 证书的 SHA-256 指纹写入 `web/public/.well-known/assetlinks.json`,并确保该文件 +以 `application/json` 在 `https://eis.biss.click/.well-known/assetlinks.json` 可匿名访问。当前 +配置的包名为 `edu.mingxu.jiaowu`;站点域名或正式签名证书变更时必须同时更新此文件和 Android +Manifest 后重新签名发布 APK/AAB,此类变更不能通过 OTA 下发。 + 用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先 使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该 意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。 diff --git a/src/Jiaowu.Api/Controllers/SsoController.cs b/src/Jiaowu.Api/Controllers/SsoController.cs index c4a258f..c4dd3b8 100644 --- a/src/Jiaowu.Api/Controllers/SsoController.cs +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -26,6 +26,7 @@ public sealed class SsoController( ILogger logger) : ControllerBase { private const string BindingIntentProperty = "sso-binding-intent"; + private const string NativeAppProperty = "sso-native-app"; private readonly SsoOptions _options = options.Value; [AllowAnonymous] @@ -42,6 +43,7 @@ public sealed class SsoController( public async Task Login( [FromQuery] string? returnUrl = null, [FromQuery] string? bindingIntent = null, + [FromQuery] bool nativeApp = false, CancellationToken cancellationToken = default) { if (!_options.Enabled) @@ -49,6 +51,8 @@ public sealed class SsoController( var safeReturnUrl = NormalizeReturnUrl(returnUrl); var properties = new AuthenticationProperties(); + if (nativeApp) + properties.Items[NativeAppProperty] = bool.TrueString; if (!string.IsNullOrWhiteSpace(bindingIntent)) { var targetUserId = await cache.GetStringAsync( @@ -108,6 +112,10 @@ public sealed class SsoController( out var storedBindingIntent) ? storedBindingIntent : null; + var nativeApp = authentication.Properties is { } externalProperties && + externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) && + bool.TryParse(nativeAppValue, out var isNativeApp) && + isNativeApp; if (!string.IsNullOrWhiteSpace(bindingIntent)) { var targetUserId = await cache.GetStringAsync( @@ -165,7 +173,7 @@ public sealed class SsoController( cancellationToken); await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie); - var bindingPage = BuildFrontendUrl("/sso/bind") + + var bindingPage = BuildFrontendUrl("/sso/bind", nativeApp) + $"?code={Uri.EscapeDataString(bindingCode)}" + $"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}"; return Redirect(bindingPage); @@ -190,7 +198,7 @@ public sealed class SsoController( cancellationToken); await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie); - var callback = BuildFrontendUrl("/sso/callback") + + var callback = BuildFrontendUrl("/sso/callback", nativeApp) + $"?code={Uri.EscapeDataString(exchangeCode)}" + $"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}"; return Redirect(callback); @@ -344,7 +352,8 @@ public sealed class SsoController( [Authorize] [HttpPost("prepare-binding")] public async Task> PrepareBinding( - CancellationToken cancellationToken) + CancellationToken cancellationToken, + [FromQuery] bool nativeApp = false) { if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); @@ -373,7 +382,8 @@ public sealed class SsoController( values: new { returnUrl = "/account", - bindingIntent = intentCode + bindingIntent = intentCode, + nativeApp })!; return new SsoBindingStartResponse(loginUrl); } @@ -417,10 +427,19 @@ public sealed class SsoController( ? returnUrl : "/dashboard"; - private string BuildFrontendUrl(string path) => + private string BuildFrontendUrl(string path, bool requireAbsoluteUrl = false) + { + if (requireAbsoluteUrl && string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)) + { + throw new InvalidOperationException( + "原生单点登录需要配置 Sso:FrontendBaseUrl 为已验证的 HTTPS 地址。"); + } + + return string.IsNullOrWhiteSpace(_options.FrontendBaseUrl) ? path : _options.FrontendBaseUrl.TrimEnd('/') + path; + } private RedirectResult RedirectToFrontendError( string error, diff --git a/web/native/android/app/src/main/AndroidManifest.xml b/web/native/android/app/src/main/AndroidManifest.xml index 8dd2642..1f416ea 100644 --- a/web/native/android/app/src/main/AndroidManifest.xml +++ b/web/native/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,26 @@ + + + + + + + + + + + + + + diff --git a/web/package-lock.json b/web/package-lock.json index d198958..3777c14 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,9 @@ "name": "web", "dependencies": { "@capacitor/android": "^8.4.2", + "@capacitor/app": "^8.0.0", "@capacitor/barcode-scanner": "^3.1.0", + "@capacitor/browser": "^8.0.0", "@capacitor/core": "^8.4.2", "@capacitor/geolocation": "^8.2.0", "@capacitor/ios": "^8.4.2", @@ -1679,6 +1681,15 @@ "@capacitor/core": "^8.5.0" } }, + "node_modules/@capacitor/app": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.0.tgz", + "integrity": "sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/barcode-scanner": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@capacitor/barcode-scanner/-/barcode-scanner-3.1.0.tgz", @@ -1691,6 +1702,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/browser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.0.tgz", + "integrity": "sha512-xey7maszGABKuStvXDV4vXN+EzIyz0o7zlyzw4JKG6o/1GzqeqHqVuE+8Ux+Hks3DZji4LoriWZVgVF6mR6RGg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/cli": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.0.tgz", diff --git a/web/package.json b/web/package.json index aa09d8b..cad57df 100644 --- a/web/package.json +++ b/web/package.json @@ -17,7 +17,9 @@ }, "dependencies": { "@capacitor/android": "^8.4.2", + "@capacitor/app": "^8.0.0", "@capacitor/barcode-scanner": "^3.1.0", + "@capacitor/browser": "^8.0.0", "@capacitor/core": "^8.4.2", "@capacitor/geolocation": "^8.2.0", "@capacitor/ios": "^8.4.2", diff --git a/web/public/.well-known/assetlinks.json b/web/public/.well-known/assetlinks.json new file mode 100644 index 0000000..63026bf --- /dev/null +++ b/web/public/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "edu.mingxu.jiaowu", + "sha256_cert_fingerprints": [ + "F3:75:B5:0B:A6:DB:41:C3:24:A8:B4:99:22:39:A2:FF:AF:FA:A7:CE:05:42:A8:AC:38:19:03:18:5F:35:AD:39" + ] + } + } +] diff --git a/web/src/main.ts b/web/src/main.ts index a4d39e9..e36232e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -10,6 +10,7 @@ import router from './router' import { initializeAppUpdates } from './services/appUpdates' import { initializeNativeHome } from './services/nativeHome' import { initializePwa } from './services/pwa' +import { initializeNativeAppLinks } from './services/nativeAppLinks' import { setRouter } from './utils/navigate' import { initializeAuthSession } from './auth/session' @@ -26,3 +27,4 @@ app.mount('#app') void initializePwa() void initializeAppUpdates() initializeNativeHome(router) +void initializeNativeAppLinks(router) diff --git a/web/src/services/nativeAppLinks.ts b/web/src/services/nativeAppLinks.ts new file mode 100644 index 0000000..06650f6 --- /dev/null +++ b/web/src/services/nativeAppLinks.ts @@ -0,0 +1,50 @@ +import { App } from '@capacitor/app' +import { Browser } from '@capacitor/browser' +import { Capacitor } from '@capacitor/core' +import type { Router } from 'vue-router' + +const ssoPaths = new Set(['/sso/callback', '/sso/bind']) + +function appLinkRoute(url: string) { + try { + const target = new URL(url) + const publicBase = new URL( + String(import.meta.env.VITE_PUBLIC_BASE_URL ?? location.origin), + ) + if ( + target.protocol !== 'https:' || + target.origin !== publicBase.origin || + !ssoPaths.has(target.pathname) + ) { + return null + } + return target.pathname + target.search + target.hash + } catch { + return null + } +} + +async function handleAppUrl(router: Router, url: string) { + const route = appLinkRoute(url) + if (!route) return + await Browser.close().catch(() => undefined) + await router.replace(route) +} + +export async function initializeNativeAppLinks(router: Router) { + if (!Capacitor.isNativePlatform()) return + + await App.addListener('appUrlOpen', event => { + void handleAppUrl(router, event.url) + }) + const launchUrl = await App.getLaunchUrl() + if (launchUrl?.url) await handleAppUrl(router, launchUrl.url) +} + +export async function openSsoLogin(url: string) { + if (!Capacitor.isNativePlatform()) { + window.location.assign(url) + return + } + await Browser.open({ url, toolbarColor: '#173a50' }) +} diff --git a/web/src/views/AccountView.vue b/web/src/views/AccountView.vue index 944cd99..62f8d33 100644 --- a/web/src/views/AccountView.vue +++ b/web/src/views/AccountView.vue @@ -3,6 +3,8 @@ import { onMounted, reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import http, { apiErrorMessage } from '../api/http' import { useAuthStore } from '../stores/auth' +import { isNativeApp } from '../auth/session' +import { openSsoLogin } from '../services/nativeAppLinks' const route = useRoute() const router = useRouter() @@ -43,8 +45,10 @@ async function loadAccount() { async function startBinding() { actionLoading.value = true try { - const { data } = await http.post('/auth/sso/prepare-binding') - window.location.assign(String(data.loginUrl)) + const { data } = await http.post('/auth/sso/prepare-binding', null, { + params: { nativeApp: isNativeApp() }, + }) + await openSsoLogin(String(data.loginUrl)) } catch (error) { ElMessage.error(apiErrorMessage(error)) actionLoading.value = false diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index f4df238..c01472a 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router' import { apiErrorMessage } from '../api/http' import { useAuthStore } from '../stores/auth' import http from '../api/http' +import { isNativeApp } from '../auth/session' +import { openSsoLogin } from '../services/nativeAppLinks' const route = useRoute() const router = useRouter() @@ -28,13 +30,18 @@ async function submit() { } } -function startSso() { +async function startSso() { ssoLoading.value = true const apiBaseUrl = String(import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '') const redirect = String(route.query.redirect ?? '/dashboard') - window.location.assign( - `${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}`, - ) + try { + await openSsoLogin( + `${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}&nativeApp=${isNativeApp()}`, + ) + } catch (error) { + ssoLoading.value = false + ElMessage.error(apiErrorMessage(error)) + } } const ssoErrors: Record = {