滑动续期与自动刷新:
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user