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
+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>
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public async Task<bool> 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 };
|
||||
}
|
||||
|
||||
/// <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.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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -208,7 +284,8 @@ namespace decePubClient.Services
|
||||
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)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.ClientApi.GET_Account, cancellationToken: cancellationToken);
|
||||
|
||||
Reference in new issue
Block a user