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
+692
-25
No files matched your search
@@ -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
|
||||
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. *@
|
||||
<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>
|
||||
@@ -120,6 +121,8 @@
|
||||
</DButton>
|
||||
</EditForm>
|
||||
|
||||
<TotpEditor />
|
||||
|
||||
<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>
|
||||
<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."]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user