Compare commits
10
Commits
No files matched your search
@@ -56,6 +56,7 @@ public sealed class SsoController(
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||
var accountPage = FrontendPagePath(safeReturnUrl, "account", nativeApp);
|
||||
if (string.IsNullOrWhiteSpace(bindingIntent) &&
|
||||
(string.IsNullOrWhiteSpace(captchaId) ||
|
||||
string.IsNullOrWhiteSpace(captchaCode) ||
|
||||
@@ -77,7 +78,7 @@ public sealed class SsoController(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
if (targetUserId is null)
|
||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||
return RedirectToFrontendError("binding_intent_expired", accountPage);
|
||||
properties.Items[BindingIntentProperty] = bindingIntent;
|
||||
}
|
||||
var completeUrl = Url.Action(
|
||||
@@ -97,7 +98,9 @@ public sealed class SsoController(
|
||||
EffectiveCallbackUrl());
|
||||
return RedirectToFrontendError(
|
||||
"configuration_error",
|
||||
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
|
||||
string.IsNullOrWhiteSpace(bindingIntent)
|
||||
? FrontendPagePath(safeReturnUrl, "login", nativeApp)
|
||||
: accountPage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,16 +114,23 @@ public sealed class SsoController(
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||
var authentication = await HttpContext.AuthenticateAsync(
|
||||
SsoAuthSchemes.ExternalCookie);
|
||||
var nativeApp = authentication.Properties is { } externalProperties &&
|
||||
externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) &&
|
||||
bool.TryParse(nativeAppValue, out var isNativeApp) &&
|
||||
isNativeApp;
|
||||
var loginPage = FrontendPagePath(safeReturnUrl, "login", nativeApp);
|
||||
var accountPage = FrontendPagePath(safeReturnUrl, "account", nativeApp);
|
||||
if (!authentication.Succeeded || authentication.Principal is null)
|
||||
return RedirectToFrontendError("authentication_failed");
|
||||
return RedirectToFrontendError("authentication_failed", loginPage);
|
||||
|
||||
var principal = authentication.Principal;
|
||||
var subject = principal.FindFirstValue("sub") ??
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return RedirectToFrontendError("missing_subject");
|
||||
return RedirectToFrontendError("missing_subject", loginPage);
|
||||
|
||||
ApplicationUser? user = null;
|
||||
var bindingIntent =
|
||||
@@ -130,10 +140,6 @@ public sealed class SsoController(
|
||||
out var storedBindingIntent)
|
||||
? storedBindingIntent
|
||||
: null;
|
||||
var nativeApp = authentication.Properties is { } externalProperties &&
|
||||
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
|
||||
@@ -147,13 +153,13 @@ public sealed class SsoController(
|
||||
? null
|
||||
: await userManager.FindByIdAsync(targetUserId);
|
||||
if (user is null)
|
||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||
return RedirectToFrontendError("binding_intent_expired", accountPage);
|
||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return RedirectToFrontendError("account_disabled", "/account");
|
||||
return RedirectToFrontendError("account_disabled", accountPage);
|
||||
|
||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||
if (linkError is not null)
|
||||
return RedirectToFrontendError(linkError, "/account");
|
||||
return RedirectToFrontendError(linkError, accountPage);
|
||||
await cache.RemoveAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
@@ -172,7 +178,7 @@ public sealed class SsoController(
|
||||
{
|
||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||
if (linkError is not null)
|
||||
return RedirectToFrontendError(linkError);
|
||||
return RedirectToFrontendError(linkError, loginPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,18 +201,20 @@ public sealed class SsoController(
|
||||
cancellationToken);
|
||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||
|
||||
var bindingPage = BuildFrontendUrl("/sso/bind", nativeApp) +
|
||||
var bindingPage = BuildFrontendUrl(
|
||||
FrontendPagePath(safeReturnUrl, "sso/bind", nativeApp),
|
||||
nativeApp) +
|
||||
$"?code={Uri.EscapeDataString(bindingCode)}" +
|
||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||
$"&redirect={Uri.EscapeDataString(safeReturnUrl)}";
|
||||
return Redirect(bindingPage);
|
||||
}
|
||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return RedirectToFrontendError("account_disabled");
|
||||
return RedirectToFrontendError("account_disabled", loginPage);
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
return RedirectToFrontendError("account_update_failed");
|
||||
return RedirectToFrontendError("account_update_failed", loginPage);
|
||||
|
||||
var exchangeCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
@@ -220,9 +228,11 @@ public sealed class SsoController(
|
||||
cancellationToken);
|
||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||
|
||||
var callback = BuildFrontendUrl("/sso/callback", nativeApp) +
|
||||
var callback = BuildFrontendUrl(
|
||||
FrontendPagePath(safeReturnUrl, "sso/callback", nativeApp),
|
||||
nativeApp) +
|
||||
$"?code={Uri.EscapeDataString(exchangeCode)}" +
|
||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||
$"&redirect={Uri.EscapeDataString(safeReturnUrl)}";
|
||||
return Redirect(callback);
|
||||
}
|
||||
|
||||
@@ -386,6 +396,7 @@ public sealed class SsoController(
|
||||
[HttpPost("prepare-binding")]
|
||||
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] string? returnUrl = null,
|
||||
[FromQuery] bool nativeApp = false,
|
||||
[FromQuery] string? nativeState = null)
|
||||
{
|
||||
@@ -411,11 +422,14 @@ public sealed class SsoController(
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||
},
|
||||
cancellationToken);
|
||||
var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl)
|
||||
? "/account"
|
||||
: NormalizeReturnUrl(returnUrl);
|
||||
var loginUrl = Url.Action(
|
||||
nameof(Login),
|
||||
values: new
|
||||
{
|
||||
returnUrl = "/account",
|
||||
returnUrl = safeReturnUrl,
|
||||
bindingIntent = intentCode,
|
||||
nativeApp,
|
||||
nativeState,
|
||||
@@ -464,6 +478,19 @@ public sealed class SsoController(
|
||||
? returnUrl
|
||||
: "/dashboard";
|
||||
|
||||
internal static string FrontendPagePath(
|
||||
string? returnUrl,
|
||||
string page,
|
||||
bool nativeApp = false)
|
||||
{
|
||||
var normalizedPage = "/" + page.Trim('/');
|
||||
if (nativeApp) return normalizedPage;
|
||||
var normalizedReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||
return normalizedReturnUrl.StartsWith("/next/", StringComparison.OrdinalIgnoreCase)
|
||||
? "/next" + normalizedPage
|
||||
: normalizedPage;
|
||||
}
|
||||
|
||||
private string BuildFrontendUrl(string path, bool nativeApp = false)
|
||||
{
|
||||
if (nativeApp)
|
||||
|
||||
@@ -119,6 +119,23 @@ public sealed class SsoControllerTests
|
||||
Assert.Equal(expected, SsoController.NormalizeReturnUrl(value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/dashboard", "sso/callback", false, "/sso/callback")]
|
||||
[InlineData("/next/dashboard", "sso/callback", false, "/next/sso/callback")]
|
||||
[InlineData("/next/account", "account", false, "/next/account")]
|
||||
[InlineData("/next/dashboard", "sso/callback", true, "/sso/callback")]
|
||||
[InlineData("https://evil.example/path", "login", false, "/login")]
|
||||
public void FrontendPagePath_SelectsReactOnlyForNextWebRoutes(
|
||||
string? returnUrl,
|
||||
string page,
|
||||
bool nativeApp,
|
||||
string expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
SsoController.FrontendPagePath(returnUrl, page, nativeApp));
|
||||
}
|
||||
|
||||
private sealed class BindingFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Development API requests are proxied to http://localhost:5255 by Vite.
|
||||
# Set this only when the API is hosted elsewhere.
|
||||
VITE_API_BASE_URL=
|
||||
|
||||
# Links for pages that have not yet migrated. The development default is
|
||||
# http://localhost:5255; use the deployed Vue origin for shared previews.
|
||||
VITE_LEGACY_APP_URL=http://localhost:5255
|
||||
Loaded 3 of 71 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user