chore: 同步当前开发内容
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台认证服务。
|
||||
/// </summary>
|
||||
public interface IAdminAuthService
|
||||
{
|
||||
Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
|
||||
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 令牌服务契约。
|
||||
/// </summary>
|
||||
public interface IJwtTokenService
|
||||
{
|
||||
Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 登录限流器。
|
||||
/// </summary>
|
||||
public interface ILoginRateLimiter
|
||||
{
|
||||
Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task ResetAsync(string key, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序认证服务。
|
||||
/// </summary>
|
||||
public interface IMiniAuthService
|
||||
{
|
||||
Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default);
|
||||
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌存储。
|
||||
/// </summary>
|
||||
public interface IRefreshTokenStore
|
||||
{
|
||||
Task<RefreshTokenDescriptor> IssueAsync(Guid userId, DateTime expiresAt, CancellationToken cancellationToken = default);
|
||||
Task<RefreshTokenDescriptor?> GetAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 code2Session 服务契约。
|
||||
/// </summary>
|
||||
public interface IWeChatAuthService
|
||||
{
|
||||
Task<WeChatSessionInfo> Code2SessionAsync(string code, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信会话信息。
|
||||
/// </summary>
|
||||
public sealed class WeChatSessionInfo
|
||||
{
|
||||
public string OpenId { get; init; } = string.Empty;
|
||||
public string? UnionId { get; init; }
|
||||
public string SessionKey { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台登录请求。
|
||||
/// </summary>
|
||||
public sealed class AdminLoginRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 登录用户档案。
|
||||
/// </summary>
|
||||
public sealed class CurrentUserProfile
|
||||
{
|
||||
public Guid UserId { get; init; }
|
||||
public string Account { get; init; } = string.Empty;
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public Guid TenantId { get; init; }
|
||||
public Guid? MerchantId { get; init; }
|
||||
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||
public string[] Permissions { get; init; } = Array.Empty<string>();
|
||||
public string? Avatar { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌请求。
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Access/Refresh 令牌响应。
|
||||
/// </summary>
|
||||
public class TokenResponse
|
||||
{
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
public DateTime AccessTokenExpiresAt { get; init; }
|
||||
public string RefreshToken { get; init; } = string.Empty;
|
||||
public DateTime RefreshTokenExpiresAt { get; init; }
|
||||
public CurrentUserProfile? User { get; init; }
|
||||
public bool IsNewUser { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序登录请求。
|
||||
/// </summary>
|
||||
public sealed class WeChatLoginRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
public string? EncryptedData { get; set; }
|
||||
|
||||
public string? Iv { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 应用层身份认证服务注入
|
||||
/// </summary>
|
||||
public static class IdentityServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册身份认证相关应用服务
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="enableMiniSupport">是否注册小程序认证服务</param>
|
||||
public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false)
|
||||
{
|
||||
services.AddScoped<IAdminAuthService, AdminAuthService>();
|
||||
|
||||
if (enableMiniSupport)
|
||||
{
|
||||
services.AddScoped<IMiniAuthService, MiniAuthService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌描述。
|
||||
/// </summary>
|
||||
public sealed record class RefreshTokenDescriptor(
|
||||
string Token,
|
||||
Guid UserId,
|
||||
DateTime ExpiresAt,
|
||||
bool Revoked);
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台认证服务实现。
|
||||
/// </summary>
|
||||
public sealed class AdminAuthService : IAdminAuthService
|
||||
{
|
||||
private readonly IIdentityUserRepository _userRepository;
|
||||
private readonly IPasswordHasher<IdentityUser> _passwordHasher;
|
||||
private readonly IJwtTokenService _jwtTokenService;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
|
||||
public AdminAuthService(
|
||||
IIdentityUserRepository userRepository,
|
||||
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)
|
||||
{
|
||||
var user = await _userRepository.FindByAccountAsync(request.Account, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
||||
|
||||
var result = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
||||
}
|
||||
|
||||
var profile = BuildProfile(user);
|
||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
||||
}
|
||||
|
||||
var user = await _userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
||||
var profile = BuildProfile(user);
|
||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.FindByIdAsync(userId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
|
||||
return BuildProfile(user);
|
||||
}
|
||||
|
||||
private static CurrentUserProfile BuildProfile(IdentityUser user)
|
||||
=> new()
|
||||
{
|
||||
UserId = user.Id,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Roles = user.Roles,
|
||||
Permissions = user.Permissions,
|
||||
Avatar = user.Avatar
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序认证服务实现。
|
||||
/// </summary>
|
||||
public sealed class MiniAuthService : IMiniAuthService
|
||||
{
|
||||
private readonly IWeChatAuthService _weChatAuthService;
|
||||
private readonly IMiniUserRepository _miniUserRepository;
|
||||
private readonly IJwtTokenService _jwtTokenService;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
private readonly ILoginRateLimiter _rateLimiter;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
|
||||
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)
|
||||
{
|
||||
var throttleKey = BuildThrottleKey();
|
||||
await _rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
|
||||
|
||||
var session = await _weChatAuthService.Code2SessionAsync(request.Code, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(session.OpenId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "获取微信用户信息失败");
|
||||
}
|
||||
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken);
|
||||
|
||||
await _rateLimiter.ResetAsync(throttleKey, cancellationToken);
|
||||
var profile = BuildProfile(user);
|
||||
return await _jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
||||
}
|
||||
|
||||
var user = await _miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
||||
var profile = BuildProfile(user);
|
||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _miniUserRepository.FindByIdAsync(userId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
|
||||
return BuildProfile(user);
|
||||
}
|
||||
|
||||
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);
|
||||
if (existing != null)
|
||||
{
|
||||
return (existing, false);
|
||||
}
|
||||
|
||||
var created = await _miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken);
|
||||
return (created, true);
|
||||
}
|
||||
|
||||
private static CurrentUserProfile BuildProfile(MiniUser user)
|
||||
=> new()
|
||||
{
|
||||
UserId = user.Id,
|
||||
Account = user.OpenId,
|
||||
DisplayName = user.Nickname,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = null,
|
||||
Roles = Array.Empty<string>(),
|
||||
Permissions = Array.Empty<string>(),
|
||||
Avatar = user.Avatar
|
||||
};
|
||||
|
||||
private string BuildThrottleKey()
|
||||
{
|
||||
var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
|
||||
return $"mini-login:{ip}";
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user