From d95ff27ee427714d30beccb14050c162d67e6e20 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 23 Aug 2026 22:41:02 +0800 Subject: [PATCH] =?UTF-8?q?sso=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Jiaowu.Api/Controllers/SsoController.cs | 76 ++++++++++++++----- .../CoursesControllerTests.cs | 6 +- .../OtherExamsControllerTests.cs | 8 +- web/src/services/nativeAppLinks.ts | 33 ++++++-- web/src/stores/auth.ts | 8 ++ web/src/views/AccountView.vue | 5 +- web/src/views/LoginView.vue | 5 +- 7 files changed, 104 insertions(+), 37 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/SsoController.cs b/src/Jiaowu.Api/Controllers/SsoController.cs index c4dd3b8..edaa78f 100644 --- a/src/Jiaowu.Api/Controllers/SsoController.cs +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -27,6 +27,7 @@ public sealed class SsoController( { private const string BindingIntentProperty = "sso-binding-intent"; private const string NativeAppProperty = "sso-native-app"; + private const string NativeAppStateProperty = "sso-native-app-state"; private readonly SsoOptions _options = options.Value; [AllowAnonymous] @@ -44,6 +45,7 @@ public sealed class SsoController( [FromQuery] string? returnUrl = null, [FromQuery] string? bindingIntent = null, [FromQuery] bool nativeApp = false, + [FromQuery] string? nativeState = null, CancellationToken cancellationToken = default) { if (!_options.Enabled) @@ -52,7 +54,12 @@ public sealed class SsoController( var safeReturnUrl = NormalizeReturnUrl(returnUrl); var properties = new AuthenticationProperties(); if (nativeApp) + { + if (!IsValidNativeState(nativeState)) + return SsoProblem("原生应用登录校验已失效,请返回应用重新发起登录。", StatusCodes.Status400BadRequest); properties.Items[NativeAppProperty] = bool.TrueString; + properties.Items[NativeAppStateProperty] = nativeState!; + } if (!string.IsNullOrWhiteSpace(bindingIntent)) { var targetUserId = await cache.GetStringAsync( @@ -116,6 +123,10 @@ public sealed class SsoController( externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) && bool.TryParse(nativeAppValue, out var isNativeApp) && isNativeApp; + var nativeState = authentication.Properties is { } nativeProperties && + nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState) + ? storedNativeState + : null; if (!string.IsNullOrWhiteSpace(bindingIntent)) { var targetUserId = await cache.GetStringAsync( @@ -165,7 +176,7 @@ public sealed class SsoController( subject; await cache.SetStringAsync( BindingCacheKey(bindingCode), - JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)), + JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName, nativeState)), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) @@ -190,7 +201,7 @@ public sealed class SsoController( RandomNumberGenerator.GetBytes(32)); await cache.SetStringAsync( ExchangeCacheKey(exchangeCode), - user.Id.ToString("D"), + JsonSerializer.Serialize(new SsoExchangeTicket(user.Id, nativeState)), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) @@ -215,14 +226,25 @@ public sealed class SsoController( return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); var cacheKey = ExchangeCacheKey(request.Code); - var userId = await cache.GetStringAsync(cacheKey, cancellationToken); - if (userId is null) + var ticketJson = await cache.GetStringAsync(cacheKey, cancellationToken); + SsoExchangeTicket? ticket; + try + { + ticket = ticketJson is null + ? null + : JsonSerializer.Deserialize(ticketJson); + } + catch (JsonException) + { + ticket = null; + } + if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState)) return SsoProblem( "统一身份认证结果已失效,请重新登录。", StatusCodes.Status401Unauthorized); await cache.RemoveAsync(cacheKey, cancellationToken); - var user = await userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(ticket.UserId.ToString("D")); if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user)) return SsoProblem( "本地账号不存在、已停用或已锁定。", @@ -269,7 +291,7 @@ public sealed class SsoController( return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken); - if (ticket is null) + if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState)) return SsoProblem( "账户绑定请求已失效,请重新使用统一身份认证登录。", StatusCodes.Status401Unauthorized); @@ -353,7 +375,8 @@ public sealed class SsoController( [HttpPost("prepare-binding")] public async Task> PrepareBinding( CancellationToken cancellationToken, - [FromQuery] bool nativeApp = false) + [FromQuery] bool nativeApp = false, + [FromQuery] string? nativeState = null) { if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); @@ -383,7 +406,8 @@ public sealed class SsoController( { returnUrl = "/account", bindingIntent = intentCode, - nativeApp + nativeApp, + nativeState })!; return new SsoBindingStartResponse(loginUrl); } @@ -427,20 +451,26 @@ public sealed class SsoController( ? returnUrl : "/dashboard"; - private string BuildFrontendUrl(string path, bool requireAbsoluteUrl = false) + private string BuildFrontendUrl(string path, bool nativeApp = false) { - if (requireAbsoluteUrl && string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)) - { - throw new InvalidOperationException( - "原生单点登录需要配置 Sso:FrontendBaseUrl 为已验证的 HTTPS 地址。"); - } - - return - string.IsNullOrWhiteSpace(_options.FrontendBaseUrl) + if (nativeApp) + return "mingxu://open" + path; + return string.IsNullOrWhiteSpace(_options.FrontendBaseUrl) ? path : _options.FrontendBaseUrl.TrimEnd('/') + path; } + private static bool IsValidNativeState(string? value) => + !string.IsNullOrWhiteSpace(value) && value.Length is >= 32 and <= 200; + + private static bool NativeStateMatches(string? expected, string? actual) => + expected is null + ? string.IsNullOrWhiteSpace(actual) + : IsValidNativeState(actual) && + CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.UTF8.GetBytes(expected), + System.Text.Encoding.UTF8.GetBytes(actual!)); + private RedirectResult RedirectToFrontendError( string error, string path = "/login") => @@ -543,7 +573,8 @@ public sealed record SsoSettingsResponse( public sealed record SsoExchangeRequest( [Required, MinLength(20), MaxLength(200)] string Code, - bool IsNativeApp = false); + bool IsNativeApp = false, + string? NativeState = null); public sealed record SsoBindingInfoResponse( string ProviderDisplayName, @@ -553,9 +584,14 @@ public sealed record SsoBindRequest( [Required, MinLength(20), MaxLength(200)] string Code, [Required, MaxLength(100)] string UserName, [Required, MaxLength(100)] string Password, - bool IsNativeApp = false); + bool IsNativeApp = false, + string? NativeState = null); -internal sealed record SsoBindingTicket(string Subject, string ExternalUserName); +internal sealed record SsoBindingTicket( + string Subject, + string ExternalUserName, + string? NativeState = null); +internal sealed record SsoExchangeTicket(Guid UserId, string? NativeState); public sealed record SsoAccountResponse( bool Enabled, diff --git a/tests/Jiaowu.Api.Tests/CoursesControllerTests.cs b/tests/Jiaowu.Api.Tests/CoursesControllerTests.cs index b6a7697..8ee8bc7 100644 --- a/tests/Jiaowu.Api.Tests/CoursesControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/CoursesControllerTests.cs @@ -52,8 +52,9 @@ public sealed class CoursesControllerTests var result = await controller.Get(cancellationToken: CancellationToken.None); var ok = Assert.IsType(result.Result); - var page = Assert.IsType>(ok.Value); + var page = Assert.IsType>(ok.Value); Assert.Equal(1, page.Total); + Assert.False(page.HasMore); var items = JsonSerializer.Serialize(page.Items); Assert.Contains("OWN-001", items); Assert.DoesNotContain("OTHER-001", items); @@ -174,8 +175,9 @@ public sealed class CoursesControllerTests var result = await controller.Get(cancellationToken: CancellationToken.None); var ok = Assert.IsType(result.Result); - var page = Assert.IsType>(ok.Value); + var page = Assert.IsType>(ok.Value); Assert.Equal(1, page.Total); + Assert.False(page.HasMore); Assert.Contains("CS102", JsonSerializer.Serialize(page.Items)); } diff --git a/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs b/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs index 07d86cc..383d87e 100644 --- a/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs @@ -13,7 +13,7 @@ namespace Jiaowu.Api.Tests; public sealed class OtherExamsControllerTests { [Fact] - public async Task Batches_AreFilteredAndReturnedByPage() + public async Task Batches_AreFilteredAndReturnedByCursorPage() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); @@ -36,11 +36,13 @@ public sealed class OtherExamsControllerTests var controller = new OtherExamsController(db, new TestDataScope(Guid.NewGuid())); var result = Assert.IsType(await controller.GetBatches( - keyword: "英语", status: OtherExamBatchStatus.Draft, page: 1, + keyword: "英语", status: OtherExamBatchStatus.Draft, pageSize: 10, ct: CancellationToken.None)); - var page = Assert.IsType>(result.Value); + var page = Assert.IsType>(result.Value); Assert.Equal(10, page.Total); Assert.Equal(10, page.Items.Count); + Assert.False(page.HasMore); + Assert.Null(page.NextCursor); } [Fact] diff --git a/web/src/services/nativeAppLinks.ts b/web/src/services/nativeAppLinks.ts index 06650f6..4a562be 100644 --- a/web/src/services/nativeAppLinks.ts +++ b/web/src/services/nativeAppLinks.ts @@ -4,21 +4,38 @@ import { Capacitor } from '@capacitor/core' import type { Router } from 'vue-router' const ssoPaths = new Set(['/sso/callback', '/sso/bind']) +const nativeSsoStateKey = 'jiaowu_native_sso_state' + +export function beginNativeSsoLogin() { + if (!Capacitor.isNativePlatform()) return null + const bytes = crypto.getRandomValues(new Uint8Array(32)) + const state = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') + localStorage.setItem(nativeSsoStateKey, state) + return state +} + +export function nativeSsoLoginState() { + return Capacitor.isNativePlatform() ? localStorage.getItem(nativeSsoStateKey) : null +} + +export function clearNativeSsoLoginState() { + localStorage.removeItem(nativeSsoStateKey) +} function appLinkRoute(url: string) { try { const target = new URL(url) + if (target.protocol === 'mingxu:' && target.host === 'open' && ssoPaths.has(target.pathname)) { + return target.pathname + target.search + target.hash + } 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 + return target.protocol === 'https:' && + target.origin === publicBase.origin && + ssoPaths.has(target.pathname) + ? target.pathname + target.search + target.hash + : null } catch { return null } diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 8b90d8d..c89945c 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -7,6 +7,10 @@ import { isNativeApp, saveAuthSession, } from '../auth/session' +import { + clearNativeSsoLoginState, + nativeSsoLoginState, +} from '../services/nativeAppLinks' export interface CurrentUser { id: string @@ -39,10 +43,12 @@ export const useAuthStore = defineStore('auth', () => { const { data } = await http.post('/auth/sso/exchange', { code, isNativeApp: isNativeApp(), + nativeState: nativeSsoLoginState(), }) token.value = data.token user.value = data.user saveAuthSession(data) + clearNativeSsoLoginState() } async function bindSso(code: string, userName: string, password: string) { @@ -51,10 +57,12 @@ export const useAuthStore = defineStore('auth', () => { userName, password, isNativeApp: isNativeApp(), + nativeState: nativeSsoLoginState(), }) token.value = data.token user.value = data.user saveAuthSession(data) + clearNativeSsoLoginState() } async function refresh() { diff --git a/web/src/views/AccountView.vue b/web/src/views/AccountView.vue index 148cc4f..57dc854 100644 --- a/web/src/views/AccountView.vue +++ b/web/src/views/AccountView.vue @@ -4,7 +4,7 @@ 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' +import { beginNativeSsoLogin, openSsoLogin } from '../services/nativeAppLinks' const route = useRoute() const router = useRouter() @@ -45,8 +45,9 @@ async function loadAccount() { async function startBinding() { actionLoading.value = true try { + const nativeState = beginNativeSsoLogin() const { data } = await http.post('/auth/sso/prepare-binding', null, { - params: { nativeApp: isNativeApp() }, + params: { nativeApp: isNativeApp(), nativeState }, }) await openSsoLogin(String(data.loginUrl)) } catch (error) { diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index c01472a..2f11274 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -5,7 +5,7 @@ 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' +import { beginNativeSsoLogin, openSsoLogin } from '../services/nativeAppLinks' const route = useRoute() const router = useRouter() @@ -34,9 +34,10 @@ 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') + const nativeState = beginNativeSsoLogin() try { await openSsoLogin( - `${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}&nativeApp=${isNativeApp()}`, + `${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}&nativeApp=${isNativeApp()}${nativeState ? `&nativeState=${encodeURIComponent(nativeState)}` : ''}`, ) } catch (error) { ssoLoading.value = false