From b731d8c8c07c3c5b312222545e7b643d5a3c604c Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 17 Oct 2026 03:33:46 +0200 Subject: [PATCH] R3 two-factor in decePub: an authenticator app set up in Settings, its code asked for at sign-in 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 Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof --- CLAUDE.md | 20 +- ClientJsonContext.cs | 4 + Components/Settings/AccountEditor.razor | 5 +- Components/Settings/TotpEditor.razor | 306 ++++++++++++++++++++++++ Models/APIs.cs | 3 + Pages/Login.razor | 103 ++++++-- Resources/AllStrings.it.resx | 87 +++++++ Resources/AllStrings.resx | 87 +++++++ Services/AppStatusService.cs | 4 + Services/AuthService.cs | 87 ++++++- wwwroot/main.js | 11 + 11 files changed, 692 insertions(+), 25 deletions(-) create mode 100644 Components/Settings/TotpEditor.razor diff --git a/CLAUDE.md b/CLAUDE.md index 36b26a8..af25c42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,9 +261,15 @@ Never redeclare the base's members in a component. forgets the session. - **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 - its own and signs out. A password change ends every other session and revokes every persona token, this device's - too: `AuthService.ChangePassword` keeps the new `JwtUser` PrivaPub answers with, drops the stored persona tokens and - exchanges one for the current persona at once (the others when next used), so the device stays signed in. + its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every + persona token, this device's too: `AuthService.ChangePassword` and `TotpEnable` keep the new `JwtUser` PrivaPub + 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): - **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 @@ -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`, 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 - 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 signs out), the persona's profile (`ProfileEditor`: `update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file, diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index a4ae438..9156247 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -41,6 +41,10 @@ namespace decePubClient; [JsonSerializable(typeof(EmailChangeForm))] [JsonSerializable(typeof(EmailCodeForm))] [JsonSerializable(typeof(PasswordForm))] +[JsonSerializable(typeof(TotpCodeForm))] +[JsonSerializable(typeof(TotpDisableForm))] +[JsonSerializable(typeof(ViewTotpSetup))] +[JsonSerializable(typeof(ViewTotpEnabled))] [JsonSerializable(typeof(ViewUserSettings))] // recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool [JsonSerializable(typeof(string))] diff --git a/Components/Settings/AccountEditor.razor b/Components/Settings/AccountEditor.razor index 4e5a804..dad0e9b 100644 --- a/Components/Settings/AccountEditor.razor +++ b/Components/Settings/AccountEditor.razor @@ -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. *@
@@ -120,6 +121,8 @@ + +
@Localizer["Sign out everywhere"]

@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."]

diff --git a/Components/Settings/TotpEditor.razor b/Components/Settings/TotpEditor.razor new file mode 100644 index 0000000..33bd20d --- /dev/null +++ b/Components/Settings/TotpEditor.razor @@ -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. *@ +
+

+ @Localizer["Two-factor sign-in"] + @if (account is { TwoFactorEnabled: true }) + { + @Localizer["active"] + } + else if (account is not null) + { + @Localizer["not active"] + } +

+ + @if (IsLoading) + { + + } + else if (recoveryCodes.Count > 0) + { +
+ + + @Localizer["Two-factor sign-in is on. Every other device signed in to this login was signed out."] + +

+ @Localizer["Save these recovery codes now: they won't be shown again."] + @Localizer["Each one signs you in once in place of the app's code, if you lose your phone."] +

+
    + @foreach (var recoveryCode in recoveryCodes) + { +
  1. @recoveryCode
  2. + } +
+
+ + @Localizer["Copy"] + + + @Localizer["I saved them"] + +
+
+ } + else if (account is { TwoFactorEnabled: true }) + { +

@Localizer["Signing in takes the password and a code from your authenticator app, or one of your recovery codes."]

+

@Localizer["Recovery codes left: {0}", account.RecoveryCodesLeft]

+ @if (account.RecoveryCodesLeft <= FewRecoveryCodes) + { + + + @Localizer["Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones."] + + } + + @if (isDisabling) + { + + + + @if (disableWithRecoveryCode) + { + + } + else + { + @Localizer["Code from the authenticator app"] + + } + + @(disableWithRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"]) + + +
+ @Localizer["Cancel"] + + @Localizer["Turn off"] + +
+
+ } + else + { + + @Localizer["Turn off"] + + } + } + else if (account is not null) + { +

@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."]

+ + @if (setup is not null) + { + + } + else if (isAskingPassword) + { + + + + +
+ @Localizer["Cancel"] + + @Localizer["Continue"] + +
+
+ } + else + { + + @Localizer["Turn on two-factor sign-in"] + + } + } +
+ +@code { + [Inject] AuthService AuthService { get; set; } + [Inject] ConfirmService Confirm { get; set; } + + /// From this many recovery codes left down, the block warns. + const int FewRecoveryCodes = 3; + + /// The login as PrivaPub describes it; null until read, or when it couldn't be (the block stays empty). + ViewAccount account; + PasswordForm setupPassword = new(); + /// What the authenticator app needs while PrivaPub waits for its first code (an hour); null otherwise. + ViewTotpSetup setup; + TotpCodeForm enable = new(); + TotpDisableForm disable = new(); + /// The recovery codes PrivaPub made as two-factor sign-in was turned on, shown this once; empty otherwise. + List recoveryCodes = []; + bool isAskingPassword; + bool isDisabling; + /// Turned off with a recovery code (two groups of five letters and digits), not the app's six digits. + 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; + } + + /// Back to the block's state; a setup PrivaPub keeps expires by itself after an hour. + 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 ?? []; + 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."]); + } +} diff --git a/Models/APIs.cs b/Models/APIs.cs index 482cad6..2aef675 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -27,6 +27,9 @@ public static class APIs public const string POST_Email = "clientapi/user/email"; public const string POST_EmailVerify = "clientapi/user/email/verify"; 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 POST_UpdateUserSettings = "clientapi/user/update/settings"; public const string POST_DeleteAccount = "clientapi/user/delete"; diff --git a/Pages/Login.razor b/Pages/Login.razor index a09b4ed..9f2b3bb 100644 --- a/Pages/Login.razor +++ b/Pages/Login.razor @@ -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 (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 - 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). *@
@@ -26,20 +27,48 @@

@Localizer["Sign in to PrivaPub"]

-

@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]

+ @if (needsCode) + { + @* The name and the password stay in the form, out of sight, and go again with the code. *@ +

@Localizer["Enter the code from your authenticator app, or a recovery code."]

+ @if (useRecoveryCode) + { + + } + else + { +
+ @Localizer["Code from the authenticator app"] + +
+ } + + @(useRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"]) + + } + else + { +

@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]

- - + + + } @@ -50,7 +79,16 @@ } @Localizer["Login"] -
@Localizer["Forgot your password?"] + @if (needsCode) + { + + @Localizer["Back"] + + } + else + { + @Localizer["Forgot your password?"] + }

@Localizer["Have an invitation?"] @Localizer["Join with an invitation"] @@ -101,6 +139,10 @@ string error; bool isWorking; bool setupPending; + ///

PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on. + bool needsCode; + /// The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits. + bool useRecoveryCode; [SupplyParameterFromQuery] public string ReturnUrl { get; set; } @@ -124,16 +166,30 @@ { error = default; isWorking = true; + form.OneTimeCode = needsCode ? form.OneTimeCode?.Trim() : default; var result = await AuthService.Login(form); + if (AuthService.NeedsOneTimeCode(result)) + { + needsCode = true; + form.OneTimeCode = default; + isWorking = false; + return; + } + 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; + form.OneTimeCode = default; isWorking = false; return; } await CascadingState.SignedIn(result.Data as JwtUser); isWorking = false; + needsCode = false; + useRecoveryCode = false; + form.OneTimeCode = default; if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null) Continue(); } @@ -153,6 +209,23 @@ Continue(); } + void ToggleRecoveryCode() + { + useRecoveryCode = !useRecoveryCode; + form.OneTimeCode = default; + error = default; + } + + /// Back to the name and the password, for another login: the password is typed again. + void Back() + { + needsCode = false; + useRecoveryCode = false; + form.OneTimeCode = default; + form.Password = default; + error = default; + } + async Task SignOut() => await CascadingState.SignOut(); /// Back where the reader was sent from, never off the app. diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 365b545..47d06e7 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -2399,4 +2399,91 @@ Mantieni lo stesso tema su ogni dispositivo collegato a questo accesso + + Inserisci il codice dell'app di autenticazione, o un codice di recupero. + + + Codice di recupero + + + Codice dell'app di autenticazione + + + Usa invece l'app di autenticazione + + + Usa invece un codice di recupero + + + Verifica in due passaggi + + + attiva + + + non attiva + + + La verifica in due passaggi è attiva. Ogni altro dispositivo collegato a questo accesso è stato disconnesso. + + + Salva subito questi codici di recupero: non verranno più mostrati. + + + Ognuno vale per un accesso al posto del codice dell'app, se perdi il telefono. + + + Copia + + + Li ho salvati + + + Per accedere servono la password e un codice dell'app di autenticazione, o uno dei codici di recupero. + + + Codici di recupero rimasti: {0} + + + Restano pochi codici di recupero: disattiva e riattiva la verifica in due passaggi per averne dieci nuovi. + + + Disattiva + + + Per accedere serve, oltre alla password, un codice da un'app di autenticazione sul telefono: così una password rubata non basta. + + + Inquadra questo codice QR con l'app di autenticazione, o inserisci la chiave a mano. + + + Codice QR della chiave, per l'app di autenticazione + + + Chiave + + + Apri nell'app di autenticazione + + + Poi inserisci il codice di 6 cifre che mostra l'app. + + + Attiva + + + Attiva la verifica in due passaggi + + + Codici di recupero copiati. + + + Disattivare la verifica in due passaggi? + + + Per accedere tornerà a bastare la password, e i codici di recupero smetteranno di funzionare. + + + La verifica in due passaggi è disattivata. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 9a342b4..0d0dd0d 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -2399,4 +2399,91 @@ Keep the theme the same on every device signed in to this login + + Enter the code from your authenticator app, or a recovery code. + + + Recovery code + + + Code from the authenticator app + + + Use the authenticator app instead + + + Use a recovery code instead + + + Two-factor sign-in + + + active + + + not active + + + Two-factor sign-in is on. Every other device signed in to this login was signed out. + + + Save these recovery codes now: they won't be shown again. + + + Each one signs you in once in place of the app's code, if you lose your phone. + + + Copy + + + I saved them + + + Signing in takes the password and a code from your authenticator app, or one of your recovery codes. + + + Recovery codes left: {0} + + + Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones. + + + Turn off + + + Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough. + + + Scan this QR code with your authenticator app, or type the key into it. + + + QR code of the key, for your authenticator app + + + Key + + + Open in the authenticator app + + + Then enter the 6-digit code the app shows. + + + Turn on + + + Turn on two-factor sign-in + + + Recovery codes copied. + + + Turn off two-factor sign-in? + + + Signing in takes only the password again, and the recovery codes stop working. + + + Two-factor sign-in is off. + diff --git a/Services/AppStatusService.cs b/Services/AppStatusService.cs index e2f3c1d..dfea9e7 100644 --- a/Services/AppStatusService.cs +++ b/Services/AppStatusService.cs @@ -36,6 +36,10 @@ public class AppStatusService(IJSRuntime jsRuntime) public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke("prefersReducedMotion"); + // puts the text on the clipboard; false when the browser refused it, which main.js reports through logFromJs + public async ValueTask CopyText(string text, CancellationToken cancellationToken = default) => + await jsRuntime.InvokeAsync("copyText", cancellationToken, text); + // a sideways-scrolling strip of tabs shows its selected tab public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip); } \ No newline at end of file diff --git a/Services/AuthService.cs b/Services/AuthService.cs index 50a1e6a..f159f18 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -23,13 +23,25 @@ namespace decePubClient.Services /// The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left. static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2); - /// Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one). + /// + /// 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. + /// public async Task Login(LoginForm form, CancellationToken cancellationToken = default) { 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); } + /// Whether a sign-in failed only for want of the authenticator app's code or a recovery code (PrivaPub's 428). + public static bool NeedsOneTimeCode(WebResult result) => result.IsInvalid && result.StatusCode is (int)HttpStatusCode.PreconditionRequired; + /// Whether the server waits for its first login, which becomes its administrator; false when it can't say. public async Task SetupPending(CancellationToken cancellationToken = default) { @@ -162,16 +174,80 @@ namespace decePubClient.Services if (string.IsNullOrEmpty(jwtUser?.Token)) return new WebResult(); + await ContinueSession(jwtUser, nameof(ChangePassword), cancellationToken); + return new WebResult { Data = jwtUser }; + } + + /// + /// 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. + /// + public async Task 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(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 }; + } + + /// + /// 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). + /// + public async Task 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(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 }; + } + + /// + /// 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). + /// + public async Task 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(cancellationToken) }; + } + + /// + /// 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. + /// + async Task ContinueSession(JwtUser jwtUser, string caller, CancellationToken cancellationToken) + { await authStateProvider.SetSession(jwtUser, cancellationToken); await authStateProvider.ForgetPersonaTokens(cancellationToken); var session = await authStateProvider.GetAuthData(cancellationToken); if (session.PersonaId is null) - return new WebResult { Data = jwtUser }; + return; using var exchanged = await httpService.ExchangePersonaToken(session.PersonaId, cancellationToken); if (!exchanged.IsSuccessStatusCode) - await LogFailure(exchanged, nameof(ChangePassword), cancellationToken); - return new WebResult { Data = jwtUser }; + await LogFailure(exchanged, caller, cancellationToken); } /// @@ -208,7 +284,8 @@ namespace decePubClient.Services return new WebResult(); } - /// The login itself (its name, its email address and whether it is verified, one waiting for its code), or null. + /// 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. public async Task Account(CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.ClientApi.GET_Account, cancellationToken: cancellationToken); diff --git a/wwwroot/main.js b/wwwroot/main.js index 7cc5ca5..9261385 100644 --- a/wwwroot/main.js +++ b/wwwroot/main.js @@ -120,6 +120,17 @@ window.prefersReducedMotion = () => { 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) window.isMobileMedia = () => { try {