滑动续期与自动刷新:

Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。
App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。
普通登录和 SSO 使用同一策略。
刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。
网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
This commit is contained in:
2026-08-03 19:20:39 +08:00 Unverified
parent 6bee29a351
commit 5ec62a03ca
24 changed files with 6986 additions and 75 deletions
+3 -1
View File
@@ -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
View File
@@ -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}"
+71 -13
View File
@@ -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,
+21 -21
View File
@@ -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);""",
@@ -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");
}
}
}
@@ -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)
+19
View File
@@ -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",
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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,
+22 -3
View File
@@ -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();
}
}
}
+22 -4
View File
@@ -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;
} }
} }
+6 -2
View File
@@ -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
View File
@@ -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)
+167
View File
@@ -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
View File
@@ -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
View File
@@ -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,