refactor(ext-oidc): replace auth modules with security extensions

This commit is contained in:
Zakaria El Orche
2026-09-16 15:54:19 +00:00
parent 017c2443f4
commit e0795299fc
126 changed files with 2944 additions and 5130 deletions
@@ -0,0 +1,17 @@
# flash-ext-security-form
Password sign-in for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md).
```java
app.install(new SecurityExtension())
.install(new FormLoginExtension(username -> accounts.find(username))); // PasswordStore
```
`POST /auth/form/login` takes `username` and `password` form-encoded, starts a session and answers
`303` to `?redirect=` (same-origin paths only) or `/`. A wrong password and an unknown account are the
same `401`, and cost the same time. The method is listed at `/auth/methods` with `"kind":"form"`, and
the entry point sends browsers to `SecurityExtension.loginPage` to render it.
`PasswordEncoder.pbkdf2()` hashes (PBKDF2-HMAC-SHA256, 600k iterations, JDK only); use it to create
accounts, or pass another encoder to `passwordEncoder(...)`. Rate-limit the route with
`flash-ext-limiter`.
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-security-form</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,74 @@
package dev.relism.flash.ext.security.form;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.security.LoginMethod;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
/**
* Password sign-in: {@code POST /auth/form/login} with {@code username} and {@code password}
* form-encoded — what an HTML form sends, and what {@code fetch} sends given {@code URLSearchParams}.
* Success starts a session and answers {@code 303} to {@code redirect} (same-origin paths only) or
* {@code /}; failure is a {@code 401}, identical for an unknown account and a wrong password.
*
* <pre>{@code
* app.install(new SecurityExtension())
* .install(new FormLoginExtension(accounts::byUsername));
* }</pre>
*
* Rate-limit the route with {@code flash-ext-limiter}; this extension does not.
*/
public final class FormLoginExtension implements FlashExtension {
public static final String LOGIN = "/auth/form/login";
private final PasswordStore store;
private PasswordEncoder encoder = PasswordEncoder.pbkdf2();
private String unknownAccountHash;
public FormLoginExtension(PasswordStore store) {
this.store = store;
}
public FormLoginExtension passwordEncoder(PasswordEncoder encoder) {
this.encoder = encoder;
return this;
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
// Checked against when no account matches, so an unknown username costs what a wrong password does.
unknownAccountHash = encoder.encode("unknown-account");
app.post(LOGIN, (req, res) -> {
String body = new String(req.body().bytes(), StandardCharsets.UTF_8);
String username = field(body, "username");
String password = field(body, "password");
if (username == null || password == null) throw HttpException.badRequest("username and password are required");
PasswordStore.Account account = store.find(username);
boolean matches = encoder.matches(password, account == null ? unknownAccountHash : account.passwordHash());
if (account == null || !matches) throw HttpException.unauthorized();
ctx.require(SecurityExtension.class).signIn(req, res, account.principal());
String redirect = req.query("redirect");
boolean local = redirect != null && redirect.startsWith("/") && !redirect.startsWith("//") && !redirect.startsWith("/\\");
res.status(303).header("Location", local ? redirect : "/");
return null;
});
ctx.onReady(() -> ctx.require(SecurityExtension.class)
.loginMethod(new LoginMethod("form", "Password", LOGIN, LoginMethod.Kind.FORM)));
}
private static String field(String body, String name) {
for (String pair : body.split("&")) {
int eq = pair.indexOf('=');
if (eq == name.length() && pair.startsWith(name)) return URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
}
return null;
}
}
@@ -0,0 +1,58 @@
package dev.relism.flash.ext.security.form;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/** One-way password hashing. {@link #pbkdf2()} unless another is configured. */
public interface PasswordEncoder {
String encode(CharSequence password);
/** Constant-time; {@code false} for anything this encoder did not produce. */
boolean matches(CharSequence password, String encoded);
/** PBKDF2-HMAC-SHA256, 600,000 iterations (OWASP 2023), a random 16-byte salt — JDK only. */
static PasswordEncoder pbkdf2() {
return Pbkdf2.INSTANCE;
}
enum Pbkdf2 implements PasswordEncoder {
INSTANCE;
private static final int ITERATIONS = 600_000;
private static final SecureRandom RANDOM = new SecureRandom();
@Override
public String encode(CharSequence password) {
byte[] salt = new byte[16];
RANDOM.nextBytes(salt);
return "pbkdf2$" + ITERATIONS + "$" + Base64.getEncoder().encodeToString(salt)
+ "$" + Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS));
}
@Override
public boolean matches(CharSequence password, String encoded) {
String[] parts = encoded == null ? new String[0] : encoded.split("\\$");
if (parts.length != 4 || !parts[0].equals("pbkdf2")) return false;
byte[] salt = Base64.getDecoder().decode(parts[2]);
return MessageDigest.isEqual(derive(password, salt, Integer.parseInt(parts[1])), Base64.getDecoder().decode(parts[3]));
}
private static byte[] derive(CharSequence password, byte[] salt, int iterations) {
char[] chars = password.toString().toCharArray();
PBEKeySpec spec = new PBEKeySpec(chars, salt, iterations, 256);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} catch (GeneralSecurityException impossible) {
throw new IllegalStateException(impossible);
} finally {
spec.clearPassword();
java.util.Arrays.fill(chars, '\0');
}
}
}
}
@@ -0,0 +1,14 @@
package dev.relism.flash.ext.security.form;
import dev.relism.flash.ext.security.Principal;
/** Where the application keeps the accounts that sign in with a password. */
@FunctionalInterface
public interface PasswordStore {
/** The account signing in as {@code username}, or {@code null}. */
Account find(String username);
/** @param passwordHash as produced by the configured {@link PasswordEncoder} */
record Account(Principal principal, String passwordHash) {}
}
@@ -0,0 +1,63 @@
package dev.relism.flash.ext.security.form;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FormLoginExtensionTest {
static final String HASH = PasswordEncoder.pbkdf2().encode("s3cret");
static final SecurityExtension security = new SecurityExtension();
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security)
.install(new FormLoginExtension(username -> username.equals("alice")
? new PasswordStore.Account(() -> "alice", HASH) : null))
.get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED)));
static FlashResponse login(String form, String query) {
return app.request().header("content-type", "application/x-www-form-urlencoded").body(form)
.post(FormLoginExtension.LOGIN + query);
}
@Test
void theRightPasswordStartsASessionAndRedirectsToALocalPath() {
var response = login("username=alice&password=s3cret", "?redirect=%2Fprojects")
.expectStatus(303).expectHeader("Location", "/projects");
String cookie = response.header("Set-Cookie");
app.request().header("Cookie", cookie.substring(0, cookie.indexOf(';'))).get("/me").expectBody("alice");
}
@Test
void aForeignRedirectIsIgnored() {
login("username=alice&password=s3cret", "?redirect=%2F%2Fevil.example").expectHeader("Location", "/");
}
@Test
void aWrongPasswordAndAnUnknownAccountAreTheSame401() {
assertNull(login("username=alice&password=nope", "").expectStatus(401).header("Set-Cookie"));
login("username=mallory&password=s3cret", "").expectStatus(401);
}
@Test
void pbkdf2RoundTripsAndRejectsWhatItDidNotProduce() {
PasswordEncoder encoder = PasswordEncoder.pbkdf2();
assertTrue(encoder.matches("s3cret", HASH));
assertFalse(encoder.matches("s3cret", "plain"));
assertFalse(encoder.matches("s3cret", null));
}
@Test
void theFormIsListedAsALoginMethod() {
app.get("/auth/methods").expectBodyContains("\"kind\":\"form\"");
}
}