Rename projects to FileShare
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
using FileShare_Common.Core;
|
||||
using FileShare_EFCore.Database;
|
||||
using FileShare_EFCore.Models;
|
||||
using FileShare_Services.Core;
|
||||
using FileShare_Services.Services.AuthService;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FileShare_API.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// API 鉴权端点服务,实现 <see cref="IApiAuthEndpointService"/>,
|
||||
/// 处理登录、刷新 Token 和登出操作,使用 JWT 与 Refresh Token 机制。
|
||||
/// </summary>
|
||||
public sealed class ApiAuthEndpointService(
|
||||
AppDataContext db,
|
||||
JwtTokenService jwtTokenService,
|
||||
RefreshTokenService refreshTokenService) : IApiAuthEndpointService
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理用户登录请求。根据账号(邮箱或用户名)查找或创建用户,
|
||||
/// 生成 JWT Access Token 和 Refresh Token 并返回。
|
||||
/// </summary>
|
||||
/// <param name="ctx">服务端点上下文,包含请求体、请求头等信息。</param>
|
||||
/// <returns>包含 AccessToken、RefreshToken 及过期时间的认证响应。</returns>
|
||||
public async Task<IApiResponse> LoginAsync(ApiLoginRequest request, ServiceEndpointContext ctx)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Account))
|
||||
{
|
||||
ctx.StatusCode = 400;
|
||||
return ResponseHelper.Failure(400, "账号不能为空");
|
||||
}
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(
|
||||
x => x.Email == request.Account || x.Name == request.Account);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
user = new UserEntity
|
||||
{
|
||||
Name = request.Account,
|
||||
Email = request.Account.Contains('@') ? request.Account : null,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var roles = NormalizeRoles(request.Roles);
|
||||
var accessToken = jwtTokenService.CreateAccessToken(user, roles);
|
||||
var refreshToken = await refreshTokenService.CreateAsync(
|
||||
user.Id,
|
||||
ctx.GetHeader("User-Agent"),
|
||||
GetRemoteIpAddress(ctx));
|
||||
|
||||
return ResponseHelper.Ok(new AuthTokenResponse(
|
||||
accessToken.Token,
|
||||
refreshToken.Token,
|
||||
accessToken.ExpiresAt,
|
||||
refreshToken.Entity.ExpiresAt,
|
||||
roles), "登录成功");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 Refresh Token 轮换新的 Access Token 和 Refresh Token。
|
||||
/// 旧的 Refresh Token 会被撤销并替换。
|
||||
/// </summary>
|
||||
/// <param name="ctx">服务端点上下文,包含请求体中的 RefreshToken。</param>
|
||||
/// <returns>新的 Token 对;若 Refresh Token 无效则返回 401 错误。</returns>
|
||||
public async Task<IApiResponse> RefreshAsync(ApiRefreshTokenRequest request, ServiceEndpointContext ctx)
|
||||
{
|
||||
var rotated = await refreshTokenService.RotateAsync(
|
||||
request.RefreshToken,
|
||||
ctx.GetHeader("User-Agent"),
|
||||
GetRemoteIpAddress(ctx));
|
||||
|
||||
if (rotated is null)
|
||||
{
|
||||
ctx.StatusCode = 401;
|
||||
return ResponseHelper.Failure(401, "刷新 token 无效或已过期");
|
||||
}
|
||||
|
||||
var user = await db.Users.FindAsync(rotated.Value.Entity.UserId);
|
||||
if (user is null)
|
||||
{
|
||||
ctx.StatusCode = 401;
|
||||
return ResponseHelper.Failure(401, "用户不存在");
|
||||
}
|
||||
|
||||
var roles = new[] { "Admin" };
|
||||
var accessToken = jwtTokenService.CreateAccessToken(user, roles);
|
||||
|
||||
return ResponseHelper.Ok(new AuthTokenResponse(
|
||||
accessToken.Token,
|
||||
rotated.Value.Token,
|
||||
accessToken.ExpiresAt,
|
||||
rotated.Value.Entity.ExpiresAt,
|
||||
roles), "刷新成功");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理用户登出请求,撤销指定的 Refresh Token。
|
||||
/// </summary>
|
||||
/// <param name="ctx">服务端点上下文,包含请求体中的 RefreshToken。</param>
|
||||
/// <returns>登出成功的响应。</returns>
|
||||
public async Task<IApiResponse> LogoutAsync(ApiLogoutRequest request, ServiceEndpointContext ctx)
|
||||
{
|
||||
await refreshTokenService.RevokeAsync(request.RefreshToken);
|
||||
return ResponseHelper.Succeed("退出成功");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从上下文的 Items 中提取 ASP.NET Core HttpContext,并获取客户端远程 IP 地址。
|
||||
/// </summary>
|
||||
/// <param name="ctx">服务端点上下文。</param>
|
||||
/// <returns>客户端 IP 地址字符串;若无法获取则返回 null。</returns>
|
||||
private static string? GetRemoteIpAddress(ServiceEndpointContext ctx)
|
||||
{
|
||||
return ctx.Items.TryGetValue("HttpContext", out var value) && value is HttpContext httpContext
|
||||
? httpContext.Connection.RemoteIpAddress?.ToString()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规范化角色数组:去空白、去重(忽略大小写),为空时默认返回 Admin 角色。
|
||||
/// </summary>
|
||||
/// <param name="roles">原始角色数组,可为 null。</param>
|
||||
/// <returns>规范化后的角色数组。</returns>
|
||||
private static string[] NormalizeRoles(string[]? roles)
|
||||
{
|
||||
var normalized = roles?
|
||||
.Where(role => !string.IsNullOrWhiteSpace(role))
|
||||
.Select(role => role.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return normalized is { Length: > 0 } ? normalized : ["Admin"];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace FileShare_API.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// JWT 鉴权配置选项,从 appsettings.json 的 Jwt 节绑定。
|
||||
/// </summary>
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置 Token 签发者。
|
||||
/// </summary>
|
||||
public string Issuer { get; set; } = "FileShare-API";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 Token 受众。
|
||||
/// </summary>
|
||||
public string Audience { get; set; } = "FileShare-Client";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置签名密钥(至少 32 字节)。
|
||||
/// </summary>
|
||||
public string SigningKey { get; set; } = "change-this-development-signing-key-at-least-32-bytes";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 Access Token 有效期(分钟),默认 60 分钟。
|
||||
/// </summary>
|
||||
public int AccessTokenMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 Refresh Token 有效期(天),默认 30 天。
|
||||
/// </summary>
|
||||
public int RefreshTokenDays { get; set; } = 30;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using FileShare_EFCore.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace FileShare_API.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// JWT Token 服务,负责创建包含用户声明和角色的 Access Token。
|
||||
/// </summary>
|
||||
public sealed class JwtTokenService(IOptions<JwtOptions> options)
|
||||
{
|
||||
/// <summary>
|
||||
/// JWT 配置选项。
|
||||
/// </summary>
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 创建包含用户声明和角色的 JWT Access Token。
|
||||
/// </summary>
|
||||
/// <param name="user">用户实体。</param>
|
||||
/// <param name="roles">角色集合。</param>
|
||||
/// <returns>包含 Token 字符串和过期时间的元组。</returns>
|
||||
public (string Token, DateTime ExpiresAt) CreateAccessToken(UserEntity user, IReadOnlyCollection<string> roles)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.Name ?? user.Email ?? $"user-{user.Id}"),
|
||||
new("auth_type", "api-jwt"),
|
||||
};
|
||||
|
||||
foreach (var role in roles.Where(role => !string.IsNullOrWhiteSpace(role)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return (new JwtSecurityTokenHandler().WriteToken(jwt), expiresAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using FileShare_EFCore.Database;
|
||||
using FileShare_EFCore.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace FileShare_API.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Refresh Token 服务,负责创建、查找、撤销和轮换 Refresh Token,
|
||||
/// Token 原文经 SHA256 哈希后存入数据库以保证安全性。
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenService(AppDataContext db, IOptions<JwtOptions> options)
|
||||
{
|
||||
/// <summary>
|
||||
/// JWT 配置选项。
|
||||
/// </summary>
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个新的 Refresh Token,生成随机 Token 原文并存储其哈希到数据库。
|
||||
/// </summary>
|
||||
/// <param name="userId">关联的用户 ID。</param>
|
||||
/// <param name="device">创建设备标识(如 User-Agent)。</param>
|
||||
/// <param name="ipAddress">客户端 IP 地址。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>包含 Token 原文和实体记录的元组。</returns>
|
||||
public async Task<(string Token, ApiRefreshTokenEntity Entity)> CreateAsync(
|
||||
int userId,
|
||||
string? device,
|
||||
string? ipAddress,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||
var entity = new ApiRefreshTokenEntity
|
||||
{
|
||||
UserId = userId,
|
||||
TokenHash = HashToken(token),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenDays),
|
||||
Device = device,
|
||||
IpAddress = ipAddress,
|
||||
};
|
||||
|
||||
db.ApiRefreshTokens.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return (token, entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找有效的 Refresh Token 实体。Token 原文会被哈希后查询数据库,
|
||||
/// 仅返回未过期且未被撤销的 Token。
|
||||
/// </summary>
|
||||
/// <param name="token">Refresh Token 原文。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>有效的 Token 实体;若无效或不存在则返回 null。</returns>
|
||||
public async Task<ApiRefreshTokenEntity?> FindActiveAsync(string? token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hash = HashToken(token);
|
||||
var entity = await db.ApiRefreshTokens.FirstOrDefaultAsync(x => x.TokenHash == hash, cancellationToken);
|
||||
return entity?.IsActive == true ? entity : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销指定的 Refresh Token,将其 RevokedAt 设为当前时间。
|
||||
/// </summary>
|
||||
/// <param name="token">要撤销的 Refresh Token 原文。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
public async Task RevokeAsync(string? token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await FindActiveAsync(token, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entity.RevokedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 轮换 Refresh Token:撤销旧的并创建新的,将新 Token 的哈希关联到旧记录。
|
||||
/// </summary>
|
||||
/// <param name="token">旧的 Refresh Token 原文。</param>
|
||||
/// <param name="device">当前设备标识。</param>
|
||||
/// <param name="ipAddress">当前客户端 IP 地址。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>新的 Token 对;若旧 Token 无效则返回 null。</returns>
|
||||
public async Task<(string Token, ApiRefreshTokenEntity Entity)?> RotateAsync(
|
||||
string? token,
|
||||
string? device,
|
||||
string? ipAddress,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await FindActiveAsync(token, cancellationToken);
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var next = await CreateAsync(current.UserId, device, ipAddress, cancellationToken);
|
||||
current.RevokedAt = DateTime.UtcNow;
|
||||
current.ReplacedByTokenHash = next.Entity.TokenHash;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对 Token 原文进行 SHA256 哈希,返回十六进制字符串。
|
||||
/// </summary>
|
||||
/// <param name="token">Token 原文。</param>
|
||||
/// <returns>SHA256 哈希后的十六进制字符串。</returns>
|
||||
private static string HashToken(string token)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user