sso优化
This commit is contained in:
@@ -85,6 +85,8 @@ Sso__Enabled=false
|
|||||||
# Sso__LinkExistingUsersByUserName=true
|
# Sso__LinkExistingUsersByUserName=true
|
||||||
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
|
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
|
||||||
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||||
|
# 必须与 Keycloak 客户端的 Valid redirect URI 完全一致。
|
||||||
|
# Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
|
||||||
|
|
||||||
AllowedHosts=jiaowu.example.edu.cn
|
AllowedHosts=jiaowu.example.edu.cn
|
||||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||||
|
|||||||
@@ -173,14 +173,20 @@ Sso__UserNameClaim=preferred_username
|
|||||||
Sso__RequireHttpsMetadata=true
|
Sso__RequireHttpsMetadata=true
|
||||||
Sso__LinkExistingUsersByUserName=true
|
Sso__LinkExistingUsersByUserName=true
|
||||||
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||||
|
Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
|
||||||
```
|
```
|
||||||
|
|
||||||
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
|
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
|
||||||
`http://localhost:5173`,Keycloak 测试客户端需同时允许
|
`http://localhost:5173`,Keycloak 测试客户端需同时允许
|
||||||
`http://localhost:5255/signin-keycloak`。生产环境位于反向代理之后时,应确保应用收到
|
`http://localhost:5255/signin-keycloak`。`Sso__CallbackUrl` 是应用实际发送给 Keycloak
|
||||||
正确的公开 HTTPS scheme(例如设置 `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`),
|
的 `redirect_uri`,必须与客户端的 Valid redirect URI 完全一致;建议生产环境始终显式
|
||||||
否则生成的 Keycloak 回调地址可能错误。多实例部署应配置 Redis,以便任意实例都能兑换
|
配置它,避免反向代理导致 scheme 或 host 推导错误。个人账户页的“管理员配置参考”也会
|
||||||
两分钟内有效、使用后即删除的 SSO 登录码。
|
显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内
|
||||||
|
有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。
|
||||||
|
|
||||||
|
用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先
|
||||||
|
使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该
|
||||||
|
意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。
|
||||||
|
|
||||||
### Linux systemd 服务
|
### Linux systemd 服务
|
||||||
|
|
||||||
|
|||||||
@@ -12,40 +12,74 @@ using Microsoft.AspNetCore.RateLimiting;
|
|||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Controllers;
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[AllowAnonymous]
|
|
||||||
[Route("api/auth/sso")]
|
[Route("api/auth/sso")]
|
||||||
public sealed class SsoController(
|
public sealed class SsoController(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IAuthSessionService authSessionService,
|
IAuthSessionService authSessionService,
|
||||||
IDistributedCache cache,
|
IDistributedCache cache,
|
||||||
IOptions<SsoOptions> options) : ControllerBase
|
IOptions<SsoOptions> options,
|
||||||
|
ILogger<SsoController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
|
private const string BindingIntentProperty = "sso-binding-intent";
|
||||||
private readonly SsoOptions _options = options.Value;
|
private readonly SsoOptions _options = options.Value;
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
public ActionResult<SsoSettingsResponse> Settings() =>
|
public ActionResult<SsoSettingsResponse> Settings() =>
|
||||||
new SsoSettingsResponse(_options.Enabled, _options.DisplayName);
|
new SsoSettingsResponse(
|
||||||
|
_options.Enabled,
|
||||||
|
_options.DisplayName,
|
||||||
|
EffectiveCallbackUrl());
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting("public-auth")]
|
[EnableRateLimiting("public-auth")]
|
||||||
[HttpGet("login")]
|
[HttpGet("login")]
|
||||||
public ActionResult Login([FromQuery] string? returnUrl = null)
|
public async Task<IActionResult> Login(
|
||||||
|
[FromQuery] string? returnUrl = null,
|
||||||
|
[FromQuery] string? bindingIntent = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!_options.Enabled)
|
if (!_options.Enabled)
|
||||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||||
|
var properties = new AuthenticationProperties();
|
||||||
|
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||||
|
{
|
||||||
|
var targetUserId = await cache.GetStringAsync(
|
||||||
|
BindingIntentCacheKey(bindingIntent),
|
||||||
|
cancellationToken);
|
||||||
|
if (targetUserId is null)
|
||||||
|
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||||
|
properties.Items[BindingIntentProperty] = bindingIntent;
|
||||||
|
}
|
||||||
var completeUrl = Url.Action(
|
var completeUrl = Url.Action(
|
||||||
nameof(Complete),
|
nameof(Complete),
|
||||||
values: new { returnUrl = safeReturnUrl })!;
|
values: new { returnUrl = safeReturnUrl })!;
|
||||||
return Challenge(
|
properties.RedirectUri = completeUrl;
|
||||||
new AuthenticationProperties { RedirectUri = completeUrl },
|
try
|
||||||
SsoAuthSchemes.Keycloak);
|
{
|
||||||
|
await HttpContext.ChallengeAsync(SsoAuthSchemes.Keycloak, properties);
|
||||||
|
return new EmptyResult();
|
||||||
|
}
|
||||||
|
catch (OpenIdConnectProtocolException exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Keycloak 拒绝了 OIDC 授权请求。当前回调地址为 {CallbackUrl}",
|
||||||
|
EffectiveCallbackUrl());
|
||||||
|
return RedirectToFrontendError(
|
||||||
|
"configuration_error",
|
||||||
|
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
[HttpGet("complete")]
|
[HttpGet("complete")]
|
||||||
public async Task<ActionResult> Complete(
|
public async Task<ActionResult> Complete(
|
||||||
@@ -66,7 +100,36 @@ public sealed class SsoController(
|
|||||||
if (string.IsNullOrWhiteSpace(subject))
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
return RedirectToFrontendError("missing_subject");
|
return RedirectToFrontendError("missing_subject");
|
||||||
|
|
||||||
var user = await userManager.FindByLoginAsync(
|
ApplicationUser? user = null;
|
||||||
|
var bindingIntent =
|
||||||
|
authentication.Properties is { } authenticationProperties &&
|
||||||
|
authenticationProperties.Items.TryGetValue(
|
||||||
|
BindingIntentProperty,
|
||||||
|
out var storedBindingIntent)
|
||||||
|
? storedBindingIntent
|
||||||
|
: null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||||
|
{
|
||||||
|
var targetUserId = await cache.GetStringAsync(
|
||||||
|
BindingIntentCacheKey(bindingIntent),
|
||||||
|
cancellationToken);
|
||||||
|
user = targetUserId is null
|
||||||
|
? null
|
||||||
|
: await userManager.FindByIdAsync(targetUserId);
|
||||||
|
if (user is null)
|
||||||
|
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||||
|
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
|
return RedirectToFrontendError("account_disabled", "/account");
|
||||||
|
|
||||||
|
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||||
|
if (linkError is not null)
|
||||||
|
return RedirectToFrontendError(linkError, "/account");
|
||||||
|
await cache.RemoveAsync(
|
||||||
|
BindingIntentCacheKey(bindingIntent),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
user ??= await userManager.FindByLoginAsync(
|
||||||
SsoAuthSchemes.LoginProvider,
|
SsoAuthSchemes.LoginProvider,
|
||||||
subject);
|
subject);
|
||||||
if (user is null && _options.LinkExistingUsersByUserName)
|
if (user is null && _options.LinkExistingUsersByUserName)
|
||||||
@@ -77,14 +140,9 @@ public sealed class SsoController(
|
|||||||
user = await userManager.FindByNameAsync(userName);
|
user = await userManager.FindByNameAsync(userName);
|
||||||
if (user is not null)
|
if (user is not null)
|
||||||
{
|
{
|
||||||
var linkResult = await userManager.AddLoginAsync(
|
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||||
user,
|
if (linkError is not null)
|
||||||
new UserLoginInfo(
|
return RedirectToFrontendError(linkError);
|
||||||
SsoAuthSchemes.LoginProvider,
|
|
||||||
subject,
|
|
||||||
_options.DisplayName));
|
|
||||||
if (!linkResult.Succeeded)
|
|
||||||
return RedirectToFrontendError("account_link_failed");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +196,7 @@ public sealed class SsoController(
|
|||||||
return Redirect(callback);
|
return Redirect(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting("public-auth")]
|
[EnableRateLimiting("public-auth")]
|
||||||
[HttpPost("exchange")]
|
[HttpPost("exchange")]
|
||||||
public async Task<ActionResult<LoginResponse>> Exchange(
|
public async Task<ActionResult<LoginResponse>> Exchange(
|
||||||
@@ -172,6 +231,7 @@ public sealed class SsoController(
|
|||||||
return AuthController.CreateLoginResponse(session);
|
return AuthController.CreateLoginResponse(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting("public-auth")]
|
[EnableRateLimiting("public-auth")]
|
||||||
[HttpGet("binding")]
|
[HttpGet("binding")]
|
||||||
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
|
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
|
||||||
@@ -190,6 +250,7 @@ public sealed class SsoController(
|
|||||||
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
|
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting("public-auth")]
|
[EnableRateLimiting("public-auth")]
|
||||||
[HttpPost("bind")]
|
[HttpPost("bind")]
|
||||||
public async Task<ActionResult<LoginResponse>> Bind(
|
public async Task<ActionResult<LoginResponse>> Bind(
|
||||||
@@ -263,6 +324,92 @@ public sealed class SsoController(
|
|||||||
return AuthController.CreateLoginResponse(session);
|
return AuthController.CreateLoginResponse(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("account")]
|
||||||
|
public async Task<ActionResult<SsoAccountResponse>> Account()
|
||||||
|
{
|
||||||
|
var user = await CurrentUserAsync();
|
||||||
|
if (user is null)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var login = (await userManager.GetLoginsAsync(user))
|
||||||
|
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
|
||||||
|
return new SsoAccountResponse(
|
||||||
|
_options.Enabled,
|
||||||
|
_options.DisplayName,
|
||||||
|
login is not null,
|
||||||
|
EffectiveCallbackUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("prepare-binding")]
|
||||||
|
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var user = await CurrentUserAsync();
|
||||||
|
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
|
return Unauthorized();
|
||||||
|
if ((await userManager.GetLoginsAsync(user))
|
||||||
|
.Any(x => x.LoginProvider == SsoAuthSchemes.LoginProvider))
|
||||||
|
{
|
||||||
|
return BindingConflict("当前账号已绑定统一身份账号,请先解绑后再更换绑定。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var intentCode = WebEncoders.Base64UrlEncode(
|
||||||
|
RandomNumberGenerator.GetBytes(32));
|
||||||
|
await cache.SetStringAsync(
|
||||||
|
BindingIntentCacheKey(intentCode),
|
||||||
|
user.Id.ToString("D"),
|
||||||
|
new DistributedCacheEntryOptions
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
var loginUrl = Url.Action(
|
||||||
|
nameof(Login),
|
||||||
|
values: new
|
||||||
|
{
|
||||||
|
returnUrl = "/account",
|
||||||
|
bindingIntent = intentCode
|
||||||
|
})!;
|
||||||
|
return new SsoBindingStartResponse(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
|
[HttpPost("unbind")]
|
||||||
|
public async Task<IActionResult> Unbind(SsoUnbindRequest request)
|
||||||
|
{
|
||||||
|
var user = await CurrentUserAsync();
|
||||||
|
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
|
return Unauthorized();
|
||||||
|
if (!await userManager.HasPasswordAsync(user))
|
||||||
|
return BindingConflict("当前账号没有本地密码,不能自行解绑,请联系管理员处理。");
|
||||||
|
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||||
|
{
|
||||||
|
await userManager.AccessFailedAsync(user);
|
||||||
|
return InvalidLocalCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
var login = (await userManager.GetLoginsAsync(user))
|
||||||
|
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
|
||||||
|
if (login is null)
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
var result = await userManager.RemoveLoginAsync(
|
||||||
|
user,
|
||||||
|
login.LoginProvider,
|
||||||
|
login.ProviderKey);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
return SsoProblem("解除统一身份绑定失败,请稍后重试。", StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
|
await userManager.ResetAccessFailedCountAsync(user);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
||||||
!string.IsNullOrWhiteSpace(returnUrl) &&
|
!string.IsNullOrWhiteSpace(returnUrl) &&
|
||||||
returnUrl.StartsWith('/') &&
|
returnUrl.StartsWith('/') &&
|
||||||
@@ -275,14 +422,64 @@ public sealed class SsoController(
|
|||||||
? path
|
? path
|
||||||
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
||||||
|
|
||||||
private RedirectResult RedirectToFrontendError(string error) =>
|
private RedirectResult RedirectToFrontendError(
|
||||||
Redirect(BuildFrontendUrl("/login") +
|
string error,
|
||||||
|
string path = "/login") =>
|
||||||
|
Redirect(BuildFrontendUrl(path) +
|
||||||
$"?ssoError={Uri.EscapeDataString(error)}");
|
$"?ssoError={Uri.EscapeDataString(error)}");
|
||||||
|
|
||||||
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
|
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
|
||||||
|
|
||||||
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
|
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
|
||||||
|
|
||||||
|
private static string BindingIntentCacheKey(string code) =>
|
||||||
|
$"sso:binding-intent:{code}";
|
||||||
|
|
||||||
|
private async Task<ApplicationUser?> CurrentUserAsync()
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
return userId is null ? null : await userManager.FindByIdAsync(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> LinkSsoIdentityAsync(
|
||||||
|
ApplicationUser user,
|
||||||
|
string subject)
|
||||||
|
{
|
||||||
|
var subjectOwner = await userManager.FindByLoginAsync(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
subject);
|
||||||
|
if (subjectOwner is not null)
|
||||||
|
return subjectOwner.Id == user.Id ? null : "identity_already_bound";
|
||||||
|
|
||||||
|
if ((await userManager.GetLoginsAsync(user)).Any(x =>
|
||||||
|
x.LoginProvider == SsoAuthSchemes.LoginProvider &&
|
||||||
|
x.ProviderKey != subject))
|
||||||
|
{
|
||||||
|
return "account_already_bound";
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await userManager.AddLoginAsync(
|
||||||
|
user,
|
||||||
|
new UserLoginInfo(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
subject,
|
||||||
|
_options.DisplayName));
|
||||||
|
if (result.Succeeded)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
subjectOwner = await userManager.FindByLoginAsync(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
subject);
|
||||||
|
return subjectOwner?.Id == user.Id ? null : "account_link_failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string EffectiveCallbackUrl()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.CallbackUrl))
|
||||||
|
return _options.CallbackUrl;
|
||||||
|
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}/signin-keycloak";
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
|
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
|
||||||
string code,
|
string code,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -320,7 +517,10 @@ public sealed class SsoController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record SsoSettingsResponse(bool Enabled, string DisplayName);
|
public sealed record SsoSettingsResponse(
|
||||||
|
bool Enabled,
|
||||||
|
string DisplayName,
|
||||||
|
string CallbackUrl);
|
||||||
|
|
||||||
public sealed record SsoExchangeRequest(
|
public sealed record SsoExchangeRequest(
|
||||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||||
@@ -337,3 +537,14 @@ public sealed record SsoBindRequest(
|
|||||||
bool IsNativeApp = false);
|
bool IsNativeApp = false);
|
||||||
|
|
||||||
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
||||||
|
|
||||||
|
public sealed record SsoAccountResponse(
|
||||||
|
bool Enabled,
|
||||||
|
string ProviderDisplayName,
|
||||||
|
bool IsBound,
|
||||||
|
string CallbackUrl);
|
||||||
|
|
||||||
|
public sealed record SsoBindingStartResponse(string LoginUrl);
|
||||||
|
|
||||||
|
public sealed record SsoUnbindRequest(
|
||||||
|
[Required, MaxLength(100)] string Password);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed class SsoOptions
|
|||||||
public bool RequireHttpsMetadata { get; set; } = true;
|
public bool RequireHttpsMetadata { get; set; } = true;
|
||||||
public bool LinkExistingUsersByUserName { get; set; } = true;
|
public bool LinkExistingUsersByUserName { get; set; } = true;
|
||||||
public string FrontendBaseUrl { get; set; } = string.Empty;
|
public string FrontendBaseUrl { get; set; } = string.Empty;
|
||||||
|
public string CallbackUrl { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SsoAuthSchemes
|
public static class SsoAuthSchemes
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Version>2.2.0</Version>
|
<Version>2.3.0</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
|
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
|
||||||
|
|||||||
@@ -104,12 +104,18 @@ if (ssoOptions.Enabled &&
|
|||||||
ssoAuthority.Scheme is not ("http" or "https") ||
|
ssoAuthority.Scheme is not ("http" or "https") ||
|
||||||
(ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") ||
|
(ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") ||
|
||||||
string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) ||
|
string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl) &&
|
||||||
|
(!Uri.TryCreate(ssoOptions.CallbackUrl, UriKind.Absolute, out var callbackUrl) ||
|
||||||
|
callbackUrl.Scheme is not ("http" or "https") ||
|
||||||
|
!callbackUrl.AbsolutePath.EndsWith(
|
||||||
|
"/signin-keycloak",
|
||||||
|
StringComparison.OrdinalIgnoreCase))) ||
|
||||||
(!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) &&
|
(!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) &&
|
||||||
(!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) ||
|
(!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) ||
|
||||||
frontendBaseUrl.Scheme is not ("http" or "https")))))
|
frontendBaseUrl.Scheme is not ("http" or "https")))))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim 和 FrontendBaseUrl;生产元数据地址必须使用 HTTPS。");
|
"启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim、CallbackUrl 和 FrontendBaseUrl;CallbackUrl 必须以 /signin-keycloak 结尾,生产元数据地址必须使用 HTTPS。");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
|
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
|
||||||
@@ -490,6 +496,12 @@ if (ssoOptions.Enabled)
|
|||||||
ssoOptions.UserNameClaim,
|
ssoOptions.UserNameClaim,
|
||||||
ssoOptions.UserNameClaim);
|
ssoOptions.UserNameClaim);
|
||||||
options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim;
|
options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim;
|
||||||
|
options.Events.OnRedirectToIdentityProvider = context =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl))
|
||||||
|
context.ProtocolMessage.RedirectUri = ssoOptions.CallbackUrl;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
options.Events.OnRemoteFailure = context =>
|
options.Events.OnRemoteFailure = context =>
|
||||||
{
|
{
|
||||||
context.HandleResponse();
|
context.HandleResponse();
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"AppIdleMinutes": 4320
|
"AppIdleMinutes": 4320
|
||||||
},
|
},
|
||||||
"Sso": {
|
"Sso": {
|
||||||
"FrontendBaseUrl": "http://localhost:5173"
|
"FrontendBaseUrl": "http://localhost:5173",
|
||||||
|
"CallbackUrl": "http://localhost:5255/signin-keycloak"
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": [
|
"Origins": [
|
||||||
|
|||||||
@@ -90,7 +90,8 @@
|
|||||||
"UserNameClaim": "preferred_username",
|
"UserNameClaim": "preferred_username",
|
||||||
"RequireHttpsMetadata": true,
|
"RequireHttpsMetadata": true,
|
||||||
"LinkExistingUsersByUserName": true,
|
"LinkExistingUsersByUserName": true,
|
||||||
"FrontendBaseUrl": ""
|
"FrontendBaseUrl": "",
|
||||||
|
"CallbackUrl": ""
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": [
|
"Origins": [
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
using Jiaowu.Api.Controllers;
|
using Jiaowu.Api.Controllers;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Tests;
|
namespace Jiaowu.Api.Tests;
|
||||||
@@ -54,6 +58,52 @@ public sealed class SsoControllerTests
|
|||||||
Assert.Equal(1, user?.AccessFailedCount);
|
Assert.Equal(1, user?.AccessFailedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Account_ReportsCurrentKeycloakBindingAndConfiguredCallback()
|
||||||
|
{
|
||||||
|
await using var fixture = await BindingFixture.CreateAsync();
|
||||||
|
Assert.True((await fixture.UserManager.AddLoginAsync(
|
||||||
|
fixture.User,
|
||||||
|
new UserLoginInfo(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
"keycloak-subject",
|
||||||
|
"学校统一身份认证"))).Succeeded);
|
||||||
|
fixture.SignInController();
|
||||||
|
|
||||||
|
var result = await fixture.Controller.Account();
|
||||||
|
|
||||||
|
var response = Assert.IsType<SsoAccountResponse>(result.Value);
|
||||||
|
Assert.True(response.Enabled);
|
||||||
|
Assert.True(response.IsBound);
|
||||||
|
Assert.Equal(
|
||||||
|
"https://jiaowu.example.edu.cn/signin-keycloak",
|
||||||
|
response.CallbackUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unbind_WithCurrentPassword_RemovesOnlyKeycloakLogin()
|
||||||
|
{
|
||||||
|
await using var fixture = await BindingFixture.CreateAsync();
|
||||||
|
Assert.True((await fixture.UserManager.AddLoginAsync(
|
||||||
|
fixture.User,
|
||||||
|
new UserLoginInfo(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
"keycloak-subject",
|
||||||
|
"学校统一身份认证"))).Succeeded);
|
||||||
|
fixture.SignInController();
|
||||||
|
|
||||||
|
var result = await fixture.Controller.Unbind(
|
||||||
|
new SsoUnbindRequest("LocalUser@123"));
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
Assert.Null(await fixture.UserManager.FindByLoginAsync(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
"keycloak-subject"));
|
||||||
|
Assert.True(await fixture.UserManager.CheckPasswordAsync(
|
||||||
|
fixture.User,
|
||||||
|
"LocalUser@123"));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null, "/dashboard")]
|
[InlineData(null, "/dashboard")]
|
||||||
[InlineData("", "/dashboard")]
|
[InlineData("", "/dashboard")]
|
||||||
@@ -93,6 +143,19 @@ public sealed class SsoControllerTests
|
|||||||
public IDistributedCache Cache { get; }
|
public IDistributedCache Cache { get; }
|
||||||
public SsoController Controller { get; }
|
public SsoController Controller { get; }
|
||||||
|
|
||||||
|
public void SignInController()
|
||||||
|
{
|
||||||
|
Controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
[new Claim(ClaimTypes.NameIdentifier, User.Id.ToString())],
|
||||||
|
"test"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<BindingFixture> CreateAsync()
|
public static async Task<BindingFixture> CreateAsync()
|
||||||
{
|
{
|
||||||
var connection = new SqliteConnection("Data Source=:memory:");
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
@@ -127,8 +190,10 @@ public sealed class SsoControllerTests
|
|||||||
Options.Create(new SsoOptions
|
Options.Create(new SsoOptions
|
||||||
{
|
{
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
DisplayName = "学校统一身份认证"
|
DisplayName = "学校统一身份认证",
|
||||||
}));
|
CallbackUrl = "https://jiaowu.example.edu.cn/signin-keycloak"
|
||||||
|
}),
|
||||||
|
NullLogger<SsoController>.Instance);
|
||||||
return new BindingFixture(
|
return new BindingFixture(
|
||||||
connection,
|
connection,
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.2.0-rc1",
|
"version": "2.3.0-rc1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ const workspaceLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const pageTitle = computed(() => {
|
const pageTitle = computed(() => {
|
||||||
|
if (route.path === '/account') return '个人账户'
|
||||||
const matchedItem = navigationGroups.value
|
const matchedItem = navigationGroups.value
|
||||||
.flatMap((group) => group.items)
|
.flatMap((group) => group.items)
|
||||||
.find((item) => item.path === route.path)
|
.find((item) => item.path === route.path)
|
||||||
@@ -318,6 +319,7 @@ onMounted(() => {
|
|||||||
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
|
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
|
||||||
<span>{{ auth.user?.roles?.[0] ?? '教务人员' }}</span>
|
<span>{{ auth.user?.roles?.[0] ?? '教务人员' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<el-button text @click="router.push('/account')">个人账户</el-button>
|
||||||
<el-button text @click="signOut">退出</el-button>
|
<el-button text @click="signOut">退出</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ const router = createRouter({
|
|||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('../views/DashboardView.vue'),
|
component: () => import('../views/DashboardView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'account',
|
||||||
|
name: 'account',
|
||||||
|
component: () => import('../views/AccountView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'base-data',
|
path: 'base-data',
|
||||||
redirect: '/base-data/organization',
|
redirect: '/base-data/organization',
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const actionLoading = ref(false)
|
||||||
|
const sso = reactive({
|
||||||
|
enabled: false,
|
||||||
|
providerDisplayName: '学校统一身份认证',
|
||||||
|
isBound: false,
|
||||||
|
callbackUrl: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ssoErrors: Record<string, string> = {
|
||||||
|
binding_intent_expired: '绑定请求已失效,请重新发起绑定。',
|
||||||
|
identity_already_bound: '该统一身份账号已经绑定其他教务系统账号。',
|
||||||
|
account_already_bound: '当前教务系统账号已经绑定其他统一身份账号。',
|
||||||
|
account_disabled: '当前教务系统账号已停用或锁定。',
|
||||||
|
account_link_failed: '统一身份账户绑定失败,请重新尝试。',
|
||||||
|
configuration_error: '统一身份认证回调地址配置不正确,请联系管理员。',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccount() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/auth/sso/account')
|
||||||
|
sso.enabled = Boolean(data.enabled)
|
||||||
|
sso.providerDisplayName = String(data.providerDisplayName || sso.providerDisplayName)
|
||||||
|
sso.isBound = Boolean(data.isBound)
|
||||||
|
sso.callbackUrl = String(data.callbackUrl || '')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBinding() {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.post('/auth/sso/prepare-binding')
|
||||||
|
window.location.assign(String(data.loginUrl))
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind() {
|
||||||
|
try {
|
||||||
|
const result = await ElMessageBox.prompt(
|
||||||
|
'解绑后将不能再使用当前统一身份账号登录。请输入教务系统密码确认。',
|
||||||
|
'解除统一身份绑定',
|
||||||
|
{
|
||||||
|
inputType: 'password',
|
||||||
|
inputPlaceholder: '请输入教务系统密码',
|
||||||
|
inputValidator: (value) => Boolean(value) || '密码不能为空',
|
||||||
|
confirmButtonText: '确认解绑',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
actionLoading.value = true
|
||||||
|
await http.post('/auth/sso/unbind', { password: result.value })
|
||||||
|
ElMessage.success('统一身份账户已解绑。')
|
||||||
|
await loadAccount()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const ssoError = String(route.query.ssoError ?? '')
|
||||||
|
if (ssoError) {
|
||||||
|
ElMessage.error(ssoErrors[ssoError] ?? '统一身份账户绑定失败,请重新尝试。')
|
||||||
|
await router.replace('/account')
|
||||||
|
}
|
||||||
|
await loadAccount()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="account-page">
|
||||||
|
<header class="account-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">ACCOUNT & SECURITY</span>
|
||||||
|
<h1>个人账户</h1>
|
||||||
|
<p>管理您的登录身份与单点登录绑定。</p>
|
||||||
|
</div>
|
||||||
|
<div class="account-avatar">{{ auth.user?.displayName?.slice(0, 1) ?? '用' }}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="account-grid">
|
||||||
|
<article class="account-card identity-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<div>
|
||||||
|
<span>基本信息</span>
|
||||||
|
<h2>{{ auth.user?.displayName }}</h2>
|
||||||
|
</div>
|
||||||
|
<el-tag type="success" effect="light">账号已启用</el-tag>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>登录账号</dt>
|
||||||
|
<dd>{{ auth.user?.userName }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>系统角色</dt>
|
||||||
|
<dd class="role-list">
|
||||||
|
<el-tag v-for="role in auth.user?.roles" :key="role" effect="plain">{{ role }}</el-tag>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>数据范围</dt>
|
||||||
|
<dd>{{ auth.user?.effectiveDataScope }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-loading="loading" class="account-card sso-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<div>
|
||||||
|
<span>单点登录</span>
|
||||||
|
<h2>{{ sso.providerDisplayName }}</h2>
|
||||||
|
</div>
|
||||||
|
<el-tag v-if="sso.isBound" type="success">已绑定</el-tag>
|
||||||
|
<el-tag v-else-if="sso.enabled" type="info">未绑定</el-tag>
|
||||||
|
<el-tag v-else type="warning">未启用</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="sso.isBound" class="sso-description">
|
||||||
|
当前教务系统账号已经关联统一身份认证。您可以直接通过 Keycloak 登录。
|
||||||
|
</p>
|
||||||
|
<p v-else-if="sso.enabled" class="sso-description">
|
||||||
|
绑定后,即使统一身份用户名与教务系统账号不同,也可以直接登录当前账号。
|
||||||
|
</p>
|
||||||
|
<p v-else class="sso-description">管理员尚未启用统一身份认证。</p>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="sso.enabled && !sso.isBound"
|
||||||
|
type="primary"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="startBinding"
|
||||||
|
>
|
||||||
|
绑定统一身份账户
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else-if="sso.isBound"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="unbind"
|
||||||
|
>
|
||||||
|
解除绑定
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<details v-if="sso.enabled && sso.callbackUrl" class="callback-details">
|
||||||
|
<summary>管理员配置参考</summary>
|
||||||
|
<p>Keycloak Valid redirect URI 必须与下列地址完全一致:</p>
|
||||||
|
<code>{{ sso.callbackUrl }}</code>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.account-page { display: grid; gap: 22px; }
|
||||||
|
.account-heading { display: flex; align-items: center; justify-content: space-between; padding: 26px 30px; border-radius: 18px; background: linear-gradient(125deg, #173a50, #176b87 65%, #2e9b91); color: white; box-shadow: 0 18px 45px rgba(23, 58, 80, .18); }
|
||||||
|
.account-heading h1 { margin: 7px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 30px; }
|
||||||
|
.account-heading p { margin: 0; color: rgba(255,255,255,.72); }
|
||||||
|
.account-heading .eyebrow { color: #8ee4d8; }
|
||||||
|
.account-avatar { display: grid; place-items: center; width: 68px; height: 68px; border: 1px solid rgba(255,255,255,.34); border-radius: 22px; background: rgba(255,255,255,.12); font-size: 28px; font-weight: 700; }
|
||||||
|
.account-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||||
|
.account-card { min-width: 0; min-height: 310px; padding: 28px; border: 1px solid #e2e8ee; border-radius: 16px; background: white; box-shadow: 0 12px 34px rgba(35, 57, 78, .07); }
|
||||||
|
.card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid #edf0f3; }
|
||||||
|
.card-title span { color: #73808d; font-size: 12px; }
|
||||||
|
.card-title h2 { margin: 6px 0 0; color: #263445; font-size: 21px; }
|
||||||
|
dl { margin: 6px 0 0; }
|
||||||
|
dl > div { display: grid; grid-template-columns: 100px 1fr; gap: 18px; padding: 16px 0; border-bottom: 1px solid #f0f2f4; }
|
||||||
|
dt { color: #7b8794; font-size: 13px; }
|
||||||
|
dd { margin: 0; color: #263445; font-weight: 650; }
|
||||||
|
.role-list { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||||
|
.sso-description { min-height: 54px; margin: 22px 0; color: #65717e; line-height: 1.7; }
|
||||||
|
.callback-details { margin-top: 24px; color: #65717e; font-size: 12px; }
|
||||||
|
.callback-details summary { cursor: pointer; color: #315b73; font-weight: 650; }
|
||||||
|
.callback-details p { margin: 10px 0 7px; }
|
||||||
|
.callback-details code { display: block; overflow-wrap: anywhere; padding: 10px 12px; border-radius: 8px; background: #f4f7f9; color: #176b87; }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.account-grid { grid-template-columns: 1fr; }
|
||||||
|
.account-heading { padding: 22px; }
|
||||||
|
.account-avatar { width: 56px; height: 56px; border-radius: 18px; }
|
||||||
|
.account-card { min-height: 0; padding: 22px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -44,6 +44,7 @@ const ssoErrors: Record<string, string> = {
|
|||||||
account_link_failed: '统一身份账号绑定失败,请联系管理员。',
|
account_link_failed: '统一身份账号绑定失败,请联系管理员。',
|
||||||
account_update_failed: '登录状态更新失败,请稍后重试。',
|
account_update_failed: '登录状态更新失败,请稍后重试。',
|
||||||
binding_expired: '账户绑定请求已失效,请重新使用统一身份认证登录。',
|
binding_expired: '账户绑定请求已失效,请重新使用统一身份认证登录。',
|
||||||
|
configuration_error: '统一身份认证回调地址配置不正确,请联系管理员。',
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user