From 94ffe6e7b75b0a6020a872a42f243279ee6b7975 Mon Sep 17 00:00:00 2001 From: Zeeshaun Date: Mon, 20 Jul 2026 02:16:02 -0500 Subject: [PATCH] Add versioned legal consent to authentication --- .../AuthApi.Tests/LegalPolicyTests.cs | 61 +++++++++++++++++++ .../AuthApi/Controllers/AuthController.cs | 46 ++++++++++++-- .../Controllers/SteamAuthController.cs | 10 ++- microservices/AuthApi/Models/Dto.cs | 4 +- microservices/AuthApi/Models/User.cs | 12 ++++ microservices/AuthApi/Program.cs | 1 + microservices/AuthApi/Services/LegalPolicy.cs | 57 +++++++++++++++++ microservices/AuthApi/appsettings.json | 1 + microservices/AuthApi/k8s/deployment.yaml | 6 ++ 9 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 microservices/AuthApi.Tests/LegalPolicyTests.cs create mode 100644 microservices/AuthApi/Services/LegalPolicy.cs diff --git a/microservices/AuthApi.Tests/LegalPolicyTests.cs b/microservices/AuthApi.Tests/LegalPolicyTests.cs new file mode 100644 index 0000000..98670a3 --- /dev/null +++ b/microservices/AuthApi.Tests/LegalPolicyTests.cs @@ -0,0 +1,61 @@ +using AuthApi.Models; +using AuthApi.Services; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace AuthApi.Tests; + +public sealed class LegalPolicyTests +{ + private static LegalPolicy Policy(string version = "2026-07-20") => new( + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Legal:TermsVersion"] = version, + ["Legal:PrivacyVersion"] = version, + ["Legal:EffectiveUtc"] = "2026-07-20T00:00:00Z" + }).Build()); + + [Fact] + public void ExistingUserWithoutAcceptanceRequiresAgreement() + { + Assert.True(Policy().Status(new User()).RequiresLegalAcceptance); + } + + [Fact] + public void AcceptRecordsVersionsTimestampAndSource() + { + var user = new User(); + var acceptedUtc = new DateTime(2026, 7, 20, 12, 0, 0, DateTimeKind.Utc); + + Policy().Accept(user, "password_registration", acceptedUtc); + + Assert.Equal("2026-07-20", user.TermsAcceptedVersion); + Assert.Equal("2026-07-20", user.PrivacyAcceptedVersion); + Assert.Equal(acceptedUtc, user.LegalAcceptedUtc); + var acceptance = Assert.Single(user.LegalAcceptances); + Assert.Equal("password_registration", acceptance.Source); + } + + [Fact] + public void AcceptIsIdempotentForCurrentVersions() + { + var user = new User(); + var policy = Policy(); + var first = new DateTime(2026, 7, 20, 12, 0, 0, DateTimeKind.Utc); + policy.Accept(user, "registration", first); + + policy.Accept(user, "prompt", first.AddHours(1)); + + Assert.Single(user.LegalAcceptances); + Assert.Equal(first, user.LegalAcceptedUtc); + } + + [Fact] + public void NewPolicyVersionRequiresAcceptanceAgain() + { + var user = new User(); + Policy().Accept(user, "registration"); + + Assert.True(Policy("2026-08-01").Status(user).RequiresLegalAcceptance); + } +} diff --git a/microservices/AuthApi/Controllers/AuthController.cs b/microservices/AuthApi/Controllers/AuthController.cs index 03fc34c..c99a51f 100644 --- a/microservices/AuthApi/Controllers/AuthController.cs +++ b/microservices/AuthApi/Controllers/AuthController.cs @@ -21,13 +21,18 @@ public class AuthController : ControllerBase private readonly BlacklistService _blacklist; private readonly IHttpClientFactory _httpClients; private readonly JwtTokenService _tokens; + private readonly LegalPolicy _legal; public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist, - IHttpClientFactory httpClients, JwtTokenService tokens) + IHttpClientFactory httpClients, JwtTokenService tokens, LegalPolicy legal) { - _users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; _tokens = tokens; + _users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; _tokens = tokens; _legal = legal; } + [HttpGet("legal")] + [AllowAnonymous] + public IActionResult Legal() => Ok(_legal.Status()); + [HttpPost("register")] public async Task Register([FromBody] RegisterRequest req) { @@ -35,6 +40,9 @@ public class AuthController : ControllerBase var password = req.Password ?? ""; var email = req.Email?.Trim() ?? ""; + if (!req.AcceptLegal) + return BadRequest("You must agree to the Terms of Service and acknowledge the Privacy Policy"); + if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$")) return BadRequest("Username must be 3-24 characters using only letters, numbers, or underscores"); if (password.Length < 8) @@ -48,6 +56,7 @@ public class AuthController : ControllerBase var hash = BCrypt.Net.BCrypt.HashPassword(password); var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email, CreatedUtc = DateTime.UtcNow }; + _legal.Accept(user, "password_registration"); try { await _users.CreateAsync(user); @@ -72,7 +81,7 @@ public class AuthController : ControllerBase user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); await _users.UpdateAsync(user); - return Ok(new { accessToken, refreshToken = user.RefreshToken, user.Username, user.Role, jti, exp = expUtc }); + return Ok(SessionResponse(user, accessToken, expUtc, jti, user.RefreshToken)); } [HttpPost("refresh")] @@ -83,7 +92,7 @@ public class AuthController : ControllerBase return Unauthorized("Invalid or expired refresh token"); var (accessToken, _, expUtc) = _tokens.Generate(user); - return Ok(new { accessToken, exp = expUtc }); + return Ok(SessionResponse(user, accessToken, expUtc)); } [HttpPost("logout")] @@ -105,6 +114,18 @@ public class AuthController : ControllerBase return user is null ? Unauthorized() : Ok(ProfileResponse(user)); } + [HttpPost("legal/accept")] + [Authorize(Roles = "USER,SUPER")] + public async Task AcceptLegal([FromBody] AcceptLegalRequest req) + { + if (!req.Accept) return BadRequest("Legal acceptance is required."); + var user = await CurrentUserAsync(); + if (user is null) return Unauthorized(); + _legal.Accept(user, "existing_user_prompt"); + await _users.UpdateAsync(user); + return Ok(_legal.Status(user)); + } + [HttpPatch("me")] [Authorize(Roles = "USER,SUPER")] public async Task UpdateProfile([FromBody] UpdateProfileRequest req) @@ -204,14 +225,27 @@ public class AuthController : ControllerBase return ObjectId.TryParse(user.Id, out var id) ? id.CreationTime : DateTime.UtcNow; } - private static object ProfileResponse(User user) => new + private object ProfileResponse(User user) => new { userId = user.Id, user.Username, user.DisplayName, user.Email, user.Role, - createdUtc = user.CreatedUtc ?? CreationDate(user) + createdUtc = user.CreatedUtc ?? CreationDate(user), + legal = _legal.Status(user) + }; + + private object SessionResponse(User user, string accessToken, DateTime expUtc, + string? jti = null, string? refreshToken = null) => new + { + accessToken, + refreshToken, + user.Username, + user.Role, + jti, + exp = expUtc, + legal = _legal.Status(user) }; private async Task RevokeCurrentTokenAsync() diff --git a/microservices/AuthApi/Controllers/SteamAuthController.cs b/microservices/AuthApi/Controllers/SteamAuthController.cs index b1bc51b..a602ce3 100644 --- a/microservices/AuthApi/Controllers/SteamAuthController.cs +++ b/microservices/AuthApi/Controllers/SteamAuthController.cs @@ -19,10 +19,11 @@ public class SteamAuthController : ControllerBase private readonly JwtTokenService _tokens; private readonly IConfiguration _cfg; private readonly ILogger _logger; + private readonly LegalPolicy _legal; public SteamAuthController(UserService users, SteamAuthTransactionService transactions, SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg, - ILogger logger) + ILogger logger, LegalPolicy legal) { _users = users; _transactions = transactions; @@ -30,6 +31,7 @@ public class SteamAuthController : ControllerBase _tokens = tokens; _cfg = cfg; _logger = logger; + _legal = legal; } [HttpPost("start")] @@ -115,7 +117,8 @@ public class SteamAuthController : ControllerBase user.Username, user.Role, jti, - exp + exp, + legal = _legal.Status(user) }); } @@ -133,6 +136,8 @@ public class SteamAuthController : ControllerBase var createdUser = false; if (request.Action == "create") { + if (!request.AcceptLegal) + return BadRequest(new { message = "You must agree to the Terms of Service and acknowledge the Privacy Policy." }); var validation = ValidateNewAccount(request); if (validation is not null) return BadRequest(new { message = validation }); @@ -149,6 +154,7 @@ public class SteamAuthController : ControllerBase RefreshToken = NewRefreshToken(), RefreshTokenExpiry = DateTime.UtcNow.AddDays(7) }; + _legal.Accept(user, "steam_registration"); try { await _users.CreateAsync(user); diff --git a/microservices/AuthApi/Models/Dto.cs b/microservices/AuthApi/Models/Dto.cs index 089ba0e..f0e200e 100644 --- a/microservices/AuthApi/Models/Dto.cs +++ b/microservices/AuthApi/Models/Dto.cs @@ -1,6 +1,6 @@ namespace AuthApi.Models; -public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } } +public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } public bool AcceptLegal { get; set; } } public class LoginRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; } public class ChangeRoleRequest { public string Username { get; set; } = ""; public string NewRole { get; set; } = ""; } public class RefreshRequest { public string Username { get; set; } = ""; public string RefreshToken { get; set; } = ""; } @@ -14,4 +14,6 @@ public class CompleteSteamRequest public string Username { get; set; } = ""; public string? Email { get; set; } public string Password { get; set; } = ""; + public bool AcceptLegal { get; set; } } +public class AcceptLegalRequest { public bool Accept { get; set; } } diff --git a/microservices/AuthApi/Models/User.cs b/microservices/AuthApi/Models/User.cs index 44cf2cd..d7f320c 100644 --- a/microservices/AuthApi/Models/User.cs +++ b/microservices/AuthApi/Models/User.cs @@ -16,4 +16,16 @@ public class User public string? RefreshToken { get; set; } public DateTime? RefreshTokenExpiry { get; set; } public string? SteamId { get; set; } + public string? TermsAcceptedVersion { get; set; } + public string? PrivacyAcceptedVersion { get; set; } + public DateTime? LegalAcceptedUtc { get; set; } + public List LegalAcceptances { get; set; } = []; +} + +public sealed class LegalAcceptanceRecord +{ + public string TermsVersion { get; set; } = ""; + public string PrivacyVersion { get; set; } = ""; + public DateTime AcceptedUtc { get; set; } = DateTime.UtcNow; + public string Source { get; set; } = ""; } diff --git a/microservices/AuthApi/Program.cs b/microservices/AuthApi/Program.cs index df801da..7cefa6d 100644 --- a/microservices/AuthApi/Program.cs +++ b/microservices/AuthApi/Program.cs @@ -15,6 +15,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddRateLimiter(options => { diff --git a/microservices/AuthApi/Services/LegalPolicy.cs b/microservices/AuthApi/Services/LegalPolicy.cs new file mode 100644 index 0000000..6a69230 --- /dev/null +++ b/microservices/AuthApi/Services/LegalPolicy.cs @@ -0,0 +1,57 @@ +using AuthApi.Models; + +namespace AuthApi.Services; + +public sealed record LegalPolicyStatus( + string TermsVersion, + string PrivacyVersion, + DateTime EffectiveUtc, + string TermsUrl, + string PrivacyUrl, + bool RequiresLegalAcceptance); + +public sealed class LegalPolicy +{ + public const string DefaultVersion = "2026-07-20"; + private readonly IConfiguration _configuration; + + public LegalPolicy(IConfiguration configuration) => _configuration = configuration; + + public string TermsVersion => _configuration["Legal:TermsVersion"] ?? DefaultVersion; + public string PrivacyVersion => _configuration["Legal:PrivacyVersion"] ?? DefaultVersion; + public string TermsUrl => _configuration["Legal:TermsUrl"] ?? "https://doctor.ranaze.com/terms.html"; + public string PrivacyUrl => _configuration["Legal:PrivacyUrl"] ?? "https://doctor.ranaze.com/privacy.html"; + public DateTime EffectiveUtc => DateTime.TryParse(_configuration["Legal:EffectiveUtc"], out var value) + ? value.ToUniversalTime() + : new DateTime(2026, 7, 20, 0, 0, 0, DateTimeKind.Utc); + + public bool IsCurrent(User user) => + user.TermsAcceptedVersion == TermsVersion && user.PrivacyAcceptedVersion == PrivacyVersion; + + public LegalPolicyStatus Status(User? user = null) => new( + TermsVersion, + PrivacyVersion, + EffectiveUtc, + TermsUrl, + PrivacyUrl, + user is not null && !IsCurrent(user)); + + public void Accept(User user, string source, DateTime? acceptedUtc = null) + { + var timestamp = acceptedUtc ?? DateTime.UtcNow; + user.LegalAcceptances ??= []; + if (IsCurrent(user) && user.LegalAcceptances.Any(value => + value.TermsVersion == TermsVersion && value.PrivacyVersion == PrivacyVersion)) + return; + user.TermsAcceptedVersion = TermsVersion; + user.PrivacyAcceptedVersion = PrivacyVersion; + user.LegalAcceptedUtc = timestamp; + user.LegalAcceptances.Add(new LegalAcceptanceRecord + { + TermsVersion = TermsVersion, + PrivacyVersion = PrivacyVersion, + AcceptedUtc = timestamp, + Source = source + }); + } +} diff --git a/microservices/AuthApi/appsettings.json b/microservices/AuthApi/appsettings.json index b3fb57f..6503c1c 100644 --- a/microservices/AuthApi/appsettings.json +++ b/microservices/AuthApi/appsettings.json @@ -3,6 +3,7 @@ "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, "Steam": { "Realm": "https://pauth.ranaze.com", "ReturnUrl": "https://pauth.ranaze.com/api/Auth/steam/callback", "TransactionTtlMinutes": 5 }, + "Legal": { "TermsVersion": "2026-07-20", "PrivacyVersion": "2026-07-20", "EffectiveUtc": "2026-07-20T00:00:00Z", "TermsUrl": "https://doctor.ranaze.com/terms.html", "PrivacyUrl": "https://doctor.ranaze.com/privacy.html" }, "Logging": { "LogLevel": { "Default": "Information" } }, "AllowedHosts": "*" } diff --git a/microservices/AuthApi/k8s/deployment.yaml b/microservices/AuthApi/k8s/deployment.yaml index c4dabc0..4ff5198 100644 --- a/microservices/AuthApi/k8s/deployment.yaml +++ b/microservices/AuthApi/k8s/deployment.yaml @@ -34,6 +34,12 @@ spec: value: https://pauth.ranaze.com/api/Auth/steam/callback - name: Steam__TransactionTtlMinutes value: "5" + - name: Legal__TermsVersion + value: "2026-07-20" + - name: Legal__PrivacyVersion + value: "2026-07-20" + - name: Legal__EffectiveUtc + value: "2026-07-20T00:00:00Z" readinessProbe: httpGet: path: /healthz