From 10cdd3f638ea86214125e97744dedcfeee453fe7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 5 Feb 2026 14:29:39 +0100 Subject: [PATCH] docs(06): research backend authentication domain Phase 6: Backend Authentication - Standard stack: Password4j (Argon2id), jwt-scala, java-totp - Architecture: JWT + DB hybrid sessions, progressive lockout, TOTP 2FA - RBAC: WinterCMS-style plugin-registered permissions - Security: OWASP-compliant password storage, session management, reset tokens --- .../06-backend-authentication/06-RESEARCH.md | 692 ++++++++++++++++++ 1 file changed, 692 insertions(+) create mode 100644 .planning/phases/06-backend-authentication/06-RESEARCH.md diff --git a/.planning/phases/06-backend-authentication/06-RESEARCH.md b/.planning/phases/06-backend-authentication/06-RESEARCH.md new file mode 100644 index 0000000..c51dd87 --- /dev/null +++ b/.planning/phases/06-backend-authentication/06-RESEARCH.md @@ -0,0 +1,692 @@ +# Phase 6: Backend Authentication - Research + +**Researched:** 2026-02-05 +**Domain:** Backend admin authentication with password hashing, sessions, TOTP 2FA, and RBAC permissions +**Confidence:** HIGH + +## Summary + +This phase implements secure authentication for the SummerCMS admin backend. The research covers password hashing (Argon2id recommended, BCrypt as fallback), session management (database-backed for revocation support), TOTP 2FA, password reset flows, progressive lockout, and WinterCMS-style plugin-registered permissions with flat RBAC. + +The recommended approach uses Password4j for Argon2id hashing (OWASP primary recommendation), JWT for stateless session tokens with database-backed revocation tracking, java-totp for TOTP 2FA with Google Authenticator compatibility, and a custom permission registry that aggregates plugin-registered permissions at boot time. Sessions are stored in the database to support the "view and revoke sessions" requirement, with JWT for efficient validation without database lookup on every request. + +**Primary recommendation:** Use Argon2id via Password4j (OWASP #1 choice), database-backed sessions with JWT tokens for scalability, java-totp for 2FA, and a plugin-based permission registry matching WinterCMS patterns. + +## Standard Stack + +The established libraries/tools for this domain: + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Password4j | 1.8.4 | Password hashing | Supports Argon2id (OWASP #1), BCrypt, scrypt; JVM standard | +| jwt-scala | 11.0.3 | JWT tokens | Scala 3 native, ZIO JSON integration, actively maintained | +| java-totp | 1.7.1 | TOTP 2FA | QR code generation, Google Authenticator compatible | +| ZIO | 2.1.x | Effect system | Already in stack, Ref for state, Hub for events | +| Quill | 4.8.6 | Database access | Already in stack, compile-time SQL validation | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| jwt-zio-json | 11.0.3 | JWT + ZIO JSON | JSON codec for JWT claims | +| zxing | 3.5.x | QR codes | Transitive via java-totp for 2FA setup QR | +| JavaMail | 1.6.x | Email sending | Password reset emails (wrap in ZIO effects) | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Password4j | scala-bcrypt | scala-bcrypt lacks Scala 3 support and only does BCrypt | +| Password4j | jbcrypt | jbcrypt is BCrypt only, no Argon2id | +| jwt-scala | zio-jwt-validator | zio-jwt-validator only validates, doesn't encode | +| java-totp | kuro-otp | kuro-otp is Scala native but less maintained | +| Database sessions | Pure JWT | Pure JWT cannot be revoked without tracking | + +**Installation (Mill build.mill):** +```scala +def mvnDeps = Seq( + // Existing deps... + + // Password hashing + mvn"com.password4j:password4j:1.8.4", + + // JWT + mvn"com.github.jwt-scala::jwt-zio-json:11.0.3", + + // TOTP 2FA + mvn"dev.samstevens.totp:totp:1.7.1", + + // Email (for password reset) + mvn"com.sun.mail:jakarta.mail:2.0.1" +) +``` + +## Architecture Patterns + +### Recommended Project Structure +``` +src/ +├── auth/ +│ ├── AdminUser.scala # Admin user model +│ ├── AdminSession.scala # Session model (database) +│ ├── Role.scala # Role model with permissions +│ ├── Permission.scala # Permission definition +│ ├── PasswordService.scala # Argon2id hashing +│ ├── SessionService.scala # JWT + DB session management +│ ├── TotpService.scala # 2FA setup/verification +│ ├── LoginService.scala # Login logic with lockout +│ ├── PasswordResetService.scala # Reset token handling +│ └── PermissionRegistry.scala # Plugin permission aggregation +├── auth/middleware/ +│ ├── AuthMiddleware.scala # JWT validation middleware +│ └── PermissionMiddleware.scala # Permission checking +└── auth/routes/ + ├── LoginRoutes.scala # Login/logout endpoints + ├── PasswordResetRoutes.scala # Reset flow endpoints + └── SessionRoutes.scala # Session management endpoints +``` + +### Pattern 1: Argon2id Password Hashing with Password4j +**What:** Hash and verify passwords using Argon2id (OWASP primary recommendation) +**When to use:** All password storage and verification +**Example:** +```scala +// Source: Password4j docs + OWASP recommendations +import com.password4j.Password + +trait PasswordService: + def hash(password: String): Task[String] + def verify(password: String, hash: String): Task[Boolean] + +object PasswordService: + val live: ZLayer[Any, Nothing, PasswordService] = + ZLayer.succeed { + new PasswordService: + // OWASP recommended: m=19456 (19 MiB), t=2, p=1 + def hash(password: String): Task[String] = + ZIO.attemptBlocking { + Password.hash(password) + .addRandomSalt() + .withArgon2() // Uses Argon2id by default + .getResult + } + + def verify(password: String, hash: String): Task[Boolean] = + ZIO.attemptBlocking { + Password.check(password, hash).withArgon2() + } + } +``` + +### Pattern 2: Hybrid Session Management (JWT + Database) +**What:** JWT for efficient validation, database for revocation and session listing +**When to use:** All authenticated requests +**Example:** +```scala +// Source: jwt-scala docs + session requirements +import pdi.jwt.{JwtZIOJson, JwtAlgorithm, JwtClaim} +import zio.json.* + +case class SessionClaims( + userId: Long, + sessionId: String, // Links to DB session for revocation + role: String, + issuedAt: Instant, + expiresAt: Instant +) derives JsonCodec + +case class AdminSession( + id: String, + userId: Long, + createdAt: Instant, + expiresAt: Instant, + ipAddress: String, + userAgent: String, + revoked: Boolean = false +) + +trait SessionService: + def createSession(userId: Long, rememberMe: Boolean, ip: String, ua: String): Task[String] + def validateToken(token: String): Task[SessionClaims] + def revokeSession(sessionId: String): Task[Unit] + def listSessions(userId: Long): Task[List[AdminSession]] + def revokeAllSessions(userId: Long): Task[Unit] + +object SessionService: + val live: ZLayer[SessionRepository & Config, Nothing, SessionService] = + ZLayer.fromFunction { (repo: SessionRepository, config: Config) => + new SessionService: + private val algorithm = JwtAlgorithm.HS256 + + def createSession(userId: Long, rememberMe: Boolean, ip: String, ua: String): Task[String] = + for + sessionId <- ZIO.succeed(java.util.UUID.randomUUID().toString) + now <- Clock.instant + duration = if rememberMe then config.rememberMeDuration else config.sessionDuration + expiresAt = now.plus(duration) + session = AdminSession(sessionId, userId, now, expiresAt, ip, ua) + _ <- repo.create(session) + claims = SessionClaims(userId, sessionId, role, now, expiresAt) + token <- ZIO.attempt { + JwtZIOJson.encode(claims.toJson, config.jwtSecret, algorithm) + } + yield token + + def validateToken(token: String): Task[SessionClaims] = + for + claimsJson <- ZIO.fromTry { + JwtZIOJson.decode(token, config.jwtSecret, Seq(algorithm)) + }.mapError(e => AuthError.InvalidToken(e.getMessage)) + claims <- ZIO.fromEither(claimsJson.content.fromJson[SessionClaims]) + _ <- ZIO.fail(AuthError.TokenExpired).when(claims.expiresAt.isBefore(Instant.now)) + session <- repo.findById(claims.sessionId) + _ <- ZIO.fail(AuthError.SessionRevoked).when(session.revoked) + yield claims + } +``` + +### Pattern 3: Progressive Lockout with Exponential Backoff +**What:** Track failed login attempts and enforce progressive delays +**When to use:** Login attempts +**Example:** +```scala +// Source: CONTEXT.md + security best practices +case class LoginAttempt( + email: String, + ipAddress: String, + attemptedAt: Instant, + successful: Boolean +) + +trait LoginService: + def authenticate(email: String, password: String, totpCode: Option[String], ip: String): Task[AuthResult] + +object LoginService: + // Lockout thresholds (Claude's discretion per CONTEXT.md) + private val lockoutThresholds = List( + (5, 10.minutes), // After 5 failures: 10 min lockout + (10, 20.minutes), // After 10 failures: 20 min lockout + (15, 60.minutes), // After 15 failures: 1 hour lockout + (20, 24.hours) // After 20 failures: 24 hour lockout + ) + + def calculateLockout(failedAttempts: Int): Option[Duration] = + lockoutThresholds.reverse.find(_._1 <= failedAttempts).map(_._2) + + // Implementation checks: + // 1. Count recent failed attempts for email/IP + // 2. If locked out, return error with remaining time + // 3. Validate credentials + // 4. If 2FA enabled, validate TOTP code + // 5. Record attempt (success or failure) + // 6. On success, clear failed attempts for this email +``` + +### Pattern 4: TOTP 2FA with Recovery Codes +**What:** Optional TOTP 2FA with backup recovery codes +**When to use:** Admin users who enable 2FA +**Example:** +```scala +// Source: java-totp documentation +import dev.samstevens.totp.secret.DefaultSecretGenerator +import dev.samstevens.totp.code.{DefaultCodeGenerator, DefaultCodeVerifier} +import dev.samstevens.totp.time.SystemTimeProvider +import dev.samstevens.totp.qr.{QrData, ZxingPngQrGenerator} + +trait TotpService: + def generateSecret: Task[String] + def generateQrCodeUri(secret: String, email: String): Task[String] + def verifyCode(secret: String, code: String): Task[Boolean] + def generateRecoveryCodes: Task[List[String]] + +object TotpService: + val live: ZLayer[Any, Nothing, TotpService] = + ZLayer.succeed { + new TotpService: + private val secretGenerator = new DefaultSecretGenerator() + private val codeGenerator = new DefaultCodeGenerator() + private val codeVerifier = new DefaultCodeVerifier(codeGenerator, new SystemTimeProvider()) + private val qrGenerator = new ZxingPngQrGenerator() + + def generateSecret: Task[String] = + ZIO.attemptBlocking(secretGenerator.generate()) + + def generateQrCodeUri(secret: String, email: String): Task[String] = + ZIO.attemptBlocking { + val data = new QrData.Builder() + .label(email) + .secret(secret) + .issuer("SummerCMS Admin") + .algorithm(HashingAlgorithm.SHA1) + .digits(6) + .period(30) + .build() + + val imageData = qrGenerator.generate(data) + Utils.getDataUriForImage(imageData, qrGenerator.getImageMimeType) + } + + def verifyCode(secret: String, code: String): Task[Boolean] = + ZIO.attemptBlocking { + codeVerifier.isValidCode(secret, code) + } + + def generateRecoveryCodes: Task[List[String]] = + ZIO.attemptBlocking { + val generator = new RecoveryCodeGenerator() + generator.generateCodes(8).asScala.toList // 8 recovery codes + } + } +``` + +### Pattern 5: Password Reset with Secure Tokens +**What:** Time-limited, single-use reset tokens with secure storage +**When to use:** Password reset flow +**Example:** +```scala +// Source: OWASP Forgot Password Cheat Sheet + CONTEXT.md +import java.security.SecureRandom +import java.security.MessageDigest + +case class PasswordResetToken( + tokenHash: String, // SHA-256 hash of token (stored) + userId: Long, + createdAt: Instant, + expiresAt: Instant, + ipAddress: String, + used: Boolean = false +) + +trait PasswordResetService: + def requestReset(email: String, ip: String): Task[Unit] // Always returns success (timing attack prevention) + def validateToken(token: String): Task[Long] // Returns userId if valid + def resetPassword(token: String, newPassword: String): Task[Unit] + +object PasswordResetService: + // Token expiry: 30 minutes (Claude's discretion per CONTEXT.md) + // OWASP recommends 15-60 minutes; 30 is a good middle ground + private val tokenExpiry = 30.minutes + private val tokenLength = 64 // Characters (256 bits of entropy) + + def generateToken: Task[String] = + ZIO.attemptBlocking { + val random = new SecureRandom() + val bytes = new Array[Byte](48) // 48 bytes = 64 base64 chars + random.nextBytes(bytes) + java.util.Base64.getUrlEncoder.withoutPadding.encodeToString(bytes) + } + + def hashToken(token: String): String = + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(token.getBytes("UTF-8")) + hash.map("%02x".format(_)).mkString + + // Reset email includes (per CONTEXT.md): + // - IP address of requester + // - Approximate location (optional, via IP geolocation) + // - Request timestamp +``` + +### Pattern 6: Plugin-Registered Permission System +**What:** Plugins register their own permissions, roles aggregate permissions +**When to use:** All permission checks in controllers and views +**Example:** +```scala +// Source: WinterCMS pattern from golem15-wintercms-starter +case class PermissionDef( + code: String, // e.g., "golem15.blog.access_posts" + label: String, // Human-readable label + tab: String, // Grouping in admin UI + order: Int = 0 +) + +trait PermissionRegistry: + def register(pluginId: PluginId, permissions: List[PermissionDef]): UIO[Unit] + def getAllPermissions: UIO[List[PermissionDef]] + def getPermissionsForPlugin(pluginId: PluginId): UIO[List[PermissionDef]] + +object PermissionRegistry: + val live: ZLayer[Any, Nothing, PermissionRegistry] = + ZLayer.fromZIO { + Ref.make(Map.empty[PluginId, List[PermissionDef]]).map { registry => + new PermissionRegistry: + def register(pluginId: PluginId, permissions: List[PermissionDef]): UIO[Unit] = + registry.update(_ + (pluginId -> permissions)) + + def getAllPermissions: UIO[List[PermissionDef]] = + registry.get.map(_.values.flatten.toList.sortBy(p => (p.tab, p.order))) + + def getPermissionsForPlugin(pluginId: PluginId): UIO[List[PermissionDef]] = + registry.get.map(_.getOrElse(pluginId, Nil)) + } + } + +// Role model - flat, no inheritance (per CONTEXT.md) +case class Role( + id: Long, + code: String, // e.g., "super_admin", "editor" + name: String, + permissions: Set[String], // Permission codes granted + isSystem: Boolean = false +) + +// Permission checking +trait PermissionChecker: + def hasPermission(userId: Long, permission: String): Task[Boolean] + def hasAnyPermission(userId: Long, permissions: List[String]): Task[Boolean] + +// Wildcard matching: "golem15.blog.*" matches "golem15.blog.access_posts" +def matchesPermission(granted: String, required: String): Boolean = + if granted.endsWith(".*") then + required.startsWith(granted.dropRight(1)) + else + granted == required +``` + +### Pattern 7: Auth Middleware for ZIO HTTP +**What:** Middleware that validates JWT and injects user context +**When to use:** All protected routes +**Example:** +```scala +// Source: ZIO HTTP middleware pattern +import zio.http.* + +object AuthMiddleware: + def apply: HandlerAspect[SessionService, SessionClaims] = + HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => + for + token <- ZIO.fromOption( + request.header(Header.Authorization).collect { + case Header.Authorization.Bearer(token) => token + } + ).orElseFail(Response.unauthorized("Missing authorization header")) + + sessionService <- ZIO.service[SessionService] + claims <- sessionService.validateToken(token) + .mapError(_ => Response.unauthorized("Invalid or expired token")) + yield (request, claims) + }) + +object PermissionMiddleware: + def require(permission: String): HandlerAspect[PermissionChecker & SessionClaims, Unit] = + HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => + for + claims <- ZIO.service[SessionClaims] + checker <- ZIO.service[PermissionChecker] + allowed <- checker.hasPermission(claims.userId, permission) + _ <- ZIO.fail(Response.forbidden("Insufficient permissions")).unless(allowed) + yield (request, ()) + }) +``` + +### Anti-Patterns to Avoid +- **Storing plain-text passwords:** Always hash with Argon2id or BCrypt +- **Using MD5/SHA1 for passwords:** These are fast hashes, not password hashes +- **Predictable reset tokens:** Use cryptographically secure random generation +- **JWT without revocation:** Must track sessions in DB for revocation support +- **Timing attacks on login:** Always hash password even if user not found +- **Verbose error messages:** Never reveal whether email exists or which field is wrong +- **Session fixation:** Regenerate session ID after successful login + +## Don't Hand-Roll + +Problems that look simple but have existing solutions: + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Password hashing | Custom hash function | Password4j with Argon2id | Secure by default, tunable parameters, constant-time comparison | +| JWT encoding/decoding | Manual JSON + signing | jwt-scala | Proper algorithm handling, claim validation, timing-safe comparison | +| TOTP generation | Manual RFC 6238 | java-totp | Time drift handling, recovery codes, QR generation | +| Secure random tokens | Random.nextInt | SecureRandom | Cryptographically secure entropy source | +| Session ID generation | UUID.randomUUID alone | UUID + database + JWT | Needs revocation, listing, IP tracking | +| Permission matching | String equality | Wildcard pattern matching | Need `plugin.*` to match `plugin.action` | + +**Key insight:** Authentication and authorization are security-critical. Hand-rolled solutions inevitably miss edge cases around timing attacks, secure comparison, entropy, and token lifecycle. + +## Common Pitfalls + +### Pitfall 1: Timing Attacks on Login +**What goes wrong:** Login reveals whether email exists based on response time +**Why it happens:** Only hashing password when user exists (fast failure for unknown email) +**How to avoid:** Always perform password hash comparison, even for non-existent users (compare against dummy hash) +**Warning signs:** Different response times for existing vs non-existing emails + +### Pitfall 2: Insecure Password Reset Token Storage +**What goes wrong:** Attacker with database read access can use reset tokens +**Why it happens:** Storing tokens in plain text +**How to avoid:** Hash tokens with SHA-256 before storing, compare hashes +**Warning signs:** Reset tokens visible as plain strings in database + +### Pitfall 3: Session Fixation +**What goes wrong:** Attacker sets session ID before victim logs in, hijacks after login +**Why it happens:** Not regenerating session after authentication state change +**How to avoid:** Create new session on login, invalidate any pre-auth session +**Warning signs:** Session ID same before and after login + +### Pitfall 4: JWT Without Revocation +**What goes wrong:** Cannot log user out, cannot terminate sessions on password change +**Why it happens:** JWTs are self-contained and valid until expiry +**How to avoid:** Track session IDs in database, check revocation status on each request +**Warning signs:** "Logout" doesn't actually prevent access with old token + +### Pitfall 5: Insufficient Password Reset Token Entropy +**What goes wrong:** Attacker brute-forces reset tokens +**Why it happens:** Using short or predictable tokens (sequential IDs, timestamps) +**How to avoid:** Use 256+ bits of entropy from SecureRandom, URL-safe Base64 encoding +**Warning signs:** Tokens under 32 characters, tokens containing timestamps/IDs + +### Pitfall 6: Permission Check Bypass +**What goes wrong:** Some routes accessible without proper permission +**Why it happens:** Middleware applied inconsistently, forgetting to check in views +**How to avoid:** Centralize permission checking in middleware, fail-closed defaults +**Warning signs:** Protected actions accessible without role assignment + +## Code Examples + +Verified patterns from official sources: + +### Database Schema (Flyway Migration) +```sql +-- Source: Phase requirements + WinterCMS patterns +-- V6__backend_auth.sql + +CREATE TABLE admin_users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100), + last_name VARCHAR(100), + role_id BIGINT REFERENCES roles(id), + totp_secret VARCHAR(64), -- Null if 2FA not enabled + totp_enabled BOOLEAN DEFAULT false, + recovery_codes TEXT[], -- Hashed recovery codes + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ +); + +CREATE TABLE roles ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + permissions TEXT[] NOT NULL DEFAULT '{}', + is_system BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE admin_sessions ( + id VARCHAR(64) PRIMARY KEY, -- UUID + user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + ip_address INET, + user_agent TEXT, + revoked BOOLEAN DEFAULT false, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_admin_sessions_user_id ON admin_sessions(user_id); +CREATE INDEX idx_admin_sessions_expires_at ON admin_sessions(expires_at); + +CREATE TABLE password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex + user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + ip_address INET, + used BOOLEAN DEFAULT false, + used_at TIMESTAMPTZ +); + +CREATE TABLE login_attempts ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + ip_address INET NOT NULL, + attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + successful BOOLEAN NOT NULL +); + +CREATE INDEX idx_login_attempts_email ON login_attempts(email, attempted_at); +CREATE INDEX idx_login_attempts_ip ON login_attempts(ip_address, attempted_at); + +-- Insert default Super Admin role +INSERT INTO roles (code, name, permissions, is_system) +VALUES ('super_admin', 'Super Admin', '{"*"}', true); +``` + +### Cookie Configuration +```scala +// Source: OWASP Session Management Cheat Sheet +import zio.http.* + +object CookieConfig: + def sessionCookie(token: String, maxAge: Duration): Cookie = + Cookie.Response( + name = "__Host-SID", // __Host- prefix for additional security + content = token, + maxAge = Some(maxAge), + path = Some(Path.root / "admin"), + isSecure = true, // HTTPS only + isHttpOnly = true, // No JavaScript access + sameSite = Some(Cookie.SameSite.Strict) // No cross-site requests + ) +``` + +### Login Endpoint +```scala +// Source: ZIO HTTP + CONTEXT.md requirements +import zio.http.* +import zio.json.* + +case class LoginRequest( + email: String, + password: String, + totpCode: Option[String], + rememberMe: Boolean = false +) derives JsonCodec + +case class LoginResponse( + token: String, + user: AdminUserSummary +) derives JsonCodec + +val loginRoute = Routes( + Method.POST / "admin" / "auth" / "login" -> handler { (req: Request) => + for + body <- req.body.asString + login <- ZIO.fromEither(body.fromJson[LoginRequest]) + .mapError(_ => Response.badRequest("Invalid request body")) + ip = req.remoteAddress.map(_.toString).getOrElse("unknown") + service <- ZIO.service[LoginService] + result <- service.authenticate(login.email, login.password, login.totpCode, ip) + .mapError { + case AuthError.InvalidCredentials => + // Generic message per CONTEXT.md + Response.unauthorized("Invalid email or password") + case AuthError.AccountLocked(until) => + Response.status(Status.TooManyRequests) + .addHeader(Header.RetryAfter.Delta(until)) + case AuthError.TotpRequired => + Response.json("""{"requiresTotp": true}""").status(Status.Unauthorized) + } + sessionService <- ZIO.service[SessionService] + token <- sessionService.createSession(result.user.id, login.rememberMe, ip, req.header(Header.UserAgent).map(_.renderedValue).getOrElse("")) + response = LoginResponse(token, result.user.toSummary) + yield Response.json(response.toJson) + .addCookie(CookieConfig.sessionCookie(token, + if login.rememberMe then 30.days else 24.hours + )) + } +) +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| BCrypt only | Argon2id primary | OWASP 2022+ | Better GPU resistance | +| MD5/SHA1 for passwords | Never use | Always | Not suitable for passwords | +| Session cookies only | JWT + DB hybrid | 2020+ | Scalability + revocation | +| 6-digit SMS OTP | TOTP app-based | 2019+ | SIM swap resistance | +| Role inheritance | Flat roles | Depends on needs | Simpler to reason about | +| Global session timeout | Per-session expiry | Current | "Remember me" flexibility | + +**Deprecated/outdated:** +- BCrypt work factor 10: Now recommend 12-14 minimum (2026) +- Plain session cookies without SameSite: Always use SameSite=Strict/Lax +- JWT RS256 with 1024-bit keys: Use RS256 with 2048+ bits or HS256 + +## Open Questions + +Things that couldn't be fully resolved: + +1. **Email Sending Library** + - What we know: zio-email exists but unmaintained (last update ~2019), Emil is Cats-based not ZIO + - What's unclear: Best ZIO-native approach for email + - Recommendation: Wrap JavaMail in ZIO effects directly, simple and reliable + +2. **IP Geolocation for Reset Emails** + - What we know: CONTEXT.md mentions including location in reset emails + - What's unclear: Which IP geolocation service to use + - Recommendation: Consider MaxMind GeoIP2 Lite (free) or skip location initially + +3. **TOTP Time Drift** + - What we know: java-totp has configurable time period discrepancy + - What's unclear: What discrepancy to allow (default is 1 period = 30 seconds each side) + - Recommendation: Use default (1 period), allow 90 second window total + +4. **Session Cleanup** + - What we know: Sessions expire but remain in database + - What's unclear: Cleanup frequency + - Recommendation: Scheduled job daily to delete expired sessions older than 7 days + +## Sources + +### Primary (HIGH confidence) +- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - Argon2id parameters, algorithm priority +- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) - Cookie settings, timeout values +- [OWASP Forgot Password Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html) - Reset token best practices +- [Password4j GitHub](https://github.com/Password4j/password4j) - v1.8.4, API examples +- [jwt-scala Documentation](https://jwt-scala.github.io/jwt-scala/jwt-zio-json.html) - v11.0.3, ZIO JSON integration +- [java-totp GitHub](https://github.com/samdjstevens/java-totp) - v1.7.1, TOTP API + +### Secondary (MEDIUM confidence) +- [WinterCMS Backend User Model](golem15-wintercms-starter/modules/backend/models/User.php) - Permission patterns +- [WinterCMS UserRole Model](golem15-wintercms-starter/modules/backend/models/UserRole.php) - Role structure +- [Stytch JWT vs Sessions Guide](https://stytch.com/blog/jwts-vs-sessions-which-is-right-for-you/) - Hybrid approach rationale +- [Authgear Account Lockout](https://docs.authgear.com/reference/rate-limits/account-lockout) - Progressive lockout patterns + +### Tertiary (LOW confidence) +- [zio-email GitHub](https://github.com/funcit/zio-email) - Unmaintained, pattern reference only +- Various Medium articles on Argon2 vs BCrypt - Validated against OWASP + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - All libraries verified via official docs, OWASP recommendations +- Architecture patterns: HIGH - Based on OWASP cheat sheets and established ZIO patterns +- Pitfalls: HIGH - OWASP security guidance, well-documented attack vectors +- Permission system: MEDIUM - Based on WinterCMS patterns, adapted for Scala + +**Research date:** 2026-02-05 +**Valid until:** 2026-03-05 (30 days - security libraries stable, update if vulnerabilities discovered)