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
This commit is contained in:
692
.planning/phases/06-backend-authentication/06-RESEARCH.md
Normal file
692
.planning/phases/06-backend-authentication/06-RESEARCH.md
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user