feat: add local authentication

This commit is contained in:
xiong
2026-07-26 12:51:56 +08:00
parent e8b03c57bc
commit 2b02bbef7b
21 changed files with 1014 additions and 15 deletions

View 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);
}
}

View File

@@ -0,0 +1,59 @@
using System.Security.Cryptography;
using Tiku.Application.Auth;
namespace Tiku.Infrastructure.Auth;
public sealed class PasswordHasher : IPasswordHasher
{
private const int SaltSize = 16;
private const int HashSize = 32;
private const int Iterations = 210_000;
private const string Prefix = "pbkdf2-sha256";
public string Hash(string password)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password);
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
Iterations,
HashAlgorithmName.SHA256,
HashSize);
return string.Join(
'$',
Prefix,
Iterations.ToString(System.Globalization.CultureInfo.InvariantCulture),
Convert.ToBase64String(salt),
Convert.ToBase64String(hash));
}
public bool Verify(string password, string passwordHash)
{
if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(passwordHash))
{
return false;
}
var parts = passwordHash.Split('$');
if (parts.Length != 4 ||
!string.Equals(parts[0], Prefix, StringComparison.Ordinal) ||
!int.TryParse(parts[1], out var iterations))
{
return false;
}
var salt = Convert.FromBase64String(parts[2]);
var expected = Convert.FromBase64String(parts[3]);
var actual = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
iterations,
HashAlgorithmName.SHA256,
expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
}

View File

@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed class SessionService(
TikuDbContext dbContext,
ITokenService tokenService,
IOptions<JwtOptions> options) : ISessionService
{
private readonly JwtOptions options = options.Value;
public string GenerateRefreshToken()
{
return Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64));
}
public string HashRefreshToken(string refreshToken)
{
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public async Task<AuthTokenPair> IssueAsync(
Guid userId,
string? phone,
string? email,
TenantMembership membership,
string provider,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default)
{
var refreshToken = GenerateRefreshToken();
var session = new AuthSession
{
TenantId = membership.TenantId,
UserId = userId,
TokenHash = HashRefreshToken(refreshToken),
Provider = provider,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
IpAddress = ipAddress,
UserAgent = userAgent
};
dbContext.AuthSessions.Add(session);
await dbContext.SaveChangesAsync(cancellationToken);
var accessToken = tokenService.CreateAccessToken(
userId,
session.Id,
phone,
email,
membership);
return new AuthTokenPair(
accessToken.Token,
refreshToken,
accessToken.ExpiresAt,
session.ExpiresAt);
}
}

View File

@@ -0,0 +1,20 @@
using System.Security.Cryptography;
using System.Text;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Auth;
public static class SmsCodeHashing
{
public static string Hash(Guid tenantId, string phone, SmsPurpose purpose, string code)
{
var normalized = $"{tenantId:N}:{NormalizePhone(phone)}:{purpose}:{code.Trim()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string NormalizePhone(string phone)
{
return phone.Trim();
}
}

View File

@@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed class SmsVerificationService(TikuDbContext dbContext) : ISmsVerificationService
{
private const int MaxPhoneRequestsPerHour = 5;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
public async Task<SmsSendResult> CreateCodeAsync(
SendSmsCodeRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var bucketStart = TruncateToHour(DateTimeOffset.UtcNow);
var scopeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, "phone-bucket");
var rateLimit = await dbContext.SmsSendRateLimits.FindAsync(
[request.TenantId, SmsRateLimitDimension.Phone, scopeHash, bucketStart],
cancellationToken);
if (rateLimit is null)
{
rateLimit = new SmsSendRateLimit
{
TenantId = request.TenantId,
Dimension = SmsRateLimitDimension.Phone,
ScopeHash = scopeHash,
BucketStart = bucketStart
};
dbContext.SmsSendRateLimits.Add(rateLimit);
}
if (rateLimit.RequestCount >= MaxPhoneRequestsPerHour)
{
throw new SmsRateLimitedException();
}
rateLimit.RequestCount++;
rateLimit.UpdatedAt = DateTimeOffset.UtcNow;
var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture);
var verification = new SmsVerificationCode
{
TenantId = request.TenantId,
Phone = phone,
Purpose = request.Purpose,
CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code),
Provider = "mock",
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
};
dbContext.SmsVerificationCodes.Add(verification);
await dbContext.SaveChangesAsync(cancellationToken);
return new SmsSendResult(verification.Id, verification.ExpiresAt);
}
public async Task VerifyCodeAsync(
Guid tenantId,
string phone,
SmsPurpose purpose,
string code,
CancellationToken cancellationToken = default)
{
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
var now = DateTimeOffset.UtcNow;
var codeHash = SmsCodeHashing.Hash(tenantId, normalizedPhone, purpose, code);
var verification = await dbContext.SmsVerificationCodes
.Where(entity =>
entity.TenantId == tenantId &&
entity.Phone == normalizedPhone &&
entity.Purpose == purpose &&
entity.ConsumedAt == null)
.OrderByDescending(entity => entity.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (verification is null ||
verification.ExpiresAt <= now ||
verification.Status is SmsVerificationStatus.Expired or SmsVerificationStatus.Blocked)
{
throw new InvalidCredentialsException("invalid_sms_code");
}
verification.Attempts++;
if (!string.Equals(verification.CodeHash, codeHash, StringComparison.Ordinal))
{
await dbContext.SaveChangesAsync(cancellationToken);
throw new InvalidCredentialsException("invalid_sms_code");
}
verification.Status = SmsVerificationStatus.Verified;
verification.ConsumedAt = now;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static DateTimeOffset TruncateToHour(DateTimeOffset value)
{
return new DateTimeOffset(
value.Year,
value.Month,
value.Day,
value.Hour,
0,
0,
value.Offset);
}
}

View File

@@ -0,0 +1,54 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Auth;
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
{
private readonly JwtOptions options = options.Value;
public (string Token, DateTimeOffset ExpiresAt) CreateAccessToken(
Guid userId,
Guid sessionId,
string? phone,
string? email,
TenantMembership membership)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(options.AccessTokenMinutes);
var claims = new List<Claim>
{
new(TikuClaimTypes.UserId, userId.ToString()),
new(TikuClaimTypes.SessionId, sessionId.ToString()),
new(TikuClaimTypes.TenantId, membership.TenantId.ToString()),
new(TikuClaimTypes.TenantRole, membership.Role.ToString())
};
if (!string.IsNullOrWhiteSpace(phone))
{
claims.Add(new Claim(TikuClaimTypes.Phone, phone));
}
if (!string.IsNullOrWhiteSpace(email))
{
claims.Add(new Claim(TikuClaimTypes.Email, email));
}
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
options.Issuer,
options.Audience,
claims,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
}

View File

@@ -1,6 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Auth;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure;
@@ -20,6 +22,11 @@ public static class DependencyInjection
options.UseNpgsql(dataSource, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
});
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ISessionService, SessionService>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddScoped<IAuthService, AuthService>();
return services;
}

View File

@@ -9,8 +9,10 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
<PropertyGroup>