滑动续期与自动刷新:
Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。 App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。 普通登录和 SSO 使用同一策略。 刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。 网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
This commit is contained in:
@@ -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 Audience { get; set; } = "Jiaowu.Web";
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
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>
|
||||
{
|
||||
@@ -37,13 +39,16 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
|
||||
expires: expiresAt,
|
||||
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>();
|
||||
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
||||
Set<AppUpdateRelease>();
|
||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
ModelConfigurationBuilder configurationBuilder)
|
||||
@@ -163,6 +164,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
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<College>(builder);
|
||||
ConfigureCatalog<Major>(builder);
|
||||
|
||||
@@ -78,6 +78,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260729_41_app_update_releases";
|
||||
private const string IntegratedExperimentSchedulingMigration =
|
||||
"20260802_42_integrated_experiment_scheduling";
|
||||
private const string RefreshSessionsMigration =
|
||||
"20260803_43_refresh_sessions";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -593,6 +595,10 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
? []
|
||||
: IntegratedExperimentSchedulingStatements,
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
RefreshSessionsMigration,
|
||||
RefreshSessionsStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
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 =
|
||||
[
|
||||
"""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");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
@@ -4133,6 +4134,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -5742,6 +5792,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
|
||||
|
||||
Reference in New Issue
Block a user