sso修复
This commit is contained in:
1 parent
76b26a49a8
commit
d95ff27ee4
7 files changed
+104
-37
No files matched your search
@@ -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
Reference in new issue
Block a user