From 2b02bbef7bc6ef5491a95b0d4c43a2505a393eb4 Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 12:51:56 +0800 Subject: [PATCH] feat: add local authentication --- Directory.Packages.props | 2 + Tiku.Api/Program.cs | 4 +- Tiku.Application/Auth/AuthContracts.cs | 56 ++++ Tiku.Application/Auth/AuthExceptions.cs | 18 ++ Tiku.Application/Auth/IAuthService.cs | 20 ++ Tiku.Application/Auth/IPasswordHasher.cs | 7 + Tiku.Application/Auth/ISessionService.cs | 19 ++ .../Auth/ISmsVerificationService.cs | 17 + Tiku.Application/Auth/ITokenService.cs | 13 + .../Security}/JwtOptions.cs | 11 +- Tiku.Infrastructure/Auth/AuthService.cs | 298 ++++++++++++++++++ Tiku.Infrastructure/Auth/PasswordHasher.cs | 59 ++++ Tiku.Infrastructure/Auth/SessionService.cs | 68 ++++ Tiku.Infrastructure/Auth/SmsCodeHashing.cs | 20 ++ .../Auth/SmsVerificationService.cs | 113 +++++++ Tiku.Infrastructure/Auth/TokenService.cs | 54 ++++ Tiku.Infrastructure/DependencyInjection.cs | 7 + .../Tiku.Infrastructure.csproj | 2 + .../Api/SecurityFoundationTests.cs | 4 +- Tiku.UnitTests/Auth/AuthServiceTests.cs | 229 ++++++++++++++ Tiku.UnitTests/Tiku.UnitTests.csproj | 8 +- 21 files changed, 1014 insertions(+), 15 deletions(-) create mode 100644 Tiku.Application/Auth/AuthContracts.cs create mode 100644 Tiku.Application/Auth/AuthExceptions.cs create mode 100644 Tiku.Application/Auth/IAuthService.cs create mode 100644 Tiku.Application/Auth/IPasswordHasher.cs create mode 100644 Tiku.Application/Auth/ISessionService.cs create mode 100644 Tiku.Application/Auth/ISmsVerificationService.cs create mode 100644 Tiku.Application/Auth/ITokenService.cs rename {Tiku.Api/Options => Tiku.Application/Security}/JwtOptions.cs (55%) create mode 100644 Tiku.Infrastructure/Auth/AuthService.cs create mode 100644 Tiku.Infrastructure/Auth/PasswordHasher.cs create mode 100644 Tiku.Infrastructure/Auth/SessionService.cs create mode 100644 Tiku.Infrastructure/Auth/SmsCodeHashing.cs create mode 100644 Tiku.Infrastructure/Auth/SmsVerificationService.cs create mode 100644 Tiku.Infrastructure/Auth/TokenService.cs create mode 100644 Tiku.UnitTests/Auth/AuthServiceTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 0a40a8e..9d4ce5b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,9 +18,11 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index a8727a9..c61e770 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -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) }; diff --git a/Tiku.Application/Auth/AuthContracts.cs b/Tiku.Application/Auth/AuthContracts.cs new file mode 100644 index 0000000..38075d8 --- /dev/null +++ b/Tiku.Application/Auth/AuthContracts.cs @@ -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); diff --git a/Tiku.Application/Auth/AuthExceptions.cs b/Tiku.Application/Auth/AuthExceptions.cs new file mode 100644 index 0000000..2b797f8 --- /dev/null +++ b/Tiku.Application/Auth/AuthExceptions.cs @@ -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."); diff --git a/Tiku.Application/Auth/IAuthService.cs b/Tiku.Application/Auth/IAuthService.cs new file mode 100644 index 0000000..6549901 --- /dev/null +++ b/Tiku.Application/Auth/IAuthService.cs @@ -0,0 +1,20 @@ +namespace Tiku.Application.Auth; + +public interface IAuthService +{ + Task LoginWithPasswordAsync( + PasswordLoginRequest request, + CancellationToken cancellationToken = default); + + Task LoginWithSmsAsync( + SmsLoginRequest request, + CancellationToken cancellationToken = default); + + Task RefreshAsync( + RefreshSessionRequest request, + CancellationToken cancellationToken = default); + + Task LogoutAsync( + LogoutSessionRequest request, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Auth/IPasswordHasher.cs b/Tiku.Application/Auth/IPasswordHasher.cs new file mode 100644 index 0000000..6e54b61 --- /dev/null +++ b/Tiku.Application/Auth/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace Tiku.Application.Auth; + +public interface IPasswordHasher +{ + string Hash(string password); + bool Verify(string password, string passwordHash); +} diff --git a/Tiku.Application/Auth/ISessionService.cs b/Tiku.Application/Auth/ISessionService.cs new file mode 100644 index 0000000..b5f3cbd --- /dev/null +++ b/Tiku.Application/Auth/ISessionService.cs @@ -0,0 +1,19 @@ +using Tiku.Domain.Tenancy; + +namespace Tiku.Application.Auth; + +public interface ISessionService +{ + string GenerateRefreshToken(); + string HashRefreshToken(string refreshToken); + + Task IssueAsync( + Guid userId, + string? phone, + string? email, + TenantMembership membership, + string provider, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Auth/ISmsVerificationService.cs b/Tiku.Application/Auth/ISmsVerificationService.cs new file mode 100644 index 0000000..abcb331 --- /dev/null +++ b/Tiku.Application/Auth/ISmsVerificationService.cs @@ -0,0 +1,17 @@ +using Tiku.Domain.Tenancy; + +namespace Tiku.Application.Auth; + +public interface ISmsVerificationService +{ + Task CreateCodeAsync( + SendSmsCodeRequest request, + CancellationToken cancellationToken = default); + + Task VerifyCodeAsync( + Guid tenantId, + string phone, + SmsPurpose purpose, + string code, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Auth/ITokenService.cs b/Tiku.Application/Auth/ITokenService.cs new file mode 100644 index 0000000..d497c89 --- /dev/null +++ b/Tiku.Application/Auth/ITokenService.cs @@ -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); +} diff --git a/Tiku.Api/Options/JwtOptions.cs b/Tiku.Application/Security/JwtOptions.cs similarity index 55% rename from Tiku.Api/Options/JwtOptions.cs rename to Tiku.Application/Security/JwtOptions.cs index 7fb7e70..1008043 100644 --- a/Tiku.Api/Options/JwtOptions.cs +++ b/Tiku.Application/Security/JwtOptions.cs @@ -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)); - } } diff --git a/Tiku.Infrastructure/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs new file mode 100644 index 0000000..e2a9733 --- /dev/null +++ b/Tiku.Infrastructure/Auth/AuthService.cs @@ -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 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 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 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 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 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); + } +} diff --git a/Tiku.Infrastructure/Auth/PasswordHasher.cs b/Tiku.Infrastructure/Auth/PasswordHasher.cs new file mode 100644 index 0000000..1723733 --- /dev/null +++ b/Tiku.Infrastructure/Auth/PasswordHasher.cs @@ -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); + } +} diff --git a/Tiku.Infrastructure/Auth/SessionService.cs b/Tiku.Infrastructure/Auth/SessionService.cs new file mode 100644 index 0000000..2733c39 --- /dev/null +++ b/Tiku.Infrastructure/Auth/SessionService.cs @@ -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 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 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); + } +} diff --git a/Tiku.Infrastructure/Auth/SmsCodeHashing.cs b/Tiku.Infrastructure/Auth/SmsCodeHashing.cs new file mode 100644 index 0000000..c50964e --- /dev/null +++ b/Tiku.Infrastructure/Auth/SmsCodeHashing.cs @@ -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(); + } +} diff --git a/Tiku.Infrastructure/Auth/SmsVerificationService.cs b/Tiku.Infrastructure/Auth/SmsVerificationService.cs new file mode 100644 index 0000000..86e51e9 --- /dev/null +++ b/Tiku.Infrastructure/Auth/SmsVerificationService.cs @@ -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 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); + } +} diff --git a/Tiku.Infrastructure/Auth/TokenService.cs b/Tiku.Infrastructure/Auth/TokenService.cs new file mode 100644 index 0000000..417fb47 --- /dev/null +++ b/Tiku.Infrastructure/Auth/TokenService.cs @@ -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 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 + { + 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); + } +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 3026ec7..70a8a14 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -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(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj index d705899..4f30f82 100644 --- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj +++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj @@ -9,8 +9,10 @@ + + diff --git a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs index 9eabe98..e136bf1 100644 --- a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs +++ b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs @@ -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 claims) { var credentials = new SigningCredentials( - JwtOptions.CreateSecurityKey(), + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)), SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( diff --git a/Tiku.UnitTests/Auth/AuthServiceTests.cs b/Tiku.UnitTests/Auth/AuthServiceTests.cs new file mode 100644 index 0000000..a08d7f4 --- /dev/null +++ b/Tiku.UnitTests/Auth/AuthServiceTests.cs @@ -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(() => + 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(() => + 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(() => + service.RefreshAsync(new RefreshSessionRequest( + login.Tokens.RefreshToken, + null, + null))); + } + + private static TikuDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .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(); + } +} diff --git a/Tiku.UnitTests/Tiku.UnitTests.csproj b/Tiku.UnitTests/Tiku.UnitTests.csproj index d4f2e17..0875050 100644 --- a/Tiku.UnitTests/Tiku.UnitTests.csproj +++ b/Tiku.UnitTests/Tiku.UnitTests.csproj @@ -14,6 +14,12 @@ + + + + + + @@ -29,4 +35,4 @@ - \ No newline at end of file +