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

@@ -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" />

View File

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

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

View 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.");

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

View File

@@ -0,0 +1,7 @@
namespace Tiku.Application.Auth;
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string password, string passwordHash);
}

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

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

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

View File

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

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>

View File

@@ -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(

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

View File

@@ -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>
@@ -29,4 +35,4 @@
</PackageReference>
</ItemGroup>
</Project>
</Project>