sso修复

This commit is contained in:
biss committed 2026-08-23 22:41:02 +08:00
1 parent 76b26a49a8
commit d95ff27ee4
7 files changed
+104 -37

No files matched your search

+56 -20
View File
@@ -27,6 +27,7 @@ public sealed class SsoController(
{ {
private const string BindingIntentProperty = "sso-binding-intent"; private const string BindingIntentProperty = "sso-binding-intent";
private const string NativeAppProperty = "sso-native-app"; private const string NativeAppProperty = "sso-native-app";
private const string NativeAppStateProperty = "sso-native-app-state";
private readonly SsoOptions _options = options.Value; private readonly SsoOptions _options = options.Value;
[AllowAnonymous] [AllowAnonymous]
@@ -44,6 +45,7 @@ public sealed class SsoController(
[FromQuery] string? returnUrl = null, [FromQuery] string? returnUrl = null,
[FromQuery] string? bindingIntent = null, [FromQuery] string? bindingIntent = null,
[FromQuery] bool nativeApp = false, [FromQuery] bool nativeApp = false,
[FromQuery] string? nativeState = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!_options.Enabled) if (!_options.Enabled)
@@ -52,7 +54,12 @@ public sealed class SsoController(
var safeReturnUrl = NormalizeReturnUrl(returnUrl); var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var properties = new AuthenticationProperties(); var properties = new AuthenticationProperties();
if (nativeApp) if (nativeApp)
{
if (!IsValidNativeState(nativeState))
return SsoProblem("原生应用登录校验已失效,请返回应用重新发起登录。", StatusCodes.Status400BadRequest);
properties.Items[NativeAppProperty] = bool.TrueString; properties.Items[NativeAppProperty] = bool.TrueString;
properties.Items[NativeAppStateProperty] = nativeState!;
}
if (!string.IsNullOrWhiteSpace(bindingIntent)) if (!string.IsNullOrWhiteSpace(bindingIntent))
{ {
var targetUserId = await cache.GetStringAsync( var targetUserId = await cache.GetStringAsync(
@@ -116,6 +123,10 @@ public sealed class SsoController(
externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) && externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) &&
bool.TryParse(nativeAppValue, out var isNativeApp) && bool.TryParse(nativeAppValue, out var isNativeApp) &&
isNativeApp; isNativeApp;
var nativeState = authentication.Properties is { } nativeProperties &&
nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState)
? storedNativeState
: null;
if (!string.IsNullOrWhiteSpace(bindingIntent)) if (!string.IsNullOrWhiteSpace(bindingIntent))
{ {
var targetUserId = await cache.GetStringAsync( var targetUserId = await cache.GetStringAsync(
@@ -165,7 +176,7 @@ public sealed class SsoController(
subject; subject;
await cache.SetStringAsync( await cache.SetStringAsync(
BindingCacheKey(bindingCode), BindingCacheKey(bindingCode),
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)), JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName, nativeState)),
new DistributedCacheEntryOptions new DistributedCacheEntryOptions
{ {
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
@@ -190,7 +201,7 @@ public sealed class SsoController(
RandomNumberGenerator.GetBytes(32)); RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync( await cache.SetStringAsync(
ExchangeCacheKey(exchangeCode), ExchangeCacheKey(exchangeCode),
user.Id.ToString("D"), JsonSerializer.Serialize(new SsoExchangeTicket(user.Id, nativeState)),
new DistributedCacheEntryOptions new DistributedCacheEntryOptions
{ {
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
@@ -215,14 +226,25 @@ public sealed class SsoController(
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var cacheKey = ExchangeCacheKey(request.Code); var cacheKey = ExchangeCacheKey(request.Code);
var userId = await cache.GetStringAsync(cacheKey, cancellationToken); var ticketJson = await cache.GetStringAsync(cacheKey, cancellationToken);
if (userId is null) SsoExchangeTicket? ticket;
try
{
ticket = ticketJson is null
? null
: JsonSerializer.Deserialize<SsoExchangeTicket>(ticketJson);
}
catch (JsonException)
{
ticket = null;
}
if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState))
return SsoProblem( return SsoProblem(
"统一身份认证结果已失效,请重新登录。", "统一身份认证结果已失效,请重新登录。",
StatusCodes.Status401Unauthorized); StatusCodes.Status401Unauthorized);
await cache.RemoveAsync(cacheKey, cancellationToken); 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)) if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return SsoProblem( return SsoProblem(
"本地账号不存在、已停用或已锁定。", "本地账号不存在、已停用或已锁定。",
@@ -269,7 +291,7 @@ public sealed class SsoController(
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken); var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
if (ticket is null) if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState))
return SsoProblem( return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。", "账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized); StatusCodes.Status401Unauthorized);
@@ -353,7 +375,8 @@ public sealed class SsoController(
[HttpPost("prepare-binding")] [HttpPost("prepare-binding")]
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding( public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
CancellationToken cancellationToken, CancellationToken cancellationToken,
[FromQuery] bool nativeApp = false) [FromQuery] bool nativeApp = false,
[FromQuery] string? nativeState = null)
{ {
if (!_options.Enabled) if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
@@ -383,7 +406,8 @@ public sealed class SsoController(
{ {
returnUrl = "/account", returnUrl = "/account",
bindingIntent = intentCode, bindingIntent = intentCode,
nativeApp nativeApp,
nativeState
})!; })!;
return new SsoBindingStartResponse(loginUrl); return new SsoBindingStartResponse(loginUrl);
} }
@@ -427,20 +451,26 @@ public sealed class SsoController(
? returnUrl ? returnUrl
: "/dashboard"; : "/dashboard";
private string BuildFrontendUrl(string path, bool requireAbsoluteUrl = false) private string BuildFrontendUrl(string path, bool nativeApp = false)
{ {
if (requireAbsoluteUrl && string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)) if (nativeApp)
{ return "mingxu://open" + path;
throw new InvalidOperationException( return string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
"原生单点登录需要配置 Sso:FrontendBaseUrl 为已验证的 HTTPS 地址。");
}
return
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
? path ? path
: _options.FrontendBaseUrl.TrimEnd('/') + 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( private RedirectResult RedirectToFrontendError(
string error, string error,
string path = "/login") => string path = "/login") =>
@@ -543,7 +573,8 @@ public sealed record SsoSettingsResponse(
public sealed record SsoExchangeRequest( public sealed record SsoExchangeRequest(
[Required, MinLength(20), MaxLength(200)] string Code, [Required, MinLength(20), MaxLength(200)] string Code,
bool IsNativeApp = false); bool IsNativeApp = false,
string? NativeState = null);
public sealed record SsoBindingInfoResponse( public sealed record SsoBindingInfoResponse(
string ProviderDisplayName, string ProviderDisplayName,
@@ -553,9 +584,14 @@ public sealed record SsoBindRequest(
[Required, MinLength(20), MaxLength(200)] string Code, [Required, MinLength(20), MaxLength(200)] string Code,
[Required, MaxLength(100)] string UserName, [Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password, [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( public sealed record SsoAccountResponse(
bool Enabled, bool Enabled,
@@ -52,8 +52,9 @@ public sealed class CoursesControllerTests
var result = await controller.Get(cancellationToken: CancellationToken.None); var result = await controller.Get(cancellationToken: CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var page = Assert.IsType<PagedResult<object>>(ok.Value); var page = Assert.IsType<CursorPagedResult<object>>(ok.Value);
Assert.Equal(1, page.Total); Assert.Equal(1, page.Total);
Assert.False(page.HasMore);
var items = JsonSerializer.Serialize(page.Items); var items = JsonSerializer.Serialize(page.Items);
Assert.Contains("OWN-001", items); Assert.Contains("OWN-001", items);
Assert.DoesNotContain("OTHER-001", items); Assert.DoesNotContain("OTHER-001", items);
@@ -174,8 +175,9 @@ public sealed class CoursesControllerTests
var result = await controller.Get(cancellationToken: CancellationToken.None); var result = await controller.Get(cancellationToken: CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var page = Assert.IsType<PagedResult<object>>(ok.Value); var page = Assert.IsType<CursorPagedResult<object>>(ok.Value);
Assert.Equal(1, page.Total); Assert.Equal(1, page.Total);
Assert.False(page.HasMore);
Assert.Contains("CS102", JsonSerializer.Serialize(page.Items)); Assert.Contains("CS102", JsonSerializer.Serialize(page.Items));
} }
@@ -13,7 +13,7 @@ namespace Jiaowu.Api.Tests;
public sealed class OtherExamsControllerTests public sealed class OtherExamsControllerTests
{ {
[Fact] [Fact]
public async Task Batches_AreFilteredAndReturnedByPage() public async Task Batches_AreFilteredAndReturnedByCursorPage()
{ {
await using var connection = new SqliteConnection("Data Source=:memory:"); await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(); await connection.OpenAsync();
@@ -36,11 +36,13 @@ public sealed class OtherExamsControllerTests
var controller = new OtherExamsController(db, new TestDataScope(Guid.NewGuid())); var controller = new OtherExamsController(db, new TestDataScope(Guid.NewGuid()));
var result = Assert.IsType<OkObjectResult>(await controller.GetBatches( var result = Assert.IsType<OkObjectResult>(await controller.GetBatches(
keyword: "英语", status: OtherExamBatchStatus.Draft, page: 1, keyword: "英语", status: OtherExamBatchStatus.Draft,
pageSize: 10, ct: CancellationToken.None)); pageSize: 10, ct: CancellationToken.None));
var page = Assert.IsType<PagedResult<object>>(result.Value); var page = Assert.IsType<CursorPagedResult<object>>(result.Value);
Assert.Equal(10, page.Total); Assert.Equal(10, page.Total);
Assert.Equal(10, page.Items.Count); Assert.Equal(10, page.Items.Count);
Assert.False(page.HasMore);
Assert.Null(page.NextCursor);
} }
[Fact] [Fact]
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff. Show more