添加sso
This commit is contained in:
@@ -69,6 +69,21 @@ Jwt__Audience=Jiaowu.Web
|
||||
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||
Jwt__ExpireMinutes=60
|
||||
|
||||
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
|
||||
# https://sso.example.edu.cn/realms/mingxu
|
||||
Sso__Enabled=false
|
||||
# Sso__DisplayName=学校统一身份认证
|
||||
# Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||
# Sso__ClientId=jiaowu-web
|
||||
# Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||
# Sso__UserNameClaim=preferred_username
|
||||
# Sso__RequireHttpsMetadata=true
|
||||
# 首次 SSO 登录优先绑定同名本地账号;用户名不同时由用户输入现有账号密码完成绑定。
|
||||
# 不会自动创建账号或授予角色。
|
||||
# Sso__LinkExistingUsersByUserName=true
|
||||
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
|
||||
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||
|
||||
AllowedHosts=jiaowu.example.edu.cn
|
||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||
Cors__Origins__1=capacitor://localhost
|
||||
|
||||
@@ -144,6 +144,44 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。
|
||||
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
||||
路径必须是运行服务器上的实际路径。
|
||||
|
||||
### Keycloak 单点登录(可选)
|
||||
|
||||
系统支持 Keycloak 的 OpenID Connect 授权码流程。Keycloak 只负责验证身份;账号是否
|
||||
启用、角色和学院数据范围仍以本系统 Identity 数据为准。首次 SSO 登录会用
|
||||
`preferred_username`(可通过 `Sso__UserNameClaim` 修改)优先匹配已有登录账号并记录
|
||||
外部账号绑定。如果 Keycloak 用户名与教务系统账号不同,认证后会进入账户绑定页,用户
|
||||
需要再输入一次现有教务系统账号和密码;验证成功后建立永久绑定并直接登录。绑定不会
|
||||
自动创建本地账号、修改人员档案或从 Keycloak 导入高权限角色。同一 Keycloak 身份不能
|
||||
绑定多个本地账号,同一本地账号也不能绑定多个 Keycloak 身份。原账号密码登录和学生
|
||||
自助激活入口不受影响。
|
||||
|
||||
在 Keycloak 中创建 OpenID Connect 客户端,并至少配置:
|
||||
|
||||
- Valid redirect URI:`https://jiaowu.example.edu.cn/signin-keycloak`
|
||||
- Valid post logout redirect URI:`https://jiaowu.example.edu.cn/*`(若后续启用 Keycloak 全局退出)
|
||||
- Standard flow:开启;Implicit flow:关闭;PKCE:`S256`
|
||||
|
||||
然后在 `.env` 中配置:
|
||||
|
||||
```dotenv
|
||||
Sso__Enabled=true
|
||||
Sso__DisplayName=学校统一身份认证
|
||||
Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||
Sso__ClientId=jiaowu-web
|
||||
Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||
Sso__UserNameClaim=preferred_username
|
||||
Sso__RequireHttpsMetadata=true
|
||||
Sso__LinkExistingUsersByUserName=true
|
||||
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||
```
|
||||
|
||||
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
|
||||
`http://localhost:5173`,Keycloak 测试客户端需同时允许
|
||||
`http://localhost:5255/signin-keycloak`。生产环境位于反向代理之后时,应确保应用收到
|
||||
正确的公开 HTTPS scheme(例如设置 `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`),
|
||||
否则生成的 Keycloak 回调地址可能错误。多实例部署应配置 Redis,以便任意实例都能兑换
|
||||
两分钟内有效、使用后即删除的 SSO 登录码。
|
||||
|
||||
### Linux systemd 服务
|
||||
|
||||
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/auth/sso")]
|
||||
public sealed class SsoController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService,
|
||||
IDistributedCache cache,
|
||||
IOptions<SsoOptions> options) : ControllerBase
|
||||
{
|
||||
private readonly SsoOptions _options = options.Value;
|
||||
|
||||
[HttpGet("settings")]
|
||||
public ActionResult<SsoSettingsResponse> Settings() =>
|
||||
new SsoSettingsResponse(_options.Enabled, _options.DisplayName);
|
||||
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpGet("login")]
|
||||
public ActionResult Login([FromQuery] string? returnUrl = null)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||
var completeUrl = Url.Action(
|
||||
nameof(Complete),
|
||||
values: new { returnUrl = safeReturnUrl })!;
|
||||
return Challenge(
|
||||
new AuthenticationProperties { RedirectUri = completeUrl },
|
||||
SsoAuthSchemes.Keycloak);
|
||||
}
|
||||
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpGet("complete")]
|
||||
public async Task<ActionResult> Complete(
|
||||
[FromQuery] string? returnUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var authentication = await HttpContext.AuthenticateAsync(
|
||||
SsoAuthSchemes.ExternalCookie);
|
||||
if (!authentication.Succeeded || authentication.Principal is null)
|
||||
return RedirectToFrontendError("authentication_failed");
|
||||
|
||||
var principal = authentication.Principal;
|
||||
var subject = principal.FindFirstValue("sub") ??
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return RedirectToFrontendError("missing_subject");
|
||||
|
||||
var user = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
if (user is null && _options.LinkExistingUsersByUserName)
|
||||
{
|
||||
var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
user = await userManager.FindByNameAsync(userName);
|
||||
if (user is not null)
|
||||
{
|
||||
var linkResult = await userManager.AddLoginAsync(
|
||||
user,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject,
|
||||
_options.DisplayName));
|
||||
if (!linkResult.Succeeded)
|
||||
return RedirectToFrontendError("account_link_failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
var bindingCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
var externalUserName =
|
||||
principal.FindFirstValue(_options.UserNameClaim)?.Trim() ??
|
||||
principal.FindFirstValue("name")?.Trim() ??
|
||||
subject;
|
||||
await cache.SetStringAsync(
|
||||
BindingCacheKey(bindingCode),
|
||||
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||
},
|
||||
cancellationToken);
|
||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||
|
||||
var bindingPage = BuildFrontendUrl("/sso/bind") +
|
||||
$"?code={Uri.EscapeDataString(bindingCode)}" +
|
||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||
return Redirect(bindingPage);
|
||||
}
|
||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return RedirectToFrontendError("account_disabled");
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
return RedirectToFrontendError("account_update_failed");
|
||||
|
||||
var exchangeCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
await cache.SetStringAsync(
|
||||
ExchangeCacheKey(exchangeCode),
|
||||
user.Id.ToString("D"),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
},
|
||||
cancellationToken);
|
||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||
|
||||
var callback = BuildFrontendUrl("/sso/callback") +
|
||||
$"?code={Uri.EscapeDataString(exchangeCode)}" +
|
||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||
return Redirect(callback);
|
||||
}
|
||||
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("exchange")]
|
||||
public async Task<ActionResult<LoginResponse>> Exchange(
|
||||
SsoExchangeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var cacheKey = ExchangeCacheKey(request.Code);
|
||||
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
if (userId is null)
|
||||
return SsoProblem(
|
||||
"统一身份认证结果已失效,请重新登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
await cache.RemoveAsync(cacheKey, cancellationToken);
|
||||
var user = await userManager.FindByIdAsync(userId);
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return SsoProblem(
|
||||
"本地账号不存在、已停用或已锁定。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
return new LoginResponse(
|
||||
tokenService.Create(user, roles),
|
||||
new CurrentUserResponse(
|
||||
user.Id,
|
||||
user.UserName!,
|
||||
user.DisplayName,
|
||||
roles,
|
||||
user.CollegeId,
|
||||
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
||||
}
|
||||
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpGet("binding")]
|
||||
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
|
||||
[FromQuery, Required, MinLength(20), MaxLength(200)] string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var ticket = await ReadBindingTicketAsync(code, cancellationToken);
|
||||
if (ticket is null)
|
||||
return SsoProblem(
|
||||
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
|
||||
}
|
||||
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("bind")]
|
||||
public async Task<ActionResult<LoginResponse>> Bind(
|
||||
SsoBindRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
|
||||
if (ticket is null)
|
||||
return SsoProblem(
|
||||
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
var user = await userManager.FindByNameAsync(request.UserName.Trim());
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return InvalidLocalCredentials();
|
||||
|
||||
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||
{
|
||||
await userManager.AccessFailedAsync(user);
|
||||
return InvalidLocalCredentials();
|
||||
}
|
||||
|
||||
var subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject);
|
||||
if (subjectOwner is not null && subjectOwner.Id != user.Id)
|
||||
return BindingConflict("该统一身份账号已绑定其他教务系统账号。");
|
||||
|
||||
var keycloakLogins = (await userManager.GetLoginsAsync(user))
|
||||
.Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider)
|
||||
.ToList();
|
||||
if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject))
|
||||
return BindingConflict("该教务系统账号已绑定其他统一身份账号。");
|
||||
|
||||
if (subjectOwner is null)
|
||||
{
|
||||
var linkResult = await userManager.AddLoginAsync(
|
||||
user,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject,
|
||||
_options.DisplayName));
|
||||
if (!linkResult.Succeeded)
|
||||
{
|
||||
subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject);
|
||||
if (subjectOwner?.Id != user.Id)
|
||||
return BindingConflict("账户绑定失败,请重新发起统一身份认证。");
|
||||
}
|
||||
}
|
||||
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError);
|
||||
|
||||
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
return new LoginResponse(
|
||||
tokenService.Create(user, roles),
|
||||
new CurrentUserResponse(
|
||||
user.Id,
|
||||
user.UserName!,
|
||||
user.DisplayName,
|
||||
roles,
|
||||
user.CollegeId,
|
||||
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
||||
}
|
||||
|
||||
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
||||
!string.IsNullOrWhiteSpace(returnUrl) &&
|
||||
returnUrl.StartsWith('/') &&
|
||||
!returnUrl.StartsWith("//", StringComparison.Ordinal)
|
||||
? returnUrl
|
||||
: "/dashboard";
|
||||
|
||||
private string BuildFrontendUrl(string path) =>
|
||||
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
|
||||
? path
|
||||
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
||||
|
||||
private RedirectResult RedirectToFrontendError(string error) =>
|
||||
Redirect(BuildFrontendUrl("/login") +
|
||||
$"?ssoError={Uri.EscapeDataString(error)}");
|
||||
|
||||
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
|
||||
|
||||
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
|
||||
|
||||
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
|
||||
if (json is null)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SsoBindingTicket>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private UnauthorizedObjectResult InvalidLocalCredentials() =>
|
||||
Unauthorized(new ProblemDetails
|
||||
{
|
||||
Title = "账户绑定失败",
|
||||
Detail = "教务系统账号或密码不正确,或账号已停用。",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
});
|
||||
|
||||
private ObjectResult BindingConflict(string detail) =>
|
||||
SsoProblem(detail, StatusCodes.Status409Conflict);
|
||||
|
||||
private ObjectResult SsoProblem(string detail, int status) =>
|
||||
StatusCode(status, new ProblemDetails
|
||||
{
|
||||
Title = "统一身份认证失败",
|
||||
Detail = detail,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SsoSettingsResponse(bool Enabled, string DisplayName);
|
||||
|
||||
public sealed record SsoExchangeRequest(
|
||||
[Required, MinLength(20), MaxLength(200)] string Code);
|
||||
|
||||
public sealed record SsoBindingInfoResponse(
|
||||
string ProviderDisplayName,
|
||||
string ExternalUserName);
|
||||
|
||||
public sealed record SsoBindRequest(
|
||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||
[Required, MaxLength(100)] string UserName,
|
||||
[Required, MaxLength(100)] string Password);
|
||||
|
||||
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||
|
||||
public sealed class SsoOptions
|
||||
{
|
||||
public const string SectionName = "Sso";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string DisplayName { get; set; } = "学校统一身份认证";
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
public string UserNameClaim { get; set; } = "preferred_username";
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
public bool LinkExistingUsersByUserName { get; set; } = true;
|
||||
public string FrontendBaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class SsoAuthSchemes
|
||||
{
|
||||
public const string Keycloak = "Keycloak";
|
||||
public const string ExternalCookie = "KeycloakExternal";
|
||||
public const string LoginProvider = "Keycloak";
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
|
||||
|
||||
@@ -13,7 +13,10 @@ using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -91,6 +94,23 @@ var performanceReportingOptions = builder.Configuration
|
||||
var rabbitMqOptions = builder.Configuration
|
||||
.GetSection(RabbitMqOptions.SectionName)
|
||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||
var ssoOptions = builder.Configuration
|
||||
.GetSection(SsoOptions.SectionName)
|
||||
.Get<SsoOptions>() ?? new SsoOptions();
|
||||
|
||||
if (ssoOptions.Enabled &&
|
||||
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
||||
!Uri.TryCreate(ssoOptions.Authority, UriKind.Absolute, out var ssoAuthority) ||
|
||||
ssoAuthority.Scheme is not ("http" or "https") ||
|
||||
(ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") ||
|
||||
string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) ||
|
||||
(!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) &&
|
||||
(!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) ||
|
||||
frontendBaseUrl.Scheme is not ("http" or "https")))))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim 和 FrontendBaseUrl;生产元数据地址必须使用 HTTPS。");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
|
||||
string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) ||
|
||||
@@ -330,6 +350,10 @@ if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
builder.Services.AddStackExchangeRedisCache(options =>
|
||||
options.Configuration = redisConnectionString);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
}
|
||||
builder.Services.AddHybridCache(options =>
|
||||
{
|
||||
options.MaximumKeyLength = 512;
|
||||
@@ -363,6 +387,8 @@ if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 ||
|
||||
|
||||
builder.Services.Configure<JwtOptions>(
|
||||
builder.Configuration.GetSection(JwtOptions.SectionName));
|
||||
builder.Services.Configure<SsoOptions>(
|
||||
builder.Configuration.GetSection(SsoOptions.SectionName));
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||
@@ -407,8 +433,12 @@ builder.Services.AddHostedService<BackgroundJobOutboxPublisher>();
|
||||
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
|
||||
builder.Services.AddScoped<OfficialDocumentService>();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
var authentication = builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
@@ -423,7 +453,45 @@ builder.Services
|
||||
Encoding.UTF8.GetBytes(jwtOptions.Key)),
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
})
|
||||
.AddCookie(SsoAuthSchemes.ExternalCookie, options =>
|
||||
{
|
||||
options.Cookie.Name = "__Host-jiaowu-sso";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
if (ssoOptions.Enabled)
|
||||
{
|
||||
authentication.AddOpenIdConnect(SsoAuthSchemes.Keycloak, options =>
|
||||
{
|
||||
options.Authority = ssoOptions.Authority.TrimEnd('/');
|
||||
options.ClientId = ssoOptions.ClientId;
|
||||
options.ClientSecret = ssoOptions.ClientSecret;
|
||||
options.SignInScheme = SsoAuthSchemes.ExternalCookie;
|
||||
options.ResponseType = "code";
|
||||
options.UsePkce = true;
|
||||
options.SaveTokens = false;
|
||||
options.RequireHttpsMetadata = ssoOptions.RequireHttpsMetadata;
|
||||
options.CallbackPath = "/signin-keycloak";
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
options.MapInboundClaims = false;
|
||||
options.ClaimActions.MapUniqueJsonKey(
|
||||
ssoOptions.UserNameClaim,
|
||||
ssoOptions.UserNameClaim);
|
||||
options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim;
|
||||
options.Events.OnRemoteFailure = context =>
|
||||
{
|
||||
context.HandleResponse();
|
||||
var loginUrl = string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl)
|
||||
? "/login"
|
||||
: ssoOptions.FrontendBaseUrl.TrimEnd('/') + "/login";
|
||||
context.Response.Redirect(loginUrl + "?ssoError=authentication_failed");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
}
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"Key": "jiaowu-development-secret-key-change-before-production",
|
||||
"ExpireMinutes": 480
|
||||
},
|
||||
"Sso": {
|
||||
"FrontendBaseUrl": "http://localhost:5173"
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"http://localhost:5173"
|
||||
|
||||
@@ -79,6 +79,17 @@
|
||||
"Key": "",
|
||||
"ExpireMinutes": 60
|
||||
},
|
||||
"Sso": {
|
||||
"Enabled": false,
|
||||
"DisplayName": "学校统一身份认证",
|
||||
"Authority": "",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"UserNameClaim": "preferred_username",
|
||||
"RequireHttpsMetadata": true,
|
||||
"LinkExistingUsersByUserName": true,
|
||||
"FrontendBaseUrl": ""
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"capacitor://localhost",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class SsoControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Bind_WithValidLocalCredentials_LinksKeycloakIdentityAndLogsIn()
|
||||
{
|
||||
await using var fixture = await BindingFixture.CreateAsync();
|
||||
var code = new string('a', 32);
|
||||
await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name");
|
||||
|
||||
var result = await fixture.Controller.Bind(
|
||||
new SsoBindRequest(code, fixture.User.UserName!, "LocalUser@123"),
|
||||
CancellationToken.None);
|
||||
|
||||
var response = Assert.IsType<LoginResponse>(result.Value);
|
||||
Assert.Equal("test-token", response.Token);
|
||||
Assert.Equal(fixture.User.Id, response.User.Id);
|
||||
var linkedUser = await fixture.UserManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
"keycloak-subject");
|
||||
Assert.Equal(fixture.User.Id, linkedUser?.Id);
|
||||
Assert.Null(await fixture.Cache.GetStringAsync($"sso:binding:{code}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bind_WithWrongPassword_DoesNotLinkKeycloakIdentity()
|
||||
{
|
||||
await using var fixture = await BindingFixture.CreateAsync();
|
||||
var code = new string('b', 32);
|
||||
await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name");
|
||||
|
||||
var result = await fixture.Controller.Bind(
|
||||
new SsoBindRequest(code, fixture.User.UserName!, "WrongPassword@123"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result.Result);
|
||||
Assert.Null(await fixture.UserManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
"keycloak-subject"));
|
||||
var user = await fixture.UserManager.FindByIdAsync(fixture.User.Id.ToString());
|
||||
Assert.Equal(1, user?.AccessFailedCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "/dashboard")]
|
||||
[InlineData("", "/dashboard")]
|
||||
[InlineData("https://evil.example/path", "/dashboard")]
|
||||
[InlineData("//evil.example/path", "/dashboard")]
|
||||
[InlineData("/grades?term=2026-1", "/grades?term=2026-1")]
|
||||
public void NormalizeReturnUrl_AllowsOnlyLocalApplicationPaths(
|
||||
string? value,
|
||||
string expected)
|
||||
{
|
||||
Assert.Equal(expected, SsoController.NormalizeReturnUrl(value));
|
||||
}
|
||||
|
||||
private sealed class BindingFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
private BindingFixture(
|
||||
SqliteConnection connection,
|
||||
ServiceProvider provider,
|
||||
ApplicationUser user,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IDistributedCache cache,
|
||||
SsoController controller)
|
||||
{
|
||||
_connection = connection;
|
||||
_provider = provider;
|
||||
User = user;
|
||||
UserManager = userManager;
|
||||
Cache = cache;
|
||||
Controller = controller;
|
||||
}
|
||||
|
||||
public ApplicationUser User { get; }
|
||||
public UserManager<ApplicationUser> UserManager { get; }
|
||||
public IDistributedCache Cache { get; }
|
||||
public SsoController Controller { get; }
|
||||
|
||||
public static async Task<BindingFixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDistributedMemoryCache();
|
||||
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
|
||||
services
|
||||
.AddIdentityCore<ApplicationUser>()
|
||||
.AddRoles<ApplicationRole>()
|
||||
.AddEntityFrameworkStores<AppDbContext>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var db = provider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var userManager = provider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = "local.user",
|
||||
DisplayName = "本地用户",
|
||||
IsEnabled = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
Assert.True((await userManager.CreateAsync(user, "LocalUser@123")).Succeeded);
|
||||
|
||||
var cache = provider.GetRequiredService<IDistributedCache>();
|
||||
var controller = new SsoController(
|
||||
userManager,
|
||||
new StubTokenService(),
|
||||
cache,
|
||||
Options.Create(new SsoOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DisplayName = "学校统一身份认证"
|
||||
}));
|
||||
return new BindingFixture(
|
||||
connection,
|
||||
provider,
|
||||
user,
|
||||
userManager,
|
||||
cache,
|
||||
controller);
|
||||
}
|
||||
|
||||
public Task StoreTicketAsync(
|
||||
string code,
|
||||
string subject,
|
||||
string externalUserName) =>
|
||||
Cache.SetStringAsync(
|
||||
$"sso:binding:{code}",
|
||||
JsonSerializer.Serialize(
|
||||
new SsoBindingTicket(subject, externalUserName)));
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _provider.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubTokenService : ITokenService
|
||||
{
|
||||
public string Create(ApplicationUser user, IEnumerable<string> roles) =>
|
||||
"test-token";
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -15,7 +15,11 @@ http.interceptors.request.use((config) => {
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && !error.config?.url?.endsWith('/auth/login')) {
|
||||
const isAuthenticationRequest =
|
||||
error.config?.url?.endsWith('/auth/login') ||
|
||||
error.config?.url?.endsWith('/auth/sso/exchange') ||
|
||||
error.config?.url?.endsWith('/auth/sso/bind')
|
||||
if (error.response?.status === 401 && !isAuthenticationRequest) {
|
||||
localStorage.removeItem('jiaowu_token')
|
||||
localStorage.removeItem('jiaowu_user')
|
||||
goLogin(location.pathname + location.search + location.hash)
|
||||
|
||||
@@ -13,6 +13,18 @@ const router = createRouter({
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/sso/callback',
|
||||
name: 'sso-callback',
|
||||
component: () => import('../views/SsoCallbackView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/sso/bind',
|
||||
name: 'sso-bind',
|
||||
component: () => import('../views/SsoBindView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/timetable',
|
||||
name: 'public-timetable',
|
||||
|
||||
+29
-1
@@ -27,6 +27,24 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
}
|
||||
|
||||
async function exchangeSso(code: string) {
|
||||
const { data } = await http.post('/auth/sso/exchange', { code })
|
||||
token.value = data.token
|
||||
user.value = data.user
|
||||
localStorage.setItem('jiaowu_token', data.token)
|
||||
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
}
|
||||
|
||||
async function bindSso(code: string, userName: string, password: string) {
|
||||
const { data } = await http.post('/auth/sso/bind', { code, userName, password })
|
||||
token.value = data.token
|
||||
user.value = data.user
|
||||
localStorage.setItem('jiaowu_token', data.token)
|
||||
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!token.value) return
|
||||
const { data } = await http.get('/auth/me')
|
||||
@@ -42,5 +60,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
}
|
||||
|
||||
return { token, user, isLoggedIn, isSuperAdmin, login, refresh, logout }
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isLoggedIn,
|
||||
isSuperAdmin,
|
||||
login,
|
||||
exchangeSso,
|
||||
bindSso,
|
||||
refresh,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1208,6 +1208,23 @@ button { cursor: pointer; }
|
||||
.public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
|
||||
.account-activation-link { display: block; margin: -8px 0 18px; color: #315b73; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
|
||||
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
|
||||
.sso-login-submit { width: 100%; height: 46px; border-color: #176b87; color: #176b87; font-weight: 650; }
|
||||
.login-divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: #9aa4b2; font-size: 12px; }
|
||||
.login-divider::before, .login-divider::after { content: ""; flex: 1; height: 1px; background: #e5e9ef; }
|
||||
.sso-callback-page { min-height: 100vh; display: grid; place-content: center; justify-items: center; padding: 24px; background: #f4f7fa; color: #263445; text-align: center; }
|
||||
.sso-callback-page h1 { margin: 18px 0 8px; font-size: 22px; }
|
||||
.sso-callback-page p { margin: 0; color: var(--muted); }
|
||||
.sso-binding-page { min-height: 100vh; display: grid; place-items: center; padding: 28px; background: radial-gradient(circle at top left, #e3f5f2, transparent 42%), #f4f7fa; }
|
||||
.sso-binding-card { width: min(470px, 100%); padding: 38px; border: 1px solid #e3e8ee; border-radius: 18px; background: #fff; box-shadow: 0 20px 55px rgba(35, 57, 78, .12); }
|
||||
.sso-binding-card .brand-mark { margin-bottom: 24px; }
|
||||
.sso-binding-card h1 { margin: 8px 0 12px; font-family: "STZhongsong", "Songti SC", serif; font-size: 28px; }
|
||||
.binding-description { margin: 0 0 26px; color: var(--muted); line-height: 1.75; }
|
||||
.binding-description strong { color: #176b87; }
|
||||
.sso-binding-card label { display: block; margin-bottom: 18px; }
|
||||
.sso-binding-card label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
|
||||
.binding-submit { width: 100%; height: 46px; }
|
||||
.binding-security-note { margin: 18px 0 10px; color: #7b8794; font-size: 12px; line-height: 1.65; }
|
||||
.sso-binding-card > a { color: #315b73; font-size: 13px; font-weight: 650; text-decoration: none; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.metric-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import http from '../api/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const ssoLoading = ref(false)
|
||||
const sso = reactive({ enabled: false, displayName: '学校统一身份认证' })
|
||||
const form = reactive({
|
||||
userName: String(route.query.userName ?? ''),
|
||||
password: '',
|
||||
@@ -24,6 +27,36 @@ async function submit() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startSso() {
|
||||
ssoLoading.value = true
|
||||
const apiBaseUrl = String(import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '')
|
||||
const redirect = String(route.query.redirect ?? '/dashboard')
|
||||
window.location.assign(
|
||||
`${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const ssoErrors: Record<string, string> = {
|
||||
authentication_failed: '统一身份认证未完成,请重新尝试。',
|
||||
missing_subject: 'Keycloak 未返回用户唯一标识,请联系管理员检查客户端映射。',
|
||||
account_disabled: '教务系统账号已停用或锁定,请联系管理员。',
|
||||
account_link_failed: '统一身份账号绑定失败,请联系管理员。',
|
||||
account_update_failed: '登录状态更新失败,请稍后重试。',
|
||||
binding_expired: '账户绑定请求已失效,请重新使用统一身份认证登录。',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const ssoError = String(route.query.ssoError ?? '')
|
||||
if (ssoError) ElMessage.error(ssoErrors[ssoError] ?? '统一身份认证失败,请重新尝试。')
|
||||
try {
|
||||
const { data } = await http.get('/auth/sso/settings')
|
||||
sso.enabled = Boolean(data.enabled)
|
||||
sso.displayName = String(data.displayName || sso.displayName)
|
||||
} catch {
|
||||
sso.enabled = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,6 +115,16 @@ async function submit() {
|
||||
>
|
||||
进入工作台
|
||||
</el-button>
|
||||
<div v-if="sso.enabled" class="login-divider"><span>或</span></div>
|
||||
<el-button
|
||||
v-if="sso.enabled"
|
||||
class="sso-login-submit"
|
||||
size="large"
|
||||
:loading="ssoLoading"
|
||||
@click="startSso"
|
||||
>
|
||||
使用{{ sso.displayName }}登录
|
||||
</el-button>
|
||||
<router-link class="public-timetable-link" to="/timetable">无需登录,查询班级课表 →</router-link>
|
||||
<router-link class="account-activation-link" to="/activate">学生首次登录?自助激活账号 →</router-link>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<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(false)
|
||||
const checking = ref(true)
|
||||
const binding = reactive({
|
||||
providerDisplayName: '学校统一身份认证',
|
||||
externalUserName: '',
|
||||
})
|
||||
const form = reactive({ userName: '', password: '' })
|
||||
|
||||
function safeRedirect(value: unknown) {
|
||||
const path = String(value ?? '')
|
||||
return path.startsWith('/') && !path.startsWith('//') ? path : '/dashboard'
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.userName.trim() || !form.password) {
|
||||
ElMessage.warning('请填写现有教务系统账号和密码。')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.bindSso(String(route.query.code ?? ''), form.userName, form.password)
|
||||
ElMessage.success('账户绑定成功。')
|
||||
await router.replace(safeRedirect(route.query.redirect))
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const code = String(route.query.code ?? '')
|
||||
if (!code) {
|
||||
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await http.get('/auth/sso/binding', { params: { code } })
|
||||
binding.providerDisplayName = String(data.providerDisplayName || binding.providerDisplayName)
|
||||
binding.externalUserName = String(data.externalUserName || '')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
await router.replace({ name: 'login', query: { ssoError: 'binding_expired' } })
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="sso-binding-page">
|
||||
<section v-loading="checking" class="sso-binding-card">
|
||||
<div class="brand-mark" aria-hidden="true">
|
||||
<span v-for="index in 9" :key="index" />
|
||||
</div>
|
||||
<span class="eyebrow">首次使用统一身份认证</span>
|
||||
<h1>绑定现有教务系统账号</h1>
|
||||
<p class="binding-description">
|
||||
已通过{{ binding.providerDisplayName }}验证
|
||||
<strong v-if="binding.externalUserName">{{ binding.externalUserName }}</strong>。
|
||||
请输入一次现有教务系统账号和密码,今后即可直接使用单点登录。
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<label>
|
||||
<span>教务系统账号</span>
|
||||
<el-input v-model="form.userName" size="large" autocomplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
<span>教务系统密码</span>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
size="large"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</label>
|
||||
<el-button
|
||||
class="binding-submit"
|
||||
type="primary"
|
||||
size="large"
|
||||
native-type="submit"
|
||||
:loading="loading"
|
||||
:disabled="checking"
|
||||
>
|
||||
确认绑定并登录
|
||||
</el-button>
|
||||
</form>
|
||||
|
||||
<p class="binding-security-note">
|
||||
绑定只建立登录关联,不会修改您的角色、学院或人员档案。
|
||||
</p>
|
||||
<router-link to="/login">取消并返回登录页</router-link>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function safeRedirect(value: unknown) {
|
||||
const path = String(value ?? '')
|
||||
return path.startsWith('/') && !path.startsWith('//') ? path : '/dashboard'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const code = String(route.query.code ?? '')
|
||||
if (!code) {
|
||||
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.exchangeSso(code)
|
||||
await router.replace(safeRedirect(route.query.redirect))
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="sso-callback-page">
|
||||
<el-icon class="is-loading" :size="34"><Loading /></el-icon>
|
||||
<h1>正在完成统一身份认证</h1>
|
||||
<p>请稍候,不要关闭此页面。</p>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user