chore: 优化代码注释
This commit is contained in:
@@ -39,10 +39,10 @@ public sealed class AuthController : BaseApiController
|
|||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ApiResponse<TokenResponse>>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
|
public async Task<ApiResponse<TokenResponse>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var response = await _authService.LoginAsync(request, cancellationToken);
|
var response = await _authService.LoginAsync(request, cancellationToken);
|
||||||
return Ok(ApiResponse<TokenResponse>.Ok(response));
|
return ApiResponse<TokenResponse>.Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -51,10 +51,10 @@ public sealed class AuthController : BaseApiController
|
|||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ApiResponse<TokenResponse>>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var response = await _authService.RefreshTokenAsync(request, cancellationToken);
|
var response = await _authService.RefreshTokenAsync(request, cancellationToken);
|
||||||
return Ok(ApiResponse<TokenResponse>.Ok(response));
|
return ApiResponse<TokenResponse>.Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -63,15 +63,16 @@ public sealed class AuthController : BaseApiController
|
|||||||
[HttpGet("profile")]
|
[HttpGet("profile")]
|
||||||
[PermissionAuthorize("identity:profile:read")]
|
[PermissionAuthorize("identity:profile:read")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ApiResponse<CurrentUserProfile>>> GetProfile(CancellationToken cancellationToken)
|
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<ApiResponse<CurrentUserProfile>> GetProfile(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
if (userId == Guid.Empty)
|
if (userId == Guid.Empty)
|
||||||
{
|
{
|
||||||
return Unauthorized(ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"));
|
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
|
||||||
}
|
}
|
||||||
|
|
||||||
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
|
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
|
||||||
return Ok(ApiResponse<CurrentUserProfile>.Ok(profile));
|
return ApiResponse<CurrentUserProfile>.Ok(profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -20,9 +21,9 @@ public class HealthController : BaseApiController
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
public IActionResult Get()
|
public ApiResponse<object> Get()
|
||||||
{
|
{
|
||||||
var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow };
|
var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow };
|
||||||
return Ok(ApiResponse<object>.Ok(payload));
|
return ApiResponse<object>.Ok(payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||||
using TakeoutSaaS.Application.Identity.Contracts;
|
using TakeoutSaaS.Application.Identity.Contracts;
|
||||||
@@ -20,6 +17,10 @@ public sealed class AuthController : BaseApiController
|
|||||||
{
|
{
|
||||||
private readonly IMiniAuthService _authService;
|
private readonly IMiniAuthService _authService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 小程序登录认证
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authService"></param>
|
||||||
public AuthController(IMiniAuthService authService)
|
public AuthController(IMiniAuthService authService)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
@@ -31,10 +32,10 @@ public sealed class AuthController : BaseApiController
|
|||||||
[HttpPost("wechat/login")]
|
[HttpPost("wechat/login")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ApiResponse<TokenResponse>>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
|
public async Task<ApiResponse<TokenResponse>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var response = await _authService.LoginWithWeChatAsync(request, cancellationToken);
|
var response = await _authService.LoginWithWeChatAsync(request, cancellationToken);
|
||||||
return Ok(ApiResponse<TokenResponse>.Ok(response));
|
return ApiResponse<TokenResponse>.Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -43,9 +44,9 @@ public sealed class AuthController : BaseApiController
|
|||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ApiResponse<TokenResponse>>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var response = await _authService.RefreshTokenAsync(request, cancellationToken);
|
var response = await _authService.RefreshTokenAsync(request, cancellationToken);
|
||||||
return Ok(ApiResponse<TokenResponse>.Ok(response));
|
return ApiResponse<TokenResponse>.Ok(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -20,9 +21,9 @@ public class HealthController : BaseApiController
|
|||||||
/// <returns>健康状态</returns>
|
/// <returns>健康状态</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
public IActionResult Get()
|
public ApiResponse<object> Get()
|
||||||
{
|
{
|
||||||
var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow };
|
var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow };
|
||||||
return Ok(ApiResponse<object>.Ok(payload));
|
return ApiResponse<object>.Ok(payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,15 +37,16 @@ public sealed class MeController : BaseApiController
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ApiResponse<CurrentUserProfile>>> Get(CancellationToken cancellationToken)
|
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<ApiResponse<CurrentUserProfile>> Get(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
if (userId == Guid.Empty)
|
if (userId == Guid.Empty)
|
||||||
{
|
{
|
||||||
return Unauthorized(ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"));
|
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
|
||||||
}
|
}
|
||||||
|
|
||||||
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
|
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
|
||||||
return Ok(ApiResponse<CurrentUserProfile>.Ok(profile));
|
return ApiResponse<CurrentUserProfile>.Ok(profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using TakeoutSaaS.Application.Identity.Extensions;
|
|
||||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
|
||||||
using TakeoutSaaS.Module.Tenancy;
|
using TakeoutSaaS.Module.Tenancy;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
using TakeoutSaaS.Shared.Web.Extensions;
|
using TakeoutSaaS.Shared.Web.Extensions;
|
||||||
@@ -15,7 +7,7 @@ using TakeoutSaaS.Shared.Web.Swagger;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Host.UseSerilog((context, _, configuration) =>
|
builder.Host.UseSerilog((_, _, configuration) =>
|
||||||
{
|
{
|
||||||
configuration
|
configuration
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -20,9 +21,9 @@ public class HealthController : BaseApiController
|
|||||||
/// <returns>健康状态</returns>
|
/// <returns>健康状态</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
public IActionResult Get()
|
public ApiResponse<object> Get()
|
||||||
{
|
{
|
||||||
var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow };
|
var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow };
|
||||||
return Ok(ApiResponse<object>.Ok(payload));
|
return ApiResponse<object>.Ok(payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using TakeoutSaaS.Module.Tenancy;
|
using TakeoutSaaS.Module.Tenancy;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
@@ -13,7 +7,7 @@ using TakeoutSaaS.Shared.Web.Swagger;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Host.UseSerilog((context, _, configuration) =>
|
builder.Host.UseSerilog((_, _, configuration) =>
|
||||||
{
|
{
|
||||||
configuration
|
configuration
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,12 +5,43 @@ namespace TakeoutSaaS.Application.Identity.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CurrentUserProfile
|
public sealed class CurrentUserProfile
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 用户 ID。
|
||||||
|
/// </summary>
|
||||||
public Guid UserId { get; init; }
|
public Guid UserId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 登录账号。
|
||||||
|
/// </summary>
|
||||||
public string Account { get; init; } = string.Empty;
|
public string Account { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示名称。
|
||||||
|
/// </summary>
|
||||||
public string DisplayName { get; init; } = string.Empty;
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所属租户 ID。
|
||||||
|
/// </summary>
|
||||||
public Guid TenantId { get; init; }
|
public Guid TenantId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所属商户 ID(平台管理员为空)。
|
||||||
|
/// </summary>
|
||||||
public Guid? MerchantId { get; init; }
|
public Guid? MerchantId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 角色集合。
|
||||||
|
/// </summary>
|
||||||
public string[] Roles { get; init; } = Array.Empty<string>();
|
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 权限集合。
|
||||||
|
/// </summary>
|
||||||
public string[] Permissions { get; init; } = Array.Empty<string>();
|
public string[] Permissions { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像地址(可选)。
|
||||||
|
/// </summary>
|
||||||
public string? Avatar { get; init; }
|
public string? Avatar { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,10 +5,33 @@ namespace TakeoutSaaS.Application.Identity.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class TokenResponse
|
public class TokenResponse
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 访问令牌(JWT)。
|
||||||
|
/// </summary>
|
||||||
public string AccessToken { get; init; } = string.Empty;
|
public string AccessToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 访问令牌过期时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
public DateTime AccessTokenExpiresAt { get; init; }
|
public DateTime AccessTokenExpiresAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新令牌。
|
||||||
|
/// </summary>
|
||||||
public string RefreshToken { get; init; } = string.Empty;
|
public string RefreshToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新令牌过期时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
public DateTime RefreshTokenExpiresAt { get; init; }
|
public DateTime RefreshTokenExpiresAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前用户档案(可选,首次登录时可能为空)。
|
||||||
|
/// </summary>
|
||||||
public CurrentUserProfile? User { get; init; }
|
public CurrentUserProfile? User { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否为新用户(首次登录)。
|
||||||
|
/// </summary>
|
||||||
public bool IsNewUser { get; init; }
|
public bool IsNewUser { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Models;
|
namespace TakeoutSaaS.Application.Identity.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 刷新令牌描述。
|
/// 刷新令牌描述:存储刷新令牌的元数据信息。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record class RefreshTokenDescriptor(
|
/// <param name="Token">刷新令牌值</param>
|
||||||
|
/// <param name="UserId">关联的用户 ID</param>
|
||||||
|
/// <param name="ExpiresAt">过期时间(UTC)</param>
|
||||||
|
/// <param name="Revoked">是否已撤销</param>
|
||||||
|
public sealed record RefreshTokenDescriptor(
|
||||||
string Token,
|
string Token,
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
DateTime ExpiresAt,
|
DateTime ExpiresAt,
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||||
using TakeoutSaaS.Application.Identity.Contracts;
|
using TakeoutSaaS.Application.Identity.Contracts;
|
||||||
@@ -14,59 +11,75 @@ namespace TakeoutSaaS.Application.Identity.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 管理后台认证服务实现。
|
/// 管理后台认证服务实现。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AdminAuthService : IAdminAuthService
|
public sealed class AdminAuthService(
|
||||||
|
IIdentityUserRepository userRepository,
|
||||||
|
IPasswordHasher<IdentityUser> passwordHasher,
|
||||||
|
IJwtTokenService jwtTokenService,
|
||||||
|
IRefreshTokenStore refreshTokenStore) : IAdminAuthService
|
||||||
{
|
{
|
||||||
private readonly IIdentityUserRepository _userRepository;
|
/// <summary>
|
||||||
private readonly IPasswordHasher<IdentityUser> _passwordHasher;
|
/// 管理后台登录:验证账号密码并生成令牌。
|
||||||
private readonly IJwtTokenService _jwtTokenService;
|
/// </summary>
|
||||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
/// <param name="request">登录请求</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
public AdminAuthService(
|
/// <returns>令牌响应</returns>
|
||||||
IIdentityUserRepository userRepository,
|
/// <exception cref="BusinessException">账号或密码错误时抛出</exception>
|
||||||
IPasswordHasher<IdentityUser> passwordHasher,
|
|
||||||
IJwtTokenService jwtTokenService,
|
|
||||||
IRefreshTokenStore refreshTokenStore)
|
|
||||||
{
|
|
||||||
_userRepository = userRepository;
|
|
||||||
_passwordHasher = passwordHasher;
|
|
||||||
_jwtTokenService = jwtTokenService;
|
|
||||||
_refreshTokenStore = refreshTokenStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
|
public async Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var user = await _userRepository.FindByAccountAsync(request.Account, cancellationToken)
|
// 1. 根据账号查找用户
|
||||||
|
var user = await userRepository.FindByAccountAsync(request.Account, cancellationToken)
|
||||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
||||||
|
|
||||||
var result = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
// 2. 验证密码(使用 ASP.NET Core Identity 的密码哈希器)
|
||||||
|
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||||
if (result == PasswordVerificationResult.Failed)
|
if (result == PasswordVerificationResult.Failed)
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 构建用户档案并生成令牌
|
||||||
var profile = BuildProfile(user);
|
var profile = BuildProfile(user);
|
||||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">刷新令牌请求</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>新的令牌响应</returns>
|
||||||
|
/// <exception cref="BusinessException">刷新令牌无效、已过期或用户不存在时抛出</exception>
|
||||||
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
// 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销)
|
||||||
|
var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||||
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
// 2. 根据用户 ID 查找用户
|
||||||
|
var user = await userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
||||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
||||||
|
|
||||||
await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
// 3. 撤销旧刷新令牌(防止重复使用)
|
||||||
|
await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
||||||
|
|
||||||
|
// 4. 生成新的令牌对
|
||||||
var profile = BuildProfile(user);
|
var profile = BuildProfile(user);
|
||||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取用户档案。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">用户 ID</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>用户档案</returns>
|
||||||
|
/// <exception cref="BusinessException">用户不存在时抛出</exception>
|
||||||
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var user = await _userRepository.FindByIdAsync(userId, cancellationToken)
|
var user = await userRepository.FindByIdAsync(userId, cancellationToken)
|
||||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||||
|
|
||||||
return BuildProfile(user);
|
return BuildProfile(user);
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
using System;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||||
using TakeoutSaaS.Application.Identity.Contracts;
|
using TakeoutSaaS.Application.Identity.Contracts;
|
||||||
@@ -16,90 +13,117 @@ namespace TakeoutSaaS.Application.Identity.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 小程序认证服务实现。
|
/// 小程序认证服务实现。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MiniAuthService : IMiniAuthService
|
public sealed class MiniAuthService(
|
||||||
|
IWeChatAuthService weChatAuthService,
|
||||||
|
IMiniUserRepository miniUserRepository,
|
||||||
|
IJwtTokenService jwtTokenService,
|
||||||
|
IRefreshTokenStore refreshTokenStore,
|
||||||
|
ILoginRateLimiter rateLimiter,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
ITenantProvider tenantProvider) : IMiniAuthService
|
||||||
{
|
{
|
||||||
private readonly IWeChatAuthService _weChatAuthService;
|
/// <summary>
|
||||||
private readonly IMiniUserRepository _miniUserRepository;
|
/// 微信小程序登录:通过微信 code 获取用户信息并生成令牌。
|
||||||
private readonly IJwtTokenService _jwtTokenService;
|
/// </summary>
|
||||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
/// <param name="request">微信登录请求</param>
|
||||||
private readonly ILoginRateLimiter _rateLimiter;
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
/// <returns>令牌响应</returns>
|
||||||
private readonly ITenantProvider _tenantProvider;
|
/// <exception cref="BusinessException">获取微信用户信息失败、缺少租户标识时抛出</exception>
|
||||||
|
|
||||||
public MiniAuthService(
|
|
||||||
IWeChatAuthService weChatAuthService,
|
|
||||||
IMiniUserRepository miniUserRepository,
|
|
||||||
IJwtTokenService jwtTokenService,
|
|
||||||
IRefreshTokenStore refreshTokenStore,
|
|
||||||
ILoginRateLimiter rateLimiter,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
ITenantProvider tenantProvider)
|
|
||||||
{
|
|
||||||
_weChatAuthService = weChatAuthService;
|
|
||||||
_miniUserRepository = miniUserRepository;
|
|
||||||
_jwtTokenService = jwtTokenService;
|
|
||||||
_refreshTokenStore = refreshTokenStore;
|
|
||||||
_rateLimiter = rateLimiter;
|
|
||||||
_httpContextAccessor = httpContextAccessor;
|
|
||||||
_tenantProvider = tenantProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default)
|
public async Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// 1. 限流检查(基于 IP 地址)
|
||||||
var throttleKey = BuildThrottleKey();
|
var throttleKey = BuildThrottleKey();
|
||||||
await _rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
|
await rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
|
||||||
|
|
||||||
var session = await _weChatAuthService.Code2SessionAsync(request.Code, cancellationToken);
|
// 2. 通过微信 code 获取 session(OpenId、UnionId、SessionKey)
|
||||||
|
var session = await weChatAuthService.Code2SessionAsync(request.Code, cancellationToken);
|
||||||
if (string.IsNullOrWhiteSpace(session.OpenId))
|
if (string.IsNullOrWhiteSpace(session.OpenId))
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.Unauthorized, "获取微信用户信息失败");
|
throw new BusinessException(ErrorCodes.Unauthorized, "获取微信用户信息失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
// 3. 获取当前租户 ID(多租户支持)
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
if (tenantId == Guid.Empty)
|
if (tenantId == Guid.Empty)
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
|
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 获取或创建小程序用户(如果 OpenId 已存在则返回现有用户,否则创建新用户)
|
||||||
var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken);
|
var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken);
|
||||||
|
|
||||||
await _rateLimiter.ResetAsync(throttleKey, cancellationToken);
|
// 5. 登录成功后重置限流计数
|
||||||
|
await rateLimiter.ResetAsync(throttleKey, cancellationToken);
|
||||||
|
|
||||||
|
// 6. 构建用户档案并生成令牌
|
||||||
var profile = BuildProfile(user);
|
var profile = BuildProfile(user);
|
||||||
return await _jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken);
|
return await jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">刷新令牌请求</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>新的令牌响应</returns>
|
||||||
|
/// <exception cref="BusinessException">刷新令牌无效、已过期或用户不存在时抛出</exception>
|
||||||
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
// 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销)
|
||||||
|
var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||||
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
// 2. 根据用户 ID 查找用户
|
||||||
|
var user = await miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
||||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
||||||
|
|
||||||
await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
// 3. 撤销旧刷新令牌(防止重复使用)
|
||||||
|
await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
||||||
|
|
||||||
|
// 4. 生成新的令牌对
|
||||||
var profile = BuildProfile(user);
|
var profile = BuildProfile(user);
|
||||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取用户档案。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">用户 ID</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>用户档案</returns>
|
||||||
|
/// <exception cref="BusinessException">用户不存在时抛出</exception>
|
||||||
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var user = await _miniUserRepository.FindByIdAsync(userId, cancellationToken)
|
var user = await miniUserRepository.FindByIdAsync(userId, cancellationToken)
|
||||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||||
|
|
||||||
return BuildProfile(user);
|
return BuildProfile(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或绑定小程序用户:如果 OpenId 已存在则返回现有用户,否则创建新用户。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="openId">微信 OpenId</param>
|
||||||
|
/// <param name="unionId">微信 UnionId(可选)</param>
|
||||||
|
/// <param name="nickname">昵称</param>
|
||||||
|
/// <param name="avatar">头像地址(可选)</param>
|
||||||
|
/// <param name="tenantId">租户 ID</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>用户实体和是否为新用户的元组</returns>
|
||||||
private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken)
|
private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var existing = await _miniUserRepository.FindByOpenIdAsync(openId, cancellationToken);
|
// 检查用户是否已存在
|
||||||
|
var existing = await miniUserRepository.FindByOpenIdAsync(openId, cancellationToken);
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
return (existing, false);
|
return (existing, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var created = await _miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken);
|
// 创建新用户
|
||||||
|
var created = await miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken);
|
||||||
return (created, true);
|
return (created, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +142,7 @@ public sealed class MiniAuthService : IMiniAuthService
|
|||||||
|
|
||||||
private string BuildThrottleKey()
|
private string BuildThrottleKey()
|
||||||
{
|
{
|
||||||
var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
|
var ip = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
|
||||||
return $"mini-login:{ip}";
|
return $"mini-login:{ip}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
namespace TakeoutSaaS.Shared.Abstractions.Entities;
|
namespace TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 审计字段接口
|
/// 审计字段接口:提供创建时间和更新时间字段。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAuditableEntity
|
public interface IAuditableEntity
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 创建时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
DateTime CreatedAt { get; set; }
|
DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新时间(UTC),未更新时为 null。
|
||||||
|
/// </summary>
|
||||||
DateTime? UpdatedAt { get; set; }
|
DateTime? UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Shared.Abstractions.Exceptions;
|
namespace TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Diagnostics;
|
using TakeoutSaaS.Shared.Abstractions.Diagnostics;
|
||||||
|
|
||||||
@@ -8,7 +7,7 @@ namespace TakeoutSaaS.Shared.Abstractions.Results;
|
|||||||
/// 统一的 API 返回结果包装。
|
/// 统一的 API 返回结果包装。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">数据载荷类型</typeparam>
|
/// <typeparam name="T">数据载荷类型</typeparam>
|
||||||
public sealed record class ApiResponse<T>
|
public sealed record ApiResponse<T>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否成功。
|
/// 是否成功。
|
||||||
@@ -97,7 +96,7 @@ public sealed record class ApiResponse<T>
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(TraceContext.TraceId))
|
if (!string.IsNullOrWhiteSpace(TraceContext.TraceId))
|
||||||
{
|
{
|
||||||
return TraceContext.TraceId!;
|
return TraceContext.TraceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Activity.Current?.Id ?? Guid.NewGuid().ToString("N");
|
return Activity.Current?.Id ?? Guid.NewGuid().ToString("N");
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
|
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户提供者接口:用于获取当前请求的租户标识。
|
||||||
|
/// </summary>
|
||||||
public interface ITenantProvider
|
public interface ITenantProvider
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前请求的租户 ID。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>租户 ID,如果未设置则返回 Guid.Empty</returns>
|
||||||
Guid GetCurrentTenantId();
|
Guid GetCurrentTenantId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public static class ServiceCollectionExtensions
|
|||||||
.AddControllers(options =>
|
.AddControllers(options =>
|
||||||
{
|
{
|
||||||
options.Filters.Add<ValidateModelAttribute>();
|
options.Filters.Add<ValidateModelAttribute>();
|
||||||
|
options.Filters.Add<ApiResponseResultFilter>();
|
||||||
})
|
})
|
||||||
.AddNewtonsoftJson();
|
.AddNewtonsoftJson();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Shared.Web.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ApiResponse 结果过滤器:自动将 ApiResponse 转换为对应的 HTTP 状态码。
|
||||||
|
/// 使用此过滤器后,控制器可以直接返回 ApiResponse<T>,无需再包一层 Ok() 或 Unauthorized()。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiResponseResultFilter : IAsyncResultFilter
|
||||||
|
{
|
||||||
|
public Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
|
||||||
|
{
|
||||||
|
// 只处理 ObjectResult 类型的结果
|
||||||
|
if (context.Result is not ObjectResult objectResult)
|
||||||
|
{
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = objectResult.Value;
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是 ApiResponse<T> 类型
|
||||||
|
var valueType = value.GetType();
|
||||||
|
if (!IsApiResponseType(valueType))
|
||||||
|
{
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用反射获取 Success 和 Code 属性
|
||||||
|
// 注意:由于已通过 IsApiResponseType 检查,属性名是固定的
|
||||||
|
const string successPropertyName = "Success";
|
||||||
|
const string codePropertyName = "Code";
|
||||||
|
var successProperty = valueType.GetProperty(successPropertyName);
|
||||||
|
var codeProperty = valueType.GetProperty(codePropertyName);
|
||||||
|
|
||||||
|
if (successProperty == null || codeProperty == null)
|
||||||
|
{
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
var success = (bool)(successProperty.GetValue(value) ?? false);
|
||||||
|
var code = (int)(codeProperty.GetValue(value) ?? 200);
|
||||||
|
|
||||||
|
// 根据 Success 和 Code 设置 HTTP 状态码
|
||||||
|
var statusCode = success ? MapSuccessCode(code) : MapErrorCode(code);
|
||||||
|
|
||||||
|
// 更新 ObjectResult 的状态码
|
||||||
|
objectResult.StatusCode = statusCode;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsApiResponseType(Type type)
|
||||||
|
{
|
||||||
|
// 检查是否是 ApiResponse<T> 类型
|
||||||
|
if (type.IsGenericType)
|
||||||
|
{
|
||||||
|
var genericTypeDefinition = type.GetGenericTypeDefinition();
|
||||||
|
return genericTypeDefinition == typeof(ApiResponse<>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int MapSuccessCode(int code)
|
||||||
|
{
|
||||||
|
// 成功情况下,通常返回 200
|
||||||
|
// 但也可以根据业务码返回其他成功状态码(如 201 Created)
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
200 => StatusCodes.Status200OK,
|
||||||
|
201 => StatusCodes.Status201Created,
|
||||||
|
204 => StatusCodes.Status204NoContent,
|
||||||
|
_ => StatusCodes.Status200OK
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int MapErrorCode(int code)
|
||||||
|
{
|
||||||
|
// 根据业务错误码映射到 HTTP 状态码
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
ErrorCodes.BadRequest => StatusCodes.Status400BadRequest,
|
||||||
|
ErrorCodes.Unauthorized => StatusCodes.Status401Unauthorized,
|
||||||
|
ErrorCodes.Forbidden => StatusCodes.Status403Forbidden,
|
||||||
|
ErrorCodes.NotFound => StatusCodes.Status404NotFound,
|
||||||
|
ErrorCodes.Conflict => StatusCodes.Status409Conflict,
|
||||||
|
ErrorCodes.ValidationFailed => StatusCodes.Status422UnprocessableEntity,
|
||||||
|
ErrorCodes.InternalServerError => StatusCodes.Status500InternalServerError,
|
||||||
|
// 业务错误码(10000+)统一返回 422
|
||||||
|
>= 10000 => StatusCodes.Status422UnprocessableEntity,
|
||||||
|
// 默认返回 400
|
||||||
|
_ => StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,15 +5,8 @@ namespace TakeoutSaaS.Shared.Web.Middleware;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 安全响应头中间件
|
/// 安全响应头中间件
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SecurityHeadersMiddleware
|
public sealed class SecurityHeadersMiddleware(RequestDelegate next)
|
||||||
{
|
{
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
|
|
||||||
public SecurityHeadersMiddleware(RequestDelegate next)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var headers = context.Response.Headers;
|
var headers = context.Response.Headers;
|
||||||
@@ -21,7 +14,7 @@ public sealed class SecurityHeadersMiddleware
|
|||||||
headers["X-Frame-Options"] = "DENY";
|
headers["X-Frame-Options"] = "DENY";
|
||||||
headers["X-XSS-Protection"] = "1; mode=block";
|
headers["X-XSS-Protection"] = "1; mode=block";
|
||||||
headers["Referrer-Policy"] = "no-referrer";
|
headers["Referrer-Policy"] = "no-referrer";
|
||||||
await _next(context);
|
await next(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -19,7 +18,7 @@ public static class SwaggerExtensions
|
|||||||
public static IServiceCollection AddSharedSwagger(this IServiceCollection services, Action<SwaggerDocumentSettings>? configure = null)
|
public static IServiceCollection AddSharedSwagger(this IServiceCollection services, Action<SwaggerDocumentSettings>? configure = null)
|
||||||
{
|
{
|
||||||
services.AddSwaggerGen();
|
services.AddSwaggerGen();
|
||||||
services.AddSingleton(provider =>
|
services.AddSingleton(_ =>
|
||||||
{
|
{
|
||||||
var settings = new SwaggerDocumentSettings();
|
var settings = new SwaggerDocumentSettings();
|
||||||
configure?.Invoke(settings);
|
configure?.Invoke(settings);
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
using TakeoutSaaS.Domain.Identity.Entities;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Domain.Identity.Repositories;
|
namespace TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
@@ -10,9 +7,31 @@ namespace TakeoutSaaS.Domain.Identity.Repositories;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMiniUserRepository
|
public interface IMiniUserRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 根据微信 OpenId 查找小程序用户。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="openId">微信 OpenId</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>小程序用户,如果不存在则返回 null</returns>
|
||||||
Task<MiniUser?> FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default);
|
Task<MiniUser?> FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据用户 ID 查找小程序用户。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">用户 ID</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>小程序用户,如果不存在则返回 null</returns>
|
||||||
Task<MiniUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
Task<MiniUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="openId">微信 OpenId</param>
|
||||||
|
/// <param name="unionId">微信 UnionId(可选)</param>
|
||||||
|
/// <param name="nickname">昵称</param>
|
||||||
|
/// <param name="avatar">头像地址(可选)</param>
|
||||||
|
/// <param name="tenantId">租户 ID</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>创建或更新后的小程序用户</returns>
|
||||||
Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default);
|
Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||||
@@ -9,22 +7,52 @@ namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AdminSeedOptions
|
public sealed class AdminSeedOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始用户列表。
|
||||||
|
/// </summary>
|
||||||
public List<SeedUserOptions> Users { get; set; } = new();
|
public List<SeedUserOptions> Users { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 种子用户配置:用于初始化管理后台账号。
|
||||||
|
/// </summary>
|
||||||
public sealed class SeedUserOptions
|
public sealed class SeedUserOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 登录账号。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string Account { get; set; } = string.Empty;
|
public string Account { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 登录密码(明文,将在初始化时进行哈希处理)。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string Password { get; set; } = string.Empty;
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示名称。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string DisplayName { get; set; } = string.Empty;
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所属租户 ID。
|
||||||
|
/// </summary>
|
||||||
public Guid TenantId { get; set; }
|
public Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所属商户 ID(平台管理员为空)。
|
||||||
|
/// </summary>
|
||||||
public Guid? MerchantId { get; set; }
|
public Guid? MerchantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 角色集合。
|
||||||
|
/// </summary>
|
||||||
public string[] Roles { get; set; } = Array.Empty<string>();
|
public string[] Roles { get; set; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 权限集合。
|
||||||
|
/// </summary>
|
||||||
public string[] Permissions { get; set; } = Array.Empty<string>();
|
public string[] Permissions { get; set; } = Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,38 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// JWT 配置。
|
/// JWT 配置选项。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class JwtOptions
|
public sealed class JwtOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 令牌颁发者(Issuer)。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string Issuer { get; set; } = string.Empty;
|
public string Issuer { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 令牌受众(Audience)。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string Audience { get; set; } = string.Empty;
|
public string Audience { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JWT 签名密钥(至少 32 个字符)。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
[MinLength(32)]
|
[MinLength(32)]
|
||||||
public string Secret { get; set; } = string.Empty;
|
public string Secret { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 访问令牌过期时间(分钟),范围:5-1440。
|
||||||
|
/// </summary>
|
||||||
[Range(5, 1440)]
|
[Range(5, 1440)]
|
||||||
public int AccessTokenExpirationMinutes { get; set; } = 60;
|
public int AccessTokenExpirationMinutes { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新令牌过期时间(分钟),范围:60-20160(14天)。
|
||||||
|
/// </summary>
|
||||||
[Range(60, 1440 * 14)]
|
[Range(60, 1440 * 14)]
|
||||||
public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
|
public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 登录限流配置。
|
/// 登录限流配置选项。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LoginRateLimitOptions
|
public sealed class LoginRateLimitOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 时间窗口(秒),范围:1-3600。
|
||||||
|
/// </summary>
|
||||||
[Range(1, 3600)]
|
[Range(1, 3600)]
|
||||||
public int WindowSeconds { get; set; } = 60;
|
public int WindowSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 时间窗口内允许的最大尝试次数,范围:1-100。
|
||||||
|
/// </summary>
|
||||||
[Range(1, 100)]
|
[Range(1, 100)]
|
||||||
public int MaxAttempts { get; set; } = 5;
|
public int MaxAttempts { get; set; } = 5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 刷新令牌存储配置。
|
/// 刷新令牌存储配置选项。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RefreshTokenStoreOptions
|
public sealed class RefreshTokenStoreOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Redis 键前缀,用于存储刷新令牌。
|
||||||
|
/// </summary>
|
||||||
public string Prefix { get; set; } = "identity:refresh:";
|
public string Prefix { get; set; } = "identity:refresh:";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 微信小程序配置。
|
/// 微信小程序配置选项。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WeChatMiniOptions
|
public sealed class WeChatMiniOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 微信小程序 AppId。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string AppId { get; set; } = string.Empty;
|
public string AppId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微信小程序 AppSecret。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string Secret { get; set; } = string.Empty;
|
public string Secret { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
|
||||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||||
|
|
||||||
@@ -17,29 +12,20 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 后台账号初始化种子任务
|
/// 后台账号初始化种子任务
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IdentityDataSeeder : IHostedService
|
public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger<IdentityDataSeeder> logger) : IHostedService
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _serviceProvider;
|
|
||||||
private readonly ILogger<IdentityDataSeeder> _logger;
|
|
||||||
|
|
||||||
public IdentityDataSeeder(IServiceProvider serviceProvider, ILogger<IdentityDataSeeder> logger)
|
|
||||||
{
|
|
||||||
_serviceProvider = serviceProvider;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
|
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
|
||||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
|
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
|
||||||
|
|
||||||
await context.Database.MigrateAsync(cancellationToken);
|
await context.Database.MigrateAsync(cancellationToken);
|
||||||
|
|
||||||
if (options.Users == null || options.Users.Count == 0)
|
if (options.Users is null or { Count: 0 })
|
||||||
{
|
{
|
||||||
_logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
|
logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +50,7 @@ public sealed class IdentityDataSeeder : IHostedService
|
|||||||
};
|
};
|
||||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||||
context.IdentityUsers.Add(user);
|
context.IdentityUsers.Add(user);
|
||||||
_logger.LogInformation("已创建后台账号 {Account}", user.Account);
|
logger.LogInformation("已创建后台账号 {Account}", user.Account);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -74,7 +60,7 @@ public sealed class IdentityDataSeeder : IHostedService
|
|||||||
user.Roles = roles;
|
user.Roles = roles;
|
||||||
user.Permissions = permissions;
|
user.Permissions = permissions;
|
||||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||||
_logger.LogInformation("已更新后台账号 {Account}", user.Account);
|
logger.LogInformation("已更新后台账号 {Account}", user.Account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +71,9 @@ public sealed class IdentityDataSeeder : IHostedService
|
|||||||
|
|
||||||
private static string[] NormalizeValues(string[]? values)
|
private static string[] NormalizeValues(string[]? values)
|
||||||
=> values == null
|
=> values == null
|
||||||
? Array.Empty<string>()
|
? []
|
||||||
: values
|
: [.. values
|
||||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||||
.Select(v => v.Trim())
|
.Select(v => v.Trim())
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)];
|
||||||
.ToArray();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
@@ -11,12 +10,8 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 身份认证 DbContext。
|
/// 身份认证 DbContext。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IdentityDbContext : DbContext
|
public sealed class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
|
|
||||||
: base(options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
||||||
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
||||||
@@ -37,11 +32,11 @@ public sealed class IdentityDbContext : DbContext
|
|||||||
builder.Property(x => x.Avatar).HasMaxLength(256);
|
builder.Property(x => x.Avatar).HasMaxLength(256);
|
||||||
|
|
||||||
var converter = new ValueConverter<string[], string>(
|
var converter = new ValueConverter<string[], string>(
|
||||||
v => string.Join(',', v ?? Array.Empty<string>()),
|
v => string.Join(',', v),
|
||||||
v => string.IsNullOrWhiteSpace(v) ? Array.Empty<string>() : v.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
v => string.IsNullOrWhiteSpace(v) ? Array.Empty<string>() : v.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
|
||||||
var comparer = new ValueComparer<string[]>(
|
var comparer = new ValueComparer<string[]>(
|
||||||
(l, r) => l!.SequenceEqual(r!),
|
(l, r) => (l == null && r == null) || (l != null && r != null && Enumerable.SequenceEqual(l, r)),
|
||||||
v => v.Aggregate(0, (current, item) => HashCode.Combine(current, item.GetHashCode())),
|
v => v.Aggregate(0, (current, item) => HashCode.Combine(current, item.GetHashCode())),
|
||||||
v => v.ToArray());
|
v => v.ToArray());
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||||
@@ -16,29 +12,33 @@ namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// JWT 令牌生成器。
|
/// JWT 令牌生成器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class JwtTokenService : IJwtTokenService
|
public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions<JwtOptions> options) : IJwtTokenService
|
||||||
{
|
{
|
||||||
private readonly JwtSecurityTokenHandler _tokenHandler = new();
|
private readonly JwtSecurityTokenHandler _tokenHandler = new();
|
||||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
private readonly JwtOptions _options = options.Value;
|
||||||
private readonly JwtOptions _options;
|
|
||||||
|
|
||||||
public JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions<JwtOptions> options)
|
|
||||||
{
|
|
||||||
_refreshTokenStore = refreshTokenStore;
|
|
||||||
_options = options.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建访问令牌和刷新令牌对。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="profile">用户档案</param>
|
||||||
|
/// <param name="isNewUser">是否为新用户(首次登录)</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>令牌响应</returns>
|
||||||
public async Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default)
|
public async Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes);
|
var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes);
|
||||||
var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes);
|
var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes);
|
||||||
|
|
||||||
|
// 1. 构建 JWT Claims(包含用户 ID、账号、租户 ID、商户 ID、角色、权限等)
|
||||||
var claims = BuildClaims(profile);
|
var claims = BuildClaims(profile);
|
||||||
|
|
||||||
|
// 2. 创建签名凭据(使用 HMAC SHA256 算法)
|
||||||
var signingCredentials = new SigningCredentials(
|
var signingCredentials = new SigningCredentials(
|
||||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)),
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)),
|
||||||
SecurityAlgorithms.HmacSha256);
|
SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
// 3. 创建 JWT 安全令牌
|
||||||
var jwt = new JwtSecurityToken(
|
var jwt = new JwtSecurityToken(
|
||||||
issuer: _options.Issuer,
|
issuer: _options.Issuer,
|
||||||
audience: _options.Audience,
|
audience: _options.Audience,
|
||||||
@@ -47,8 +47,11 @@ public sealed class JwtTokenService : IJwtTokenService
|
|||||||
expires: accessExpires,
|
expires: accessExpires,
|
||||||
signingCredentials: signingCredentials);
|
signingCredentials: signingCredentials);
|
||||||
|
|
||||||
|
// 4. 序列化 JWT 为字符串
|
||||||
var accessToken = _tokenHandler.WriteToken(jwt);
|
var accessToken = _tokenHandler.WriteToken(jwt);
|
||||||
var refreshDescriptor = await _refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
|
|
||||||
|
// 5. 生成刷新令牌并存储到 Redis
|
||||||
|
var refreshDescriptor = await refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
|
||||||
|
|
||||||
return new TokenResponse
|
return new TokenResponse
|
||||||
{
|
{
|
||||||
@@ -61,6 +64,11 @@ public sealed class JwtTokenService : IJwtTokenService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构建 JWT Claims:将用户档案转换为 Claims 集合。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="profile">用户档案</param>
|
||||||
|
/// <returns>Claims 集合</returns>
|
||||||
private static IEnumerable<Claim> BuildClaims(CurrentUserProfile profile)
|
private static IEnumerable<Claim> BuildClaims(CurrentUserProfile profile)
|
||||||
{
|
{
|
||||||
var userId = profile.UserId.ToString();
|
var userId = profile.UserId.ToString();
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using TakeoutSaaS.Module.Authorization.Policies;
|
using TakeoutSaaS.Module.Authorization.Policies;
|
||||||
|
|
||||||
@@ -9,7 +6,7 @@ namespace TakeoutSaaS.Module.Authorization.Attributes;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 权限校验特性
|
/// 权限校验特性
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
||||||
public sealed class PermissionAuthorizeAttribute : AuthorizeAttribute
|
public sealed class PermissionAuthorizeAttribute : AuthorizeAttribute
|
||||||
{
|
{
|
||||||
public PermissionAuthorizeAttribute(params string[] permissions)
|
public PermissionAuthorizeAttribute(params string[] permissions)
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -10,16 +6,10 @@ namespace TakeoutSaaS.Module.Authorization.Policies;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 权限策略提供者(按需动态构建策略)
|
/// 权限策略提供者(按需动态构建策略)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider
|
public sealed class PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options) : DefaultAuthorizationPolicyProvider(options)
|
||||||
{
|
{
|
||||||
public const string PolicyPrefix = "PERMISSION:";
|
public const string PolicyPrefix = "PERMISSION:";
|
||||||
private readonly AuthorizationOptions _options;
|
private readonly AuthorizationOptions _options = options.Value;
|
||||||
|
|
||||||
public PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
|
|
||||||
: base(options)
|
|
||||||
{
|
|
||||||
_options = options.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
public override Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||||
{
|
{
|
||||||
@@ -28,7 +18,7 @@ public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorization
|
|||||||
var existingPolicy = _options.GetPolicy(policyName);
|
var existingPolicy = _options.GetPolicy(policyName);
|
||||||
if (existingPolicy != null)
|
if (existingPolicy != null)
|
||||||
{
|
{
|
||||||
return Task.FromResult(existingPolicy);
|
return Task.FromResult<AuthorizationPolicy?>(existingPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
var permissions = ParsePermissions(policyName);
|
var permissions = ParsePermissions(policyName);
|
||||||
@@ -61,9 +51,8 @@ public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorization
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string[] NormalizePermissions(IEnumerable<string> permissions)
|
private static string[] NormalizePermissions(IEnumerable<string> permissions)
|
||||||
=> permissions
|
=> [.. permissions
|
||||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||||
.Select(p => p.Trim())
|
.Select(p => p.Trim())
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)];
|
||||||
.ToArray();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Module.Authorization.Policies;
|
namespace TakeoutSaaS.Module.Authorization.Policies;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 权限要求
|
/// 权限要求:用于授权策略中定义所需的权限集合。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PermissionRequirement : IAuthorizationRequirement
|
public sealed class PermissionRequirement(IReadOnlyCollection<string> permissions) : IAuthorizationRequirement
|
||||||
{
|
{
|
||||||
public PermissionRequirement(IReadOnlyCollection<string> permissions)
|
/// <summary>
|
||||||
{
|
/// 所需的权限集合。
|
||||||
Permissions = permissions ?? throw new ArgumentNullException(nameof(permissions));
|
/// </summary>
|
||||||
}
|
public IReadOnlyCollection<string> Permissions { get; } = permissions ?? throw new ArgumentNullException(nameof(permissions));
|
||||||
|
|
||||||
public IReadOnlyCollection<string> Permissions { get; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user