R3 two-factor in decePub: an authenticator app set up in Settings, its code asked for at sign-in
Build / Build (push) Successful in 1m3s

Completion program R3, with PrivaPub's TOTP routes of the same release.

- Settings, the login: TotpEditor turns two-factor sign-in on (the password, then the QR code, the secret and the
  otpauth:// link, then the app's first code; the ten recovery codes shown once with Copy), shows how many recovery
  codes are left (warned at three), and turns it off with the password and a code, asked first.
- Turning it on, like a password change, ends every other session: AuthService.ContinueSession takes the new JWT and
  exchanges the persona's token again.
- Sign-in: PrivaPub's 428 keeps the name and password and asks for the six digits (DOtp), or a recovery code instead;
  a wrong code says so; Back returns to the password.
- window.copyText (main.js) through AppStatusService.CopyText.
- 29 keys in both resx files; CLAUDE.md follows.

Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 03:33:46 +02:00
1 parent 7d4b35f0d7
commit b731d8c8c0
11 files changed
+678 -11

No files matched your search

+16 -4
View File
@@ -261,9 +261,15 @@ Never redeclare the base's members in a component.
forgets the session. forgets the session.
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every - **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
its own and signs out. A password change ends every other session and revokes every persona token, this device's its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
too: `AuthService.ChangePassword` keeps the new `JwtUser` PrivaPub answers with, drops the stored persona tokens and persona token, this device's too: `AuthService.ChangePassword` and `TotpEnable` keep the new `JwtUser` PrivaPub
exchanges one for the current persona at once (the others when next used), so the device stays signed in. answers with (`ContinueSession`), drop the stored persona tokens and exchange one for the current persona at once
(the others when next used), so the device stays signed in.
- **Two-factor sign-in** (TOTP, `/clientapi/user/totp/*`): with it on, a right password is answered with a 428
(`AuthService.NeedsOneTimeCode`). Login keeps the name and the password in the form, out of sight, and sends it again
with `LoginForm.OneTimeCode`: the authenticator app's six digits (`DOtp`, `data-login-code`) or, after "Use a recovery
code instead", a recovery code like `abcde-fghjk` (a text field, `data-login-recovery`). A wrong code is a 400, like a
wrong password, with PrivaPub's message. It is turned on and off in Settings (`TotpEditor`, see **Settings**).
- **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16): - **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16):
- **Setup** (`Pages/Setup.razor` at `/setup`): while PrivaPub has no administrator (`/clientapi/user/setup`), the first - **Setup** (`Pages/Setup.razor` at `/setup`): while PrivaPub has no administrator (`/clientapi/user/setup`), the first
login is made there, first come, first served, with its first persona. It becomes the administrator, and setup login is made there, first come, first served, with its first persona. It becomes the administrator, and setup
@@ -362,7 +368,13 @@ Never redeclare the base's members in a component.
(`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`, (`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as
such; its password changed; every session of it ended ("Sign out everywhere", see **One sign-in**); or the whole of it such; its password changed; its two-factor sign-in (`TotpEditor`, a block of AccountEditor that reads the login itself:
turned on with the password (`/totp/setup`), which shows the secret as a QR image, as selectable text and as an
`otpauth://` link an authenticator app opens, then the app's first code (`/totp/enable`, a 409 once the hour-long
setup expired), after which the ten recovery codes are shown once, with Copy (`AppStatusService.CopyText`) and "I saved
them"; on, it shows how many recovery codes are left, warns from three down, and is turned off, asked first, with the
password and a code from the app or a recovery code (`/totp/disable`)); every session of it ended ("Sign out
everywhere", see **One sign-in**); or the whole of it
deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then
signs out), the persona's profile (`ProfileEditor`: signs out), the persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file, `update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
+4
View File
@@ -41,6 +41,10 @@ namespace decePubClient;
[JsonSerializable(typeof(EmailChangeForm))] [JsonSerializable(typeof(EmailChangeForm))]
[JsonSerializable(typeof(EmailCodeForm))] [JsonSerializable(typeof(EmailCodeForm))]
[JsonSerializable(typeof(PasswordForm))] [JsonSerializable(typeof(PasswordForm))]
[JsonSerializable(typeof(TotpCodeForm))]
[JsonSerializable(typeof(TotpDisableForm))]
[JsonSerializable(typeof(ViewTotpSetup))]
[JsonSerializable(typeof(ViewTotpEnabled))]
[JsonSerializable(typeof(ViewUserSettings))] [JsonSerializable(typeof(ViewUserSettings))]
// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool // recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool
[JsonSerializable(typeof(string))] [JsonSerializable(typeof(string))]
+4 -1
View File
@@ -2,7 +2,8 @@
@* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the @* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the
new one, since a forgotten password is recovered through it; its password changed (every other session ends, this new one, since a forgotten password is recovered through it; its password changed (every other session ends, this
device's goes on with a new JWT); every session of it ended at once; or the whole of it deleted, every persona with it. device's goes on with a new JWT); its two-factor sign-in (TotpEditor); every session of it ended at once; or the whole
of it deleted, every persona with it.
Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@ Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
<div class="flex flex-col gap-4 w-full" data-account> <div class="flex flex-col gap-4 w-full" data-account>
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-email> <div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-email>
@@ -120,6 +121,8 @@
</DButton> </DButton>
</EditForm> </EditForm>
<TotpEditor />
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-everywhere> <div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-everywhere>
<b class="text-sm">@Localizer["Sign out everywhere"]</b> <b class="text-sm">@Localizer["Sign out everywhere"]</b>
<p class="text-sm">@Localizer["Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours."]</p> <p class="text-sm">@Localizer["Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours."]</p>
+306
View File
@@ -0,0 +1,306 @@
@inherits LocalizableComponentBase
@* Two-factor sign-in (TOTP, /clientapi/user/totp), a block of the login's settings (AccountEditor). Off: turned on with
the password (setup), then the secret shown as a QR code, as text and as an otpauth:// link an authenticator app opens,
and the app's first code (enable); then the ten recovery codes, shown this once. Turning it on ends every other session
and every persona token, this device's too, and AuthService.TotpEnable goes on with the JWT PrivaPub answers. On: the
recovery codes left, and turned off with the password and a code from the app or a recovery code, asked first. *@
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-totp>
<p class="flex flex-wrap items-center gap-2">
<b class="text-sm">@Localizer["Two-factor sign-in"]</b>
@if (account is { TwoFactorEnabled: true })
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-totp-on>@Localizer["active"]</DBadge>
}
else if (account is not null)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-totp-off>@Localizer["not active"]</DBadge>
}
</p>
@if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
else if (recoveryCodes.Count > 0)
{
<div class="flex flex-col gap-3" data-totp-codes>
<DAlert Color="DAlertColor.Success" Variant="DAlertVariant.Soft" role="status">
<Icon><i class="ion-md-checkmark-circle text-xl" aria-hidden="true"></i></Icon>
<ChildContent>@Localizer["Two-factor sign-in is on. Every other device signed in to this login was signed out."]</ChildContent>
</DAlert>
<p class="text-sm">
<b>@Localizer["Save these recovery codes now: they won't be shown again."]</b>
@Localizer["Each one signs you in once in place of the app's code, if you lose your phone."]
</p>
<ol class="grid grid-cols-2 gap-x-4 gap-y-1 p-3 rounded-lg neo-inset neo-2xs font-mono text-sm select-all" data-totp-recovery-codes>
@foreach (var recoveryCode in recoveryCodes)
{
<li>@recoveryCode</li>
}
</ol>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CopyCodes" data-totp-copy>
<i class="ion-md-copy"></i><span>@Localizer["Copy"]</span>
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="CodesSaved" data-totp-saved>
<i class="ion-md-checkmark"></i><span>@Localizer["I saved them"]</span>
</DButton>
</div>
</div>
}
else if (account is { TwoFactorEnabled: true })
{
<p class="text-sm">@Localizer["Signing in takes the password and a code from your authenticator app, or one of your recovery codes."]</p>
<p class="text-sm" data-totp-codes-left>@Localizer["Recovery codes left: {0}", account.RecoveryCodesLeft]</p>
@if (account.RecoveryCodesLeft <= FewRecoveryCodes)
{
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" role="status" data-totp-codes-few>
<Icon><i class="ion-md-warning text-xl" aria-hidden="true"></i></Icon>
<ChildContent>@Localizer["Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones."]</ChildContent>
</DAlert>
}
@if (isDisabling)
{
<EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-totp-disable-password />
@if (disableWithRecoveryCode)
{
<DInput TValue="string" @bind-Value="disable.Code" Immediate="true" required="@true" autocomplete="one-time-code" inputmode="text"
autocapitalize="none" spellcheck="false" placeholder="@Localizer["Recovery code"]" data-totp-disable-recovery />
}
else
{
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
<DOtp @bind-Value="disable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-disable-code />
}
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-start" OnClick="ToggleDisableRecoveryCode" data-totp-disable-use-recovery>
@(disableWithRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
</DButton>
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-disable-confirm>
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<DButton Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenDisable" data-totp-disable-open>
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
</DButton>
}
}
else if (account is not null)
{
<p class="text-sm">@Localizer["Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough."]</p>
@if (setup is not null)
{
<div class="flex flex-col gap-3" data-totp-scan>
<p class="text-sm">@Localizer["Scan this QR code with your authenticator app, or type the key into it."]</p>
<img src="@setup.QrDataUri" alt="@Localizer["QR code of the key, for your authenticator app"]"
class="self-center w-48 h-48 p-2 rounded-lg bg-white" data-totp-qr />
<div class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Key"]</span>
<code class="p-2 rounded-lg neo-inset neo-2xs font-mono text-sm break-all select-all" data-totp-secret>@setup.Secret</code>
</div>
@* an <a href> to otpauth://, which a phone with an authenticator app opens in it *@
<DButton Href="@setup.OtpAuthUri" Size="DSize.Sm" class="self-center" data-totp-uri>
<i class="ion-md-open"></i><span>@Localizer["Open in the authenticator app"]</span>
</DButton>
<EditForm Model="enable" OnValidSubmit="Enable" class="flex flex-col gap-2" data-totp-enable-form>
<DataAnnotationsValidator />
<span class="text-sm">@Localizer["Then enter the 6-digit code the app shows."]</span>
<DOtp @bind-Value="enable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-code />
<ErrorViewer />
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-enable>
<i class="ion-md-lock"></i><span>@Localizer["Turn on"]</span>
</DButton>
</div>
</EditForm>
</div>
}
else if (isAskingPassword)
{
<EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-totp-setup-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-setup-confirm>
<i class="ion-md-arrow-forward"></i><span>@Localizer["Continue"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenSetup"
data-totp-setup-open>
<i class="ion-md-lock"></i><span>@Localizer["Turn on two-factor sign-in"]</span>
</DButton>
}
}
</div>
@code {
[Inject] AuthService AuthService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
/// <summary>From this many recovery codes left down, the block warns.</summary>
const int FewRecoveryCodes = 3;
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the block stays empty).</summary>
ViewAccount account;
PasswordForm setupPassword = new();
/// <summary>What the authenticator app needs while PrivaPub waits for its first code (an hour); null otherwise.</summary>
ViewTotpSetup setup;
TotpCodeForm enable = new();
TotpDisableForm disable = new();
/// <summary>The recovery codes PrivaPub made as two-factor sign-in was turned on, shown this once; empty otherwise.</summary>
List<string> recoveryCodes = [];
bool isAskingPassword;
bool isDisabling;
/// <summary>Turned off with a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
bool disableWithRecoveryCode;
bool isSaving;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => account = await AuthService.Account();
void OpenSetup()
{
setupPassword = new();
isAskingPassword = true;
}
void OpenDisable()
{
disable = new();
disableWithRecoveryCode = false;
isDisabling = true;
}
/// <summary>Back to the block's state; a setup PrivaPub keeps expires by itself after an hour.</summary>
void CloseForms()
{
isAskingPassword = false;
isDisabling = false;
setup = default;
setupPassword = new();
enable = new();
disable = new();
}
void ToggleDisableRecoveryCode()
{
disableWithRecoveryCode = !disableWithRecoveryCode;
disable.Code = default;
}
async Task Setup()
{
isSaving = true;
var result = await AuthService.TotpSetup(setupPassword.Password);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
// a 409: two-factor sign-in was turned on meanwhile, from another device
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
{
CloseForms();
account = await AuthService.Account() ?? account;
}
return;
}
setup = result.Data as ViewTotpSetup;
setupPassword = new();
enable = new();
isAskingPassword = false;
}
async Task Enable()
{
isSaving = true;
// every other session ends here, and this device goes on with a new JWT and a new token for the persona
var result = await AuthService.TotpEnable(enable.Code.Trim());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
enable = new();
// a 409: the setup expired (an hour), so it begins again with the password
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
{
CloseForms();
account = await AuthService.Account() ?? account;
isAskingPassword = account is { TwoFactorEnabled: false };
}
return;
}
recoveryCodes = result.Data as List<string> ?? [];
setup = default;
enable = new();
account.TwoFactorEnabled = true;
account.RecoveryCodesLeft = recoveryCodes.Count;
}
async Task CopyCodes()
{
// a refusal is reported by main.js; the codes can still be selected and copied by hand
if (!await CascadingState.Status.CopyText(string.Join('\n', recoveryCodes)))
return;
Toast.AddResponseSuccess(Localizer["Recovery codes copied."]);
}
void CodesSaved() => recoveryCodes = [];
async Task Disable()
{
if (!await Confirm.Ask(Localizer["Turn off two-factor sign-in?"],
Localizer["Signing in takes only the password again, and the recovery codes stop working."], Localizer["Turn off"], true))
return;
isSaving = true;
var result = await AuthService.TotpDisable(disable.Password, disable.Code.Trim());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
disable.Code = default;
return;
}
account = result.Data as ViewAccount ?? await AuthService.Account();
CloseForms();
Toast.AddResponseSuccess(Localizer["Two-factor sign-in is off."]);
}
}
+3
View File
@@ -27,6 +27,9 @@ public static class APIs
public const string POST_Email = "clientapi/user/email"; public const string POST_Email = "clientapi/user/email";
public const string POST_EmailVerify = "clientapi/user/email/verify"; public const string POST_EmailVerify = "clientapi/user/email/verify";
public const string POST_EmailRemove = "clientapi/user/email/remove"; public const string POST_EmailRemove = "clientapi/user/email/remove";
public const string POST_TotpSetup = "clientapi/user/totp/setup";
public const string POST_TotpEnable = "clientapi/user/totp/enable";
public const string POST_TotpDisable = "clientapi/user/totp/disable";
public const string GET_UserSettings = "clientapi/user/settings"; public const string GET_UserSettings = "clientapi/user/settings";
public const string POST_UpdateUserSettings = "clientapi/user/update/settings"; public const string POST_UpdateUserSettings = "clientapi/user/update/settings";
public const string POST_DeleteAccount = "clientapi/user/delete"; public const string POST_DeleteAccount = "clientapi/user/delete";
+74 -1
View File
@@ -5,7 +5,8 @@
@* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than @* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than
one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers
its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through
the login's verified address (PasswordRecovery). *@ the login's verified address (PasswordRecovery). With two-factor sign-in on, PrivaPub answers a right password with a
428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode). *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto"> <div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -26,6 +27,33 @@
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm"> <EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1> <h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1>
@if (needsCode)
{
@* The name and the password stay in the form, out of sight, and go again with the code. *@
<p class="text-sm" data-login-code-asked>@Localizer["Enter the code from your authenticator app, or a recovery code."]</p>
@if (useRecoveryCode)
{
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Recovery code"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.OneTimeCode" required="@true" autocomplete="one-time-code" inputmode="text"
autocapitalize="none" spellcheck="false" data-login-recovery>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
}
else
{
<div class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
<DOtp @bind-Value="form.OneTimeCode" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-login-code />
</div>
}
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-center" OnClick="ToggleRecoveryCode" data-login-use-recovery>
@(useRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
</DButton>
}
else
{
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p> <p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
@@ -40,6 +68,7 @@
<Prefix><i class="ion-md-key"></i></Prefix> <Prefix><i class="ion-md-key"></i></Prefix>
</DInput> </DInput>
</label> </label>
}
<ErrorViewer AdditionalError="@error" /> <ErrorViewer AdditionalError="@error" />
@@ -50,7 +79,16 @@
} }
<span>@Localizer["Login"]</span> <span>@Localizer["Login"]</span>
</DButton> </DButton>
@if (needsCode)
{
<DButton Variant="DButtonVariant.Ghost" Size="DSize.Sm" class="self-center" Disabled="isWorking" OnClick="Back" data-login-back>
<i class="ion-md-arrow-back"></i><span>@Localizer["Back"]</span>
</DButton>
}
else
{
<a class="link text-sm self-center touch-target" href="password-recovery" data-recovery-offer>@Localizer["Forgot your password?"]</a> <a class="link text-sm self-center touch-target" href="password-recovery" data-recovery-offer>@Localizer["Forgot your password?"]</a>
}
</EditForm> </EditForm>
<p class="text-sm text-center"> <p class="text-sm text-center">
@Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a> @Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
@@ -101,6 +139,10 @@
string error; string error;
bool isWorking; bool isWorking;
bool setupPending; bool setupPending;
/// <summary>PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on.</summary>
bool needsCode;
/// <summary>The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
bool useRecoveryCode;
[SupplyParameterFromQuery] public string ReturnUrl { get; set; } [SupplyParameterFromQuery] public string ReturnUrl { get; set; }
@@ -124,16 +166,30 @@
{ {
error = default; error = default;
isWorking = true; isWorking = true;
form.OneTimeCode = needsCode ? form.OneTimeCode?.Trim() : default;
var result = await AuthService.Login(form); var result = await AuthService.Login(form);
if (AuthService.NeedsOneTimeCode(result))
{
needsCode = true;
form.OneTimeCode = default;
isWorking = false;
return;
}
if (result.IsInvalid) if (result.IsInvalid)
{ {
// a wrong code is PrivaPub's 400, like a wrong password, with its own message; the code is typed again
error = result.ErrorMessage; error = result.ErrorMessage;
form.OneTimeCode = default;
isWorking = false; isWorking = false;
return; return;
} }
await CascadingState.SignedIn(result.Data as JwtUser); await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false; isWorking = false;
needsCode = false;
useRecoveryCode = false;
form.OneTimeCode = default;
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null) if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
Continue(); Continue();
} }
@@ -153,6 +209,23 @@
Continue(); Continue();
} }
void ToggleRecoveryCode()
{
useRecoveryCode = !useRecoveryCode;
form.OneTimeCode = default;
error = default;
}
/// <summary>Back to the name and the password, for another login: the password is typed again.</summary>
void Back()
{
needsCode = false;
useRecoveryCode = false;
form.OneTimeCode = default;
form.Password = default;
error = default;
}
async Task SignOut() => await CascadingState.SignOut(); async Task SignOut() => await CascadingState.SignOut();
/// <summary>Back where the reader was sent from, never off the app.</summary> /// <summary>Back where the reader was sent from, never off the app.</summary>
+87
View File
@@ -2399,4 +2399,91 @@
<data name="Keep the theme the same on every device signed in to this login" xml:space="preserve"> <data name="Keep the theme the same on every device signed in to this login" xml:space="preserve">
<value>Mantieni lo stesso tema su ogni dispositivo collegato a questo accesso</value> <value>Mantieni lo stesso tema su ogni dispositivo collegato a questo accesso</value>
</data> </data>
<data name="Enter the code from your authenticator app, or a recovery code." xml:space="preserve">
<value>Inserisci il codice dell'app di autenticazione, o un codice di recupero.</value>
</data>
<data name="Recovery code" xml:space="preserve">
<value>Codice di recupero</value>
</data>
<data name="Code from the authenticator app" xml:space="preserve">
<value>Codice dell'app di autenticazione</value>
</data>
<data name="Use the authenticator app instead" xml:space="preserve">
<value>Usa invece l'app di autenticazione</value>
</data>
<data name="Use a recovery code instead" xml:space="preserve">
<value>Usa invece un codice di recupero</value>
</data>
<data name="Two-factor sign-in" xml:space="preserve">
<value>Verifica in due passaggi</value>
</data>
<data name="active" xml:space="preserve">
<value>attiva</value>
</data>
<data name="not active" xml:space="preserve">
<value>non attiva</value>
</data>
<data name="Two-factor sign-in is on. Every other device signed in to this login was signed out." xml:space="preserve">
<value>La verifica in due passaggi è attiva. Ogni altro dispositivo collegato a questo accesso è stato disconnesso.</value>
</data>
<data name="Save these recovery codes now: they won't be shown again." xml:space="preserve">
<value>Salva subito questi codici di recupero: non verranno più mostrati.</value>
</data>
<data name="Each one signs you in once in place of the app's code, if you lose your phone." xml:space="preserve">
<value>Ognuno vale per un accesso al posto del codice dell'app, se perdi il telefono.</value>
</data>
<data name="Copy" xml:space="preserve">
<value>Copia</value>
</data>
<data name="I saved them" xml:space="preserve">
<value>Li ho salvati</value>
</data>
<data name="Signing in takes the password and a code from your authenticator app, or one of your recovery codes." xml:space="preserve">
<value>Per accedere servono la password e un codice dell'app di autenticazione, o uno dei codici di recupero.</value>
</data>
<data name="Recovery codes left: {0}" xml:space="preserve">
<value>Codici di recupero rimasti: {0}</value>
</data>
<data name="Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones." xml:space="preserve">
<value>Restano pochi codici di recupero: disattiva e riattiva la verifica in due passaggi per averne dieci nuovi.</value>
</data>
<data name="Turn off" xml:space="preserve">
<value>Disattiva</value>
</data>
<data name="Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough." xml:space="preserve">
<value>Per accedere serve, oltre alla password, un codice da un'app di autenticazione sul telefono: così una password rubata non basta.</value>
</data>
<data name="Scan this QR code with your authenticator app, or type the key into it." xml:space="preserve">
<value>Inquadra questo codice QR con l'app di autenticazione, o inserisci la chiave a mano.</value>
</data>
<data name="QR code of the key, for your authenticator app" xml:space="preserve">
<value>Codice QR della chiave, per l'app di autenticazione</value>
</data>
<data name="Key" xml:space="preserve">
<value>Chiave</value>
</data>
<data name="Open in the authenticator app" xml:space="preserve">
<value>Apri nell'app di autenticazione</value>
</data>
<data name="Then enter the 6-digit code the app shows." xml:space="preserve">
<value>Poi inserisci il codice di 6 cifre che mostra l'app.</value>
</data>
<data name="Turn on" xml:space="preserve">
<value>Attiva</value>
</data>
<data name="Turn on two-factor sign-in" xml:space="preserve">
<value>Attiva la verifica in due passaggi</value>
</data>
<data name="Recovery codes copied." xml:space="preserve">
<value>Codici di recupero copiati.</value>
</data>
<data name="Turn off two-factor sign-in?" xml:space="preserve">
<value>Disattivare la verifica in due passaggi?</value>
</data>
<data name="Signing in takes only the password again, and the recovery codes stop working." xml:space="preserve">
<value>Per accedere tornerà a bastare la password, e i codici di recupero smetteranno di funzionare.</value>
</data>
<data name="Two-factor sign-in is off." xml:space="preserve">
<value>La verifica in due passaggi è disattivata.</value>
</data>
</root> </root>
+87
View File
@@ -2399,4 +2399,91 @@
<data name="Keep the theme the same on every device signed in to this login" xml:space="preserve"> <data name="Keep the theme the same on every device signed in to this login" xml:space="preserve">
<value>Keep the theme the same on every device signed in to this login</value> <value>Keep the theme the same on every device signed in to this login</value>
</data> </data>
<data name="Enter the code from your authenticator app, or a recovery code." xml:space="preserve">
<value>Enter the code from your authenticator app, or a recovery code.</value>
</data>
<data name="Recovery code" xml:space="preserve">
<value>Recovery code</value>
</data>
<data name="Code from the authenticator app" xml:space="preserve">
<value>Code from the authenticator app</value>
</data>
<data name="Use the authenticator app instead" xml:space="preserve">
<value>Use the authenticator app instead</value>
</data>
<data name="Use a recovery code instead" xml:space="preserve">
<value>Use a recovery code instead</value>
</data>
<data name="Two-factor sign-in" xml:space="preserve">
<value>Two-factor sign-in</value>
</data>
<data name="active" xml:space="preserve">
<value>active</value>
</data>
<data name="not active" xml:space="preserve">
<value>not active</value>
</data>
<data name="Two-factor sign-in is on. Every other device signed in to this login was signed out." xml:space="preserve">
<value>Two-factor sign-in is on. Every other device signed in to this login was signed out.</value>
</data>
<data name="Save these recovery codes now: they won't be shown again." xml:space="preserve">
<value>Save these recovery codes now: they won't be shown again.</value>
</data>
<data name="Each one signs you in once in place of the app's code, if you lose your phone." xml:space="preserve">
<value>Each one signs you in once in place of the app's code, if you lose your phone.</value>
</data>
<data name="Copy" xml:space="preserve">
<value>Copy</value>
</data>
<data name="I saved them" xml:space="preserve">
<value>I saved them</value>
</data>
<data name="Signing in takes the password and a code from your authenticator app, or one of your recovery codes." xml:space="preserve">
<value>Signing in takes the password and a code from your authenticator app, or one of your recovery codes.</value>
</data>
<data name="Recovery codes left: {0}" xml:space="preserve">
<value>Recovery codes left: {0}</value>
</data>
<data name="Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones." xml:space="preserve">
<value>Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones.</value>
</data>
<data name="Turn off" xml:space="preserve">
<value>Turn off</value>
</data>
<data name="Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough." xml:space="preserve">
<value>Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough.</value>
</data>
<data name="Scan this QR code with your authenticator app, or type the key into it." xml:space="preserve">
<value>Scan this QR code with your authenticator app, or type the key into it.</value>
</data>
<data name="QR code of the key, for your authenticator app" xml:space="preserve">
<value>QR code of the key, for your authenticator app</value>
</data>
<data name="Key" xml:space="preserve">
<value>Key</value>
</data>
<data name="Open in the authenticator app" xml:space="preserve">
<value>Open in the authenticator app</value>
</data>
<data name="Then enter the 6-digit code the app shows." xml:space="preserve">
<value>Then enter the 6-digit code the app shows.</value>
</data>
<data name="Turn on" xml:space="preserve">
<value>Turn on</value>
</data>
<data name="Turn on two-factor sign-in" xml:space="preserve">
<value>Turn on two-factor sign-in</value>
</data>
<data name="Recovery codes copied." xml:space="preserve">
<value>Recovery codes copied.</value>
</data>
<data name="Turn off two-factor sign-in?" xml:space="preserve">
<value>Turn off two-factor sign-in?</value>
</data>
<data name="Signing in takes only the password again, and the recovery codes stop working." xml:space="preserve">
<value>Signing in takes only the password again, and the recovery codes stop working.</value>
</data>
<data name="Two-factor sign-in is off." xml:space="preserve">
<value>Two-factor sign-in is off.</value>
</data>
</root> </root>
+4
View File
@@ -36,6 +36,10 @@ public class AppStatusService(IJSRuntime jsRuntime)
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion"); public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
// puts the text on the clipboard; false when the browser refused it, which main.js reports through logFromJs
public async ValueTask<bool> CopyText(string text, CancellationToken cancellationToken = default) =>
await jsRuntime.InvokeAsync<bool>("copyText", cancellationToken, text);
// a sideways-scrolling strip of tabs shows its selected tab // a sideways-scrolling strip of tabs shows its selected tab
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip); public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
} }
+82 -5
View File
@@ -23,13 +23,25 @@ namespace decePubClient.Services
/// <summary>The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left.</summary> /// <summary>The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left.</summary>
static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2); static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2);
/// <summary>Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one).</summary> /// <summary>
/// Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one). With two-factor
/// sign-in on, a right password without LoginForm.OneTimeCode comes back invalid with StatusCode 428
/// (NeedsOneTimeCode): the caller asks for the code and sends the same form again with it. A wrong code is a 400, like a
/// wrong password, with PrivaPub's own message.
/// </summary>
public async Task<WebResult> Login(LoginForm form, CancellationToken cancellationToken = default) public async Task<WebResult> Login(LoginForm form, CancellationToken cancellationToken = default)
{ {
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken); using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken);
if (response.StatusCode is HttpStatusCode.PreconditionRequired)
return new WebResult().Invalidate(localizer["Enter the code from your authenticator app, or a recovery code."],
(int)HttpStatusCode.PreconditionRequired);
return await StartSession(response, cancellationToken); return await StartSession(response, cancellationToken);
} }
/// <summary>Whether a sign-in failed only for want of the authenticator app's code or a recovery code (PrivaPub's 428).</summary>
public static bool NeedsOneTimeCode(WebResult result) => result.IsInvalid && result.StatusCode is (int)HttpStatusCode.PreconditionRequired;
/// <summary>Whether the server waits for its first login, which becomes its administrator; false when it can't say.</summary> /// <summary>Whether the server waits for its first login, which becomes its administrator; false when it can't say.</summary>
public async Task<bool> SetupPending(CancellationToken cancellationToken = default) public async Task<bool> SetupPending(CancellationToken cancellationToken = default)
{ {
@@ -162,16 +174,80 @@ namespace decePubClient.Services
if (string.IsNullOrEmpty(jwtUser?.Token)) if (string.IsNullOrEmpty(jwtUser?.Token))
return new WebResult(); return new WebResult();
await ContinueSession(jwtUser, nameof(ChangePassword), cancellationToken);
return new WebResult { Data = jwtUser };
}
/// <summary>
/// Begins two-factor sign-in, the password given (a wrong one is PrivaPub's 403, read as such). Data is what the
/// authenticator app needs (ViewTotpSetup): the secret as text, as an otpauth:// URI and as that URI's QR code. PrivaPub
/// keeps it an hour for TotpEnable; a login with two-factor on already is a 409.
/// </summary>
public async Task<WebResult> TotpSetup(string password, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_TotpSetup, new PasswordForm { Password = password },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await ReadPasswordFailure(response, cancellationToken);
var setup = await response.DefaultReadFromJsonAsync<ViewTotpSetup>(cancellationToken);
if (string.IsNullOrEmpty(setup?.Secret))
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
return new WebResult { Data = setup };
}
/// <summary>
/// Turns two-factor sign-in on with the app's first code (a wrong one is a 400; a setup older than an hour a 409, to
/// begin again). Data is the ten recovery codes (List&lt;string&gt;), which PrivaPub never shows again. PrivaPub ends every
/// session of the login with it and revokes every persona's token, this device's too, and answers with this device's new
/// JWT: the session goes on with it, as after a password change (ContinueSession).
/// </summary>
public async Task<WebResult> TotpEnable(string code, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_TotpEnable, new TotpCodeForm { Code = code },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var enabled = await response.DefaultReadFromJsonAsync<ViewTotpEnabled>(cancellationToken);
if (string.IsNullOrEmpty(enabled?.Session?.Token))
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
await ContinueSession(enabled.Session, nameof(TotpEnable), cancellationToken);
return new WebResult { Data = enabled.RecoveryCodes };
}
/// <summary>
/// Turns two-factor sign-in off with the password (a wrong one is PrivaPub's 403) and a code: the app's, or a recovery
/// code (a wrong one is a 400). Data is the login as it is now (ViewAccount).
/// </summary>
public async Task<WebResult> TotpDisable(string password, string code, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_TotpDisable, new TotpDisableForm { Password = password, Code = code },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await ReadPasswordFailure(response, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
}
/// <summary>
/// Goes on with the JWT PrivaPub answered after it ended every session of the login and revoked every persona's token,
/// this device's too (a password change, two-factor sign-in turned on): the stored persona tokens are dropped and the
/// current persona gets a new one at once, the others when they are next used.
/// </summary>
async Task ContinueSession(JwtUser jwtUser, string caller, CancellationToken cancellationToken)
{
await authStateProvider.SetSession(jwtUser, cancellationToken); await authStateProvider.SetSession(jwtUser, cancellationToken);
await authStateProvider.ForgetPersonaTokens(cancellationToken); await authStateProvider.ForgetPersonaTokens(cancellationToken);
var session = await authStateProvider.GetAuthData(cancellationToken); var session = await authStateProvider.GetAuthData(cancellationToken);
if (session.PersonaId is null) if (session.PersonaId is null)
return new WebResult { Data = jwtUser }; return;
using var exchanged = await httpService.ExchangePersonaToken(session.PersonaId, cancellationToken); using var exchanged = await httpService.ExchangePersonaToken(session.PersonaId, cancellationToken);
if (!exchanged.IsSuccessStatusCode) if (!exchanged.IsSuccessStatusCode)
await LogFailure(exchanged, nameof(ChangePassword), cancellationToken); await LogFailure(exchanged, caller, cancellationToken);
return new WebResult { Data = jwtUser };
} }
/// <summary> /// <summary>
@@ -208,7 +284,8 @@ namespace decePubClient.Services
return new WebResult(); return new WebResult();
} }
/// <summary>The login itself (its name, its email address and whether it is verified, one waiting for its code), or null.</summary> /// <summary>The login itself (its name, its email address and whether it is verified, one waiting for its code, whether
/// two-factor sign-in is on and how many recovery codes are left), or null.</summary>
public async Task<ViewAccount> Account(CancellationToken cancellationToken = default) public async Task<ViewAccount> Account(CancellationToken cancellationToken = default)
{ {
using var response = await httpService.Get(APIs.ClientApi.GET_Account, cancellationToken: cancellationToken); using var response = await httpService.Get(APIs.ClientApi.GET_Account, cancellationToken: cancellationToken);
+11
View File
@@ -120,6 +120,17 @@ window.prefersReducedMotion = () => {
return false return false
} }
} }
// text put on the clipboard (the recovery codes of two-factor sign-in); false when the browser refused it
window.copyText = async text => {
try {
await navigator.clipboard.writeText(text)
return true
} catch (e) {
console.error(e)
window.logFromJs(e.message, "copyText")
return false
}
}
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant) // a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
window.isMobileMedia = () => { window.isMobileMedia = () => {
try { try {