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 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<SsoExchangeTicket>(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<ActionResult<SsoBindingStartResponse>> 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,
@@ -52,8 +52,9 @@ public sealed class CoursesControllerTests
var result = await controller.Get(cancellationToken: CancellationToken.None);
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.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<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.False(page.HasMore);
Assert.Contains("CS102", JsonSerializer.Serialize(page.Items));
}
@@ -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<OkObjectResult>(await controller.GetBatches(
keyword: "英语", status: OtherExamBatchStatus.Draft, page: 1,
keyword: "英语", status: OtherExamBatchStatus.Draft,
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.Items.Count);
Assert.False(page.HasMore);
Assert.Null(page.NextCursor);
}
[Fact]
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff. Show more