forked from xiongyuxing/tiku-backend.net
feat: add local authentication
This commit is contained in:
@@ -18,9 +18,11 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageVersion>
|
</PackageVersion>
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" 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.DependencyInjection.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" 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.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
using System.Text;
|
||||||
using Tiku.Api.Middleware;
|
using Tiku.Api.Middleware;
|
||||||
using Tiku.Api.Options;
|
|
||||||
using Tiku.Api.Security;
|
using Tiku.Api.Security;
|
||||||
using Tiku.Application;
|
using Tiku.Application;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
@@ -38,7 +38,7 @@ builder.Services
|
|||||||
ValidateAudience = true,
|
ValidateAudience = true,
|
||||||
ValidAudience = jwtOptions.Audience,
|
ValidAudience = jwtOptions.Audience,
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKey = jwtOptions.CreateSecurityKey(),
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.FromMinutes(1)
|
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;
|
namespace Tiku.Application.Security;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Tiku.Api.Options;
|
|
||||||
|
|
||||||
public sealed class JwtOptions
|
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 string SigningKey { get; set; } = "development-only-tiku-signing-key-change-before-production";
|
||||||
public int AccessTokenMinutes { get; set; } = 30;
|
public int AccessTokenMinutes { get; set; } = 30;
|
||||||
public int RefreshTokenDays { 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.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using Tiku.Application.Auth;
|
||||||
|
using Tiku.Infrastructure.Auth;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure;
|
namespace Tiku.Infrastructure;
|
||||||
@@ -20,6 +22,11 @@ public static class DependencyInjection
|
|||||||
options.UseNpgsql(dataSource, npgsql =>
|
options.UseNpgsql(dataSource, npgsql =>
|
||||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
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;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
<PackageReference Include="Npgsql" />
|
<PackageReference Include="Npgsql" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Tiku.Api.Options;
|
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ public sealed class SecurityFoundationTests
|
|||||||
private static string CreateToken(IEnumerable<Claim> claims)
|
private static string CreateToken(IEnumerable<Claim> claims)
|
||||||
{
|
{
|
||||||
var credentials = new SigningCredentials(
|
var credentials = new SigningCredentials(
|
||||||
JwtOptions.CreateSecurityKey(),
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)),
|
||||||
SecurityAlgorithms.HmacSha256);
|
SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
||||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -29,4 +35,4 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user