Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api/Accounts/AccountService.cs
diff options
context:
space:
mode:
authorLeitwolf112026-10-03 18:46:59 +0300
committerLeitwolf112026-10-03 18:46:59 +0300
commitb84a2216c26cc5392d0dc300b7a605ccb288d8c8 (patch)
tree048ae623f49cc5f2f94234d747351f9e659f5267 /apps/api/Accounts/AccountService.cs
parentec774db15976385a6d5ea6f48d3622fe507d3843 (diff)
parentcf444eb3bccf99c10f59ed5330b61c0397e0aeaa (diff)
downloadprogmaweb-fix/api-without-google.tar.gz
Merge remote-tracking branch 'origin/main' into fix/api-without-googlefix/api-without-google
Diffstat (limited to 'apps/api/Accounts/AccountService.cs')
-rw-r--r--apps/api/Accounts/AccountService.cs53
1 files changed, 53 insertions, 0 deletions
diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs
index 7168eb6..cd3e711 100644
--- a/apps/api/Accounts/AccountService.cs
+++ b/apps/api/Accounts/AccountService.cs
@@ -5,10 +5,14 @@ using Microsoft.AspNetCore.Identity;
namespace Progmasoft.Progmaweb.Api.Accounts;
+/// <summary>Registration and sign-in rules of the account service.</summary>
+/// <param name="store">Store that keeps accounts.</param>
+/// <param name="timeProvider">Clock used for creation times.</param>
internal sealed class AccountService(
IAccountStore store,
TimeProvider timeProvider)
{
+ /// <summary>An account that never exists; the subject of the dummy verification.</summary>
private static readonly AccountRecord DummyAccount = new(
Guid.Empty,
"Missing00",
@@ -18,11 +22,24 @@ internal sealed class AccountService(
null,
string.Empty,
DateTimeOffset.UnixEpoch);
+ /// <summary>
+ /// A hash that no submitted password matches. Verifying against it makes a sign-in with an unknown address cost
+ /// as much as one with a wrong password.
+ /// </summary>
private static readonly string DummyPasswordHash =
new PasswordHasher<AccountRecord>().HashPassword(DummyAccount, "not-the-provided-password");
+ /// <summary>Hashes and verifies passwords with the ASP.NET Core Identity algorithm.</summary>
private readonly PasswordHasher<AccountRecord> passwordHasher = new();
+ /// <summary>Creates a password account and opens a session for it.</summary>
+ /// <param name="request">The registration body.</param>
+ /// <param name="sessions">Session service.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>
+ /// The signed-in account and no errors, or no account and the messages of each refused field, keyed by
+ /// <c>accountName</c>, <c>email</c> and <c>password</c>.
+ /// </returns>
public async ValueTask<(AuthenticatedAccount? Authentication, Dictionary<string, string[]> Errors)> RegisterAsync(
RegisterAccountRequest request,
SessionService sessions,
@@ -63,6 +80,15 @@ internal sealed class AccountService(
return (authentication, errors);
}
+ /// <summary>Signs in with email address and password.</summary>
+ /// <remarks>
+ /// An unknown address costs one password verification against a private dummy hash, so the response time does not
+ /// reveal whether an address is registered.
+ /// </remarks>
+ /// <param name="request">The login body.</param>
+ /// <param name="sessions">Session service.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The signed-in account, or <see langword="null"/> when the credentials are refused.</returns>
public async ValueTask<AuthenticatedAccount?> LoginAsync(
LoginAccountRequest request,
SessionService sessions,
@@ -92,6 +118,18 @@ internal sealed class AccountService(
return await sessions.CreateAsync(account, cancellationToken);
}
+ /// <summary>Signs in with a Google identity, creating the account on first use.</summary>
+ /// <remarks>
+ /// An identity that is already linked signs in to its account. A new identity creates an account under the
+ /// requested name. A new identity whose email address belongs to an existing account is refused: password
+ /// registrations do not verify the address, so linking by address could hand an account to someone else.
+ /// </remarks>
+ /// <param name="providerSubject">Subject identifier Google issued for the identity.</param>
+ /// <param name="providerEmail">Email address Google reported for the identity.</param>
+ /// <param name="requestedAccountName">Account name to create for a new identity; may be <see langword="null"/>.</param>
+ /// <param name="sessions">Session service.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The signed-in account, or no account and the reason.</returns>
public async ValueTask<(AuthenticatedAccount? Authentication, GoogleAccountFailure Failure)>
AuthenticateGoogleAsync(
string? providerSubject,
@@ -165,6 +203,12 @@ internal sealed class AccountService(
return (await sessions.CreateAsync(account, cancellationToken), GoogleAccountFailure.None);
}
+ /// <summary>Validates every field of a registration and collects the messages of the refused ones.</summary>
+ /// <param name="request">The registration body.</param>
+ /// <param name="accountName">The canonical account name.</param>
+ /// <param name="email">The email address as entered, trimmed.</param>
+ /// <param name="normalizedEmail">The email address in its lookup form.</param>
+ /// <returns>Messages keyed by field name; empty when the registration is valid.</returns>
private static Dictionary<string, string[]> ValidateRegistration(
RegisterAccountRequest request,
out string accountName,
@@ -192,11 +236,20 @@ internal sealed class AccountService(
}
}
+/// <summary>Reason a sign-in with Google did not produce a session.</summary>
internal enum GoogleAccountFailure
{
+ /// <summary>The sign-in succeeded.</summary>
None,
+ /// <summary>
+ /// The identity cannot be used: Google reported no usable subject or address, or the address belongs to another
+ /// account.
+ /// </summary>
ProviderRejected,
+ /// <summary>The identity is new and no account name was supplied.</summary>
AccountNameRequired,
+ /// <summary>The supplied account name breaks <see cref="AccountNamePolicy"/>.</summary>
InvalidAccountName,
+ /// <summary>The supplied account name is taken.</summary>
AccountNameUnavailable
}