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. *@
@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["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."] +
+@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["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) + { +@Localizer["Scan this QR code with your authenticator app, or type the key into it."]
+@setup.Secret
+ @Localizer["Every other device signed in to this login is signed out."]
+@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["One login for all of your personas: each one is signed in separately and never linked to the others."]
- - + + + }
@Localizer["Have an invitation?"] @Localizer["Join with an invitation"]
@@ -101,6 +139,10 @@
string error;
bool isWorking;
bool setupPending;
+ ///