Authentication
Flo supports several authentication flows. All result in a cookie-based session (FloAuth, HttpOnly, Secure, SameSite=Lax).
Auth Flows
1. Password-Based Login
Traditional email/password authentication with brute force protection.
- Lockout: 5 failed attempts triggers a 15-minute lockout
- Password hashing: BCrypt
- Session: Cookie set on successful login
Flow:
User → POST /api/v1/auth/login { email, password }
→ Server validates credentials
→ Server checks lockout counter
→ Cookie set → Redirect to dashboard
Accounts without a password (OTP/social) are rejected before the BCrypt check. Every login also passes LoginAccessPolicy: the account must be enabled, enable_login must be ON for non-privileged users, and AdminOnlyLogin blocks non-admin/pro accounts.
2. OTP Email Login
Passwordless login via a 6-digit code sent by email.
- Code length: 6 digits
- Expiry: 30 minutes
- Max attempts: 3 per code
- Cooldown: 30 seconds between OTP requests (per email; configurable via
Otp:MinResendIntervalSeconds, never below 30 seconds in production)
Flow:
User → POST /api/v1/public/auth/otp/request { email, mode }
→ Server generates 6-digit code and returns a request id
→ Email sent with code
→ User enters code
→ POST /api/v1/public/auth/otp/validate { email, code, requestId }
→ Cookie set → Redirect to dashboard
mode=register creates the account and is gated by enable_registration; login-mode requests are anti-enumerated (an ineligible address gets a decoy response). /api/v1/public/auth/otp/resend re-sends within the cooldown policy.
3. OIDC/OAuth2 (OpenIddict)
Full OAuth2 server for external clients (e.g., blog CMS, third-party apps).
- Grant type: Authorization Code with PKCE
- Refresh tokens: Supported
- Scopes:
openid,profile,email,roles - Certificate-based: Signing and encryption certs configured per tenant
Flow:
Client → GET /connect/authorize (with PKCE challenge)
→ User authenticates (password or OTP)
→ Authorization code returned
→ POST /connect/token (exchange code for tokens)
→ Access token + Refresh token
4. Social Login (Google / Apple)
Anonymous endpoints under /api/v1/external-auth:
| Endpoint | Payload |
|---|---|
GET /config | Public provider config (which buttons render, Client IDs) |
POST /google | { credential, nonce? } — Google ID token |
POST /apple | { idToken, nonce?, firstName?, lastName? } |
- A provider is exposed only when its feature flag (
enable_google_login/enable_apple_login) is ON and a Client ID is configured; otherwise the login screen does not render the button. - Settings are managed by SuperAdmin in Settings > provider access (
impostazioni/login-providers) and stored in theExternalAuthSettingstable (Client ID + Apple redirect URI). - The ID token is verified against the provider's JWKS with the stored Client ID as audience (
OidcTokenVerifier). - User resolution: returning identity by
(provider, sub); auto-link when the provider-verified email matches an existing account; otherwise a new password-less account is created only whenenable_registrationis ON and the email is verified. - Social sessions honour the same login kill-switches as password login (
LoginAccessPolicy) and are issued throughAuthCookieHelper, so the cookie and claims are identical.
5. Registration & Email Confirmation
POST /api/v1/auth/register requires enable_login and enable_registration ON. Registration creates the user with IsEnabled = false and sends a confirmation email (skipped when no email provider is configured); no session is issued.
| Endpoint | Purpose |
|---|---|
POST /api/v1/auth/enable-user { email, token } | Confirms the email and enables the account |
POST /api/v1/auth/set-password { email, token, newPassword } | Sets the password for invited accounts and enables them |
POST /api/v1/auth/resend-confirmation | Admin-gated re-send of the confirmation email |
POST /api/v1/auth/resend-password-creation | Admin-gated re-send of the password-creation email |
POST /api/v1/auth/forgot-password / POST /api/v1/auth/reset-password | Password recovery |
6. Forced First-Access Password Change
The seeded SuperAdmin operator accounts start with known default passwords. LoginAccessMiddleware keeps such a session out of every endpoint with 403 errors.auth.forcedPasswordChangeRequired, except the allowlist marked with [AllowWhenDefaultPassword]:
GET /api/v1/auth/verify-session,POST /api/v1/auth/logoutGET /api/v1/users/me/password-status,PUT /api/v1/users/me/preferred-content-languagePOST /api/v1/users/{id}/change-passwordandGET /api/v1/users/{id}— only when{id}is the caller's own id
The verdict is cached (30 seconds) by DefaultPasswordGate; the cache key includes SessionsInvalidatedAt, so a password write invalidates it automatically. On the frontend, DefaultPasswordGuard redirects dashboard navigation to /auth/forced-password-change, and the login flow skips dashboard data loads while the gate is active. The change uses POST /api/v1/users/{id}/change-password with the caller's own id; the write revokes the session and sends the user back to the login. The gate has no feature flag and is always on (spec docs/specs/2026-09-22-forced-password-change.md).
Session Management
All auth flows result in the same cookie:
| Property | Value |
|---|---|
| Name | FloAuth (per-tenant COOKIE_NAME=FloAuth-<slug> override) |
| HttpOnly | true |
| Secure | true (production; same-as-request in development) |
| SameSite | Lax |
| Expiry | 30 days, sliding |
| Domain | Configurable via COOKIE_DOMAIN env var (dot-prefixed in production) |
Cookie sessions carry an issued-at claim (flo_auth_issued_at) and are capped by an absolute lifetime of 7 days, independent of the sliding expiry. The frontend restores the session on startup by calling GET /api/v1/auth/verify-session and then GET /api/v1/users/me/password-status.
Session Invalidation
| Scope | Mechanism | Exposed via |
|---|---|---|
| One user | AuthSessionService.InvalidateUserAsync stamps User.SessionsInvalidatedAt and revokes that user's OpenIddict tokens | Password change/reset (same transaction), logout |
| All users | InvalidateAllAsync stamps the singleton AuthSessionState.InvalidatedAt and revokes all OpenIddict tokens | POST /api/v1/admin/invalidate-sessions (Admin; also signs out the caller), POST /api/v1/maintenance/auth/invalidate-sessions (maintenance key) |
LoginAccessMiddleware rejects a cookie session whose issued-at timestamp is older than the tenant or user invalidation timestamp. Legacy cookies without the claim are accepted and re-stamped once.
Role Claim Refresh
The role is baked into the session cookie as a bitmask at login. On every authenticated request LoginAccessMiddleware compares the privilege bits (Pro | Admin | SuperAdmin) in the database with the cookie: when they differ (a grant or a revocation), the cookie is re-issued with the current claims before the request reaches the controllers. A re-issue reuses the original ticket properties, so the 30-day expiry is untouched. OTP/OIDC bearer tokens are not affected — they do not use the cookie (spec docs/specs/2026-09-17-refresh-role-claims.md).
Claims
Different auth methods use different claim types for the user ID:
| Auth Method | Claim Type |
|---|---|
| Cookie (password) | ClaimTypes.NameIdentifier |
| OTP / OpenIddict | Claims.Subject |
Backend services handle both claim types when resolving the current user.
Cookie sessions also carry the role bitmask in ClaimTypes.Role and the is_admin / is_super_admin / is_pro flags. AuthCookieHelper.BuildSessionClaims is the single source used by password login, social login and the per-request role refresh.
Brute-Force Protection
- Failed password attempts are recorded per email in the
LoginAttempttable; 5 failures lock the account for 15 minutes. A locked attempt returns too-many-attempts with the remaining minutes; a successful login clears the counter, and the counter restarts after the window elapses. - Unknown emails still pay a BCrypt verify (dummy hash) so the response time does not reveal account existence.
- Password-less accounts never write a lockout row: they are rejected before the password check.
- Auth events are recorded for login successes and failures (
AuthEventService), with emails masked.
Key Files
| File | Purpose |
|---|---|
Flo.BE/Services/AuthService.cs | Password login, registration, email confirmation, brute force protection |
Flo.BE/Services/OtpAuthService.cs | OTP code generation and verification |
Flo.BE/Services/ExternalAuthService.cs | Google/Apple sign-in and account linking |
Flo.BE/Services/ExternalAuthSettingsService.cs | Provider Client IDs and public config |
Flo.BE/Services/AuthSessionService.cs | Session invalidation and lifetime checks |
Flo.BE/Services/DefaultPasswordGate.cs | Forced-password-change verdict cache |
Flo.BE/Middlewares/LoginAccessMiddleware.cs | Per-request session, role and forced-password checks |
Flo.BE/Helpers/AuthCookieHelper.cs | Single source of session cookie claims |
Flo.BE/Controllers/AuthController.cs | Login, register, confirmation, password reset |
Flo.BE/Controllers/ExternalAuthController.cs | Social sign-in endpoints |
Flo.BE/Controllers/OAuthController.cs | OIDC authorization and token endpoints |
Flo.FE/src/app/guards/auth.guard.ts | Frontend route protection |
Flo.FE/src/app/guards/default-password.guard.ts | Forced password change redirect |
Flo.FE/src/app/services/global.store.ts | Session restore on app init |