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:
1 parent
e6d9487f0c
commit
24b7946df1
38 files changed
+2493
-55
No files matched your search
+3
-3
@@ -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
@@ -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" }
|
||||
@@ -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
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user