滑动续期与自动刷新:
Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。 App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。 普通登录和 SSO 使用同一策略。 刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。 网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
This commit is contained in:
+3
-1
@@ -67,7 +67,9 @@ Operations__MySqlClientPath=mysql
|
|||||||
Jwt__Issuer=Jiaowu.Api
|
Jwt__Issuer=Jiaowu.Api
|
||||||
Jwt__Audience=Jiaowu.Web
|
Jwt__Audience=Jiaowu.Web
|
||||||
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||||
Jwt__ExpireMinutes=60
|
Jwt__AccessTokenMinutes=10
|
||||||
|
Jwt__WebIdleMinutes=30
|
||||||
|
Jwt__AppIdleMinutes=4320
|
||||||
|
|
||||||
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
|
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
|
||||||
# https://sso.example.edu.cn/realms/mingxu
|
# https://sso.example.edu.cn/realms/mingxu
|
||||||
|
|||||||
+3
-1
@@ -30,7 +30,9 @@ x-jiaowu-environment: &jiaowu-environment
|
|||||||
Jwt__Issuer: Jiaowu.Api
|
Jwt__Issuer: Jiaowu.Api
|
||||||
Jwt__Audience: Jiaowu.Web
|
Jwt__Audience: Jiaowu.Web
|
||||||
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
|
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
|
||||||
Jwt__ExpireMinutes: "60"
|
Jwt__AccessTokenMinutes: "10"
|
||||||
|
Jwt__WebIdleMinutes: "30"
|
||||||
|
Jwt__AppIdleMinutes: "4320"
|
||||||
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
|
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
|
||||||
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
|
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
|
||||||
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
|
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
public sealed class AuthController(
|
public sealed class AuthController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ITokenService tokenService,
|
IAuthSessionService authSessionService,
|
||||||
IAppCache cache) : ControllerBase
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -135,8 +135,11 @@ public sealed class AuthController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
|
public async Task<ActionResult<LoginResponse>> Login(
|
||||||
|
LoginRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user = await userManager.FindByNameAsync(request.UserName);
|
var user = await userManager.FindByNameAsync(request.UserName);
|
||||||
if (user is null || !user.IsEnabled)
|
if (user is null || !user.IsEnabled)
|
||||||
@@ -166,15 +169,47 @@ public sealed class AuthController(
|
|||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
var roles = await userManager.GetRolesAsync(user);
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
return new LoginResponse(
|
var session = await authSessionService.CreateAsync(
|
||||||
tokenService.Create(user, roles),
|
user,
|
||||||
new CurrentUserResponse(
|
roles,
|
||||||
user.Id,
|
request.IsNativeApp
|
||||||
user.UserName!,
|
? AuthenticationClientType.App
|
||||||
user.DisplayName,
|
: AuthenticationClientType.Web,
|
||||||
roles,
|
cancellationToken);
|
||||||
user.CollegeId,
|
return CreateLoginResponse(session);
|
||||||
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("token-refresh")]
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<ActionResult<LoginResponse>> Refresh(
|
||||||
|
RefreshTokenRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await authSessionService.RefreshAsync(
|
||||||
|
request.RefreshToken,
|
||||||
|
cancellationToken);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "登录已过期",
|
||||||
|
Detail = "登录已过期或刷新令牌已失效,请重新登录。",
|
||||||
|
Status = StatusCodes.Status401Unauthorized
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateLoginResponse(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public async Task<IActionResult> Logout(
|
||||||
|
RefreshTokenRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await authSessionService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||||
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -212,11 +247,29 @@ public sealed class AuthController(
|
|||||||
Detail = detail,
|
Detail = detail,
|
||||||
Status = status
|
Status = status
|
||||||
});
|
});
|
||||||
|
|
||||||
|
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
|
||||||
|
new(
|
||||||
|
session.AccessToken,
|
||||||
|
session.AccessTokenExpiresAt,
|
||||||
|
session.RefreshToken,
|
||||||
|
session.SessionExpiresAt,
|
||||||
|
new CurrentUserResponse(
|
||||||
|
session.User.Id,
|
||||||
|
session.User.UserName!,
|
||||||
|
session.User.DisplayName,
|
||||||
|
session.Roles,
|
||||||
|
session.User.CollegeId,
|
||||||
|
EffectiveDataScopeResolver.Resolve(session.Roles).ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record LoginRequest(
|
public sealed record LoginRequest(
|
||||||
[Required, MaxLength(100)] string UserName,
|
[Required, MaxLength(100)] string UserName,
|
||||||
[Required, MaxLength(100)] string Password);
|
[Required, MaxLength(100)] string Password,
|
||||||
|
bool IsNativeApp = false);
|
||||||
|
|
||||||
|
public sealed record RefreshTokenRequest(
|
||||||
|
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
|
||||||
|
|
||||||
public sealed record StudentActivationRequest(
|
public sealed record StudentActivationRequest(
|
||||||
[Required, MaxLength(50)] string Name,
|
[Required, MaxLength(50)] string Name,
|
||||||
@@ -227,7 +280,12 @@ public sealed record StudentActivationRequest(
|
|||||||
Guid AdministrativeClassId,
|
Guid AdministrativeClassId,
|
||||||
[Required, MinLength(8), MaxLength(100)] string Password);
|
[Required, MinLength(8), MaxLength(100)] string Password);
|
||||||
|
|
||||||
public sealed record LoginResponse(string Token, CurrentUserResponse User);
|
public sealed record LoginResponse(
|
||||||
|
string Token,
|
||||||
|
DateTime AccessTokenExpiresAt,
|
||||||
|
string RefreshToken,
|
||||||
|
DateTime SessionExpiresAt,
|
||||||
|
CurrentUserResponse User);
|
||||||
|
|
||||||
public sealed record CurrentUserResponse(
|
public sealed record CurrentUserResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/auth/sso")]
|
[Route("api/auth/sso")]
|
||||||
public sealed class SsoController(
|
public sealed class SsoController(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ITokenService tokenService,
|
IAuthSessionService authSessionService,
|
||||||
IDistributedCache cache,
|
IDistributedCache cache,
|
||||||
IOptions<SsoOptions> options) : ControllerBase
|
IOptions<SsoOptions> options) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -162,15 +162,14 @@ public sealed class SsoController(
|
|||||||
StatusCodes.Status401Unauthorized);
|
StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
var roles = await userManager.GetRolesAsync(user);
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
return new LoginResponse(
|
var session = await authSessionService.CreateAsync(
|
||||||
tokenService.Create(user, roles),
|
user,
|
||||||
new CurrentUserResponse(
|
roles,
|
||||||
user.Id,
|
request.IsNativeApp
|
||||||
user.UserName!,
|
? AuthenticationClientType.App
|
||||||
user.DisplayName,
|
: AuthenticationClientType.Web,
|
||||||
roles,
|
cancellationToken);
|
||||||
user.CollegeId,
|
return AuthController.CreateLoginResponse(session);
|
||||||
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[EnableRateLimiting("public-auth")]
|
[EnableRateLimiting("public-auth")]
|
||||||
@@ -254,15 +253,14 @@ public sealed class SsoController(
|
|||||||
|
|
||||||
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
|
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
|
||||||
var roles = await userManager.GetRolesAsync(user);
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
return new LoginResponse(
|
var session = await authSessionService.CreateAsync(
|
||||||
tokenService.Create(user, roles),
|
user,
|
||||||
new CurrentUserResponse(
|
roles,
|
||||||
user.Id,
|
request.IsNativeApp
|
||||||
user.UserName!,
|
? AuthenticationClientType.App
|
||||||
user.DisplayName,
|
: AuthenticationClientType.Web,
|
||||||
roles,
|
cancellationToken);
|
||||||
user.CollegeId,
|
return AuthController.CreateLoginResponse(session);
|
||||||
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
||||||
@@ -325,7 +323,8 @@ public sealed class SsoController(
|
|||||||
public sealed record SsoSettingsResponse(bool Enabled, string DisplayName);
|
public sealed record SsoSettingsResponse(bool Enabled, string DisplayName);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
public sealed record SsoBindingInfoResponse(
|
public sealed record SsoBindingInfoResponse(
|
||||||
string ProviderDisplayName,
|
string ProviderDisplayName,
|
||||||
@@ -334,6 +333,7 @@ public sealed record SsoBindingInfoResponse(
|
|||||||
public sealed record SsoBindRequest(
|
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);
|
||||||
|
|
||||||
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Jiaowu.Api.Domain.Identity;
|
||||||
|
|
||||||
|
public enum AuthenticationClientType
|
||||||
|
{
|
||||||
|
Web = 0,
|
||||||
|
App = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RefreshSession
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public ApplicationUser? User { get; set; }
|
||||||
|
public required string TokenHash { get; set; }
|
||||||
|
public AuthenticationClientType ClientType { get; set; }
|
||||||
|
public required string SecurityStamp { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime LastRefreshedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? RevokedAt { get; set; }
|
||||||
|
public Guid? ReplacedBySessionId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
|
||||||
|
public interface IAuthSessionService
|
||||||
|
{
|
||||||
|
Task<AuthSessionResult> CreateAsync(
|
||||||
|
ApplicationUser user,
|
||||||
|
IEnumerable<string> roles,
|
||||||
|
AuthenticationClientType clientType,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<AuthSessionResult?> RefreshAsync(
|
||||||
|
string refreshToken,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task RevokeAsync(
|
||||||
|
string refreshToken,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AuthSessionResult(
|
||||||
|
string AccessToken,
|
||||||
|
DateTime AccessTokenExpiresAt,
|
||||||
|
string RefreshToken,
|
||||||
|
DateTime SessionExpiresAt,
|
||||||
|
ApplicationUser User,
|
||||||
|
IReadOnlyList<string> Roles);
|
||||||
|
|
||||||
|
public sealed class AuthSessionService(
|
||||||
|
AppDbContext db,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
ITokenService tokenService,
|
||||||
|
IOptions<JwtOptions> options) : IAuthSessionService
|
||||||
|
{
|
||||||
|
private readonly JwtOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<AuthSessionResult> CreateAsync(
|
||||||
|
ApplicationUser user,
|
||||||
|
IEnumerable<string> roles,
|
||||||
|
AuthenticationClientType clientType,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var roleList = roles.ToList();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var rawRefreshToken = CreateRefreshToken();
|
||||||
|
var session = new RefreshSession
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
TokenHash = HashToken(rawRefreshToken),
|
||||||
|
ClientType = clientType,
|
||||||
|
SecurityStamp = user.SecurityStamp ?? string.Empty,
|
||||||
|
CreatedAt = now,
|
||||||
|
LastRefreshedAt = now,
|
||||||
|
ExpiresAt = now.Add(GetIdleTimeout(clientType))
|
||||||
|
};
|
||||||
|
|
||||||
|
await RemoveExpiredSessionsAsync(user.Id, now, cancellationToken);
|
||||||
|
db.RefreshSessions.Add(session);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return BuildResult(user, roleList, rawRefreshToken, session.ExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthSessionResult?> RefreshAsync(
|
||||||
|
string refreshToken,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var tokenHash = HashToken(refreshToken);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var current = await db.RefreshSessions
|
||||||
|
.Include(x => x.User)
|
||||||
|
.SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
|
||||||
|
var user = current?.User;
|
||||||
|
if (current is null || user is null || current.RevokedAt.HasValue ||
|
||||||
|
current.ExpiresAt <= now || !user.IsEnabled ||
|
||||||
|
await userManager.IsLockedOutAsync(user) ||
|
||||||
|
!string.Equals(current.SecurityStamp, user.SecurityStamp ?? string.Empty,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newRawToken = CreateRefreshToken();
|
||||||
|
var replacement = new RefreshSession
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
TokenHash = HashToken(newRawToken),
|
||||||
|
ClientType = current.ClientType,
|
||||||
|
SecurityStamp = current.SecurityStamp,
|
||||||
|
CreatedAt = now,
|
||||||
|
LastRefreshedAt = now,
|
||||||
|
ExpiresAt = now.Add(GetIdleTimeout(current.ClientType))
|
||||||
|
};
|
||||||
|
|
||||||
|
var rotated = await db.ExecuteInRetriableTransactionAsync(
|
||||||
|
async transaction =>
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var updated = await db.RefreshSessions
|
||||||
|
.Where(x => x.Id == current.Id && x.RevokedAt == null && x.ExpiresAt > now)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(x => x.RevokedAt, now)
|
||||||
|
.SetProperty(x => x.ReplacedBySessionId, replacement.Id),
|
||||||
|
cancellationToken);
|
||||||
|
if (updated != 1)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.RefreshSessions.Add(replacement);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
if (!rotated) return null;
|
||||||
|
|
||||||
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
return BuildResult(user, roles.ToList(), newRawToken, replacement.ExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeAsync(
|
||||||
|
string refreshToken,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var tokenHash = HashToken(refreshToken);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
await db.RefreshSessions
|
||||||
|
.Where(x => x.TokenHash == tokenHash && x.RevokedAt == null)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(x => x.RevokedAt, now),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthSessionResult BuildResult(
|
||||||
|
ApplicationUser user,
|
||||||
|
IReadOnlyList<string> roles,
|
||||||
|
string refreshToken,
|
||||||
|
DateTime sessionExpiresAt)
|
||||||
|
{
|
||||||
|
var accessToken = tokenService.Create(user, roles);
|
||||||
|
return new AuthSessionResult(
|
||||||
|
accessToken.Token,
|
||||||
|
accessToken.ExpiresAt,
|
||||||
|
refreshToken,
|
||||||
|
sessionExpiresAt,
|
||||||
|
user,
|
||||||
|
roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSpan GetIdleTimeout(AuthenticationClientType clientType) =>
|
||||||
|
TimeSpan.FromMinutes(clientType == AuthenticationClientType.App
|
||||||
|
? _options.AppIdleMinutes
|
||||||
|
: _options.WebIdleMinutes);
|
||||||
|
|
||||||
|
private async Task RemoveExpiredSessionsAsync(
|
||||||
|
Guid userId,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var retentionCutoff = now.AddDays(-7);
|
||||||
|
await db.RefreshSessions
|
||||||
|
.Where(x => x.UserId == userId &&
|
||||||
|
(x.ExpiresAt < now || x.RevokedAt < retentionCutoff))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateRefreshToken() =>
|
||||||
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||||
|
|
||||||
|
private static string HashToken(string token) =>
|
||||||
|
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
|
||||||
|
}
|
||||||
@@ -6,5 +6,7 @@ public sealed class JwtOptions
|
|||||||
public string Issuer { get; set; } = "Jiaowu.Api";
|
public string Issuer { get; set; } = "Jiaowu.Api";
|
||||||
public string Audience { get; set; } = "Jiaowu.Web";
|
public string Audience { get; set; } = "Jiaowu.Web";
|
||||||
public string Key { get; set; } = string.Empty;
|
public string Key { get; set; } = string.Empty;
|
||||||
public int ExpireMinutes { get; set; } = 480;
|
public int AccessTokenMinutes { get; set; } = 10;
|
||||||
|
public int WebIdleMinutes { get; set; } = 30;
|
||||||
|
public int AppIdleMinutes { get; set; } = 3 * 24 * 60;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ namespace Jiaowu.Api.Infrastructure.Auth;
|
|||||||
|
|
||||||
public interface ITokenService
|
public interface ITokenService
|
||||||
{
|
{
|
||||||
string Create(ApplicationUser user, IEnumerable<string> roles);
|
AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AccessTokenResult(string Token, DateTime ExpiresAt);
|
||||||
|
|
||||||
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||||
{
|
{
|
||||||
private readonly JwtOptions _options = options.Value;
|
private readonly JwtOptions _options = options.Value;
|
||||||
|
|
||||||
public string Create(ApplicationUser user, IEnumerable<string> roles)
|
public AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
@@ -37,13 +39,16 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
|||||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
|
||||||
SecurityAlgorithms.HmacSha256);
|
SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
issuer: _options.Issuer,
|
issuer: _options.Issuer,
|
||||||
audience: _options.Audience,
|
audience: _options.Audience,
|
||||||
claims: claims,
|
claims: claims,
|
||||||
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
|
expires: expiresAt,
|
||||||
signingCredentials: credentials);
|
signingCredentials: credentials);
|
||||||
|
|
||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
return new AccessTokenResult(
|
||||||
|
new JwtSecurityTokenHandler().WriteToken(token),
|
||||||
|
expiresAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<BackgroundJobOutboxMessage>();
|
Set<BackgroundJobOutboxMessage>();
|
||||||
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
||||||
Set<AppUpdateRelease>();
|
Set<AppUpdateRelease>();
|
||||||
|
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||||
|
|
||||||
protected override void ConfigureConventions(
|
protected override void ConfigureConventions(
|
||||||
ModelConfigurationBuilder configurationBuilder)
|
ModelConfigurationBuilder configurationBuilder)
|
||||||
@@ -163,6 +164,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Description).HasMaxLength(100);
|
entity.Property(x => x.Description).HasMaxLength(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<RefreshSession>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.TokenHash).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.SecurityStamp).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.ClientType)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(20);
|
||||||
|
entity.HasIndex(x => x.TokenHash).IsUnique();
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.ExpiresAt });
|
||||||
|
entity.HasOne(x => x.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
ConfigureCatalog<Campus>(builder);
|
ConfigureCatalog<Campus>(builder);
|
||||||
ConfigureCatalog<College>(builder);
|
ConfigureCatalog<College>(builder);
|
||||||
ConfigureCatalog<Major>(builder);
|
ConfigureCatalog<Major>(builder);
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260729_41_app_update_releases";
|
"20260729_41_app_update_releases";
|
||||||
private const string IntegratedExperimentSchedulingMigration =
|
private const string IntegratedExperimentSchedulingMigration =
|
||||||
"20260802_42_integrated_experiment_scheduling";
|
"20260802_42_integrated_experiment_scheduling";
|
||||||
|
private const string RefreshSessionsMigration =
|
||||||
|
"20260803_43_refresh_sessions";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -593,6 +595,10 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
? []
|
? []
|
||||||
: IntegratedExperimentSchedulingStatements,
|
: IntegratedExperimentSchedulingStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
RefreshSessionsMigration,
|
||||||
|
RefreshSessionsStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -2104,6 +2110,34 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] RefreshSessionsStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "RefreshSessions" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_RefreshSessions" PRIMARY KEY,
|
||||||
|
"UserId" TEXT NOT NULL,
|
||||||
|
"TokenHash" TEXT NOT NULL,
|
||||||
|
"ClientType" TEXT NOT NULL,
|
||||||
|
"SecurityStamp" TEXT NOT NULL,
|
||||||
|
"ExpiresAt" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"LastRefreshedAt" TEXT NOT NULL,
|
||||||
|
"RevokedAt" TEXT NULL,
|
||||||
|
"ReplacedBySessionId" TEXT NULL,
|
||||||
|
CONSTRAINT "FK_RefreshSessions_AspNetUsers_UserId"
|
||||||
|
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "IX_RefreshSessions_TokenHash"
|
||||||
|
ON "RefreshSessions" ("TokenHash");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_RefreshSessions_UserId_ExpiresAt"
|
||||||
|
ON "RefreshSessions" ("UserId", "ExpiresAt");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] ApprovalTableStatements =
|
private static readonly string[] ApprovalTableStatements =
|
||||||
[
|
[
|
||||||
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
||||||
|
|||||||
+6052
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RefreshSessions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RefreshSessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
TokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||||
|
ClientType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||||
|
SecurityStamp = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
LastRefreshedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
RevokedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ReplacedBySessionId = table.Column<Guid>(type: "char(36)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_RefreshSessions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RefreshSessions_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RefreshSessions_TokenHash",
|
||||||
|
table: "RefreshSessions",
|
||||||
|
column: "TokenHash",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RefreshSessions_UserId_ExpiresAt",
|
||||||
|
table: "RefreshSessions",
|
||||||
|
columns: new[] { "UserId", "ExpiresAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RefreshSessions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -3297,6 +3297,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("Kind")
|
b.Property<int>("Kind")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("int")
|
.HasColumnType("int")
|
||||||
.HasDefaultValue(1);
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
@@ -4133,6 +4134,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.RefreshSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastRefreshedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReplacedBySessionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "ExpiresAt");
|
||||||
|
|
||||||
|
b.ToTable("RefreshSessions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.System.AppUpdateRelease", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.System.AppUpdateRelease", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -5742,6 +5792,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("AcademicTerm");
|
b.Navigation("AcademicTerm");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.RefreshSession", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
|
||||||
|
|||||||
@@ -384,6 +384,14 @@ if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 ||
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Jwt:Key 必须配置为至少 32 字节的随机生产密钥,不能使用示例值。");
|
"Jwt:Key 必须配置为至少 32 字节的随机生产密钥,不能使用示例值。");
|
||||||
}
|
}
|
||||||
|
if (jwtOptions.AccessTokenMinutes is < 1 or > 30 ||
|
||||||
|
jwtOptions.WebIdleMinutes is < 5 or > 1440 ||
|
||||||
|
jwtOptions.AppIdleMinutes is < 60 or > 43200 ||
|
||||||
|
jwtOptions.AccessTokenMinutes > jwtOptions.WebIdleMinutes)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Jwt 访问令牌或 Web/App 空闲有效期配置超出允许范围。");
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.Configure<JwtOptions>(
|
builder.Services.Configure<JwtOptions>(
|
||||||
builder.Configuration.GetSection(JwtOptions.SectionName));
|
builder.Configuration.GetSection(JwtOptions.SectionName));
|
||||||
@@ -391,6 +399,7 @@ builder.Services.Configure<SsoOptions>(
|
|||||||
builder.Configuration.GetSection(SsoOptions.SectionName));
|
builder.Configuration.GetSection(SsoOptions.SectionName));
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
|
builder.Services.AddScoped<IAuthSessionService, AuthSessionService>();
|
||||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<DemoDataSeeder>();
|
builder.Services.AddScoped<DemoDataSeeder>();
|
||||||
@@ -506,6 +515,16 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
AutoReplenishment = true
|
AutoReplenishment = true
|
||||||
}));
|
}));
|
||||||
|
options.AddPolicy("token-refresh", context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 600,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
options.AddPolicy("official-verification", context =>
|
options.AddPolicy("official-verification", context =>
|
||||||
RateLimitPartition.GetFixedWindowLimiter(
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Key": "jiaowu-development-secret-key-change-before-production",
|
"Key": "jiaowu-development-secret-key-change-before-production",
|
||||||
"ExpireMinutes": 480
|
"AccessTokenMinutes": 10,
|
||||||
|
"WebIdleMinutes": 30,
|
||||||
|
"AppIdleMinutes": 4320
|
||||||
},
|
},
|
||||||
"Sso": {
|
"Sso": {
|
||||||
"FrontendBaseUrl": "http://localhost:5173"
|
"FrontendBaseUrl": "http://localhost:5173"
|
||||||
|
|||||||
@@ -77,7 +77,9 @@
|
|||||||
"Issuer": "Jiaowu.Api",
|
"Issuer": "Jiaowu.Api",
|
||||||
"Audience": "Jiaowu.Web",
|
"Audience": "Jiaowu.Web",
|
||||||
"Key": "",
|
"Key": "",
|
||||||
"ExpireMinutes": 60
|
"AccessTokenMinutes": 10,
|
||||||
|
"WebIdleMinutes": 30,
|
||||||
|
"AppIdleMinutes": 4320
|
||||||
},
|
},
|
||||||
"Sso": {
|
"Sso": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ public sealed class AuthControllerTests
|
|||||||
var controller = new AuthController(
|
var controller = new AuthController(
|
||||||
db,
|
db,
|
||||||
userManager,
|
userManager,
|
||||||
new StubTokenService(),
|
new StubAuthSessionService(),
|
||||||
NoOpAppCache.Instance);
|
NoOpAppCache.Instance);
|
||||||
var request = new StudentActivationRequest(
|
var request = new StudentActivationRequest(
|
||||||
student.Name,
|
student.Name,
|
||||||
@@ -143,8 +143,27 @@ public sealed class AuthControllerTests
|
|||||||
protected override bool ShouldRetryOn(Exception exception) => false;
|
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StubTokenService : ITokenService
|
private sealed class StubAuthSessionService : IAuthSessionService
|
||||||
{
|
{
|
||||||
public string Create(ApplicationUser user, IEnumerable<string> roles) => string.Empty;
|
public Task<AuthSessionResult> CreateAsync(
|
||||||
|
ApplicationUser user,
|
||||||
|
IEnumerable<string> roles,
|
||||||
|
AuthenticationClientType clientType,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(new AuthSessionResult(
|
||||||
|
string.Empty,
|
||||||
|
DateTime.UtcNow.AddMinutes(10),
|
||||||
|
"test-refresh-token-value-with-sufficient-length",
|
||||||
|
DateTime.UtcNow.AddMinutes(30),
|
||||||
|
user,
|
||||||
|
roles.ToList()));
|
||||||
|
|
||||||
|
public Task<AuthSessionResult?> RefreshAsync(
|
||||||
|
string refreshToken,
|
||||||
|
CancellationToken cancellationToken = default) => Task.FromResult<AuthSessionResult?>(null);
|
||||||
|
|
||||||
|
public Task RevokeAsync(
|
||||||
|
string refreshToken,
|
||||||
|
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AuthSessionServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Web_session_rotates_refresh_token_and_rejects_reuse()
|
||||||
|
{
|
||||||
|
await using var fixture = await SessionFixture.CreateAsync();
|
||||||
|
|
||||||
|
var issued = await fixture.Service.CreateAsync(
|
||||||
|
fixture.User,
|
||||||
|
[SystemRoles.Student],
|
||||||
|
AuthenticationClientType.Web);
|
||||||
|
|
||||||
|
Assert.InRange(
|
||||||
|
issued.SessionExpiresAt,
|
||||||
|
DateTime.UtcNow.AddMinutes(29),
|
||||||
|
DateTime.UtcNow.AddMinutes(31));
|
||||||
|
var stored = await fixture.Db.RefreshSessions.SingleAsync();
|
||||||
|
Assert.NotEqual(issued.RefreshToken, stored.TokenHash);
|
||||||
|
|
||||||
|
var refreshed = await fixture.Service.RefreshAsync(issued.RefreshToken);
|
||||||
|
|
||||||
|
Assert.NotNull(refreshed);
|
||||||
|
Assert.NotEqual(issued.RefreshToken, refreshed.RefreshToken);
|
||||||
|
Assert.Null(await fixture.Service.RefreshAsync(issued.RefreshToken));
|
||||||
|
Assert.Equal(1, await fixture.Db.RefreshSessions.CountAsync(x => x.RevokedAt == null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task App_session_uses_three_day_sliding_window()
|
||||||
|
{
|
||||||
|
await using var fixture = await SessionFixture.CreateAsync();
|
||||||
|
|
||||||
|
var issued = await fixture.Service.CreateAsync(
|
||||||
|
fixture.User,
|
||||||
|
[SystemRoles.Student],
|
||||||
|
AuthenticationClientType.App);
|
||||||
|
|
||||||
|
Assert.InRange(
|
||||||
|
issued.SessionExpiresAt,
|
||||||
|
DateTime.UtcNow.AddDays(3).AddMinutes(-1),
|
||||||
|
DateTime.UtcNow.AddDays(3).AddMinutes(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SessionFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
private readonly AsyncServiceScope _scope;
|
||||||
|
|
||||||
|
private SessionFixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
ServiceProvider provider,
|
||||||
|
AsyncServiceScope scope,
|
||||||
|
AppDbContext db,
|
||||||
|
ApplicationUser user,
|
||||||
|
IAuthSessionService service)
|
||||||
|
{
|
||||||
|
_connection = connection;
|
||||||
|
_provider = provider;
|
||||||
|
_scope = scope;
|
||||||
|
Db = db;
|
||||||
|
User = user;
|
||||||
|
Service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public ApplicationUser User { get; }
|
||||||
|
public IAuthSessionService Service { get; }
|
||||||
|
|
||||||
|
public static async Task<SessionFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
|
||||||
|
services.Configure<JwtOptions>(options =>
|
||||||
|
{
|
||||||
|
options.Issuer = "tests";
|
||||||
|
options.Audience = "tests-web";
|
||||||
|
options.Key = "a-test-signing-key-that-is-at-least-32-bytes-long";
|
||||||
|
options.AccessTokenMinutes = 10;
|
||||||
|
options.WebIdleMinutes = 30;
|
||||||
|
options.AppIdleMinutes = 4320;
|
||||||
|
});
|
||||||
|
services
|
||||||
|
.AddIdentityCore<ApplicationUser>()
|
||||||
|
.AddRoles<ApplicationRole>()
|
||||||
|
.AddEntityFrameworkStores<AppDbContext>();
|
||||||
|
services.AddScoped<ITokenService, TokenService>();
|
||||||
|
services.AddScoped<IAuthSessionService, AuthSessionService>();
|
||||||
|
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var scope = provider.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = "session-user",
|
||||||
|
DisplayName = "会话测试用户",
|
||||||
|
IsEnabled = true,
|
||||||
|
LockoutEnabled = true
|
||||||
|
};
|
||||||
|
Assert.True((await userManager.CreateAsync(user, "SessionUser@123")).Succeeded);
|
||||||
|
|
||||||
|
return new SessionFixture(
|
||||||
|
connection,
|
||||||
|
provider,
|
||||||
|
scope,
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
scope.ServiceProvider.GetRequiredService<IAuthSessionService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _scope.DisposeAsync();
|
||||||
|
await _provider.DisposeAsync();
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ public sealed class SsoControllerTests
|
|||||||
var cache = provider.GetRequiredService<IDistributedCache>();
|
var cache = provider.GetRequiredService<IDistributedCache>();
|
||||||
var controller = new SsoController(
|
var controller = new SsoController(
|
||||||
userManager,
|
userManager,
|
||||||
new StubTokenService(),
|
new StubAuthSessionService(),
|
||||||
cache,
|
cache,
|
||||||
Options.Create(new SsoOptions
|
Options.Create(new SsoOptions
|
||||||
{
|
{
|
||||||
@@ -154,9 +154,27 @@ public sealed class SsoControllerTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StubTokenService : ITokenService
|
private sealed class StubAuthSessionService : IAuthSessionService
|
||||||
{
|
{
|
||||||
public string Create(ApplicationUser user, IEnumerable<string> roles) =>
|
public Task<AuthSessionResult> CreateAsync(
|
||||||
"test-token";
|
ApplicationUser user,
|
||||||
|
IEnumerable<string> roles,
|
||||||
|
AuthenticationClientType clientType,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(new AuthSessionResult(
|
||||||
|
"test-token",
|
||||||
|
DateTime.UtcNow.AddMinutes(10),
|
||||||
|
"test-refresh-token-value-with-sufficient-length",
|
||||||
|
DateTime.UtcNow.AddMinutes(30),
|
||||||
|
user,
|
||||||
|
roles.ToList()));
|
||||||
|
|
||||||
|
public Task<AuthSessionResult?> RefreshAsync(
|
||||||
|
string refreshToken,
|
||||||
|
CancellationToken cancellationToken = default) => Task.FromResult<AuthSessionResult?>(null);
|
||||||
|
|
||||||
|
public Task RevokeAsync(
|
||||||
|
string refreshToken,
|
||||||
|
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ public sealed class TokenServiceTests
|
|||||||
};
|
};
|
||||||
var service = new TokenService(options);
|
var service = new TokenService(options);
|
||||||
|
|
||||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(
|
var result = service.Create(user, [SystemRoles.Teacher]);
|
||||||
service.Create(user, [SystemRoles.Teacher]));
|
var token = new JwtSecurityTokenHandler().ReadJwtToken(result.Token);
|
||||||
|
|
||||||
Assert.Contains(token.Claims, x =>
|
Assert.Contains(token.Claims, x =>
|
||||||
x.Type == ClaimTypes.Role && x.Value == SystemRoles.Teacher);
|
x.Type == ClaimTypes.Role && x.Value == SystemRoles.Teacher);
|
||||||
Assert.Contains(token.Claims, x =>
|
Assert.Contains(token.Claims, x =>
|
||||||
x.Type == ClaimTypes.Name && x.Value == "陈老师");
|
x.Type == ClaimTypes.Name && x.Value == "陈老师");
|
||||||
|
Assert.InRange(
|
||||||
|
result.ExpiresAt,
|
||||||
|
DateTime.UtcNow.AddMinutes(9),
|
||||||
|
DateTime.UtcNow.AddMinutes(11));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-5
@@ -1,27 +1,56 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { goLogin } from '../utils/navigate'
|
import { goLogin } from '../utils/navigate'
|
||||||
|
import {
|
||||||
|
authStorageKeys,
|
||||||
|
clearAuthSession,
|
||||||
|
markActivity,
|
||||||
|
refreshAuthSession,
|
||||||
|
refreshIfNeeded,
|
||||||
|
} from '../auth/session'
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.interceptors.request.use((config) => {
|
http.interceptors.request.use(async (config) => {
|
||||||
const token = localStorage.getItem('jiaowu_token')
|
const isAuthenticationRequest =
|
||||||
|
config.url?.endsWith('/auth/login') ||
|
||||||
|
config.url?.endsWith('/auth/refresh') ||
|
||||||
|
config.url?.endsWith('/auth/logout') ||
|
||||||
|
config.url?.endsWith('/auth/sso/exchange') ||
|
||||||
|
config.url?.endsWith('/auth/sso/bind')
|
||||||
|
if (!isAuthenticationRequest) {
|
||||||
|
const activeToken = await refreshIfNeeded(true)
|
||||||
|
if (activeToken) markActivity()
|
||||||
|
}
|
||||||
|
const token = localStorage.getItem(authStorageKeys.token)
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
const isAuthenticationRequest =
|
const isAuthenticationRequest =
|
||||||
error.config?.url?.endsWith('/auth/login') ||
|
error.config?.url?.endsWith('/auth/login') ||
|
||||||
|
error.config?.url?.endsWith('/auth/refresh') ||
|
||||||
|
error.config?.url?.endsWith('/auth/logout') ||
|
||||||
error.config?.url?.endsWith('/auth/sso/exchange') ||
|
error.config?.url?.endsWith('/auth/sso/exchange') ||
|
||||||
error.config?.url?.endsWith('/auth/sso/bind')
|
error.config?.url?.endsWith('/auth/sso/bind')
|
||||||
|
const retryableConfig = error.config as
|
||||||
|
(typeof error.config & { _jiaowuRetried?: boolean }) | undefined
|
||||||
|
if (error.response?.status === 401 && !isAuthenticationRequest &&
|
||||||
|
!retryableConfig?._jiaowuRetried) {
|
||||||
|
const token = await refreshAuthSession()
|
||||||
|
if (token && retryableConfig) {
|
||||||
|
retryableConfig._jiaowuRetried = true
|
||||||
|
retryableConfig.headers.Authorization = `Bearer ${token}`
|
||||||
|
return http.request(retryableConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (error.response?.status === 401 && !isAuthenticationRequest) {
|
if (error.response?.status === 401 && !isAuthenticationRequest) {
|
||||||
localStorage.removeItem('jiaowu_token')
|
clearAuthSession()
|
||||||
localStorage.removeItem('jiaowu_user')
|
|
||||||
goLogin(location.pathname + location.search + location.hash)
|
goLogin(location.pathname + location.search + location.hash)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'jiaowu_token'
|
||||||
|
const REFRESH_TOKEN_KEY = 'jiaowu_refresh_token'
|
||||||
|
const ACCESS_EXPIRES_KEY = 'jiaowu_access_expires_at'
|
||||||
|
const SESSION_EXPIRES_KEY = 'jiaowu_session_expires_at'
|
||||||
|
const USER_KEY = 'jiaowu_user'
|
||||||
|
const LAST_ACTIVITY_KEY = 'jiaowu_last_activity_at'
|
||||||
|
const WEB_IDLE_MILLISECONDS = 30 * 60 * 1000
|
||||||
|
const REFRESH_AHEAD_MILLISECONDS = 60 * 1000
|
||||||
|
const WEB_SLIDING_TOUCH_MILLISECONDS = 60 * 1000
|
||||||
|
|
||||||
|
export interface AuthSessionPayload {
|
||||||
|
token: string
|
||||||
|
accessTokenExpiresAt: string
|
||||||
|
refreshToken: string
|
||||||
|
sessionExpiresAt: string
|
||||||
|
user: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isNativeApp = () => Capacitor.isNativePlatform()
|
||||||
|
|
||||||
|
export function saveAuthSession(payload: AuthSessionPayload, recordActivity = true) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, payload.token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken)
|
||||||
|
localStorage.setItem(ACCESS_EXPIRES_KEY, payload.accessTokenExpiresAt)
|
||||||
|
localStorage.setItem(SESSION_EXPIRES_KEY, payload.sessionExpiresAt)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(payload.user))
|
||||||
|
if (recordActivity) markActivity()
|
||||||
|
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthSession(notifyExpired = false) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(ACCESS_EXPIRES_KEY)
|
||||||
|
localStorage.removeItem(SESSION_EXPIRES_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
localStorage.removeItem(LAST_ACTIVITY_KEY)
|
||||||
|
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||||
|
if (notifyExpired) window.dispatchEvent(new Event('mingxu-session-expired'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markActivity() {
|
||||||
|
localStorage.setItem(LAST_ACTIVITY_KEY, String(Date.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExceededWebIdleTimeout(now = Date.now()) {
|
||||||
|
if (isNativeApp()) return false
|
||||||
|
const lastActivity = Number(localStorage.getItem(LAST_ACTIVITY_KEY) ?? 0)
|
||||||
|
return lastActivity > 0 && now - lastActivity >= WEB_IDLE_MILLISECONDS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasLocallyExpired(now = Date.now()) {
|
||||||
|
const sessionExpiresAt = Date.parse(localStorage.getItem(SESSION_EXPIRES_KEY) ?? '')
|
||||||
|
return hasExceededWebIdleTimeout(now) ||
|
||||||
|
(Number.isFinite(sessionExpiresAt) && sessionExpiresAt <= now)
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPromise: Promise<string | null> | null = null
|
||||||
|
|
||||||
|
export function refreshAuthSession(): Promise<string | null> {
|
||||||
|
if (refreshPromise) return refreshPromise
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||||
|
if (!refreshToken || hasLocallyExpired()) {
|
||||||
|
clearAuthSession(true)
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPromise = axios.post<AuthSessionPayload>(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL ?? '/api'}/auth/refresh`,
|
||||||
|
{ refreshToken },
|
||||||
|
{ timeout: 15000 },
|
||||||
|
).then(({ data }) => {
|
||||||
|
saveAuthSession(data, false)
|
||||||
|
return data.token
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
const currentRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||||
|
if (currentRefreshToken && currentRefreshToken !== refreshToken) {
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
if (axios.isAxiosError(error) &&
|
||||||
|
error.response &&
|
||||||
|
[400, 401, 403].includes(error.response.status)) {
|
||||||
|
clearAuthSession(true)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}).finally(() => {
|
||||||
|
refreshPromise = null
|
||||||
|
})
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshIfNeeded(isCurrentRequestActivity = false) {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
if (!token) return null
|
||||||
|
if (hasLocallyExpired()) {
|
||||||
|
clearAuthSession(true)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const expiresAt = Date.parse(localStorage.getItem(ACCESS_EXPIRES_KEY) ?? '')
|
||||||
|
const sessionExpiresAt = Date.parse(localStorage.getItem(SESSION_EXPIRES_KEY) ?? '')
|
||||||
|
const lastActivity = Number(localStorage.getItem(LAST_ACTIVITY_KEY) ?? 0)
|
||||||
|
const now = Date.now()
|
||||||
|
const hasRecentWebActivity = !isNativeApp() &&
|
||||||
|
(isCurrentRequestActivity || now - lastActivity <= WEB_SLIDING_TOUCH_MILLISECONDS)
|
||||||
|
const webSessionNeedsSlidingTouch = hasRecentWebActivity &&
|
||||||
|
Number.isFinite(sessionExpiresAt) &&
|
||||||
|
sessionExpiresAt - now <= WEB_IDLE_MILLISECONDS - WEB_SLIDING_TOUCH_MILLISECONDS
|
||||||
|
if (!Number.isFinite(expiresAt) ||
|
||||||
|
expiresAt - now <= REFRESH_AHEAD_MILLISECONDS ||
|
||||||
|
webSessionNeedsSlidingTouch) {
|
||||||
|
return refreshAuthSession()
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeAuthSession() {
|
||||||
|
if (!localStorage.getItem(TOKEN_KEY)) return
|
||||||
|
if (hasLocallyExpired()) {
|
||||||
|
clearAuthSession(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastActivityWrite = 0
|
||||||
|
const recordActivity = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastActivityWrite < 5000) return
|
||||||
|
lastActivityWrite = now
|
||||||
|
markActivity()
|
||||||
|
}
|
||||||
|
const activityEvents: Array<keyof WindowEventMap> = [
|
||||||
|
'pointerdown',
|
||||||
|
'keydown',
|
||||||
|
'touchstart',
|
||||||
|
'scroll',
|
||||||
|
]
|
||||||
|
activityEvents.forEach(event =>
|
||||||
|
window.addEventListener(event, recordActivity, { passive: true }))
|
||||||
|
|
||||||
|
window.setInterval(() => {
|
||||||
|
if (!localStorage.getItem(TOKEN_KEY)) return
|
||||||
|
if (hasLocallyExpired()) {
|
||||||
|
clearAuthSession(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
void refreshIfNeeded().catch(() => undefined)
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
void refreshIfNeeded(true).catch(() => undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
window.addEventListener('online', () =>
|
||||||
|
void refreshIfNeeded(true).catch(() => undefined))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStorageKeys = {
|
||||||
|
token: TOKEN_KEY,
|
||||||
|
refreshToken: REFRESH_TOKEN_KEY,
|
||||||
|
user: USER_KEY,
|
||||||
|
}
|
||||||
@@ -6,11 +6,17 @@ import router from './router'
|
|||||||
import { initializeAppUpdates } from './services/appUpdates'
|
import { initializeAppUpdates } from './services/appUpdates'
|
||||||
import { initializeNativeHome } from './services/nativeHome'
|
import { initializeNativeHome } from './services/nativeHome'
|
||||||
import { setRouter } from './utils/navigate'
|
import { setRouter } from './utils/navigate'
|
||||||
|
import { initializeAuthSession } from './auth/session'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
setRouter(router)
|
setRouter(router)
|
||||||
|
window.addEventListener('mingxu-session-expired', () => {
|
||||||
|
const returnUrl = location.pathname + location.search + location.hash
|
||||||
|
void router.push({ name: 'login', query: { redirect: returnUrl } })
|
||||||
|
})
|
||||||
|
initializeAuthSession()
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
void initializeAppUpdates()
|
void initializeAppUpdates()
|
||||||
initializeNativeHome(router)
|
initializeNativeHome(router)
|
||||||
|
|||||||
+36
-18
@@ -1,6 +1,12 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import http from '../api/http'
|
import http from '../api/http'
|
||||||
|
import {
|
||||||
|
authStorageKeys,
|
||||||
|
clearAuthSession,
|
||||||
|
isNativeApp,
|
||||||
|
saveAuthSession,
|
||||||
|
} from '../auth/session'
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,54 +18,66 @@ export interface CurrentUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref(localStorage.getItem('jiaowu_token') ?? '')
|
const token = ref(localStorage.getItem(authStorageKeys.token) ?? '')
|
||||||
const saved = localStorage.getItem('jiaowu_user')
|
const saved = localStorage.getItem(authStorageKeys.user)
|
||||||
const user = ref<CurrentUser | null>(saved ? JSON.parse(saved) : null)
|
const user = ref<CurrentUser | null>(saved ? JSON.parse(saved) : null)
|
||||||
const isLoggedIn = computed(() => Boolean(token.value))
|
const isLoggedIn = computed(() => Boolean(token.value))
|
||||||
const isSuperAdmin = computed(() => user.value?.roles.includes('SuperAdmin') ?? false)
|
const isSuperAdmin = computed(() => user.value?.roles.includes('SuperAdmin') ?? false)
|
||||||
|
|
||||||
async function login(userName: string, password: string) {
|
async function login(userName: string, password: string) {
|
||||||
const { data } = await http.post('/auth/login', { userName, password })
|
const { data } = await http.post('/auth/login', {
|
||||||
|
userName,
|
||||||
|
password,
|
||||||
|
isNativeApp: isNativeApp(),
|
||||||
|
})
|
||||||
token.value = data.token
|
token.value = data.token
|
||||||
user.value = data.user
|
user.value = data.user
|
||||||
localStorage.setItem('jiaowu_token', data.token)
|
saveAuthSession(data)
|
||||||
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
|
|
||||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeSso(code: string) {
|
async function exchangeSso(code: string) {
|
||||||
const { data } = await http.post('/auth/sso/exchange', { code })
|
const { data } = await http.post('/auth/sso/exchange', {
|
||||||
|
code,
|
||||||
|
isNativeApp: isNativeApp(),
|
||||||
|
})
|
||||||
token.value = data.token
|
token.value = data.token
|
||||||
user.value = data.user
|
user.value = data.user
|
||||||
localStorage.setItem('jiaowu_token', data.token)
|
saveAuthSession(data)
|
||||||
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
|
|
||||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bindSso(code: string, userName: string, password: string) {
|
async function bindSso(code: string, userName: string, password: string) {
|
||||||
const { data } = await http.post('/auth/sso/bind', { code, userName, password })
|
const { data } = await http.post('/auth/sso/bind', {
|
||||||
|
code,
|
||||||
|
userName,
|
||||||
|
password,
|
||||||
|
isNativeApp: isNativeApp(),
|
||||||
|
})
|
||||||
token.value = data.token
|
token.value = data.token
|
||||||
user.value = data.user
|
user.value = data.user
|
||||||
localStorage.setItem('jiaowu_token', data.token)
|
saveAuthSession(data)
|
||||||
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
|
|
||||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!token.value) return
|
if (!token.value) return
|
||||||
const { data } = await http.get('/auth/me')
|
const { data } = await http.get('/auth/me')
|
||||||
user.value = data
|
user.value = data
|
||||||
localStorage.setItem('jiaowu_user', JSON.stringify(data))
|
localStorage.setItem(authStorageKeys.user, JSON.stringify(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
|
const refreshToken = localStorage.getItem(authStorageKeys.refreshToken)
|
||||||
|
if (refreshToken) void http.post('/auth/logout', { refreshToken }).catch(() => undefined)
|
||||||
token.value = ''
|
token.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
localStorage.removeItem('jiaowu_token')
|
clearAuthSession()
|
||||||
localStorage.removeItem('jiaowu_user')
|
|
||||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mingxu-auth-changed', () => {
|
||||||
|
token.value = localStorage.getItem(authStorageKeys.token) ?? ''
|
||||||
|
const currentUser = localStorage.getItem(authStorageKeys.user)
|
||||||
|
user.value = currentUser ? JSON.parse(currentUser) : null
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
|
|||||||
Reference in New Issue
Block a user