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
+