feat(backend): sign-in with local accounts and Keycloak (ADR-014)

The backend now authenticates itself: local accounts from USERS_FILE
(PBKDF2-SHA256, throttled) and OIDC code flow with PKCE, independent of
each other, so Keycloak being down never locks the family out. A signed
stateless cookie carries the session; its user replaces Remote-User as
the requester. Without any sign-in configured the backend refuses to
start unless AUTH_DISABLED=true. /api/live serves the healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 21:51:21 +02:00
1 parent e6d9487f0c
commit 24b7946df1
38 files changed
+2493 -55

No files matched your search

+3 -3
View File
@@ -15,8 +15,8 @@ USER 10001:10001
WORKDIR /app
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
EXPOSE 8080
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
# /api/live needs no session and does not look at the gateway, so it only tells whether the backend
# itself serves requests. busybox wget ships with alpine.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/live" || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
+56 -7
View File
@@ -23,7 +23,7 @@ The settings come from the gateway's slower `deye_settings` reading and are stal
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back as
Same Origin, header and JSON rules as the other writes; the signed-in user goes on as the requester. The gateway's verdict comes back as
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
@@ -31,7 +31,7 @@ inverter holds), 400 bad body, 503 gateway unreachable or without battery comman
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
`HHMM`, `0/1`), `who` (the signed-in user; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
@@ -43,7 +43,7 @@ when a refresh fails the older answer is served with `stale` true. 503 only when
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
Setpoint changes go only to the gateway; the signed-in user is sent as `X-Requested-By`.
| Variable | Default | Meaning |
|---|---|---|
@@ -55,15 +55,65 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
| `USERS_FILE` | unset | local accounts, see Sign-in; outside the image like `PRESETS_FILE` |
| `OIDC_ISSUER` | unset | Keycloak realm URL, e.g. `https://login.mariuszpacek.pl/realms/home`; unset turns OIDC off |
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | required with `OIDC_ISSUER` | the confidential client; redirect `https://pompa.mariuszpacek.pl/api/auth/oidc/callback` |
| `OIDC_REQUIRED_ROLE` | unset | a role the user must have: realm role (`realm_access.roles`) or role of this client (`resource_access.<client>.roles`) in the ID token |
| `SESSION_SECRET` | required with `USERS_FILE` or `OIDC_ISSUER` | at least 32 bytes signing the cookies, e.g. `openssl rand -base64 48`; changing it signs everyone out |
| `SESSION_DAYS` | `30` | how long a session lasts |
| `TRUSTED_PROXY_HOPS` | `1` | proxies that append to `X-Forwarded-For` before the backend (Traefik: 1), for the login throttle |
| `AUTH_DISABLED` | unset | `true` runs without sign-in (every request is `phone`), for development; refused together with `USERS_FILE` or `OIDC_ISSUER` |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
```bash
mvn -f backend/pom.xml clean package
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example AUTH_DISABLED=true \
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
```
## Sign-in (ADR-014)
Without `USERS_FILE` or `OIDC_ISSUER` the backend refuses to start unless `AUTH_DISABLED=true`. The two ways in are
independent: local accounts keep working when Keycloak is down, and the backend never waits for Keycloak at start
(discovery and keys are fetched at the first sign-in and kept an hour). Every endpoint but those below answers
`401 {"error":"unauthenticated"}` without a valid session; the writes keep their Origin, header and JSON rules.
| Endpoint | Answer |
|---|---|
| `GET /api/auth/config` | `{"local", "oidc"}` booleans (`auth-config.json`) |
| `POST /api/auth/login` `{"username","password"}` | 200 `{"user","method":"local"}` and the cookie; 401 `{"error":"invalid"}`; 429 `{"error":"throttled","retryAfterSeconds"}` with `Retry-After`; 404 without `USERS_FILE` |
| `POST /api/auth/logout` | 204, cookie cleared |
| `GET /api/auth/me` | 200 `{"user","method"}` (`auth-me.json`; `method` is `local`, `oidc`, or `none` with `AUTH_DISABLED`) or 401 |
| `GET /api/auth/oidc/start` | 302 to Keycloak; 404 without OIDC |
| `GET /api/auth/oidc/callback` | 302 to `/`, or to `/#login?error=oidc_unavailable` (Keycloak down), `oidc_denied` (refused or role missing), `oidc_failed` (bad state, token or code) |
| `GET /api/live` | 200 `ok`, for the container healthcheck |
The session is a cookie `hp_session` (HttpOnly, Secure, SameSite=Lax, Path=/) signed with HMAC-SHA256 and holding user, method,
issue time and expiry; there is no server state, so a stolen cookie lives until it expires or the secret changes.
The OIDC flow is authorization code with PKCE S256, state and nonce, in a 10 minute signed cookie `hp_oidc`; the ID token is
verified here (RS256 against the JWKS key by `kid`, `iss`, `aud` and `azp`, `exp`/`iat` with 60 s skew, `nonce`).
The user name is `preferred_username`, else `email`, else `sub`.
**Local accounts**: `USERS_FILE` is JSON `[{"username": "tata", "passwordHash": "pbkdf2-sha256$600000$<salt>$<hash>"}]`, a username of
1 to 64 of `A-Za-z0-9_.@+-`, a bad file stops the start. Mount it read-only into the container (readable by uid 10001) and point `USERS_FILE` at it. Make a hash without echoing the password (asked twice on a terminal;
a pipe reads one line):
```bash
docker run --rm -it git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:latest hash-password
java -jar backend/target/home-pump-backend-1.0.0.jar hash-password
```
Five failed logins in 15 minutes block that username and, separately, that client address (429, `Retry-After`); a blocked
username is refused even with the right password. An unknown username costs one password hash like a wrong password.
The address is the entry `TRUSTED_PROXY_HOPS` from the right of `X-Forwarded-For` (Traefik appends the address it saw;
what a client put in front is ignored); without it the socket's address.
**Keycloak client** in the realm of `OIDC_ISSUER`: Client authentication on (confidential), only Standard flow, PKCE method
`S256`, valid redirect URI `https://pompa.mariuszpacek.pl/api/auth/oidc/callback`, web origin `https://pompa.mariuszpacek.pl`.
Its secret is `OIDC_CLIENT_SECRET`. For `OIDC_REQUIRED_ROLE`, the client scope `roles` must put the roles into the ID token
(its realm and client role mappers: "Add to ID token" on).
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
@@ -83,9 +133,8 @@ pending change of that setpoint now and answers with the gateway's verdict, exam
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
pending), 503 gateway unreachable.
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
rules (ADR-009).
Publish no host port in production: only the frontend, behind Traefik, may reach the backend, so Traefik's
access rules (ADR-009) stay in front of the sign-in.
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
@@ -1,5 +1,6 @@
package home.backend;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.config.PresetFile;
import home.backend.adapters.gateway.GatewayHttpClient;
import home.backend.adapters.gateway.GatewayHttpCommands;
@@ -43,7 +44,7 @@ final class Backend {
/**
* Binds the port but does not start serving.
*
* @throws IllegalArgumentException when PRESETS_FILE is unusable
* @throws IllegalArgumentException when PRESETS_FILE or USERS_FILE is unusable
*/
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
@@ -59,7 +60,8 @@ final class Backend {
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
new BatteryService(gateway, commands, clock),
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
weather(influxSettings, clock), commands);
weather(influxSettings, clock), commands,
Authentication.create(settings.auth(), clock));
}
private static WeatherService weather(InfluxSettings influx, Clock clock) {
@@ -2,6 +2,8 @@ package home.backend;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.auth.PasswordHashCli;
import java.io.IOException;
import java.time.Clock;
@@ -11,6 +13,9 @@ public final class Main {
}
public static void main(String[] args) throws IOException {
if (args.length == 1 && args[0].equals("hash-password")) {
System.exit(PasswordHashCli.run(System.console(), System.in, System.out, System.err));
}
Settings settings;
ApiServer server;
try {
@@ -1,6 +1,10 @@
package home.backend;
import home.backend.adapters.auth.AuthSettings;
import home.backend.adapters.auth.OidcSettings;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
@@ -8,7 +12,7 @@ import java.util.Optional;
/** Twelve-factor settings: everything comes from environment variables. */
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
String gatewayToken, Path presetsFile) {
String gatewayToken, Path presetsFile, AuthSettings auth) {
public static Settings fromEnvironment(Map<String, String> env) {
return new Settings(
@@ -18,7 +22,44 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
optional(env, "GATEWAY_TOKEN"),
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null),
auth(env));
}
private static final int MIN_SECRET_BYTES = 32;
/** Refuses to start open by accident: sign-in is configured, or AUTH_DISABLED=true says it is meant. */
private static AuthSettings auth(Map<String, String> env) {
Path usersFile = Optional.ofNullable(optional(env, "USERS_FILE")).map(Path::of).orElse(null);
OidcSettings oidc = optional(env, "OIDC_ISSUER") == null ? null : oidc(env);
String secret = optional(env, "SESSION_SECRET");
boolean disabled = "true".equals(optional(env, "AUTH_DISABLED"));
int days = (int) number(env, "SESSION_DAYS", 30, 1, 365);
int hops = (int) number(env, "TRUSTED_PROXY_HOPS", 1, 1, 10);
boolean configured = usersFile != null || oidc != null;
if (disabled && configured) {
throw new IllegalArgumentException("AUTH_DISABLED=true contradicts USERS_FILE and OIDC_ISSUER: unset one");
}
if (!disabled && !configured) {
throw new IllegalArgumentException("no sign-in configured: set USERS_FILE and/or OIDC_ISSUER, "
+ "or AUTH_DISABLED=true for development only");
}
if (configured && (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < MIN_SECRET_BYTES)) {
throw new IllegalArgumentException("SESSION_SECRET is required and must be at least " + MIN_SECRET_BYTES
+ " bytes, e.g. from: openssl rand -base64 48");
}
return new AuthSettings(usersFile, oidc, secret, days, disabled, hops);
}
private static OidcSettings oidc(Map<String, String> env) {
for (String name : new String[] {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI"}) {
if (optional(env, name) == null) {
throw new IllegalArgumentException(name + " is required when OIDC_ISSUER is set");
}
}
return new OidcSettings(URI.create(optional(env, "OIDC_ISSUER")), optional(env, "OIDC_CLIENT_ID"),
optional(env, "OIDC_CLIENT_SECRET"), URI.create(optional(env, "OIDC_REDIRECT_URI")),
optional(env, "OIDC_REQUIRED_ROLE"));
}
/** Null when unset or blank. */
@@ -0,0 +1,11 @@
package home.backend.adapters.auth;
import java.nio.file.Path;
/**
* How users sign in. At least one of usersFile and oidc, or disabled; the secret signs the cookies.
* proxyHops is how many proxies append to X-Forwarded-For before the backend (Traefik: 1).
*/
public record AuthSettings(Path usersFile, OidcSettings oidc, String sessionSecret, int sessionDays,
boolean disabled, int proxyHops) {
}
@@ -0,0 +1,96 @@
package home.backend.adapters.auth;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
public final class Authentication {
public enum Status { OK, INVALID, THROTTLED }
public record Login(Status status, long retryAfterSeconds) {
}
private static final int MAX_FAILURES = 5;
private static final Duration THROTTLE_WINDOW = Duration.ofMinutes(15);
private final Sessions sessions;
private final UserStore users;
private final LoginThrottle throttle;
private final OidcFlow oidc;
private final int proxyHops;
private Authentication(Sessions sessions, UserStore users, LoginThrottle throttle, OidcFlow oidc, int proxyHops) {
this.sessions = sessions;
this.users = users;
this.throttle = throttle;
this.oidc = oidc;
this.proxyHops = proxyHops;
}
/** @throws IllegalArgumentException when USERS_FILE is unusable */
public static Authentication create(AuthSettings settings, Clock clock) {
if (settings.disabled()) {
return new Authentication(null, null, null, null, settings.proxyHops());
}
var tokens = new SignedTokens(settings.sessionSecret().getBytes(StandardCharsets.UTF_8), clock);
return new Authentication(new Sessions(tokens, clock, Duration.ofDays(settings.sessionDays())),
settings.usersFile() == null ? null : UserStore.load(settings.usersFile()),
new LoginThrottle(clock, MAX_FAILURES, THROTTLE_WINDOW),
settings.oidc() == null ? null : new OidcFlow(settings.oidc(), tokens, clock),
settings.proxyHops());
}
/** For development and tests: every request is the user "phone". */
public static Authentication disabled() {
return new Authentication(null, null, null, null, 1);
}
public boolean localEnabled() {
return users != null;
}
public boolean oidcEnabled() {
return oidc != null;
}
public OidcFlow oidc() {
return oidc;
}
public int proxyHops() {
return proxyHops;
}
public Optional<Session> session(String cookieHeader) {
return sessions == null ? Optional.of(Session.OPEN) : sessions.read(cookieHeader);
}
/** Checks the password unless the user or the address is blocked; a blocked one learns nothing about it. */
public Login login(String username, char[] password, String clientIp) {
String userKey = "user:" + username.toLowerCase(Locale.ROOT);
String ipKey = "ip:" + clientIp;
long wait = throttle.retryAfterSeconds(userKey, ipKey);
if (wait > 0) {
return new Login(Status.THROTTLED, wait);
}
if (users.authenticate(username, password)) {
throttle.cleared(userKey);
return new Login(Status.OK, 0);
}
throttle.failed(userKey, ipKey);
return new Login(Status.INVALID, 0);
}
/** The Set-Cookie header value that signs the user in. */
public String sessionCookie(String user, String method) {
return sessions.setCookie(user, method);
}
public String clearedSessionCookie() {
return sessions == null ? "" : sessions.clearCookie();
}
}
@@ -0,0 +1,18 @@
package home.backend.adapters.auth;
import java.util.Base64;
/** Unpadded base64url, as tokens and PKCE use it. */
final class Base64Url {
private Base64Url() {
}
static String encode(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static byte[] decode(String text) {
return Base64.getUrlDecoder().decode(text);
}
}
@@ -0,0 +1,36 @@
package home.backend.adapters.auth;
import java.time.Duration;
import java.util.Optional;
/** Reads the Cookie header and builds Set-Cookie headers with the flags every cookie here carries. */
final class Cookies {
private Cookies() {
}
static Optional<String> read(String header, String name) {
if (header == null) {
return Optional.empty();
}
for (String pair : header.split(";")) {
String[] kv = pair.trim().split("=", 2);
if (kv.length == 2 && kv[0].equals(name)) {
return Optional.of(kv[1]);
}
}
return Optional.empty();
}
static String set(String name, String value, Duration maxAge, String path) {
return name + "=" + value + "; Max-Age=" + maxAge.toSeconds() + attributes(path);
}
static String clear(String name, String path) {
return name + "=; Max-Age=0" + attributes(path);
}
private static String attributes(String path) {
return "; Path=" + path + "; HttpOnly; Secure; SameSite=Lax";
}
}
@@ -0,0 +1,143 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
/** Checks an ID token with the JDK alone: RS256 signature, issuer, audience, times, nonce and the required role. */
final class IdTokenVerifier {
/** The signing keys of the identity provider. */
interface Keys {
PublicKey key(String kid) throws OidcException;
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Duration SKEW = Duration.ofSeconds(60);
private final OidcSettings settings;
private final Clock clock;
IdTokenVerifier(OidcSettings settings, Clock clock) {
this.settings = settings;
this.clock = clock;
}
/** @return the user name of the token */
String verify(String idToken, String nonce, Keys keys) throws OidcException {
String[] parts = idToken.split("\\.", -1);
if (parts.length != 3) {
throw new OidcException(FAILED, "the ID token is not a JWT");
}
JsonNode header = json(parts[0]);
if (!"RS256".equals(header.path("alg").asText())) {
throw new OidcException(FAILED, "the ID token is not signed with RS256");
}
verifySignature(parts[0] + "." + parts[1], parts[2], keys.key(header.path("kid").asText()));
JsonNode claims = json(parts[1]);
checkClaims(claims, nonce);
checkRole(claims);
return userName(claims);
}
private void checkClaims(JsonNode claims, String nonce) throws OidcException {
long now = clock.instant().getEpochSecond();
if (!settings.issuerBase().equals(claims.path("iss").asText("").replaceAll("/+$", ""))) {
throw new OidcException(FAILED, "wrong issuer");
}
if (!audiences(claims).contains(settings.clientId())) {
throw new OidcException(FAILED, "the token is for another client");
}
if (audiences(claims).size() > 1 && !settings.clientId().equals(claims.path("azp").asText())) {
throw new OidcException(FAILED, "wrong authorized party");
}
if (!claims.path("exp").canConvertToLong() || claims.get("exp").asLong() + SKEW.toSeconds() <= now) {
throw new OidcException(FAILED, "the token has expired");
}
if (!claims.path("iat").canConvertToLong() || claims.get("iat").asLong() - SKEW.toSeconds() > now) {
throw new OidcException(FAILED, "the token is issued in the future");
}
boolean sameNonce = MessageDigest.isEqual(nonce.getBytes(StandardCharsets.UTF_8),
claims.path("nonce").asText("").getBytes(StandardCharsets.UTF_8));
if (!sameNonce) {
throw new OidcException(FAILED, "wrong nonce");
}
}
/** A realm role or a role of this client, as Keycloak puts them in {@code realm_access} and {@code resource_access}. */
private void checkRole(JsonNode claims) throws OidcException {
String role = settings.requiredRole();
if (role == null) {
return;
}
boolean has = contains(claims.path("realm_access").path("roles"), role)
|| contains(claims.path("resource_access").path(settings.clientId()).path("roles"), role);
if (!has) {
throw new OidcException(DENIED, "the user lacks the role " + role);
}
}
private String userName(JsonNode claims) throws OidcException {
for (String claim : new String[] {"preferred_username", "email", "sub"}) {
String name = claims.path(claim).asText("");
if (Session.isValidUser(name)) {
return name;
}
}
throw new OidcException(FAILED, "the token has no usable user name");
}
private static List<String> audiences(JsonNode claims) {
JsonNode aud = claims.path("aud");
var all = new ArrayList<String>();
if (aud.isArray()) {
aud.forEach(a -> all.add(a.asText()));
} else if (aud.isTextual()) {
all.add(aud.asText());
}
return all;
}
private static boolean contains(JsonNode array, String value) {
for (JsonNode item : array) {
if (value.equals(item.asText())) {
return true;
}
}
return false;
}
private static void verifySignature(String signedPart, String signature, PublicKey key) throws OidcException {
try {
Signature rsa = Signature.getInstance("SHA256withRSA");
rsa.initVerify(key);
rsa.update(signedPart.getBytes(StandardCharsets.US_ASCII));
if (!rsa.verify(Base64Url.decode(signature))) {
throw new OidcException(FAILED, "the ID token signature is wrong");
}
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new OidcException(FAILED, "the ID token signature cannot be checked", e);
}
}
private static JsonNode json(String part) throws OidcException {
try {
return JSON.readTree(Base64Url.decode(part));
} catch (IOException | IllegalArgumentException e) {
throw new OidcException(FAILED, "the ID token is malformed", e);
}
}
}
@@ -0,0 +1,53 @@
package home.backend.adapters.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
/** Blocks a key (a username or a client address) after too many failures within a window. */
public final class LoginThrottle {
private final Clock clock;
private final int maxFailures;
private final Duration window;
/** The newest failures of each key, at most {@code maxFailures}, oldest first. */
private final Map<String, ArrayDeque<Instant>> failures = new HashMap<>();
public LoginThrottle(Clock clock, int maxFailures, Duration window) {
this.clock = clock;
this.maxFailures = maxFailures;
this.window = window;
}
/** Seconds until the most blocked of the keys may try again; 0 when none is blocked. */
public synchronized long retryAfterSeconds(String... keys) {
Instant now = clock.instant();
long wait = 0;
for (String key : keys) {
var times = failures.get(key);
if (times != null && times.size() >= maxFailures && times.peekFirst().plus(window).isAfter(now)) {
wait = Math.max(wait, Duration.between(now, times.peekFirst().plus(window)).toSeconds() + 1);
}
}
return wait;
}
public synchronized void failed(String... keys) {
Instant now = clock.instant();
failures.values().removeIf(times -> !times.peekLast().plus(window).isAfter(now));
for (String key : keys) {
var times = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
times.addLast(now);
if (times.size() > maxFailures) {
times.removeFirst();
}
}
}
public synchronized void cleared(String key) {
failures.remove(key);
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
/** Why an OIDC sign-in did not happen; the code is what the login screen is told. */
public final class OidcException extends Exception {
public enum Reason {
/** Keycloak did not answer. */
UNAVAILABLE("oidc_unavailable"),
/** The user said no, or lacks the required role. */
DENIED("oidc_denied"),
/** Anything else: bad state, a token that does not verify, a refused code. */
FAILED("oidc_failed");
private final String code;
Reason(String code) {
this.code = code;
}
public String code() {
return code;
}
}
private final Reason reason;
public OidcException(Reason reason, String message) {
super(message);
this.reason = reason;
}
public OidcException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason reason() {
return reason;
}
}
@@ -0,0 +1,107 @@
package home.backend.adapters.auth;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
/**
* Authorization code flow with PKCE (S256), state and nonce. What must survive the trip to the identity
* provider travels in a short-lived signed cookie, so the backend keeps no state.
*/
public final class OidcFlow {
/** Where to send the browser, and the cookie to set on that response. */
public record Start(URI location, String setCookie) {
}
private static final String COOKIE = "hp_oidc";
private static final String PURPOSE = "oidc-login";
private static final String PATH = "/api/auth/oidc";
private static final Duration LOGIN_TIME = Duration.ofMinutes(10);
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final SecureRandom RANDOM = new SecureRandom();
private final OidcSettings settings;
private final OidcProvider provider;
private final IdTokenVerifier verifier;
private final SignedTokens tokens;
OidcFlow(OidcSettings settings, SignedTokens tokens, Clock clock) {
this.settings = settings;
this.provider = new OidcProvider(settings, TIMEOUT, clock);
this.verifier = new IdTokenVerifier(settings, clock);
this.tokens = tokens;
}
/** @throws OidcException UNAVAILABLE when the identity provider does not answer */
public Start start() throws OidcException {
String state = random();
String nonce = random();
String verifierText = random();
Map<String, String> query = new LinkedHashMap<>();
query.put("response_type", "code");
query.put("client_id", settings.clientId());
query.put("redirect_uri", settings.redirectUri().toString());
query.put("scope", "openid profile");
query.put("state", state);
query.put("nonce", nonce);
query.put("code_challenge", challenge(verifierText));
query.put("code_challenge_method", "S256");
URI endpoint = provider.endpoints().authorization();
String separator = endpoint.getRawQuery() == null ? "?" : "&";
String cookie = Cookies.set(COOKIE, tokens.issue(PURPOSE, LOGIN_TIME,
Map.of("state", state, "nonce", nonce, "verifier", verifierText)), LOGIN_TIME, PATH);
return new Start(URI.create(endpoint + separator + query.entrySet().stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"))), cookie);
}
/** @return the signed-in user's name */
public String callback(Map<String, String> query, String cookieHeader) throws OidcException {
Map<String, String> login = Cookies.read(cookieHeader, COOKIE).flatMap(t -> tokens.read(PURPOSE, t))
.orElseThrow(() -> new OidcException(FAILED, "no login in progress, or it took too long"));
boolean sameState = MessageDigest.isEqual(login.get("state").getBytes(StandardCharsets.UTF_8),
query.getOrDefault("state", "").getBytes(StandardCharsets.UTF_8));
if (!sameState) {
throw new OidcException(FAILED, "wrong state");
}
if ("access_denied".equals(query.get("error"))) {
throw new OidcException(DENIED, "the user denied access");
}
String code = query.get("code");
if (code == null || code.isEmpty()) {
throw new OidcException(FAILED, "the identity provider answered with " + query.get("error"));
}
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
}
public String clearCookie() {
return Cookies.clear(COOKIE, PATH);
}
private static String random() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64Url.encode(bytes);
}
private static String challenge(String verifierText) {
try {
return Base64Url.encode(MessageDigest.getInstance("SHA-256")
.digest(verifierText.getBytes(StandardCharsets.US_ASCII)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is part of every JDK", e);
}
}
}
@@ -0,0 +1,161 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
import static home.backend.adapters.auth.OidcException.Reason.UNAVAILABLE;
/**
* The identity provider over HTTP: discovery, signing keys and the code exchange. Everything is fetched on first
* use and kept, so an identity provider that is down never delays the start and never affects local login.
*/
final class OidcProvider implements IdTokenVerifier.Keys {
record Endpoints(URI authorization, URI token, URI jwks) {
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Duration CACHE = Duration.ofHours(1);
/** An unknown key id may mean a rotation; look again, but not on every forged token. */
private static final Duration REFETCH_AFTER = Duration.ofMinutes(1);
private final OidcSettings settings;
private final HttpClient http;
private final Duration timeout;
private final Clock clock;
private Endpoints endpoints;
private Instant endpointsAt = Instant.MIN;
private Map<String, PublicKey> keys = Map.of();
private Instant keysAt = Instant.MIN;
OidcProvider(OidcSettings settings, Duration timeout, Clock clock) {
this.settings = settings;
this.timeout = timeout;
this.clock = clock;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
synchronized Endpoints endpoints() throws OidcException {
if (endpoints == null || endpointsAt.plus(CACHE).isBefore(clock.instant())) {
String base = settings.issuerBase();
JsonNode doc = getJson(URI.create(base + "/.well-known/openid-configuration"));
if (!base.equals(doc.path("issuer").asText("").replaceAll("/+$", ""))) {
throw new OidcException(FAILED, "the discovery document names another issuer");
}
endpoints = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"),
uri(doc, "jwks_uri"));
endpointsAt = clock.instant();
}
return endpoints;
}
@Override
public synchronized PublicKey key(String kid) throws OidcException {
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
boolean unknownAndRefetchable = !keys.containsKey(kid) && keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
if (stale || unknownAndRefetchable) {
keys = parseKeys(getJson(endpoints().jwks()));
keysAt = clock.instant();
}
PublicKey key = keys.get(kid);
if (key == null) {
throw new OidcException(FAILED, "no signing key " + kid);
}
return key;
}
/** @return the ID token the identity provider issued for the code */
String exchange(String code, String verifier) throws OidcException {
var form = Map.of("grant_type", "authorization_code", "code", code,
"redirect_uri", settings.redirectUri().toString(), "code_verifier", verifier);
String credentials = encode(settings.clientId()) + ":" + encode(settings.clientSecret());
var request = HttpRequest.newBuilder(endpoints().token()).timeout(timeout)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)))
.POST(HttpRequest.BodyPublishers.ofString(form.entrySet().stream()
.map(e -> e.getKey() + "=" + encode(e.getValue())).collect(Collectors.joining("&"))))
.build();
JsonNode answer = send(request, "the token endpoint");
String idToken = answer.path("id_token").asText("");
if (idToken.isEmpty()) {
throw new OidcException(FAILED, "the token endpoint sent no id_token");
}
return idToken;
}
private JsonNode getJson(URI uri) throws OidcException {
return send(HttpRequest.newBuilder(uri).timeout(timeout).GET().build(), uri.getPath());
}
private JsonNode send(HttpRequest request, String what) throws OidcException {
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 500) {
throw new OidcException(UNAVAILABLE, what + " answered " + response.statusCode());
}
if (response.statusCode() != 200) {
throw new OidcException(FAILED, what + " answered " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new OidcException(UNAVAILABLE, what + " was interrupted", e);
} catch (IOException e) {
throw new OidcException(UNAVAILABLE, what + " is unreachable: " + e, e);
}
}
private static Map<String, PublicKey> parseKeys(JsonNode jwks) throws OidcException {
Map<String, PublicKey> keys = new HashMap<>();
for (JsonNode jwk : jwks.path("keys")) {
boolean signingRsa = "RSA".equals(jwk.path("kty").asText()) && !"enc".equals(jwk.path("use").asText());
if (signingRsa && jwk.hasNonNull("kid")) {
try {
keys.put(jwk.get("kid").asText(), KeyFactory.getInstance("RSA").generatePublic(
new RSAPublicKeySpec(integer(jwk, "n"), integer(jwk, "e"))));
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new OidcException(FAILED, "unusable signing key " + jwk.get("kid").asText(), e);
}
}
}
return keys;
}
private static BigInteger integer(JsonNode jwk, String field) {
return new BigInteger(1, Base64Url.decode(jwk.path(field).asText()));
}
private static URI uri(JsonNode doc, String field) throws OidcException {
String value = doc.path(field).asText("");
if (value.isEmpty()) {
throw new OidcException(FAILED, "the discovery document has no " + field);
}
return URI.create(value);
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,12 @@
package home.backend.adapters.auth;
import java.net.URI;
/** requiredRole is null when any user of the realm may sign in. */
public record OidcSettings(URI issuer, String clientId, String clientSecret, URI redirectUri, String requiredRole) {
/** The issuer without a trailing slash, the form tokens and discovery are compared in. */
String issuerBase() {
return issuer.toString().replaceAll("/+$", "");
}
}
@@ -0,0 +1,78 @@
package home.backend.adapters.auth;
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;
/** Password hashes as {@code pbkdf2-sha256$<iterations>$<salt b64>$<hash b64>}: the cost travels with the hash. */
public final class PasswordHash {
private static final String SCHEME = "pbkdf2-sha256";
private static final int ITERATIONS = 600_000;
/** A file with a cheaper hash is refused, so a weak one cannot slip in. */
private static final int MIN_ITERATIONS = 310_000;
private static final int SALT_BYTES = 16;
private static final int HASH_BITS = 256;
private static final SecureRandom RANDOM = new SecureRandom();
private record Parsed(int iterations, byte[] salt, byte[] hash) {
}
private PasswordHash() {
}
public static String create(char[] password) {
byte[] salt = new byte[SALT_BYTES];
RANDOM.nextBytes(salt);
return String.join("$", SCHEME, String.valueOf(ITERATIONS), Base64.getEncoder().encodeToString(salt),
Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS, HASH_BITS)));
}
/** Compares in constant time. */
public static boolean matches(String stored, char[] password) {
Parsed parsed = parse(stored);
byte[] actual = derive(password, parsed.salt(), parsed.iterations(), parsed.hash().length * 8);
return MessageDigest.isEqual(parsed.hash(), actual);
}
/** @throws IllegalArgumentException when the text is not a hash this class made or accepts */
public static void requireValid(String stored) {
parse(stored);
}
private static Parsed parse(String stored) {
String[] parts = stored == null ? new String[0] : stored.split("\\$");
if (parts.length != 4 || !SCHEME.equals(parts[0])) {
throw new IllegalArgumentException("a password hash must look like " + SCHEME
+ "$<iterations>$<salt>$<hash>; create one with: hash-password");
}
try {
int iterations = Integer.parseInt(parts[1]);
byte[] salt = Base64.getDecoder().decode(parts[2]);
byte[] hash = Base64.getDecoder().decode(parts[3]);
if (iterations < MIN_ITERATIONS || salt.length < SALT_BYTES || hash.length < HASH_BITS / 8) {
throw new IllegalArgumentException("password hash is too weak: at least " + MIN_ITERATIONS
+ " iterations, 16 bytes of salt, a 32 byte hash");
}
return new Parsed(iterations, salt, hash);
} catch (IllegalArgumentException e) {
// NumberFormatException and a bad base64 are both IllegalArgumentException
throw new IllegalArgumentException("password hash is malformed or too weak (at least " + MIN_ITERATIONS
+ " iterations, 16 bytes of salt, a 32 byte hash)", e);
}
}
private static byte[] derive(char[] password, byte[] salt, int iterations, int bits) {
var spec = new PBEKeySpec(password, salt, iterations, bits);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("PBKDF2WithHmacSHA256 is part of every JDK", e);
} finally {
spec.clearPassword();
}
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
import java.io.BufferedReader;
import java.io.Console;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/** {@code hash-password}: prints the hash for USERS_FILE; on a terminal the password is asked twice, unechoed. */
public final class PasswordHashCli {
private PasswordHashCli() {
}
/** @return the exit code */
public static int run(Console console, InputStream stdin, PrintStream out, PrintStream err) throws IOException {
char[] password;
if (console != null) {
password = console.readPassword("Password: ");
char[] again = console.readPassword("Again: ");
if (password == null || !Arrays.equals(password, again)) {
err.println("The passwords differ.");
return 1;
}
} else {
String line = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine();
password = line == null ? null : line.toCharArray();
}
if (password == null || password.length == 0) {
err.println("No password given.");
return 1;
}
out.println(PasswordHash.create(password));
Arrays.fill(password, '\0');
return 0;
}
}
@@ -0,0 +1,19 @@
package home.backend.adapters.auth;
import java.time.Instant;
import java.util.regex.Pattern;
/** Who is signed in and how. {@code method} is {@code local}, {@code oidc} or {@code none} (AUTH_DISABLED). */
public record Session(String user, String method, Instant issuedAt) {
/** The name goes to the gateway's audit log as a header, so only plain characters are allowed. */
public static final String USER_PATTERN = "[\\w.@+-]{1,64}";
private static final Pattern USER = Pattern.compile(USER_PATTERN);
/** Every request is this one when authentication is switched off for development. */
static final Session OPEN = new Session("phone", "none", Instant.EPOCH);
public static boolean isValidUser(String name) {
return USER.matcher(name).matches();
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
/** The session cookie: a signed token that holds the user, the method and when it was issued. */
final class Sessions {
static final String COOKIE = "hp_session";
private static final String PURPOSE = "session";
private final SignedTokens tokens;
private final Clock clock;
private final Duration lifetime;
Sessions(SignedTokens tokens, Clock clock, Duration lifetime) {
this.tokens = tokens;
this.clock = clock;
this.lifetime = lifetime;
}
String setCookie(String user, String method) {
String token = tokens.issue(PURPOSE, lifetime, Map.of("user", user, "method", method,
"iat", String.valueOf(clock.instant().getEpochSecond())));
return Cookies.set(COOKIE, token, lifetime, "/");
}
String clearCookie() {
return Cookies.clear(COOKIE, "/");
}
Optional<Session> read(String cookieHeader) {
return Cookies.read(cookieHeader, COOKIE).flatMap(token -> tokens.read(PURPOSE, token)).map(claims ->
new Session(claims.get("user"), claims.get("method"),
Instant.ofEpochSecond(Long.parseLong(claims.get("iat")))));
}
}
@@ -0,0 +1,71 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/** Tamper-proof, expiring text for cookies: {@code base64url(json).base64url(HMAC-SHA256)}. */
final class SignedTokens {
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<Map<String, String>> CLAIMS = new TypeReference<>() {
};
private static final String HMAC = "HmacSHA256";
private final SecretKeySpec key;
private final Clock clock;
SignedTokens(byte[] secret, Clock clock) {
this.key = new SecretKeySpec(secret, HMAC);
this.clock = clock;
}
/** The purpose keeps a token made for one use (a session) from passing as another (a login in progress). */
String issue(String purpose, Duration lifetime, Map<String, String> claims) {
var all = new HashMap<>(claims);
all.put("purpose", purpose);
all.put("exp", String.valueOf(clock.instant().plus(lifetime).getEpochSecond()));
try {
String payload = Base64Url.encode(JSON.writeValueAsBytes(all));
return payload + "." + Base64Url.encode(sign(payload));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
Optional<Map<String, String>> read(String purpose, String token) {
try {
String[] parts = token.split("\\.", -1);
if (parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64Url.decode(parts[1]))) {
return Optional.empty();
}
Map<String, String> claims = JSON.readValue(Base64Url.decode(parts[0]), CLAIMS);
boolean valid = purpose.equals(claims.get("purpose"))
&& Long.parseLong(claims.get("exp")) > clock.instant().getEpochSecond();
return valid ? Optional.of(claims) : Optional.empty();
} catch (IOException | IllegalArgumentException e) {
return Optional.empty();
}
}
private byte[] sign(String payload) {
try {
Mac mac = Mac.getInstance(HMAC);
mac.init(key);
return mac.doFinal(payload.getBytes(StandardCharsets.US_ASCII));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(HMAC + " is part of every JDK", e);
}
}
}
@@ -0,0 +1,65 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. */
public final class UserStore {
private record Entry(String username, String passwordHash) {
}
/** What an unknown user is checked against, so that it costs as much as a wrong password. */
private static final String DECOY = PasswordHash.create(randomPassword());
private final Map<String, String> hashes;
UserStore(Map<String, String> hashes) {
this.hashes = Map.copyOf(hashes);
}
/** @throws IllegalArgumentException when the file is missing, unreadable or holds a bad account */
public static UserStore load(Path file) {
List<Entry> entries;
try {
entries = new ObjectMapper().readValue(file.toFile(), new TypeReference<>() {
});
} catch (IOException e) {
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
}
Map<String, String> hashes = new HashMap<>();
for (Entry entry : entries) {
if (entry.username() == null || !Session.isValidUser(entry.username())) {
throw new IllegalArgumentException("USERS_FILE: a username must match " + Session.USER_PATTERN);
}
PasswordHash.requireValid(entry.passwordHash());
if (hashes.put(entry.username(), entry.passwordHash()) != null) {
throw new IllegalArgumentException("USERS_FILE: duplicate username " + entry.username());
}
}
if (hashes.isEmpty()) {
throw new IllegalArgumentException("USERS_FILE has no accounts");
}
return new UserStore(hashes);
}
public boolean authenticate(String username, char[] password) {
String stored = hashes.get(username);
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
return stored != null && matches;
}
private static char[] randomPassword() {
byte[] bytes = new byte[24];
new SecureRandom().nextBytes(bytes);
return Base64.getEncoder().encodeToString(bytes).toCharArray();
}
}
@@ -9,6 +9,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.auth.Authentication;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
@@ -73,11 +74,6 @@ public class ApiServer {
private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/";
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
private static final String USER_HEADER = "Remote-User";
private static final String DEFAULT_USER = "phone";
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
@@ -85,6 +81,7 @@ public class ApiServer {
private static final int STOP_GRACE_SECONDS = 5;
private final HttpServer server;
private final SessionFilter sessionFilter;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
/** Binds the port now; 0 picks a free one. */
@@ -92,15 +89,28 @@ public class ApiServer {
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
GatewayRefresh refresh) throws IOException {
GatewayRefresh refresh, Authentication auth) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
sessionFilter = new SessionFilter(auth);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
context("/", exchange -> {
open("/", exchange -> {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
});
var signIn = new AuthEndpoints(auth);
openRoute("/api/auth/config", signIn::config);
openRoute("/api/auth/login", signIn::login);
openRoute("/api/auth/logout", signIn::logout);
openRoute("/api/auth/me", signIn::me);
openRoute("/api/auth/oidc/start", signIn::oidcStart);
openRoute("/api/auth/oidc/callback", signIn::oidcCallback);
// The container healthcheck: no session, nothing in the answer.
openRoute("/api/live", exchange -> handle(exchange, Map.of("GET", () -> {
exchange.sendResponseHeaders(200, 2);
exchange.getResponseBody().write("ok".getBytes(StandardCharsets.US_ASCII));
})));
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current());
@@ -135,9 +145,17 @@ public class ApiServer {
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private void route(String path, HttpHandler handler) {
context(path, exchange -> {
context(path, exactly(path, handler));
}
private void openRoute(String path, HttpHandler handler) {
open(path, exactly(path, handler));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private static HttpHandler exactly(String path, HttpHandler handler) {
return exchange -> {
if (path.equals(exchange.getRequestURI().getPath())) {
handler.handle(exchange);
} else {
@@ -145,10 +163,18 @@ public class ApiServer {
exchange.sendResponseHeaders(404, -1);
}
}
});
};
}
/** Needs a session. */
private void context(String path, HttpHandler handler) {
var context = server.createContext(path, handler);
context.getFilters().add(SECURITY_HEADERS);
context.getFilters().add(sessionFilter);
}
/** Answers without a session. */
private void open(String path, HttpHandler handler) {
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
}
@@ -184,7 +210,7 @@ public class ApiServer {
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
*/
private static void guarded(HttpExchange exchange, Write write) throws IOException {
static void guarded(HttpExchange exchange, Write write) throws IOException {
var headers = exchange.getRequestHeaders();
String origin = headers.getFirst("Origin");
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
@@ -337,9 +363,9 @@ public class ApiServer {
};
}
/** The signed-in user, set by the session filter. */
private static String user(HttpExchange exchange) {
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
}
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
@@ -380,15 +406,15 @@ public class ApiServer {
}));
}
private interface Action {
interface Action {
void run() throws IOException;
}
private interface Write {
interface Write {
void run(byte[] body) throws IOException;
}
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
try (exchange) {
Action action = byMethod.get(exchange.getRequestMethod());
if (action == null) {
@@ -407,7 +433,7 @@ public class ApiServer {
}
}
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
static void send(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
@@ -419,7 +445,7 @@ public class ApiServer {
return Map.of("error", message);
}
private static Map<String, String> parseQuery(String raw) {
static Map<String, String> parseQuery(String raw) {
Map<String, String> query = new HashMap<>();
if (raw == null) {
return query;
@@ -0,0 +1,142 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.auth.OidcException;
import home.backend.adapters.auth.Session;
import java.io.IOException;
import java.util.Map;
/** The sign-in endpoints under {@code /api/auth/}; none of them needs a session. */
final class AuthEndpoints {
private record LoginRequest(String username, String password) {
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final int MAX_CREDENTIAL_LENGTH = 256;
private final Authentication auth;
AuthEndpoints(Authentication auth) {
this.auth = auth;
}
void config(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> ApiServer.send(exchange, 200,
Map.of("local", auth.localEnabled(), "oidc", auth.oidcEnabled()))));
}
void login(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> postLogin(exchange, body))));
}
void logout(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
String cookie = auth.clearedSessionCookie();
if (!cookie.isEmpty()) {
exchange.getResponseHeaders().add("Set-Cookie", cookie);
}
exchange.sendResponseHeaders(204, -1);
})));
}
void me(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
var session = auth.session(SessionFilter.cookies(exchange));
if (session.isPresent()) {
ApiServer.send(exchange, 200, user(session.get()));
} else {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
}
}));
}
void oidcStart(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
if (!auth.oidcEnabled()) {
exchange.sendResponseHeaders(404, -1);
return;
}
try {
var start = auth.oidc().start();
exchange.getResponseHeaders().add("Set-Cookie", start.setCookie());
redirect(exchange, start.location().toString());
} catch (OidcException e) {
redirectWithError(exchange, e);
}
}));
}
void oidcCallback(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
if (!auth.oidcEnabled()) {
exchange.sendResponseHeaders(404, -1);
return;
}
exchange.getResponseHeaders().add("Set-Cookie", auth.oidc().clearCookie());
try {
String name = auth.oidc().callback(ApiServer.parseQuery(exchange.getRequestURI().getRawQuery()),
SessionFilter.cookies(exchange));
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(name, "oidc"));
redirect(exchange, "/");
} catch (OidcException e) {
redirectWithError(exchange, e);
}
}));
}
private void postLogin(HttpExchange exchange, byte[] body) throws IOException {
if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
return;
}
LoginRequest request;
try {
request = JSON.readValue(body, LoginRequest.class);
} catch (JsonProcessingException e) {
request = null;
}
if (request == null || request.username() == null || request.password() == null
|| request.username().length() > MAX_CREDENTIAL_LENGTH
|| request.password().length() > MAX_CREDENTIAL_LENGTH) {
ApiServer.send(exchange, 400, Map.of("error", "expected {\"username\", \"password\"}"));
return;
}
String ip = ClientIp.of(exchange, auth.proxyHops());
var login = auth.login(request.username(), request.password().toCharArray(), ip);
switch (login.status()) {
case OK -> {
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
}
case INVALID -> {
System.err.println("home-pump-backend: failed login from " + ip);
ApiServer.send(exchange, 401, Map.of("error", "invalid"));
}
case THROTTLED -> {
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
ApiServer.send(exchange, 429, Map.of("error", "throttled",
"retryAfterSeconds", login.retryAfterSeconds()));
}
}
}
private static Map<String, String> user(Session session) {
return Map.of("user", session.user(), "method", session.method());
}
private static void redirectWithError(HttpExchange exchange, OidcException e) throws IOException {
System.err.println("home-pump-backend: OIDC sign-in failed: " + e.getMessage());
redirect(exchange, "/#login?error=" + e.reason().code());
}
private static void redirect(HttpExchange exchange, String location) throws IOException {
exchange.getResponseHeaders().set("Location", location);
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(302, -1);
}
}
@@ -0,0 +1,29 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import java.util.Arrays;
import java.util.List;
/**
* The client's address for the login throttle. Each trusted proxy appends the address it saw to
* X-Forwarded-For, so the entry {@code hops} from the right is the first one no trusted proxy vouches for
* beyond that; anything the client put in front of it is ignored. Without enough entries the request did not
* come through the proxies, and the socket's address is used.
*/
final class ClientIp {
private ClientIp() {
}
static String of(HttpExchange exchange, int hops) {
List<String> values = exchange.getRequestHeaders().get("X-Forwarded-For");
if (values != null) {
List<String> chain = Arrays.stream(String.join(",", values).split(",")).map(String::trim).toList();
if (chain.size() >= hops && !chain.get(chain.size() - hops).isEmpty()) {
return chain.get(chain.size() - hops);
}
}
return exchange.getRemoteAddress().getAddress().getHostAddress();
}
}
@@ -0,0 +1,45 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.auth.Session;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
/** Lets a request through only with a valid session, and hands the user's name on to the handlers. */
final class SessionFilter extends Filter {
static final String USER_ATTRIBUTE = "user";
private final Authentication auth;
SessionFilter(Authentication auth) {
this.auth = auth;
}
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
Optional<Session> session = auth.session(cookies(exchange));
if (session.isPresent()) {
exchange.setAttribute(USER_ATTRIBUTE, session.get().user());
chain.doFilter(exchange);
} else {
try (exchange) {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
}
}
}
static String cookies(HttpExchange exchange) {
var values = exchange.getRequestHeaders().get("Cookie");
return values == null ? null : String.join("; ", values);
}
@Override
public String description() {
return "session check";
}
}
@@ -0,0 +1,466 @@
package home.backend;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.auth.AuthSettings;
import home.backend.adapters.auth.OidcSettings;
import home.backend.adapters.auth.PasswordHash;
import home.backend.adapters.auth.StubIssuer;
import home.backend.adapters.http.ApiServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Sign-in end to end: local accounts and OIDC against a stub issuer, behind the real HTTP server. */
class AuthApiTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final String SECRET = "0123456789abcdef0123456789abcdef-test";
private static final String PASSWORD = "correct horse";
private static final String LOGIN = "{\"username\":\"tata\",\"password\":\"" + PASSWORD + "\"}";
private static final String CALLBACK = "https://pompa.example/api/auth/oidc/callback";
private static String hash;
private final HttpClient client = HttpClient.newHttpClient();
private final List<ApiServer> servers = new ArrayList<>();
private final List<HttpServer> stubs = new ArrayList<>();
private final List<String> requestedBy = new ArrayList<>();
private StubIssuer issuer;
private ApiServer backend;
private HttpServer gateway;
@TempDir
Path dir;
@BeforeAll
static void hashOnce() {
hash = PasswordHash.create(PASSWORD.toCharArray());
}
@AfterEach
void stop() {
servers.forEach(ApiServer::stop);
stubs.forEach(s -> s.stop(0));
if (issuer != null) {
issuer.stop();
}
}
// --- set-up ---
private void launch(boolean local, boolean oidc, String role) throws IOException {
issuer = new StubIssuer();
launch(local ? users() : null, oidc ? oidcSettings(issuer.issuer(), role) : null, false);
}
private Path users() throws IOException {
return Files.writeString(dir.resolve("users.json"),
"[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]");
}
private static OidcSettings oidcSettings(URI issuer, String role) {
return new OidcSettings(issuer, StubIssuer.CLIENT_ID, StubIssuer.CLIENT_SECRET, URI.create(CALLBACK), role);
}
private void launch(Path usersFile, OidcSettings oidc, boolean disabled) throws IOException {
gateway = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
gateway.createContext("/commands", this::commands);
gateway.start();
stubs.add(gateway);
URI dead = URI.create("http://127.0.0.1:1");
var settings = new Settings(URI.create("http://127.0.0.1:" + gateway.getAddress().getPort()), dead, 0,
Duration.ofSeconds(2), Duration.ofSeconds(2), "s3cret-token", null,
new AuthSettings(usersFile, oidc, SECRET, 30, disabled, 1));
backend = Backend.assemble(settings, Clock.systemUTC());
servers.add(backend);
backend.start();
}
private void commands(HttpExchange exchange) throws IOException {
boolean post = exchange.getRequestMethod().equals("POST");
if (post) {
requestedBy.add(exchange.getRequestHeaders().getFirst("X-Requested-By"));
}
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
+ (post ? "response-applied.json" : "setpoints.json")));
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
// --- requests ---
private HttpResponse<String> send(String method, String path, String body, String... headers) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (headers.length > 0) {
request.headers(headers);
}
return client.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
private HttpResponse<String> get(String path, String... headers) throws Exception {
return send("GET", path, null, headers);
}
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
var all = new ArrayList<>(List.of("Content-Type", "application/json", "X-Requested-With", "home-pump"));
all.addAll(List.of(headers));
return send("POST", path, body, all.toArray(String[]::new));
}
private static String cookie(HttpResponse<?> response, String name) {
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
.findFirst().map(c -> c.split(";")[0]).orElse(null);
}
private static String setCookie(HttpResponse<?> response, String name) {
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
.findFirst().orElse(null);
}
private String signIn() throws Exception {
return cookie(post("/api/auth/login", LOGIN), "hp_session");
}
private static JsonNode json(HttpResponse<String> response) throws IOException {
return JSON.readTree(response.body());
}
private static JsonNode example(String name) throws IOException {
return JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + name + ".json")));
}
// --- what is open and what is not ---
@Test
void configTellsWhichSignInsExist() throws Exception {
launch(true, true, null);
assertEquals(example("auth-config"), json(get("/api/auth/config")));
}
@Test
void everythingElseNeedsASession() throws Exception {
launch(true, true, null);
for (String path : List.of("/api/health", "/api/heat-pump", "/api/setpoints", "/api/audit", "/api/weather")) {
var response = get(path);
assertEquals(401, response.statusCode(), path);
assertEquals("{\"error\":\"unauthenticated\"}", response.body(), path);
}
assertEquals(401, post("/api/setpoints", "{}").statusCode());
assertEquals(401, get("/api/auth/me").statusCode());
assertEquals(200, get("/api/live").statusCode());
assertEquals("ok", get("/api/live").body());
assertEquals(404, get("/api/unknown").statusCode());
}
@Test
void sessionCookieOpensTheApiAndNamesTheRequester() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
assertEquals(200, post("/api/setpoints", "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}",
"Cookie", session, "Remote-User", "intruder").statusCode());
assertEquals(List.of("tata"), requestedBy);
}
@Test
void forgedSessionCookiesAreRefused() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(401, get("/api/health", "Cookie", session + "x").statusCode());
assertEquals(401, get("/api/health", "Cookie", "hp_session=" + session.split("\\.")[0].split("=")[1]).statusCode());
assertEquals(401, get("/api/auth/me", "Cookie", "hp_session=eyJ1c2VyIjoiYWRtaW4ifQ.AAAA").statusCode());
}
// --- local login ---
@Test
void localLoginSetsAHardenedCookieAndMeAnswers() throws Exception {
launch(true, false, null);
var login = post("/api/auth/login", LOGIN);
assertEquals(200, login.statusCode());
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(login));
String set = setCookie(login, "hp_session");
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
assertTrue(set.contains(flag), flag);
}
var me = get("/api/auth/me", "Cookie", cookie(login, "hp_session"));
assertEquals(200, me.statusCode());
assertEquals(example("auth-me"), json(me));
}
@Test
void wrongPasswordAndUnknownUserLookAlike() throws Exception {
launch(true, false, null);
for (String body : List.of("{\"username\":\"tata\",\"password\":\"nope\"}",
"{\"username\":\"ghost\",\"password\":\"" + PASSWORD + "\"}")) {
var response = post("/api/auth/login", body);
assertEquals(401, response.statusCode());
assertEquals("{\"error\":\"invalid\"}", response.body());
assertEquals(null, setCookie(response, "hp_session"));
}
}
@Test
void fiveFailuresThrottleTheUserAndTheAddressEvenForTheRightPassword() throws Exception {
launch(true, false, null);
String wrong = "{\"username\":\"tata\",\"password\":\"nope\"}";
for (int i = 0; i < 5; i++) {
assertEquals(401, post("/api/auth/login", wrong, "X-Forwarded-For", "1.1.1." + i + ", 7.7.7.7").statusCode());
}
var user = post("/api/auth/login", LOGIN, "X-Forwarded-For", "8.8.8.8");
var address = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
"X-Forwarded-For", "2.2.2.2, 7.7.7.7");
var other = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
"X-Forwarded-For", "2.2.2.2, 8.8.8.8");
assertEquals(429, user.statusCode());
assertEquals("throttled", json(user).get("error").asText());
int wait = json(user).get("retryAfterSeconds").asInt();
assertTrue(wait > 14 * 60 && wait <= 15 * 60 + 1, "retry after " + wait);
assertEquals(String.valueOf(wait), user.headers().firstValue("Retry-After").orElseThrow());
assertEquals(429, address.statusCode());
assertEquals(401, other.statusCode());
}
@Test
void loginAndLogoutKeepTheWriteGuards() throws Exception {
launch(true, false, null);
assertEquals(403, post("/api/auth/login", LOGIN, "Origin", "https://evil.example").statusCode());
assertEquals(403, send("POST", "/api/auth/login", LOGIN, "Content-Type", "application/json").statusCode());
assertEquals(415, send("POST", "/api/auth/login", LOGIN, "X-Requested-With", "home-pump",
"Content-Type", "text/plain").statusCode());
assertEquals(400, post("/api/auth/login", "{}").statusCode());
assertEquals(400, post("/api/auth/login", "not json").statusCode());
assertEquals(403, send("POST", "/api/auth/logout", "{}", "Content-Type", "application/json").statusCode());
assertEquals(405, get("/api/auth/login").statusCode());
}
@Test
void logoutClearsTheCookie() throws Exception {
launch(true, false, null);
var response = post("/api/auth/logout", "{}", "Cookie", signIn());
assertEquals(204, response.statusCode());
assertTrue(setCookie(response, "hp_session").startsWith("hp_session=; Max-Age=0"));
}
@Test
void loginIsNotFoundWithoutLocalAccounts() throws Exception {
launch(false, true, null);
assertEquals(404, post("/api/auth/login", LOGIN).statusCode());
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true}"), json(get("/api/auth/config")));
}
@Test
void disabledAuthenticationOpensTheApiAsPhone() throws Exception {
launch(null, null, true);
assertEquals(200, get("/api/health").statusCode());
assertEquals(JSON.readTree("{\"user\":\"phone\",\"method\":\"none\"}"), json(get("/api/auth/me")));
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":false}"), json(get("/api/auth/config")));
assertEquals(204, post("/api/auth/logout", "{}").statusCode());
}
// --- OIDC ---
private record Started(Map<String, String> query, String transaction) {
}
private Started start() throws Exception {
var response = get("/api/auth/oidc/start");
assertEquals(302, response.statusCode());
URI location = URI.create(response.headers().firstValue("Location").orElseThrow());
assertEquals(issuer.issuer() + "/protocol/openid-connect/auth", location.toString().split("\\?")[0]);
String set = setCookie(response, "hp_oidc");
assertTrue(set.contains("HttpOnly") && set.contains("Secure") && set.contains("SameSite=Lax")
&& set.contains("Max-Age=600"), set);
return new Started(query(location.getRawQuery()), cookie(response, "hp_oidc"));
}
private static Map<String, String> query(String raw) {
Map<String, String> query = new HashMap<>();
for (String pair : raw.split("&")) {
String[] kv = pair.split("=", 2);
query.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
return query;
}
private HttpResponse<String> callback(Started started, String queryString) throws Exception {
return get("/api/auth/oidc/callback?" + queryString, "Cookie", started.transaction());
}
private HttpResponse<String> callbackWithCode(Started started) throws Exception {
return callback(started, "code=abc&state=" + started.query().get("state"));
}
@Test
void oidcLoginSendsTheRightRequestAndSignsTheUserIn() throws Exception {
launch(false, true, "home-pump");
Started started = start();
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
var response = callbackWithCode(started);
assertEquals(302, response.statusCode());
assertEquals("/", response.headers().firstValue("Location").orElseThrow());
assertTrue(setCookie(response, "hp_oidc").startsWith("hp_oidc=; Max-Age=0"));
var me = get("/api/auth/me", "Cookie", cookie(response, "hp_session"));
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"oidc\"}"), json(me));
var authorize = started.query();
assertEquals("code", authorize.get("response_type"));
assertEquals(StubIssuer.CLIENT_ID, authorize.get("client_id"));
assertEquals(CALLBACK, authorize.get("redirect_uri"));
assertEquals("S256", authorize.get("code_challenge_method"));
assertTrue(authorize.get("scope").contains("openid"));
var token = issuer.tokenRequests.getFirst();
assertEquals("Basic " + Base64.getEncoder().encodeToString(
(StubIssuer.CLIENT_ID + ":" + StubIssuer.CLIENT_SECRET).getBytes(StandardCharsets.UTF_8)),
token.authorization());
assertEquals("authorization_code", token.form().get("grant_type"));
assertEquals("abc", token.form().get("code"));
assertEquals(CALLBACK, token.form().get("redirect_uri"));
assertEquals(authorize.get("code_challenge"), Base64.getUrlEncoder().withoutPadding().encodeToString(
MessageDigest.getInstance("SHA-256").digest(
token.form().get("code_verifier").getBytes(StandardCharsets.US_ASCII))));
}
@Test
void oidcTokensThatDoNotVerifyEndInOidcFailed() throws Exception {
launch(false, true, null);
long now = Instant.now().getEpochSecond();
List<Function<String, String>> bad = List.of(
nonce -> StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(),
"aud", StubIssuer.CLIENT_ID, "nonce", nonce, "iat", now, "exp", now + 300,
"preferred_username", "tata"), StubIssuer.newKeys().getPrivate()),
nonce -> issuer.idToken(nonce, c -> c.put("aud", "other")),
nonce -> issuer.idToken(nonce, c -> c.put("exp", now - 600)),
nonce -> issuer.idToken("another-nonce", c -> { }));
for (var token : bad) {
Started started = start();
issuer.idTokenToServe = token.apply(started.query().get("nonce"));
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(response, "hp_session"));
}
}
@Test
void wrongStateOrMissingLoginIsRefusedWithoutAskingTheIssuer() throws Exception {
launch(false, true, null);
Started started = start();
var wrongState = callback(started, "code=abc&state=forged");
var noCookie = get("/api/auth/oidc/callback?code=abc&state=" + started.query().get("state"));
var noParameters = callback(started, "");
for (var response : List.of(wrongState, noCookie, noParameters)) {
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
}
assertTrue(issuer.tokenRequests.isEmpty());
}
@Test
void missingRoleIsDenied() throws Exception {
launch(false, true, "admin");
Started started = start();
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(response, "hp_session"));
}
@Test
void refusedByTheUserIsDenied() throws Exception {
launch(false, true, null);
Started started = start();
var response = callback(started, "error=access_denied&state=" + started.query().get("state"));
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
}
@Test
void tokenEndpointRefusalIsOidcFailed() throws Exception {
launch(false, true, null);
Started started = start();
issuer.tokenStatus = 400;
assertEquals("/#login?error=oidc_failed",
callbackWithCode(started).headers().firstValue("Location").orElseThrow());
}
@Test
void keycloakDownBeforeTheStartRedirectsBackWhileLocalLoginWorks() throws Exception {
issuer = new StubIssuer();
URI gone = issuer.issuer();
issuer.stop();
launch(users(), oidcSettings(gone, null), false);
var start = get("/api/auth/oidc/start");
assertEquals(302, start.statusCode());
assertEquals("/#login?error=oidc_unavailable", start.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(start, "hp_oidc"));
assertEquals(200, get("/api/auth/config").statusCode());
assertTrue(signIn() != null);
}
@Test
void keycloakDownDuringTheCallbackIsUnavailable() throws Exception {
launch(true, true, null);
Started started = start();
issuer.stop();
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_unavailable", response.headers().firstValue("Location").orElseThrow());
assertEquals(200, post("/api/auth/login", LOGIN).statusCode());
}
}
@@ -0,0 +1,83 @@
package home.backend;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The environment rules that keep the backend from starting open by accident. */
class AuthSettingsTest {
private static final String SECRET = "0123456789abcdef0123456789abcdef";
private static Map<String, String> env(String... extra) {
Map<String, String> env = new HashMap<>(Map.of("GATEWAY_URL", "http://10.0.0.2:8080",
"INFLUX_URL", "https://influx.example"));
for (int i = 0; i < extra.length; i += 2) {
env.put(extra[i], extra[i + 1]);
}
return env;
}
private static String refusal(String... extra) {
return assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env(extra))).getMessage();
}
@Test
void withoutAnySignInTheBackendRefusesToStart() {
assertTrue(refusal().contains("AUTH_DISABLED=true"));
assertTrue(refusal("AUTH_DISABLED", "false").contains("no sign-in configured"));
}
@Test
void authDisabledIsAnExplicitChoice() {
var auth = Settings.fromEnvironment(env("AUTH_DISABLED", "true")).auth();
assertTrue(auth.disabled());
assertNull(auth.oidc());
}
@Test
void disabledContradictsConfiguredSignIn() {
assertTrue(refusal("AUTH_DISABLED", "true", "USERS_FILE", "/data/users.json").contains("contradicts"));
}
@Test
void configuredSignInNeedsALongSessionSecret() {
assertTrue(refusal("USERS_FILE", "/data/users.json").contains("SESSION_SECRET"));
assertTrue(refusal("USERS_FILE", "/data/users.json", "SESSION_SECRET", "short").contains("32 bytes"));
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "OIDC_CLIENT_ID", "x",
"OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb").contains("SESSION_SECRET"));
}
@Test
void oidcNeedsItsClientSettings() {
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "SESSION_SECRET", SECRET)
.contains("OIDC_CLIENT_ID"));
}
@Test
void localAndOidcAreConfiguredIndependently() {
var local = Settings.fromEnvironment(env("USERS_FILE", "/data/users.json", "SESSION_SECRET", SECRET)).auth();
var oidc = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
"OIDC_CLIENT_ID", "home-pump", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI",
"https://pompa.example/api/auth/oidc/callback", "OIDC_REQUIRED_ROLE", "family",
"SESSION_SECRET", SECRET, "SESSION_DAYS", "7", "TRUSTED_PROXY_HOPS", "2")).auth();
assertEquals("/data/users.json", local.usersFile().toString());
assertNull(local.oidc());
assertEquals(30, local.sessionDays());
assertEquals(1, local.proxyHops());
assertNull(oidc.usersFile());
assertEquals("family", oidc.oidc().requiredRole());
assertEquals(7, oidc.sessionDays());
assertEquals(2, oidc.proxyHops());
assertFalse(oidc.disabled());
}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.auth.AuthSettings;
import home.backend.adapters.http.ApiServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -43,6 +44,8 @@ class BackendTest {
/** Just after the readings in the snapshot fixture, so they count as fresh. */
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
private static final AuthSettings OPEN = new AuthSettings(null, null, null, 30, true, 1);
private final HttpClient client = HttpClient.newHttpClient();
private HttpServer gateway;
private HttpServer influx;
@@ -56,7 +59,7 @@ class BackendTest {
gateway.createContext("/commands", this::commands);
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
"s3cret-token", null);
"s3cret-token", null, OPEN);
backend = Backend.assemble(settings, CLOCK);
backend.start();
}
@@ -83,7 +86,7 @@ class BackendTest {
private HttpResponse<String> post(String path, String body) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
.header("Remote-User", "anna").header("Content-Type", "application/json")
.header("Content-Type", "application/json")
.header("X-Requested-With", "home-pump")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
@@ -199,7 +202,7 @@ class BackendTest {
JSON.readTree(response.body()));
assertEquals(1, commandsSeen.size());
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
assertEquals("anna", commandsSeen.getFirst().requestedBy());
assertEquals("phone", commandsSeen.getFirst().requestedBy());
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
JSON.readTree(commandsSeen.getFirst().body()));
}
@@ -209,7 +212,7 @@ class BackendTest {
Path file = Files.writeString(dir.resolve("presets.json"),
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
Duration.ofSeconds(2), "s3cret-token", file, OPEN), CLOCK);
custom.start();
try {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
@@ -229,7 +232,7 @@ class BackendTest {
@Test
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), null, null), CLOCK);
Duration.ofSeconds(2), null, null, OPEN), CLOCK);
noToken.start();
try {
var base = "http://127.0.0.1:" + noToken.port();
@@ -256,7 +259,7 @@ class BackendTest {
@Test
void urlsMayEndWithASingleSlash() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/", "AUTH_DISABLED", "true"));
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
}
@@ -264,7 +267,7 @@ class BackendTest {
@Test
void settingsHaveDefaults() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example", "AUTH_DISABLED", "true"));
assertEquals(8080, settings.port());
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
@@ -273,7 +276,8 @@ class BackendTest {
@Test
void tokenAndPresetsFileAreOptional() {
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example",
"AUTH_DISABLED", "true");
var none = Settings.fromEnvironment(urls);
var env = new HashMap<>(urls);
env.put("GATEWAY_TOKEN", " s3cret-token ");
@@ -0,0 +1,151 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URI;
import java.time.Clock;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class IdTokenVerifierTest {
private static StubIssuer issuer;
@BeforeAll
static void start() throws IOException {
issuer = new StubIssuer();
}
@AfterAll
static void stop() {
issuer.stop();
}
private IdTokenVerifier verifier(String role) {
return new IdTokenVerifier(new OidcSettings(issuer.issuer(), StubIssuer.CLIENT_ID, "x",
URI.create("https://pompa.example/api/auth/oidc/callback"), role), Clock.systemUTC());
}
private String verify(String token, String role) throws OidcException {
return verifier(role).verify(token, "n1", kid -> kid.equals("k1") ? issuer.publicKey() : null);
}
private OidcException failure(String token, String role) {
return assertThrows(OidcException.class, () -> verify(token, role));
}
private String token(Consumer<Map<String, Object>> change) {
return issuer.idToken("n1", change);
}
@Test
void validTokenGivesTheUserName() throws Exception {
assertEquals("tata", verify(token(c -> { }), "home-pump"));
}
@Test
void userNameFallsBackToEmailThenSubject() throws Exception {
assertEquals("tata@example.pl", verify(token(c -> {
c.remove("preferred_username");
c.put("email", "tata@example.pl");
}), null));
assertEquals("7f1c", verify(token(c -> c.remove("preferred_username")), null));
}
@Test
void signatureOfAnotherKeyIsRefused() {
var forged = StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(), "aud", "home-pump"),
StubIssuer.newKeys().getPrivate());
assertEquals(FAILED, failure(forged, null).reason());
}
@Test
void tamperedPayloadIsRefused() {
String[] parts = token(c -> { }).split("\\.");
String other = token(c -> c.put("preferred_username", "admin")).split("\\.")[1];
assertEquals(FAILED, failure(parts[0] + "." + other + "." + parts[2], null).reason());
}
@Test
void otherAlgorithmsAreRefused() {
String none = "eyJhbGciOiJub25lIn0." + token(c -> { }).split("\\.")[1] + ".";
assertEquals(FAILED, failure(none, null).reason());
}
@Test
void tokenForAnotherClientIsRefused() {
assertEquals(FAILED, failure(token(c -> c.put("aud", "other-app")), null).reason());
}
@Test
void severalAudiencesNeedOurClientAsAuthorizedParty() throws Exception {
assertEquals(FAILED, failure(token(c -> c.put("aud", List.of("home-pump", "account"))), null).reason());
assertEquals("tata", verify(token(c -> {
c.put("aud", List.of("home-pump", "account"));
c.put("azp", "home-pump");
}), null));
}
@Test
void wrongIssuerIsRefused() {
assertEquals(FAILED, failure(token(c -> c.put("iss", "https://evil.example/realms/home")), null).reason());
}
@Test
void expiredTokenIsRefusedButASmallSkewIsAllowed() throws Exception {
long now = java.time.Instant.now().getEpochSecond();
assertEquals(FAILED, failure(token(c -> c.put("exp", now - 120)), null).reason());
assertEquals("tata", verify(token(c -> c.put("exp", now - 10)), null));
}
@Test
void tokenIssuedInTheFutureIsRefused() {
long now = java.time.Instant.now().getEpochSecond();
assertEquals(FAILED, failure(token(c -> c.put("iat", now + 600)), null).reason());
}
@Test
void wrongNonceIsRefused() {
assertEquals(FAILED, failure(issuer.idToken("another", c -> { }), null).reason());
}
@Test
void requiredRoleMayBeARealmRoleOrAClientRole() throws Exception {
assertEquals("tata", verify(token(c -> { }), "home-pump"));
assertEquals("tata", verify(token(c -> {
c.remove("realm_access");
c.put("resource_access", Map.of("home-pump", Map.of("roles", List.of("family"))));
}), "family"));
}
@Test
void missingRoleIsDenied() {
assertEquals(DENIED, failure(token(c -> { }), "admin").reason());
assertEquals(DENIED, failure(token(c -> c.put("resource_access",
Map.of("account", Map.of("roles", List.of("admin"))))), "admin").reason());
}
@Test
void unknownKeyIsRefused() {
var verifier = verifier(null);
assertEquals(FAILED, assertThrows(OidcException.class,
() -> verifier.verify(token(c -> { }), "n1", kid -> {
throw new OidcException(FAILED, "no signing key " + kid);
})).reason());
}
}
@@ -0,0 +1,114 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Hashing, the accounts file and the hash-password command. */
class PasswordTest {
private static String hash;
@BeforeAll
static void hashOnce() {
hash = PasswordHash.create("correct horse".toCharArray());
}
@Test
void hashIsSelfDescribingSaltedAndChecked() {
String[] parts = hash.split("\\$");
assertEquals("pbkdf2-sha256", parts[0]);
assertTrue(Integer.parseInt(parts[1]) >= 310_000);
assertTrue(PasswordHash.matches(hash, "correct horse".toCharArray()));
assertFalse(PasswordHash.matches(hash, "wrong horse".toCharArray()));
assertNotEquals(hash, PasswordHash.create("correct horse".toCharArray()));
}
@Test
void weakOrMalformedHashesAreRefused() {
for (String bad : new String[] {"", "plain", "pbkdf2-sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA",
"pbkdf2-sha256$x$y$z", "md5$600000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA"}) {
assertThrows(IllegalArgumentException.class, () -> PasswordHash.requireValid(bad), bad);
}
}
@Test
void usersFileHoldsAccounts(@TempDir Path dir) throws IOException {
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
assertTrue(users.authenticate("tata", "correct horse".toCharArray()));
assertFalse(users.authenticate("tata", "nope".toCharArray()));
assertFalse(users.authenticate("ghost", "correct horse".toCharArray()));
}
@Test
void badUsersFilesStopTheStart(@TempDir Path dir) throws IOException {
String account = "{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}";
for (String bad : new String[] {"[]", "not json", "[" + account + "," + account + "]",
"[{\"username\":\"a b\",\"passwordHash\":\"" + hash + "\"}]",
"[{\"username\":\"tata\",\"passwordHash\":\"plain\"}]"}) {
Path file = file(dir, bad);
assertThrows(IllegalArgumentException.class, () -> UserStore.load(file), bad);
}
assertThrows(IllegalArgumentException.class, () -> UserStore.load(dir.resolve("missing.json")));
}
/** An unknown user is run through the same hashing, so the answer does not tell which names exist. */
@Test
void unknownUserCostsAboutAsMuchAsAWrongPassword(@TempDir Path dir) throws IOException {
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
users.authenticate("tata", "warm up".toCharArray());
long wrong = nanos(() -> users.authenticate("tata", "nope".toCharArray()));
long unknown = nanos(() -> users.authenticate("ghost", "nope".toCharArray()));
assertTrue(unknown > wrong / 2, "unknown " + unknown + " ns vs wrong " + wrong + " ns");
}
@Test
void commandPrintsAHashFromStdin() throws IOException {
var out = new ByteArrayOutputStream();
int code = PasswordHashCli.run(null, stream("s3cr3t-pass\n"), new PrintStream(out), System.err);
assertEquals(0, code);
assertTrue(PasswordHash.matches(out.toString(StandardCharsets.UTF_8).trim(), "s3cr3t-pass".toCharArray()));
}
@Test
void commandRefusesAnEmptyPassword() throws IOException {
var err = new ByteArrayOutputStream();
assertEquals(1, PasswordHashCli.run(null, stream("\n"), System.out, new PrintStream(err)));
assertTrue(err.toString(StandardCharsets.UTF_8).contains("No password"));
}
private static ByteArrayInputStream stream(String text) {
return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
}
private static Path file(Path dir, String content) throws IOException {
return Files.writeString(dir.resolve("users-" + content.hashCode() + ".json"), content);
}
private static long nanos(Runnable action) {
long start = System.nanoTime();
action.run();
return System.nanoTime() - start;
}
}
@@ -0,0 +1,121 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The session cookie, the throttle and the other signed cookie. */
class SessionsTest {
private static final byte[] SECRET = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z");
private Instant now = T0;
private final Clock clock = new Clock() {
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
};
private final SignedTokens tokens = new SignedTokens(SECRET, clock);
private final Sessions sessions = new Sessions(tokens, clock, Duration.ofDays(30));
private static String cookieHeader(String setCookie) {
return setCookie.split(";")[0];
}
@Test
void cookieCarriesTheSessionAndTheRightFlags() {
String setCookie = sessions.setCookie("tata", "local");
assertEquals(Optional.of(new Session("tata", "local", T0)), sessions.read(cookieHeader(setCookie)));
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
assertTrue(setCookie.contains(flag), flag);
}
}
@Test
void sessionExpiresAfterItsLifetime() {
String cookie = cookieHeader(sessions.setCookie("tata", "oidc"));
now = T0.plus(Duration.ofDays(30)).minusSeconds(1);
assertTrue(sessions.read(cookie).isPresent());
now = T0.plus(Duration.ofDays(30));
assertTrue(sessions.read(cookie).isEmpty());
}
@Test
void tamperedOrForeignCookiesAreRefused() {
String cookie = cookieHeader(sessions.setCookie("tata", "local"));
String other = cookieHeader(sessions.setCookie("mama", "local"));
String forgedPayload = other.substring(0, other.indexOf('.')) + cookie.substring(cookie.indexOf('.'));
var otherSecret = new Sessions(new SignedTokens("another-secret-another-secret-12".getBytes(StandardCharsets.UTF_8),
clock), clock, Duration.ofDays(30));
assertTrue(sessions.read(forgedPayload).isEmpty());
assertTrue(sessions.read(cookie + "x").isEmpty());
assertTrue(sessions.read("hp_session=garbage").isEmpty());
assertTrue(sessions.read("hp_session=a.b.c").isEmpty());
assertTrue(sessions.read(null).isEmpty());
assertTrue(otherSecret.read(cookie).isEmpty());
}
@Test
void aTokenForAnotherPurposeIsNotASession() {
String token = tokens.issue("oidc-login", Duration.ofMinutes(10),
Map.of("user", "tata", "method", "local", "iat", "0"));
assertTrue(sessions.read("hp_session=" + token).isEmpty());
}
@Test
void clearingExpiresTheCookie() {
assertTrue(sessions.clearCookie().startsWith("hp_session=; Max-Age=0"));
}
@Test
void throttleBlocksAfterFiveFailuresUntilTheOldestLeavesTheWindow() {
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
for (int i = 0; i < 4; i++) {
throttle.failed("user:tata", "ip:1.2.3.4");
}
assertEquals(0, throttle.retryAfterSeconds("user:tata", "ip:1.2.3.4"));
now = T0.plusSeconds(60);
throttle.failed("user:tata", "ip:1.2.3.4");
assertEquals(15 * 60 - 60 + 1, throttle.retryAfterSeconds("user:tata"));
assertEquals(0, throttle.retryAfterSeconds("user:mama", "ip:5.6.7.8"));
now = T0.plus(Duration.ofMinutes(15));
assertEquals(0, throttle.retryAfterSeconds("user:tata", "ip:1.2.3.4"));
}
@Test
void successForgetsTheUsersFailures() {
var throttle = new LoginThrottle(clock, 2, Duration.ofMinutes(15));
throttle.failed("user:tata");
throttle.failed("user:tata");
throttle.cleared("user:tata");
assertEquals(0, throttle.retryAfterSeconds("user:tata"));
}
}
@@ -0,0 +1,140 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/** A Keycloak stand-in on localhost: discovery, signing keys and a token endpoint that hands out what a test says. */
public final class StubIssuer {
public static final String CLIENT_ID = "home-pump";
public static final String CLIENT_SECRET = "stub-client-secret";
private static final ObjectMapper JSON = new ObjectMapper();
/** What the token endpoint received. */
public record TokenRequest(String authorization, Map<String, String> form) {
}
public final List<TokenRequest> tokenRequests = new ArrayList<>();
/** What the token endpoint answers with; set by the test. */
public volatile String idTokenToServe = "";
public volatile int tokenStatus = 200;
private final KeyPair keys = newKeys();
private final HttpServer server;
public StubIssuer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/realms/home/.well-known/openid-configuration", exchange -> reply(exchange, 200,
Map.of("issuer", issuer().toString(),
"authorization_endpoint", issuer() + "/protocol/openid-connect/auth",
"token_endpoint", issuer() + "/protocol/openid-connect/token",
"jwks_uri", issuer() + "/protocol/openid-connect/certs")));
server.createContext("/realms/home/protocol/openid-connect/certs", exchange -> reply(exchange, 200,
Map.of("keys", List.of(jwk()))));
server.createContext("/realms/home/protocol/openid-connect/token", this::token);
server.start();
}
public URI issuer() {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/realms/home");
}
public java.security.PublicKey publicKey() {
return keys.getPublic();
}
public void stop() {
server.stop(0);
}
/** A valid ID token for the nonce, with {@code change} applied to the claims. */
public String idToken(String nonce, Consumer<Map<String, Object>> change) {
long now = Instant.now().getEpochSecond();
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("iss", issuer().toString());
claims.put("aud", CLIENT_ID);
claims.put("sub", "7f1c");
claims.put("preferred_username", "tata");
claims.put("iat", now);
claims.put("exp", now + 300);
claims.put("nonce", nonce);
claims.put("realm_access", Map.of("roles", List.of("home-pump")));
change.accept(claims);
return sign("RS256", "k1", claims, keys.getPrivate());
}
/** Signs with any key, so a test can forge a token. */
public static String sign(String alg, String kid, Map<String, Object> claims, PrivateKey key) {
try {
String header = Base64Url.encode(JSON.writeValueAsBytes(Map.of("alg", alg, "kid", kid, "typ", "JWT")));
String signed = header + "." + Base64Url.encode(JSON.writeValueAsBytes(claims));
Signature rsa = Signature.getInstance("SHA256withRSA");
rsa.initSign(key);
rsa.update(signed.getBytes(StandardCharsets.US_ASCII));
return signed + "." + Base64Url.encode(rsa.sign());
} catch (IOException | GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
public static KeyPair newKeys() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
private Map<String, String> jwk() {
var key = (RSAPublicKey) keys.getPublic();
return Map.of("kty", "RSA", "use", "sig", "alg", "RS256", "kid", "k1",
"n", Base64Url.encode(unsigned(key.getModulus())), "e", Base64Url.encode(unsigned(key.getPublicExponent())));
}
private static byte[] unsigned(BigInteger number) {
byte[] bytes = number.toByteArray();
return bytes[0] == 0 ? java.util.Arrays.copyOfRange(bytes, 1, bytes.length) : bytes;
}
private void token(HttpExchange exchange) throws IOException {
Map<String, String> form = new HashMap<>();
for (String pair : new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8).split("&")) {
String[] kv = pair.split("=", 2);
form.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
tokenRequests.add(new TokenRequest(exchange.getRequestHeaders().getFirst("Authorization"), form));
reply(exchange, tokenStatus, Map.of("id_token", idTokenToServe, "token_type", "Bearer"));
}
private static void reply(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}
@@ -7,6 +7,7 @@ import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient;
import home.backend.application.GatewayBattery;
@@ -214,7 +215,7 @@ class ApiServerTest {
refreshes++;
return new RefreshResult(List.of("deye", "house-meter"),
List.of(new RefreshResult.Skipped("heat-pump", 41)));
});
}, Authentication.disabled());
server.start();
}
@@ -389,13 +390,12 @@ class ApiServerTest {
@Test
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
"Remote-User", "anna");
var response = post("/api/battery/schedule", example("battery-schedule-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
new BatteryChange.Slot(3, null, 85, null))) + " by phone"), batterySent);
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
JSON.readTree(response.body()));
}
@@ -642,11 +642,11 @@ class ApiServerTest {
@Test
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}");
assertEquals(200, response.statusCode());
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
assertEquals(List.of("cwu_economy by anna"), restored);
assertEquals(List.of("cwu_economy by phone"), restored);
}
@Test
@@ -730,12 +730,10 @@ class ApiServerTest {
}
@Test
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
post("/api/setpoints", BOOST);
void aRemoteUserHeaderIsNotTrusted() throws Exception {
post("/api/setpoints", BOOST, "Remote-User", "anna");
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
assertEquals(List.of("phone"), sent.stream().map(Command::requestedBy).toList());
}
@Test
@@ -0,0 +1 @@
{ "local": true, "oidc": true }
@@ -0,0 +1 @@
{ "user": "tata", "method": "local" }
+31
View File
@@ -0,0 +1,31 @@
# ADR-014: Sign-in in the app: local accounts and Keycloak, independent
Status: accepted · 2026-10-24
## Context
ADR-009 left the UI without a login, admitted by Traefik's address whitelist. The app is to be
used away from home, and writes to the heat pump and the battery are on. Keycloak runs in Kraków
but may be down, and then my parents must still get in.
## Decision
- The backend authenticates itself; it no longer trusts `Remote-User` (the header handling and
the Traefik middleware that cleared it are dropped). The whitelist of ADR-009 stays in front for now.
- Two independent ways in, either or both configured:
- Local accounts: `USERS_FILE`, PBKDF2-SHA256 hashes (at least 310000 iterations, 600000 by
default), created with `hash-password`. Five failures in 15 minutes block the username and
the client address.
- OIDC with Keycloak: authorization code with PKCE, state and nonce, a confidential client, the
ID token verified in the backend with the JDK. Discovery and keys are fetched lazily, so
Keycloak being down never stops the start or local login.
- A stateless session cookie signed with `SESSION_SECRET` (30 days, HttpOnly, Secure, SameSite=Lax).
- No silent open mode: without either configured the backend refuses to start unless
`AUTH_DISABLED=true`. `/api/live` and `/api/auth/*` are the only open paths.
## Consequences
- Supersedes the "no login" part of ADR-009; its network rules and "no published port" stay.
- Sessions cannot be revoked one by one; changing `SESSION_SECRET` signs everyone out.
- Auditing names the real user, whichever way they came in.
- Operating it needs a Keycloak client and a users file next to the stack, outside the image.
+1 -1
View File
@@ -111,7 +111,7 @@ Safety, for every write:
Non-functional:
- N1 Reachable from outside only through Traefik with authentication.
- N1 Reachable from outside only through Traefik (Krosno addresses), and behind the app's own sign-in: local accounts and Keycloak, independent ([ADR-014](decisions/014-login.md)).
- N2 Recovers on its own from device, InfluxDB and tunnel outages.
- N3 Existing Grafana dashboards keep working.
- N4 Usable by my parents: large controls, few screens, Polish UI.
+2 -3
View File
@@ -20,10 +20,9 @@ services:
traefik.http.routers.home-pump.tls.certresolver: http
traefik.http.routers.home-pump.service: home-pump
traefik.http.services.home-pump.loadbalancer.server.port: "8080"
# ADR-009: only Krosno may open it, and no client may name the user who changes a setpoint.
# ADR-009: only Krosno may open it; the backend signs users in itself (ADR-014).
traefik.http.middlewares.home-pump-krosno.ipwhitelist.sourcerange: ${KROSNO_SOURCES:?Krosno's address or range}
traefik.http.middlewares.home-pump-no-user.headers.customrequestheaders.Remote-User: ""
traefik.http.routers.home-pump.middlewares: home-pump-krosno,home-pump-no-user
traefik.http.routers.home-pump.middlewares: home-pump-krosno
networks:
home-pump: