refactor(ext-oidc): replace auth modules with security extensions
This commit is contained in:
@@ -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>
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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) {}
|
||||
}
|
||||
+63
@@ -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\"");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user