Add versioned legal consent to authentication
This commit is contained in:
@@ -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 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<IActionResult> 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<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")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
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;
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -19,10 +19,11 @@ public class SteamAuthController : ControllerBase
|
||||
private readonly JwtTokenService _tokens;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<SteamAuthController> _logger;
|
||||
private readonly LegalPolicy _legal;
|
||||
|
||||
public SteamAuthController(UserService users, SteamAuthTransactionService transactions,
|
||||
SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg,
|
||||
ILogger<SteamAuthController> logger)
|
||||
ILogger<SteamAuthController> 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);
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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<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; } = "";
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ builder.Services.AddSingleton<BlacklistService>();
|
||||
builder.Services.AddSingleton<SteamAuthTransactionService>();
|
||||
builder.Services.AddSingleton<JwtTokenService>();
|
||||
builder.Services.AddSingleton<SteamOpenIdService>();
|
||||
builder.Services.AddSingleton<LegalPolicy>();
|
||||
builder.Services.AddHttpClient();
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user