Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Applied at startup by `ApplyMigrations()` in `Program.cs`.

## Auth

- Sign-in is built in via Toamaisutaa (`Toamaisutaa.*` 0.14.0). No external IdP. Tables live in the Schuly DB (migration `AddToamaisutaa`).
- Sign-in is built in via Toamaisutaa (`Toamaisutaa.*` 0.15.0). No external IdP. Tables live in the Schuly DB (migration `AddToamaisutaa`).
- Endpoints under `/auth` (register, login, refresh, logout, password, email, `2fa/*`, devices, `external/*` for optional OIDC single sign-on). `/api/*` takes `Authorization: Bearer`. Access tokens live 15 min; refresh tokens rotate on every use.
- Web app sends `X-Schuly-Client: web` and gets refresh + device tokens as `HttpOnly` `SameSite=Strict` cookies on `/auth`; other clients get them in the JSON body.
- Required secrets: `LocalLogin:SigningKey`, `TwoFactor:EncryptionKey` (base64, 32 bytes, keep stable). Set via user-secrets or env vars.
Expand Down
3 changes: 3 additions & 0 deletions src/Schuly.API/Extensions/AuthenticationExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Schuly.Infrastructure;
using Schuly.Infrastructure.Security;
using Schuly.Infrastructure.Services;
Expand All @@ -26,6 +27,7 @@ public static IServiceCollection AddSchulyAuthentication(this IServiceCollection
services.AddToamaisutaaPasswordLogin(configuration);
services.AddToamaisutaaTwoFactor(configuration);
services.AddToamaisutaaTrustedDevices(configuration);
VerifiedSignUpEmail.Apply(configuration);
services.AddToamaisutaaExternalLogin(configuration);
services.PostConfigure<ToamaisutaaExternalLoginOptions>(options =>
{
Expand All @@ -38,6 +40,7 @@ public static IServiceCollection AddSchulyAuthentication(this IServiceCollection
services.AddToamaisutaaAuthenticationEventSink<SecurityAuditSink>();
services.AddHostedService<SecurityAuditCleanupService>();
services.AddScoped<AccountPasswordCheck>();
services.TryAddSingleton(TimeProvider.System);

return services;
}
Expand Down
6 changes: 3 additions & 3 deletions src/Schuly.API/Schuly.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="3.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="Scalar.AspNetCore" Version="2.18.*" />
<PackageReference Include="Toamaisutaa.AspNetCore" Version="0.14.0" />
<PackageReference Include="Toamaisutaa.Email.Smtp" Version="0.14.0" />
<PackageReference Include="Toamaisutaa.OpenIdConnect" Version="0.14.0" />
<PackageReference Include="Toamaisutaa.AspNetCore" Version="0.15.0" />
<PackageReference Include="Toamaisutaa.Email.Smtp" Version="0.15.0" />
<PackageReference Include="Toamaisutaa.OpenIdConnect" Version="0.15.0" />
</ItemGroup>

<ItemGroup>
Expand Down
35 changes: 29 additions & 6 deletions src/Schuly.API/Security/AccountPasswordCheck.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
namespace Schuly.API.Security
{
/// <summary>
/// Asks for the password again before an account is deleted, so a stolen access token alone can't delete it. An account signed in only through single sign-on has no
/// password to ask for and passes. Attempts are capped by the endpoint's rate limit; the library's own attempt counter isn't reachable from outside it.
/// Asks for proof before an account is deleted, so a stolen access token alone can't delete it. An account with a password proves it with the password. One signed in
/// only through single sign-on proves it with a sign-in within <c>ExternalLogin:LinkProofWindow</c>, read off the session's start the way Toamaisutaa reads it for
/// linking a provider: a token refresh doesn't move it. Attempts are capped by the endpoint's rate limit; the library's own attempt counter isn't reachable from outside it.
/// </summary>
public sealed class AccountPasswordCheck(IPasswordCredentialStore credentials, IPasswordHasher hasher, IOptions<ToamaisutaaLocalLoginOptions> options, SecurityAuditLog audit)
public sealed class AccountPasswordCheck(IPasswordCredentialStore credentials, IPasswordHasher hasher, IRefreshTokenStore refreshTokens, IOptions<ToamaisutaaLocalLoginOptions> options, IOptions<ToamaisutaaExternalLoginOptions> externalOptions, SecurityAuditLog audit, TimeProvider time)
{
public const string RefusedKind = "account-deletion-refused";

Expand All @@ -22,19 +23,41 @@ public sealed class AccountPasswordCheck(IPasswordCredentialStore credentials, I

var credential = await credentials.FindByUserIdAsync(loginId, cancellationToken);
if (credential is null)
return null;
{
if (await SignedInRecentlyAsync(user, cancellationToken))
return null;

await audit.WriteAsync(RefusedKind, loginId, null, time.GetUtcNow(), cancellationToken);
return "To delete your account, sign in again first, then try while that sign-in is fresh.";
}

if (string.IsNullOrEmpty(password))
return "Enter your password to delete your account. If your app doesn't ask for it, update the app.";

if (credential.LockedOutUntil is { } until && until > DateTimeOffset.UtcNow)
if (credential.LockedOutUntil is { } until && until > time.GetUtcNow())
return "Too many wrong passwords. Try again later.";

if (password.Length <= options.Value.MaximumPasswordLength && hasher.Verify(password, credential.PasswordHash) != PasswordVerificationResult.Failed)
return null;

await audit.WriteAsync(RefusedKind, loginId, null, DateTimeOffset.UtcNow, cancellationToken);
await audit.WriteAsync(RefusedKind, loginId, null, time.GetUtcNow(), cancellationToken);
return "Your password is not correct.";
}

private async Task<bool> SignedInRecentlyAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
{
DateTimeOffset? at = null;
if (Guid.TryParse(user.FindFirst(ToamaisutaaDefaults.SessionIdClaim)?.Value, out var family) && await refreshTokens.FindLiveByFamilyAsync(family, cancellationToken) is { } live)
at = live.FamilyStartedAt;

foreach (var type in new[] { ToamaisutaaDefaults.SecondFactorAtClaim, "auth_time" })
{
if (long.TryParse(user.FindFirst(type)?.Value, out var seconds) && DateTimeOffset.FromUnixTimeSeconds(seconds) is var claimed && (at is null || claimed > at))
at = claimed;
}

var now = time.GetUtcNow();
return at is { } signedIn && signedIn <= now && now - signedIn <= externalOptions.Value.LinkProofWindow;
}
}
}
24 changes: 24 additions & 0 deletions src/Schuly.API/SignUp/VerifiedSignUpEmail.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Schuly.API.SignUp
{
/// <summary>
/// A Schuly account has to have a confirmed email before it can be used. An account made by signing up through a provider has no local address to confirm with a code, so
/// the provider's <c>email_verified</c> is the proof: every configured provider gets <c>RequireVerifiedEmailForSignUp</c> unless the hoster set it. Set through
/// configuration rather than the options type, so this compiles against a Toamaisutaa that predates the option and simply does nothing there.
/// </summary>
public static class VerifiedSignUpEmail
{
public const string Setting = "RequireVerifiedEmailForSignUp";

/// <summary>The keys to add: one per provider that doesn't say for itself.</summary>
public static Dictionary<string, string?> Defaults(IConfiguration configuration) =>
configuration.GetSection("ExternalLogin:Providers").GetChildren()
.Where(provider => provider[Setting] is null)
.ToDictionary(provider => $"{provider.Path}:{Setting}", _ => (string?)"true");

public static void Apply(IConfiguration configuration)
{
if (configuration is IConfigurationBuilder builder && Defaults(configuration) is { Count: > 0 } defaults)
builder.AddInMemoryCollection(defaults);
}
}
}
4 changes: 4 additions & 0 deletions src/Schuly.Application/Dtos/ApplicationUserDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ public class ApplicationUserDto
public required string Email { get; set; }
public string? DisplayName { get; set; }
public string? ProfilePictureUrl { get; set; }
/// <summary>False for an account signed in only through single sign-on, which can set a first password after a fresh sign-in.</summary>
public bool HasPassword { get; set; }
/// <summary>True when <see cref="Email"/> is the sign-in address and somebody proved it.</summary>
public bool EmailConfirmed { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public List<SchoolUserSummaryDto> SchoolUsers { get; set; } = new List<SchoolUserSummaryDto>();
Expand Down
9 changes: 8 additions & 1 deletion src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
using Schuly.Infrastructure;
using Schuly.Infrastructure.Services;
using Schuly.Application.Authorization;
using Toamaisutaa.Abstractions;

namespace Schuly.Application.Queries.User
{
[AllowAuthenticated]
public record GetCurrentUserQuery() : IQuery<Result<ApplicationUserDto>>;

public class GetCurrentUserQueryHandler(IUserService userService, SchulyDbContext dbContext, IAvatarUrlSigner avatarUrlSigner) : IQueryHandler<GetCurrentUserQuery, Result<ApplicationUserDto>>
public class GetCurrentUserQueryHandler(IUserService userService, SchulyDbContext dbContext, IAvatarUrlSigner avatarUrlSigner, IPasswordCredentialStore credentials, IExternalLoginStore externalLogins) : IQueryHandler<GetCurrentUserQuery, Result<ApplicationUserDto>>
{
public async ValueTask<Result<ApplicationUserDto>> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken)
{
Expand All @@ -27,6 +28,12 @@ public async ValueTask<Result<ApplicationUserDto>> Handle(GetCurrentUserQuery qu

var dto = user.ToDto();
dto.ProfilePictureUrl = avatarUrlSigner.ToPublicUserUrl(user.Id, user.ProfilePictureUrl);
var hasLogin = Guid.TryParse(user.ExternalId, out var loginId);
var credential = hasLogin ? await credentials.FindByUserIdAsync(loginId, cancellationToken) : null;
dto.HasPassword = credential is not null;
// Confirmed here by code, or by a provider that said it verified this address.
dto.EmailConfirmed = (credential?.EmailConfirmedAt is not null && string.Equals(credential.Email, user.Email, StringComparison.OrdinalIgnoreCase))
|| (hasLogin && (await externalLogins.ListAsync(loginId, cancellationToken)).Any(login => login.EmailVerified && string.Equals(login.Email, user.Email, StringComparison.OrdinalIgnoreCase)));
return Result<ApplicationUserDto>.Success(dto);
}
}
Expand Down
Loading
Loading