JWTs were previously fully stateless -- the token alone was the credential
until its own expiry, with no way to list or kill a session server-side. Add
a UserSession table alongside every JWT issued (AppSessionIssuer), embed its
id as a "sid" claim, and check that claim against the DB on every "local"
scheme request (Program.cs OnTokenValidated) so a session can actually be
revoked before its JWT naturally expires. New /api/auth/sessions endpoints
(list, revoke one, revoke-others) plus a Sessions card on the profile page.
Fails closed on a missing "sid" claim: every JWT issued going forward has
one, so a token without it is either pre-deploy (forces one re-login for
already-signed-in users at deploy time, same additive-forward cost the
2FA/trusted-device work on this branch already paid) or forged.
Adds a "trust this device" option to the 2FA challenge: on success, mints a
random token (only its SHA-256 hash is stored), sets it as a new httpOnly,
Secure, SameSite=Strict cookie, and records a TrustedDevice row. AuthController
checks that cookie for the exact signing-in user before gating on 2FA -- a
mismatched user, expired, or revoked device falls through to the normal 2FA
prompt, never errors. TwoFactorController also exposes list/revoke/revoke-all
endpoints for managing trusted devices, scoped to the owning user.
Schema added via the existing raw-SQL reconciler (SQLite + MySQL dialects),
not EF migrations, matching this repo's established pattern.
Adds three layers of account-security hardening, all gated behind the
existing SignInWithAppSessionAsync-equivalent (now AppSessionIssuer) so
every sign-in path -- local, Google, Microsoft -- goes through the same
lockout/2FA checks:
- Per-account lockout: Identity's built-in lockout store (columns already
provisioned, previously unused) is now wired up in AuthController.Login
via IsLockedOutAsync/AccessFailedAsync/ResetAccessFailedCountAsync, 5
failed attempts / 15 min, same generic 401 as wrong-password to avoid
enumeration.
- RFC 6238 TOTP 2FA (Otp.NET) with QR-code setup (QRCoder, fully local/
offline) on a new TwoFactorController: setup requires password
re-confirmation and returns a pending (unconfirmed) secret + QR; the
secret is only persisted as active once verify-setup checks a real
code. Secrets are encrypted at rest via the same IDataProtector pattern
already used for Gmail/Microsoft OAuth refresh tokens.
- Login/OAuth exchange now checks TwoFactorEnabled before issuing a real
session. If enabled, it hands back an opaque, server-side (IMemoryCache)
pending token via a new ITwoFactorPendingTokenService -- deliberately
NOT a JWT, so it can never be presented as a bearer token to bypass the
2FA check on any other endpoint. Only POST /api/auth/2fa/challenge can
redeem it, rate-limited at 5/5min (tighter than password login, since a
6-digit space is far more brute-forceable).
- One-time recovery codes (10 per enable/regenerate, SHA-256-hashed at
rest, shown once in plaintext) accepted in the same challenge endpoint
as an alternative to a TOTP code.
Schema: AspNetUsers gains TotpSecretEncrypted / TotpPendingSecretEncrypted
/ TotpEnabledAtUtc, plus a new TwoFactorRecoveryCodes table, added to both
the SQLite and MySQL dialect blocks in the startup schema reconciler.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
b3 of the multi-provider email roadmap. Adds ImapConnection model + table
(reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client),
ImapProvider implementing the existing IEmailProvider contract unchanged,
and ImapController for credential-based connect (no OAuth — user supplies
host/username/password directly, verified by a live connect before storage).
Scope, documented inline with ponytail: comments:
- INBOX only, no multi-folder support.
- Thread grouping approximates the References/In-Reply-To chain root rather
than the IMAP THREAD extension, which not every server implements.
- External message ids are IMAP UIDs, scoped to the connection's current
UIDVALIDITY.
Security: ran the security-audit skill against this diff (credential
handling + arbitrary-host connect is exactly the class of change the
standing security gate exists for). Found and fixed a real SSRF: the
connect endpoint let an authenticated user point the server at an
arbitrary host:port with no internal-range check, and connect-vs-auth
failure was distinguishable to the caller -- together a working oracle to
fingerprint internal services (loopback/RFC1918/link-local/cloud metadata)
from the server's network position. Fixed with EnsureHostIsExternalAsync
(DNS-resolve + reject internal ranges, re-checked on every reconnect to
close the DNS-rebinding gap) and a single generic failure message that no
longer distinguishes connect vs auth failure. 7 regression tests added.
Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj --
stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing)
would be a large, security-sensitive protocol implementation nobody asked
for, so this is the correct dependency, not a stdlib substitute.
168/168 green (161 existing + 7 new SSRF regression tests; the earlier
14 IMAP feature tests are included in the 161).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b2 of the multi-provider email roadmap. Mirrors the Gmail provider's shape
end-to-end so the two stay structurally interchangeable:
- MicrosoftGraphConnection model + table (reconciler pattern, SQLite+MySQL,
same shape as GmailConnection: encrypted refresh/access token, sync state).
- MicrosoftGraphOAuthService: auth-code + offline-access flow against
login.microsoftonline.com, encrypted token storage via IDataProtector,
message search/thread/detail fetch against Microsoft Graph (conversationId
stands in for Gmail's threadId), attachment listing.
- MicrosoftGraphProvider implements IEmailProvider — no contract changes;
the existing seam was already provider-neutral.
- MicrosoftGraphController: connect-url/oauth/callback/status/disconnect,
mirrors GmailController's OAuth surface exactly (including the popup
postMessage handshake). Job-matching/review endpoints stay Gmail-only for
now, per the roadmap — generalising those needs the frontend provider
picker work, not this slice.
- Registered in DI + IEmailProviderRegistry (multi-registration of
IEmailProvider, resolved by ProviderKey).
- Config: Microsoft:ClientId/ClientSecret/TenantId/RedirectUri, wired through
docker-compose.yml + .env.example alongside the existing Google:Gmail* keys.
- Tests: MicrosoftGraphControllerTests (OAuth lifecycle) +
MicrosoftGraphProviderTests (DTO mapping onto the neutral contract).
147/147 green (135 existing + 12 new).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add composite indexes (OwnerUserId, IsDeleted) — for the tenant-scoped
list/board/stats/analytics queries that all filter !IsDeleted — and
(OwnerUserId, FollowUpAt) for the reminders surface. Every JobApplication
query is scoped by the OwnerUserId global filter first, so owner-prefixed
composites are the useful shape; the pre-existing single OwnerUserId index
is now a redundant prefix but kept to avoid churn.
Status is intentionally excluded: Pomelo maps the unbounded string column to
MariaDB longtext, which cannot be indexed without a prefix length.
Applied via the startup schema reconciler (StartupInitializationExtensions),
which is how this repo actually provisions schema/indexes on both providers
(SQLite: CREATE INDEX IF NOT EXISTS; MariaDB: MySqlIndexExists-guarded CREATE
INDEX) — NOT via EF migrations, whose committed ModelSnapshot is stale.
OnModelCreating also declares the indexes for model consistency.
Backend suite: 92/92 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>