forked from gongxuegit/tiku-backend.net
feat: add local authentication
This commit is contained in:
298
Tiku.Infrastructure/Auth/AuthService.cs
Normal file
298
Tiku.Infrastructure/Auth/AuthService.cs
Normal file
@@ -0,0 +1,298 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthService(
|
||||
TikuDbContext dbContext,
|
||||
IPasswordHasher passwordHasher,
|
||||
ISmsVerificationService smsVerificationService,
|
||||
ISessionService sessionService) : IAuthService
|
||||
{
|
||||
private const string PasswordProvider = "password";
|
||||
private const string SmsProvider = "sms";
|
||||
|
||||
public async Task<AuthenticatedUser> LoginWithPasswordAsync(
|
||||
PasswordLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var user = await dbContext.Users
|
||||
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
|
||||
var identity = user is null
|
||||
? null
|
||||
: await dbContext.UserIdentities
|
||||
.SingleOrDefaultAsync(
|
||||
entity =>
|
||||
entity.UserId == user.Id &&
|
||||
entity.Provider == PasswordProvider &&
|
||||
entity.ProviderSubject == phone,
|
||||
cancellationToken);
|
||||
|
||||
if (user is null ||
|
||||
identity is null ||
|
||||
!TryGetPasswordHash(identity.SecretPayload, out var passwordHash) ||
|
||||
!passwordHasher.Verify(request.Password, passwordHash))
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
user?.Id,
|
||||
PasswordProvider,
|
||||
phone,
|
||||
AuthLoginResult.Failed,
|
||||
"invalid_credentials",
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.TenantId,
|
||||
user,
|
||||
PasswordProvider,
|
||||
phone,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthenticatedUser> LoginWithSmsAsync(
|
||||
SmsLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var user = await dbContext.Users
|
||||
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await smsVerificationService.VerifyCodeAsync(
|
||||
request.TenantId,
|
||||
phone,
|
||||
SmsPurpose.Login,
|
||||
request.Code,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (InvalidCredentialsException exception)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
user?.Id,
|
||||
SmsProvider,
|
||||
phone,
|
||||
AuthLoginResult.Failed,
|
||||
exception.Code,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
null,
|
||||
SmsProvider,
|
||||
phone,
|
||||
AuthLoginResult.Failed,
|
||||
"user_not_found",
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.TenantId,
|
||||
user,
|
||||
SmsProvider,
|
||||
phone,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthTokenPair> RefreshAsync(
|
||||
RefreshSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var session = await dbContext.AuthSessions
|
||||
.SingleOrDefaultAsync(entity => entity.TokenHash == tokenHash, cancellationToken);
|
||||
|
||||
if (session is null || session.RevokedAt is not null || session.ExpiresAt <= now)
|
||||
{
|
||||
throw new SessionRevokedException();
|
||||
}
|
||||
|
||||
var user = await dbContext.Users.FindAsync([session.UserId], cancellationToken)
|
||||
?? throw new SessionRevokedException();
|
||||
var membership = await FindActiveMembershipAsync(session.TenantId, session.UserId, cancellationToken)
|
||||
?? throw new TenantAccessDeniedException();
|
||||
|
||||
session.RevokedAt = now;
|
||||
await AddLoginEventAsync(
|
||||
session.TenantId,
|
||||
session.UserId,
|
||||
"refresh",
|
||||
user.Phone ?? user.Email,
|
||||
AuthLoginResult.Success,
|
||||
null,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
|
||||
return await sessionService.IssueAsync(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
membership,
|
||||
"refresh",
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(
|
||||
LogoutSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
|
||||
var session = await dbContext.AuthSessions
|
||||
.SingleOrDefaultAsync(entity => entity.TokenHash == tokenHash, cancellationToken);
|
||||
|
||||
if (session is null || session.RevokedAt is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
session.RevokedAt = DateTimeOffset.UtcNow;
|
||||
await AddLoginEventAsync(
|
||||
session.TenantId,
|
||||
session.UserId,
|
||||
"logout",
|
||||
null,
|
||||
AuthLoginResult.Success,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthenticatedUser> CompleteSuccessfulLoginAsync(
|
||||
Guid tenantId,
|
||||
User user,
|
||||
string provider,
|
||||
string identifier,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await FindActiveMembershipAsync(tenantId, user.Id, cancellationToken);
|
||||
if (membership is null)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
tenantId,
|
||||
user.Id,
|
||||
provider,
|
||||
identifier,
|
||||
AuthLoginResult.Failed,
|
||||
"tenant_access_denied",
|
||||
ipAddress,
|
||||
userAgent,
|
||||
cancellationToken);
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
var tenant = await dbContext.Tenants.FindAsync([tenantId], cancellationToken)
|
||||
?? throw new TenantAccessDeniedException();
|
||||
var tokens = await sessionService.IssueAsync(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
membership,
|
||||
provider,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
cancellationToken);
|
||||
|
||||
await AddLoginEventAsync(
|
||||
tenantId,
|
||||
user.Id,
|
||||
provider,
|
||||
identifier,
|
||||
AuthLoginResult.Success,
|
||||
null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
cancellationToken);
|
||||
|
||||
return new AuthenticatedUser(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
user.Name,
|
||||
new TenantMembershipSummary(
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
membership.Role,
|
||||
membership.Status),
|
||||
tokens);
|
||||
}
|
||||
|
||||
private async Task<TenantMembership?> FindActiveMembershipAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.TenantMemberships
|
||||
.Where(entity =>
|
||||
entity.TenantId == tenantId &&
|
||||
entity.UserId == userId &&
|
||||
entity.Status == MembershipStatus.Active)
|
||||
.OrderBy(entity => entity.Role)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddLoginEventAsync(
|
||||
Guid tenantId,
|
||||
Guid? userId,
|
||||
string provider,
|
||||
string? identifier,
|
||||
AuthLoginResult result,
|
||||
string? failureCode,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Provider = provider,
|
||||
Identifier = identifier,
|
||||
Result = result,
|
||||
FailureCode = failureCode,
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool TryGetPasswordHash(JsonElement secretPayload, out string passwordHash)
|
||||
{
|
||||
passwordHash = string.Empty;
|
||||
return secretPayload.ValueKind == JsonValueKind.Object &&
|
||||
secretPayload.TryGetProperty("passwordHash", out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(passwordHash = property.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user