From e3036c8a22fcd9d534bd1cd138e255e0257cb280 Mon Sep 17 00:00:00 2001 From: Akash Goswami Date: Mon, 27 Jul 2026 16:16:56 +0530 Subject: [PATCH 1/3] added 2fa and session management capability for all users --- .../Controllers/AuthControllerTests.cs | 222 ++- .../Configuration/RateLimitOptions.cs | 2 + .../Configuration/RateLimitingExtensions.cs | 15 + .../Controllers/AuthController.cs | 103 +- DocAnalytics.Data/AppDbContext.cs | 14 + ...0260727061041_AddTwoFactorAuth.Designer.cs | 1227 +++++++++++++++++ .../20260727061041_AddTwoFactorAuth.cs | 93 ++ .../Migrations/AppDbContextModelSnapshot.cs | 56 + DocAnalytics.Domain/Entities/RefreshToken.cs | 10 + .../Entities/TwoFactorRecoveryCode.cs | 20 + DocAnalytics.Domain/Entities/User.cs | 7 + .../Auth/AuthServiceTests.cs | 234 +++- DocAnalytics.Service/Auth/AuthDtos.cs | 24 + .../Auth/AuthFeatureExtensions.cs | 2 + DocAnalytics.Service/Auth/AuthService.cs | 133 +- .../Auth/DeviceLabelParser.cs | 31 + DocAnalytics.Service/Auth/IAuthService.cs | 16 +- DocAnalytics.Service/Auth/IJwtTokenService.cs | 7 + .../Auth/IRefreshTokenService.cs | 14 +- .../Auth/ITwoFactorService.cs | 20 + DocAnalytics.Service/Auth/JwtTokenService.cs | 55 + .../Auth/RefreshTokenService.cs | 61 +- DocAnalytics.Service/Auth/TwoFactorService.cs | 67 + .../DocAnalytics.Service.csproj | 1 + docanalytics-web/package-lock.json | 309 ++++- docanalytics-web/package.json | 2 + docanalytics-web/src/app/app.routes.ts | 15 + .../src/app/core/models/auth.model.ts | 26 + .../src/app/core/services/auth.service.ts | 47 +- .../app/features/auth/login.component.html | 134 +- .../src/app/features/auth/login.component.ts | 57 +- .../features/security/sessions.component.css | 156 +++ .../features/security/sessions.component.html | 54 + .../features/security/sessions.component.ts | 36 + .../app/features/security/sessions.service.ts | 59 + .../security/two-factor-setup.component.css | 167 +++ .../security/two-factor-setup.component.html | 54 + .../security/two-factor-setup.component.ts | 70 + .../src/app/layout/shell/shell.component.css | 19 + .../src/app/layout/shell/shell.component.html | 3 + 40 files changed, 3522 insertions(+), 120 deletions(-) create mode 100644 DocAnalytics.Data/Migrations/20260727061041_AddTwoFactorAuth.Designer.cs create mode 100644 DocAnalytics.Data/Migrations/20260727061041_AddTwoFactorAuth.cs create mode 100644 DocAnalytics.Domain/Entities/TwoFactorRecoveryCode.cs create mode 100644 DocAnalytics.Service/Auth/DeviceLabelParser.cs create mode 100644 DocAnalytics.Service/Auth/ITwoFactorService.cs create mode 100644 DocAnalytics.Service/Auth/TwoFactorService.cs create mode 100644 docanalytics-web/src/app/features/security/sessions.component.css create mode 100644 docanalytics-web/src/app/features/security/sessions.component.html create mode 100644 docanalytics-web/src/app/features/security/sessions.component.ts create mode 100644 docanalytics-web/src/app/features/security/sessions.service.ts create mode 100644 docanalytics-web/src/app/features/security/two-factor-setup.component.css create mode 100644 docanalytics-web/src/app/features/security/two-factor-setup.component.html create mode 100644 docanalytics-web/src/app/features/security/two-factor-setup.component.ts diff --git a/DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs b/DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs index 24b5e29..41d71cd 100644 --- a/DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs +++ b/DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs @@ -11,8 +11,6 @@ namespace DocAnalytics.Api.Tests.Controllers; public class AuthControllerTests { - // Login touches HttpContext.Connection / Response — give the controller a real context. - // refresh/jwt/passwordReset default to bare mocks so existing tests don't need to pass them. private static AuthController NewController( IAuthService auth, ICurrentUser user, ILoginLockoutService lockout, IRefreshTokenService? refresh = null, IJwtTokenService? jwt = null, @@ -25,16 +23,16 @@ private static AuthController NewController( ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; - [Fact] public async Task Login_returns_200_with_envelope_on_success() { - var response = new LoginResponse("jwt", new UserDto(Guid.NewGuid(), "a@org.com", "Viewer"), new List(), false); + var login = new LoginResponse("jwt", new UserDto(Guid.NewGuid(), "a@org.com", "Viewer"), new List(), false); + var loginResult = new LoginResult(false, null, login); var auth = new Mock(); - auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync(response); + auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync(loginResult); var refresh = new Mock(); - refresh.Setup(r => r.IssueAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(("raw-refresh", DateTime.UtcNow.AddDays(7))); // ← valid expiry, no MinValue blow-up + refresh.Setup(r => r.IssueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(("raw-refresh", DateTime.UtcNow.AddDays(7))); var result = await NewController(auth.Object, Mock.Of(), Mock.Of(), refresh.Object) .Login(new LoginRequest("a@org.com", "pw"), default); @@ -44,12 +42,27 @@ public async Task Login_returns_200_with_envelope_on_success() Assert.Equal("jwt", body.Data!.Token); } + [Fact] + public async Task Login_returns_2fa_challenge_when_account_has_2fa_enabled() + { + var loginResult = new LoginResult(true, "challenge-token", null); + var auth = new Mock(); + auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync(loginResult); + + var result = await NewController(auth.Object, Mock.Of(), Mock.Of()) + .Login(new LoginRequest("a@org.com", "pw"), default); + + var ok = Assert.IsType(result); + var body = Assert.IsType>(ok.Value); + Assert.True(body.Data!.RequiresTwoFactor); + Assert.Equal("challenge-token", body.Data.ChallengeToken); + } [Fact] public async Task Login_returns_401_on_invalid_credentials() { var auth = new Mock(); - auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync((LoginResponse?)null); + auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync((LoginResult?)null); var result = await NewController(auth.Object, Mock.Of(), Mock.Of()) .Login(new LoginRequest("a@org.com", "bad"), default); @@ -74,7 +87,6 @@ public async Task Login_returns_429_when_account_locked() Assert.Equal(429, obj.StatusCode); var body = Assert.IsType>(obj.Value); Assert.Equal("RATE_LIMITED", body.Error!.Code); - // Locked out BEFORE credentials are ever checked. auth.Verify(a => a.LoginAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -82,7 +94,7 @@ public async Task Login_returns_429_when_account_locked() public async Task Login_registers_failure_on_bad_password() { var auth = new Mock(); - auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync((LoginResponse?)null); + auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync((LoginResult?)null); var lockout = new Mock(); lockout.Setup(l => l.IsLockedAsync(It.IsAny(), It.IsAny())).ReturnsAsync((false, 0)); @@ -96,30 +108,132 @@ public async Task Login_registers_failure_on_bad_password() [Fact] public async Task Login_sets_refresh_token_cookie_on_success() { - var response = new LoginResponse("jwt", new UserDto(Guid.NewGuid(), "a@org.com", "Viewer"), new List(), false); + var login = new LoginResponse("jwt", new UserDto(Guid.NewGuid(), "a@org.com", "Viewer"), new List(), false); + var loginResult = new LoginResult(false, null, login); var auth = new Mock(); - auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync(response); + auth.Setup(a => a.LoginAsync(It.IsAny(), It.IsAny())).ReturnsAsync(loginResult); var refresh = new Mock(); - refresh.Setup(r => r.IssueAsync(It.IsAny(), It.IsAny(), It.IsAny())) + refresh.Setup(r => r.IssueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(("raw-refresh", DateTime.UtcNow.AddDays(7))); var controller = NewController(auth.Object, Mock.Of(), Mock.Of(), refresh.Object); var result = await controller.Login(new LoginRequest("a@org.com", "pw"), default); Assert.IsType(result); - // refresh token is now in the HttpOnly cookie, NOT the body var setCookie = controller.Response.Headers["Set-Cookie"].ToString(); Assert.Contains("refresh_token=raw-refresh", setCookie); Assert.Contains("httponly", setCookie.ToLowerInvariant()); } + [Fact] + public async Task LoginTwoFactor_returns_401_on_invalid_code() + { + var auth = new Mock(); + auth.Setup(a => a.LoginWithTwoFactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((LoginResponse?)null); + + var controller = NewController(auth.Object, Mock.Of(), Mock.Of()); + var result = await controller.LoginTwoFactor(new TwoFactorLoginRequest("bad", "000000"), default); + + var unauth = Assert.IsType(result); + Assert.Equal("INVALID_2FA_CODE", Assert.IsType>(unauth.Value).Error!.Code); + } + + [Fact] + public async Task LoginTwoFactor_issues_refresh_cookie_on_success() + { + var login = new LoginResponse("jwt", new UserDto(Guid.NewGuid(), "a@org.com", "Viewer"), new List(), false); + var auth = new Mock(); + auth.Setup(a => a.LoginWithTwoFactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(login); + var refresh = new Mock(); + refresh.Setup(r => r.IssueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(("raw-refresh", DateTime.UtcNow.AddDays(7))); + + var controller = NewController(auth.Object, Mock.Of(), Mock.Of(), refresh.Object); + var result = await controller.LoginTwoFactor(new TwoFactorLoginRequest("good", "123456"), default); + + Assert.IsType(result); + Assert.Contains("refresh_token=raw-refresh", controller.Response.Headers["Set-Cookie"].ToString()); + } + + [Fact] + public async Task SetupTwoFactor_returns_200_with_setup_payload() + { + var payload = new TwoFactorSetupResponse("SECRET", "otpauth://totp/x", "SECR ET"); + var userId = Guid.NewGuid(); + var auth = new Mock(); + auth.Setup(a => a.SetupTwoFactorAsync(userId, It.IsAny())).ReturnsAsync(payload); + var currentUser = new Mock(); + currentUser.SetupGet(c => c.UserId).Returns(userId); + + var result = await NewController(auth.Object, currentUser.Object, Mock.Of()).SetupTwoFactor(default); + + var ok = Assert.IsType(result); + Assert.Equal("SECRET", Assert.IsType>(ok.Value).Data!.Secret); + } + + [Fact] + public async Task ConfirmTwoFactor_returns_400_on_error() + { + var auth = new Mock(); + auth.Setup(a => a.ConfirmTwoFactorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(("Invalid code.", (TwoFactorConfirmResponse?)null)); + + var result = await NewController(auth.Object, Mock.Of(), Mock.Of()) + .ConfirmTwoFactor(new TwoFactorConfirmRequest("000000"), default); + + var bad = Assert.IsType(result); + Assert.Equal("INVALID_2FA_CODE", Assert.IsType>(bad.Value).Error!.Code); + } + + [Fact] + public async Task ConfirmTwoFactor_returns_200_with_recovery_codes_on_success() + { + var payload = new TwoFactorConfirmResponse(new List { "ABCD-1234" }); + var auth = new Mock(); + auth.Setup(a => a.ConfirmTwoFactorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(((string?)null, payload)); + + var result = await NewController(auth.Object, Mock.Of(), Mock.Of()) + .ConfirmTwoFactor(new TwoFactorConfirmRequest("123456"), default); + + var ok = Assert.IsType(result); + Assert.Single(Assert.IsType>(ok.Value).Data!.RecoveryCodes); + } + + [Fact] + public async Task DisableTwoFactor_returns_400_on_wrong_password() + { + var auth = new Mock(); + auth.Setup(a => a.DisableTwoFactorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("Password is incorrect."); + + var result = await NewController(auth.Object, Mock.Of(), Mock.Of()) + .DisableTwoFactor(new TwoFactorDisableRequest("wrong"), default); + + Assert.IsType(result); + } + + [Fact] + public async Task DisableTwoFactor_returns_200_on_success() + { + var auth = new Mock(); + auth.Setup(a => a.DisableTwoFactorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string?)null); + + var result = await NewController(auth.Object, Mock.Of(), Mock.Of()) + .DisableTwoFactor(new TwoFactorDisableRequest("correct"), default); + + Assert.IsType(result); + } [Fact] public async Task Refresh_returns_200_and_rotates_cookie() { var user = new User { Id = Guid.NewGuid(), Email = "a@org.com", Role = "Viewer" }; var refresh = new Mock(); - refresh.Setup(r => r.ValidateAndRotateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + refresh.Setup(r => r.ValidateAndRotateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((user, "new-refresh", DateTime.UtcNow.AddDays(7))); var jwt = new Mock(); jwt.Setup(j => j.CreateToken(user)).Returns("new-access"); @@ -131,19 +245,18 @@ public async Task Refresh_returns_200_and_rotates_cookie() var ok = Assert.IsType(result); var body = Assert.IsType>(ok.Value); - Assert.Equal("new-access", body.Data!.Token); // only the access token is in the body now + Assert.Equal("new-access", body.Data!.Token); var setCookie = controller.Response.Headers["Set-Cookie"].ToString(); - Assert.Contains("refresh_token=new-refresh", setCookie); // rotated cookie - refresh.Verify(r => r.ValidateAndRotateAsync("old-refresh", It.IsAny(), It.IsAny()), Times.Once); + Assert.Contains("refresh_token=new-refresh", setCookie); + refresh.Verify(r => r.ValidateAndRotateAsync("old-refresh", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } - [Fact] public async Task Refresh_returns_401_when_token_invalid() { var refresh = new Mock(); - refresh.Setup(r => r.ValidateAndRotateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + refresh.Setup(r => r.ValidateAndRotateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((User, string, DateTime)?)null); var controller = NewController(Mock.Of(), Mock.Of(), Mock.Of(), refresh.Object); @@ -156,7 +269,6 @@ public async Task Refresh_returns_401_when_token_invalid() Assert.Equal("INVALID_REFRESH_TOKEN", body.Error!.Code); } - [Fact] public async Task Logout_revokes_token_and_returns_200() { @@ -171,7 +283,6 @@ public async Task Logout_revokes_token_and_returns_200() refresh.Verify(r => r.RevokeAsync("some-token", It.IsAny()), Times.Once); } - [Fact] public async Task Me_returns_401_when_user_not_found() { @@ -189,12 +300,11 @@ public async Task Me_returns_401_when_user_not_found() public async Task Refresh_returns_401_when_cookie_missing() { var controller = NewController(Mock.Of(), Mock.Of(), Mock.Of()); - var result = await controller.Refresh(default); // no cookie set + var result = await controller.Refresh(default); var unauth = Assert.IsType(result); Assert.Equal("INVALID_REFRESH_TOKEN", Assert.IsType>(unauth.Value).Error!.Code); } - [Fact] public async Task Me_returns_200_with_user_and_sites() { @@ -211,6 +321,7 @@ public async Task Me_returns_200_with_user_and_sites() var body = Assert.IsType>(ok.Value); Assert.Equal("a@org.com", body.Data!.User.Email); } + [Fact] public async Task ForgotPassword_returns_200_and_calls_service() { @@ -257,4 +368,67 @@ public async Task ResetPassword_returns_200_on_success() Assert.IsType(result); } + [Fact] + public async Task GetSessions_returns_200_with_session_list() + { + var userId = Guid.NewGuid(); + var sessions = new List { new(Guid.NewGuid(), "Chrome on Windows", "1.2.3.4", DateTime.UtcNow, DateTime.UtcNow, true) }; + var refresh = new Mock(); + refresh.Setup(r => r.ListActiveSessionsAsync(userId, It.IsAny(), It.IsAny())).ReturnsAsync(sessions); + var currentUser = new Mock(); + currentUser.SetupGet(c => c.UserId).Returns(userId); + + var controller = NewController(Mock.Of(), currentUser.Object, Mock.Of(), refresh.Object); + var result = await controller.GetSessions(default); + + var ok = Assert.IsType(result); + Assert.Single(Assert.IsType>>(ok.Value).Data!); + } + + [Fact] + public async Task RevokeSession_returns_404_when_not_found_or_not_owned() + { + var refresh = new Mock(); + refresh.Setup(r => r.RevokeSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var controller = NewController(Mock.Of(), Mock.Of(), Mock.Of(), refresh.Object); + var result = await controller.RevokeSession(Guid.NewGuid(), default); + + Assert.IsType(result); + } + + [Fact] + public async Task RevokeSession_returns_200_on_success() + { + var refresh = new Mock(); + refresh.Setup(r => r.RevokeSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var controller = NewController(Mock.Of(), Mock.Of(), Mock.Of(), refresh.Object); + var result = await controller.RevokeSession(Guid.NewGuid(), default); + + Assert.IsType(result); + } + + [Fact] + public async Task RevokeOtherSessions_returns_401_when_no_cookie() + { + var controller = NewController(Mock.Of(), Mock.Of(), Mock.Of()); + var result = await controller.RevokeOtherSessions(default); + + Assert.IsType(result); + } + + [Fact] + public async Task RevokeOtherSessions_returns_200_with_count() + { + var refresh = new Mock(); + refresh.Setup(r => r.RevokeAllOtherSessionsAsync(It.IsAny(), "some-token", It.IsAny())).ReturnsAsync(2); + + var controller = NewController(Mock.Of(), Mock.Of(), Mock.Of(), refresh.Object); + controller.HttpContext.Request.Headers["Cookie"] = "refresh_token=some-token"; + + var result = await controller.RevokeOtherSessions(default); + + Assert.IsType(result); + } } diff --git a/DocAnalytics.Api/Configuration/RateLimitOptions.cs b/DocAnalytics.Api/Configuration/RateLimitOptions.cs index 4cff910..744ccda 100644 --- a/DocAnalytics.Api/Configuration/RateLimitOptions.cs +++ b/DocAnalytics.Api/Configuration/RateLimitOptions.cs @@ -14,6 +14,8 @@ public sealed class RateLimitOptions public RateLimitPolicySettings Reads { get; set; } = new() { PermitLimit = 100, WindowSeconds = 60 }; /// Limits for export endpoints (tight, per user). public RateLimitPolicySettings Export { get; set; } = new() { PermitLimit = 3, WindowSeconds = 60 }; + /// Limits for 2FA code-verification endpoints (per authenticated user, else per IP). + public RateLimitPolicySettings Mfa { get; set; } = new() { PermitLimit = 5, WindowSeconds = 300 }; } /// Fixed-window limit settings for a single rate-limit policy. diff --git a/DocAnalytics.Api/Configuration/RateLimitingExtensions.cs b/DocAnalytics.Api/Configuration/RateLimitingExtensions.cs index d99e06e..0d5d75d 100644 --- a/DocAnalytics.Api/Configuration/RateLimitingExtensions.cs +++ b/DocAnalytics.Api/Configuration/RateLimitingExtensions.cs @@ -13,6 +13,9 @@ public static class RateLimitingExtensions public const string ReadsPolicy = "reads"; /// Policy name for export endpoints (tight, partitioned by user). public const string ExportPolicy = "export"; + /// Policy name for 2FA verification endpoints (partitioned by user, else IP). + public const string MfaPolicy = "mfa"; + /// Configures the rate limiter, its policies, and the 429 rejection response. /// The service collection. @@ -61,6 +64,18 @@ public static IServiceCollection AddRateLimitingFeature( QueueProcessingOrder = QueueProcessingOrder.OldestFirst })); + // mfa: 2FA code attempts — per authenticated user where available, else per IP + // (UserKey already falls back to IP, exactly what "partitioned by user/IP" means here). + options.AddPolicy(MfaPolicy, httpContext => + RateLimitPartition.GetFixedWindowLimiter(UserKey(httpContext), _ => new FixedWindowRateLimiterOptions + { + PermitLimit = opts.Mfa.PermitLimit, + Window = TimeSpan.FromSeconds(opts.Mfa.WindowSeconds), + QueueLimit = opts.Mfa.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + })); + + // 429 body in the standard ApiResponse envelope + Retry-After (your R1 code, generic message) options.OnRejected = async (context, token) => { diff --git a/DocAnalytics.Api/Controllers/AuthController.cs b/DocAnalytics.Api/Controllers/AuthController.cs index 5f45d43..fe1bff6 100644 --- a/DocAnalytics.Api/Controllers/AuthController.cs +++ b/DocAnalytics.Api/Controllers/AuthController.cs @@ -65,14 +65,23 @@ public async Task Login([FromBody] LoginRequest req, Cancellation await _lockout.ResetAsync(email, ct); + if (result.RequiresTwoFactor) + { + // No refresh cookie yet — the real session starts only after /auth/login/2fa succeeds. + return Ok(ApiResponse.Ok( + new TwoFactorChallengeResponse(true, result.ChallengeToken!))); + } + // Refresh token now lives ONLY in an HttpOnly cookie — never in the JSON body. var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString(); - var (raw, refreshExpiresAt) = await _refresh.IssueAsync(result.User.Id, clientIp, ct); + var userAgent = Request.Headers["User-Agent"].ToString(); + var (raw, refreshExpiresAt) = await _refresh.IssueAsync(result.Login!.User.Id, clientIp, userAgent, ct); SetRefreshCookie(raw, refreshExpiresAt); - return Ok(ApiResponse.Ok(result)); + return Ok(ApiResponse.Ok(result.Login!)); } + /// Exchanges the refresh-token cookie for a fresh access token and rotates the cookie. [AllowAnonymous] [HttpPost("refresh")] @@ -84,7 +93,9 @@ public async Task Refresh(CancellationToken ct) "INVALID_REFRESH_TOKEN", "Refresh token is missing.")); var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); - var rotated = await _refresh.ValidateAndRotateAsync(presented, ip, ct); + var userAgent = Request.Headers["User-Agent"].ToString(); + var rotated = await _refresh.ValidateAndRotateAsync(presented, ip, userAgent, ct); + if (rotated is null) { DeleteRefreshCookie(); // clear the bad cookie @@ -160,6 +171,92 @@ public async Task ResetPassword([FromBody] ResetPasswordRequest r return Ok(ApiResponse.Ok(new { reset = true })); } + /// Completes a 2FA-gated login: exchanges the challenge token + a TOTP/recovery code for a real session. + [AllowAnonymous] + [HttpPost("login/2fa")] + [EnableRateLimiting("mfa")] + public async Task LoginTwoFactor([FromBody] TwoFactorLoginRequest req, CancellationToken ct) + { + var result = await _auth.LoginWithTwoFactorAsync(req, ct); + if (result is null) + return Unauthorized(ApiResponse.Fail("INVALID_2FA_CODE", "That code is invalid or expired.")); + + var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = Request.Headers["User-Agent"].ToString(); + var (raw, refreshExpiresAt) = await _refresh.IssueAsync(result.User.Id, clientIp, userAgent, ct); + SetRefreshCookie(raw, refreshExpiresAt); + + return Ok(ApiResponse.Ok(result)); + } + + /// Begins 2FA setup for the current user: returns the secret + otpauth URI for client-side QR rendering. + [Authorize] + [HttpPost("2fa/setup")] + public async Task SetupTwoFactor(CancellationToken ct) + { + var result = await _auth.SetupTwoFactorAsync(_currentUser.UserId, ct); + return Ok(ApiResponse.Ok(result)); + } + + /// Confirms 2FA setup with a valid code: enables 2FA, returns one-time recovery codes. + [Authorize] + [HttpPost("2fa/confirm")] + [EnableRateLimiting("mfa")] + public async Task ConfirmTwoFactor([FromBody] TwoFactorConfirmRequest req, CancellationToken ct) + { + var (error, resultBody) = await _auth.ConfirmTwoFactorAsync(_currentUser.UserId, req.Code, ct); + if (error is not null) + return BadRequest(ApiResponse.Fail("INVALID_2FA_CODE", error)); + + return Ok(ApiResponse.Ok(resultBody!)); + } + + /// Disables 2FA after re-verifying the password. + [Authorize] + [HttpPost("2fa/disable")] + public async Task DisableTwoFactor([FromBody] TwoFactorDisableRequest req, CancellationToken ct) + { + var error = await _auth.DisableTwoFactorAsync(_currentUser.UserId, req.Password, ct); + if (error is not null) + return BadRequest(ApiResponse.Fail("INVALID_PASSWORD", error)); + + return Ok(ApiResponse.Ok(new { disabled = true })); + } + + /// Lists this user's active sessions/devices. + [Authorize] + [HttpGet("sessions")] + public async Task GetSessions(CancellationToken ct) + { + var currentRaw = Request.Cookies["refresh_token"]; + var sessions = await _refresh.ListActiveSessionsAsync(_currentUser.UserId, currentRaw, ct); + return Ok(ApiResponse>.Ok(sessions)); + } + + /// Revokes one session (log out that device). + [Authorize] + [HttpDelete("sessions/{id:guid}")] + public async Task RevokeSession(Guid id, CancellationToken ct) + { + var ok = await _refresh.RevokeSessionAsync(_currentUser.UserId, id, ct); + if (!ok) return NotFound(ApiResponse.Fail("SESSION_NOT_FOUND", "Session not found.")); + return Ok(ApiResponse.Ok(new { revoked = true })); + } + + /// Logs out every OTHER device (keeps the current session active). + [Authorize] + [HttpPost("sessions/revoke-others")] + public async Task RevokeOtherSessions(CancellationToken ct) + { + var currentRaw = Request.Cookies["refresh_token"]; + if (string.IsNullOrEmpty(currentRaw)) + return Unauthorized(ApiResponse.Fail("INVALID_REFRESH_TOKEN", "No active session.")); + + var count = await _refresh.RevokeAllOtherSessionsAsync(_currentUser.UserId, currentRaw, ct); + return Ok(ApiResponse.Ok(new { revoked_count = count })); + } + + // ── refresh-token cookie helpers ──────────────────────────────────────── private void SetRefreshCookie(string rawToken, DateTime expiresAt) diff --git a/DocAnalytics.Data/AppDbContext.cs b/DocAnalytics.Data/AppDbContext.cs index 3e18016..85631e0 100644 --- a/DocAnalytics.Data/AppDbContext.cs +++ b/DocAnalytics.Data/AppDbContext.cs @@ -39,6 +39,8 @@ public AppDbContext(DbContextOptions options, ICurrentUser current public virtual DbSet PasswordResetTokens => Set(); public virtual DbSet InvoiceHeaders => Set(); + public virtual DbSet TwoFactorRecoveryCodes => Set(); + protected override void OnModelCreating(ModelBuilder b) @@ -157,11 +159,14 @@ protected override void OnModelCreating(ModelBuilder b) e.Property(x => x.TokenHash).HasMaxLength(88).IsRequired(); // base64 SHA-256 = 44 chars; pad room e.Property(x => x.CreatedByIp).HasMaxLength(64); e.Property(x => x.ReplacedByTokenHash).HasMaxLength(88); + e.Property(x => x.UserAgent).HasMaxLength(500); // NEW + e.Property(x => x.IpAddress).HasMaxLength(64); // NEW e.HasIndex(x => x.TokenHash).IsUnique(); // fast lookup on refresh e.HasIndex(x => x.UserId); // revoke-all-for-user e.Ignore(x => x.IsActive); }); + b.Entity(e => { e.ToTable("password_reset_tokens"); @@ -173,6 +178,15 @@ protected override void OnModelCreating(ModelBuilder b) e.Ignore(x => x.IsActive); }); + b.Entity(e => + { + e.ToTable("two_factor_recovery_codes"); + e.HasKey(x => x.Id); + e.Property(x => x.CodeHash).HasMaxLength(100).IsRequired(); + e.HasIndex(x => x.UserId); + }); + + // ---- GLOBAL TENANT/SITE FILTER (every ITenantScoped entity) ---- diff --git a/DocAnalytics.Data/Migrations/20260727061041_AddTwoFactorAuth.Designer.cs b/DocAnalytics.Data/Migrations/20260727061041_AddTwoFactorAuth.Designer.cs new file mode 100644 index 0000000..72095ba --- /dev/null +++ b/DocAnalytics.Data/Migrations/20260727061041_AddTwoFactorAuth.Designer.cs @@ -0,0 +1,1227 @@ +// +using System; +using DocAnalytics.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DocAnalytics.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260727061041_AddTwoFactorAuth")] + partial class AddTwoFactorAuth + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityName") + .HasColumnType("text") + .HasColumnName("entity_name"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entity_type"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_type"); + + b.Property("NewState") + .HasColumnType("text") + .HasColumnName("new_state"); + + b.Property("OldState") + .HasColumnType("text") + .HasColumnName("old_state"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TriggeredBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("triggered_by"); + + b.HasKey("Id") + .HasName("pk_activity_log"); + + b.HasIndex("TenantId", "SiteId", "CreatedAt") + .HasDatabaseName("ix_activity_log_tenant_id_site_id_created_at"); + + b.ToTable("activity_log", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.AlertNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlertRuleId") + .HasColumnType("uuid") + .HasColumnName("alert_rule_id"); + + b.Property("FiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("fired_at"); + + b.Property("IsRead") + .HasColumnType("boolean") + .HasColumnName("is_read"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message"); + + b.Property("ObservedPercent") + .HasColumnType("double precision") + .HasColumnName("observed_percent"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at"); + + b.Property("RuleName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("rule_name"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ThresholdPercent") + .HasColumnType("double precision") + .HasColumnName("threshold_percent"); + + b.HasKey("Id") + .HasName("pk_alert_notifications"); + + b.HasIndex("TenantId", "SiteId", "IsRead", "FiredAt") + .HasDatabaseName("ix_alert_notifications_tenant_site_read_fired"); + + b.ToTable("alert_notifications", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CooldownMinutes") + .HasColumnType("integer") + .HasColumnName("cooldown_minutes"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("email"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("LastTriggeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_triggered_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ThresholdPercent") + .HasColumnType("double precision") + .HasColumnName("threshold_percent"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WindowMinutes") + .HasColumnType("integer") + .HasColumnName("window_minutes"); + + b.HasKey("Id") + .HasName("pk_alert_rules"); + + b.HasIndex("TenantId", "SiteId") + .HasDatabaseName("ix_alert_rules_tenant_id_site_id"); + + b.ToTable("alert_rules", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.DocumentType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type_name"); + + b.HasKey("Id") + .HasName("pk_document_types"); + + b.HasIndex("TypeName") + .IsUnique() + .HasDatabaseName("ix_document_types_type_name"); + + b.ToTable("document_types", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.ErrorCatalog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ErrorCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("error_code"); + + b.Property("RemediationMsg") + .HasColumnType("text") + .HasColumnName("remediation_msg"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_error_catalog"); + + b.HasIndex("ErrorCode") + .IsUnique() + .HasDatabaseName("ix_error_catalog_error_code"); + + b.ToTable("error_catalog", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.FileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStep") + .IsRequired() + .HasColumnType("text") + .HasColumnName("current_step"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid") + .HasColumnName("document_type_id"); + + b.Property("ExtractionConfidence") + .HasPrecision(4, 3) + .HasColumnType("numeric(4,3)") + .HasColumnName("extraction_confidence"); + + b.Property("ExtractionStatus") + .HasColumnType("text") + .HasColumnName("extraction_status"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_type"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_updated_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StorageKey") + .HasColumnType("text") + .HasColumnName("storage_key"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TransactionId") + .HasColumnType("uuid") + .HasColumnName("transaction_id"); + + b.HasKey("Id") + .HasName("pk_files"); + + b.HasIndex("DocumentTypeId") + .HasDatabaseName("ix_files_document_type_id"); + + b.HasIndex("TransactionId") + .HasDatabaseName("ix_files_transaction_id"); + + b.HasIndex("TenantId", "SiteId", "DocumentTypeId") + .HasDatabaseName("ix_files_tenant_id_site_id_document_type_id"); + + b.HasIndex("TenantId", "SiteId", "Status", "LastUpdatedAt") + .HasDatabaseName("ix_files_tenant_id_site_id_status_last_updated_at"); + + b.ToTable("files", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.FileStepHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid") + .HasColumnName("document_type_id"); + + b.Property("ErrorCode") + .HasColumnType("text") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("FileId") + .HasColumnType("uuid") + .HasColumnName("file_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StepName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("step_name"); + + b.HasKey("Id") + .HasName("pk_file_step_history"); + + b.HasIndex("DocumentTypeId") + .HasDatabaseName("ix_file_step_history_document_type_id"); + + b.HasIndex("FileId") + .HasDatabaseName("ix_file_step_history_file_id"); + + b.HasIndex("StepName", "Status") + .HasDatabaseName("ix_file_step_history_step_name_status"); + + b.ToTable("file_step_history", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.InvoiceHeader", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Buyer") + .HasColumnType("text") + .HasColumnName("buyer"); + + b.Property("Currency") + .HasColumnType("text") + .HasColumnName("currency"); + + b.Property("Discount") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("discount"); + + b.Property("ExtractedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("extracted_at"); + + b.Property("FileId") + .HasColumnType("uuid") + .HasColumnName("file_id"); + + b.Property("InvoiceDate") + .HasColumnType("text") + .HasColumnName("invoice_date"); + + b.Property("InvoiceNumber") + .HasColumnType("text") + .HasColumnName("invoice_number"); + + b.Property("Seller") + .HasColumnType("text") + .HasColumnName("seller"); + + b.Property("Shipping") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("shipping"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Subtotal") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("subtotal"); + + b.Property("Tax") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("tax"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Total") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("total"); + + b.HasKey("Id") + .HasName("pk_invoice_headers"); + + b.HasIndex("FileId") + .HasDatabaseName("ix_invoice_headers_file_id"); + + b.ToTable("invoice_headers", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.InvoiceLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Confidence") + .HasPrecision(4, 3) + .HasColumnType("numeric(4,3)") + .HasColumnName("confidence"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExtractedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("extracted_at"); + + b.Property("FileId") + .HasColumnType("uuid") + .HasColumnName("file_id"); + + b.Property("IsValid") + .HasColumnType("boolean") + .HasColumnName("is_valid"); + + b.Property("ItemCategoryId") + .HasColumnType("uuid") + .HasColumnName("item_category_id"); + + b.Property("LineNumber") + .HasColumnType("integer") + .HasColumnName("line_number"); + + b.Property("LineTotal") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("line_total"); + + b.Property("Quantity") + .HasPrecision(12, 3) + .HasColumnType("numeric(12,3)") + .HasColumnName("quantity"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UnitPrice") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("unit_price"); + + b.HasKey("Id") + .HasName("pk_invoice_line_items"); + + b.HasIndex("FileId") + .HasDatabaseName("ix_invoice_line_items_file_id"); + + b.HasIndex("ItemCategoryId") + .HasDatabaseName("ix_invoice_line_items_item_category_id"); + + b.HasIndex("TenantId", "SiteId", "ItemCategoryId") + .HasDatabaseName("ix_invoice_line_items_tenant_id_site_id_item_category_id"); + + b.ToTable("invoice_line_items", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.ItemCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CategoryCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category_code"); + + b.Property("CategoryName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.HasKey("Id") + .HasName("pk_item_categories"); + + b.HasIndex("CategoryCode") + .IsUnique() + .HasDatabaseName("ix_item_categories_category_code"); + + b.ToTable("item_categories", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.LoginAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("email"); + + b.Property("FailedCount") + .HasColumnType("integer") + .HasColumnName("failed_count"); + + b.Property("FirstFailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_failed_at"); + + b.Property("Ip") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip"); + + b.Property("LastFailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_failed_at"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.HasKey("Id") + .HasName("pk_login_attempts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_login_attempts_email"); + + b.ToTable("login_attempts", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(88) + .HasColumnType("character varying(88)") + .HasColumnName("token_hash"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_password_reset_tokens"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_password_reset_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_password_reset_tokens_user_id"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip_address"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(88) + .HasColumnType("character varying(88)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(88) + .HasColumnType("character varying(88)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Location") + .HasColumnType("text") + .HasColumnName("location"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_sites"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_sites_tenant_id"); + + b.ToTable("sites", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OrgDomain") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("org_domain"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("OrgDomain") + .IsUnique() + .HasDatabaseName("ix_tenants_org_domain"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CompletedCount") + .HasColumnType("integer") + .HasColumnName("completed_count"); + + b.Property("FailedCount") + .HasColumnType("integer") + .HasColumnName("failed_count"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_updated_at"); + + b.Property("ProcessingCount") + .HasColumnType("integer") + .HasColumnName("processing_count"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("SourceSystem") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source_system"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalFiles") + .HasColumnType("integer") + .HasColumnName("total_files"); + + b.Property("UploadedCount") + .HasColumnType("integer") + .HasColumnName("uploaded_count"); + + b.HasKey("Id") + .HasName("pk_transactions"); + + b.HasIndex("TenantId", "SiteId", "LastUpdatedAt") + .HasDatabaseName("ix_transactions_tenant_id_site_id_last_updated_at"); + + b.HasIndex("TenantId", "SiteId", "State") + .HasDatabaseName("ix_transactions_tenant_id_site_id_state"); + + b.ToTable("transactions", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_two_factor_recovery_codes"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_two_factor_recovery_codes_user_id"); + + b.ToTable("two_factor_recovery_codes", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("TwoFactorSecret") + .HasColumnType("text") + .HasColumnName("two_factor_secret"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_users_tenant_id"); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("ck_users_role", "role IN ('Developer','Admin','Viewer')"); + }); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.UserSiteAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("granted_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_site_access"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_user_site_access_site_id"); + + b.HasIndex("UserId", "SiteId") + .IsUnique() + .HasDatabaseName("ix_user_site_access_user_id_site_id"); + + b.ToTable("user_site_access", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text") + .HasColumnName("friendly_name"); + + b.Property("Xml") + .HasColumnType("text") + .HasColumnName("xml"); + + b.HasKey("Id") + .HasName("pk_data_protection_keys"); + + b.ToTable("data_protection_keys", (string)null); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.FileRecord", b => + { + b.HasOne("DocAnalytics.Domain.Entities.DocumentType", "DocumentType") + .WithMany() + .HasForeignKey("DocumentTypeId") + .HasConstraintName("fk_files_document_types_document_type_id"); + + b.HasOne("DocAnalytics.Domain.Entities.Transaction", "Transaction") + .WithMany("Files") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_files_transactions_transaction_id"); + + b.Navigation("DocumentType"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.FileStepHistory", b => + { + b.HasOne("DocAnalytics.Domain.Entities.DocumentType", "DocumentType") + .WithMany() + .HasForeignKey("DocumentTypeId") + .HasConstraintName("fk_file_step_history_document_types_document_type_id"); + + b.HasOne("DocAnalytics.Domain.Entities.FileRecord", "File") + .WithMany("Steps") + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_file_step_history_files_file_id"); + + b.Navigation("DocumentType"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.InvoiceHeader", b => + { + b.HasOne("DocAnalytics.Domain.Entities.FileRecord", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_invoice_headers_files_file_id"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.InvoiceLineItem", b => + { + b.HasOne("DocAnalytics.Domain.Entities.FileRecord", "File") + .WithMany("LineItems") + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_invoice_line_items_files_file_id"); + + b.HasOne("DocAnalytics.Domain.Entities.ItemCategory", "ItemCategory") + .WithMany("LineItems") + .HasForeignKey("ItemCategoryId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_invoice_line_items_item_categories_item_category_id"); + + b.Navigation("File"); + + b.Navigation("ItemCategory"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.Site", b => + { + b.HasOne("DocAnalytics.Domain.Entities.Tenant", "Tenant") + .WithMany("Sites") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_sites_tenants_tenant_id"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.User", b => + { + b.HasOne("DocAnalytics.Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_users_tenants_tenant_id"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.UserSiteAccess", b => + { + b.HasOne("DocAnalytics.Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_site_access_sites_site_id"); + + b.HasOne("DocAnalytics.Domain.Entities.User", "User") + .WithMany("SiteAccess") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_site_access_users_user_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.FileRecord", b => + { + b.Navigation("LineItems"); + + b.Navigation("Steps"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.ItemCategory", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.Tenant", b => + { + b.Navigation("Sites"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.Transaction", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("DocAnalytics.Domain.Entities.User", b => + { + b.Navigation("SiteAccess"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DocAnalytics.Data/Migrations/20260727061041_AddTwoFactorAuth.cs b/DocAnalytics.Data/Migrations/20260727061041_AddTwoFactorAuth.cs new file mode 100644 index 0000000..be67906 --- /dev/null +++ b/DocAnalytics.Data/Migrations/20260727061041_AddTwoFactorAuth.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DocAnalytics.Data.Migrations; + +/// +public partial class AddTwoFactorAuth : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "two_factor_enabled", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "two_factor_secret", + table: "users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ip_address", + table: "refresh_tokens", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "last_used_at", + table: "refresh_tokens", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "user_agent", + table: "refresh_tokens", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.CreateTable( + name: "two_factor_recovery_codes", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + code_hash = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + used_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_two_factor_recovery_codes", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_two_factor_recovery_codes_user_id", + table: "two_factor_recovery_codes", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "two_factor_recovery_codes"); + + migrationBuilder.DropColumn( + name: "two_factor_enabled", + table: "users"); + + migrationBuilder.DropColumn( + name: "two_factor_secret", + table: "users"); + + migrationBuilder.DropColumn( + name: "ip_address", + table: "refresh_tokens"); + + migrationBuilder.DropColumn( + name: "last_used_at", + table: "refresh_tokens"); + + migrationBuilder.DropColumn( + name: "user_agent", + table: "refresh_tokens"); + } +} diff --git a/DocAnalytics.Data/Migrations/AppDbContextModelSnapshot.cs b/DocAnalytics.Data/Migrations/AppDbContextModelSnapshot.cs index ef2b862..a55e9a7 100644 --- a/DocAnalytics.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/DocAnalytics.Data/Migrations/AppDbContextModelSnapshot.cs @@ -733,6 +733,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("expires_at"); + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip_address"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + b.Property("ReplacedByTokenHash") .HasMaxLength(88) .HasColumnType("character varying(88)") @@ -748,6 +757,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(88)") .HasColumnName("token_hash"); + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + b.Property("UserId") .HasColumnType("uuid") .HasColumnName("user_id"); @@ -907,6 +921,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("transactions", (string)null); }); + modelBuilder.Entity("DocAnalytics.Domain.Entities.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_two_factor_recovery_codes"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_two_factor_recovery_codes_user_id"); + + b.ToTable("two_factor_recovery_codes", (string)null); + }); + modelBuilder.Entity("DocAnalytics.Domain.Entities.User", b => { b.Property("Id") @@ -949,6 +997,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("tenant_id"); + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("TwoFactorSecret") + .HasColumnType("text") + .HasColumnName("two_factor_secret"); + b.HasKey("Id") .HasName("pk_users"); diff --git a/DocAnalytics.Domain/Entities/RefreshToken.cs b/DocAnalytics.Domain/Entities/RefreshToken.cs index 7de7fe9..30144fa 100644 --- a/DocAnalytics.Domain/Entities/RefreshToken.cs +++ b/DocAnalytics.Domain/Entities/RefreshToken.cs @@ -23,6 +23,16 @@ public class RefreshToken /// The IP that created the token, if captured. public string? CreatedByIp { get; set; } + /// Raw User-Agent header captured at issuance/rotation time. + public string? UserAgent { get; set; } + + /// Last-known client IP (updated on each rotation). CreatedByIp stays as the ORIGINAL issuance IP. + public string? IpAddress { get; set; } + + /// When this token was last used (issued or rotated). Drives the "last active" column in the sessions UI. + public DateTime? LastUsedAt { get; set; } + + /// When the token was revoked (UTC) — set on logout, rotation, or reuse detection. public DateTime? RevokedAt { get; set; } // set on logout / rotation / reuse-detected /// Hash of the token that replaced this one (rotation chain for audit / reuse detection). diff --git a/DocAnalytics.Domain/Entities/TwoFactorRecoveryCode.cs b/DocAnalytics.Domain/Entities/TwoFactorRecoveryCode.cs new file mode 100644 index 0000000..4d8bf75 --- /dev/null +++ b/DocAnalytics.Domain/Entities/TwoFactorRecoveryCode.cs @@ -0,0 +1,20 @@ +namespace DocAnalytics.Domain.Entities; + +/// A single-use TOTP backup code, BCrypt-hashed like passwords. 8-10 are generated at setup time. +public class TwoFactorRecoveryCode +{ + /// Primary key. + public Guid Id { get; set; } + + /// The owning user. + public Guid UserId { get; set; } + + /// BCrypt hash of the recovery code. Plaintext is shown once at generation time and never stored. + public string CodeHash { get; set; } = null!; + + /// When this code was consumed; null while still usable. + public DateTime? UsedAt { get; set; } + + /// Creation timestamp (UTC). + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DocAnalytics.Domain/Entities/User.cs b/DocAnalytics.Domain/Entities/User.cs index 3cf317f..68dde31 100644 --- a/DocAnalytics.Domain/Entities/User.cs +++ b/DocAnalytics.Domain/Entities/User.cs @@ -30,6 +30,13 @@ public class User /// Creation timestamp (UTC). public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + /// Whether TOTP two-factor authentication is enabled for this account. + public bool TwoFactorEnabled { get; set; } = false; + + /// Base32 TOTP secret, encrypted at rest via IDataProtector. Null until 2FA setup starts. + public string? TwoFactorSecret { get; set; } + + /// The owning tenant navigation property; null to match . public Tenant? Tenant { get; set; } // nullable to match TenantId /// The sites this user has been granted access to. diff --git a/DocAnalytics.Service.Tests/Auth/AuthServiceTests.cs b/DocAnalytics.Service.Tests/Auth/AuthServiceTests.cs index 2e69793..938130c 100644 --- a/DocAnalytics.Service.Tests/Auth/AuthServiceTests.cs +++ b/DocAnalytics.Service.Tests/Auth/AuthServiceTests.cs @@ -1,6 +1,7 @@ using DocAnalytics.Domain.Entities; using DocAnalytics.Service.Auth; using DocAnalytics.Service.Tests.Support; +using Microsoft.AspNetCore.DataProtection; using MockQueryable.Moq; using Moq; @@ -8,7 +9,10 @@ namespace DocAnalytics.Service.Tests.Auth; public class AuthServiceTests { - private static User ActiveUser(string email, string password) + // Ephemeral (in-memory, non-persistent) protector — perfect for unit tests, no Postgres key ring needed. + private static readonly IDataProtectionProvider DataProtection = new EphemeralDataProtectionProvider(); + + private static User ActiveUser(string email, string password, bool twoFactorEnabled = false, string? twoFactorSecret = null) => new() { Id = Guid.NewGuid(), @@ -16,22 +20,31 @@ private static User ActiveUser(string email, string password) Role = "Viewer", IsActive = true, TenantId = Guid.NewGuid(), - PasswordHash = BCrypt.Net.BCrypt.HashPassword(password) + PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), + TwoFactorEnabled = twoFactorEnabled, + TwoFactorSecret = twoFactorSecret, }; - private static Mock Ctx(User[] users, UserSiteAccess[] access, Site[] sites) + private static Mock Ctx( + User[] users, UserSiteAccess[] access, Site[] sites, TwoFactorRecoveryCode[]? recoveryCodes = null) { var ctx = MockDb.Create(); ctx.Setup(c => c.Users).Returns(users.ToList().BuildMockDbSet().Object); ctx.Setup(c => c.UserSiteAccess).Returns(access.ToList().BuildMockDbSet().Object); ctx.Setup(c => c.Sites).Returns(sites.ToList().BuildMockDbSet().Object); + ctx.Setup(c => c.TwoFactorRecoveryCodes).Returns((recoveryCodes ?? Array.Empty()).ToList().BuildMockDbSet().Object); return ctx; } + private static AuthService NewSut( + Mock ctx, IJwtTokenService? jwt = null, ITwoFactorService? twoFactor = null) + => new(ctx.Object, jwt ?? Mock.Of(), Mock.Of(), + twoFactor ?? new TwoFactorService(), DataProtection); + [Fact] public async Task LoginAsync_returns_null_when_user_not_found() { - var sut = new AuthService(Ctx(Array.Empty(), Array.Empty(), Array.Empty()).Object, Mock.Of(), Mock.Of()); + var sut = NewSut(Ctx(Array.Empty(), Array.Empty(), Array.Empty())); Assert.Null(await sut.LoginAsync(new LoginRequest("nobody@org.com", "pw"), default)); } @@ -39,12 +52,12 @@ public async Task LoginAsync_returns_null_when_user_not_found() public async Task LoginAsync_returns_null_on_wrong_password() { var user = ActiveUser("a@org.com", "correct"); - var sut = new AuthService(Ctx(new[] { user }, Array.Empty(), Array.Empty()).Object, Mock.Of(), Mock.Of()); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty())); Assert.Null(await sut.LoginAsync(new LoginRequest("a@org.com", "wrong"), default)); } [Fact] - public async Task LoginAsync_returns_token_user_and_sites_on_success() + public async Task LoginAsync_returns_full_login_when_2fa_disabled() { var user = ActiveUser("a@org.com", "pw"); var siteId = Guid.NewGuid(); @@ -54,21 +67,203 @@ public async Task LoginAsync_returns_token_user_and_sites_on_success() var jwt = new Mock(); jwt.Setup(j => j.CreateToken(It.IsAny())).Returns("jwt-123"); - var result = await new AuthService(Ctx(new[] { user }, access, sites).Object, jwt.Object, Mock.Of()) + var result = await NewSut(Ctx(new[] { user }, access, sites), jwt.Object) .LoginAsync(new LoginRequest("a@org.com", "pw"), default); Assert.NotNull(result); - Assert.Equal("jwt-123", result!.Token); - Assert.Equal("a@org.com", result.User.Email); - Assert.Single(result.Sites); - Assert.Equal("Plant One", result.Sites[0].SiteName); + Assert.False(result!.RequiresTwoFactor); + Assert.NotNull(result.Login); + Assert.Equal("jwt-123", result.Login!.Token); + Assert.Equal("a@org.com", result.Login.User.Email); + Assert.Single(result.Login.Sites); + Assert.Equal("Plant One", result.Login.Sites[0].SiteName); jwt.Verify(j => j.CreateToken(It.Is(u => u.Id == user.Id)), Times.Once); } + [Fact] + public async Task LoginAsync_returns_challenge_when_2fa_enabled_and_never_issues_a_real_token() + { + var user = ActiveUser("a@org.com", "pw", twoFactorEnabled: true, twoFactorSecret: "irrelevant-for-this-test"); + var jwt = new Mock(); + jwt.Setup(j => j.CreateTwoFactorChallengeToken(user.Id)).Returns("challenge-abc"); + + var result = await NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty()), jwt.Object) + .LoginAsync(new LoginRequest("a@org.com", "pw"), default); + + Assert.NotNull(result); + Assert.True(result!.RequiresTwoFactor); + Assert.Equal("challenge-abc", result.ChallengeToken); + Assert.Null(result.Login); + jwt.Verify(j => j.CreateToken(It.IsAny()), Times.Never); // never issue the real token pre-2FA + } + + [Fact] + public async Task LoginWithTwoFactorAsync_returns_null_on_invalid_challenge_token() + { + var jwt = new Mock(); + jwt.Setup(j => j.ValidateTwoFactorChallengeToken("bad")).Returns((Guid?)null); + + var sut = NewSut(Ctx(Array.Empty(), Array.Empty(), Array.Empty()), jwt.Object); + var result = await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("bad", "123456"), default); + + Assert.Null(result); + } + + [Fact] + public async Task LoginWithTwoFactorAsync_succeeds_with_a_valid_totp_code() + { + var twoFactor = new TwoFactorService(); + var (secret, _, _) = twoFactor.GenerateSetup("a@org.com"); + + var dp = DataProtection.CreateProtector("DocAnalytics.TwoFactorSecret"); + var user = ActiveUser("a@org.com", "pw", twoFactorEnabled: true, twoFactorSecret: dp.Protect(secret)); + + var validCode = new OtpNet.Totp(OtpNet.Base32Encoding.ToBytes(secret)).ComputeTotp(); + + var jwt = new Mock(); + jwt.Setup(j => j.ValidateTwoFactorChallengeToken("good-challenge")).Returns(user.Id); + jwt.Setup(j => j.CreateToken(user)).Returns("jwt-final"); + + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty()), jwt.Object, twoFactor); + var result = await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("good-challenge", validCode), default); + + Assert.NotNull(result); + Assert.Equal("jwt-final", result!.Token); + } + + [Fact] + public async Task LoginWithTwoFactorAsync_falls_back_to_a_valid_recovery_code() + { + var twoFactor = new TwoFactorService(); + var dp = DataProtection.CreateProtector("DocAnalytics.TwoFactorSecret"); + var user = ActiveUser("a@org.com", "pw", twoFactorEnabled: true, twoFactorSecret: dp.Protect("ANYSECRETXXXX")); + + var recoveryPlain = "ABCD-1234"; + var recoveryCode = new TwoFactorRecoveryCode + { + Id = Guid.NewGuid(), + UserId = user.Id, + CodeHash = twoFactor.HashRecoveryCode(recoveryPlain), + }; + + var jwt = new Mock(); + jwt.Setup(j => j.ValidateTwoFactorChallengeToken("good-challenge")).Returns(user.Id); + jwt.Setup(j => j.CreateToken(user)).Returns("jwt-final"); + + var sut = NewSut( + Ctx(new[] { user }, Array.Empty(), Array.Empty(), new[] { recoveryCode }), + jwt.Object, twoFactor); + + var result = await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("good-challenge", recoveryPlain), default); + + Assert.NotNull(result); + Assert.Equal("jwt-final", result!.Token); + } + + [Fact] + public async Task LoginWithTwoFactorAsync_rejects_a_wrong_code_and_a_wrong_recovery_code() + { + var twoFactor = new TwoFactorService(); + var dp = DataProtection.CreateProtector("DocAnalytics.TwoFactorSecret"); + var (secret, _, _) = twoFactor.GenerateSetup("a@org.com"); + var user = ActiveUser("a@org.com", "pw", twoFactorEnabled: true, twoFactorSecret: dp.Protect(secret)); + + var jwt = new Mock(); + jwt.Setup(j => j.ValidateTwoFactorChallengeToken("good-challenge")).Returns(user.Id); + + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty()), jwt.Object, twoFactor); + var result = await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("good-challenge", "000000"), default); + + Assert.Null(result); + } + + [Fact] + public async Task SetupTwoFactorAsync_stores_an_encrypted_secret_and_returns_setup_payload() + { + var user = ActiveUser("a@org.com", "pw"); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty())); + + var result = await sut.SetupTwoFactorAsync(user.Id, default); + + Assert.False(string.IsNullOrWhiteSpace(result.Secret)); + Assert.StartsWith("otpauth://totp/", result.OtpAuthUri); + Assert.NotNull(user.TwoFactorSecret); + Assert.NotEqual(result.Secret, user.TwoFactorSecret); // stored value is encrypted, not plaintext + } + + [Fact] + public async Task ConfirmTwoFactorAsync_enables_2fa_and_returns_recovery_codes_on_valid_code() + { + var twoFactor = new TwoFactorService(); + var user = ActiveUser("a@org.com", "pw"); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty()), twoFactor: twoFactor); + + var setup = await sut.SetupTwoFactorAsync(user.Id, default); + var code = new OtpNet.Totp(OtpNet.Base32Encoding.ToBytes(setup.Secret)).ComputeTotp(); + + var (error, result) = await sut.ConfirmTwoFactorAsync(user.Id, code, default); + + Assert.Null(error); + Assert.NotNull(result); + Assert.Equal(10, result!.RecoveryCodes.Count); + Assert.True(user.TwoFactorEnabled); + } + + [Fact] + public async Task ConfirmTwoFactorAsync_returns_error_on_invalid_code_and_does_not_enable_2fa() + { + var user = ActiveUser("a@org.com", "pw"); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty())); + await sut.SetupTwoFactorAsync(user.Id, default); + + var (error, result) = await sut.ConfirmTwoFactorAsync(user.Id, "000000", default); + + Assert.NotNull(error); + Assert.Null(result); + Assert.False(user.TwoFactorEnabled); + } + + [Fact] + public async Task ConfirmTwoFactorAsync_returns_error_when_setup_was_never_started() + { + var user = ActiveUser("a@org.com", "pw"); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty())); + + var (error, result) = await sut.ConfirmTwoFactorAsync(user.Id, "123456", default); + + Assert.NotNull(error); + Assert.Null(result); + } + + [Fact] + public async Task DisableTwoFactorAsync_returns_error_on_wrong_password() + { + var user = ActiveUser("a@org.com", "correct", twoFactorEnabled: true); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty())); + + var error = await sut.DisableTwoFactorAsync(user.Id, "wrong", default); + + Assert.NotNull(error); + Assert.True(user.TwoFactorEnabled); + } + + [Fact] + public async Task DisableTwoFactorAsync_clears_2fa_on_correct_password() + { + var user = ActiveUser("a@org.com", "correct", twoFactorEnabled: true, twoFactorSecret: "enc-secret"); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty())); + + var error = await sut.DisableTwoFactorAsync(user.Id, "correct", default); + + Assert.Null(error); + Assert.False(user.TwoFactorEnabled); + Assert.Null(user.TwoFactorSecret); + } + [Fact] public async Task GetMeAsync_returns_null_when_user_missing() { - var sut = new AuthService(Ctx(Array.Empty(), Array.Empty(), Array.Empty()).Object, Mock.Of(), Mock.Of()); + var sut = NewSut(Ctx(Array.Empty(), Array.Empty(), Array.Empty())); Assert.Null(await sut.GetMeAsync(Guid.NewGuid(), default)); } @@ -80,8 +275,7 @@ public async Task GetMeAsync_returns_user_and_sites() var access = new[] { new UserSiteAccess { Id = Guid.NewGuid(), UserId = user.Id, SiteId = siteId } }; var sites = new[] { new Site { Id = siteId, Name = "Plant One", IsActive = true } }; - var result = await new AuthService(Ctx(new[] { user }, access, sites).Object, Mock.Of(), Mock.Of()) - .GetMeAsync(user.Id, default); + var result = await NewSut(Ctx(new[] { user }, access, sites)).GetMeAsync(user.Id, default); Assert.NotNull(result); Assert.Equal("a@org.com", result!.User.Email); @@ -104,8 +298,7 @@ public async Task GetSitesAsync_excludes_inactive_sites() new Site { Id = inactiveId, Name = "Inactive", IsActive = false }, }; - var result = await new AuthService(Ctx(Array.Empty(), access, sites).Object, Mock.Of(), Mock.Of()) - .GetSitesAsync(userId, default); + var result = await NewSut(Ctx(Array.Empty(), access, sites)).GetSitesAsync(userId, default); Assert.Single(result); Assert.Equal("Active", result[0].SiteName); @@ -114,7 +307,7 @@ public async Task GetSitesAsync_excludes_inactive_sites() [Fact] public async Task ChangePasswordAsync_returns_error_when_user_missing() { - var sut = new AuthService(Ctx(Array.Empty(), Array.Empty(), Array.Empty()).Object, Mock.Of(), Mock.Of()); + var sut = NewSut(Ctx(Array.Empty(), Array.Empty(), Array.Empty())); Assert.NotNull(await sut.ChangePasswordAsync(Guid.NewGuid(), new ChangePasswordRequest("old", "newpassword12"), default)); } @@ -122,7 +315,7 @@ public async Task ChangePasswordAsync_returns_error_when_user_missing() public async Task ChangePasswordAsync_returns_error_on_wrong_current_password() { var user = ActiveUser("a@org.com", "correct"); - var sut = new AuthService(Ctx(new[] { user }, Array.Empty(), Array.Empty()).Object, Mock.Of(), Mock.Of()); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty())); Assert.NotNull(await sut.ChangePasswordAsync(user.Id, new ChangePasswordRequest("wrong", "newpassword12"), default)); } @@ -131,12 +324,11 @@ public async Task ChangePasswordAsync_updates_hash_and_clears_flag() { var user = ActiveUser("a@org.com", "oldpassword"); user.MustChangePassword = true; - var sut = new AuthService(Ctx(new[] { user }, Array.Empty(), Array.Empty()).Object, Mock.Of(), Mock.Of()); + var sut = NewSut(Ctx(new[] { user }, Array.Empty(), Array.Empty())); var error = await sut.ChangePasswordAsync(user.Id, new ChangePasswordRequest("oldpassword", "newpassword12"), default); - Assert.Null(error); // null == success + Assert.Null(error); Assert.False(user.MustChangePassword); Assert.True(BCrypt.Net.BCrypt.Verify("newpassword12", user.PasswordHash)); } - } diff --git a/DocAnalytics.Service/Auth/AuthDtos.cs b/DocAnalytics.Service/Auth/AuthDtos.cs index 235e81d..6587c9e 100644 --- a/DocAnalytics.Service/Auth/AuthDtos.cs +++ b/DocAnalytics.Service/Auth/AuthDtos.cs @@ -58,3 +58,27 @@ public record ForgotPasswordRequest(string Email); /// Reset-password request — the raw token from the email link + the new password. public record ResetPasswordRequest(string Token, string NewPassword); +/// Result of a login attempt: either a full login payload, or a 2FA challenge to complete. +public record LoginResult(bool RequiresTwoFactor, string? ChallengeToken, LoginResponse? Login); + +/// What POST /auth/login returns when the account has 2FA enabled. +public record TwoFactorChallengeResponse(bool RequiresTwoFactor, string ChallengeToken); + +/// Request body for POST /auth/login/2fa. +public record TwoFactorLoginRequest(string ChallengeToken, string Code); + +/// Response for POST /auth/2fa/setup. +public record TwoFactorSetupResponse(string Secret, string OtpAuthUri, string ManualKey); + +/// Request body for POST /auth/2fa/confirm. +public record TwoFactorConfirmRequest(string Code); + +/// Response for POST /auth/2fa/confirm — recovery codes are shown exactly once. +public record TwoFactorConfirmResponse(IReadOnlyList RecoveryCodes); + +/// Request body for POST /auth/2fa/disable — requires password re-verification. +public record TwoFactorDisableRequest(string Password); + +/// One active session, as shown in the "Manage devices" settings page. +public record SessionDto(Guid Id, string DeviceLabel, string? IpAddress, DateTime CreatedAt, DateTime? LastUsedAt, bool IsCurrent); + diff --git a/DocAnalytics.Service/Auth/AuthFeatureExtensions.cs b/DocAnalytics.Service/Auth/AuthFeatureExtensions.cs index 0165008..99655ce 100644 --- a/DocAnalytics.Service/Auth/AuthFeatureExtensions.cs +++ b/DocAnalytics.Service/Auth/AuthFeatureExtensions.cs @@ -16,6 +16,8 @@ public static IServiceCollection AddAuthFeature(this IServiceCollection services services.AddScoped(); // ← the only new line services.AddScoped(); // NEW (R4) services.AddScoped(); + services.AddScoped(); // NEW (Account Security) return services; + } } diff --git a/DocAnalytics.Service/Auth/AuthService.cs b/DocAnalytics.Service/Auth/AuthService.cs index d52b247..b6b4339 100644 --- a/DocAnalytics.Service/Auth/AuthService.cs +++ b/DocAnalytics.Service/Auth/AuthService.cs @@ -1,4 +1,5 @@ using DocAnalytics.Data; +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; namespace DocAnalytics.Service.Auth; @@ -9,16 +10,26 @@ public class AuthService : IAuthService private readonly AppDbContext _db; private readonly IJwtTokenService _jwt; private readonly IPasswordPolicy _passwordPolicy; - - public AuthService(AppDbContext db, IJwtTokenService jwt, IPasswordPolicy passwordPolicy) + private readonly ITwoFactorService _twoFactor; + private readonly Microsoft.AspNetCore.DataProtection.IDataProtector _protector; + + public AuthService( + AppDbContext db, + IJwtTokenService jwt, + IPasswordPolicy passwordPolicy, + ITwoFactorService twoFactor, + Microsoft.AspNetCore.DataProtection.IDataProtectionProvider dataProtection) { _db = db; _jwt = jwt; _passwordPolicy = passwordPolicy; + _twoFactor = twoFactor; + _protector = dataProtection.CreateProtector("DocAnalytics.TwoFactorSecret"); } + /// - public async Task LoginAsync(LoginRequest req, CancellationToken ct) + public async Task LoginAsync(LoginRequest req, CancellationToken ct) { // 1) Find user by globally-unique email (safe pre-token lookup) var user = await _db.Users @@ -29,19 +40,125 @@ public AuthService(AppDbContext db, IJwtTokenService jwt, IPasswordPolicy passwo bool passwordOk = BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash); if (!passwordOk) return null; + // 2fa) Do NOT issue the real access token yet — only a short-lived, purpose-scoped challenge token. + if (user.TwoFactorEnabled) + { + var challengeToken = _jwt.CreateTwoFactorChallengeToken(user.Id); + return new LoginResult(true, challengeToken, null); + } + // 3) Which sites can this user see? (the join) var sites = await GetSitesForUserAsync(user.Id, ct); // 4) Mint the JWT var token = _jwt.CreateToken(user); - return new LoginResponse( - token, - new UserDto(user.Id, user.Email, user.Role), - sites, - user.MustChangePassword); + var login = new LoginResponse( + token, + new UserDto(user.Id, user.Email, user.Role), + sites, + user.MustChangePassword); + + return new LoginResult(false, null, login); + } + + /// + public async Task LoginWithTwoFactorAsync(TwoFactorLoginRequest req, CancellationToken ct) + { + var userId = _jwt.ValidateTwoFactorChallengeToken(req.ChallengeToken); + if (userId is null) return null; + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); + if (user is null || !user.TwoFactorEnabled || user.TwoFactorSecret is null) return null; + + var secret = _protector.Unprotect(user.TwoFactorSecret); + bool codeOk = _twoFactor.ValidateCode(secret, req.Code); + + if (!codeOk) + { + codeOk = await TryConsumeRecoveryCodeAsync(user.Id, req.Code, ct); // fall back to a single-use recovery code + } + if (!codeOk) return null; + + var sites = await GetSitesForUserAsync(user.Id, ct); + var token = _jwt.CreateToken(user); + return new LoginResponse(token, new UserDto(user.Id, user.Email, user.Role), sites, user.MustChangePassword); + } + + /// + public async Task SetupTwoFactorAsync(Guid userId, CancellationToken ct) + { + var user = await _db.Users.FirstAsync(u => u.Id == userId, ct); + var (secret, uri, manualKey) = _twoFactor.GenerateSetup(user.Email); + + // Store encrypted immediately so /confirm can validate against it; NOT enabled until confirmed. + user.TwoFactorSecret = _protector.Protect(secret); + await _db.SaveChangesAsync(ct); + + return new TwoFactorSetupResponse(secret, uri, manualKey); } + /// + public async Task<(string? Error, TwoFactorConfirmResponse? Result)> ConfirmTwoFactorAsync(Guid userId, string code, CancellationToken ct) + { + var user = await _db.Users.FirstAsync(u => u.Id == userId, ct); + if (user.TwoFactorSecret is null) return ("Call /auth/2fa/setup first.", null); + + var secret = _protector.Unprotect(user.TwoFactorSecret); + if (!_twoFactor.ValidateCode(secret, code)) return ("Invalid code. Check your app and try again.", null); + + user.TwoFactorEnabled = true; + + var old = await _db.TwoFactorRecoveryCodes.Where(r => r.UserId == userId).ToListAsync(ct); + _db.TwoFactorRecoveryCodes.RemoveRange(old); // re-confirm scenario — wipe stale codes + + var plainCodes = _twoFactor.GenerateRecoveryCodes(); + foreach (var plain in plainCodes) + { + _db.TwoFactorRecoveryCodes.Add(new DocAnalytics.Domain.Entities.TwoFactorRecoveryCode + { + Id = Guid.NewGuid(), + UserId = userId, + CodeHash = _twoFactor.HashRecoveryCode(plain), + CreatedAt = DateTime.UtcNow, + }); + } + await _db.SaveChangesAsync(ct); + + return (null, new TwoFactorConfirmResponse(plainCodes)); + } + + /// + public async Task DisableTwoFactorAsync(Guid userId, string password, CancellationToken ct) + { + var user = await _db.Users.FirstAsync(u => u.Id == userId, ct); + if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) return "Password is incorrect."; + + user.TwoFactorEnabled = false; + user.TwoFactorSecret = null; + + var codes = await _db.TwoFactorRecoveryCodes.Where(r => r.UserId == userId).ToListAsync(ct); + _db.TwoFactorRecoveryCodes.RemoveRange(codes); + + await _db.SaveChangesAsync(ct); + return null; + } + + private async Task TryConsumeRecoveryCodeAsync(Guid userId, string presented, CancellationToken ct) + { + var candidates = await _db.TwoFactorRecoveryCodes + .Where(r => r.UserId == userId && r.UsedAt == null) + .ToListAsync(ct); + + var match = candidates.FirstOrDefault(c => _twoFactor.VerifyRecoveryCode(presented, c.CodeHash)); + if (match is null) return false; + + match.UsedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return true; + } + + /// public async Task ChangePasswordAsync(Guid userId, ChangePasswordRequest req, CancellationToken ct) { diff --git a/DocAnalytics.Service/Auth/DeviceLabelParser.cs b/DocAnalytics.Service/Auth/DeviceLabelParser.cs new file mode 100644 index 0000000..b809513 --- /dev/null +++ b/DocAnalytics.Service/Auth/DeviceLabelParser.cs @@ -0,0 +1,31 @@ +namespace DocAnalytics.Service.Auth; + +/// Best-effort "Browser on OS" label from a raw User-Agent string. No UA-parsing library needed — +/// this is a settings-page nicety, not a security control. +public static class DeviceLabelParser +{ + public static string Parse(string? userAgent) + { + if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown device"; + + var ua = userAgent; + + string browser = + ua.Contains("Edg/") ? "Edge" : + ua.Contains("OPR/") || ua.Contains("Opera") ? "Opera" : + ua.Contains("Chrome/") && !ua.Contains("Chromium") ? "Chrome" : + ua.Contains("Firefox/") ? "Firefox" : + ua.Contains("Safari/") && !ua.Contains("Chrome") ? "Safari" : + "Unknown browser"; + + string os = + ua.Contains("Windows") ? "Windows" : + ua.Contains("Mac OS X") ? "macOS" : + ua.Contains("Android") ? "Android" : + (ua.Contains("iPhone") || ua.Contains("iPad")) ? "iOS" : + ua.Contains("Linux") ? "Linux" : + "Unknown OS"; + + return $"{browser} on {os}"; + } +} diff --git a/DocAnalytics.Service/Auth/IAuthService.cs b/DocAnalytics.Service/Auth/IAuthService.cs index b3bfcdd..ce13e11 100644 --- a/DocAnalytics.Service/Auth/IAuthService.cs +++ b/DocAnalytics.Service/Auth/IAuthService.cs @@ -7,7 +7,8 @@ public interface IAuthService /// Login request with email and password. /// Cancellation token. /// The login response, or null if the credentials are invalid. - Task LoginAsync(LoginRequest req, CancellationToken ct); + Task LoginAsync(LoginRequest req, CancellationToken ct); + /// Returns the current user's profile and authorized sites for session rehydration. /// The current user id. @@ -30,4 +31,17 @@ public interface IAuthService // null = success; non-null = human-readable failure reason + /// Starts 2FA setup for an authenticated user: generates + stores an encrypted secret, returns the QR payload. + Task SetupTwoFactorAsync(Guid userId, CancellationToken ct); + + /// Confirms 2FA setup with a valid TOTP code: flips TwoFactorEnabled, returns one-time recovery codes. + Task<(string? Error, TwoFactorConfirmResponse? Result)> ConfirmTwoFactorAsync(Guid userId, string code, CancellationToken ct); + + /// Disables 2FA after re-verifying the password. + Task DisableTwoFactorAsync(Guid userId, string password, CancellationToken ct); + + /// Completes a 2FA-gated login: validates the challenge token + TOTP/recovery code, issues the real login payload. + Task LoginWithTwoFactorAsync(TwoFactorLoginRequest req, CancellationToken ct); + + } diff --git a/DocAnalytics.Service/Auth/IJwtTokenService.cs b/DocAnalytics.Service/Auth/IJwtTokenService.cs index 728b81a..5f7fcda 100644 --- a/DocAnalytics.Service/Auth/IJwtTokenService.cs +++ b/DocAnalytics.Service/Auth/IJwtTokenService.cs @@ -9,4 +9,11 @@ public interface IJwtTokenService /// The user to mint a token for. /// The encoded JWT string. string CreateToken(User user); + + /// Creates a short-lived (5 min), purpose-scoped token identifying a user mid-login, + /// used only to complete a 2FA challenge. Carries no role/tenant claims. + string CreateTwoFactorChallengeToken(Guid userId); + + /// Validates a 2FA challenge token. Returns the embedded user id, or null if invalid/expired/wrong purpose. + Guid? ValidateTwoFactorChallengeToken(string token); } diff --git a/DocAnalytics.Service/Auth/IRefreshTokenService.cs b/DocAnalytics.Service/Auth/IRefreshTokenService.cs index 715ca50..5db8d69 100644 --- a/DocAnalytics.Service/Auth/IRefreshTokenService.cs +++ b/DocAnalytics.Service/Auth/IRefreshTokenService.cs @@ -7,17 +7,27 @@ public interface IRefreshTokenService { /// Mints a new opaque refresh token for the user and persists its hash. /// Returns the RAW token (shown once) + its expiry. - Task<(string RawToken, DateTime ExpiresAt)> IssueAsync(Guid userId, string? ip, CancellationToken ct = default); + Task<(string RawToken, DateTime ExpiresAt)> IssueAsync( + Guid userId, string? ip, string? userAgent = null, CancellationToken ct = default); /// Validates a presented raw token. On success ROTATES it (revokes old, issues new) /// and returns the owning user + the new raw token. Returns null if invalid/expired/revoked. /// Reuse of an already-revoked token revokes the whole chain for that user. Task<(User User, string RawToken, DateTime ExpiresAt)?> ValidateAndRotateAsync( - string rawToken, string? ip, CancellationToken ct = default); + string rawToken, string? ip, string? userAgent = null, CancellationToken ct = default); /// Revokes a single token (logout). Task RevokeAsync(string rawToken, CancellationToken ct = default); /// Revokes every active token for a user (e.g. password change / force logout). Task RevokeAllForUserAsync(Guid userId, CancellationToken ct = default); + + /// Lists this user's active (non-revoked, non-expired) sessions, newest-active first. + Task> ListActiveSessionsAsync(Guid userId, string? currentRawToken, CancellationToken ct = default); + + /// Revokes ONE session. Scoped by userId — a user can never revoke someone else's session. + Task RevokeSessionAsync(Guid userId, Guid tokenId, CancellationToken ct = default); + + /// Revokes every session EXCEPT the current one ("log out everywhere else"). Returns the count revoked. + Task RevokeAllOtherSessionsAsync(Guid userId, string currentRawToken, CancellationToken ct = default); } diff --git a/DocAnalytics.Service/Auth/ITwoFactorService.cs b/DocAnalytics.Service/Auth/ITwoFactorService.cs new file mode 100644 index 0000000..88b6f43 --- /dev/null +++ b/DocAnalytics.Service/Auth/ITwoFactorService.cs @@ -0,0 +1,20 @@ +namespace DocAnalytics.Service.Auth; + +/// TOTP secret generation/validation and recovery-code generation/verification for 2FA. +public interface ITwoFactorService +{ + /// Generates a new Base32 secret + otpauth:// URI (for client-side QR rendering) + a spaced manual-entry key. + (string Secret, string OtpAuthUri, string ManualKey) GenerateSetup(string accountLabel, string issuer = "DocAnalytics"); + + /// Validates a 6-digit TOTP code against a Base32 secret, tolerating ±1 time-step clock drift. + bool ValidateCode(string base32Secret, string code); + + /// Generates single-use recovery codes in "XXXX-XXXX" form (plaintext — caller shows once, never persists raw). + IReadOnlyList GenerateRecoveryCodes(int count = 10); + + /// BCrypt-hashes a recovery code for storage. + string HashRecoveryCode(string code); + + /// Verifies a presented recovery code against its stored BCrypt hash. + bool VerifyRecoveryCode(string code, string hash); +} diff --git a/DocAnalytics.Service/Auth/JwtTokenService.cs b/DocAnalytics.Service/Auth/JwtTokenService.cs index c04898d..ec5eb12 100644 --- a/DocAnalytics.Service/Auth/JwtTokenService.cs +++ b/DocAnalytics.Service/Auth/JwtTokenService.cs @@ -42,4 +42,59 @@ public string CreateToken(User user) return new JwtSecurityTokenHandler().WriteToken(token); } + + /// + public string CreateTwoFactorChallengeToken(Guid userId) + { + var keyString = _config["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keyString)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + // Deliberately NO role/tenantId claims — this token proves "who", not "what they can do". + var claims = new List + { + new("userId", userId.ToString()), + new("purpose", "2fa_challenge"), + }; + + var token = new JwtSecurityToken( + issuer: _config["Jwt:Issuer"], + audience: _config["Jwt:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddMinutes(5), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + public Guid? ValidateTwoFactorChallengeToken(string token) + { + var keyString = _config["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); + var issuer = _config["Jwt:Issuer"]; + var audience = _config["Jwt:Audience"]; + + try + { + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = !string.IsNullOrEmpty(issuer), + ValidIssuer = issuer, + ValidateAudience = !string.IsNullOrEmpty(audience), + ValidAudience = audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keyString)), + }, out _); + + if (principal.FindFirst("purpose")?.Value != "2fa_challenge") return null; + var idStr = principal.FindFirst("userId")?.Value; + return Guid.TryParse(idStr, out var id) ? id : null; + } + catch + { + return null; // expired/tampered/wrong purpose — reject silently + } + } } diff --git a/DocAnalytics.Service/Auth/RefreshTokenService.cs b/DocAnalytics.Service/Auth/RefreshTokenService.cs index 917b342..83843d1 100644 --- a/DocAnalytics.Service/Auth/RefreshTokenService.cs +++ b/DocAnalytics.Service/Auth/RefreshTokenService.cs @@ -35,7 +35,7 @@ private static string Hash(string raw) /// public async Task<(string RawToken, DateTime ExpiresAt)> IssueAsync( - Guid userId, string? ip, CancellationToken ct = default) + Guid userId, string? ip, string? userAgent = null, CancellationToken ct = default) { var raw = NewRawToken(); var now = DateTime.UtcNow; @@ -49,6 +49,9 @@ private static string Hash(string raw) CreatedAt = now, ExpiresAt = expiresAt, CreatedByIp = ip, + IpAddress = ip, + UserAgent = userAgent, + LastUsedAt = now, }); await _db.SaveChangesAsync(ct); @@ -57,7 +60,7 @@ private static string Hash(string raw) /// public async Task<(User User, string RawToken, DateTime ExpiresAt)?> ValidateAndRotateAsync( - string rawToken, string? ip, CancellationToken ct = default) + string rawToken, string? ip, string? userAgent = null, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(rawToken)) return null; @@ -84,6 +87,8 @@ private static string Hash(string raw) var expiresAt = now.AddDays(_lifetimeDays); var newHash = Hash(raw); + existing.LastUsedAt = now; // record use before it's superseded + _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), @@ -92,6 +97,9 @@ private static string Hash(string raw) CreatedAt = now, ExpiresAt = expiresAt, CreatedByIp = ip, + IpAddress = ip, + UserAgent = userAgent, + LastUsedAt = now, }); existing.RevokedAt = now; @@ -127,4 +135,53 @@ public async Task RevokeAllForUserAsync(Guid userId, CancellationToken ct = defa foreach (var t in active) t.RevokedAt = now; if (active.Count > 0) await _db.SaveChangesAsync(ct); } + + /// + public async Task> ListActiveSessionsAsync( + Guid userId, string? currentRawToken, CancellationToken ct = default) + { + var currentHash = string.IsNullOrEmpty(currentRawToken) ? null : Hash(currentRawToken); + var now = DateTime.UtcNow; + + var sessions = await _db.RefreshTokens + .Where(t => t.UserId == userId && t.RevokedAt == null && t.ExpiresAt > now) + .OrderByDescending(t => t.LastUsedAt ?? t.CreatedAt) + .ToListAsync(ct); + + return sessions.Select(t => new SessionDto( + t.Id, + DeviceLabelParser.Parse(t.UserAgent), + t.IpAddress, + t.CreatedAt, + t.LastUsedAt, + currentHash != null && t.TokenHash == currentHash + )).ToList(); + } + + /// + public async Task RevokeSessionAsync(Guid userId, Guid tokenId, CancellationToken ct = default) + { + // scoped by userId — this WHERE clause is the entire security guarantee here + var token = await _db.RefreshTokens.FirstOrDefaultAsync(t => t.Id == tokenId && t.UserId == userId, ct); + if (token is null || token.RevokedAt is not null) return false; + + token.RevokedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return true; + } + + /// + public async Task RevokeAllOtherSessionsAsync(Guid userId, string currentRawToken, CancellationToken ct = default) + { + var currentHash = Hash(currentRawToken); + var now = DateTime.UtcNow; + + var others = await _db.RefreshTokens + .Where(t => t.UserId == userId && t.RevokedAt == null && t.TokenHash != currentHash) + .ToListAsync(ct); + + foreach (var t in others) t.RevokedAt = now; + if (others.Count > 0) await _db.SaveChangesAsync(ct); + return others.Count; + } } diff --git a/DocAnalytics.Service/Auth/TwoFactorService.cs b/DocAnalytics.Service/Auth/TwoFactorService.cs new file mode 100644 index 0000000..644f611 --- /dev/null +++ b/DocAnalytics.Service/Auth/TwoFactorService.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using OtpNet; + +namespace DocAnalytics.Service.Auth; + +/// Default implementation, built on Otp.NET. +public sealed class TwoFactorService : ITwoFactorService +{ + /// + public (string Secret, string OtpAuthUri, string ManualKey) GenerateSetup(string accountLabel, string issuer = "DocAnalytics") + { + var keyBytes = KeyGeneration.GenerateRandomKey(20); // 160-bit — standard TOTP secret size + var secret = Base32Encoding.ToString(keyBytes); + + var label = Uri.EscapeDataString($"{issuer}:{accountLabel}"); + var uri = $"otpauth://totp/{label}?secret={secret}&issuer={Uri.EscapeDataString(issuer)}&digits=6&period=30&algorithm=SHA1"; + + var manualKey = string.Join(' ', Chunk(secret, 4)); // e.g. "ABCD EFGH IJKL ..." + + return (secret, uri, manualKey); + } + + /// + public bool ValidateCode(string base32Secret, string code) + { + if (string.IsNullOrWhiteSpace(code)) return false; + try + { + var keyBytes = Base32Encoding.ToBytes(base32Secret); + var totp = new Totp(keyBytes, step: 30, mode: OtpHashMode.Sha1, totpSize: 6); + return totp.VerifyTotp(code.Trim(), out _, new VerificationWindow(previous: 1, future: 1)); + } + catch + { + return false; // malformed secret/code — never throw out of a security check + } + } + + /// + public IReadOnlyList GenerateRecoveryCodes(int count = 10) + { + const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1 ambiguity + var codes = new List(count); + for (int i = 0; i < count; i++) + { + var bytes = RandomNumberGenerator.GetBytes(8); + var chars = new char[8]; + for (int j = 0; j < 8; j++) chars[j] = alphabet[bytes[j] % alphabet.Length]; + codes.Add($"{new string(chars, 0, 4)}-{new string(chars, 4, 4)}"); + } + return codes; + } + + /// + public string HashRecoveryCode(string code) => BCrypt.Net.BCrypt.HashPassword(Normalize(code)); + + /// + public bool VerifyRecoveryCode(string code, string hash) => BCrypt.Net.BCrypt.Verify(Normalize(code), hash); + + private static string Normalize(string code) => code.Trim().ToUpperInvariant(); + + private static IEnumerable Chunk(string s, int size) + { + for (int i = 0; i < s.Length; i += size) + yield return s.Substring(i, Math.Min(size, s.Length - i)); + } +} diff --git a/DocAnalytics.Service/DocAnalytics.Service.csproj b/DocAnalytics.Service/DocAnalytics.Service.csproj index 7d5f030..0e03f88 100644 --- a/DocAnalytics.Service/DocAnalytics.Service.csproj +++ b/DocAnalytics.Service/DocAnalytics.Service.csproj @@ -13,6 +13,7 @@ + diff --git a/docanalytics-web/package-lock.json b/docanalytics-web/package-lock.json index 6776336..62ac182 100644 --- a/docanalytics-web/package-lock.json +++ b/docanalytics-web/package-lock.json @@ -16,6 +16,7 @@ "@angular/router": "^22.0.6", "@microsoft/signalr": "^10.0.0", "jszip": "^3.10.1", + "qrcode": "^1.5.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -25,6 +26,7 @@ "@angular/compiler-cli": "^22.0.6", "@playwright/test": "^1.61.1", "@types/node": "^26.1.1", + "@types/qrcode": "^1.5.6", "@vitest/coverage-v8": "^4.1.10", "jsdom": "^29.1.1", "prettier": "^3.9.5", @@ -3755,6 +3757,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", @@ -4331,6 +4343,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -4515,6 +4536,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -4681,6 +4720,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -4709,6 +4757,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -5185,6 +5239,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5257,7 +5324,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5975,6 +6041,18 @@ "@lmdb/lmdb-win32-x64": "3.5.4" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -6831,6 +6909,33 @@ "license": "MIT", "optional": true }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-map": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", @@ -6844,6 +6949,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pacote": { "version": "21.5.1", "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", @@ -6959,6 +7073,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7104,6 +7227,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", @@ -7234,6 +7366,154 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -7323,6 +7603,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7333,6 +7622,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -7578,6 +7873,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -9015,6 +9316,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/docanalytics-web/package.json b/docanalytics-web/package.json index 55880c1..59afaa8 100644 --- a/docanalytics-web/package.json +++ b/docanalytics-web/package.json @@ -24,6 +24,7 @@ "@angular/router": "^22.0.6", "@microsoft/signalr": "^10.0.0", "jszip": "^3.10.1", + "qrcode": "^1.5.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -33,6 +34,7 @@ "@angular/compiler-cli": "^22.0.6", "@playwright/test": "^1.61.1", "@types/node": "^26.1.1", + "@types/qrcode": "^1.5.6", "@vitest/coverage-v8": "^4.1.10", "jsdom": "^29.1.1", "prettier": "^3.9.5", diff --git a/docanalytics-web/src/app/app.routes.ts b/docanalytics-web/src/app/app.routes.ts index 7e16bda..731f659 100644 --- a/docanalytics-web/src/app/app.routes.ts +++ b/docanalytics-web/src/app/app.routes.ts @@ -108,6 +108,21 @@ export const routes: Routes = [ loadComponent: () => import('./features/auth/change-password.component').then((m) => m.ChangePasswordComponent), }, + { + path: 'security', + canActivate: [authGuard], + loadComponent: () => + import('./features/security/sessions.component').then((m) => m.SessionsComponent), + }, + { + path: 'security/2fa', + canActivate: [authGuard], + loadComponent: () => + import('./features/security/two-factor-setup.component').then( + (m) => m.TwoFactorSetupComponent, + ), + }, + { path: 'provision', canActivate: [roleGuard(['Developer'])], diff --git a/docanalytics-web/src/app/core/models/auth.model.ts b/docanalytics-web/src/app/core/models/auth.model.ts index 018c7ed..7a3ac77 100644 --- a/docanalytics-web/src/app/core/models/auth.model.ts +++ b/docanalytics-web/src/app/core/models/auth.model.ts @@ -66,3 +66,29 @@ export interface MeResponse { user: AuthUser; sites: SiteSummary[]; } + +export interface TwoFactorChallengeResponse { + requires_two_factor: true; + challenge_token: string; +} + +export type LoginOrChallenge = LoginResponse | TwoFactorChallengeResponse; + +export interface TwoFactorSetupResponse { + secret: string; + otp_auth_uri: string; + manual_key: string; +} + +export interface TwoFactorConfirmResponse { + recovery_codes: string[]; +} + +export interface SessionSummary { + id: string; + device_label: string; + ip_address: string | null; + created_at: string; + last_used_at: string | null; + is_current: boolean; +} diff --git a/docanalytics-web/src/app/core/services/auth.service.ts b/docanalytics-web/src/app/core/services/auth.service.ts index bd53c09..d3a661c 100644 --- a/docanalytics-web/src/app/core/services/auth.service.ts +++ b/docanalytics-web/src/app/core/services/auth.service.ts @@ -6,9 +6,12 @@ import { ApiResponse } from '../models/api-response.model'; import { AuthUser, LoginResponse, + LoginOrChallenge, MeResponse, RefreshResponse, SiteSummary, + TwoFactorSetupResponse, + TwoFactorConfirmResponse, } from '../models/auth.model'; import { Router } from '@angular/router'; @@ -34,22 +37,58 @@ export class AuthService { readonly isAuthenticated = computed(() => !!this._token()); /** POST /auth/login — stores access token + user + sites. The refresh token is set by the server as an HttpOnly cookie. */ - login(email: string, password: string): Observable> { + login(email: string, password: string): Observable> { return this.http - .post>( + .post>( `${this.baseUrl}/login`, { email, password }, { withCredentials: true }, // needed to receive the Set-Cookie ) .pipe( tap((res) => { - if (res.data) { - this.setSession(res.data.token, res.data.user, res.data.sites); + const data = res.data; + // Only set the session on a FULL login — not on the requires_two_factor branch. + if (data && !('requires_two_factor' in data)) { + this.setSession(data.token, data.user, data.sites); } }), ); } + /** POST /auth/login/2fa — completes a 2FA-gated login using the challenge token + a 6-digit (or recovery) code. */ + loginWithTwoFactor(challengeToken: string, code: string): Observable> { + return this.http + .post>( + `${this.baseUrl}/login/2fa`, + { challenge_token: challengeToken, code }, + { withCredentials: true }, + ) + .pipe( + tap((res) => { + if (res.data) this.setSession(res.data.token, res.data.user, res.data.sites); + }), + ); + } + + /** POST /auth/2fa/setup — returns the secret + otpauth URI for client-side QR rendering. */ + setupTwoFactor(): Observable> { + return this.http.post>(`${this.baseUrl}/2fa/setup`, {}); + } + + /** POST /auth/2fa/confirm — verifies the first code, enables 2FA, returns one-time recovery codes. */ + confirmTwoFactor(code: string): Observable> { + return this.http.post>(`${this.baseUrl}/2fa/confirm`, { + code, + }); + } + + /** POST /auth/2fa/disable — re-verifies password, clears 2FA. */ + disableTwoFactor(password: string): Observable> { + return this.http.post>(`${this.baseUrl}/2fa/disable`, { + password, + }); + } + /** POST /auth/forgot-password — always resolves 200 (generic message, no enumeration). */ forgotPassword(email: string): Observable> { return this.http.post>(`${this.baseUrl}/forgot-password`, { diff --git a/docanalytics-web/src/app/features/auth/login.component.html b/docanalytics-web/src/app/features/auth/login.component.html index b467a7c..64d076e 100644 --- a/docanalytics-web/src/app/features/auth/login.component.html +++ b/docanalytics-web/src/app/features/auth/login.component.html @@ -1,55 +1,87 @@ diff --git a/docanalytics-web/src/app/features/auth/login.component.ts b/docanalytics-web/src/app/features/auth/login.component.ts index f2ef36e..762cd70 100644 --- a/docanalytics-web/src/app/features/auth/login.component.ts +++ b/docanalytics-web/src/app/features/auth/login.component.ts @@ -3,11 +3,12 @@ import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; import { AuthService } from '../../core/services/auth.service'; +import { FormsModule } from '@angular/forms'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule, RouterLink], + imports: [ReactiveFormsModule, RouterLink, FormsModule], templateUrl: './login.component.html', styleUrl: './login.component.css', }) @@ -19,6 +20,12 @@ export class LoginComponent { readonly loading = signal(false); readonly errorMessage = signal(null); + readonly step = signal<'credentials' | 'mfa'>('credentials'); + readonly challengeToken = signal(null); + readonly mfaCode = signal(''); + readonly mfaError = signal(null); + readonly mfaLoading = signal(false); + readonly form = this.fb.nonNullable.group({ email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required]], @@ -51,13 +58,20 @@ export class LoginComponent { this.auth.login(email, password).subscribe({ next: (res) => { this.loading.set(false); - if (res.error || !res.data) { + const data = res.data; + if (res.error || !data) { this.errorMessage.set('Invalid email or password.'); return; } + if ('requires_two_factor' in data) { + this.challengeToken.set(data.challenge_token); + this.step.set('mfa'); + return; + } + // Forced first-login password reset takes priority over everything. - if (res.data.must_change_password) { + if (data.must_change_password) { this.router.navigate(['/change-password']); return; } @@ -81,6 +95,43 @@ export class LoginComponent { }); } + submitMfa(): void { + const token = this.challengeToken(); + const code = this.mfaCode().trim(); + if (!token || code.length < 6) return; + + this.mfaError.set(null); + this.mfaLoading.set(true); + this.auth.loginWithTwoFactor(token, code).subscribe({ + next: (res) => { + this.mfaLoading.set(false); + if (res.error || !res.data) { + this.mfaError.set('Invalid or expired code. Try again or use a recovery code.'); + return; + } + if (res.data.must_change_password) { + this.router.navigate(['/change-password']); + return; + } + if (!this.routeByRole()) { + this.mfaError.set('Your account has no site access. Contact your administrator.'); + this.auth.logout(); + } + }, + error: () => { + this.mfaLoading.set(false); + this.mfaError.set('Invalid or expired code. Try again or use a recovery code.'); + }, + }); + } + + backToCredentials(): void { + this.step.set('credentials'); + this.challengeToken.set(null); + this.mfaCode.set(''); + this.mfaError.set(null); + } + /** Route by role: Developer → provisioning console; others → first site. Returns false if nowhere to go. */ private routeByRole(): boolean { if (this.auth.currentUser()?.role === 'Developer') { diff --git a/docanalytics-web/src/app/features/security/sessions.component.css b/docanalytics-web/src/app/features/security/sessions.component.css new file mode 100644 index 0000000..495a740 --- /dev/null +++ b/docanalytics-web/src/app/features/security/sessions.component.css @@ -0,0 +1,156 @@ +.sessions-card { + max-width: 900px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; + color: #e6e9ee; +} + +.sessions-back { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + color: #8fb8ff; + font-size: 0.9rem; + cursor: pointer; + padding: 0; +} + +.sessions-back:hover { + color: #b3d1ff; + text-decoration: underline; +} + +.sessions-head { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.sessions-head h2 { + margin: 0; + color: #f1f3f6; +} + +.sessions-table { + width: 100%; + border-collapse: collapse; + background: #242b35; + border: 1px solid #363f4b; + border-radius: 8px; + overflow: hidden; +} + +.sessions-table th, +.sessions-table td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid #363f4b; + font-size: 0.9rem; + color: #d7dbe2; +} + +.sessions-table th { + color: #9aa4b2; + font-weight: 600; +} + +.sessions-table tr.current { + background: rgba(76, 111, 255, 0.1); +} + +.badge { + margin-left: 8px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; + background: rgba(76, 111, 255, 0.22); + color: #a9bdff; +} + +.btn-revoke { + background: transparent; + border: 1px solid #f87171; + color: #f87171; + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; + font-size: 0.82rem; +} + +.btn-revoke:hover { + background: #f87171; + color: #1a1f27; +} + +.btn-revoke-all { + align-self: flex-start; + background: transparent; + border: 1px solid #6ea8fe; + color: #8fb8ff; + border-radius: 6px; + padding: 9px 16px; + font-weight: 600; + cursor: pointer; +} + +.btn-revoke-all:hover { + background: #4c6fff; + color: #fff; + border-color: #4c6fff; +} + +.alert { + background: rgba(220, 38, 38, 0.12); + border: 1px solid rgba(248, 113, 113, 0.4); + color: #fca5a5; + padding: 10px 12px; + border-radius: 8px; + font-size: 0.86rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.alert button { + background: transparent; + border: 1px solid #fca5a5; + color: #fca5a5; + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; +} + +/* Light theme override */ +:host-context([data-theme='light']) .sessions-card { + color: #1f2430; +} + +:host-context([data-theme='light']) .sessions-head h2 { + color: #1f2430; +} + +:host-context([data-theme='light']) .sessions-table { + background: #fff; + border-color: rgba(128, 128, 128, 0.3); +} + +:host-context([data-theme='light']) .sessions-table th, +:host-context([data-theme='light']) .sessions-table td { + color: #1f2430; + border-color: rgba(128, 128, 128, 0.2); +} + +:host-context([data-theme='light']) .sessions-table th { + color: #6b7280; +} + +:host-context([data-theme='light']) .sessions-back { + color: #4c6fff; +} diff --git a/docanalytics-web/src/app/features/security/sessions.component.html b/docanalytics-web/src/app/features/security/sessions.component.html new file mode 100644 index 0000000..048e516 --- /dev/null +++ b/docanalytics-web/src/app/features/security/sessions.component.html @@ -0,0 +1,54 @@ +
+ +
+

Active sessions

+ Set up two-factor authentication -> +
+ + @if (sessions.error()) { + + } + + @if (sessions.loading()) { +

Loading sessions…

+ } @else { + + + + + + + + + + + + @for (s of sessions.sessions(); track s.id) { + + + + + + + + } + +
DeviceIP addressSigned inLast active
+ {{ s.device_label }} + @if (s.is_current) { + This device + } + {{ s.ip_address ?? '-' }}{{ s.created_at | date: 'short' }}{{ s.last_used_at ?? s.created_at | date: 'short' }} + @if (!s.is_current) { + + } +
+ + + } +
diff --git a/docanalytics-web/src/app/features/security/sessions.component.ts b/docanalytics-web/src/app/features/security/sessions.component.ts new file mode 100644 index 0000000..0ab9eee --- /dev/null +++ b/docanalytics-web/src/app/features/security/sessions.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { DatePipe, Location } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { SessionsService } from './sessions.service'; + +@Component({ + selector: 'app-sessions', + standalone: true, + imports: [DatePipe, RouterLink], + templateUrl: './sessions.component.html', + styleUrl: './sessions.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SessionsComponent implements OnInit { + protected sessions = inject(SessionsService); + + private location = inject(Location); + + ngOnInit(): void { + this.sessions.load(); + } + + protected revoke(id: string): void { + if (confirm('Log out this device?')) this.sessions.revoke(id); + } + + protected revokeOthers(): void { + if (confirm('Log out all other devices? This device will stay signed in.')) { + this.sessions.revokeAllOthers(); + } + } + + goBack(): void { + this.location.back(); + } +} diff --git a/docanalytics-web/src/app/features/security/sessions.service.ts b/docanalytics-web/src/app/features/security/sessions.service.ts new file mode 100644 index 0000000..0d203c8 --- /dev/null +++ b/docanalytics-web/src/app/features/security/sessions.service.ts @@ -0,0 +1,59 @@ +import { HttpClient, HttpContext } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { ApiResponse } from '../../core/models/api-response.model'; +import { SessionSummary } from '../../core/models/auth.model'; +import { SKIP_ERROR_TOAST } from '../../core/interceptors/error.interceptor'; + +@Injectable({ providedIn: 'root' }) +export class SessionsService { + private http = inject(HttpClient); + private baseUrl = `${environment.apiBase}/auth/sessions`; + + readonly sessions = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + + load(): void { + this.loading.set(true); + this.error.set(null); + this.http + .get>(this.baseUrl, { + context: new HttpContext().set(SKIP_ERROR_TOAST, true), + }) + .subscribe({ + next: (res) => { + this.loading.set(false); + this.sessions.set(res.data ?? []); + }, + error: () => { + this.loading.set(false); + this.error.set('Could not load active sessions.'); + }, + }); + } + + revoke(id: string): void { + this.http + .delete>(`${this.baseUrl}/${id}`, { + context: new HttpContext().set(SKIP_ERROR_TOAST, true), + }) + .subscribe({ + next: () => this.sessions.update((list) => list.filter((s) => s.id !== id)), + error: () => this.error.set('Could not revoke that session.'), + }); + } + + revokeAllOthers(): void { + this.http + .post>( + `${this.baseUrl}/revoke-others`, + {}, + { context: new HttpContext().set(SKIP_ERROR_TOAST, true) }, + ) + .subscribe({ + next: () => this.sessions.update((list) => list.filter((s) => s.is_current)), + error: () => this.error.set('Could not log out other devices.'), + }); + } +} diff --git a/docanalytics-web/src/app/features/security/two-factor-setup.component.css b/docanalytics-web/src/app/features/security/two-factor-setup.component.css new file mode 100644 index 0000000..5d6678f --- /dev/null +++ b/docanalytics-web/src/app/features/security/two-factor-setup.component.css @@ -0,0 +1,167 @@ +.tfa-card { + max-width: 460px; + margin: 40px auto; + background: #242b35; + border: 1px solid #363f4b; + border-radius: 14px; + padding: 28px; + display: flex; + flex-direction: column; + gap: 14px; + color: #e6e9ee; +} + +.tfa-back { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + color: #8fb8ff; + font-size: 0.9rem; + cursor: pointer; + padding: 0; + margin-bottom: 4px; +} + +.tfa-back:hover { + color: #b3d1ff; + text-decoration: underline; +} + +.tfa-card h2 { + margin: 0; + color: #c9a6ff; + font-size: 1.4rem; +} + +.tfa-card p { + color: #b7bec8; + line-height: 1.45; +} + +.tfa-card img { + align-self: center; + border-radius: 8px; + padding: 10px; + background: #fff; +} + +.manual-key { + display: block; + padding: 10px 12px; + background: #1b2028; + border: 1px solid #363f4b; + border-radius: 6px; + font-size: 0.9rem; + letter-spacing: 0.05em; + word-break: break-all; + color: #d7dbe2; +} + +.tfa-card input { + padding: 10px 12px; + border: 1px solid #454f5c; + border-radius: 8px; + font-size: 1rem; + background: #1b2028; + color: #f1f3f6; +} + +.tfa-card input::placeholder { + color: #6b7480; +} + +.tfa-card input:focus { + outline: none; + border-color: #6ea8fe; + box-shadow: 0 0 0 3px rgba(110, 168, 254, 0.25); +} + +.tfa-card button[type='submit'], +.tfa-card button.btn-primary { + padding: 11px 16px; + border: none; + border-radius: 8px; + background: #4c6fff; + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.tfa-card button[type='submit']:hover:not(:disabled), +.tfa-card button.btn-primary:hover:not(:disabled) { + background: #6483ff; +} + +.tfa-card button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.alert { + background: rgba(220, 38, 38, 0.12); + border: 1px solid rgba(248, 113, 113, 0.4); + color: #fca5a5; + padding: 10px 12px; + border-radius: 8px; + font-size: 0.86rem; +} + +.alert[role='status'] { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(74, 222, 128, 0.4); + color: #86efac; +} + +.recovery-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.recovery-list code { + display: block; + padding: 8px 10px; + background: #1b2028; + border: 1px solid #363f4b; + border-radius: 6px; + text-align: center; + color: #d7dbe2; +} + +/* Light theme override */ +:host-context([data-theme='light']) .tfa-card { + background: #fff; + border-color: rgba(128, 128, 128, 0.3); + color: #1f2430; +} + +:host-context([data-theme='light']) .tfa-card h2 { + color: #6b2fb3; +} + +:host-context([data-theme='light']) .tfa-card p { + color: #4b5563; +} + +:host-context([data-theme='light']) .manual-key, +:host-context([data-theme='light']) .recovery-list code { + background: #f5f3f8; + border-color: #ece8f1; + color: #1f2430; +} + +:host-context([data-theme='light']) .tfa-card input { + background: #fff; + border-color: #ece8f1; + color: #1f2430; +} + +:host-context([data-theme='light']) .tfa-back { + color: #4c6fff; +} diff --git a/docanalytics-web/src/app/features/security/two-factor-setup.component.html b/docanalytics-web/src/app/features/security/two-factor-setup.component.html new file mode 100644 index 0000000..41594c3 --- /dev/null +++ b/docanalytics-web/src/app/features/security/two-factor-setup.component.html @@ -0,0 +1,54 @@ +
+ +

Two-factor authentication

+ + @if (error()) { + + } + + @if (step() === 'loading') { +

Setting up…

+ } + + @if (step() === 'scan') { +

1. Scan this QR code with Google Authenticator, Authy, or 1Password.

+ @if (qrDataUrl()) { + 2FA QR code + } +

Can't scan? Enter this key manually:

+ {{ manualKey() }} + +

2. Enter the 6-digit code it generates:

+ + + } + + @if (step() === 'confirmed') { +
2FA is now enabled on your account.
+

+ Save these recovery codes now — they are shown only once. Each can be used + once if you lose your device. +

+
    + @for (rc of recoveryCodes(); track rc) { +
  • + {{ rc }} +
  • + } +
+ } +
diff --git a/docanalytics-web/src/app/features/security/two-factor-setup.component.ts b/docanalytics-web/src/app/features/security/two-factor-setup.component.ts new file mode 100644 index 0000000..29292f5 --- /dev/null +++ b/docanalytics-web/src/app/features/security/two-factor-setup.component.ts @@ -0,0 +1,70 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AuthService } from '../../core/services/auth.service'; +import { toDataURL } from 'qrcode'; +import { Location } from '@angular/common'; + +@Component({ + selector: 'app-two-factor-setup', + standalone: true, + imports: [FormsModule], + templateUrl: './two-factor-setup.component.html', + styleUrl: './two-factor-setup.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TwoFactorSetupComponent { + private auth = inject(AuthService); + + protected step = signal<'loading' | 'scan' | 'confirmed'>('loading'); + protected qrDataUrl = signal(null); + protected manualKey = signal(''); + protected code = signal(''); + protected error = signal(null); + protected loading = signal(false); + protected recoveryCodes = signal([]); + + constructor(private location: Location) { + this.startSetup(); + } + + goBack(): void { + this.location.back(); + } + + private startSetup(): void { + this.auth.setupTwoFactor().subscribe({ + next: async (res) => { + if (!res.data) { + this.error.set('Could not start 2FA setup.'); + return; + } + this.manualKey.set(res.data.manual_key); + // QR is rendered CLIENT-SIDE from the otpauth:// URI — never generated server-side. + this.qrDataUrl.set(await toDataURL(res.data.otp_auth_uri)); + this.step.set('scan'); + }, + error: () => this.error.set('Could not start 2FA setup.'), + }); + } + + protected confirm(): void { + if (this.code().length !== 6) return; + this.loading.set(true); + this.error.set(null); + this.auth.confirmTwoFactor(this.code()).subscribe({ + next: (res) => { + this.loading.set(false); + if (res.error || !res.data) { + this.error.set('Invalid code. Check your app and try again.'); + return; + } + this.recoveryCodes.set(res.data.recovery_codes); + this.step.set('confirmed'); + }, + error: () => { + this.loading.set(false); + this.error.set('Invalid code. Check your app and try again.'); + }, + }); + } +} diff --git a/docanalytics-web/src/app/layout/shell/shell.component.css b/docanalytics-web/src/app/layout/shell/shell.component.css index 958eced..519cb1d 100644 --- a/docanalytics-web/src/app/layout/shell/shell.component.css +++ b/docanalytics-web/src/app/layout/shell/shell.component.css @@ -380,6 +380,25 @@ nav a.active { background: color-mix(in srgb, currentColor 8%, transparent); } +.me-card-security { + display: block; + margin-top: 14px; + width: 100%; + padding: 9px 0; + border-radius: 8px; + text-align: center; + cursor: pointer; + font-weight: 600; + color: inherit; + background: transparent; + border: 1px solid var(--cool-gray, rgba(128, 128, 128, 0.35)); + text-decoration: none; +} + +.me-card-security:hover { + background: color-mix(in srgb, currentColor 8%, transparent); +} + /* bell wrapper is the positioning anchor */ .al-dd { position: relative; diff --git a/docanalytics-web/src/app/layout/shell/shell.component.html b/docanalytics-web/src/app/layout/shell/shell.component.html index fb2e50b..c843a9f 100644 --- a/docanalytics-web/src/app/layout/shell/shell.component.html +++ b/docanalytics-web/src/app/layout/shell/shell.component.html @@ -133,6 +133,9 @@ {{ user()?.role }} + + Security + From 4e2184cb32eecb8f79348fbd6afb763c10c16d64 Mon Sep 17 00:00:00 2001 From: Akash Goswami Date: Tue, 28 Jul 2026 11:35:31 +0530 Subject: [PATCH 2/3] feat(auth): add TOTP-based 2FA and refresh-token session management --- docanalytics-web/angular.json | 1 + docanalytics-web/package-lock.json | 161 ++++++++++--- .../app/core/services/auth-two-factor.spec.ts | 166 +++++++++++++ .../core/services/auth.service.extra.spec.ts | 202 ++++++++++++++++ .../alerts/alerts.service.extra.spec.ts | 218 ++++++++++++++++++ .../security/sessions.component.spec.ts | 156 +++++++++++++ .../security/sessions.service.spec.ts | 123 ++++++++++ .../two-factor-setup.component.spec.ts | 164 +++++++++++++ 8 files changed, 1162 insertions(+), 29 deletions(-) create mode 100644 docanalytics-web/src/app/core/services/auth-two-factor.spec.ts create mode 100644 docanalytics-web/src/app/core/services/auth.service.extra.spec.ts create mode 100644 docanalytics-web/src/app/features/alerts/alerts.service.extra.spec.ts create mode 100644 docanalytics-web/src/app/features/security/sessions.component.spec.ts create mode 100644 docanalytics-web/src/app/features/security/sessions.service.spec.ts create mode 100644 docanalytics-web/src/app/features/security/two-factor-setup.component.spec.ts diff --git a/docanalytics-web/angular.json b/docanalytics-web/angular.json index 2c17e1b..e20a93e 100644 --- a/docanalytics-web/angular.json +++ b/docanalytics-web/angular.json @@ -85,6 +85,7 @@ ], "coverageReporters": [ "text-summary", + "text", "cobertura" ], "coverageThresholds": { diff --git a/docanalytics-web/package-lock.json b/docanalytics-web/package-lock.json index 62ac182..44165f5 100644 --- a/docanalytics-web/package-lock.json +++ b/docanalytics-web/package-lock.json @@ -305,13 +305,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "22.0.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.0.7.tgz", - "integrity": "sha512-bKgnBB0LPAj44uVXfW0UO1rQBb3HGXDZxa1bLtESr/KCK4j5iiaXlHqvJjc/z0em1Ds9WNQOfNXjV3IdJo9sSw==", + "version": "22.0.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.0.8.tgz", + "integrity": "sha512-9WjTKnW/5oIw4hNNcd0rSYtgHRrHKKAkG6+bdLqTCY68P2brO2yEJM2JjYtMYk/WSovknb0GxUP4n3dY3IxPug==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.0.7", + "@angular-devkit/core": "22.0.8", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.4.0", @@ -323,6 +323,34 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { + "version": "22.0.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.0.8.tgz", + "integrity": "sha512-Hnr4SCkxQM+xtq4uERC09qwTZpt97t4CwDZYzv/HbQA4pMYDfTxvIRHCyl+UHf4NbkC7ZVgyiab+KzTfuc609Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular/build": { "version": "22.0.7", "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.0.7.tgz", @@ -426,19 +454,19 @@ } }, "node_modules/@angular/cli": { - "version": "22.0.7", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.0.7.tgz", - "integrity": "sha512-5r+fgP8ARnXZeylAGt5BuToVcR8Vn2QQZ6dPMe7V9o4NdvJqqOKmE7fEiRe7KaxIx5WSDhuAhgW9t+scLZQbvw==", + "version": "22.0.8", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.0.8.tgz", + "integrity": "sha512-kefbTsmf7sEmW5g3NIRvH0OrM18I1jjZHlB9KABgRQhsDfD/WgfLt6aSLJQeFDlAOs+ehDPzzOwd9l48wyBlFQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2200.7", - "@angular-devkit/core": "22.0.7", - "@angular-devkit/schematics": "22.0.7", + "@angular-devkit/architect": "0.2200.8", + "@angular-devkit/core": "22.0.8", + "@angular-devkit/schematics": "22.0.8", "@inquirer/prompts": "8.4.2", "@listr2/prompt-adapter-inquirer": "4.2.3", "@modelcontextprotocol/sdk": "1.29.0", - "@schematics/angular": "22.0.7", + "@schematics/angular": "22.0.8", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.52.0", "ini": "6.0.0", @@ -460,6 +488,53 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { + "version": "0.2200.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2200.8.tgz", + "integrity": "sha512-LFKK2v96nO0ESM9nftlCkTy+O/66AxNkwUpG+Q1er7LLNR2S8UReZnQ1/RhMkldCWxsmp2rQzdgHWnubaY/ZfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.0.8", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/core": { + "version": "22.0.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.0.8.tgz", + "integrity": "sha512-Hnr4SCkxQM+xtq4uERC09qwTZpt97t4CwDZYzv/HbQA4pMYDfTxvIRHCyl+UHf4NbkC7ZVgyiab+KzTfuc609Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular/common": { "version": "22.0.7", "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.0.7.tgz", @@ -1592,9 +1667,9 @@ "optional": true }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "dev": true, "license": "MIT", "engines": { @@ -3595,14 +3670,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "22.0.7", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.0.7.tgz", - "integrity": "sha512-/GVLhB5zaxYGIaC4GFfsfk81SDq9ht0HbEHhHB+t1SqNPV8gnULDV2ZE0Cg+LGj+3YnPtSwpb7tS0vaHUcihVg==", + "version": "22.0.8", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.0.8.tgz", + "integrity": "sha512-zD2WLe15SoKYyBZH1GTLLaVABNNObHkH4soQqXb9agN6zkrpE0naeWaBSFSWD+95VKCiMZl8LkiOXvK+6b8S6w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.0.7", - "@angular-devkit/schematics": "22.0.7", + "@angular-devkit/core": "22.0.8", + "@angular-devkit/schematics": "22.0.8", "jsonc-parser": "3.3.1", "typescript": "6.0.3" }, @@ -3612,6 +3687,34 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { + "version": "22.0.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.0.8.tgz", + "integrity": "sha512-Hnr4SCkxQM+xtq4uERC09qwTZpt97t4CwDZYzv/HbQA4pMYDfTxvIRHCyl+UHf4NbkC7ZVgyiab+KzTfuc609Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@sigstore/bundle": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", @@ -4217,16 +4320,16 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -8282,9 +8385,9 @@ "license": "MIT" }, "node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -9466,9 +9569,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "dev": true, "license": "MIT", "engines": { diff --git a/docanalytics-web/src/app/core/services/auth-two-factor.spec.ts b/docanalytics-web/src/app/core/services/auth-two-factor.spec.ts new file mode 100644 index 0000000..904d972 --- /dev/null +++ b/docanalytics-web/src/app/core/services/auth-two-factor.spec.ts @@ -0,0 +1,166 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; +import { AuthService } from './auth.service'; +import { environment } from '../../../environments/environment'; + +describe('AuthService — 2FA & session extras', () => { + let service: AuthService; + let httpMock: HttpTestingController; + const baseUrl = `${environment.apiBase}/auth`; + + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])], + }); + service = TestBed.inject(AuthService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('login() does NOT set the session when the response is a 2FA challenge', () => { + service.login('user@test.com', 'Password123!').subscribe(); + + httpMock.expectOne(`${baseUrl}/login`).flush({ + data: { requires_two_factor: true, challenge_token: 'chal-123' }, + error: null, + }); + + expect(service.isAuthenticated()).toBe(false); + expect(service.currentUser()).toBeNull(); + }); + + it('login() sets the session on a full (non-2FA) login', () => { + service.login('user@test.com', 'Password123!').subscribe(); + + httpMock.expectOne(`${baseUrl}/login`).flush({ + data: { + token: 'jwt-abc', + user: { id: 'u1', email: 'user@test.com', role: 'Admin' }, + sites: [{ site_id: 's1', site_name: 'Site One' }], + must_change_password: false, + }, + error: null, + }); + + expect(service.isAuthenticated()).toBe(true); + expect(service.token()).toBe('jwt-abc'); + }); + + it('loginWithTwoFactor() posts the challenge token + code and sets the session on success', () => { + service.loginWithTwoFactor('chal-123', '123456').subscribe(); + + const req = httpMock.expectOne(`${baseUrl}/login/2fa`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ challenge_token: 'chal-123', code: '123456' }); + + req.flush({ + data: { + token: 'jwt-xyz', + user: { id: 'u1', email: 'user@test.com', role: 'Admin' }, + sites: [], + must_change_password: false, + }, + error: null, + }); + + expect(service.isAuthenticated()).toBe(true); + expect(service.token()).toBe('jwt-xyz'); + }); + + it('loginWithTwoFactor() does not set the session when the server returns no data', () => { + service.loginWithTwoFactor('chal-123', '000000').subscribe(); + + httpMock + .expectOne(`${baseUrl}/login/2fa`) + .flush({ data: null, error: 'Invalid or expired code.' }); + + expect(service.isAuthenticated()).toBe(false); + }); + + it('setupTwoFactor() posts to /2fa/setup', () => { + service.setupTwoFactor().subscribe(); + + const req = httpMock.expectOne(`${baseUrl}/2fa/setup`); + expect(req.request.method).toBe('POST'); + req.flush({ + data: { secret: 's', otp_auth_uri: 'otpauth://...', manual_key: 'S ECR ET' }, + error: null, + }); + }); + + it('confirmTwoFactor() posts the code to /2fa/confirm', () => { + service.confirmTwoFactor('123456').subscribe(); + + const req = httpMock.expectOne(`${baseUrl}/2fa/confirm`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ code: '123456' }); + req.flush({ data: { recovery_codes: ['AAAA-1111'] }, error: null }); + }); + + it('disableTwoFactor() posts the password to /2fa/disable', () => { + service.disableTwoFactor('Password123!').subscribe(); + + const req = httpMock.expectOne(`${baseUrl}/2fa/disable`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ password: 'Password123!' }); + req.flush({ data: { disabled: true }, error: null }); + }); + + it('forgotPassword() posts the email', () => { + service.forgotPassword('user@test.com').subscribe(); + + const req = httpMock.expectOne(`${baseUrl}/forgot-password`); + expect(req.request.body).toEqual({ email: 'user@test.com' }); + req.flush({ data: { message: 'ok' }, error: null }); + }); + + it('resetPassword() posts token + new_password', () => { + service.resetPassword('reset-tok', 'NewPassword123!').subscribe(); + + const req = httpMock.expectOne(`${baseUrl}/reset-password`); + expect(req.request.body).toEqual({ token: 'reset-tok', new_password: 'NewPassword123!' }); + req.flush({ data: { reset: true }, error: null }); + }); + + it('hasSiteAccess() reflects the sites returned by a full login', () => { + service.login('user@test.com', 'Password123!').subscribe(); + httpMock.expectOne(`${baseUrl}/login`).flush({ + data: { + token: 'jwt-abc', + user: { id: 'u1', email: 'user@test.com', role: 'Admin' }, + sites: [{ site_id: 's1', site_name: 'Site One' }], + must_change_password: false, + }, + error: null, + }); + + expect(service.hasSiteAccess('s1')).toBe(true); + expect(service.hasSiteAccess('unknown-site')).toBe(false); + }); + + it('logout() clears the session even if the server call fails', () => { + service.login('user@test.com', 'Password123!').subscribe(); + httpMock.expectOne(`${baseUrl}/login`).flush({ + data: { + token: 'jwt-abc', + user: { id: 'u1', email: 'user@test.com', role: 'Admin' }, + sites: [], + must_change_password: false, + }, + error: null, + }); + expect(service.isAuthenticated()).toBe(true); + + service.logout(); + httpMock + .expectOne(`${baseUrl}/logout`) + .flush(null, { status: 500, statusText: 'Server Error' }); + + expect(service.isAuthenticated()).toBe(false); + expect(service.currentUser()).toBeNull(); + }); +}); diff --git a/docanalytics-web/src/app/core/services/auth.service.extra.spec.ts b/docanalytics-web/src/app/core/services/auth.service.extra.spec.ts new file mode 100644 index 0000000..e8c3a54 --- /dev/null +++ b/docanalytics-web/src/app/core/services/auth.service.extra.spec.ts @@ -0,0 +1,202 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; +import { AuthService } from './auth.service'; +import { environment } from '../../../environments/environment'; + +describe('AuthService — extra coverage (forgotPassword, resetPassword, ensureSession, routeAfterLogin, refreshToken)', () => { + let httpMock: HttpTestingController; + let routerMock: { navigate: ReturnType }; + const base = `${environment.apiBase}/auth`; + + function setup() { + routerMock = { navigate: vi.fn() }; + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: Router, useValue: routerMock }, + ], + }); + httpMock = TestBed.inject(HttpTestingController); + return TestBed.inject(AuthService); + } + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + httpMock?.verify(); + }); + + it('forgotPassword() posts the email and returns the response', () => { + const service = setup(); + let result: any; + service.forgotPassword('a@b.com').subscribe((res) => (result = res)); + + const req = httpMock.expectOne(`${base}/forgot-password`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ email: 'a@b.com' }); + req.flush({ data: { message: 'ok' }, error: null }); + + expect(result.data.message).toBe('ok'); + }); + + it('resetPassword() posts the token and new password', () => { + const service = setup(); + let result: any; + service.resetPassword('tok123', 'NewPass!1').subscribe((res) => (result = res)); + + const req = httpMock.expectOne(`${base}/reset-password`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ token: 'tok123', new_password: 'NewPass!1' }); + req.flush({ data: { reset: true }, error: null }); + + expect(result.data.reset).toBe(true); + }); + + describe('ensureSession()', () => { + it('returns false immediately when there is no token', async () => { + const service = setup(); + const result = await service.ensureSession(); + + expect(result).toBe(false); + httpMock.expectNone(`${base}/me`); + }); + + it('returns true without a network call when the user is already loaded', async () => { + localStorage.setItem('da_token', 'existing-token'); + const service = setup(); + + service.loadMe().subscribe(); + httpMock.expectOne(`${base}/me`).flush({ + data: { user: { id: 'u1', email: 'a@b.com', role: 'Viewer' }, sites: [] }, + error: null, + }); + + const result = await service.ensureSession(); + expect(result).toBe(true); + httpMock.expectNone(`${base}/me`); + }); + + it('calls /me when a token exists but the user is not loaded, and returns true on success', async () => { + localStorage.setItem('da_token', 'existing-token'); + const service = setup(); + + const promise = service.ensureSession(); + httpMock.expectOne(`${base}/me`).flush({ + data: { user: { id: 'u1', email: 'a@b.com', role: 'Viewer' }, sites: [] }, + error: null, + }); + + expect(await promise).toBe(true); + }); + + it('logs out and returns false when /me fails', async () => { + localStorage.setItem('da_token', 'stale-token'); + const service = setup(); + + const promise = service.ensureSession(); + httpMock.expectOne(`${base}/me`).flush('error', { status: 500, statusText: 'Server Error' }); + + // let the promise rejection's .catch() -> logout() microtask run + await Promise.resolve(); + await Promise.resolve(); + + httpMock.expectOne(`${base}/logout`).flush({ data: null, error: null }); + + expect(await promise).toBe(false); + expect(localStorage.getItem('da_token')).toBeNull(); + }); + }); + + describe('routeAfterLogin()', () => { + async function loadUser(role: string, sites: { site_id: string }[]) { + const service = setup(); + service.loadMe().subscribe(); + httpMock.expectOne(`${base}/me`).flush({ + data: { user: { id: 'u1', email: 'a@b.com', role }, sites }, + error: null, + }); + return service; + } + + it('navigates to /provision for a Developer', async () => { + const service = await loadUser('Developer', []); + service.routeAfterLogin(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/provision']); + }); + + it('navigates to the first site for a non-developer with sites', async () => { + const service = await loadUser('Viewer', [{ site_id: 'site-1' } as any]); + service.routeAfterLogin(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/site', 'site-1']); + }); + + it('navigates to /login when there are no sites', async () => { + const service = await loadUser('Viewer', []); + service.routeAfterLogin(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + }); + + describe('refreshToken()', () => { + it('sets the token and persists it to localStorage on success', async () => { + const service = setup(); + const promise = new Promise((resolve) => { + service.refreshToken().subscribe(resolve); + }); + + httpMock.expectOne(`${base}/refresh`).flush({ data: { token: 'new-token' }, error: null }); + + expect(await promise).toBe('new-token'); + expect(localStorage.getItem('da_token')).toBe('new-token'); + expect(service.token()).toBe('new-token'); + }); + + it('clears the session and resolves null when the refresh is rejected', async () => { + localStorage.setItem('da_token', 'stale-token'); + const service = setup(); + + const promise = new Promise((resolve) => { + service.refreshToken().subscribe(resolve); + }); + + httpMock + .expectOne(`${base}/refresh`) + .flush('nope', { status: 401, statusText: 'Unauthorized' }); + + expect(await promise).toBeNull(); + expect(localStorage.getItem('da_token')).toBeNull(); + }); + + it('shares a single in-flight request across concurrent callers', () => { + const service = setup(); + + let firstResult: string | null | undefined; + let secondResult: string | null | undefined; + service.refreshToken().subscribe((v) => (firstResult = v)); + service.refreshToken().subscribe((v) => (secondResult = v)); + + // Only ONE actual HTTP request should have gone out for both callers. + const req = httpMock.expectOne(`${base}/refresh`); + req.flush({ data: { token: 'shared-token' }, error: null }); + + expect(firstResult).toBe('shared-token'); + expect(secondResult).toBe('shared-token'); + }); + + it('resolves null (without clearing the session) when the refresh response has no data', async () => { + const service = setup(); + const promise = new Promise((resolve) => { + service.refreshToken().subscribe(resolve); + }); + + httpMock.expectOne(`${base}/refresh`).flush({ data: null, error: 'unexpected' }); + + expect(await promise).toBeNull(); + }); + }); +}); diff --git a/docanalytics-web/src/app/features/alerts/alerts.service.extra.spec.ts b/docanalytics-web/src/app/features/alerts/alerts.service.extra.spec.ts new file mode 100644 index 0000000..984b8eb --- /dev/null +++ b/docanalytics-web/src/app/features/alerts/alerts.service.extra.spec.ts @@ -0,0 +1,218 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { AlertsService } from './alerts.service'; +import { environment } from '../../../environments/environment'; + +describe('AlertsService — extra coverage', () => { + let httpMock: HttpTestingController; + const base = `${environment.apiBase}/alerts`; + + function setup() { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + httpMock = TestBed.inject(HttpTestingController); + return TestBed.inject(AlertsService); + } + + afterEach(() => { + httpMock?.verify(); + }); + + const rule: any = { + id: 'r1', + name: 'High volume', + threshold_percent: 10, + window_minutes: 30, + email: 'a@b.com', + cooldown_minutes: 60, + is_enabled: true, + }; + + const payload: any = { + name: 'High volume', + threshold_percent: 10, + window_minutes: 30, + email: 'a@b.com', + cooldown_minutes: 60, + is_enabled: true, + }; + + it('loadRecipients() populates recipients from the response', () => { + const service = setup(); + service.loadRecipients(); + + const req = httpMock.expectOne(`${base}/recipients`); + expect(req.request.method).toBe('GET'); + req.flush({ data: [{ email: 'a@b.com' }], error: null }); + + expect(service.recipients()).toEqual([{ email: 'a@b.com' }]); + }); + + it('loadRecipients() defaults to an empty list when data is missing', () => { + const service = setup(); + service.loadRecipients(); + + httpMock.expectOne(`${base}/recipients`).flush({ data: null, error: null }); + + expect(service.recipients()).toEqual([]); + }); + + describe('loadRules()', () => { + it('sets rules and toggles loading on success', () => { + const service = setup(); + service.loadRules(); + expect(service.loading()).toBe(true); + + httpMock.expectOne(base).flush({ data: [rule], error: null }); + + expect(service.loading()).toBe(false); + expect(service.rules()).toEqual([rule]); + expect(service.error()).toBeNull(); + }); + + it('sets an error and stops loading on failure', () => { + const service = setup(); + service.loadRules(); + + httpMock.expectOne(base).flush('nope', { status: 500, statusText: 'Server Error' }); + + expect(service.loading()).toBe(false); + expect(service.error()).toBe('Could not load alert rules.'); + }); + }); + + it('create() posts the payload, toggles saving, and reloads the rules list', () => { + const service = setup(); + service.create(payload); + expect(service.saving()).toBe(true); + + const postReq = httpMock.expectOne(base); + expect(postReq.request.method).toBe('POST'); + expect(postReq.request.body).toEqual(payload); + postReq.flush({ data: rule, error: null }); + + expect(service.saving()).toBe(false); + + httpMock.expectOne(base).flush({ data: [rule], error: null }); + expect(service.rules()).toEqual([rule]); + }); + + it('update() puts the payload, toggles saving, and reloads the rules list', () => { + const service = setup(); + service.update('r1', payload); + expect(service.saving()).toBe(true); + + const putReq = httpMock.expectOne(`${base}/r1`); + expect(putReq.request.method).toBe('PUT'); + expect(putReq.request.body).toEqual(payload); + putReq.flush({ data: rule, error: null }); + + expect(service.saving()).toBe(false); + httpMock.expectOne(base).flush({ data: [rule], error: null }); + }); + + it('toggle() calls update() with is_enabled flipped and the same other fields', () => { + const service = setup(); + service.toggle(rule); + + const putReq = httpMock.expectOne(`${base}/${rule.id}`); + expect(putReq.request.method).toBe('PUT'); + expect(putReq.request.body).toEqual({ + name: rule.name, + threshold_percent: rule.threshold_percent, + window_minutes: rule.window_minutes, + email: rule.email, + cooldown_minutes: rule.cooldown_minutes, + is_enabled: !rule.is_enabled, + }); + putReq.flush({ data: rule, error: null }); + + httpMock.expectOne(base).flush({ data: [rule], error: null }); + }); + + it('remove() deletes the rule and reloads the rules list', () => { + const service = setup(); + service.remove('r1'); + + const delReq = httpMock.expectOne(`${base}/r1`); + expect(delReq.request.method).toBe('DELETE'); + delReq.flush(null); + + httpMock.expectOne(base).flush({ data: [], error: null }); + expect(service.rules()).toEqual([]); + }); + + describe('loadNotifications()', () => { + it('defaults to unread-only, sets notifications, and invokes onDone', () => { + const service = setup(); + const onDone = vi.fn(); + service.loadNotifications(true, onDone); + + const req = httpMock.expectOne(`${base}/notifications?unread=true`); + req.flush({ data: [{ id: 'n1', is_read: false }], error: null }); + + expect(service.notifications()).toEqual([{ id: 'n1', is_read: false }]); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('fetches all notifications when unreadOnly is false', () => { + const service = setup(); + service.loadNotifications(false); + + httpMock.expectOne(`${base}/notifications`).flush({ data: [], error: null }); + }); + }); + + it('markRead() marks only the matching notification as read', () => { + const service = setup(); + service.loadNotifications(true); + httpMock.expectOne(`${base}/notifications?unread=true`).flush({ + data: [ + { id: 'n1', is_read: false }, + { id: 'n2', is_read: false }, + ], + error: null, + }); + + service.markRead('n1'); + httpMock.expectOne(`${base}/notifications/n1/read`).flush({ data: null, error: null }); + + expect(service.notifications()).toEqual([ + { id: 'n1', is_read: true }, + { id: 'n2', is_read: false }, + ]); + expect(service.unreadCount()).toBe(1); + }); + + it('markAllRead() marks every notification as read', () => { + const service = setup(); + service.loadNotifications(true); + httpMock.expectOne(`${base}/notifications?unread=true`).flush({ + data: [ + { id: 'n1', is_read: false }, + { id: 'n2', is_read: false }, + ], + error: null, + }); + + service.markAllRead(); + httpMock.expectOne(`${base}/notifications/read-all`).flush({ data: null, error: null }); + + expect(service.unreadCount()).toBe(0); + }); + + it('clear() empties the notifications list', () => { + const service = setup(); + service.loadNotifications(true); + httpMock.expectOne(`${base}/notifications?unread=true`).flush({ + data: [{ id: 'n1', is_read: false }], + error: null, + }); + expect(service.notifications().length).toBe(1); + + service.clear(); + expect(service.notifications()).toEqual([]); + }); +}); diff --git a/docanalytics-web/src/app/features/security/sessions.component.spec.ts b/docanalytics-web/src/app/features/security/sessions.component.spec.ts new file mode 100644 index 0000000..7980095 --- /dev/null +++ b/docanalytics-web/src/app/features/security/sessions.component.spec.ts @@ -0,0 +1,156 @@ +import { TestBed } from '@angular/core/testing'; +import { Location } from '@angular/common'; +import { provideRouter } from '@angular/router'; +import { signal } from '@angular/core'; +import { SessionsComponent } from './sessions.component'; +import { SessionsService } from './sessions.service'; + +describe('SessionsComponent', () => { + let sessionsServiceMock: { + load: ReturnType; + revoke: ReturnType; + revokeAllOthers: ReturnType; + sessions: ReturnType>; + loading: ReturnType>; + error: ReturnType>; + }; + let locationMock: { back: ReturnType }; + + function createFixture() { + const fixture = TestBed.createComponent(SessionsComponent); + return { fixture, component: fixture.componentInstance }; + } + + beforeEach(() => { + sessionsServiceMock = { + load: vi.fn(), + revoke: vi.fn(), + revokeAllOthers: vi.fn(), + sessions: signal([]), + loading: signal(false), + error: signal(null), + }; + locationMock = { back: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + { provide: SessionsService, useValue: sessionsServiceMock }, + { provide: Location, useValue: locationMock }, + provideRouter([]), + ], + }); + }); + + it('calls sessions.load() on init and renders the loading state', () => { + sessionsServiceMock.loading.set(true); + const { fixture } = createFixture(); + fixture.detectChanges(); + + expect(sessionsServiceMock.load).toHaveBeenCalledTimes(1); + expect(fixture.nativeElement.textContent).toContain('Loading sessions'); + }); + + it('renders an error alert with a working Retry button', () => { + sessionsServiceMock.error.set('Could not load active sessions.'); + const { fixture } = createFixture(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Could not load active sessions.'); + const retryBtn: HTMLButtonElement = fixture.nativeElement.querySelector('.alert button'); + retryBtn.click(); + + expect(sessionsServiceMock.load).toHaveBeenCalledTimes(2); + }); + + it('renders the sessions table with current-device badge and per-row revoke buttons', () => { + sessionsServiceMock.sessions.set([ + { + id: 's1', + device_label: 'Chrome on Windows', + ip_address: '1.2.3.4', + created_at: '2026-01-01T00:00:00Z', + last_used_at: '2026-01-02T00:00:00Z', + is_current: true, + }, + { + id: 's2', + device_label: 'Firefox on Mac', + ip_address: '5.6.7.8', + created_at: '2026-01-01T00:00:00Z', + last_used_at: null, + is_current: false, + }, + ]); + const { fixture } = createFixture(); + fixture.detectChanges(); + + const text: string = fixture.nativeElement.textContent; + expect(text).toContain('Chrome on Windows'); + expect(text).toContain('This device'); + expect(text).toContain('Firefox on Mac'); + expect(fixture.nativeElement.querySelectorAll('.btn-revoke').length).toBe(1); + }); + + it('goBack() delegates to Location.back()', () => { + const { fixture, component } = createFixture(); + fixture.detectChanges(); + component.goBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + }); + + describe('revoke()', () => { + it('calls sessions.revoke(id) when the user confirms', () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + const { fixture, component } = createFixture(); + fixture.detectChanges(); + + (component as any).revoke('session-1'); + + expect(sessionsServiceMock.revoke).toHaveBeenCalledWith('session-1'); + }); + + it('does not call sessions.revoke() when the user cancels', () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + const { fixture, component } = createFixture(); + fixture.detectChanges(); + + (component as any).revoke('session-1'); + + expect(sessionsServiceMock.revoke).not.toHaveBeenCalled(); + }); + }); + + describe('revokeOthers()', () => { + it('calls sessions.revokeAllOthers() via the rendered button when confirmed', () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + sessionsServiceMock.sessions.set([ + { + id: 's1', + device_label: 'A', + ip_address: null, + created_at: '2026-01-01T00:00:00Z', + last_used_at: null, + is_current: true, + }, + ]); + const { fixture } = createFixture(); + fixture.detectChanges(); + + const btn: HTMLButtonElement = fixture.nativeElement.querySelector('.btn-revoke-all'); + btn.click(); + + expect(sessionsServiceMock.revokeAllOthers).toHaveBeenCalledTimes(1); + }); + + it('does not call sessions.revokeAllOthers() when the user cancels', () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + const { fixture, component } = createFixture(); + fixture.detectChanges(); + + (component as any).revokeOthers(); + + expect(sessionsServiceMock.revokeAllOthers).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/docanalytics-web/src/app/features/security/sessions.service.spec.ts b/docanalytics-web/src/app/features/security/sessions.service.spec.ts new file mode 100644 index 0000000..82b0961 --- /dev/null +++ b/docanalytics-web/src/app/features/security/sessions.service.spec.ts @@ -0,0 +1,123 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { SessionsService } from './sessions.service'; +import { environment } from '../../../environments/environment'; +import { SessionSummary } from '../../core/models/auth.model'; + +describe('SessionsService', () => { + let service: SessionsService; + let httpMock: HttpTestingController; + const baseUrl = `${environment.apiBase}/auth/sessions`; + + const session1: SessionSummary = { + id: 's1', + device_label: 'Chrome on Windows', + ip_address: '1.2.3.4', + created_at: '2026-01-01T00:00:00Z', + last_used_at: '2026-01-02T00:00:00Z', + is_current: true, + } as SessionSummary; + + const session2: SessionSummary = { + id: 's2', + device_label: 'Firefox on Mac', + ip_address: '5.6.7.8', + created_at: '2026-01-01T00:00:00Z', + last_used_at: null, + is_current: false, + } as SessionSummary; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(SessionsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('load() populates sessions on success', () => { + service.load(); + expect(service.loading()).toBe(true); + + const req = httpMock.expectOne(baseUrl); + expect(req.request.method).toBe('GET'); + req.flush({ data: [session1, session2], error: null }); + + expect(service.loading()).toBe(false); + expect(service.sessions()).toEqual([session1, session2]); + expect(service.error()).toBeNull(); + }); + + it('load() sets an error message on failure', () => { + service.load(); + httpMock + .expectOne(baseUrl) + .flush({ message: 'boom' }, { status: 500, statusText: 'Server Error' }); + + expect(service.loading()).toBe(false); + expect(service.error()).toBe('Could not load active sessions.'); + }); + + it('load() clears any previous error before firing a new request', () => { + service.load(); + httpMock + .expectOne(baseUrl) + .flush({ message: 'boom' }, { status: 500, statusText: 'Server Error' }); + expect(service.error()).not.toBeNull(); + + service.load(); + expect(service.error()).toBeNull(); + httpMock.expectOne(baseUrl).flush({ data: [], error: null }); + }); + + it('revoke() removes the matching session from the local list on success', () => { + service.sessions.set([session1, session2]); + + service.revoke('s2'); + + const req = httpMock.expectOne(`${baseUrl}/s2`); + expect(req.request.method).toBe('DELETE'); + req.flush({ data: { revoked: true }, error: null }); + + expect(service.sessions()).toEqual([session1]); + }); + + it('revoke() sets an error and leaves the list untouched on failure', () => { + service.sessions.set([session1, session2]); + + service.revoke('s2'); + httpMock + .expectOne(`${baseUrl}/s2`) + .flush({ message: 'boom' }, { status: 500, statusText: 'Server Error' }); + + expect(service.sessions()).toEqual([session1, session2]); + expect(service.error()).toBe('Could not revoke that session.'); + }); + + it('revokeAllOthers() keeps only the current session on success', () => { + service.sessions.set([session1, session2]); + + service.revokeAllOthers(); + + const req = httpMock.expectOne(`${baseUrl}/revoke-others`); + expect(req.request.method).toBe('POST'); + req.flush({ data: { revoked_count: 1 }, error: null }); + + expect(service.sessions()).toEqual([session1]); + }); + + it('revokeAllOthers() sets an error on failure, list untouched', () => { + service.sessions.set([session1, session2]); + + service.revokeAllOthers(); + httpMock + .expectOne(`${baseUrl}/revoke-others`) + .flush({ message: 'boom' }, { status: 500, statusText: 'Server Error' }); + + expect(service.error()).toBe('Could not log out other devices.'); + expect(service.sessions()).toEqual([session1, session2]); + }); +}); diff --git a/docanalytics-web/src/app/features/security/two-factor-setup.component.spec.ts b/docanalytics-web/src/app/features/security/two-factor-setup.component.spec.ts new file mode 100644 index 0000000..ae9aa9c --- /dev/null +++ b/docanalytics-web/src/app/features/security/two-factor-setup.component.spec.ts @@ -0,0 +1,164 @@ +import { TestBed } from '@angular/core/testing'; +import { Location } from '@angular/common'; +import { of, throwError } from 'rxjs'; +import { TwoFactorSetupComponent } from './two-factor-setup.component'; +import { AuthService } from '../../core/services/auth.service'; + +vi.mock('qrcode', () => ({ + toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,FAKE'), +})); + +describe('TwoFactorSetupComponent', () => { + let authServiceMock: { + setupTwoFactor: ReturnType; + confirmTwoFactor: ReturnType; + }; + let locationMock: { back: ReturnType }; + + const setupResponse = { + data: { + secret: 'SECRETBASE32', + otp_auth_uri: 'otpauth://totp/DocAnalytics:user@test.com?secret=SECRETBASE32', + manual_key: 'SECR ETBA SE32', + }, + error: null, + }; + + function createFixture() { + const fixture = TestBed.createComponent(TwoFactorSetupComponent); + return { fixture, component: fixture.componentInstance }; + } + + async function renderAtScanStep() { + const { fixture, component } = createFixture(); + fixture.detectChanges(); + await Promise.resolve(); + await Promise.resolve(); + fixture.detectChanges(); + return { fixture, component }; + } + + beforeEach(() => { + authServiceMock = { + setupTwoFactor: vi.fn().mockReturnValue(of(setupResponse)), + confirmTwoFactor: vi.fn(), + }; + locationMock = { back: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: authServiceMock }, + { provide: Location, useValue: locationMock }, + ], + }); + }); + + it('renders the scan step with QR code and manual key after setup resolves', async () => { + const { fixture, component } = await renderAtScanStep(); + + expect(authServiceMock.setupTwoFactor).toHaveBeenCalledTimes(1); + expect((component as any).step()).toBe('scan'); + expect((component as any).manualKey()).toBe('SECR ETBA SE32'); + expect((component as any).qrDataUrl()).toBe('data:image/png;base64,FAKE'); + + expect(fixture.nativeElement.textContent).toContain('SECR ETBA SE32'); + const img = fixture.nativeElement.querySelector('img'); + expect(img?.getAttribute('src')).toBe('data:image/png;base64,FAKE'); + }); + + it('renders an error alert when setup returns no data', async () => { + authServiceMock.setupTwoFactor.mockReturnValue(of({ data: null, error: 'boom' })); + const { fixture, component } = createFixture(); + fixture.detectChanges(); + await Promise.resolve(); + fixture.detectChanges(); + + expect((component as any).error()).toBe('Could not start 2FA setup.'); + expect(fixture.nativeElement.textContent).toContain('Could not start 2FA setup.'); + }); + + it('renders an error alert when the setup request fails', async () => { + authServiceMock.setupTwoFactor.mockReturnValue(throwError(() => new Error('network'))); + const { fixture, component } = createFixture(); + fixture.detectChanges(); + await Promise.resolve(); + fixture.detectChanges(); + + expect((component as any).error()).toBe('Could not start 2FA setup.'); + expect(fixture.nativeElement.querySelector('.alert')?.textContent).toContain( + 'Could not start 2FA setup.', + ); + }); + + it('goBack() delegates to Location.back()', () => { + const { fixture, component } = createFixture(); + fixture.detectChanges(); + (component as any).goBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + }); + + describe('confirm()', () => { + it('does nothing if the code is not exactly 6 characters', async () => { + const { component } = await renderAtScanStep(); + (component as any).code.set('123'); + + (component as any).confirm(); + + expect(authServiceMock.confirmTwoFactor).not.toHaveBeenCalled(); + }); + + it('enables 2FA and renders recovery codes on success', async () => { + authServiceMock.confirmTwoFactor.mockReturnValue( + of({ data: { recovery_codes: ['AAAA-1111', 'BBBB-2222'] }, error: null }), + ); + const { fixture, component } = await renderAtScanStep(); + (component as any).code.set('123456'); + + (component as any).confirm(); + fixture.detectChanges(); + + expect((component as any).step()).toBe('confirmed'); + const text: string = fixture.nativeElement.textContent; + expect(text).toContain('AAAA-1111'); + expect(text).toContain('BBBB-2222'); + expect(text).toContain('2FA is now enabled'); + }); + + it('renders an error and stays on the scan step for an invalid code', async () => { + authServiceMock.confirmTwoFactor.mockReturnValue(of({ data: null, error: 'bad code' })); + const { fixture, component } = await renderAtScanStep(); + (component as any).code.set('000000'); + + (component as any).confirm(); + fixture.detectChanges(); + + expect((component as any).error()).toBe('Invalid code. Check your app and try again.'); + expect((component as any).step()).toBe('scan'); + expect(fixture.nativeElement.textContent).toContain( + 'Invalid code. Check your app and try again.', + ); + }); + + it('sets an error when the confirm request itself fails', async () => { + authServiceMock.confirmTwoFactor.mockReturnValue(throwError(() => new Error('network'))); + const { fixture, component } = await renderAtScanStep(); + (component as any).code.set('123456'); + + (component as any).confirm(); + fixture.detectChanges(); + + expect((component as any).error()).toBe('Invalid code. Check your app and try again.'); + expect((component as any).loading()).toBe(false); + }); + + it('shows a "Verifying…" state while the confirm request is in flight', async () => { + const { fixture, component } = await renderAtScanStep(); + (component as any).code.set('123456'); + (component as any).loading.set(true); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Verifying'); + }); + }); +}); From e23bf24e2dacb84c21b947338306102807f4ff47 Mon Sep 17 00:00:00 2001 From: Akash Goswami Date: Tue, 28 Jul 2026 11:36:24 +0530 Subject: [PATCH 3/3] Add tests --- .../Auth/AuthServiceTwoFactorTests.cs | 327 ++++++++++++++++++ .../Auth/RefreshTokenServiceTests.cs | 308 +++++++++++++++++ .../Auth/TwoFactorServiceTests.cs | 109 ++++++ .../DocAnalytics.Service.Tests.csproj | 1 + perf-results/perf-report.csv | 12 +- perf-results/perf-report.md | 14 +- 6 files changed, 758 insertions(+), 13 deletions(-) create mode 100644 DocAnalytics.Service.Tests/Auth/AuthServiceTwoFactorTests.cs create mode 100644 DocAnalytics.Service.Tests/Auth/RefreshTokenServiceTests.cs create mode 100644 DocAnalytics.Service.Tests/Auth/TwoFactorServiceTests.cs diff --git a/DocAnalytics.Service.Tests/Auth/AuthServiceTwoFactorTests.cs b/DocAnalytics.Service.Tests/Auth/AuthServiceTwoFactorTests.cs new file mode 100644 index 0000000..5c27d71 --- /dev/null +++ b/DocAnalytics.Service.Tests/Auth/AuthServiceTwoFactorTests.cs @@ -0,0 +1,327 @@ +using DocAnalytics.Data; +using DocAnalytics.Domain.Common; +using DocAnalytics.Domain.Entities; +using DocAnalytics.Service.Auth; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace DocAnalytics.Service.Tests.Auth; + +public class AuthServiceTwoFactorTests +{ + private readonly Mock _jwt = new(); + private readonly Mock _passwordPolicy = new(); + private readonly Mock _twoFactor = new(); + private readonly IDataProtectionProvider _dataProtection = new EphemeralDataProtectionProvider(); + + private static AppDbContext NewDb() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options, + Mock.Of()); + + + private AuthService NewSut(AppDbContext db) => + new(db, _jwt.Object, _passwordPolicy.Object, _twoFactor.Object, _dataProtection); + + private static User NewUser(AppDbContext db, string password = "Password123!", bool twoFactorEnabled = false) + { + var user = new User + { + Id = Guid.NewGuid(), + Email = "user@test.com", + PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), + Role = "Admin", + IsActive = true, + TwoFactorEnabled = twoFactorEnabled, + }; + db.Users.Add(user); + db.SaveChanges(); + return user; + } + + private string Protect(string plain) => + _dataProtection.CreateProtector("DocAnalytics.TwoFactorSecret").Protect(plain); + + [Fact] + public async Task LoginAsync_ReturnsChallenge_WhenTwoFactorEnabled_DoesNotIssueRealToken() + { + using var db = NewDb(); + var user = NewUser(db, "Password123!", twoFactorEnabled: true); + _jwt.Setup(j => j.CreateTwoFactorChallengeToken(user.Id)).Returns("challenge-token-abc"); + var sut = NewSut(db); + + var result = await sut.LoginAsync(new LoginRequest(user.Email, "Password123!"), CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.RequiresTwoFactor); + Assert.Equal("challenge-token-abc", result.ChallengeToken); + Assert.Null(result.Login); + _jwt.Verify(j => j.CreateToken(It.IsAny()), Times.Never); + } + + [Fact] + public async Task LoginAsync_ReturnsFullLogin_WhenTwoFactorDisabled() + { + using var db = NewDb(); + var user = NewUser(db, "Password123!", twoFactorEnabled: false); + _jwt.Setup(j => j.CreateToken(It.IsAny())).Returns("real-jwt"); + var sut = NewSut(db); + + var result = await sut.LoginAsync(new LoginRequest(user.Email, "Password123!"), CancellationToken.None); + + Assert.NotNull(result); + Assert.False(result!.RequiresTwoFactor); + Assert.Equal("real-jwt", result.Login!.Token); + } + + [Fact] + public async Task LoginAsync_ReturnsNull_ForWrongPassword() + { + using var db = NewDb(); + var user = NewUser(db, "Password123!"); + var sut = NewSut(db); + + Assert.Null(await sut.LoginAsync(new LoginRequest(user.Email, "WrongPassword!"), CancellationToken.None)); + } + + [Fact] + public async Task LoginAsync_ReturnsNull_ForUnknownEmail() + { + using var db = NewDb(); + var sut = NewSut(db); + + Assert.Null(await sut.LoginAsync(new LoginRequest("nobody@test.com", "whatever"), CancellationToken.None)); + } + + [Fact] + public async Task LoginWithTwoFactorAsync_Succeeds_WithValidTotpCode() + { + using var db = NewDb(); + var user = NewUser(db, twoFactorEnabled: true); + user.TwoFactorSecret = Protect("SECRETBASE32"); + await db.SaveChangesAsync(); + + _jwt.Setup(j => j.ValidateTwoFactorChallengeToken("valid-token")).Returns(user.Id); + _jwt.Setup(j => j.CreateToken(It.IsAny())).Returns("real-jwt"); + _twoFactor.Setup(t => t.ValidateCode("SECRETBASE32", "123456")).Returns(true); + var sut = NewSut(db); + + var result = await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("valid-token", "123456"), CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal("real-jwt", result!.Token); + } + + [Fact] + public async Task LoginWithTwoFactorAsync_FallsBackToRecoveryCode_WhenTotpFails() + { + using var db = NewDb(); + var user = NewUser(db, twoFactorEnabled: true); + user.TwoFactorSecret = Protect("SECRETBASE32"); + await db.SaveChangesAsync(); + db.TwoFactorRecoveryCodes.Add(new TwoFactorRecoveryCode + { + Id = Guid.NewGuid(), + UserId = user.Id, + CodeHash = "stored-hash", + CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + _jwt.Setup(j => j.ValidateTwoFactorChallengeToken("valid-token")).Returns(user.Id); + _jwt.Setup(j => j.CreateToken(It.IsAny())).Returns("real-jwt"); + _twoFactor.Setup(t => t.ValidateCode("SECRETBASE32", "QJ8F-XFRU")).Returns(false); + _twoFactor.Setup(t => t.VerifyRecoveryCode("QJ8F-XFRU", "stored-hash")).Returns(true); + var sut = NewSut(db); + + var result = await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("valid-token", "QJ8F-XFRU"), CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull((await db.TwoFactorRecoveryCodes.SingleAsync()).UsedAt); + } + + [Fact] + public async Task LoginWithTwoFactorAsync_Fails_WhenRecoveryCodeAlreadyUsed() + { + using var db = NewDb(); + var user = NewUser(db, twoFactorEnabled: true); + user.TwoFactorSecret = Protect("SECRETBASE32"); + await db.SaveChangesAsync(); + db.TwoFactorRecoveryCodes.Add(new TwoFactorRecoveryCode + { + Id = Guid.NewGuid(), + UserId = user.Id, + CodeHash = "stored-hash", + CreatedAt = DateTime.UtcNow, + UsedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + _jwt.Setup(j => j.ValidateTwoFactorChallengeToken("valid-token")).Returns(user.Id); + _twoFactor.Setup(t => t.ValidateCode("SECRETBASE32", "QJ8F-XFRU")).Returns(false); + _twoFactor.Setup(t => t.VerifyRecoveryCode("QJ8F-XFRU", "stored-hash")).Returns(true); + var sut = NewSut(db); + + var result = await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("valid-token", "QJ8F-XFRU"), CancellationToken.None); + + Assert.Null(result); // already-used codes are excluded from the candidate query entirely + } + + [Fact] + public async Task LoginWithTwoFactorAsync_ReturnsNull_ForInvalidChallengeToken() + { + using var db = NewDb(); + _jwt.Setup(j => j.ValidateTwoFactorChallengeToken("bad-token")).Returns((Guid?)null); + var sut = NewSut(db); + + Assert.Null(await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("bad-token", "123456"), CancellationToken.None)); + } + + [Fact] + public async Task LoginWithTwoFactorAsync_ReturnsNull_WhenUserNoLongerHasTwoFactorEnabled() + { + using var db = NewDb(); + var user = NewUser(db, twoFactorEnabled: false); + _jwt.Setup(j => j.ValidateTwoFactorChallengeToken("valid-token")).Returns(user.Id); + var sut = NewSut(db); + + Assert.Null(await sut.LoginWithTwoFactorAsync(new TwoFactorLoginRequest("valid-token", "123456"), CancellationToken.None)); + } + + [Fact] + public async Task SetupTwoFactorAsync_StoresEncryptedSecret_AndReturnsSetupPayload() + { + using var db = NewDb(); + var user = NewUser(db); + _twoFactor.Setup(t => t.GenerateSetup(user.Email, "DocAnalytics")) + .Returns(("SECRETBASE32", "otpauth://totp/...", "SECR ETBA SE32")); + var sut = NewSut(db); + + var response = await sut.SetupTwoFactorAsync(user.Id, CancellationToken.None); + + Assert.Equal("SECRETBASE32", response.Secret); + var stored = await db.Users.SingleAsync(); + Assert.NotNull(stored.TwoFactorSecret); + Assert.NotEqual("SECRETBASE32", stored.TwoFactorSecret); // must be encrypted + } + + [Fact] + public async Task ConfirmTwoFactorAsync_EnablesTwoFactor_AndIssuesRecoveryCodes_OnValidCode() + { + using var db = NewDb(); + var user = NewUser(db); + user.TwoFactorSecret = Protect("SECRETBASE32"); + await db.SaveChangesAsync(); + + _twoFactor.Setup(t => t.ValidateCode("SECRETBASE32", "123456")).Returns(true); + _twoFactor.Setup(t => t.GenerateRecoveryCodes(10)).Returns(new List { "AAAA-1111", "BBBB-2222" }); + _twoFactor.Setup(t => t.HashRecoveryCode(It.IsAny())).Returns("hashed"); + var sut = NewSut(db); + + var (error, result) = await sut.ConfirmTwoFactorAsync(user.Id, "123456", CancellationToken.None); + + Assert.Null(error); + Assert.Equal(2, result!.RecoveryCodes.Count); + Assert.True((await db.Users.SingleAsync()).TwoFactorEnabled); + Assert.Equal(2, await db.TwoFactorRecoveryCodes.CountAsync()); + } + + [Fact] + public async Task ConfirmTwoFactorAsync_ReturnsError_ForInvalidCode() + { + using var db = NewDb(); + var user = NewUser(db); + user.TwoFactorSecret = Protect("SECRETBASE32"); + await db.SaveChangesAsync(); + _twoFactor.Setup(t => t.ValidateCode("SECRETBASE32", "000000")).Returns(false); + var sut = NewSut(db); + + var (error, result) = await sut.ConfirmTwoFactorAsync(user.Id, "000000", CancellationToken.None); + + Assert.NotNull(error); + Assert.Null(result); + Assert.False((await db.Users.SingleAsync()).TwoFactorEnabled); + } + + [Fact] + public async Task ConfirmTwoFactorAsync_ReturnsError_WhenSetupWasNeverCalled() + { + using var db = NewDb(); + var user = NewUser(db); + var sut = NewSut(db); + + var (error, result) = await sut.ConfirmTwoFactorAsync(user.Id, "123456", CancellationToken.None); + + Assert.NotNull(error); + Assert.Null(result); + } + + [Fact] + public async Task ConfirmTwoFactorAsync_WipesStaleRecoveryCodes_OnReConfirm() + { + using var db = NewDb(); + var user = NewUser(db); + user.TwoFactorSecret = Protect("SECRETBASE32"); + await db.SaveChangesAsync(); + db.TwoFactorRecoveryCodes.Add(new TwoFactorRecoveryCode + { + Id = Guid.NewGuid(), + UserId = user.Id, + CodeHash = "old-hash", + CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + _twoFactor.Setup(t => t.ValidateCode("SECRETBASE32", "123456")).Returns(true); + _twoFactor.Setup(t => t.GenerateRecoveryCodes(10)).Returns(new List { "NEW1-CODE" }); + _twoFactor.Setup(t => t.HashRecoveryCode(It.IsAny())).Returns("new-hash"); + var sut = NewSut(db); + + await sut.ConfirmTwoFactorAsync(user.Id, "123456", CancellationToken.None); + + var codes = await db.TwoFactorRecoveryCodes.ToListAsync(); + Assert.Single(codes); + Assert.Equal("new-hash", codes[0].CodeHash); + } + + [Fact] + public async Task DisableTwoFactorAsync_ClearsSecretAndRecoveryCodes_OnCorrectPassword() + { + using var db = NewDb(); + var user = NewUser(db, "Password123!", twoFactorEnabled: true); + user.TwoFactorSecret = "encrypted-blob"; + await db.SaveChangesAsync(); + db.TwoFactorRecoveryCodes.Add(new TwoFactorRecoveryCode + { + Id = Guid.NewGuid(), + UserId = user.Id, + CodeHash = "h", + CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + var sut = NewSut(db); + + var error = await sut.DisableTwoFactorAsync(user.Id, "Password123!", CancellationToken.None); + + Assert.Null(error); + var stored = await db.Users.SingleAsync(); + Assert.False(stored.TwoFactorEnabled); + Assert.Null(stored.TwoFactorSecret); + Assert.Empty(await db.TwoFactorRecoveryCodes.ToListAsync()); + } + + [Fact] + public async Task DisableTwoFactorAsync_ReturnsError_ForWrongPassword() + { + using var db = NewDb(); + var user = NewUser(db, "Password123!", twoFactorEnabled: true); + var sut = NewSut(db); + + var error = await sut.DisableTwoFactorAsync(user.Id, "WrongPassword!", CancellationToken.None); + + Assert.NotNull(error); + Assert.True((await db.Users.SingleAsync()).TwoFactorEnabled); + } +} diff --git a/DocAnalytics.Service.Tests/Auth/RefreshTokenServiceTests.cs b/DocAnalytics.Service.Tests/Auth/RefreshTokenServiceTests.cs new file mode 100644 index 0000000..d4c9a25 --- /dev/null +++ b/DocAnalytics.Service.Tests/Auth/RefreshTokenServiceTests.cs @@ -0,0 +1,308 @@ +using DocAnalytics.Data; +using DocAnalytics.Domain.Common; +using DocAnalytics.Domain.Entities; +using DocAnalytics.Service.Auth; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Moq; + +namespace DocAnalytics.Service.Tests.Auth; + +public class RefreshTokenServiceTests +{ + private static AppDbContext NewDb() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options, + Mock.Of()); + + + private static IConfiguration NewConfig(int? refreshDays = null) + { + var dict = new Dictionary(); + if (refreshDays is not null) dict["Jwt:RefreshExpiryDays"] = refreshDays.ToString(); + return new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + } + + // NOTE: adjust required fields here if your User entity needs more (e.g. TenantId, CreatedAt). + private static User NewActiveUser(AppDbContext db) + { + var user = new User + { + Id = Guid.NewGuid(), + Email = $"{Guid.NewGuid()}@test.com", + PasswordHash = "irrelevant", + Role = "Admin", + IsActive = true, + }; + db.Users.Add(user); + db.SaveChanges(); + return user; + } + + private static string HashForTest(string raw) + { + var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(raw)); + return Convert.ToBase64String(bytes); + } + + [Fact] + public async Task IssueAsync_CreatesToken_WithDeviceInfo_AndDefaultSevenDayExpiry() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + + var (raw, expiresAt) = await sut.IssueAsync(user.Id, "1.2.3.4", "TestAgent/1.0", CancellationToken.None); + + Assert.NotEmpty(raw); + var stored = await db.RefreshTokens.SingleAsync(); + Assert.Equal(user.Id, stored.UserId); + Assert.Equal("1.2.3.4", stored.IpAddress); + Assert.Equal("TestAgent/1.0", stored.UserAgent); + Assert.Null(stored.RevokedAt); + Assert.True((expiresAt - DateTime.UtcNow).TotalDays is > 6.9 and < 7.1); + } + + [Fact] + public async Task IssueAsync_RespectsConfiguredLifetimeDays() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig(30)); + var user = NewActiveUser(db); + + var (_, expiresAt) = await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + + Assert.True((expiresAt - DateTime.UtcNow).TotalDays is > 29.9 and < 30.1); + } + + [Fact] + public async Task ValidateAndRotateAsync_ReturnsNull_ForUnknownToken() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + + Assert.Null(await sut.ValidateAndRotateAsync("not-a-real-token", "1.2.3.4", "Agent", CancellationToken.None)); + } + + [Fact] + public async Task ValidateAndRotateAsync_ReturnsNull_ForEmptyOrNullToken() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + + Assert.Null(await sut.ValidateAndRotateAsync("", "1.2.3.4", null, CancellationToken.None)); + Assert.Null(await sut.ValidateAndRotateAsync(null!, "1.2.3.4", null, CancellationToken.None)); + } + + [Fact] + public async Task ValidateAndRotateAsync_RotatesToken_OnValidPresentation() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + var (raw, _) = await sut.IssueAsync(user.Id, "1.2.3.4", "AgentA", CancellationToken.None); + + var result = await sut.ValidateAndRotateAsync(raw, "5.6.7.8", "AgentB", CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(user.Id, result!.Value.User.Id); + Assert.NotEqual(raw, result.Value.RawToken); + + var allTokens = await db.RefreshTokens.ToListAsync(); + Assert.Equal(2, allTokens.Count); + var oldToken = allTokens.Single(t => t.RevokedAt != null); + var newToken = allTokens.Single(t => t.RevokedAt == null); + Assert.Equal(oldToken.ReplacedByTokenHash, newToken.TokenHash); + Assert.Equal("5.6.7.8", newToken.IpAddress); + Assert.Equal("AgentB", newToken.UserAgent); + } + + [Fact] + public async Task ValidateAndRotateAsync_ReturnsNull_ForExpiredToken() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + + db.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + TokenHash = HashForTest("expired-token"), + CreatedAt = DateTime.UtcNow.AddDays(-10), + ExpiresAt = DateTime.UtcNow.AddDays(-3), + }); + await db.SaveChangesAsync(); + + Assert.Null(await sut.ValidateAndRotateAsync("expired-token", null, null, CancellationToken.None)); + } + + [Fact] + public async Task ValidateAndRotateAsync_ReturnsNull_WhenUserDeactivated() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + var (raw, _) = await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + + user.IsActive = false; + await db.SaveChangesAsync(); + + Assert.Null(await sut.ValidateAndRotateAsync(raw, null, null, CancellationToken.None)); + } + + [Fact] + public async Task ValidateAndRotateAsync_DetectsReuse_AndRevokesAllTokensForUser() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + + var (raw, _) = await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + var first = await sut.ValidateAndRotateAsync(raw, null, null, CancellationToken.None); + Assert.NotNull(first); + + var second = await sut.ValidateAndRotateAsync(raw, null, null, CancellationToken.None); + Assert.Null(second); + + var allTokens = await db.RefreshTokens.Where(t => t.UserId == user.Id).ToListAsync(); + Assert.All(allTokens, t => Assert.NotNull(t.RevokedAt)); + } + + [Fact] + public async Task RevokeAsync_RevokesMatchingToken() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + var (raw, _) = await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + + await sut.RevokeAsync(raw, CancellationToken.None); + + Assert.NotNull((await db.RefreshTokens.SingleAsync()).RevokedAt); + } + + [Fact] + public async Task RevokeAsync_IsNoOp_ForUnknownOrEmptyToken() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + + await sut.RevokeAsync("does-not-exist", CancellationToken.None); + await sut.RevokeAsync("", CancellationToken.None); + + Assert.Empty(await db.RefreshTokens.ToListAsync()); + } + + [Fact] + public async Task RevokeAllForUserAsync_RevokesEveryActiveToken_ForThatUserOnly() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + var otherUser = NewActiveUser(db); + + await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + await sut.IssueAsync(otherUser.Id, null, null, CancellationToken.None); + + await sut.RevokeAllForUserAsync(user.Id, CancellationToken.None); + + var userTokens = await db.RefreshTokens.Where(t => t.UserId == user.Id).ToListAsync(); + Assert.All(userTokens, t => Assert.NotNull(t.RevokedAt)); + + var otherTokens = await db.RefreshTokens.Where(t => t.UserId == otherUser.Id).ToListAsync(); + Assert.All(otherTokens, t => Assert.Null(t.RevokedAt)); + } + + [Fact] + public async Task ListActiveSessionsAsync_ReturnsOnlyActiveUnexpired_MarksCurrentCorrectly() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + + var (currentRaw, _) = await sut.IssueAsync(user.Id, "1.1.1.1", "Chrome/1", CancellationToken.None); + await sut.IssueAsync(user.Id, "2.2.2.2", "Firefox/1", CancellationToken.None); + var (revokedRaw, _) = await sut.IssueAsync(user.Id, "3.3.3.3", "Safari/1", CancellationToken.None); + await sut.RevokeAsync(revokedRaw, CancellationToken.None); + + var sessions = await sut.ListActiveSessionsAsync(user.Id, currentRaw, CancellationToken.None); + + Assert.Equal(2, sessions.Count); + Assert.Contains(sessions, s => s.IsCurrent); + Assert.DoesNotContain(sessions, s => s.IpAddress == "3.3.3.3"); + } + + [Fact] + public async Task ListActiveSessionsAsync_MarksNoneCurrent_WhenNoCurrentTokenProvided() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + + var sessions = await sut.ListActiveSessionsAsync(user.Id, null, CancellationToken.None); + + Assert.All(sessions, s => Assert.False(s.IsCurrent)); + } + + [Fact] + public async Task RevokeSessionAsync_RevokesOwnSession_ReturnsTrue() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + var tokenId = (await db.RefreshTokens.SingleAsync()).Id; + + Assert.True(await sut.RevokeSessionAsync(user.Id, tokenId, CancellationToken.None)); + Assert.NotNull((await db.RefreshTokens.SingleAsync()).RevokedAt); + } + + [Fact] + public async Task RevokeSessionAsync_ReturnsFalse_WhenSessionBelongsToAnotherUser() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var owner = NewActiveUser(db); + var attacker = NewActiveUser(db); + await sut.IssueAsync(owner.Id, null, null, CancellationToken.None); + var tokenId = (await db.RefreshTokens.SingleAsync()).Id; + + Assert.False(await sut.RevokeSessionAsync(attacker.Id, tokenId, CancellationToken.None)); + Assert.Null((await db.RefreshTokens.SingleAsync()).RevokedAt); + } + + [Fact] + public async Task RevokeSessionAsync_ReturnsFalse_ForAlreadyRevokedSession() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + var (raw, _) = await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + await sut.RevokeAsync(raw, CancellationToken.None); + var tokenId = (await db.RefreshTokens.SingleAsync()).Id; + + Assert.False(await sut.RevokeSessionAsync(user.Id, tokenId, CancellationToken.None)); + } + + [Fact] + public async Task RevokeAllOtherSessionsAsync_RevokesEverythingExceptCurrent() + { + using var db = NewDb(); + var sut = new RefreshTokenService(db, NewConfig()); + var user = NewActiveUser(db); + var (currentRaw, _) = await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + await sut.IssueAsync(user.Id, null, null, CancellationToken.None); + + var revokedCount = await sut.RevokeAllOtherSessionsAsync(user.Id, currentRaw, CancellationToken.None); + + Assert.Equal(2, revokedCount); + var sessions = await sut.ListActiveSessionsAsync(user.Id, currentRaw, CancellationToken.None); + Assert.Single(sessions); + Assert.True(sessions[0].IsCurrent); + } +} diff --git a/DocAnalytics.Service.Tests/Auth/TwoFactorServiceTests.cs b/DocAnalytics.Service.Tests/Auth/TwoFactorServiceTests.cs new file mode 100644 index 0000000..049e8dd --- /dev/null +++ b/DocAnalytics.Service.Tests/Auth/TwoFactorServiceTests.cs @@ -0,0 +1,109 @@ +using OtpNet; + +namespace DocAnalytics.Service.Tests.Auth; + +public class TwoFactorServiceTests +{ + private readonly DocAnalytics.Service.Auth.TwoFactorService _sut = new(); + + [Fact] + public void GenerateSetup_ReturnsValidSecret_AndMatchingOtpAuthUri() + { + var (secret, uri, manualKey) = _sut.GenerateSetup("user@example.com"); + + Assert.NotEmpty(secret); + Assert.Contains("otpauth://totp/", uri); + Assert.Contains(secret, uri); + Assert.Contains("DocAnalytics", uri); + Assert.Equal(secret, manualKey.Replace(" ", "")); + } + + [Fact] + public void GenerateSetup_UsesCustomIssuer_WhenProvided() + { + var (_, uri, _) = _sut.GenerateSetup("user@example.com", issuer: "MyCo"); + + Assert.Contains("issuer=MyCo", uri); + } + + [Fact] + public void ValidateCode_ReturnsTrue_ForCurrentlyValidCode() + { + var (secret, _, _) = _sut.GenerateSetup("user@example.com"); + var keyBytes = Base32Encoding.ToBytes(secret); + var totp = new Totp(keyBytes, step: 30, mode: OtpHashMode.Sha1, totpSize: 6); + + Assert.True(_sut.ValidateCode(secret, totp.ComputeTotp())); + } + + [Fact] + public void ValidateCode_ReturnsFalse_ForWrongCode() + { + var (secret, _, _) = _sut.GenerateSetup("user@example.com"); + + Assert.False(_sut.ValidateCode(secret, "000000")); + } + + [Fact] + public void ValidateCode_ReturnsFalse_ForEmptyOrWhitespaceCode() + { + var (secret, _, _) = _sut.GenerateSetup("user@example.com"); + + Assert.False(_sut.ValidateCode(secret, "")); + Assert.False(_sut.ValidateCode(secret, " ")); + } + + [Fact] + public void ValidateCode_ReturnsFalse_ForMalformedSecret_NeverThrows() + { + Assert.False(_sut.ValidateCode("not-valid-base32!!!", "123456")); + } + + [Fact] + public void GenerateRecoveryCodes_ReturnsRequestedCount_InExpectedFormat() + { + var codes = _sut.GenerateRecoveryCodes(10); + + Assert.Equal(10, codes.Count); + Assert.All(codes, c => Assert.Matches("^[A-Z2-9]{4}-[A-Z2-9]{4}$", c)); + } + + [Fact] + public void GenerateRecoveryCodes_ProducesUniqueCodes() + { + var codes = _sut.GenerateRecoveryCodes(20); + + Assert.Equal(codes.Count, codes.Distinct().Count()); + } + + [Fact] + public void GenerateRecoveryCodes_RespectsCustomCount() + { + Assert.Equal(3, _sut.GenerateRecoveryCodes(3).Count); + } + + [Fact] + public void HashRecoveryCode_ThenVerify_RoundTrips() + { + var hash = _sut.HashRecoveryCode("QJ8F-XFRU"); + + Assert.True(_sut.VerifyRecoveryCode("QJ8F-XFRU", hash)); + } + + [Fact] + public void VerifyRecoveryCode_IsCaseInsensitive_AndTrimsWhitespace() + { + var hash = _sut.HashRecoveryCode("QJ8F-XFRU"); + + Assert.True(_sut.VerifyRecoveryCode("qj8f-xfru", hash)); + Assert.True(_sut.VerifyRecoveryCode(" QJ8F-XFRU ", hash)); + } + + [Fact] + public void VerifyRecoveryCode_ReturnsFalse_ForWrongCode() + { + var hash = _sut.HashRecoveryCode("QJ8F-XFRU"); + + Assert.False(_sut.VerifyRecoveryCode("WRONG-CODE", hash)); + } +} diff --git a/DocAnalytics.Service.Tests/DocAnalytics.Service.Tests.csproj b/DocAnalytics.Service.Tests/DocAnalytics.Service.Tests.csproj index 730a462..7a48106 100644 --- a/DocAnalytics.Service.Tests/DocAnalytics.Service.Tests.csproj +++ b/DocAnalytics.Service.Tests/DocAnalytics.Service.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/perf-results/perf-report.csv b/perf-results/perf-report.csv index e050310..0a513fd 100644 --- a/perf-results/perf-report.csv +++ b/perf-results/perf-report.csv @@ -1,7 +1,7 @@ operation,samples,p50_ms,p90_ms,max_ms -batch_list_page,10,0.8,2.6,29.6 -concurrent_dashboard_summary,10,1.1,13.0,22.5 -concurrent_total_wall_time,1,52.3,52.3,52.3 -dashboard_summary,10,1.1,3.2,20.5 -error_list_page,10,151.9,275.1,418.8 -recent_failures_page,10,57.6,197.9,287.9 +batch_list_page,10,0.9,3.2,23.3 +concurrent_dashboard_summary,10,1.1,12.9,23.9 +concurrent_total_wall_time,1,53.7,53.7,53.7 +dashboard_summary,10,1.4,21.8,103.0 +error_list_page,10,197.1,318.3,497.7 +recent_failures_page,10,62.9,189.4,230.4 diff --git a/perf-results/perf-report.md b/perf-results/perf-report.md index ca5ddf6..4cbb384 100644 --- a/perf-results/perf-report.md +++ b/perf-results/perf-report.md @@ -1,13 +1,13 @@ # Performance Report (mocked, in-memory) -Generated: 2026-07-23T04:08:58Z +Generated: 2026-07-27T11:32:21Z Dataset: 2,000 batches x 50 files | Operation | Samples | P50 (ms) | P90 (ms) | Max (ms) | |---|---:|---:|---:|---:| -| batch_list_page | 10 | 0.8 | 2.6 | 29.6 | -| concurrent_dashboard_summary | 10 | 1.1 | 13.0 | 22.5 | -| concurrent_total_wall_time | 1 | 52.3 | 52.3 | 52.3 | -| dashboard_summary | 10 | 1.1 | 3.2 | 20.5 | -| error_list_page | 10 | 151.9 | 275.1 | 418.8 | -| recent_failures_page | 10 | 57.6 | 197.9 | 287.9 | +| batch_list_page | 10 | 0.9 | 3.2 | 23.3 | +| concurrent_dashboard_summary | 10 | 1.1 | 12.9 | 23.9 | +| concurrent_total_wall_time | 1 | 53.7 | 53.7 | 53.7 | +| dashboard_summary | 10 | 1.4 | 21.8 | 103.0 | +| error_list_page | 10 | 197.1 | 318.3 | 497.7 | +| recent_failures_page | 10 | 62.9 | 189.4 | 230.4 |