feat: add local authentication
This commit is contained in:
@@ -18,9 +18,11 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageVersion>
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Scalar.AspNetCore;
|
||||
using System.Text;
|
||||
using Tiku.Api.Middleware;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.Security;
|
||||
@@ -38,7 +38,7 @@ builder.Services
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = jwtOptions.CreateSecurityKey(),
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
|
||||
56
Tiku.Application/Auth/AuthContracts.cs
Normal file
56
Tiku.Application/Auth/AuthContracts.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public sealed record AuthTokenPair(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
DateTimeOffset RefreshTokenExpiresAt);
|
||||
|
||||
public sealed record TenantMembershipSummary(
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
TenantRole Role,
|
||||
MembershipStatus Status);
|
||||
|
||||
public sealed record AuthenticatedUser(
|
||||
Guid UserId,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string? Name,
|
||||
TenantMembershipSummary Tenant,
|
||||
AuthTokenPair Tokens);
|
||||
|
||||
public sealed record PasswordLoginRequest(
|
||||
Guid TenantId,
|
||||
string Phone,
|
||||
string Password,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record SmsLoginRequest(
|
||||
Guid TenantId,
|
||||
string Phone,
|
||||
string Code,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record RefreshSessionRequest(
|
||||
string RefreshToken,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record LogoutSessionRequest(
|
||||
string RefreshToken);
|
||||
|
||||
public sealed record SmsSendResult(
|
||||
Guid VerificationId,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record SendSmsCodeRequest(
|
||||
Guid TenantId,
|
||||
string Phone,
|
||||
SmsPurpose Purpose,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
18
Tiku.Application/Auth/AuthExceptions.cs
Normal file
18
Tiku.Application/Auth/AuthExceptions.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public class AuthException(string code, string message) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
|
||||
public sealed class InvalidCredentialsException(string code = "invalid_credentials")
|
||||
: AuthException(code, "The supplied credentials are invalid.");
|
||||
|
||||
public sealed class TenantAccessDeniedException()
|
||||
: AuthException("tenant_access_denied", "The user is not an active member of the requested tenant.");
|
||||
|
||||
public sealed class SessionRevokedException()
|
||||
: AuthException("session_revoked", "The session has been revoked or expired.");
|
||||
|
||||
public sealed class SmsRateLimitedException()
|
||||
: AuthException("sms_rate_limited", "SMS verification requests are rate limited.");
|
||||
20
Tiku.Application/Auth/IAuthService.cs
Normal file
20
Tiku.Application/Auth/IAuthService.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthenticatedUser> LoginWithPasswordAsync(
|
||||
PasswordLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticatedUser> LoginWithSmsAsync(
|
||||
SmsLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthTokenPair> RefreshAsync(
|
||||
RefreshSessionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task LogoutAsync(
|
||||
LogoutSessionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
7
Tiku.Application/Auth/IPasswordHasher.cs
Normal file
7
Tiku.Application/Auth/IPasswordHasher.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string Hash(string password);
|
||||
bool Verify(string password, string passwordHash);
|
||||
}
|
||||
19
Tiku.Application/Auth/ISessionService.cs
Normal file
19
Tiku.Application/Auth/ISessionService.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface ISessionService
|
||||
{
|
||||
string GenerateRefreshToken();
|
||||
string HashRefreshToken(string refreshToken);
|
||||
|
||||
Task<AuthTokenPair> IssueAsync(
|
||||
Guid userId,
|
||||
string? phone,
|
||||
string? email,
|
||||
TenantMembership membership,
|
||||
string provider,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
17
Tiku.Application/Auth/ISmsVerificationService.cs
Normal file
17
Tiku.Application/Auth/ISmsVerificationService.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface ISmsVerificationService
|
||||
{
|
||||
Task<SmsSendResult> CreateCodeAsync(
|
||||
SendSmsCodeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task VerifyCodeAsync(
|
||||
Guid tenantId,
|
||||
string phone,
|
||||
SmsPurpose purpose,
|
||||
string code,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
13
Tiku.Application/Auth/ITokenService.cs
Normal file
13
Tiku.Application/Auth/ITokenService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
(string Token, DateTimeOffset ExpiresAt) CreateAccessToken(
|
||||
Guid userId,
|
||||
Guid sessionId,
|
||||
string? phone,
|
||||
string? email,
|
||||
TenantMembership membership);
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
|
||||
namespace Tiku.Api.Options;
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
@@ -10,10 +7,4 @@ public sealed class JwtOptions
|
||||
public string SigningKey { get; set; } = "development-only-tiku-signing-key-change-before-production";
|
||||
public int AccessTokenMinutes { get; set; } = 30;
|
||||
public int RefreshTokenDays { get; set; } = 30;
|
||||
|
||||
public SymmetricSecurityKey CreateSecurityKey()
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(SigningKey);
|
||||
return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
59
Tiku.Infrastructure/Auth/PasswordHasher.cs
Normal file
59
Tiku.Infrastructure/Auth/PasswordHasher.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
68
Tiku.Infrastructure/Auth/SessionService.cs
Normal file
68
Tiku.Infrastructure/Auth/SessionService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
Tiku.Infrastructure/Auth/SmsCodeHashing.cs
Normal file
20
Tiku.Infrastructure/Auth/SmsCodeHashing.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
113
Tiku.Infrastructure/Auth/SmsVerificationService.cs
Normal file
113
Tiku.Infrastructure/Auth/SmsVerificationService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
54
Tiku.Infrastructure/Auth/TokenService.cs
Normal file
54
Tiku.Infrastructure/Auth/TokenService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
@@ -75,7 +75,7 @@ public sealed class SecurityFoundationTests
|
||||
private static string CreateToken(IEnumerable<Claim> claims)
|
||||
{
|
||||
var credentials = new SigningCredentials(
|
||||
JwtOptions.CreateSecurityKey(),
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
|
||||
229
Tiku.UnitTests/Auth/AuthServiceTests.cs
Normal file
229
Tiku.UnitTests/Auth/AuthServiceTests.cs
Normal file
@@ -0,0 +1,229 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.UnitTests.Auth;
|
||||
|
||||
public sealed class AuthServiceTests
|
||||
{
|
||||
private static readonly JwtOptions JwtOptions = new()
|
||||
{
|
||||
Issuer = "tiku-unit-tests",
|
||||
Audience = "tiku-api-unit-tests",
|
||||
SigningKey = "unit-test-signing-key-that-is-long-enough"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Password_hasher_verifies_own_hash()
|
||||
{
|
||||
var hasher = new PasswordHasher();
|
||||
|
||||
var hash = hasher.Hash("passw0rd!");
|
||||
|
||||
Assert.True(hasher.Verify("passw0rd!", hash));
|
||||
Assert.False(hasher.Verify("wrong", hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_login_creates_session_and_success_event()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var hasher = new PasswordHasher();
|
||||
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
|
||||
var service = CreateAuthService(context);
|
||||
|
||||
var result = await service.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"passw0rd!",
|
||||
"127.0.0.1",
|
||||
"unit-test"));
|
||||
|
||||
Assert.Equal(seed.UserId, result.UserId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Tokens.AccessToken));
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Tokens.RefreshToken));
|
||||
Assert.Single(context.AuthSessions);
|
||||
Assert.Contains(context.AuthLoginEvents, entity => entity.Result == AuthLoginResult.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_password_records_failed_event()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var hasher = new PasswordHasher();
|
||||
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
|
||||
var service = CreateAuthService(context);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"wrong",
|
||||
null,
|
||||
null)));
|
||||
|
||||
Assert.Empty(context.AuthSessions);
|
||||
Assert.Contains(context.AuthLoginEvents, entity =>
|
||||
entity.Result == AuthLoginResult.Failed &&
|
||||
entity.FailureCode == "invalid_credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sms_login_consumes_code_and_creates_session()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
|
||||
context.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = CreateAuthService(context);
|
||||
|
||||
var result = await service.LoginWithSmsAsync(new SmsLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"123456",
|
||||
null,
|
||||
null));
|
||||
|
||||
Assert.Equal(seed.UserId, result.UserId);
|
||||
Assert.Single(context.AuthSessions);
|
||||
Assert.Contains(context.SmsVerificationCodes, entity => entity.ConsumedAt is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_sms_code_records_failed_event()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
|
||||
context.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = CreateAuthService(context);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.LoginWithSmsAsync(new SmsLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"999999",
|
||||
null,
|
||||
null)));
|
||||
|
||||
Assert.Empty(context.AuthSessions);
|
||||
Assert.Contains(context.AuthLoginEvents, entity =>
|
||||
entity.Result == AuthLoginResult.Failed &&
|
||||
entity.FailureCode == "invalid_sms_code");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoked_refresh_token_cannot_be_refreshed()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var hasher = new PasswordHasher();
|
||||
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
|
||||
var service = CreateAuthService(context);
|
||||
var login = await service.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"passw0rd!",
|
||||
null,
|
||||
null));
|
||||
|
||||
await service.LogoutAsync(new LogoutSessionRequest(login.Tokens.RefreshToken));
|
||||
|
||||
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
||||
service.RefreshAsync(new RefreshSessionRequest(
|
||||
login.Tokens.RefreshToken,
|
||||
null,
|
||||
null)));
|
||||
}
|
||||
|
||||
private static TikuDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<TikuDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new TikuDbContext(options);
|
||||
}
|
||||
|
||||
private static IAuthService CreateAuthService(TikuDbContext context)
|
||||
{
|
||||
var tokenService = new TokenService(Options.Create(JwtOptions));
|
||||
var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions));
|
||||
var smsService = new SmsVerificationService(context);
|
||||
|
||||
return new AuthService(
|
||||
context,
|
||||
new PasswordHasher(),
|
||||
smsService,
|
||||
sessionService);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedUserAsync(
|
||||
TikuDbContext context,
|
||||
string passwordHash)
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = Guid.NewGuid().ToString("N"),
|
||||
Name = "Test Tenant"
|
||||
};
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = "13800000000",
|
||||
Name = "Test User"
|
||||
};
|
||||
var membership = new TenantMembership
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
UserId = user.Id,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
};
|
||||
var identity = new UserIdentity
|
||||
{
|
||||
UserId = user.Id,
|
||||
Provider = "password",
|
||||
ProviderSubject = user.Phone,
|
||||
Phone = user.Phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
};
|
||||
|
||||
context.Tenants.Add(tenant);
|
||||
context.Users.Add(user);
|
||||
context.TenantMemberships.Add(membership);
|
||||
context.UserIdentities.Add(identity);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return (tenant.Id, user.Id, user.Phone);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user