Merge authentication into Questions API
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/Auth")]
|
||||
public sealed class AuthController(
|
||||
AuthUserStore users,
|
||||
TokenBlacklistStore blacklist,
|
||||
JwtTokenService tokens,
|
||||
LegalPolicy legal,
|
||||
UserProfileStore profiles,
|
||||
PasswordResetStore passwordResets,
|
||||
SteamAuthTransactionStore steamTransactions,
|
||||
PasswordResetCodeService resetCodes,
|
||||
IPasswordResetEmailSender resetEmail,
|
||||
IConfiguration configuration,
|
||||
ILogger<AuthController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("legal")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Legal() => Ok(legal.Status());
|
||||
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-register")]
|
||||
public async Task<IActionResult> Register(RegisterRequest request)
|
||||
{
|
||||
var username = request.Username.Trim();
|
||||
var password = request.Password;
|
||||
var email = AuthValidation.NormalizeEmail(request.Email ?? "");
|
||||
if (!request.AcceptLegal)
|
||||
return BadRequest("You must agree to the Terms of Service and acknowledge the Privacy Policy");
|
||||
if (AuthValidation.ValidateNewAccount(username, email, password) is { } validation)
|
||||
return BadRequest(validation);
|
||||
if (await users.GetByUsernameAsync(username) is not null)
|
||||
return Conflict("Username already exists");
|
||||
if (await users.EmailExistsAsync(email))
|
||||
return Conflict("Email address is already in use.");
|
||||
|
||||
var user = new AuthUser
|
||||
{
|
||||
Username = username,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||
Role = "USER",
|
||||
Email = email,
|
||||
NormalizedEmail = email,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
legal.Accept(user, "password_registration");
|
||||
try
|
||||
{
|
||||
await users.CreateAsync(user);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Conflict("Username or email address already exists");
|
||||
}
|
||||
return Ok("User created");
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<IActionResult> Login(LoginRequest request)
|
||||
{
|
||||
var candidates = await users.GetByLoginCandidatesAsync(request.Username);
|
||||
var user = candidates.FirstOrDefault(candidate =>
|
||||
BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash));
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var (accessToken, jti, expiresUtc) = tokens.Generate(user);
|
||||
user.RefreshToken = JwtTokenService.NewRefreshToken();
|
||||
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
||||
await users.UpdateAsync(user);
|
||||
return Ok(SessionResponse(user, accessToken, expiresUtc, jti, user.RefreshToken));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-refresh")]
|
||||
public async Task<IActionResult> Refresh(RefreshRequest request)
|
||||
{
|
||||
var user = await users.GetByUsernameAsync(request.Username);
|
||||
if (user is null || user.RefreshToken != request.RefreshToken ||
|
||||
user.RefreshTokenExpiry < DateTime.UtcNow)
|
||||
return Unauthorized("Invalid or expired refresh token");
|
||||
var (accessToken, _, expiresUtc) = tokens.Generate(user);
|
||||
return Ok(SessionResponse(user, accessToken, expiresUtc));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
if (ReadCurrentToken() is not { } token) return BadRequest("Token missing");
|
||||
await blacklist.AddAsync(token.Id, token.ValidTo);
|
||||
return Ok("Logged out.");
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Me() =>
|
||||
await CurrentUserAsync() is { } user ? Ok(ProfileResponse(user)) : Unauthorized();
|
||||
|
||||
[HttpPost("legal/accept")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> AcceptLegal(AcceptLegalRequest request)
|
||||
{
|
||||
if (!request.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(UpdateAuthProfileRequest request)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null) return Unauthorized();
|
||||
var displayName = request.DisplayName?.Trim();
|
||||
var email = string.IsNullOrWhiteSpace(request.Email)
|
||||
? null
|
||||
: AuthValidation.NormalizeEmail(request.Email);
|
||||
if (!string.IsNullOrEmpty(displayName) && displayName.Length > 80)
|
||||
return BadRequest("Display name must be 80 characters or fewer.");
|
||||
if (email is not null && !AuthValidation.ValidEmail(email))
|
||||
return BadRequest("A valid email address is required.");
|
||||
if (email is not null && await users.EmailExistsAsync(email, user.Id))
|
||||
return Conflict("Email address is already in use.");
|
||||
|
||||
user.DisplayName = string.IsNullOrEmpty(displayName) ? null : displayName;
|
||||
user.Email = email;
|
||||
user.NormalizedEmail = email;
|
||||
user.CreatedUtc ??= CreationDate(user);
|
||||
try
|
||||
{
|
||||
await users.UpdateAsync(user);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Conflict("Email address is already in use.");
|
||||
}
|
||||
return Ok(ProfileResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("me/change-password")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> ChangePassword(ChangePasswordRequest request)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null) return Unauthorized();
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
||||
return BadRequest("Current password is incorrect.");
|
||||
if (request.NewPassword.Length < 8)
|
||||
return BadRequest("Password must be at least 8 characters.");
|
||||
if (!await users.ResetPasswordAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(request.NewPassword)))
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
await RevokeCurrentTokenAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("me")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> DeleteAccount(DeleteAccountRequest request)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null) return Unauthorized();
|
||||
if (request.Confirmation != "DELETE") return BadRequest("Type DELETE to confirm permanent deletion.");
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||
return BadRequest("Password is incorrect.");
|
||||
|
||||
try
|
||||
{
|
||||
await profiles.PurgeAsync(user.Id);
|
||||
await passwordResets.DeleteForUserAsync(user.Id);
|
||||
await steamTransactions.DeleteForUserAsync(user.Id);
|
||||
await RevokeCurrentTokenAsync();
|
||||
await users.DeleteAsync(user.Id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Account purge failed for user {UserId}", user.Id);
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable,
|
||||
"Study data could not be removed. The account was not deleted; please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("password-reset/request")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("password-reset-request")]
|
||||
public async Task<IActionResult> RequestPasswordReset(
|
||||
PasswordResetStartRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!PasswordResetEnabled())
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable,
|
||||
"Password reset is temporarily unavailable.");
|
||||
var user = await users.ResolveUniqueLoginAsync(request.Login);
|
||||
if (user is null || string.IsNullOrWhiteSpace(user.Email) ||
|
||||
await passwordResets.IssuedRecentlyAsync(user.Id, DateTime.UtcNow.AddMinutes(-1)))
|
||||
return Ok(new { message = "If the account exists, a verification code has been sent." });
|
||||
|
||||
var code = resetCodes.GenerateCode();
|
||||
var reset = new PasswordResetRequest
|
||||
{
|
||||
UserId = user.Id,
|
||||
CreatedUtc = DateTime.UtcNow,
|
||||
ExpiresUtc = DateTime.UtcNow.AddMinutes(10)
|
||||
};
|
||||
reset.CodeHash = resetCodes.Hash(reset.Id, code);
|
||||
try
|
||||
{
|
||||
await passwordResets.CreateAsync(reset);
|
||||
await resetEmail.SendAsync(user.Email, user.Username, code, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await passwordResets.ConsumeForUserAsync(user.Id);
|
||||
logger.LogError(exception, "Unable to deliver password reset email for user {UserId}", user.Id);
|
||||
}
|
||||
return Ok(new { message = "If the account exists, a verification code has been sent." });
|
||||
}
|
||||
|
||||
[HttpPost("password-reset/confirm")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("password-reset-confirm")]
|
||||
public async Task<IActionResult> ConfirmPasswordReset(PasswordResetConfirmRequest request)
|
||||
{
|
||||
if (!PasswordResetEnabled())
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable,
|
||||
"Password reset is temporarily unavailable.");
|
||||
if (request.NewPassword.Length < 8)
|
||||
return BadRequest("Password must be at least 8 characters.");
|
||||
if (request.Code.Length != 6 || !request.Code.All(char.IsAsciiDigit))
|
||||
return BadRequest("The verification code is invalid or expired.");
|
||||
var user = await users.ResolveUniqueLoginAsync(request.Login);
|
||||
if (user is null) return BadRequest("The verification code is invalid or expired.");
|
||||
var reset = await passwordResets.LatestActiveAsync(user.Id);
|
||||
if (reset is null) return BadRequest("The verification code is invalid or expired.");
|
||||
if (!resetCodes.Verify(reset.Id, request.Code, reset.CodeHash))
|
||||
{
|
||||
await passwordResets.RecordFailureAsync(reset.Id);
|
||||
return BadRequest("The verification code is invalid or expired.");
|
||||
}
|
||||
if (!await passwordResets.TryConsumeAsync(reset.Id))
|
||||
return BadRequest("The verification code is invalid or expired.");
|
||||
if (!await users.ResetPasswordAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(request.NewPassword)))
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
await passwordResets.ConsumeForUserAsync(user.Id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("role")]
|
||||
[Authorize(Roles = "SUPER")]
|
||||
public async Task<IActionResult> ChangeUserRole(ChangeRoleRequest request)
|
||||
{
|
||||
if (request.NewRole is not ("USER" or "SUPER"))
|
||||
return BadRequest("Role must be 'USER' or 'SUPER'");
|
||||
var user = await users.GetByUsernameAsync(request.Username);
|
||||
if (user is null) return NotFound("User not found");
|
||||
user.Role = request.NewRole;
|
||||
user.AuthVersion++;
|
||||
user.RefreshToken = null;
|
||||
user.RefreshTokenExpiry = null;
|
||||
await users.UpdateAsync(user);
|
||||
return Ok($"{request.Username}'s role updated to {request.NewRole}");
|
||||
}
|
||||
|
||||
[HttpGet("users")]
|
||||
[Authorize(Roles = "SUPER")]
|
||||
public async Task<IActionResult> GetAllUsers() => Ok((await users.GetAllAsync()).Select(ProfileResponse));
|
||||
|
||||
private async Task<AuthUser?> CurrentUserAsync()
|
||||
{
|
||||
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return string.IsNullOrWhiteSpace(id) ? null : await users.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
private static DateTime CreationDate(AuthUser user) =>
|
||||
ObjectId.TryParse(user.Id, out var id) ? id.CreationTime : DateTime.UtcNow;
|
||||
|
||||
private object ProfileResponse(AuthUser user) => new
|
||||
{
|
||||
userId = user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Email,
|
||||
user.Role,
|
||||
createdUtc = user.CreatedUtc ?? CreationDate(user),
|
||||
legal = legal.Status(user)
|
||||
};
|
||||
|
||||
private object SessionResponse(AuthUser user, string accessToken, DateTime expiresUtc,
|
||||
string? jti = null, string? refreshToken = null) => new
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user.Username,
|
||||
user.Role,
|
||||
jti,
|
||||
exp = expiresUtc,
|
||||
legal = legal.Status(user)
|
||||
};
|
||||
|
||||
private JwtSecurityToken? ReadCurrentToken()
|
||||
{
|
||||
var value = Request.Headers.Authorization.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return new JwtSecurityTokenHandler().ReadJwtToken(value.Replace("Bearer ", ""));
|
||||
}
|
||||
|
||||
private async Task RevokeCurrentTokenAsync()
|
||||
{
|
||||
if (ReadCurrentToken() is { } token) await blacklist.AddAsync(token.Id, token.ValidTo);
|
||||
}
|
||||
|
||||
private bool PasswordResetEnabled() =>
|
||||
configuration.GetValue("PasswordReset:Enabled", false) && resetCodes.IsConfigured;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/Auth/steam")]
|
||||
public sealed class SteamAuthController(
|
||||
AuthUserStore users,
|
||||
SteamAuthTransactionStore transactions,
|
||||
SteamOpenIdService openId,
|
||||
JwtTokenService tokens,
|
||||
IConfiguration configuration,
|
||||
ILogger<SteamAuthController> logger,
|
||||
LegalPolicy legal) : ControllerBase
|
||||
{
|
||||
[HttpPost("start")]
|
||||
[EnableRateLimiting("steam-start")]
|
||||
public async Task<IActionResult> Start()
|
||||
{
|
||||
var transactionId = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var lifetime = Math.Clamp(configuration.GetValue("Steam:TransactionTtlMinutes", 5), 2, 15);
|
||||
var expiresUtc = DateTime.UtcNow.AddMinutes(lifetime);
|
||||
await transactions.CreateAsync(new SteamAuthTransaction
|
||||
{
|
||||
Id = transactionId,
|
||||
CreatedUtc = DateTime.UtcNow,
|
||||
ExpiresUtc = expiresUtc
|
||||
});
|
||||
return Ok(new
|
||||
{
|
||||
transactionId,
|
||||
authUrl = SteamOpenIdService.BuildAuthenticationUrl(ConfiguredRealm(), ReturnUrl(transactionId)),
|
||||
expiresUtc
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("callback")]
|
||||
[EnableRateLimiting("steam-complete")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string transactionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var transaction = await ActiveTransactionAsync(transactionId);
|
||||
if (transaction is null || transaction.Status != "pending")
|
||||
return CallbackPage(false, "This Steam sign-in request has expired. Return to RanaZe and try again.");
|
||||
var steamId = await openId.VerifyCallbackAsync(Request.Query, ReturnUrl(transactionId), cancellationToken);
|
||||
if (steamId is null)
|
||||
{
|
||||
logger.LogWarning("Steam OpenID verification failed");
|
||||
return CallbackPage(false, "Steam could not verify this sign-in. Return to RanaZe and try again.");
|
||||
}
|
||||
|
||||
var user = await users.GetBySteamIdAsync(steamId);
|
||||
var status = user is null ? "requires_account" : "complete";
|
||||
if (!await transactions.MarkVerifiedAsync(transactionId, steamId, status, user?.Id))
|
||||
return CallbackPage(false, "This Steam sign-in request has already been used.");
|
||||
if (user is not null)
|
||||
{
|
||||
user.RefreshToken = JwtTokenService.NewRefreshToken();
|
||||
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
||||
await users.UpdateAsync(user);
|
||||
}
|
||||
return CallbackPage(true, "Steam verified your identity. You can return to RanaZe.");
|
||||
}
|
||||
|
||||
[HttpGet("status/{transactionId}")]
|
||||
[EnableRateLimiting("steam-poll")]
|
||||
public async Task<IActionResult> Status(string transactionId)
|
||||
{
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
var transaction = await ActiveTransactionAsync(transactionId);
|
||||
if (transaction is null) return NotFound(new { message = "Steam sign-in expired." });
|
||||
if (transaction.Status != "complete") return Ok(new { status = transaction.Status });
|
||||
if (string.IsNullOrWhiteSpace(transaction.UserId))
|
||||
return Conflict(new { message = "Steam sign-in is incomplete." });
|
||||
|
||||
var user = await users.GetByIdAsync(transaction.UserId);
|
||||
if (user is null || user.SteamId != transaction.SteamId)
|
||||
return Conflict(new { message = "Steam account link is no longer valid." });
|
||||
if (string.IsNullOrWhiteSpace(user.RefreshToken) || user.RefreshTokenExpiry <= DateTime.UtcNow)
|
||||
{
|
||||
user.RefreshToken = JwtTokenService.NewRefreshToken();
|
||||
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
||||
await users.UpdateAsync(user);
|
||||
}
|
||||
var (accessToken, jti, expiresUtc) = tokens.Generate(user);
|
||||
return Ok(new
|
||||
{
|
||||
status = "complete",
|
||||
accessToken,
|
||||
refreshToken = user.RefreshToken,
|
||||
user.Username,
|
||||
user.Role,
|
||||
jti,
|
||||
exp = expiresUtc,
|
||||
legal = legal.Status(user)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("complete")]
|
||||
[EnableRateLimiting("steam-complete")]
|
||||
public async Task<IActionResult> Complete(CompleteSteamRequest request)
|
||||
{
|
||||
var transaction = await ActiveTransactionAsync(request.TransactionId);
|
||||
if (transaction?.Status == "complete") return Ok(new { status = "complete" });
|
||||
if (transaction is null || transaction.Status != "requires_account" ||
|
||||
string.IsNullOrWhiteSpace(transaction.SteamId))
|
||||
return BadRequest(new { message = "Steam sign-in is not ready or has expired." });
|
||||
|
||||
AuthUser user;
|
||||
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 username = request.Username.Trim();
|
||||
var email = AuthValidation.NormalizeEmail(request.Email ?? "");
|
||||
if (AuthValidation.ValidateNewAccount(username, email, request.Password) is { } validation)
|
||||
return BadRequest(new { message = validation });
|
||||
if (await users.GetByUsernameAsync(username) is not null)
|
||||
return Conflict(new { message = "Username already exists." });
|
||||
if (await users.EmailExistsAsync(email))
|
||||
return Conflict(new { message = "Email address is already in use." });
|
||||
user = new AuthUser
|
||||
{
|
||||
Username = username,
|
||||
Email = email,
|
||||
NormalizedEmail = email,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||
Role = "USER",
|
||||
CreatedUtc = DateTime.UtcNow,
|
||||
SteamId = transaction.SteamId,
|
||||
RefreshToken = JwtTokenService.NewRefreshToken(),
|
||||
RefreshTokenExpiry = DateTime.UtcNow.AddDays(7)
|
||||
};
|
||||
legal.Accept(user, "steam_registration");
|
||||
try
|
||||
{
|
||||
await users.CreateAsync(user);
|
||||
createdUser = true;
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Conflict(new { message = "That username, email, or Steam account is already connected." });
|
||||
}
|
||||
}
|
||||
else if (request.Action == "link")
|
||||
{
|
||||
var candidates = await users.GetByLoginCandidatesAsync(request.Username);
|
||||
user = candidates.FirstOrDefault(candidate =>
|
||||
BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash))!;
|
||||
if (user is null) return Unauthorized(new { message = "The account credentials are incorrect." });
|
||||
if (!string.IsNullOrWhiteSpace(user.SteamId) && user.SteamId != transaction.SteamId)
|
||||
return Conflict(new { message = "That RanaZe account is already connected to another Steam account." });
|
||||
try
|
||||
{
|
||||
if (!await users.TryLinkSteamAsync(user.Id, transaction.SteamId,
|
||||
JwtTokenService.NewRefreshToken(), DateTime.UtcNow.AddDays(7)))
|
||||
return Conflict(new { message = "That account could not be connected to Steam." });
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Conflict(new { message = "That Steam account is already connected." });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest(new { message = "Action must be create or link." });
|
||||
}
|
||||
|
||||
if (!await transactions.MarkCompleteAsync(request.TransactionId, transaction.SteamId, user.Id))
|
||||
{
|
||||
if (createdUser) await users.DeleteAsync(user.Id);
|
||||
return Conflict(new { message = "This Steam sign-in was already completed." });
|
||||
}
|
||||
return Ok(new { status = "complete" });
|
||||
}
|
||||
|
||||
private async Task<SteamAuthTransaction?> ActiveTransactionAsync(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || id.Length > 128) return null;
|
||||
var transaction = await transactions.GetAsync(id);
|
||||
return transaction is not null && transaction.ExpiresUtc > DateTime.UtcNow ? transaction : null;
|
||||
}
|
||||
|
||||
private string ConfiguredRealm() =>
|
||||
(configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/');
|
||||
|
||||
private string ReturnUrl(string transactionId)
|
||||
{
|
||||
var callback = configuration["Steam:ReturnUrl"] ?? $"{ConfiguredRealm()}/api/Auth/steam/callback";
|
||||
return $"{callback}?transactionId={Uri.EscapeDataString(transactionId)}";
|
||||
}
|
||||
|
||||
private ContentResult CallbackPage(bool success, string message)
|
||||
{
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers.ContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'";
|
||||
Response.Headers.XFrameOptions = "DENY";
|
||||
Response.Headers["Referrer-Policy"] = "no-referrer";
|
||||
var color = success ? "#22c55e" : "#ef4444";
|
||||
var html = $$"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<title>RanaZe Steam sign-in</title><style>body{font-family:system-ui;background:#081324;color:#f8fafc;display:grid;place-items:center;min-height:100vh;margin:0}.card{max-width:34rem;padding:2rem;border:1px solid #33415d;border-radius:8px}h1{color:{{color}};font-size:1.4rem}</style></head>
|
||||
<body><main class="card"><h1>{{(success ? "Steam sign-in complete" : "Steam sign-in failed")}}</h1><p>{{System.Net.WebUtility.HtmlEncode(message)}}</p></main>
|
||||
<script>if(window.opener){setTimeout(()=>window.close(),1200);}</script></body></html>
|
||||
""";
|
||||
return Content(html, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
Reference in New Issue
Block a user