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
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:
1 parent
7d4b35f0d7
commit
b731d8c8c0
11 files changed
+678
-11
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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))]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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<string>), 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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user