Files
SocialPub/PrivaPub/Web/Pages/OAuth/OAuthPages.cs
T
thepraandClaude Opus 5.5 1f3bb27e17
Build / Build (push) Successful in 36s
R3 two-factor sign-in: TOTP with recovery codes, asked for only after a correct password
Completion program R3 (docs/ROADMAP.md, 2026-10-17).

- Services/TwoFactor.cs: TOTP (RFC 6238, 30 s, 6 digits, SHA-1, one step of slack, Otp.NET 1.4.1) and ten recovery
  codes (SHA-256 kept, pulled when used). RootUser.Totp holds the secret, the last step taken (moved only by a
  conditional update, so a code works once even for two sign-ins at the same moment) and the codes' hashes.
- /clientapi/user/totp/setup (the password) answers the secret, its otpauth:// URI and a QR code as an SVG data URI
  (QRCoder 1.8.0); /totp/enable takes the app's first code, shows the recovery codes once and ends every other session
  (this device gets a new one); /totp/disable takes the password and a code. GET /clientapi/user/account says whether
  it is on and how many recovery codes are left.
- Sign-in (/clientapi/user/login, the invitation login, /oauth/login) asks for LoginForm.OneTimeCode with a 428, and
  only once the password was right; the OAuth page has the field. Recovery by email leaves two-factor on.
- PrivaPub admin reset-totp <root>: the way back for a login that lost its authenticator and its codes.
- scenarios/accounts.sh checks it all, with codes computed in Python; the OAuth page with its antiforgery token.

Verified: dotnet build PrivaPub.sln -c Release; the pasture (gts, mailpit): accounts 45/45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:18:19 +02:00

171 lines
7.0 KiB
C#

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.ClientModels;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Web.Pages.OAuth
{
[EnableRateLimiting(RateLimiting.Accounts)]
public class LoginModel : PageModel
{
readonly IRootUsersService _users;
public LoginModel(IRootUsersService users)
{
_users = users;
}
[BindProperty(SupportsGet = true)]
public string ReturnUrl { get; set; }
public string Error { get; private set; }
public void OnGet() => Harden();
public async Task<IActionResult> OnPostAsync([FromForm] string userName, [FromForm] string password, [FromForm] string oneTimeCode)
{
Harden();
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
{
Error = "Enter your username and password.";
return Page();
}
var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password, OneTimeCode = oneTimeCode });
if (result.StatusCode == StatusCodes.Status428PreconditionRequired)
{
Error = "Enter the code from your authenticator app, or a recovery code, with your password.";
return Page();
}
if (!result.IsValid)
{
Error = string.IsNullOrEmpty(oneTimeCode) ? "That username and password do not match." : "That username, password and code do not match.";
return Page();
}
var (root, _) = ((RootUser, ClientModels.User.ViewUserSettings))result.Data;
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, root.ID) }, OAuthSetup.LoginScheme);
await HttpContext.SignInAsync(OAuthSetup.LoginScheme, new ClaimsPrincipal(identity));
var target = ReturnUrl?.StartsWith("/oauth/authorize?", StringComparison.Ordinal) == true ? ReturnUrl : "/oauth/authorize";
return LocalRedirect(target + (target.Contains('?') ? "&" : "?") + "signed_in=1");
}
void Harden()
{
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'";
Response.Headers["Cache-Control"] = "no-store";
}
}
[IgnoreAntiforgeryToken]
public class AuthorizeModel : PageModel
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly OpenIddict.Abstractions.IOpenIddictApplicationManager _applications;
public AuthorizeModel(DbEntities dbEntities, ILocalActorService localActors, OpenIddict.Abstractions.IOpenIddictApplicationManager applications)
{
_dbEntities = dbEntities;
_localActors = localActors;
_applications = applications;
}
public string ApplicationName { get; private set; }
public IReadOnlyList<string> RequestedScopes { get; private set; } = Array.Empty<string>();
public IReadOnlyList<LocalActor> Avatars { get; private set; } = Array.Empty<LocalActor>();
public IReadOnlyList<KeyValuePair<string, string>> Parameters { get; private set; } = Array.Empty<KeyValuePair<string, string>>();
public async Task<IActionResult> OnGetAsync(CancellationToken token) => await Show(token);
public async Task<IActionResult> OnPostAsync([FromForm] string avatarId, [FromForm] string decision, CancellationToken token)
{
var request = HttpContext.GetOpenIddictServerRequest();
var rootId = await SignedInRoot();
if (request == default || rootId == default)
return await Show(token);
if (decision != "allow")
return Forbid(new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user denied the request."
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
var owns = !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
var avatar = owns ? await _localActors.FindById(LocalActorKind.Person, avatarId, token) : default;
if (avatar == default)
return await Show(token);
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, avatar.Id);
identity.SetClaim(Claims.Name, avatar.UserName);
identity.SetScopes(MastodonScopes.Parse(request.Scope));
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
await HttpContext.SignOutAsync(OAuthSetup.LoginScheme);
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
async Task<IActionResult> Show(CancellationToken token)
{
var request = HttpContext.GetOpenIddictServerRequest();
if (request == default)
return BadRequest();
var rootId = await SignedInRoot();
var forceLogin = string.Equals((string)request["force_login"], "true", StringComparison.OrdinalIgnoreCase)
&& Request.Query["signed_in"] != "1";
if (rootId == default || forceLogin)
{
var query = string.Join("&", Request.Query.Where(q => q.Key != "signed_in").Select(q => $"{Uri.EscapeDataString(q.Key)}={Uri.EscapeDataString(q.Value.ToString())}"));
return Redirect($"/oauth/login?returnUrl={Uri.EscapeDataString("/oauth/authorize?" + query)}");
}
var application = await _applications.FindByClientIdAsync(request.ClientId, token);
ApplicationName = application == default ? "an application" : await _applications.GetDisplayNameAsync(application, token) ?? "an application";
RequestedScopes = MastodonScopes.Parse(request.Scope);
var avatarIds = (await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList();
var avatars = new List<LocalActor>();
foreach (var id in avatarIds)
if (await _localActors.FindById(LocalActorKind.Person, id, token) is { } avatar)
avatars.Add(avatar);
Avatars = avatars;
Parameters = request.GetParameters()
.Where(p => p.Key is not ("avatarId" or "decision" or "signed_in"))
.Select(p => new KeyValuePair<string, string>(p.Key, (string)p.Value))
.ToList();
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'";
Response.Headers["Cache-Control"] = "no-store";
return Page();
}
async Task<string> SignedInRoot()
{
var login = await HttpContext.AuthenticateAsync(OAuthSetup.LoginScheme);
var rootId = login.Succeeded ? login.Principal.FindFirstValue(ClaimTypes.NameIdentifier) : default;
if (rootId == default)
return default;
var root = await _dbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync();
return root is { IsBanned: false, DeletedAt: null } ? rootId : default;
}
}
}