using System.Net; using System.Text.Json; using decePubClient.Extensions; using decePubClient.Helpers; using decePubClient.Models; using decePubClient.Models.Mastodon; using PrivaPub.ClientModels; using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User.Avatar; namespace decePubClient.Services { /// /// One sign-in for both of PrivaPub's APIs: the root signs in on /clientapi, then each persona it uses gets its own /// Mastodon token in exchange for the JWT (PrivaPub's PersonaExchange), so the Mastodon API never learns the root. /// Results are WebResults, lists come back empty on failure; nothing throws. /// public class AuthService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AppConfiguration appConfiguration, ILoggingService logger, CoalescingStringLocalizer localizer) { /// 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). 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) { using var response = await httpService.GetTotallyAnon(APIs.ClientApi.GET_Setup, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(SetupPending), cancellationToken); return false; } return (await response.DefaultReadFromJsonAsync(cancellationToken))?.IsPending ?? false; } /// Makes the server's first login, its administrator, with its first persona, and signs it in. public async Task SetUp(SetupForm form, CancellationToken cancellationToken = default) { using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Setup, form, cancellationToken: cancellationToken); return await StartSession(response, cancellationToken); } /// Makes a login through a group's invitation, its first persona a member of the group, and signs it in. public async Task Join(InvitationLoginForm form, CancellationToken cancellationToken = default) { using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_InvitationSignUp, form, cancellationToken: cancellationToken); return await StartSession(response, cancellationToken); } /// Keeps the JWT a login, a setup or an invitation answered with. async Task StartSession(HttpResponseMessage response, CancellationToken cancellationToken) { if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); var jwtUser = await response.DefaultReadFromJsonAsync(cancellationToken); if (string.IsNullOrEmpty(jwtUser?.Token)) return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway); await authStateProvider.SetSession(jwtUser, cancellationToken); return new WebResult { Data = jwtUser }; } /// The root's personas. public async Task> Personas(CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.ClientApi.GET_Personas, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(Personas), cancellationToken); return []; } return await response.DefaultReadFromJsonAsync>(cancellationToken) ?? []; } /// Makes a new persona for the root; Data is the persona. No other persona is linked to it, anywhere. public async Task CreatePersona(InsertAvatarForm form, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.ClientApi.POST_InsertPersona, form, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; } /// Acts as the persona from now on, exchanging a token for it unless it has one. public async Task UsePersona(string personaId, CancellationToken cancellationToken = default) { var session = await authStateProvider.GetAuthData(cancellationToken); if (session.PersonaTokens.ContainsKey(personaId)) { await authStateProvider.SetPersona(personaId, cancellationToken: cancellationToken); return new(); } using var response = await httpService.ExchangePersonaToken(personaId, cancellationToken); return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); } /// The current persona's Mastodon account, or null. public async Task CurrentAccount(CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.Mastodon.GET_VerifyCredentials, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(CurrentAccount), cancellationToken); return null; } return await response.MastodonReadFromJsonAsync(cancellationToken); } /// Renews the JWT when it is about to expire; persona tokens do not expire and are kept. public async Task RefreshIfNeeded(CancellationToken cancellationToken = default) { var session = await authStateProvider.GetAuthData(cancellationToken); if (session.TokenExpiration is not { } expiration) return; var lifetime = session.TokenIssuedAt is { } issued ? TimeSpan.FromTicks(expiration - issued) : RenewBefore * 4; var renewBefore = lifetime / 4 < RenewBefore ? lifetime / 4 : RenewBefore; if (new DateTime(expiration, DateTimeKind.Utc) - DateTime.UtcNow > renewBefore) return; if (await authStateProvider.GetToken(cancellationToken) is null) return; using var response = await httpService.Get(APIs.ClientApi.GET_SniffAgain, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(RefreshIfNeeded), cancellationToken); return; } var jwtUser = await response.DefaultReadFromJsonAsync(cancellationToken); if (!string.IsNullOrEmpty(jwtUser?.Token)) await authStateProvider.SetSession(jwtUser, cancellationToken); } /// /// Changes the root's password, the current one given. PrivaPub ends every session of the login with it, and revokes /// every persona's token, this device's too; it answers with a new JWT, so the session goes on with it and the current /// persona gets a new token at once (the others when they are next used). /// public async Task ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); // a server from before sessions answered with nothing, and ended none var jwtUser = await response.DefaultReadFromJsonAsync(cancellationToken); 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; using var exchanged = await httpService.ExchangePersonaToken(session.PersonaId, cancellationToken); if (!exchanged.IsSuccessStatusCode) await LogFailure(exchanged, caller, cancellationToken); } /// /// Asks PrivaPub to mail a recovery link to the login named, or to the login whose verified address is given. Data is /// PrivaPub's answer, the same sentence whether or not there is such a login. Sent with no credentials. /// public async Task RequestRecovery(PasswordRecoveryForm form, CancellationToken cancellationToken = default) { using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverPassword, form, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await ReadSentence(response, cancellationToken) }; } /// Whether a recovery link's code still works (Data, a bool): it lasts an hour and is used once. public async Task CheckRecoveryCode(string recoveryCode, CancellationToken cancellationToken = default) { // [FromBody] string: the code goes as a JSON string using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverValid, recoveryCode, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; } /// Sets a new password through a recovery link's code; PrivaPub ends every session the login had. public async Task Recover(NewPasswordForm form, CancellationToken cancellationToken = default) { using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverUpdatePassword, form, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult(); } /// 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); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(Account), cancellationToken); return null; } return await response.DefaultReadFromJsonAsync(cancellationToken); } /// /// Asks for the login's address to become another, the password given: PrivaPub mails a code to it, which VerifyEmail /// brings back. Data is PrivaPub's answer, the same sentence whether or not the address is free. /// public async Task ChangeEmail(EmailChangeForm form, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.ClientApi.POST_Email, form, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await ReadPasswordFailure(response, cancellationToken); return new WebResult { Data = await ReadSentence(response, cancellationToken) }; } /// Makes the address waiting for its code the login's, verified; Data is the login as it is now. public async Task VerifyEmail(string code, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.ClientApi.POST_EmailVerify, new EmailCodeForm { Code = code }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; } /// Removes the login's address, the password given; without one a forgotten password can't be recovered. /// Data is the login as it is now. public async Task RemoveEmail(string password, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.ClientApi.POST_EmailRemove, new PasswordForm { Password = password }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await ReadPasswordFailure(response, cancellationToken); return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; } /// /// Ends every session of the login, this device's too, and PrivaPub revokes every persona's token with them. The client /// forgets its session at once; the caller signs out, which then has nothing left to tell PrivaPub. /// public async Task LogoutEverywhere(CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.ClientApi.POST_LogoutEverywhere, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); await authStateProvider.LogoutAsync(cancellationToken); return new WebResult(); } /// The theme and language the login keeps for every device, or null; SavedAt is null until a client saved them. public async Task Settings(CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.ClientApi.GET_UserSettings, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(Settings), cancellationToken); return null; } return await response.DefaultReadFromJsonAsync(cancellationToken); } /// Saves the theme and language as the login's, for every device signed in to it. A failure is logged here. public async Task SaveSettings(ViewUserSettings settings, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.ClientApi.POST_UpdateUserSettings, settings, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); if (result.StatusCode is not (int)HttpStatusCode.Gone) await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{nameof(SaveSettings)}", cancellationToken); return result; } return new WebResult(); } /// Deletes the root, its password given: every persona of it and everything public they had, here and, as /// PrivaPub tells them, on the servers that hold it. The caller signs out afterwards. public async Task DeleteAccount(RemoveSelfForm form, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.ClientApi.POST_DeleteAccount, form, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult(); } /// Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data. public async Task Logout(CancellationToken cancellationToken = default) { var session = await authStateProvider.GetAuthData(cancellationToken); foreach (var token in session.PersonaTokens.Values) { using var revoked = await httpService.PostTotallyAnon(APIs.OAuth.POST_Revoke, new Dictionary { ["token"] = token, ["client_id"] = appConfiguration.ClientId }, cancellationToken: cancellationToken); if (!revoked.IsSuccessStatusCode) await LogFailure(revoked, nameof(Logout), cancellationToken); } if (await authStateProvider.GetToken(cancellationToken) is not null) { using var response = await httpService.Get(APIs.ClientApi.GET_Logout, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) await LogFailure(response, nameof(Logout), cancellationToken); } await authStateProvider.LogoutAsync(cancellationToken); } /// /// The failure of what only the password may do: PrivaPub refuses a wrong one with 403, which ReadWebResult would /// take for a missing permission. /// async Task ReadPasswordFailure(HttpResponseMessage response, CancellationToken cancellationToken) { if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader)) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden); } /// A sentence PrivaPub answered with: a JSON string, or plain text (ASP.NET writes a string so unless JSON is asked for). static async Task ReadSentence(HttpResponseMessage response, CancellationToken cancellationToken) { try { var text = await response.Content.ReadAsStringAsync(cancellationToken); return text.StartsWith('"') ? JsonSerializer.Deserialize(text, SUtility.DefaultSerializer) : text; } catch (Exception ex) when (ex is OperationCanceledException or JsonException) { return null; } } async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) { var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); if (result.StatusCode is not (int)HttpStatusCode.Gone) await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{caller}", cancellationToken); } } }