Add versioned legal consent to authentication
Deploy Promiscuity Auth API / deploy (push) Successful in 1m29s
k8s smoke test / test (push) Successful in 20s

This commit is contained in:
2026-07-20 02:16:02 -05:00
parent 4121c59484
commit 94ffe6e7b7
9 changed files with 189 additions and 9 deletions
@@ -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<string, string?>
{
["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);
}
}
@@ -21,13 +21,18 @@ public class AuthController : ControllerBase
private readonly BlacklistService _blacklist; private readonly BlacklistService _blacklist;
private readonly IHttpClientFactory _httpClients; private readonly IHttpClientFactory _httpClients;
private readonly JwtTokenService _tokens; private readonly JwtTokenService _tokens;
private readonly LegalPolicy _legal;
public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist, 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")] [HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest req) public async Task<IActionResult> Register([FromBody] RegisterRequest req)
{ {
@@ -35,6 +40,9 @@ public class AuthController : ControllerBase
var password = req.Password ?? ""; var password = req.Password ?? "";
var email = req.Email?.Trim() ?? ""; 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}$")) if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$"))
return BadRequest("Username must be 3-24 characters using only letters, numbers, or underscores"); return BadRequest("Username must be 3-24 characters using only letters, numbers, or underscores");
if (password.Length < 8) if (password.Length < 8)
@@ -48,6 +56,7 @@ public class AuthController : ControllerBase
var hash = BCrypt.Net.BCrypt.HashPassword(password); var hash = BCrypt.Net.BCrypt.HashPassword(password);
var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email, CreatedUtc = DateTime.UtcNow }; var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email, CreatedUtc = DateTime.UtcNow };
_legal.Accept(user, "password_registration");
try try
{ {
await _users.CreateAsync(user); await _users.CreateAsync(user);
@@ -72,7 +81,7 @@ public class AuthController : ControllerBase
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
await _users.UpdateAsync(user); 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")] [HttpPost("refresh")]
@@ -83,7 +92,7 @@ public class AuthController : ControllerBase
return Unauthorized("Invalid or expired refresh token"); return Unauthorized("Invalid or expired refresh token");
var (accessToken, _, expUtc) = _tokens.Generate(user); var (accessToken, _, expUtc) = _tokens.Generate(user);
return Ok(new { accessToken, exp = expUtc }); return Ok(SessionResponse(user, accessToken, expUtc));
} }
[HttpPost("logout")] [HttpPost("logout")]
@@ -105,6 +114,18 @@ public class AuthController : ControllerBase
return user is null ? Unauthorized() : Ok(ProfileResponse(user)); return user is null ? Unauthorized() : Ok(ProfileResponse(user));
} }
[HttpPost("legal/accept")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> 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")] [HttpPatch("me")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest req) public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest req)
@@ -204,14 +225,27 @@ public class AuthController : ControllerBase
return ObjectId.TryParse(user.Id, out var id) ? id.CreationTime : DateTime.UtcNow; 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, userId = user.Id,
user.Username, user.Username,
user.DisplayName, user.DisplayName,
user.Email, user.Email,
user.Role, 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() private async Task RevokeCurrentTokenAsync()
@@ -19,10 +19,11 @@ public class SteamAuthController : ControllerBase
private readonly JwtTokenService _tokens; private readonly JwtTokenService _tokens;
private readonly IConfiguration _cfg; private readonly IConfiguration _cfg;
private readonly ILogger<SteamAuthController> _logger; private readonly ILogger<SteamAuthController> _logger;
private readonly LegalPolicy _legal;
public SteamAuthController(UserService users, SteamAuthTransactionService transactions, public SteamAuthController(UserService users, SteamAuthTransactionService transactions,
SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg, SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg,
ILogger<SteamAuthController> logger) ILogger<SteamAuthController> logger, LegalPolicy legal)
{ {
_users = users; _users = users;
_transactions = transactions; _transactions = transactions;
@@ -30,6 +31,7 @@ public class SteamAuthController : ControllerBase
_tokens = tokens; _tokens = tokens;
_cfg = cfg; _cfg = cfg;
_logger = logger; _logger = logger;
_legal = legal;
} }
[HttpPost("start")] [HttpPost("start")]
@@ -115,7 +117,8 @@ public class SteamAuthController : ControllerBase
user.Username, user.Username,
user.Role, user.Role,
jti, jti,
exp exp,
legal = _legal.Status(user)
}); });
} }
@@ -133,6 +136,8 @@ public class SteamAuthController : ControllerBase
var createdUser = false; var createdUser = false;
if (request.Action == "create") 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); var validation = ValidateNewAccount(request);
if (validation is not null) if (validation is not null)
return BadRequest(new { message = validation }); return BadRequest(new { message = validation });
@@ -149,6 +154,7 @@ public class SteamAuthController : ControllerBase
RefreshToken = NewRefreshToken(), RefreshToken = NewRefreshToken(),
RefreshTokenExpiry = DateTime.UtcNow.AddDays(7) RefreshTokenExpiry = DateTime.UtcNow.AddDays(7)
}; };
_legal.Accept(user, "steam_registration");
try try
{ {
await _users.CreateAsync(user); await _users.CreateAsync(user);
+3 -1
View File
@@ -1,6 +1,6 @@
namespace AuthApi.Models; 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 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 ChangeRoleRequest { public string Username { get; set; } = ""; public string NewRole { get; set; } = ""; }
public class RefreshRequest { public string Username { get; set; } = ""; public string RefreshToken { 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 Username { get; set; } = "";
public string? Email { get; set; } public string? Email { get; set; }
public string Password { get; set; } = ""; public string Password { get; set; } = "";
public bool AcceptLegal { get; set; }
} }
public class AcceptLegalRequest { public bool Accept { get; set; } }
+12
View File
@@ -16,4 +16,16 @@ public class User
public string? RefreshToken { get; set; } public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiry { get; set; } public DateTime? RefreshTokenExpiry { get; set; }
public string? SteamId { get; set; } public string? SteamId { get; set; }
public string? TermsAcceptedVersion { get; set; }
public string? PrivacyAcceptedVersion { get; set; }
public DateTime? LegalAcceptedUtc { get; set; }
public List<LegalAcceptanceRecord> 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; } = "";
} }
+1
View File
@@ -15,6 +15,7 @@ builder.Services.AddSingleton<BlacklistService>();
builder.Services.AddSingleton<SteamAuthTransactionService>(); builder.Services.AddSingleton<SteamAuthTransactionService>();
builder.Services.AddSingleton<JwtTokenService>(); builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<SteamOpenIdService>(); builder.Services.AddSingleton<SteamOpenIdService>();
builder.Services.AddSingleton<LegalPolicy>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
@@ -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
});
}
}
+1
View File
@@ -3,6 +3,7 @@
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, "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 }, "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" } }, "Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*" "AllowedHosts": "*"
} }
@@ -34,6 +34,12 @@ spec:
value: https://pauth.ranaze.com/api/Auth/steam/callback value: https://pauth.ranaze.com/api/Auth/steam/callback
- name: Steam__TransactionTtlMinutes - name: Steam__TransactionTtlMinutes
value: "5" 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: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz