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 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
Reference in new issue
Block a user