Add provider-neutral Google authentication
This commit is contained in:
@@ -57,6 +57,8 @@ kubectl create secret generic step3-runtime-secrets \
|
||||
--from-literal=purge-secret="${INTERNAL_PURGE_SECRET}" \
|
||||
--from-literal=stripe-secret-key="${STRIPE_SECRET_KEY}" \
|
||||
--from-literal=stripe-webhook-secret="${STRIPE_WEBHOOK_SECRET}" \
|
||||
--from-literal=google-client-id="${GOOGLE_CLIENT_ID:-}" \
|
||||
--from-literal=google-client-secret="${GOOGLE_CLIENT_SECRET:-}" \
|
||||
--dry-run=client -o yaml | kubectl apply -n "${KUBE_NAMESPACE}" -f -
|
||||
|
||||
kubectl kustomize "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}" \
|
||||
|
||||
@@ -37,5 +37,6 @@ jobs:
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.PRD_INTERNAL_PURGE_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.PRD_STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.PRD_GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.PRD_GOOGLE_CLIENT_SECRET }}
|
||||
run: bash .gitea/scripts/deploy-environment.sh
|
||||
|
||||
|
||||
@@ -37,5 +37,6 @@ jobs:
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.UAT_STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.UAT_GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.UAT_GOOGLE_CLIENT_SECRET }}
|
||||
run: bash .gitea/scripts/deploy-environment.sh
|
||||
|
||||
|
||||
@@ -86,6 +86,53 @@ public sealed class AuthTests
|
||||
Assert.Null(SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoogleProviderIsDisabledUntilAllSecureConfigurationExists()
|
||||
{
|
||||
var incomplete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary<string, string?>
|
||||
{
|
||||
["Google:ClientId"] = "client-id",
|
||||
["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback"
|
||||
}));
|
||||
var complete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary<string, string?>
|
||||
{
|
||||
["Google:ClientId"] = "client-id",
|
||||
["Google:ClientSecret"] = "client-secret",
|
||||
["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback"
|
||||
}));
|
||||
|
||||
Assert.False(incomplete.IsConfigured);
|
||||
Assert.True(complete.IsConfigured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoogleAuthorizationUsesStateNonceAndPkce()
|
||||
{
|
||||
var provider = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary<string, string?>
|
||||
{
|
||||
["Google:ClientId"] = "client-id",
|
||||
["Google:ClientSecret"] = "client-secret",
|
||||
["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback"
|
||||
}));
|
||||
var url = provider.BuildAuthorizationUrl(new ExternalAuthTransaction
|
||||
{
|
||||
Id = "transaction-state",
|
||||
Nonce = "transaction-nonce",
|
||||
CodeVerifier = "a-long-test-code-verifier"
|
||||
});
|
||||
|
||||
Assert.Contains("state=transaction-state", url);
|
||||
Assert.Contains("nonce=transaction-nonce", url);
|
||||
Assert.Contains("code_challenge_method=S256", url);
|
||||
Assert.Contains("scope=openid%20email%20profile", url);
|
||||
Assert.DoesNotContain("client-secret", url);
|
||||
}
|
||||
|
||||
private static IConfiguration Configuration(Dictionary<string, string?> values) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
private sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public sealed class AuthController(
|
||||
UserProfileStore profiles,
|
||||
PasswordResetStore passwordResets,
|
||||
SteamAuthTransactionStore steamTransactions,
|
||||
ExternalIdentityStore externalIdentities,
|
||||
ExternalAuthTransactionStore externalTransactions,
|
||||
IEnumerable<IExternalAuthProvider> externalProviders,
|
||||
PasswordResetCodeService resetCodes,
|
||||
IPasswordResetEmailSender resetEmail,
|
||||
IConfiguration configuration,
|
||||
@@ -109,7 +112,7 @@ public sealed class AuthController(
|
||||
[HttpGet("me")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Me() =>
|
||||
await CurrentUserAsync() is { } user ? Ok(ProfileResponse(user)) : Unauthorized();
|
||||
await CurrentUserAsync() is { } user ? Ok(await ProfileResponseAsync(user)) : Unauthorized();
|
||||
|
||||
[HttpPost("legal/accept")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
@@ -152,7 +155,7 @@ public sealed class AuthController(
|
||||
{
|
||||
return Conflict("Email address is already in use.");
|
||||
}
|
||||
return Ok(ProfileResponse(user));
|
||||
return Ok(await ProfileResponseAsync(user));
|
||||
}
|
||||
|
||||
[HttpPost("me/change-password")]
|
||||
@@ -186,6 +189,7 @@ public sealed class AuthController(
|
||||
await profiles.PurgeAsync(user.Id);
|
||||
await passwordResets.DeleteForUserAsync(user.Id);
|
||||
await steamTransactions.DeleteForUserAsync(user.Id);
|
||||
await externalTransactions.DeleteForUserAsync(user.Id);
|
||||
await RevokeCurrentTokenAsync();
|
||||
await users.DeleteAsync(user.Id);
|
||||
return NoContent();
|
||||
@@ -302,6 +306,20 @@ public sealed class AuthController(
|
||||
legal = legal.Status(user)
|
||||
};
|
||||
|
||||
private async Task<object> ProfileResponseAsync(AuthUser user) => new
|
||||
{
|
||||
userId = user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Email,
|
||||
user.Role,
|
||||
createdUtc = user.CreatedUtc ?? CreationDate(user),
|
||||
connectedProviders = await externalIdentities.ProvidersForUserAsync(user.Id),
|
||||
availableProviders = externalProviders.Where(provider => provider.IsConfigured)
|
||||
.Select(provider => provider.Id).OrderBy(provider => provider),
|
||||
legal = legal.Status(user)
|
||||
};
|
||||
|
||||
private object SessionResponse(AuthUser user, string accessToken, DateTime expiresUtc,
|
||||
string? jti = null, string? refreshToken = null) => new
|
||||
{
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/Auth/external")]
|
||||
public sealed class ExternalAuthController(
|
||||
IEnumerable<IExternalAuthProvider> providerServices,
|
||||
ExternalIdentityStore identities,
|
||||
ExternalAuthTransactionStore transactions,
|
||||
AuthUserStore users,
|
||||
JwtTokenService tokens,
|
||||
LegalPolicy legal,
|
||||
IConfiguration configuration,
|
||||
ILogger<ExternalAuthController> logger) : ControllerBase
|
||||
{
|
||||
private readonly Dictionary<string, IExternalAuthProvider> _providers = providerServices
|
||||
.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[HttpGet("providers")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Providers() => Ok(new
|
||||
{
|
||||
providers = _providers.Values.Where(value => value.IsConfigured)
|
||||
.OrderBy(value => value.DisplayName)
|
||||
.Select(value => new { id = value.Id, name = value.DisplayName })
|
||||
});
|
||||
|
||||
[HttpPost("{provider}/start")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("external-start")]
|
||||
public async Task<IActionResult> Start(string provider, ExternalAuthStartRequest? request)
|
||||
{
|
||||
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
|
||||
var purpose = (request?.Purpose ?? "login").Trim().ToLowerInvariant();
|
||||
if (purpose is not ("login" or "link")) return BadRequest(new { message = "Purpose must be login or link." });
|
||||
string? userId = null;
|
||||
if (purpose == "link")
|
||||
{
|
||||
userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
if (await identities.GetForUserAsync(userId, service.Id) is not null)
|
||||
return Conflict(new { message = $"This account is already connected to {service.DisplayName}." });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lifetime = Math.Clamp(configuration.GetValue("ExternalAuth:TransactionTtlMinutes", 5), 2, 15);
|
||||
var transaction = new ExternalAuthTransaction
|
||||
{
|
||||
Id = Base64Url(RandomNumberGenerator.GetBytes(32)),
|
||||
Provider = service.Id,
|
||||
Purpose = purpose,
|
||||
UserId = userId,
|
||||
Nonce = GoogleExternalAuthProvider.NewNonce(),
|
||||
CodeVerifier = GoogleExternalAuthProvider.NewVerifier(),
|
||||
CreatedUtc = now,
|
||||
ExpiresUtc = now.AddMinutes(lifetime)
|
||||
};
|
||||
await transactions.CreateAsync(transaction);
|
||||
return Ok(new
|
||||
{
|
||||
transactionId = transaction.Id,
|
||||
provider = service.Id,
|
||||
authUrl = service.BuildAuthorizationUrl(transaction),
|
||||
transaction.ExpiresUtc
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{provider}/callback")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("external-complete")]
|
||||
public async Task<IActionResult> Callback(
|
||||
string provider,
|
||||
[FromQuery] string? transactionId,
|
||||
[FromQuery] string? state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryProvider(provider, out var service))
|
||||
return CallbackPage(false, "External sign-in", "This provider is unavailable.");
|
||||
var callbackTransactionId = string.IsNullOrWhiteSpace(transactionId) ? state : transactionId;
|
||||
var transaction = await ActiveTransactionAsync(callbackTransactionId ?? "");
|
||||
if (transaction is null || transaction.Status != "pending" || transaction.Provider != service.Id)
|
||||
return CallbackPage(false, service.DisplayName, "This sign-in request has expired. Return to RanaZe and try again.");
|
||||
var claims = await service.VerifyCallbackAsync(Request.Query, transaction, cancellationToken);
|
||||
if (claims is null)
|
||||
{
|
||||
logger.LogWarning("{Provider} external identity verification failed", service.Id);
|
||||
return CallbackPage(false, service.DisplayName, $"{service.DisplayName} could not verify this sign-in.");
|
||||
}
|
||||
|
||||
if (transaction.Purpose == "link")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(transaction.UserId) ||
|
||||
!await identities.TryLinkAsync(transaction.UserId, service.Id, claims) ||
|
||||
!await transactions.MarkVerifiedAsync(transaction.Id, claims, "linked", transaction.UserId))
|
||||
return CallbackPage(false, service.DisplayName,
|
||||
$"That {service.DisplayName} identity is already connected to another account.");
|
||||
return CallbackPage(true, service.DisplayName, $"{service.DisplayName} is now connected to your RanaZe account.");
|
||||
}
|
||||
|
||||
var identity = await identities.GetAsync(service.Id, claims.Subject);
|
||||
var user = identity is null ? null : await users.GetByIdAsync(identity.UserId);
|
||||
var status = user is null ? "requires_account" : "complete";
|
||||
if (!await transactions.MarkVerifiedAsync(transaction.Id, claims, status, user?.Id))
|
||||
return CallbackPage(false, service.DisplayName, "This sign-in request has already been used.");
|
||||
if (user is not null)
|
||||
{
|
||||
await identities.TouchAsync(identity!.Id);
|
||||
user.RefreshToken = JwtTokenService.NewRefreshToken();
|
||||
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
||||
await users.UpdateAsync(user);
|
||||
}
|
||||
return CallbackPage(true, service.DisplayName,
|
||||
$"{service.DisplayName} verified your identity. You can return to RanaZe.");
|
||||
}
|
||||
|
||||
[HttpGet("{provider}/status/{transactionId}")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("external-poll")]
|
||||
public async Task<IActionResult> Status(string provider, string transactionId)
|
||||
{
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
|
||||
var transaction = await ActiveTransactionAsync(transactionId);
|
||||
if (transaction is null || transaction.Provider != service.Id)
|
||||
return NotFound(new { message = $"{service.DisplayName} sign-in expired." });
|
||||
if (transaction.Status == "requires_account")
|
||||
return Ok(new
|
||||
{
|
||||
status = transaction.Status,
|
||||
provider = service.Id,
|
||||
providerName = service.DisplayName,
|
||||
email = transaction.EmailVerified ? transaction.ProviderEmail : null,
|
||||
emailVerified = transaction.EmailVerified
|
||||
});
|
||||
if (transaction.Status is "pending" or "linked" or "failed")
|
||||
return Ok(new { status = transaction.Status, provider = service.Id, message = transaction.Error });
|
||||
if (transaction.Status != "complete" || string.IsNullOrWhiteSpace(transaction.UserId))
|
||||
return Conflict(new { message = $"{service.DisplayName} sign-in is incomplete." });
|
||||
|
||||
var user = await users.GetByIdAsync(transaction.UserId);
|
||||
if (user is null || transaction.Subject is null ||
|
||||
await identities.GetAsync(service.Id, transaction.Subject) is not { } identity ||
|
||||
identity.UserId != user.Id)
|
||||
return Conflict(new { message = $"{service.DisplayName} 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("{provider}/complete")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("external-complete")]
|
||||
public async Task<IActionResult> Complete(string provider, CompleteExternalAuthRequest request)
|
||||
{
|
||||
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
|
||||
var transaction = await ActiveTransactionAsync(request.TransactionId);
|
||||
if (transaction?.Status == "complete") return Ok(new { status = "complete" });
|
||||
if (transaction is null || transaction.Provider != service.Id || transaction.Status != "requires_account" ||
|
||||
string.IsNullOrWhiteSpace(transaction.Subject))
|
||||
return BadRequest(new { message = $"{service.DisplayName} sign-in is not ready or has expired." });
|
||||
if (await identities.GetAsync(service.Id, transaction.Subject) is not null)
|
||||
return Conflict(new { message = $"That {service.DisplayName} identity is already connected." });
|
||||
|
||||
var claims = new ExternalProviderClaims(
|
||||
transaction.Subject, transaction.ProviderEmail, transaction.EmailVerified);
|
||||
AuthUser user;
|
||||
var createdUser = false;
|
||||
if (request.Action == "create")
|
||||
{
|
||||
if (!request.AcceptLegal)
|
||||
return BadRequest(new { message = "You must accept the current legal agreements." });
|
||||
var username = request.Username.Trim();
|
||||
var email = service.Id == ExternalAuthProviders.Google
|
||||
? AuthValidation.NormalizeEmail(transaction.ProviderEmail ?? "")
|
||||
: AuthValidation.NormalizeEmail(request.Email ?? "");
|
||||
if (service.Id == ExternalAuthProviders.Google && !transaction.EmailVerified)
|
||||
return BadRequest(new { message = "Google did not provide a verified email address." });
|
||||
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 = "That email already belongs to a RanaZe account. Link the existing account with its password."
|
||||
});
|
||||
user = new AuthUser
|
||||
{
|
||||
Username = username,
|
||||
Email = email,
|
||||
NormalizedEmail = email,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||
Role = "USER",
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
legal.Accept(user, $"{service.Id}_registration");
|
||||
try
|
||||
{
|
||||
await users.CreateAsync(user);
|
||||
createdUser = true;
|
||||
}
|
||||
catch (MongoWriteException exception) when (
|
||||
exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Conflict(new { message = "That username or email is already in use." });
|
||||
}
|
||||
}
|
||||
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 (await identities.GetForUserAsync(user.Id, service.Id) is not null)
|
||||
return Conflict(new { message = $"That RanaZe account already has a {service.DisplayName} identity." });
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest(new { message = "Action must be create or link." });
|
||||
}
|
||||
|
||||
if (!await identities.TryLinkAsync(user.Id, service.Id, claims))
|
||||
{
|
||||
if (createdUser) await users.DeleteAsync(user.Id);
|
||||
return Conflict(new { message = $"That {service.DisplayName} identity is already connected." });
|
||||
}
|
||||
if (!await transactions.MarkCompleteAsync(transaction.Id, transaction.Subject, user.Id))
|
||||
{
|
||||
if (createdUser)
|
||||
{
|
||||
await identities.Collection.DeleteManyAsync(value => value.UserId == user.Id);
|
||||
await users.DeleteAsync(user.Id);
|
||||
}
|
||||
return Conflict(new { message = "This external sign-in was already completed." });
|
||||
}
|
||||
return Ok(new { status = "complete" });
|
||||
}
|
||||
|
||||
private bool TryProvider(string provider, out IExternalAuthProvider service)
|
||||
{
|
||||
if (_providers.TryGetValue(provider.Trim(), out service!) && service.IsConfigured) return true;
|
||||
service = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<ExternalAuthTransaction?> 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 ContentResult CallbackPage(bool success, string provider, 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 safeProvider = System.Net.WebUtility.HtmlEncode(provider);
|
||||
var safeMessage = System.Net.WebUtility.HtmlEncode(message);
|
||||
var html = $$"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<title>RanaZe {{safeProvider}} 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>{{safeProvider}} sign-in {{(success ? "complete" : "failed")}}</h1><p>{{safeMessage}}</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('/', '_');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class ExternalAuthProviders
|
||||
{
|
||||
public const string Steam = "steam";
|
||||
public const string Google = "google";
|
||||
|
||||
public static bool Supported(string provider) => provider is Steam or Google;
|
||||
}
|
||||
|
||||
public sealed class ExternalIdentity
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
public string UserId { get; set; } = "";
|
||||
public string Provider { get; set; } = "";
|
||||
public string Subject { get; set; } = "";
|
||||
public string? Email { get; set; }
|
||||
public bool EmailVerified { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime LastUsedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExternalAuthTransaction
|
||||
{
|
||||
[BsonId] public string Id { get; set; } = "";
|
||||
public string Provider { get; set; } = "";
|
||||
public string Purpose { get; set; } = "login";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? Subject { get; set; }
|
||||
public string? ProviderEmail { get; set; }
|
||||
public bool EmailVerified { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? Nonce { get; set; }
|
||||
public string? CodeVerifier { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExternalAuthStartRequest
|
||||
{
|
||||
public string Purpose { get; set; } = "login";
|
||||
}
|
||||
|
||||
public sealed class CompleteExternalAuthRequest
|
||||
{
|
||||
public string TransactionId { get; set; } = "";
|
||||
public string Action { get; set; } = "";
|
||||
public string Username { get; set; } = "";
|
||||
public string? Email { get; set; }
|
||||
public string Password { get; set; } = "";
|
||||
public bool AcceptLegal { get; set; }
|
||||
}
|
||||
|
||||
public sealed record ExternalProviderClaims(
|
||||
string Subject,
|
||||
string? Email,
|
||||
bool EmailVerified);
|
||||
@@ -49,6 +49,10 @@ builder.Services.AddSingleton<JwtTokenService>();
|
||||
builder.Services.AddSingleton<LegalPolicy>();
|
||||
builder.Services.AddSingleton<SteamAuthTransactionStore>();
|
||||
builder.Services.AddSingleton<SteamOpenIdService>();
|
||||
builder.Services.AddSingleton<ExternalIdentityStore>();
|
||||
builder.Services.AddSingleton<ExternalAuthTransactionStore>();
|
||||
builder.Services.AddSingleton<IExternalAuthProvider, SteamExternalAuthProvider>();
|
||||
builder.Services.AddSingleton<IExternalAuthProvider, GoogleExternalAuthProvider>();
|
||||
builder.Services.AddSingleton<PasswordResetStore>();
|
||||
builder.Services.AddSingleton<PasswordResetCodeService>();
|
||||
builder.Services.AddSingleton<IPasswordResetEmailSender, SmtpPasswordResetEmailSender>();
|
||||
@@ -78,6 +82,9 @@ builder.Services.AddRateLimiter(options =>
|
||||
options.AddPolicy("steam-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1)));
|
||||
options.AddPolicy("steam-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1)));
|
||||
options.AddPolicy("steam-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1)));
|
||||
options.AddPolicy("external-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1)));
|
||||
options.AddPolicy("external-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1)));
|
||||
options.AddPolicy("external-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1)));
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,17 @@ public sealed class AuthDataInitializer(
|
||||
AuthUserStore users,
|
||||
TokenBlacklistStore blacklist,
|
||||
SteamAuthTransactionStore steamTransactions,
|
||||
ExternalIdentityStore externalIdentities,
|
||||
ExternalAuthTransactionStore externalTransactions,
|
||||
PasswordResetStore passwordResets,
|
||||
ILogger<AuthDataInitializer> logger)
|
||||
{
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await BackfillNormalizedEmailsAsync();
|
||||
await externalIdentities.BackfillSteamAsync(logger);
|
||||
await externalIdentities.EnsureIndexesAsync();
|
||||
await externalTransactions.EnsureIndexesAsync();
|
||||
|
||||
await users.Collection.Indexes.CreateManyAsync([
|
||||
new CreateIndexModel<AuthUser>(
|
||||
|
||||
@@ -70,6 +70,15 @@ public sealed class AuthUserStore(MongoDatabaseProvider mongo)
|
||||
return (await _users.UpdateOneAsync(filter, update)).MatchedCount == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> TrySetSteamIdAsync(string userId, string steamId)
|
||||
{
|
||||
var filter = Builders<AuthUser>.Filter.Eq(user => user.Id, userId) &
|
||||
(Builders<AuthUser>.Filter.Eq(user => user.SteamId, null) |
|
||||
Builders<AuthUser>.Filter.Eq(user => user.SteamId, steamId));
|
||||
return (await _users.UpdateOneAsync(filter,
|
||||
Builders<AuthUser>.Update.Set(user => user.SteamId, steamId))).MatchedCount == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(string userId, string passwordHash)
|
||||
{
|
||||
var update = Builders<AuthUser>.Update
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using QuestionsApi.Models;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public interface IExternalAuthProvider
|
||||
{
|
||||
string Id { get; }
|
||||
string DisplayName { get; }
|
||||
bool IsConfigured { get; }
|
||||
string CallbackUrl { get; }
|
||||
string BuildAuthorizationUrl(ExternalAuthTransaction transaction);
|
||||
Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
||||
IQueryCollection query,
|
||||
ExternalAuthTransaction transaction,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class SteamExternalAuthProvider(
|
||||
SteamOpenIdService openId,
|
||||
IConfiguration configuration) : IExternalAuthProvider
|
||||
{
|
||||
public string Id => ExternalAuthProviders.Steam;
|
||||
public string DisplayName => "Steam";
|
||||
public bool IsConfigured => true;
|
||||
public string CallbackUrl => configuration["ExternalAuth:SteamReturnUrl"] ??
|
||||
$"{Realm()}/api/Auth/external/steam/callback";
|
||||
|
||||
public string BuildAuthorizationUrl(ExternalAuthTransaction transaction) =>
|
||||
SteamOpenIdService.BuildAuthenticationUrl(Realm(), CallbackUrlWithTransaction(transaction.Id));
|
||||
|
||||
public async Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
||||
IQueryCollection query,
|
||||
ExternalAuthTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var subject = await openId.VerifyCallbackAsync(
|
||||
query, CallbackUrlWithTransaction(transaction.Id), cancellationToken);
|
||||
return subject is null ? null : new ExternalProviderClaims(subject, null, false);
|
||||
}
|
||||
|
||||
private string Realm() =>
|
||||
(configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/');
|
||||
|
||||
private string CallbackUrlWithTransaction(string transactionId) =>
|
||||
$"{CallbackUrl}?transactionId={Uri.EscapeDataString(transactionId)}";
|
||||
}
|
||||
|
||||
public sealed class GoogleExternalAuthProvider : IExternalAuthProvider
|
||||
{
|
||||
private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||
private const string DiscoveryEndpoint = "https://accounts.google.com/.well-known/openid-configuration";
|
||||
private readonly IHttpClientFactory _httpClients;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IConfigurationManager<OpenIdConnectConfiguration> _discovery;
|
||||
|
||||
public GoogleExternalAuthProvider(IHttpClientFactory httpClients, IConfiguration configuration)
|
||||
{
|
||||
_httpClients = httpClients;
|
||||
_configuration = configuration;
|
||||
_discovery = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
DiscoveryEndpoint, new OpenIdConnectConfigurationRetriever());
|
||||
}
|
||||
|
||||
public string Id => ExternalAuthProviders.Google;
|
||||
public string DisplayName => "Google";
|
||||
public string CallbackUrl => _configuration["Google:ReturnUrl"] ?? "";
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(ClientId) &&
|
||||
!string.IsNullOrWhiteSpace(ClientSecret) &&
|
||||
Uri.TryCreate(CallbackUrl, UriKind.Absolute, out var callback) && callback.Scheme == Uri.UriSchemeHttps;
|
||||
|
||||
private string ClientId => _configuration["Google:ClientId"] ?? "";
|
||||
private string ClientSecret => _configuration["Google:ClientSecret"] ?? "";
|
||||
|
||||
public string BuildAuthorizationUrl(ExternalAuthTransaction transaction)
|
||||
{
|
||||
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(transaction.CodeVerifier ?? "")));
|
||||
return AuthorizationEndpoint + "?" + Query(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = ClientId,
|
||||
["redirect_uri"] = CallbackUrl,
|
||||
["response_type"] = "code",
|
||||
["scope"] = "openid email profile",
|
||||
["state"] = transaction.Id,
|
||||
["nonce"] = transaction.Nonce ?? "",
|
||||
["code_challenge"] = challenge,
|
||||
["code_challenge_method"] = "S256",
|
||||
["prompt"] = "select_account"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
||||
IQueryCollection query,
|
||||
ExternalAuthTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured || !string.IsNullOrWhiteSpace(query["error"]) ||
|
||||
!CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(query["state"].ToString()),
|
||||
Encoding.UTF8.GetBytes(transaction.Id)))
|
||||
return null;
|
||||
var code = query["code"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(transaction.CodeVerifier)) return null;
|
||||
|
||||
var client = _httpClients.CreateClient(nameof(GoogleExternalAuthProvider));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
using var response = await client.PostAsync(TokenEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = ClientId,
|
||||
["client_secret"] = ClientSecret,
|
||||
["code"] = code,
|
||||
["code_verifier"] = transaction.CodeVerifier,
|
||||
["grant_type"] = "authorization_code",
|
||||
["redirect_uri"] = CallbackUrl
|
||||
}), cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
||||
if (!payload.RootElement.TryGetProperty("id_token", out var idTokenElement)) return null;
|
||||
var idToken = idTokenElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(idToken)) return null;
|
||||
|
||||
var oidc = await _discovery.GetConfigurationAsync(cancellationToken);
|
||||
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||
ClaimsPrincipal principal;
|
||||
try
|
||||
{
|
||||
principal = handler.ValidateToken(idToken, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = ["https://accounts.google.com", "accounts.google.com"],
|
||||
ValidateAudience = true,
|
||||
ValidAudience = ClientId,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = oidc.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
}, out _);
|
||||
}
|
||||
catch (SecurityTokenException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var subject = principal.FindFirstValue("sub");
|
||||
var nonce = principal.FindFirstValue("nonce");
|
||||
var email = principal.FindFirstValue("email");
|
||||
var verified = string.Equals(principal.FindFirstValue("email_verified"), "true",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(nonce) ||
|
||||
!CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(nonce), Encoding.UTF8.GetBytes(transaction.Nonce ?? "")))
|
||||
return null;
|
||||
return new ExternalProviderClaims(subject, email, verified);
|
||||
}
|
||||
|
||||
internal static string NewVerifier() => Base64Url(RandomNumberGenerator.GetBytes(48));
|
||||
internal static string NewNonce() => Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
private static string Query(Dictionary<string, string> values) => string.Join("&", values.Select(pair =>
|
||||
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
|
||||
|
||||
private static string Base64Url(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class ExternalIdentityStore(MongoDatabaseProvider mongo, AuthUserStore users)
|
||||
{
|
||||
private readonly IMongoCollection<ExternalIdentity> _identities =
|
||||
mongo.Database.GetCollection<ExternalIdentity>("ExternalIdentities");
|
||||
|
||||
public async Task<ExternalIdentity?> GetAsync(string provider, string subject) =>
|
||||
await _identities.Find(value => value.Provider == provider && value.Subject == subject)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task<ExternalIdentity?> GetForUserAsync(string userId, string provider) =>
|
||||
await _identities.Find(value => value.UserId == userId && value.Provider == provider)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task<List<string>> ProvidersForUserAsync(string userId) =>
|
||||
await _identities.Find(value => value.UserId == userId)
|
||||
.Project(value => value.Provider).ToListAsync();
|
||||
|
||||
public async Task TouchAsync(string id) =>
|
||||
await _identities.UpdateOneAsync(value => value.Id == id,
|
||||
Builders<ExternalIdentity>.Update.Set(value => value.LastUsedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task<bool> TryLinkAsync(
|
||||
string userId,
|
||||
string provider,
|
||||
ExternalProviderClaims claims)
|
||||
{
|
||||
var bySubject = await GetAsync(provider, claims.Subject);
|
||||
if (bySubject is not null) return bySubject.UserId == userId;
|
||||
var byUser = await GetForUserAsync(userId, provider);
|
||||
if (byUser is not null) return byUser.Subject == claims.Subject;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
await _identities.InsertOneAsync(new ExternalIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = provider,
|
||||
Subject = claims.Subject,
|
||||
Email = claims.Email,
|
||||
EmailVerified = claims.EmailVerified,
|
||||
CreatedUtc = now,
|
||||
LastUsedUtc = now
|
||||
});
|
||||
if (provider == ExternalAuthProviders.Steam &&
|
||||
!await users.TrySetSteamIdAsync(userId, claims.Subject))
|
||||
{
|
||||
await _identities.DeleteOneAsync(value =>
|
||||
value.UserId == userId && value.Provider == provider && value.Subject == claims.Subject);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (MongoWriteException exception) when (
|
||||
exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task BackfillSteamAsync(ILogger logger)
|
||||
{
|
||||
var candidates = await users.Collection.Find(user => user.SteamId != null && user.SteamId != "")
|
||||
.ToListAsync();
|
||||
var inserted = 0;
|
||||
foreach (var user in candidates)
|
||||
{
|
||||
var filter = Builders<ExternalIdentity>.Filter.Eq(value => value.Provider, ExternalAuthProviders.Steam) &
|
||||
Builders<ExternalIdentity>.Filter.Eq(value => value.Subject, user.SteamId!);
|
||||
var update = Builders<ExternalIdentity>.Update
|
||||
.SetOnInsert(value => value.UserId, user.Id)
|
||||
.SetOnInsert(value => value.Provider, ExternalAuthProviders.Steam)
|
||||
.SetOnInsert(value => value.Subject, user.SteamId!)
|
||||
.SetOnInsert(value => value.CreatedUtc, user.CreatedUtc ?? DateTime.UtcNow)
|
||||
.SetOnInsert(value => value.LastUsedUtc, DateTime.UtcNow);
|
||||
var result = await _identities.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true });
|
||||
if (result.UpsertedId is not null) inserted++;
|
||||
var identity = await _identities.Find(filter).FirstAsync();
|
||||
if (identity.UserId != user.Id)
|
||||
throw new InvalidOperationException(
|
||||
$"Steam identity {user.SteamId} is associated with conflicting users.");
|
||||
}
|
||||
logger.LogInformation("External identity migration found {Candidates} Steam users and inserted {Inserted}",
|
||||
candidates.Count, inserted);
|
||||
}
|
||||
|
||||
public async Task EnsureIndexesAsync()
|
||||
{
|
||||
await _identities.Indexes.CreateManyAsync([
|
||||
new CreateIndexModel<ExternalIdentity>(
|
||||
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.Provider)
|
||||
.Ascending(value => value.Subject),
|
||||
new CreateIndexOptions { Unique = true, Name = "external_identity_provider_subject" }),
|
||||
new CreateIndexModel<ExternalIdentity>(
|
||||
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.UserId)
|
||||
.Ascending(value => value.Provider),
|
||||
new CreateIndexOptions { Unique = true, Name = "external_identity_user_provider" }),
|
||||
new CreateIndexModel<ExternalIdentity>(
|
||||
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Name = "external_identity_user" })
|
||||
]);
|
||||
}
|
||||
|
||||
internal IMongoCollection<ExternalIdentity> Collection => _identities;
|
||||
}
|
||||
|
||||
public sealed class ExternalAuthTransactionStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
private readonly IMongoCollection<ExternalAuthTransaction> _transactions =
|
||||
mongo.Database.GetCollection<ExternalAuthTransaction>("ExternalAuthTransactions");
|
||||
|
||||
public Task CreateAsync(ExternalAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
|
||||
|
||||
public async Task<ExternalAuthTransaction?> GetAsync(string id) =>
|
||||
await _transactions.Find(value => value.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<bool> MarkVerifiedAsync(
|
||||
string id,
|
||||
ExternalProviderClaims claims,
|
||||
string status,
|
||||
string? userId,
|
||||
string? error = null)
|
||||
{
|
||||
var filter = Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Id, id) &
|
||||
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Status, "pending") &
|
||||
Builders<ExternalAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
|
||||
var update = Builders<ExternalAuthTransaction>.Update
|
||||
.Set(value => value.Subject, claims.Subject)
|
||||
.Set(value => value.ProviderEmail, claims.Email)
|
||||
.Set(value => value.EmailVerified, claims.EmailVerified)
|
||||
.Set(value => value.Status, status)
|
||||
.Set(value => value.UserId, userId)
|
||||
.Set(value => value.Error, error);
|
||||
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> MarkCompleteAsync(string id, string subject, string userId)
|
||||
{
|
||||
var filter = Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Id, id) &
|
||||
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Status, "requires_account") &
|
||||
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Subject, subject) &
|
||||
Builders<ExternalAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
|
||||
var update = Builders<ExternalAuthTransaction>.Update
|
||||
.Set(value => value.Status, "complete")
|
||||
.Set(value => value.UserId, userId);
|
||||
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public Task DeleteForUserAsync(string userId) =>
|
||||
_transactions.DeleteManyAsync(value => value.UserId == userId);
|
||||
|
||||
public Task EnsureIndexesAsync() => _transactions.Indexes.CreateOneAsync(
|
||||
new CreateIndexModel<ExternalAuthTransaction>(
|
||||
Builders<ExternalAuthTransaction>.IndexKeys.Ascending(value => value.ExpiresUtc),
|
||||
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "external_auth_expiry" }));
|
||||
|
||||
internal IMongoCollection<ExternalAuthTransaction> Collection => _transactions;
|
||||
}
|
||||
@@ -15,7 +15,8 @@ public sealed class UserProfileStore
|
||||
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
|
||||
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
|
||||
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles"
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# External Authentication
|
||||
|
||||
RanaZe supports provider-neutral external identities. Users can sign in with a configured provider or connect it from Profile. Every account remains password-backed, and an external identity is never linked automatically by matching email addresses.
|
||||
|
||||
## Google setup
|
||||
|
||||
Create separate Google OAuth 2.0 Web application clients for UAT and production. Configure these exact authorized redirect URIs:
|
||||
|
||||
- UAT: `https://us3q.ranaze.com/api/Auth/external/google/callback`
|
||||
- Production: `https://s3q.ranaze.com/api/Auth/external/google/callback`
|
||||
|
||||
Add the credentials as Gitea repository secrets:
|
||||
|
||||
- `UAT_GOOGLE_CLIENT_ID`
|
||||
- `UAT_GOOGLE_CLIENT_SECRET`
|
||||
- `PRD_GOOGLE_CLIENT_ID`
|
||||
- `PRD_GOOGLE_CLIENT_SECRET`
|
||||
|
||||
The deployment scripts treat Google as optional. If either credential is absent, Google is omitted from the API provider list and the client does not display its button.
|
||||
|
||||
## Account-linking rules
|
||||
|
||||
- A provider subject can belong to only one RanaZe user.
|
||||
- A RanaZe user can connect at most one identity per provider.
|
||||
- A verified Google email that already exists requires the user to choose **Link an existing RanaZe account** and enter that account's password.
|
||||
- Connected providers are intentionally not removable in the current UI to prevent accidental lockout and identity takeover edge cases.
|
||||
- Legacy `Users.SteamId` values are backfilled into `ExternalIdentities`; new Steam links are dual-written during the compatibility period.
|
||||
|
||||
## Collections
|
||||
|
||||
- `ExternalIdentities` stores durable provider-to-user mappings.
|
||||
- `ExternalAuthTransactions` stores short-lived login/link state and has a TTL index.
|
||||
|
||||
Deleting a user purges both collections for that user.
|
||||
@@ -56,6 +56,16 @@ spec:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: stripe-webhook-secret
|
||||
- name: Google__ClientId
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: google-client-id
|
||||
- name: Google__ClientSecret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: google-client-secret
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
@@ -79,4 +89,3 @@ spec:
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ configMapGenerator:
|
||||
- PasswordReset__Enabled=false
|
||||
- Steam__Realm=https://s3q.ranaze.com
|
||||
- Steam__ReturnUrl=https://s3q.ranaze.com/api/Auth/steam/callback
|
||||
- ExternalAuth__SteamReturnUrl=https://s3q.ranaze.com/api/Auth/external/steam/callback
|
||||
- Google__ReturnUrl=https://s3q.ranaze.com/api/Auth/external/google/callback
|
||||
- Legal__TermsVersion=2026-07-20
|
||||
- Legal__PrivacyVersion=2026-07-20
|
||||
- Legal__EffectiveUtc=2026-07-20T00:00:00Z
|
||||
|
||||
@@ -17,6 +17,8 @@ configMapGenerator:
|
||||
- PasswordReset__Enabled=false
|
||||
- Steam__Realm=https://us3q.ranaze.com
|
||||
- Steam__ReturnUrl=https://us3q.ranaze.com/api/Auth/steam/callback
|
||||
- ExternalAuth__SteamReturnUrl=https://us3q.ranaze.com/api/Auth/external/steam/callback
|
||||
- Google__ReturnUrl=https://us3q.ranaze.com/api/Auth/external/google/callback
|
||||
- Legal__TermsVersion=2026-07-20
|
||||
- Legal__PrivacyVersion=2026-07-20
|
||||
- Legal__EffectiveUtc=2026-07-20T00:00:00Z
|
||||
@@ -26,4 +28,3 @@ configMapGenerator:
|
||||
- Stripe__CancelUrl=https://ustep3.ranaze.com/donation-canceled.html
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ signal preferences_saved
|
||||
signal password_changed
|
||||
signal account_deleted
|
||||
signal archive_saved(path: String)
|
||||
signal external_link_started(provider: String)
|
||||
signal external_linked(provider: String)
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const AUTH_URL := "https://us3q.ranaze.com/api/Auth"
|
||||
@@ -15,11 +17,18 @@ var account: Dictionary = {}
|
||||
var preferences: Dictionary = _default_preferences()
|
||||
var auth_request: HTTPRequest
|
||||
var profile_request: HTTPRequest
|
||||
var external_request: HTTPRequest
|
||||
var external_poll_timer: Timer
|
||||
var auth_operation := ""
|
||||
var profile_operation := ""
|
||||
var auth_token := ""
|
||||
var profile_token := ""
|
||||
var preferences_pending := false
|
||||
var external_operation := ""
|
||||
var external_provider := ""
|
||||
var external_provider_name := ""
|
||||
var external_transaction_id := ""
|
||||
var external_poll_deadline_msec := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -31,6 +40,14 @@ func _ready() -> void:
|
||||
profile_request.timeout = 30.0
|
||||
profile_request.request_completed.connect(_on_profile_completed)
|
||||
add_child(profile_request)
|
||||
external_request = HTTPRequest.new()
|
||||
external_request.timeout = 25.0
|
||||
external_request.request_completed.connect(_on_external_completed)
|
||||
add_child(external_request)
|
||||
external_poll_timer = Timer.new()
|
||||
external_poll_timer.wait_time = 2.0
|
||||
external_poll_timer.timeout.connect(_poll_external_link)
|
||||
add_child(external_poll_timer)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
|
||||
|
||||
@@ -83,6 +100,32 @@ func delete_account(password: String, confirmation: String) -> void:
|
||||
})
|
||||
|
||||
|
||||
func connect_provider(provider: String, provider_name: String) -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
request_failed.emit("Sign in again before connecting an account.")
|
||||
return
|
||||
if external_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
request_failed.emit("Another connection request is still in progress.")
|
||||
return
|
||||
external_provider = provider
|
||||
external_provider_name = provider_name
|
||||
var storage_key := _external_storage_key()
|
||||
if OS.has_feature("web"):
|
||||
var popup_result: Variant = JavaScriptBridge.eval(
|
||||
"window.localStorage.removeItem(%s); var bridge = new URL('/external-redirect.html?key=' + encodeURIComponent(%s) + '&provider=' + encodeURIComponent(%s), window.location.origin).href; window.ranazeProfileExternalWindow = window.open(bridge, 'ranaze_profile_external', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); window.ranazeProfileExternalWindow !== null;" % [JSON.stringify(storage_key), JSON.stringify(storage_key), JSON.stringify(provider_name)], true)
|
||||
if not bool(popup_result):
|
||||
request_failed.emit("Allow popups for this site, then try again.")
|
||||
_reset_external_link()
|
||||
return
|
||||
external_operation = "start"
|
||||
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token, "Content-Type: application/json"])
|
||||
var error := external_request.request("%s/external/%s/start" % [_auth_base(), provider], headers,
|
||||
HTTPClient.METHOD_POST, JSON.stringify({"purpose": "link"}))
|
||||
if error != OK:
|
||||
request_failed.emit("The account provider could not be reached.")
|
||||
_reset_external_link()
|
||||
|
||||
|
||||
func _request_auth(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void:
|
||||
if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
request_failed.emit("Another account request is still in progress.")
|
||||
@@ -162,6 +205,82 @@ func _on_profile_completed(result: int, response_code: int, _headers: PackedStri
|
||||
_save_archive(JSON.stringify(archive, " "))
|
||||
|
||||
|
||||
func _on_external_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var operation := external_operation
|
||||
external_operation = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(AuthState.access_token)
|
||||
_reset_external_link()
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
request_failed.emit(_error_message(body, "The external account request failed."))
|
||||
_reset_external_link()
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
request_failed.emit("The external account service returned invalid data.")
|
||||
_reset_external_link()
|
||||
return
|
||||
var data: Dictionary = parsed
|
||||
if operation == "start":
|
||||
external_transaction_id = str(data.get("transactionId", ""))
|
||||
var auth_url := str(data.get("authUrl", ""))
|
||||
if external_transaction_id.is_empty() or auth_url.is_empty():
|
||||
request_failed.emit("The provider returned an invalid response.")
|
||||
_reset_external_link()
|
||||
return
|
||||
if OS.has_feature("web"):
|
||||
JavaScriptBridge.eval("var url=%s; window.localStorage.setItem(%s,url); if(window.ranazeProfileExternalWindow&&!window.ranazeProfileExternalWindow.closed){window.ranazeProfileExternalWindow.location.replace(url);window.ranazeProfileExternalWindow.focus();}" % [JSON.stringify(auth_url), JSON.stringify(_external_storage_key())], true)
|
||||
else:
|
||||
OS.shell_open(auth_url)
|
||||
external_poll_deadline_msec = Time.get_ticks_msec() + 300000
|
||||
external_poll_timer.start()
|
||||
external_link_started.emit(external_provider)
|
||||
elif operation == "status":
|
||||
match str(data.get("status", "pending")):
|
||||
"linked":
|
||||
var linked_provider := external_provider
|
||||
_reset_external_link()
|
||||
external_linked.emit(linked_provider)
|
||||
refresh()
|
||||
"failed":
|
||||
request_failed.emit(str(data.get("message", "The provider could not connect this account.")))
|
||||
_reset_external_link()
|
||||
|
||||
|
||||
func _poll_external_link() -> void:
|
||||
if external_transaction_id.is_empty() or external_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
if Time.get_ticks_msec() >= external_poll_deadline_msec:
|
||||
request_failed.emit("The account connection timed out.")
|
||||
_reset_external_link()
|
||||
return
|
||||
external_operation = "status"
|
||||
var error := external_request.request("%s/external/%s/status/%s" % [_auth_base(), external_provider, external_transaction_id])
|
||||
if error != OK:
|
||||
external_operation = ""
|
||||
|
||||
|
||||
func _reset_external_link() -> void:
|
||||
if external_poll_timer != null:
|
||||
external_poll_timer.stop()
|
||||
if OS.has_feature("web") and not external_provider.is_empty():
|
||||
JavaScriptBridge.eval("window.localStorage.removeItem(%s);if(window.ranazeProfileExternalWindow&&!window.ranazeProfileExternalWindow.closed){window.ranazeProfileExternalWindow.close();}window.ranazeProfileExternalWindow=null;" % JSON.stringify(_external_storage_key()), true)
|
||||
external_operation = ""
|
||||
external_provider = ""
|
||||
external_provider_name = ""
|
||||
external_transaction_id = ""
|
||||
external_poll_deadline_msec = 0
|
||||
|
||||
|
||||
func _external_storage_key() -> String:
|
||||
return "ranaze_profile_external_auth_url_%s" % external_provider
|
||||
|
||||
|
||||
func _auth_base() -> String:
|
||||
return EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/")
|
||||
|
||||
|
||||
func _save_archive(content: String) -> void:
|
||||
var filename := "ranaze-account-export-%s.json" % Time.get_date_string_from_system()
|
||||
if OS.has_feature("web"):
|
||||
|
||||
+78
-29
@@ -3,7 +3,7 @@ extends Control
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const AUTH_BASE := "https://us3q.ranaze.com/api/Auth"
|
||||
const STEAM_AUTH_STORAGE_KEY := "ranaze_steam_auth_url"
|
||||
const EXTERNAL_AUTH_STORAGE_PREFIX := "ranaze_external_auth_url_"
|
||||
|
||||
var main: Control
|
||||
var auth_base_url := AUTH_BASE
|
||||
@@ -38,6 +38,7 @@ var steam_username_input: LineEdit
|
||||
var steam_email_input: LineEdit
|
||||
var steam_password_input: LineEdit
|
||||
var steam_legal_check: CheckBox
|
||||
var external_buttons: Dictionary = {}
|
||||
var panel_gutter: MarginContainer
|
||||
var submit_buttons: Array[Button] = []
|
||||
var fields: Array[LineEdit] = []
|
||||
@@ -49,6 +50,9 @@ var steam_transaction_id := ""
|
||||
var steam_poll_deadline_msec := 0
|
||||
var steam_poll_failures := 0
|
||||
var steam_popup_opened := false
|
||||
var external_provider_id := ""
|
||||
var external_provider_name := ""
|
||||
var external_verified_email := ""
|
||||
var automatic_login_pending := false
|
||||
var login_attempt_username := ""
|
||||
var compact_layout := false
|
||||
@@ -63,6 +67,7 @@ func _ready() -> void:
|
||||
_create_request("register", _on_register_completed)
|
||||
_create_request("reset_request", _on_reset_requested)
|
||||
_create_request("reset_confirm", _on_reset_confirmed)
|
||||
_create_request("providers", _on_providers_loaded)
|
||||
_create_request("steam_start", _on_steam_started)
|
||||
_create_request("steam_status", _on_steam_status)
|
||||
_create_request("steam_complete", _on_steam_completed)
|
||||
@@ -76,6 +81,7 @@ func _ready() -> void:
|
||||
_update_responsive_layout.call_deferred()
|
||||
_set_mode("login")
|
||||
_apply_theme()
|
||||
_load_external_providers()
|
||||
|
||||
|
||||
func on_show(payload: Variant = null) -> void:
|
||||
@@ -249,12 +255,15 @@ func _build_login_view() -> VBoxContainer:
|
||||
var sign_in := _add_primary_button(view, "Sign In", _on_login_pressed)
|
||||
password_input.text_submitted.connect(func(_text: String): _on_login_pressed())
|
||||
view.add_child(_build_divider())
|
||||
var steam := Button.new()
|
||||
steam.text = "Sign in with Steam"
|
||||
steam.custom_minimum_size = Vector2(0, 68)
|
||||
steam.pressed.connect(_start_steam)
|
||||
view.add_child(steam)
|
||||
submit_buttons.append(steam)
|
||||
for provider in [["steam", "Steam"], ["google", "Google"]]:
|
||||
var external := Button.new()
|
||||
external.text = "Sign in with %s" % provider[1]
|
||||
external.custom_minimum_size = Vector2(0, 68)
|
||||
external.visible = false
|
||||
external.pressed.connect(_start_external.bind(str(provider[0]), str(provider[1])))
|
||||
view.add_child(external)
|
||||
external_buttons[provider[0]] = external
|
||||
submit_buttons.append(external)
|
||||
var create := Button.new()
|
||||
create.text = "New to RanaZe? Create an account"
|
||||
create.flat = true
|
||||
@@ -298,7 +307,7 @@ func _build_reset_view() -> VBoxContainer:
|
||||
func _build_steam_view() -> VBoxContainer:
|
||||
var view := _new_form()
|
||||
var help := Label.new()
|
||||
help.text = "Steam was verified. Create a RanaZe account or link an existing one."
|
||||
help.text = "Your identity was verified. Create a RanaZe account or link an existing one."
|
||||
help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
view.add_child(help)
|
||||
steam_action = OptionButton.new()
|
||||
@@ -312,7 +321,7 @@ func _build_steam_view() -> VBoxContainer:
|
||||
_add_show_password(view, [steam_password_input])
|
||||
steam_legal_check = _add_legal_consent(view)
|
||||
_add_primary_button(view, "Continue", _complete_steam)
|
||||
_add_back_button(view, "Cancel Steam sign in", "login")
|
||||
_add_back_button(view, "Cancel external sign in", "login")
|
||||
return view
|
||||
|
||||
|
||||
@@ -461,7 +470,7 @@ func _set_mode(next_mode: String) -> void:
|
||||
match mode:
|
||||
"register": heading.text = "Create your account"; subheading.text = "Start studying with RanaZe"
|
||||
"reset": heading.text = "Reset your password"; subheading.text = "We will email you a verification code"
|
||||
"steam": heading.text = "Finish Steam sign in"; subheading.text = "Connect Steam to RanaZe"
|
||||
"steam": heading.text = "Finish %s sign in" % external_provider_name; subheading.text = "Connect %s to RanaZe" % external_provider_name
|
||||
_: heading.text = "Welcome back"; subheading.text = "Sign in to continue"
|
||||
_clear_message()
|
||||
_update_responsive_layout()
|
||||
@@ -538,7 +547,7 @@ func _apply_theme() -> void:
|
||||
typed.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
||||
typed.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
||||
for button in submit_buttons:
|
||||
if button.text not in ["Sign in with Steam"]:
|
||||
if not button.text.begins_with("Sign in with "):
|
||||
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
var theme_normal := StyleBoxFlat.new()
|
||||
@@ -648,39 +657,66 @@ func _on_reset_confirmed(result: int, code: int, _headers: PackedStringArray, bo
|
||||
_show_success("Password updated. You can now sign in.")
|
||||
|
||||
|
||||
func _load_external_providers() -> void:
|
||||
var request: HTTPRequest = requests["providers"]
|
||||
if request.get_http_client_status() == HTTPClient.STATUS_DISCONNECTED:
|
||||
request.request("%s/external/providers" % auth_base_url)
|
||||
|
||||
|
||||
func _on_providers_loaded(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
||||
return
|
||||
var data := _parse_dictionary(body)
|
||||
var enabled: Dictionary = {}
|
||||
for provider_value in data.get("providers", []):
|
||||
if typeof(provider_value) == TYPE_DICTIONARY:
|
||||
var provider: Dictionary = provider_value
|
||||
enabled[str(provider.get("id", ""))] = true
|
||||
for provider_id in external_buttons:
|
||||
(external_buttons[provider_id] as Button).visible = enabled.has(provider_id)
|
||||
|
||||
|
||||
func _start_steam() -> void:
|
||||
_start_external("steam", "Steam")
|
||||
|
||||
|
||||
func _start_external(provider_id: String, provider_name: String) -> void:
|
||||
external_provider_id = provider_id
|
||||
external_provider_name = provider_name
|
||||
external_verified_email = ""
|
||||
var storage_key := _external_storage_key()
|
||||
if OS.has_feature("web"):
|
||||
var popup_result: Variant = JavaScriptBridge.eval(
|
||||
"window.localStorage.removeItem('%s'); var ranazeSteamBridge = new URL('/external-redirect.html?key=%s&provider=Steam', window.location.origin).href; window.ranazeSteamWindow = window.open(ranazeSteamBridge, 'ranaze_steam', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); if (window.ranazeSteamWindow) { window.ranazeSteamWindow.blur(); window.focus(); } window.ranazeSteamWindow !== null;" % [STEAM_AUTH_STORAGE_KEY, STEAM_AUTH_STORAGE_KEY],
|
||||
"window.localStorage.removeItem(%s); var ranazeExternalBridge = new URL('/external-redirect.html?key=' + encodeURIComponent(%s) + '&provider=' + encodeURIComponent(%s), window.location.origin).href; window.ranazeExternalWindow = window.open(ranazeExternalBridge, 'ranaze_external', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); if (window.ranazeExternalWindow) { window.ranazeExternalWindow.blur(); window.focus(); } window.ranazeExternalWindow !== null;" % [JSON.stringify(storage_key), JSON.stringify(storage_key), JSON.stringify(provider_name)],
|
||||
true
|
||||
)
|
||||
steam_popup_opened = bool(popup_result)
|
||||
if not steam_popup_opened:
|
||||
return _show_error("Steam sign in needs a popup. Allow popups for this site, then try again.")
|
||||
_send_json("steam_start", "/steam/start", {}, "Opening Steam...")
|
||||
return _show_error("%s sign in needs a popup. Allow popups for this site, then try again." % provider_name)
|
||||
_send_json("steam_start", "/external/%s/start" % provider_id, {"purpose": "login"}, "Opening %s..." % provider_name)
|
||||
|
||||
|
||||
func _on_steam_started(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
_set_busy(false)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
||||
_close_steam_popup()
|
||||
return _show_error("Unable to start Steam sign in.")
|
||||
return _show_error("Unable to start %s sign in." % external_provider_name)
|
||||
var data := _parse_dictionary(body)
|
||||
steam_transaction_id = str(data.get("transactionId", ""))
|
||||
var auth_url := str(data.get("authUrl", ""))
|
||||
if steam_transaction_id.is_empty() or auth_url.is_empty():
|
||||
_close_steam_popup()
|
||||
return _show_error("Steam returned an invalid sign-in response.")
|
||||
return _show_error("%s returned an invalid sign-in response." % external_provider_name)
|
||||
if OS.has_feature("web"):
|
||||
JavaScriptBridge.eval(
|
||||
"var ranazeSteamAuthUrl = %s; window.localStorage.setItem('%s', ranazeSteamAuthUrl); if (window.ranazeSteamWindow && !window.ranazeSteamWindow.closed) { try { window.ranazeSteamWindow.location.replace(ranazeSteamAuthUrl); } catch (_) {} window.ranazeSteamWindow.focus(); } else { var ranazeSteamPopup = window.open(ranazeSteamAuthUrl, 'ranaze_steam'); if (ranazeSteamPopup) { window.ranazeSteamWindow = ranazeSteamPopup; ranazeSteamPopup.focus(); } else { window.location.assign(ranazeSteamAuthUrl); } }" % [JSON.stringify(auth_url), STEAM_AUTH_STORAGE_KEY],
|
||||
"var ranazeExternalAuthUrl = %s; window.localStorage.setItem(%s, ranazeExternalAuthUrl); if (window.ranazeExternalWindow && !window.ranazeExternalWindow.closed) { try { window.ranazeExternalWindow.location.replace(ranazeExternalAuthUrl); } catch (_) {} window.ranazeExternalWindow.focus(); } else { var ranazeExternalPopup = window.open(ranazeExternalAuthUrl, 'ranaze_external'); if (ranazeExternalPopup) { window.ranazeExternalWindow = ranazeExternalPopup; ranazeExternalPopup.focus(); } else { window.location.assign(ranazeExternalAuthUrl); } }" % [JSON.stringify(auth_url), JSON.stringify(_external_storage_key())],
|
||||
true
|
||||
)
|
||||
else:
|
||||
OS.shell_open(auth_url)
|
||||
steam_poll_deadline_msec = Time.get_ticks_msec() + 300000
|
||||
steam_poll_failures = 0
|
||||
_show_success("Complete sign in in the Steam window. Waiting for verification...")
|
||||
_show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name)
|
||||
poll_timer.start()
|
||||
|
||||
|
||||
@@ -690,28 +726,34 @@ func _poll_steam() -> void:
|
||||
if steam_poll_deadline_msec > 0 and Time.get_ticks_msec() >= steam_poll_deadline_msec:
|
||||
_reset_steam_flow()
|
||||
return _show_error("Steam sign in timed out. Try again when ready.")
|
||||
var error: Error = status_request.request("%s/steam/status/%s" % [auth_base_url, steam_transaction_id])
|
||||
var error: Error = status_request.request("%s/external/%s/status/%s" % [auth_base_url, external_provider_id, steam_transaction_id])
|
||||
if error != OK:
|
||||
steam_poll_failures += 1
|
||||
if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for Steam...")
|
||||
if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for %s..." % external_provider_name)
|
||||
|
||||
|
||||
func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code >= 500 or code == 429:
|
||||
steam_poll_failures += 1
|
||||
if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for Steam...")
|
||||
if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for %s..." % external_provider_name)
|
||||
return
|
||||
if code == 404:
|
||||
_reset_steam_flow()
|
||||
return _show_error("Steam sign in expired. Please try again.")
|
||||
return _show_error("%s sign in expired. Please try again." % external_provider_name)
|
||||
if code < 200 or code >= 300:
|
||||
_reset_steam_flow()
|
||||
return _show_error(_response_message(body, "Steam sign in could not be completed."))
|
||||
return _show_error(_response_message(body, "%s sign in could not be completed." % external_provider_name))
|
||||
steam_poll_failures = 0
|
||||
var data := _parse_dictionary(body)
|
||||
match str(data.get("status", "pending")):
|
||||
"pending": _show_success("Complete sign in in the Steam window. Waiting for verification...")
|
||||
"requires_account": poll_timer.stop(); _close_steam_popup(); _set_mode("steam")
|
||||
"pending": _show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name)
|
||||
"requires_account":
|
||||
poll_timer.stop()
|
||||
_close_steam_popup()
|
||||
external_verified_email = str(data.get("email", ""))
|
||||
steam_email_input.text = external_verified_email
|
||||
steam_email_input.editable = external_verified_email.is_empty()
|
||||
_set_mode("steam")
|
||||
"complete": poll_timer.stop(); _close_steam_popup(); _accept_session(body, str(data.get("username", "")))
|
||||
|
||||
|
||||
@@ -730,7 +772,7 @@ func _complete_steam() -> void:
|
||||
return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.")
|
||||
elif steam_username_input.text.strip_edges().is_empty() or steam_password_input.text.is_empty():
|
||||
return _show_error("Enter your existing account credentials.")
|
||||
_send_json("steam_complete", "/steam/complete", {
|
||||
_send_json("steam_complete", "/external/%s/complete" % external_provider_id, {
|
||||
"transactionId": steam_transaction_id, "action": action,
|
||||
"username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(),
|
||||
"password": steam_password_input.text, "acceptLegal": action == "create"
|
||||
@@ -740,7 +782,7 @@ func _complete_steam() -> void:
|
||||
func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
_set_busy(false)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
||||
return _show_error(_response_message(body, "Unable to connect this Steam account."))
|
||||
return _show_error(_response_message(body, "Unable to connect this %s account." % external_provider_name))
|
||||
steam_poll_failures = 0
|
||||
poll_timer.start()
|
||||
_poll_steam()
|
||||
@@ -748,18 +790,25 @@ func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, bo
|
||||
|
||||
func _reset_steam_flow() -> void:
|
||||
if poll_timer != null: poll_timer.stop()
|
||||
_close_steam_popup()
|
||||
steam_transaction_id = ""
|
||||
steam_poll_deadline_msec = 0
|
||||
steam_poll_failures = 0
|
||||
_close_steam_popup()
|
||||
external_provider_id = ""
|
||||
external_provider_name = ""
|
||||
external_verified_email = ""
|
||||
|
||||
|
||||
func _close_steam_popup() -> void:
|
||||
if OS.has_feature("web") and steam_popup_opened:
|
||||
JavaScriptBridge.eval("window.localStorage.removeItem('%s'); if (window.ranazeSteamWindow && !window.ranazeSteamWindow.closed) { window.ranazeSteamWindow.close(); } window.ranazeSteamWindow = null;" % STEAM_AUTH_STORAGE_KEY, true)
|
||||
JavaScriptBridge.eval("window.localStorage.removeItem(%s); if (window.ranazeExternalWindow && !window.ranazeExternalWindow.closed) { window.ranazeExternalWindow.close(); } window.ranazeExternalWindow = null;" % JSON.stringify(_external_storage_key()), true)
|
||||
steam_popup_opened = false
|
||||
|
||||
|
||||
func _external_storage_key() -> String:
|
||||
return EXTERNAL_AUTH_STORAGE_PREFIX + external_provider_id
|
||||
|
||||
|
||||
func _accept_session(body: PackedByteArray, fallback_username: String) -> void:
|
||||
var response := _parse_dictionary(body)
|
||||
var token := str(response.get("accessToken", ""))
|
||||
|
||||
@@ -18,6 +18,7 @@ var outcome_date: LineEdit
|
||||
var outcome_consent: CheckBox
|
||||
var outcome_status: Label
|
||||
var outcome_delete: Button
|
||||
var connected_accounts_list: VBoxContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -26,6 +27,8 @@ func _ready() -> void:
|
||||
AccountProfileService.request_failed.connect(_show_error)
|
||||
AccountProfileService.preferences_saved.connect(func(): _show_saved("Study identity saved."))
|
||||
AccountProfileService.archive_saved.connect(func(path: String): _show_saved("Archive saved: %s" % path))
|
||||
AccountProfileService.external_link_started.connect(func(provider: String): _show_saved("Complete %s connection in the opened window." % provider.capitalize()))
|
||||
AccountProfileService.external_linked.connect(func(provider: String): _show_saved("%s account connected." % provider.capitalize()))
|
||||
DashboardService.dashboard_changed.connect(_render)
|
||||
DashboardService.dashboard_failed.connect(_show_error)
|
||||
ExamSimulationService.outcome_changed.connect(_render_outcome)
|
||||
@@ -82,6 +85,7 @@ func _build() -> void:
|
||||
right.add_theme_constant_override("separation", 14)
|
||||
columns.add_child(right)
|
||||
right.add_child(_outcome_card())
|
||||
right.add_child(_connected_accounts_card())
|
||||
right.add_child(_account_actions_card())
|
||||
_update_responsive()
|
||||
_render()
|
||||
@@ -228,6 +232,16 @@ func _account_actions_card() -> PanelContainer:
|
||||
return _card(body)
|
||||
|
||||
|
||||
func _connected_accounts_card() -> PanelContainer:
|
||||
var body := VBoxContainer.new()
|
||||
body.add_theme_constant_override("separation", 9)
|
||||
body.add_child(_section_header("Connected Accounts", "Use another trusted identity to sign in"))
|
||||
connected_accounts_list = VBoxContainer.new()
|
||||
connected_accounts_list.add_theme_constant_override("separation", 8)
|
||||
body.add_child(connected_accounts_list)
|
||||
return _card(body)
|
||||
|
||||
|
||||
func _render() -> void:
|
||||
if profile_name == null:
|
||||
return
|
||||
@@ -241,6 +255,7 @@ func _render() -> void:
|
||||
var role := str(account.get("role", AuthState.role)).capitalize()
|
||||
var created := _date_only(str(account.get("createdUtc", "")))
|
||||
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
|
||||
_render_connected_accounts(account.get("connectedProviders", []), account.get("availableProviders", []))
|
||||
(identity_values["exam"] as Label).text = _date_only(str(prefs.get("targetExamDate", "")), "Not scheduled")
|
||||
(identity_values["stage"] as Label).text = _friendly(str(prefs.get("preparationStage", "pre_dedicated")))
|
||||
(identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10))
|
||||
@@ -256,6 +271,38 @@ func _render() -> void:
|
||||
_render_outcome()
|
||||
|
||||
|
||||
func _render_connected_accounts(provider_values: Variant, available_values: Variant) -> void:
|
||||
if connected_accounts_list == null:
|
||||
return
|
||||
_clear(connected_accounts_list)
|
||||
var connected: Dictionary = {}
|
||||
var available: Dictionary = {}
|
||||
if typeof(provider_values) == TYPE_ARRAY:
|
||||
for value in provider_values:
|
||||
connected[str(value).to_lower()] = true
|
||||
if typeof(available_values) == TYPE_ARRAY:
|
||||
for value in available_values:
|
||||
available[str(value).to_lower()] = true
|
||||
for provider in [["steam", "Steam"], ["google", "Google"]]:
|
||||
var provider_id := str(provider[0])
|
||||
var provider_name := str(provider[1])
|
||||
if not connected.has(provider_id) and not available.has(provider_id):
|
||||
continue
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.add_child(_label(provider_name, 14, Brand.text_color(_dark()), true))
|
||||
copy.add_child(_label("Connected" if connected.has(provider_id) else "Not connected", 12,
|
||||
Brand.SUCCESS if connected.has(provider_id) else Brand.muted_color(_dark())))
|
||||
row.add_child(copy)
|
||||
if not connected.has(provider_id):
|
||||
var connect := _button("Connect", "link", false)
|
||||
connect.pressed.connect(AccountProfileService.connect_provider.bind(provider_id, provider_name))
|
||||
row.add_child(connect)
|
||||
connected_accounts_list.add_child(row)
|
||||
|
||||
|
||||
func _render_systems(systems: Array = DashboardService.data.get("systems", [])) -> void:
|
||||
if systems_list == null:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user