Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b69beadd76 | ||
|
|
62d61af5dd | ||
|
|
4e8ffc2ec3 | ||
|
|
eac21b17e2 | ||
|
|
73bbb1a0d7 | ||
|
|
f92307d9e1 | ||
|
|
34b12881b9 | ||
|
|
b7b26d83b2 | ||
|
|
24b7946df1 | ||
|
|
16534ddcee |
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"]
|
||||
+63
-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,72 @@ 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", "setup"}` booleans (`auth-config.json`); `setup` is true while `USERS_FILE` has no account |
|
||||
| `POST /api/auth/setup` `{"username","password"}` | first account, once: 200 `{"user","method":"local"}` and the cookie; 409 `{"error":"setup_done"}` once any account exists; 400 `weak_password` (under 10 characters) or `invalid_username`; 404 without `USERS_FILE` |
|
||||
| `GET /api/auth/users` | session needed: `[{"username"}]` |
|
||||
| `POST /api/auth/users` `{"username","password"}` | session needed: adds a local account; 200 `{"username"}`, 409 `exists`, 400 as setup |
|
||||
| `POST /api/auth/password` `{"current","new"}` | session needed, local sessions only (403 `local_only` otherwise): 204; 401 `invalid` (counts toward the throttle), 429, 400 `weak_password` |
|
||||
| `POST /api/auth/users/{name}/delete` | session needed: 204 (removing yourself also clears your cookie); 404 `unknown_user`; 409 `last_user` |
|
||||
| `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 and external monitors: `/api/health` needs a session now |
|
||||
|
||||
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; a `local` session also ends when its account
|
||||
is removed (it is checked on every request).
|
||||
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. The app edits the file: it rewrites it atomically (temp file in the same directory, fsync, rename, mode 600), so mount a **directory** (`./auth:/config/auth`, `USERS_FILE=/config/auth/users.json`) that uid 10001 owns: `chown 10001:10001 auth`. A missing file or one with no accounts is setup mode: the first visitor to the app creates the first account in the app (accepted risk, ADR-014: Traefik admits only the two home networks). Add, remove accounts and change a password in the app; each change is logged (who, what, never a password). A hash can still be made by hand, 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 login attempts in 15 minutes block that username and, separately, that client address (429, `Retry-After`; an attempt is
|
||||
counted before the password is checked, so parallel guesses cannot outrun it, and a success clears the username); a blocked
|
||||
username is refused even with the right password. At most 4 passwords are hashed at once, more get 429 at once. 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`. Without `OIDC_REQUIRED_ROLE`, every user of the realm can sign in; the backend logs a warning at start. 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 +140,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).
|
||||
@@ -9,6 +9,13 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: Europe/Warsaw
|
||||
# Sign-in (README): the accounts directory, writable by uid 10001 (chown 10001:10001 auth); the app rewrites users.json in it. In .env, for example:
|
||||
# USERS_FILE=/config/auth/users.json SESSION_SECRET=<openssl rand -base64 48>
|
||||
# OIDC_ISSUER=https://login.mariuszpacek.pl/realms/home OIDC_CLIENT_ID=home-pump
|
||||
# OIDC_CLIENT_SECRET=<from Keycloak> OIDC_REQUIRED_ROLE=family
|
||||
# OIDC_REDIRECT_URI=https://pompa.mariuszpacek.pl/api/auth/oidc/callback
|
||||
volumes:
|
||||
- ./auth:/config/auth
|
||||
networks: [home-pump]
|
||||
|
||||
networks:
|
||||
|
||||
@@ -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 {
|
||||
@@ -21,6 +26,7 @@ public final class Main {
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
|
||||
@@ -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,20 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
/** Things worth saying at startup. */
|
||||
public List<String> warnings() {
|
||||
if (oidc != null && oidc.requiredRole() == null) {
|
||||
return List.of("OIDC_REQUIRED_ROLE is not set: every user of the Keycloak realm can sign in");
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
|
||||
public final class Authentication {
|
||||
|
||||
public static final String LOCAL = "local";
|
||||
|
||||
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);
|
||||
/** Password hashing is slow on purpose; a flood must not queue up behind it. */
|
||||
private static final int PARALLEL_HASHES = 4;
|
||||
private static final int MIN_PASSWORD = 10;
|
||||
private static final long BUSY_RETRY_SECONDS = 1;
|
||||
|
||||
private final Sessions sessions;
|
||||
private final UserStore users;
|
||||
private final LoginThrottle throttle;
|
||||
private final OidcFlow oidc;
|
||||
private final int proxyHops;
|
||||
private final Semaphore hashing = new Semaphore(PARALLEL_HASHES);
|
||||
|
||||
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) {
|
||||
if (sessions == null) {
|
||||
return Optional.of(Session.OPEN);
|
||||
}
|
||||
// A removed account loses its sessions at once; OIDC sessions end with their expiry.
|
||||
return sessions.read(cookieHeader).filter(s -> !LOCAL.equals(s.method()) || users != null && users.exists(s.user()));
|
||||
}
|
||||
|
||||
/** 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.tryAcquire(userKey, ipKey);
|
||||
if (wait > 0) {
|
||||
return new Login(Status.THROTTLED, wait);
|
||||
}
|
||||
if (!hashing.tryAcquire()) {
|
||||
throttle.release(userKey, ipKey);
|
||||
return new Login(Status.THROTTLED, BUSY_RETRY_SECONDS);
|
||||
}
|
||||
try {
|
||||
if (users.authenticate(username, password)) {
|
||||
throttle.cleared(userKey);
|
||||
throttle.release(ipKey);
|
||||
return new Login(Status.OK, 0);
|
||||
}
|
||||
return new Login(Status.INVALID, 0);
|
||||
} finally {
|
||||
hashing.release();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setupNeeded() {
|
||||
return users != null && users.isEmpty();
|
||||
}
|
||||
|
||||
public static boolean strongEnough(String password) {
|
||||
return password.length() >= MIN_PASSWORD;
|
||||
}
|
||||
|
||||
/** Creates the first account, once: OK for the winner, INVALID (setup is done) for everyone after. */
|
||||
public Login setup(String username, char[] password, String clientIp) {
|
||||
String ipKey = "ip:" + clientIp;
|
||||
long wait = throttle.tryAcquire(ipKey);
|
||||
if (wait > 0) {
|
||||
return new Login(Status.THROTTLED, wait);
|
||||
}
|
||||
if (!users.addFirst(username, PasswordHash.create(password))) {
|
||||
return new Login(Status.INVALID, 0);
|
||||
}
|
||||
System.err.println("home-pump-backend: setup created the first account " + username);
|
||||
throttle.release(ipKey);
|
||||
return new Login(Status.OK, 0);
|
||||
}
|
||||
|
||||
public java.util.List<String> usernames() {
|
||||
return users.usernames();
|
||||
}
|
||||
|
||||
/** @return false when the name is taken */
|
||||
public boolean addUser(String by, String username, char[] password) {
|
||||
boolean added = users.add(username, PasswordHash.create(password));
|
||||
System.err.println("home-pump-backend: " + by + " added account " + username + (added ? "" : " (refused, exists)"));
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Checks the current password like a login does (throttled), then sets the new one. */
|
||||
public Login changePassword(String username, char[] current, char[] next, String clientIp) {
|
||||
Login login = login(username, current, clientIp);
|
||||
if (login.status() == Status.OK) {
|
||||
users.changePassword(username, PasswordHash.create(next));
|
||||
System.err.println("home-pump-backend: " + username + " changed their password");
|
||||
}
|
||||
return login;
|
||||
}
|
||||
|
||||
public UserStore.Removal removeUser(String by, String username) {
|
||||
var removal = users.remove(username);
|
||||
System.err.println("home-pump-backend: " + by + " removed account " + username + ": " + removal);
|
||||
return removal;
|
||||
}
|
||||
|
||||
/** 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,70 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts an attempt against every key before the password is checked, so parallel guesses cannot
|
||||
* outrun the limit; {@link #release} takes it back.
|
||||
*
|
||||
* @return 0 when the attempt may go on, else the seconds until the most blocked key may try again
|
||||
*/
|
||||
public synchronized long tryAcquire(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);
|
||||
}
|
||||
}
|
||||
if (wait > 0) {
|
||||
return wait;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Takes back the newest attempt of each key. */
|
||||
public synchronized void release(String... keys) {
|
||||
for (String key : keys) {
|
||||
var times = failures.get(key);
|
||||
if (times != null) {
|
||||
times.pollLast();
|
||||
if (times.isEmpty()) {
|
||||
failures.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,113 @@
|
||||
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 " + printable(query.get("error")));
|
||||
}
|
||||
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
|
||||
}
|
||||
|
||||
public String clearCookie() {
|
||||
return Cookies.clear(COOKIE, PATH);
|
||||
}
|
||||
|
||||
/** The provider's text goes into the log: letters, digits and a few separators only, and short. */
|
||||
static String printable(String text) {
|
||||
String safe = text == null ? "nothing" : text.replaceAll("[^A-Za-z0-9_. -]", "?");
|
||||
return safe.length() > 40 ? safe.substring(0, 40) + "..." : safe;
|
||||
}
|
||||
|
||||
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,179 @@
|
||||
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();
|
||||
}
|
||||
|
||||
/** The network calls run outside the lock, so a hanging identity provider holds up only its own callers. */
|
||||
Endpoints endpoints() throws OidcException {
|
||||
synchronized (this) {
|
||||
if (endpoints != null && !endpointsAt.plus(CACHE).isBefore(clock.instant())) {
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
var fresh = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"), uri(doc, "jwks_uri"));
|
||||
synchronized (this) {
|
||||
endpoints = fresh;
|
||||
endpointsAt = clock.instant();
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicKey key(String kid) throws OidcException {
|
||||
synchronized (this) {
|
||||
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
|
||||
boolean unknownAndRefetchable = !keys.containsKey(kid)
|
||||
&& keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
|
||||
if (!stale && !unknownAndRefetchable) {
|
||||
return known(kid);
|
||||
}
|
||||
}
|
||||
Map<String, PublicKey> fresh = parseKeys(getJson(endpoints().jwks()));
|
||||
synchronized (this) {
|
||||
keys = fresh;
|
||||
keysAt = clock.instant();
|
||||
return known(kid);
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey known(String kid) throws OidcException {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** A key that cannot be read is skipped, so one odd entry does not lock everyone out. */
|
||||
private static Map<String, PublicKey> parseKeys(JsonNode jwks) {
|
||||
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) {
|
||||
System.err.println("home-pump-backend: skipping unusable signing key " + jwk.get("kid").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
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,151 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. A missing file is an empty store
|
||||
* (setup mode). Every change is written back atomically before it counts, under one lock.
|
||||
*/
|
||||
public final class UserStore {
|
||||
|
||||
public enum Removal { REMOVED, UNKNOWN, LAST }
|
||||
|
||||
private record Entry(String username, String passwordHash) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
/** 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 Path file;
|
||||
private Map<String, String> hashes;
|
||||
|
||||
UserStore(Path file, Map<String, String> hashes) {
|
||||
this.file = file;
|
||||
this.hashes = new LinkedHashMap<>(hashes);
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when the file exists but is unreadable or holds a bad account */
|
||||
public static UserStore load(Path file) {
|
||||
Map<String, String> hashes = new LinkedHashMap<>();
|
||||
if (Files.exists(file)) {
|
||||
List<Entry> entries;
|
||||
try {
|
||||
entries = JSON.readValue(file.toFile(), new TypeReference<>() {
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new UserStore(file, hashes);
|
||||
}
|
||||
|
||||
public synchronized boolean isEmpty() {
|
||||
return hashes.isEmpty();
|
||||
}
|
||||
|
||||
public synchronized boolean exists(String username) {
|
||||
return hashes.containsKey(username);
|
||||
}
|
||||
|
||||
public synchronized List<String> usernames() {
|
||||
return List.copyOf(hashes.keySet());
|
||||
}
|
||||
|
||||
public boolean authenticate(String username, char[] password) {
|
||||
String stored;
|
||||
synchronized (this) {
|
||||
stored = hashes.get(username);
|
||||
}
|
||||
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
|
||||
return stored != null && matches;
|
||||
}
|
||||
|
||||
/** The first account; false when there already is one, so only one setup can win. */
|
||||
public synchronized boolean addFirst(String username, String hash) {
|
||||
return hashes.isEmpty() && put(username, hash);
|
||||
}
|
||||
|
||||
/** @return false when the name is taken */
|
||||
public synchronized boolean add(String username, String hash) {
|
||||
return !hashes.containsKey(username) && put(username, hash);
|
||||
}
|
||||
|
||||
/** @return false when there is no such account */
|
||||
public synchronized boolean changePassword(String username, String hash) {
|
||||
return hashes.containsKey(username) && put(username, hash);
|
||||
}
|
||||
|
||||
public synchronized Removal remove(String username) {
|
||||
if (!hashes.containsKey(username)) {
|
||||
return Removal.UNKNOWN;
|
||||
}
|
||||
if (hashes.size() == 1) {
|
||||
return Removal.LAST;
|
||||
}
|
||||
var next = new LinkedHashMap<>(hashes);
|
||||
next.remove(username);
|
||||
write(next);
|
||||
hashes = next;
|
||||
return Removal.REMOVED;
|
||||
}
|
||||
|
||||
private boolean put(String username, String hash) {
|
||||
var next = new LinkedHashMap<>(hashes);
|
||||
next.put(username, hash);
|
||||
write(next);
|
||||
hashes = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Temp file next to the target, synced, then renamed over it; the old file stays if anything fails. */
|
||||
private void write(Map<String, String> accounts) {
|
||||
Path directory = file.toAbsolutePath().getParent();
|
||||
Path temp = null;
|
||||
try {
|
||||
temp = Files.createTempFile(directory, "users", ".tmp",
|
||||
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
Files.write(temp, JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(accounts.entrySet().stream()
|
||||
.map(e -> new Entry(e.getKey(), e.getValue())).toList()));
|
||||
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException e) {
|
||||
if (temp != null) {
|
||||
temp.toFile().delete();
|
||||
}
|
||||
throw new UncheckedIOException("cannot write USERS_FILE " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
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,32 @@ 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/setup", signIn::setup);
|
||||
openRoute("/api/auth/users", signIn::users);
|
||||
openRoute("/api/auth/password", signIn::password);
|
||||
open("/api/auth/users/", signIn::deleteUser);
|
||||
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 +149,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 +167,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 +214,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 +367,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 +410,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 +437,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 +449,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,284 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
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;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The endpoints under {@code /api/auth/}: sign-in needs no session, managing accounts does. */
|
||||
final class AuthEndpoints {
|
||||
|
||||
private record LoginRequest(String username, String password) {
|
||||
}
|
||||
|
||||
private record PasswordRequest(String current, @JsonProperty("new") String next) {
|
||||
}
|
||||
|
||||
private static final Pattern DELETE_PATH = Pattern.compile("/api/auth/users/([^/]{1,64})/delete");
|
||||
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(),
|
||||
"setup", auth.setupNeeded()))));
|
||||
}
|
||||
|
||||
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 = credentials(exchange, body);
|
||||
if (request == null) {
|
||||
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 -> throttled(exchange, login);
|
||||
}
|
||||
}
|
||||
|
||||
/** The username and password of the body, or null after answering 400. */
|
||||
private static LoginRequest credentials(HttpExchange exchange, byte[] body) throws IOException {
|
||||
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 null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void throttled(HttpExchange exchange, Authentication.Login login) throws IOException {
|
||||
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
|
||||
ApiServer.send(exchange, 429, Map.of("error", "throttled", "retryAfterSeconds", login.retryAfterSeconds()));
|
||||
}
|
||||
|
||||
/** True when the name and password may become an account; else answers 400. */
|
||||
private static boolean acceptable(HttpExchange exchange, String username, String password) throws IOException {
|
||||
if (!Session.isValidUser(username)) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "invalid_username"));
|
||||
return false;
|
||||
}
|
||||
if (!Authentication.strongEnough(password)) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void setup(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||
if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
return;
|
||||
}
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
if (!auth.setupNeeded()) {
|
||||
ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||
return;
|
||||
}
|
||||
if (!acceptable(exchange, request.username(), request.password())) {
|
||||
return;
|
||||
}
|
||||
var result = auth.setup(request.username(), request.password().toCharArray(),
|
||||
ClientIp.of(exchange, auth.proxyHops()));
|
||||
switch (result.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 -> ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||
case THROTTLED -> throttled(exchange, result);
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
/** Runs the action for a signed-in user of a configured local store; else answers 401 or 404. */
|
||||
private void asUser(HttpExchange exchange, SignedIn action) throws IOException {
|
||||
var session = auth.session(SessionFilter.cookies(exchange));
|
||||
if (session.isEmpty()) {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
} else if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
} else {
|
||||
action.run(session.get());
|
||||
}
|
||||
}
|
||||
|
||||
private interface SignedIn {
|
||||
void run(Session session) throws IOException;
|
||||
}
|
||||
|
||||
void users(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of(
|
||||
"GET", () -> asUser(exchange, session -> ApiServer.send(exchange, 200,
|
||||
auth.usernames().stream().map(name -> Map.of("username", name)).toList())),
|
||||
"POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null || !acceptable(exchange, request.username(), request.password())) {
|
||||
return;
|
||||
}
|
||||
if (auth.addUser(session.user(), request.username(), request.password().toCharArray())) {
|
||||
ApiServer.send(exchange, 200, Map.of("username", request.username()));
|
||||
} else {
|
||||
ApiServer.send(exchange, 409, Map.of("error", "exists"));
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
void password(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
PasswordRequest request;
|
||||
try {
|
||||
request = JSON.readValue(body, PasswordRequest.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
request = null;
|
||||
}
|
||||
if (request == null || request.current() == null || request.next() == null
|
||||
|| request.current().length() > MAX_CREDENTIAL_LENGTH
|
||||
|| request.next().length() > MAX_CREDENTIAL_LENGTH) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "expected {\"current\", \"new\"}"));
|
||||
} else if (!Authentication.LOCAL.equals(session.method())) {
|
||||
ApiServer.send(exchange, 403, Map.of("error", "local_only"));
|
||||
} else if (!Authentication.strongEnough(request.next())) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||
} else {
|
||||
var result = auth.changePassword(session.user(), request.current().toCharArray(),
|
||||
request.next().toCharArray(), ClientIp.of(exchange, auth.proxyHops()));
|
||||
switch (result.status()) {
|
||||
case OK -> exchange.sendResponseHeaders(204, -1);
|
||||
case INVALID -> ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||
case THROTTLED -> throttled(exchange, result);
|
||||
}
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
void deleteUser(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
var path = DELETE_PATH.matcher(exchange.getRequestURI().getRawPath());
|
||||
if (!path.matches()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
String name = path.group(1);
|
||||
switch (auth.removeUser(session.user(), name)) {
|
||||
case UNKNOWN -> ApiServer.send(exchange, 404, Map.of("error", "unknown_user"));
|
||||
case LAST -> ApiServer.send(exchange, 409, Map.of("error", "last_user"));
|
||||
case REMOVED -> {
|
||||
if (name.equals(session.user())) {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.clearedSessionCookie());
|
||||
}
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
}
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
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,672 @@
|
||||
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.assertFalse;
|
||||
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 SIGNING_KEY = "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_KEY, 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, SIGNING_KEY, 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 sessionOfARemovedAccountStopsWorking() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
|
||||
|
||||
Files.writeString(dir.resolve("users.json"), "[{\"username\":\"mama\",\"passwordHash\":\"" + hash + "\"}]");
|
||||
launch(dir.resolve("users.json"), null, false);
|
||||
|
||||
assertEquals(401, get("/api/health", "Cookie", session).statusCode());
|
||||
assertEquals(401, get("/api/auth/me", "Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelWrongPasswordsAreEvaluatedAtMostFiveTimes() throws Exception {
|
||||
launch(true, false, null);
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(40);
|
||||
var futures = new ArrayList<java.util.concurrent.Future<Integer>>();
|
||||
for (int i = 0; i < 40; i++) {
|
||||
futures.add(pool.submit(() -> post("/api/auth/login", "{\"username\":\"tata\",\"password\":\"nope\"}")
|
||||
.statusCode()));
|
||||
}
|
||||
int evaluated = 0;
|
||||
for (var future : futures) {
|
||||
if (future.get() == 401) {
|
||||
evaluated++;
|
||||
}
|
||||
}
|
||||
pool.shutdown();
|
||||
|
||||
assertTrue(evaluated >= 1 && evaluated <= 5, "evaluated " + evaluated);
|
||||
}
|
||||
|
||||
// --- first account and account management ---
|
||||
|
||||
private static final String NEW_PASSWORD = "a brand new password";
|
||||
|
||||
private static String account(String name, String password) {
|
||||
return "{\"username\":\"" + name + "\",\"password\":\"" + password + "\"}";
|
||||
}
|
||||
|
||||
private void launchInSetupMode() throws IOException {
|
||||
launch(dir.resolve("auth-users.json"), null, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupCreatesTheFirstAccountOnceAndSignsItIn() throws Exception {
|
||||
launchInSetupMode();
|
||||
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":true}"), json(get("/api/auth/config")));
|
||||
|
||||
var created = post("/api/auth/setup", account("tata", NEW_PASSWORD));
|
||||
|
||||
assertEquals(200, created.statusCode());
|
||||
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(created));
|
||||
assertEquals(200, get("/api/health", "Cookie", cookie(created, "hp_session")).statusCode());
|
||||
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
var second = post("/api/auth/setup", account("mama", NEW_PASSWORD));
|
||||
assertEquals(409, second.statusCode());
|
||||
assertEquals("{\"error\":\"setup_done\"}", second.body());
|
||||
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(401, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupWithAWeakPasswordOrOddNameIsRefusedAndCreatesNothing() throws Exception {
|
||||
launchInSetupMode();
|
||||
|
||||
var weak = post("/api/auth/setup", account("tata", "short"));
|
||||
var odd = post("/api/auth/setup", account("a b", NEW_PASSWORD));
|
||||
|
||||
assertEquals(400, weak.statusCode());
|
||||
assertEquals("{\"error\":\"weak_password\"}", weak.body());
|
||||
assertEquals(400, odd.statusCode());
|
||||
assertEquals(true, json(get("/api/auth/config")).get("setup").asBoolean());
|
||||
assertFalse(Files.exists(dir.resolve("auth-users.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupKeepsTheWriteGuards() throws Exception {
|
||||
launchInSetupMode();
|
||||
|
||||
assertEquals(403, post("/api/auth/setup", account("tata", NEW_PASSWORD), "Origin", "https://evil.example")
|
||||
.statusCode());
|
||||
assertEquals(403, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "Content-Type",
|
||||
"application/json").statusCode());
|
||||
assertEquals(415, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "X-Requested-With",
|
||||
"home-pump", "Content-Type", "text/plain").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupIsNotFoundWithoutLocalAccounts() throws Exception {
|
||||
launch(false, true, null);
|
||||
|
||||
assertEquals(404, post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoParallelSetupsLeaveExactlyOneWinner() throws Exception {
|
||||
launchInSetupMode();
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
var a = pool.submit(() -> post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
|
||||
var b = pool.submit(() -> post("/api/auth/setup", account("mama", NEW_PASSWORD)).statusCode());
|
||||
var codes = new java.util.TreeSet<>(List.of(a.get(), b.get()));
|
||||
pool.shutdown();
|
||||
|
||||
assertEquals(new java.util.TreeSet<>(List.of(200, 409)), codes);
|
||||
assertEquals(1, JSON.readTree(Files.readString(dir.resolve("auth-users.json"))).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountManagementNeedsASession() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
assertEquals(401, get("/api/auth/users").statusCode());
|
||||
assertEquals(401, post("/api/auth/users", account("mama", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(401, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}")
|
||||
.statusCode());
|
||||
assertEquals(401, post("/api/auth/users/tata/delete", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usersCanBeListedAddedAndRemovedButNotTheLastOne() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
|
||||
assertEquals(JSON.readTree("[{\"username\":\"tata\"}]"), json(get("/api/auth/users", "Cookie", session)));
|
||||
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(409, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(400, post("/api/auth/users", account("zosia", "short"), "Cookie", session).statusCode());
|
||||
assertEquals(2, json(get("/api/auth/users", "Cookie", session)).size());
|
||||
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||
|
||||
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
|
||||
assertEquals(404, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
|
||||
var last = post("/api/auth/users/tata/delete", "{}", "Cookie", session);
|
||||
assertEquals(409, last.statusCode());
|
||||
assertEquals("{\"error\":\"last_user\"}", last.body());
|
||||
assertEquals(403, send("POST", "/api/auth/users/mama/delete", "{}", "Cookie", session,
|
||||
"Content-Type", "application/json").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOidcSessionMayManageAccountsButNotChangeAPassword() throws Exception {
|
||||
launch(true, true, null);
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||
String session = cookie(callbackWithCode(started), "hp_session");
|
||||
|
||||
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(403, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}",
|
||||
"Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removedAccountLosesItsSessionAtOnceAndRemovingYourselfSignsYouOut() throws Exception {
|
||||
launch(true, false, null);
|
||||
String tata = signIn();
|
||||
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", tata);
|
||||
String mama = cookie(post("/api/auth/login", account("mama", NEW_PASSWORD)), "hp_session");
|
||||
assertEquals(200, get("/api/health", "Cookie", mama).statusCode());
|
||||
|
||||
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", tata).statusCode());
|
||||
assertEquals(401, get("/api/health", "Cookie", mama).statusCode());
|
||||
|
||||
post("/api/auth/users", account("zosia", NEW_PASSWORD), "Cookie", tata);
|
||||
var self = post("/api/auth/users/tata/delete", "{}", "Cookie", tata);
|
||||
assertEquals(204, self.statusCode());
|
||||
assertTrue(setCookie(self, "hp_session").startsWith("hp_session=; Max-Age=0"));
|
||||
assertEquals(401, get("/api/health", "Cookie", tata).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownPasswordChangeNeedsTheCurrentOneAndCountsFailuresInTheThrottle() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
String wrong = "{\"current\":\"nope\",\"new\":\"" + NEW_PASSWORD + "\"}";
|
||||
|
||||
assertEquals(400, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\"short\"}",
|
||||
"Cookie", session).statusCode());
|
||||
var rejected = post("/api/auth/password", wrong, "Cookie", session);
|
||||
assertEquals(401, rejected.statusCode());
|
||||
assertEquals("{\"error\":\"invalid\"}", rejected.body());
|
||||
assertEquals(204, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\""
|
||||
+ NEW_PASSWORD + "\"}", "Cookie", session).statusCode());
|
||||
|
||||
assertEquals(401, post("/api/auth/login", LOGIN).statusCode());
|
||||
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
|
||||
for (int i = 0; i < 5; i++) {
|
||||
post("/api/auth/password", wrong, "Cookie", session);
|
||||
}
|
||||
assertEquals(429, post("/api/auth/password", wrong, "Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesSurviveARestartBecauseTheFileIsRewritten() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session);
|
||||
|
||||
launch(dir.resolve("users.json"), null, false);
|
||||
|
||||
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).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,\"setup\":false}"), 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,\"setup\":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_KEY).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,96 @@
|
||||
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 SIGNING_KEY = "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 oidcWithoutARequiredRoleWarns() {
|
||||
var open = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
|
||||
"SESSION_SECRET", SIGNING_KEY)).auth();
|
||||
var restricted = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
|
||||
"OIDC_REQUIRED_ROLE", "family", "SESSION_SECRET", SIGNING_KEY)).auth();
|
||||
|
||||
assertTrue(open.warnings().getFirst().contains("every user of the Keycloak realm"));
|
||||
assertTrue(restricted.warnings().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcNeedsItsClientSettings() {
|
||||
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "SESSION_SECRET", SIGNING_KEY)
|
||||
.contains("OIDC_CLIENT_ID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localAndOidcAreConfiguredIndependently() {
|
||||
var local = Settings.fromEnvironment(env("USERS_FILE", "/data/users.json", "SESSION_SECRET", SIGNING_KEY)).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", SIGNING_KEY, "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,164 @@
|
||||
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.io.UncheckedIOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.List;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrEmptyFileIsAnEmptyStoreForSetup(@TempDir Path dir) throws IOException {
|
||||
assertTrue(UserStore.load(dir.resolve("missing.json")).isEmpty());
|
||||
assertTrue(UserStore.load(file(dir, "[]")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesAreWrittenAtomicallyPrivatelyAndReadBack(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("users.json");
|
||||
UserStore users = UserStore.load(file);
|
||||
|
||||
assertTrue(users.addFirst("tata", hash));
|
||||
assertFalse(users.addFirst("mama", hash));
|
||||
assertTrue(users.add("mama", hash));
|
||||
assertFalse(users.add("mama", hash));
|
||||
assertTrue(users.changePassword("mama", PasswordHash.create("another password".toCharArray())));
|
||||
assertFalse(users.changePassword("ghost", hash));
|
||||
|
||||
assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file)));
|
||||
try (var listing = Files.list(dir)) {
|
||||
assertEquals(List.of("users.json"), listing.map(p -> p.getFileName().toString()).toList());
|
||||
}
|
||||
UserStore again = UserStore.load(file);
|
||||
assertEquals(List.of("tata", "mama"), again.usernames());
|
||||
assertTrue(again.authenticate("tata", "correct horse".toCharArray()));
|
||||
assertTrue(again.authenticate("mama", "another password".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastAccountCannotBeRemoved(@TempDir Path dir) {
|
||||
UserStore users = UserStore.load(dir.resolve("users.json"));
|
||||
users.addFirst("tata", hash);
|
||||
users.add("mama", hash);
|
||||
|
||||
assertEquals(UserStore.Removal.REMOVED, users.remove("mama"));
|
||||
assertEquals(UserStore.Removal.LAST, users.remove("tata"));
|
||||
assertEquals(UserStore.Removal.UNKNOWN, users.remove("mama"));
|
||||
assertEquals(List.of("tata"), UserStore.load(dir.resolve("users.json")).usernames());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteLeavesTheStoreAsItWas(@TempDir Path dir) {
|
||||
UserStore users = UserStore.load(dir.resolve("gone/users.json"));
|
||||
|
||||
assertThrows(UncheckedIOException.class, () -> users.addFirst("tata", hash));
|
||||
assertTrue(users.isEmpty());
|
||||
}
|
||||
|
||||
/** 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,153 @@
|
||||
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[] SIGNING_KEY = "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(SIGNING_KEY, 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 throttleBlocksTheSixthAttemptUntilTheOldestLeavesTheWindow() {
|
||||
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
|
||||
for (int i = 0; i < 4; i++) {
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
}
|
||||
|
||||
now = T0.plusSeconds(60);
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
|
||||
assertEquals(15 * 60 - 60 + 1, throttle.tryAcquire("user:tata"));
|
||||
assertEquals(0, throttle.tryAcquire("user:mama", "ip:5.6.7.8"));
|
||||
now = T0.plus(Duration.ofMinutes(15));
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasedAttemptsAndSuccessDoNotCount() {
|
||||
var throttle = new LoginThrottle(clock, 2, Duration.ofMinutes(15));
|
||||
throttle.tryAcquire("user:tata");
|
||||
throttle.release("user:tata");
|
||||
throttle.tryAcquire("user:tata");
|
||||
assertEquals(0, throttle.tryAcquire("user:tata"));
|
||||
assertTrue(throttle.tryAcquire("user:tata") > 0);
|
||||
|
||||
throttle.cleared("user:tata");
|
||||
|
||||
assertEquals(0, throttle.tryAcquire("user:tata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelAttemptsCannotOutrunTheLimit() throws Exception {
|
||||
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(32);
|
||||
var allowed = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var tasks = new java.util.ArrayList<java.util.concurrent.Future<?>>();
|
||||
for (int i = 0; i < 200; i++) {
|
||||
tasks.add(pool.submit(() -> {
|
||||
if (throttle.tryAcquire("user:tata", "ip:1.2.3.4") == 0) {
|
||||
allowed.incrementAndGet();
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (var task : tasks) {
|
||||
task.get();
|
||||
}
|
||||
pool.shutdown();
|
||||
|
||||
assertEquals(5, allowed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void odditiesInTheProvidersErrorAreCleanedForTheLog() {
|
||||
assertEquals("server_error", OidcFlow.printable("server_error"));
|
||||
assertEquals("a?b?c", OidcFlow.printable("a\nb\rc"));
|
||||
assertEquals(40 + 3, OidcFlow.printable("x".repeat(100)).length());
|
||||
assertEquals("nothing", OidcFlow.printable(null));
|
||||
}
|
||||
}
|
||||
@@ -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_KEY = "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(Map.of("kty", "RSA", "kid", "broken", "n", "***", "e", "AQAB"), 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, "setup": false }
|
||||
@@ -0,0 +1 @@
|
||||
{ "user": "tata", "method": "local" }
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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), managed in the app by any signed-in user; the backend rewrites the file atomically. 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.
|
||||
- With `USERS_FILE` set but no account in it the app is in setup mode: the first visitor creates the
|
||||
first account. Accepted because Traefik admits only the two home networks.
|
||||
- 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.
|
||||
@@ -35,6 +35,18 @@ Podgląd opens with the alarm and one collapsible line per distinct IMGW warning
|
||||
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
|
||||
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
|
||||
|
||||
## Login
|
||||
|
||||
`AuthGate` (`Auth.tsx`) asks `api/auth/me` first: 200 shows the app, 401 the login screen, any other failure "Brak połączenia z
|
||||
serwerem" with a retry. The screen has the login and password form (always the way in when Keycloak is down) and, when
|
||||
`api/auth/config` says `oidc`, "Zaloguj przez Keycloak" (a full page visit to `api/auth/oidc/start`; its failures return as
|
||||
`#login?error=…`). If the config cannot be read the form still shows. A 401 from any poller or write (`api.ts` reports it)
|
||||
ends the session: the app unmounts, so every poller stops, and the login screen says "Sesja wygasła"; the hash is kept, so
|
||||
after login the same screen opens. "Wyloguj" is in Stan and the side panel.
|
||||
|
||||
When `api/auth/config` has `setup`, the form is "Utwórz pierwsze konto" (`api/auth/setup`; a 409 falls back to the login form).
|
||||
The "Konta" card at the end of Ustawienia (`Accounts.tsx`, only with a login of our own) adds and deletes accounts and changes the own password.
|
||||
|
||||
## Install on a phone (PWA)
|
||||
|
||||
The build has a manifest (`public/`), icons and a service worker (`src/sw.js`, written to `dist/sw.js` with the list of built
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -20,6 +20,8 @@ server {
|
||||
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL};
|
||||
# Do not set X-Forwarded-For here (no $proxy_add_x_forwarded_for): the backend's login throttle
|
||||
# takes the entry Traefik added, TRUSTED_PROXY_HOPS=1, and nginx passes the header on unchanged.
|
||||
# With the port: the backend compares Origin with Host before a write.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_read_timeout 30s;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { answer } from "./fixtures";
|
||||
|
||||
type Handler = (body: any) => Promise<Response>;
|
||||
const bodyOf = (init?: RequestInit) => (init?.body ? JSON.parse(String(init.body)) : undefined);
|
||||
const LONG = "dlugie-haslo-1";
|
||||
const empty = () => Promise.resolve(new Response(null, { status: 204 }));
|
||||
|
||||
// A logged-in user and a list of accounts; POSTs go to the handlers by URL.
|
||||
async function open(method: string, users: string[], posts: Record<string, Handler> = {}) {
|
||||
const fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") return (posts[url] ?? (() => answer({}, 404)))(bodyOf(init));
|
||||
if (url === "api/auth/me") return answer({ user: "mariusz", method });
|
||||
if (url === "api/auth/users") return answer(users.map((username) => ({ username })));
|
||||
return answer({}, 404);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await mount();
|
||||
return fetch;
|
||||
}
|
||||
async function mount() {
|
||||
const { AuthGate } = await import("./Auth");
|
||||
const { Accounts } = await import("./Accounts");
|
||||
render(<AuthGate><Accounts /></AuthGate>);
|
||||
}
|
||||
const type = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
const click = (name: string) => fireEvent.click(screen.getByRole("button", { name }));
|
||||
const fillNew = (name: string, password: string, repeat = password) => {
|
||||
click("Dodaj konto");
|
||||
type("Login", name);
|
||||
type("Hasło", password);
|
||||
type("Powtórz hasło", repeat);
|
||||
click("Zapisz");
|
||||
};
|
||||
const changePassword = () => {
|
||||
click("Zmień moje hasło");
|
||||
type("Obecne hasło", "stare-haslo-1");
|
||||
type("Nowe hasło", "nowe-haslo-123");
|
||||
type("Powtórz hasło", "nowe-haslo-123");
|
||||
click("Zapisz");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
|
||||
});
|
||||
|
||||
describe("Accounts", () => {
|
||||
it("lists the accounts and marks the own one", async () => {
|
||||
await open("local", ["mariusz", "ola"]);
|
||||
|
||||
expect(await screen.findByText("ola")).toBeInTheDocument();
|
||||
expect(screen.getByText("(Ty)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not shown on a server without login", async () => {
|
||||
const fetch = await open("none", []);
|
||||
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith("api/auth/me", expect.anything()));
|
||||
expect(screen.queryByText("Konta")).toBeNull();
|
||||
expect(fetch.mock.calls.map(([url]) => url)).not.toContain("api/auth/users");
|
||||
});
|
||||
|
||||
it("adds an account and lists the accounts again", async () => {
|
||||
const post = vi.fn(() => answer({ username: "ola" }));
|
||||
const fetch = await open("local", ["mariusz"], { "api/auth/users": post });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
fillNew("ola", "dlugie-haslo-1");
|
||||
|
||||
expect(await screen.findByText("Dodano konto")).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledWith({ username: "ola", password: LONG });
|
||||
expect(fetch.mock.calls.filter(([url, init]) => url === "api/auth/users" && init?.method !== "POST")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["exists", () => answer({ error: "exists" }, 409), "Takie konto już istnieje"],
|
||||
["weak", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
|
||||
])("says so when adding fails: %s", async (_name, reply, text) => {
|
||||
await open("local", ["mariusz"], { "api/auth/users": reply });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
fillNew("ola", "dlugie-haslo-1");
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("checks the repeated password before sending", async () => {
|
||||
const post = vi.fn(() => answer({}));
|
||||
await open("local", ["mariusz"], { "api/auth/users": post });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
fillNew("ola", "dlugie-haslo-1", "inne-haslo-123");
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Hasła nie są takie same");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("changes the own password", async () => {
|
||||
const post = vi.fn(empty);
|
||||
await open("local", ["mariusz"], { "api/auth/password": post });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
changePassword();
|
||||
|
||||
expect(await screen.findByText("Hasło zmienione")).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledWith({ current: "stare-haslo-1", new: "nowe-haslo-123" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["401", () => answer({ error: "invalid" }, 401), "Nieprawidłowe obecne hasło"],
|
||||
["400", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
|
||||
["429", () => answer({ error: "throttled", retryAfterSeconds: 120 }, 429), "Za dużo prób – spróbuj za 2 min"],
|
||||
])("says so when the password change fails: %s", async (_name, reply, text) => {
|
||||
await open("local", ["mariusz"], { "api/auth/password": reply });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
changePassword();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("offers no password change to a Keycloak user", async () => {
|
||||
await open("oidc", ["mariusz"]);
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Dodaj konto" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Zmień moje hasło" })).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes only after the confirmation", async () => {
|
||||
const post = vi.fn(empty);
|
||||
await open("local", ["mariusz", "ola"], { "api/auth/users/ola/delete": post });
|
||||
await screen.findByText("ola");
|
||||
|
||||
click("Usuń ola");
|
||||
expect(screen.getByText("Usunąć konto ola?")).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
click("Anuluj");
|
||||
expect(screen.queryByText("Usunąć konto ola?")).toBeNull();
|
||||
|
||||
click("Usuń ola");
|
||||
click("Tak, usuń");
|
||||
|
||||
expect(await screen.findByText("Usunięto konto ola")).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not delete the last account", async () => {
|
||||
await open("local", ["mariusz"]);
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Usuń mariusz" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("says so when the server refuses to delete the last account", async () => {
|
||||
await open("local", ["mariusz", "ola"], { "api/auth/users/ola/delete": () => answer({ error: "last_user" }, 409) });
|
||||
await screen.findByText("ola");
|
||||
|
||||
click("Usuń ola");
|
||||
click("Tak, usuń");
|
||||
|
||||
expect(await screen.findByText("Nie można usunąć ostatniego konta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the login screen when the own account is deleted", async () => {
|
||||
let deleted = false;
|
||||
vi.stubGlobal("fetch", vi.fn((url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
deleted = true;
|
||||
return empty();
|
||||
}
|
||||
if (url === "api/auth/me") return answer({ user: "mariusz", method: "local" });
|
||||
if (url === "api/auth/users") return deleted ? answer({ error: "unauthenticated" }, 401) : answer([{ username: "mariusz" }, { username: "ola" }]);
|
||||
return answer({ local: true, oidc: false });
|
||||
}));
|
||||
await mount();
|
||||
await screen.findByText("ola");
|
||||
|
||||
click("Usuń mariusz");
|
||||
click("Tak, usuń");
|
||||
|
||||
expect(await screen.findByText("Sesja wygasła – zaloguj się ponownie")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { getJson, postAuth } from "./api";
|
||||
import { accountFailure, BUTTON, FIELD, newPasswordProblem, useSession } from "./Auth";
|
||||
import { Card } from "./ui";
|
||||
|
||||
type Form = "add" | "password" | null;
|
||||
|
||||
function Field({ label, value, onChange, auto, type = "password" }: {
|
||||
label: string; value: string; onChange: (value: string) => void; auto: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">{label}</span>
|
||||
<input value={value} onChange={(e) => onChange(e.target.value)} type={type} autoComplete={auto} required
|
||||
autoCapitalize="none" spellCheck={false} className={FIELD} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Sends the fields once the new password passes the checks; says what went wrong, or closes the form.
|
||||
function AccountForm({ title, fields, send, onDone }: {
|
||||
title: string; fields: "add" | "password";
|
||||
send: (values: Record<string, string>) => Promise<{ status: number; body: any }>; onDone: () => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const set = (key: string) => (value: string) => setValues((now) => ({ ...now, [key]: value }));
|
||||
const secret = (fields === "add" ? values.password : values.new) ?? "";
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const problem = newPasswordProblem(secret, values.repeat ?? "");
|
||||
if (problem) return setError(problem);
|
||||
setSending(true);
|
||||
setError("");
|
||||
const { status, body } = await send(values);
|
||||
setSending(false);
|
||||
if (status === 200 || status === 204) return onDone();
|
||||
setError(status === 401 ? "Nieprawidłowe obecne hasło" : accountFailure(status, body));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mt-3 space-y-3 border-t border-edge pt-3">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
{fields === "add" ? (
|
||||
<>
|
||||
<Field label="Login" value={values.username ?? ""} onChange={set("username")} auto="off" type="text" />
|
||||
<Field label="Hasło" value={values.password ?? ""} onChange={set("password")} auto="new-password" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Obecne hasło" value={values.current ?? ""} onChange={set("current")} auto="current-password" />
|
||||
<Field label="Nowe hasło" value={values.new ?? ""} onChange={set("new")} auto="new-password" />
|
||||
</>
|
||||
)}
|
||||
<Field label="Powtórz hasło" value={values.repeat ?? ""} onChange={set("repeat")} auto="new-password" />
|
||||
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
|
||||
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Zapisz</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const SMALL = "min-h-11 rounded-xl border-2 border-brand px-3 font-bold text-brand disabled:opacity-40";
|
||||
|
||||
/** The accounts that may log in: add, change the own password, delete. Nothing without a login of our own. */
|
||||
export function Accounts() {
|
||||
const session = useSession();
|
||||
const [users, setUsers] = useState<string[]>();
|
||||
const [form, setForm] = useState<Form>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const managed = session != null && session.method !== "none";
|
||||
const load = useCallback(() => {
|
||||
getJson<{ username: string }[]>("api/auth/users").then(
|
||||
(list) => setUsers(list.map((u) => u.username)), () => setMessage("Nie udało się pobrać kont"));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (managed) load();
|
||||
}, [managed, load]);
|
||||
if (!managed) return null;
|
||||
|
||||
const done = (text: string) => () => {
|
||||
setForm(null);
|
||||
setMessage(text);
|
||||
load();
|
||||
};
|
||||
// Deleting yourself ends the session: the list request that follows gets 401 and the login screen shows.
|
||||
const remove = async (name: string) => {
|
||||
setConfirming(null);
|
||||
const { status, body } = await postAuth(`api/auth/users/${encodeURIComponent(name)}/delete`);
|
||||
if (status === 204) return done(`Usunięto konto ${name}`)();
|
||||
setMessage(body?.error === "last_user" ? "Nie można usunąć ostatniego konta" : accountFailure(status, body));
|
||||
};
|
||||
const toggle = (next: Form) => setForm(form === next ? null : next);
|
||||
|
||||
return (
|
||||
<Card title="Konta" className="mt-4">
|
||||
{users?.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-3 border-t border-edge py-2 first:border-t-0">
|
||||
<span>{name}{name === session.user && <span className="text-soft"> (Ty)</span>}</span>
|
||||
{confirming === name ? (
|
||||
<span className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-sm font-semibold">Usunąć konto {name}?</span>
|
||||
<button onClick={() => remove(name)} className={`${SMALL} !border-alarm bg-alarm !text-white`}>Tak, usuń</button>
|
||||
<button onClick={() => setConfirming(null)} className={SMALL}>Anuluj</button>
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => setConfirming(name)} disabled={users.length < 2} aria-label={`Usuń ${name}`} className={SMALL}>Usuń</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{message && <p role="status" className="mt-2 text-sm font-semibold text-soft">{message}</p>}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button onClick={() => toggle("add")} aria-pressed={form === "add"} className={SMALL}>Dodaj konto</button>
|
||||
{session.method === "local" && (
|
||||
<button onClick={() => toggle("password")} aria-pressed={form === "password"} className={SMALL}>Zmień moje hasło</button>
|
||||
)}
|
||||
</div>
|
||||
{form === "add" && (
|
||||
<AccountForm title="Nowe konto" fields="add" onDone={done("Dodano konto")}
|
||||
send={({ username, password }) => postAuth("api/auth/users", { username, password })} />
|
||||
)}
|
||||
{form === "password" && (
|
||||
<AccountForm title="Zmiana hasła" fields="password" onDone={done("Hasło zmienione")}
|
||||
send={({ current, new: next }) => postAuth("api/auth/password", { current, new: next })} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useEffect, useState, type ComponentType } from "react";
|
||||
import { Account } from "./Auth";
|
||||
import { celsius, hour, longDate } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Energy } from "./Energy";
|
||||
@@ -70,6 +71,7 @@ function SideFooter() {
|
||||
</div>
|
||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||
<ThemeSwitch className="bg-white/10" />
|
||||
<Account className="text-sm text-white/80" />
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<Dot tone={offline ? "bad" : old ? "warn" : "ok"} live={!offline && !old} />
|
||||
<div className="text-sm">
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { answer, example } from "./fixtures";
|
||||
|
||||
const ME = { user: "mariusz", method: "local" };
|
||||
const LONG = "dlugie-haslo-1";
|
||||
const CONFIG = { local: true, oidc: true };
|
||||
|
||||
type Handler = (body: unknown) => Promise<Response>;
|
||||
// Answers by "METHOD url"; what is not listed is a 404. Replace entries to change the server's mind.
|
||||
function server(routes: Record<string, Handler | unknown>) {
|
||||
const fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const route = routes[`${init?.method ?? "GET"} ${url}`];
|
||||
if (typeof route === "function") return (route as Handler)(init?.body ? JSON.parse(String(init.body)) : undefined);
|
||||
return route === undefined ? answer({}, 404) : answer(route);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
return fetch;
|
||||
}
|
||||
const unauthenticated = () => answer({ error: "unauthenticated" }, 401);
|
||||
|
||||
async function open(path = "") {
|
||||
history.replaceState(null, "", `/${path}`);
|
||||
const { AuthGate } = await import("./Auth");
|
||||
const { App } = await import("./App");
|
||||
render(<AuthGate><App /></AuthGate>);
|
||||
}
|
||||
|
||||
const login = (name = "mariusz", password = "tajne") => {
|
||||
fireEvent.change(screen.getByLabelText("Login"), { target: { value: name } });
|
||||
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: password } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Zaloguj" }));
|
||||
};
|
||||
const loginScreen = () => screen.findByRole("button", { name: "Zaloguj" });
|
||||
|
||||
beforeEach(() => {
|
||||
history.replaceState(null, "", "/");
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("shows the login screen, not the app, without a session", async () => {
|
||||
const fetch = server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: true, oidc: false } });
|
||||
await open();
|
||||
|
||||
expect(await loginScreen()).toBeEnabled();
|
||||
expect(screen.getByLabelText("Login")).toHaveAttribute("autocomplete", "username");
|
||||
expect(screen.getByLabelText("Hasło")).toHaveAttribute("autocomplete", "current-password");
|
||||
expect(screen.queryByRole("navigation")).toBeNull();
|
||||
expect(screen.queryByText("Zaloguj przez Keycloak")).toBeNull();
|
||||
expect(fetch.mock.calls.map(([url]) => url).filter((url) => !url.startsWith("api/auth"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows the app after a successful login, and sends the password once with the page header", async () => {
|
||||
const post = vi.fn(() => answer(ME));
|
||||
const fetch = server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": post });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
login();
|
||||
|
||||
expect(await screen.findByRole("navigation", { name: "Ekrany" })).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledWith({ username: "mariusz", password: "tajne" });
|
||||
const init = fetch.mock.calls.find(([url]) => url === "api/auth/login")![1]!;
|
||||
expect(init.headers).toMatchObject({ "X-Requested-With": "home-pump" });
|
||||
});
|
||||
|
||||
it("lets the password be shown and submits with Enter", async () => {
|
||||
const post = vi.fn(() => answer(ME));
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": post });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pokaż" }));
|
||||
expect(screen.getByLabelText("Hasło")).toHaveAttribute("type", "text");
|
||||
fireEvent.change(screen.getByLabelText("Login"), { target: { value: "a" } });
|
||||
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: "b" } });
|
||||
fireEvent.submit(screen.getByLabelText("Hasło").closest("form")!);
|
||||
|
||||
await screen.findByRole("navigation", { name: "Ekrany" });
|
||||
expect(post).toHaveBeenCalledWith({ username: "a", password: "b" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a wrong password", () => answer({ error: "invalid" }, 401), "Nieprawidłowy login lub hasło"],
|
||||
["too many tries", () => answer({ error: "throttled", retryAfterSeconds: 130 }, 429), "Za dużo prób – spróbuj za 3 min"],
|
||||
["a lost network", () => Promise.reject(new TypeError("offline")), "Brak połączenia z serwerem"],
|
||||
])("says so on %s and lets the user try again", async (_name, reply, text) => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": reply });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
login();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
expect(screen.getByRole("button", { name: "Zaloguj" })).toBeEnabled();
|
||||
expect(screen.queryByRole("navigation")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the button while the login is sent", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": () => new Promise(() => {}) });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
login();
|
||||
|
||||
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Zaloguj" })).toBeDisabled());
|
||||
});
|
||||
|
||||
it("offers Keycloak only when enabled, as a full page navigation", async () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { ...location, assign, hash: "" });
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
|
||||
await open();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Zaloguj przez Keycloak" }));
|
||||
|
||||
expect(screen.getByText("lub")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Login")).toBeInTheDocument();
|
||||
expect(assign).toHaveBeenCalledWith("api/auth/oidc/start");
|
||||
});
|
||||
|
||||
it("hides the form when local login is off", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: false, oidc: true } });
|
||||
await open();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Zaloguj przez Keycloak" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Login")).toBeNull();
|
||||
expect(screen.queryByText("lub")).toBeNull();
|
||||
});
|
||||
|
||||
it("still shows the form when the config cannot be read", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated });
|
||||
await open();
|
||||
|
||||
expect(await loginScreen()).toBeInTheDocument();
|
||||
expect(screen.queryByText("Zaloguj przez Keycloak")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["oidc_unavailable", "Keycloak jest niedostępny – zaloguj się loginem i hasłem"],
|
||||
["oidc_denied", "Brak uprawnień do aplikacji"],
|
||||
["oidc_failed", "Logowanie przez Keycloak nie powiodło się"],
|
||||
])("explains %s from the address", async (code, text) => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
|
||||
await open(`#login?error=${code}`);
|
||||
|
||||
expect(await screen.findByLabelText("Login")).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it.each(["__proto__", "constructor", "toString"])("ignores the unknown error code %s", async (code) => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
|
||||
await open(`#login?error=${code}`);
|
||||
|
||||
expect(await screen.findByLabelText("Login")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("says local login is off when the server answers 404", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "POST api/auth/login": () => answer({}, 404) });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
login();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Logowanie loginem i hasłem jest wyłączone");
|
||||
});
|
||||
|
||||
it("offers a reload instead of an empty screen when no way to log in is enabled", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: false, oidc: false } });
|
||||
await open();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Wczytaj ponownie" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Login")).toBeNull();
|
||||
});
|
||||
|
||||
it("says there is no connection instead of showing the login when the server cannot be reached", async () => {
|
||||
server({ "GET api/auth/me": () => Promise.reject(new TypeError("offline")) });
|
||||
await open();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Brak połączenia z serwerem");
|
||||
expect(screen.queryByLabelText("Login")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Spróbuj ponownie" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session", () => {
|
||||
const health = example("health");
|
||||
|
||||
it("returns to the login screen when a poller gets 401, and to the same screen after login", async () => {
|
||||
let expired = false;
|
||||
server({
|
||||
"GET api/auth/me": ME,
|
||||
"GET api/health": () => (expired ? unauthenticated() : answer(health)),
|
||||
"GET api/auth/config": CONFIG,
|
||||
"POST api/auth/login": ME,
|
||||
});
|
||||
await open("#status");
|
||||
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
|
||||
|
||||
expired = true;
|
||||
await act(async () => document.dispatchEvent(new Event("visibilitychange")));
|
||||
|
||||
expect(await screen.findByText("Sesja wygasła – zaloguj się ponownie")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation")).toBeNull();
|
||||
expired = false;
|
||||
await loginScreen();
|
||||
login();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
|
||||
expect(location.hash).toBe("#status");
|
||||
});
|
||||
|
||||
it("shows the app without account or logout when the server has no login", async () => {
|
||||
server({ "GET api/auth/me": { user: "phone", method: "none" }, "GET api/health": health });
|
||||
await open("#status");
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Zalogowano:")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Wyloguj" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows who is logged in and logs out", async () => {
|
||||
const out = vi.fn(() => new Response(null, { status: 204 }));
|
||||
server({
|
||||
"GET api/auth/me": ME, "GET api/health": health, "GET api/auth/config": CONFIG,
|
||||
"POST api/auth/logout": out as unknown as Handler,
|
||||
});
|
||||
await open("#status");
|
||||
|
||||
expect((await screen.findAllByText("Zalogowano:"))[0].parentElement).toHaveTextContent("Zalogowano: mariusz");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Wyloguj" })[0]);
|
||||
|
||||
expect(await loginScreen()).toBeInTheDocument();
|
||||
expect(out).toHaveBeenCalled();
|
||||
expect(screen.queryByText("Sesja wygasła – zaloguj się ponownie")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("first account", () => {
|
||||
const SETUP = { local: true, oidc: true, setup: true };
|
||||
const fill = (password: string, repeat: string) => {
|
||||
fireEvent.change(screen.getByLabelText("Login"), { target: { value: "mariusz" } });
|
||||
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: password } });
|
||||
fireEvent.change(screen.getByLabelText("Powtórz hasło"), { target: { value: repeat } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Utwórz konto" }));
|
||||
};
|
||||
|
||||
it("offers the creation instead of the login form, with Keycloak below, and opens the app", async () => {
|
||||
const post = vi.fn(() => answer(ME));
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": post });
|
||||
await open();
|
||||
|
||||
expect(await screen.findByText("Utwórz pierwsze konto")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Zaloguj" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Zaloguj przez Keycloak" })).toBeInTheDocument();
|
||||
fill("dlugie-haslo-1", "dlugie-haslo-1");
|
||||
|
||||
expect(await screen.findByRole("navigation", { name: "Ekrany" })).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledWith({ username: "mariusz", password: LONG });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["too short", "krotkie", "krotkie", "Hasło musi mieć co najmniej 10 znaków"],
|
||||
["different", "dlugie-haslo-1", "dlugie-haslo-2", "Hasła nie są takie same"],
|
||||
])("does not send a password that is %s", async (_name, password, repeat, text) => {
|
||||
const post = vi.fn(() => answer(ME));
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": post });
|
||||
await open();
|
||||
await screen.findByText("Utwórz pierwsze konto");
|
||||
|
||||
fill(password, repeat);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the login form when somebody created the account first", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": () => answer({ error: "setup_done" }, 409) });
|
||||
await open();
|
||||
await screen.findByText("Utwórz pierwsze konto");
|
||||
|
||||
fill("dlugie-haslo-1", "dlugie-haslo-1");
|
||||
|
||||
expect(await loginScreen()).toBeInTheDocument();
|
||||
expect(screen.getByText("Konto już istnieje – zaloguj się")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a weak password", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
|
||||
["a bad login", () => answer({ error: "bad_username" }, 400), "Nieprawidłowy login"],
|
||||
["too many tries", () => answer({ error: "throttled", retryAfterSeconds: 60 }, 429), "Za dużo prób – spróbuj za 1 min"],
|
||||
["a lost network", () => Promise.reject(new TypeError("offline")), "Brak połączenia z serwerem"],
|
||||
])("says so on %s from the server", async (_name, reply, text) => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": reply });
|
||||
await open();
|
||||
await screen.findByText("Utwórz pierwsze konto");
|
||||
|
||||
fill("dlugie-haslo-1", "dlugie-haslo-1");
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { getJson, HttpError, postAuth, whenSessionExpires } from "./api";
|
||||
import { Icon } from "./icons";
|
||||
import { Banner, Card, Loading } from "./ui";
|
||||
|
||||
interface Session { user: string; method: "local" | "oidc" | "none" }
|
||||
interface AuthConfig { local: boolean; oidc: boolean; setup?: boolean }
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
| { kind: "unreachable" }
|
||||
| { kind: "out"; expired: boolean }
|
||||
| { kind: "in"; session: Session };
|
||||
|
||||
const SessionContext = createContext<{ user: string; method: Session["method"]; logout: () => void } | null>(null);
|
||||
|
||||
// A Map, so a code such as "__proto__" finds nothing.
|
||||
const OIDC_ERRORS = new Map([
|
||||
["oidc_unavailable", "Keycloak jest niedostępny – zaloguj się loginem i hasłem"],
|
||||
["oidc_denied", "Brak uprawnień do aplikacji"],
|
||||
["oidc_failed", "Logowanie przez Keycloak nie powiodło się"],
|
||||
]);
|
||||
|
||||
// Keycloak sends the browser back to "#login?error=…".
|
||||
const oidcError = () => OIDC_ERRORS.get(new URLSearchParams(location.hash.split("?")[1]).get("error") ?? "");
|
||||
|
||||
export const FIELD = "min-h-12 w-full rounded-xl border-2 border-off bg-page px-3 text-lg text-ink focus:border-brand focus:outline-none";
|
||||
export const BUTTON = "min-h-12 w-full rounded-xl border-2 border-brand px-3 text-lg font-bold transition active:scale-[0.98] disabled:opacity-40";
|
||||
|
||||
export const MIN_PASSWORD = 10;
|
||||
|
||||
/** The message for a new password that cannot be sent, or "" when it can. */
|
||||
export function newPasswordProblem(password: string, repeat: string) {
|
||||
if (password.length < MIN_PASSWORD) return `Hasło musi mieć co najmniej ${MIN_PASSWORD} znaków`;
|
||||
return password === repeat ? "" : "Hasła nie są takie same";
|
||||
}
|
||||
|
||||
/** What the server's refusal of a new account or password means. */
|
||||
export function accountFailure(status: number, body: { error?: string; retryAfterSeconds?: number } | null) {
|
||||
if (status === 400) return body?.error === "weak_password" ? `Hasło jest za słabe (co najmniej ${MIN_PASSWORD} znaków)` : "Nieprawidłowy login";
|
||||
if (status === 409) return "Takie konto już istnieje";
|
||||
return failure(status, body);
|
||||
}
|
||||
|
||||
function failure(status: number, body: { retryAfterSeconds?: number } | null) {
|
||||
if (status === 0) return "Brak połączenia z serwerem";
|
||||
if (status === 429) return `Za dużo prób – spróbuj za ${Math.max(1, Math.ceil((body?.retryAfterSeconds ?? 0) / 60))} min`;
|
||||
if (status === 404) return "Logowanie loginem i hasłem jest wyłączone";
|
||||
return status === 401 ? "Nieprawidłowy login lub hasło" : "Logowanie nie powiodło się, spróbuj ponownie";
|
||||
}
|
||||
|
||||
function Screen({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-5 px-4 py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-12 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-7" /></span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold leading-tight">Dom Szczepańcowa</h1>
|
||||
<p className="text-soft">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm({ onLogin }: { onLogin: (session: Session) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [shown, setShown] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setSending(true);
|
||||
setError("");
|
||||
const { status, body } = await postAuth("api/auth/login", { username, password });
|
||||
if (status === 200) return onLogin(body);
|
||||
setError(failure(status, body));
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Login</span>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required
|
||||
autoCapitalize="none" spellCheck={false} className={FIELD} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Hasło</span>
|
||||
<span className="flex gap-2">
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} type={shown ? "text" : "password"}
|
||||
autoComplete="current-password" required className={FIELD} />
|
||||
<button type="button" onClick={() => setShown(!shown)} aria-pressed={shown}
|
||||
className="min-h-12 shrink-0 rounded-xl border-2 border-off px-3 font-semibold text-soft aria-pressed:border-brand aria-pressed:text-brand">
|
||||
{shown ? "Ukryj" : "Pokaż"}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
|
||||
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Zaloguj</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupForm({ onLogin, onExists }: { onLogin: (session: Session) => void; onExists: () => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const problem = newPasswordProblem(password, repeat);
|
||||
if (problem) return setError(problem);
|
||||
setSending(true);
|
||||
setError("");
|
||||
const { status, body } = await postAuth("api/auth/setup", { username, password });
|
||||
if (status === 200) return onLogin(body);
|
||||
if (status === 409) return onExists();
|
||||
setError(accountFailure(status, body));
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Utwórz pierwsze konto</h2>
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Login</span>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required
|
||||
autoCapitalize="none" spellCheck={false} className={FIELD} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Hasło</span>
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" autoComplete="new-password" required className={FIELD} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Powtórz hasło</span>
|
||||
<input value={repeat} onChange={(e) => setRepeat(e.target.value)} type="password" autoComplete="new-password" required className={FIELD} />
|
||||
</label>
|
||||
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
|
||||
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Utwórz konto</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Login({ expired, onLogin }: { expired: boolean; onLogin: (session: Session) => void }) {
|
||||
// The form is the way in when Keycloak is down, so it also shows when the config cannot be read.
|
||||
const [config, setConfig] = useState<AuthConfig>();
|
||||
useEffect(() => {
|
||||
getJson<AuthConfig>("api/auth/config").then(setConfig, () => setConfig({ local: true, oidc: false }));
|
||||
}, []);
|
||||
const [accountExists, setAccountExists] = useState(false);
|
||||
const keycloakProblem = oidcError();
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
{expired && <Banner tone="warn" compact>Sesja wygasła – zaloguj się ponownie</Banner>}
|
||||
{accountExists && <Banner tone="ok" compact>Konto już istnieje – zaloguj się</Banner>}
|
||||
{keycloakProblem && <Banner tone="bad" compact>{keycloakProblem}</Banner>}
|
||||
{!config ? <Loading /> : !config.local && !config.oidc ? (
|
||||
<>
|
||||
<p className="font-semibold">Logowanie jest teraz niedostępne.</p>
|
||||
<button type="button" onClick={() => location.reload()} className={`${BUTTON} bg-panel text-brand`}>Wczytaj ponownie</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{config.local && (config.setup && !accountExists
|
||||
? <SetupForm onLogin={onLogin} onExists={() => setAccountExists(true)} />
|
||||
: <LoginForm onLogin={onLogin} />)}
|
||||
{config.local && config.oidc && (
|
||||
<p className="flex items-center gap-3 text-soft before:h-px before:flex-1 before:bg-edge after:h-px after:flex-1 after:bg-edge">lub</p>
|
||||
)}
|
||||
{config.oidc && (
|
||||
<button type="button" onClick={() => location.assign("api/auth/oidc/start")} className={`${BUTTON} bg-panel text-brand`}>
|
||||
Zaloguj przez Keycloak
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shows the app only for a logged-in user; otherwise the login screen. */
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<State>({ kind: "loading" });
|
||||
|
||||
const load = useCallback(() => {
|
||||
setState({ kind: "loading" });
|
||||
getJson<Session>("api/auth/me").then(
|
||||
(session) => setState({ kind: "in", session }),
|
||||
(error) => setState(error instanceof HttpError && error.status === 401 ? { kind: "out", expired: false } : { kind: "unreachable" }),
|
||||
);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
whenSessionExpires(() => setState((now) => (now.kind === "in" ? { kind: "out", expired: true } : now)));
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await postAuth("api/auth/logout");
|
||||
setState({ kind: "out", expired: false });
|
||||
}, []);
|
||||
|
||||
switch (state.kind) {
|
||||
case "loading": return <Screen><Loading /></Screen>;
|
||||
case "unreachable":
|
||||
return (
|
||||
<Screen>
|
||||
<Banner tone="bad" compact>Brak połączenia z serwerem</Banner>
|
||||
<button type="button" onClick={load} className={`${BUTTON} bg-panel text-brand`}>Spróbuj ponownie</button>
|
||||
</Screen>
|
||||
);
|
||||
case "out":
|
||||
return <Login expired={state.expired} onLogin={(session) => {
|
||||
// Back to where the user was; the "#login?error=…" of a failed Keycloak try is no screen.
|
||||
if (location.hash.startsWith("#login")) history.replaceState(null, "", location.pathname + location.search);
|
||||
setState({ kind: "in", session });
|
||||
}} />;
|
||||
case "in":
|
||||
return <SessionContext value={{ ...state.session, logout }}>{children}</SessionContext>;
|
||||
}
|
||||
}
|
||||
|
||||
/** The logged-in user, for the screens that manage accounts; null outside a login. */
|
||||
export const useSession = () => useContext(SessionContext);
|
||||
|
||||
/** "Zalogowano: …" with the logout button; nothing outside a login (the tests of single screens). */
|
||||
export function Account({ className = "", card = false }: { className?: string; card?: boolean }) {
|
||||
const session = useSession();
|
||||
if (!session || session.method === "none") return null; // no login in a development setup
|
||||
const line = (
|
||||
<div className={`flex items-center justify-between gap-3 ${className}`}>
|
||||
<p>Zalogowano: <b className="font-semibold">{session.user}</b></p>
|
||||
<button onClick={session.logout}
|
||||
className="min-h-11 rounded-xl border-2 border-current px-3 font-bold">Wyloguj</button>
|
||||
</div>
|
||||
);
|
||||
return card ? <Card title="Konto">{line}</Card> : line;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
import { Accounts } from "./Accounts";
|
||||
import { ChangeHistory } from "./ChangeHistory";
|
||||
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
@@ -343,6 +344,7 @@ export function Settings() {
|
||||
</Card>
|
||||
</div>
|
||||
<ChangeHistory />
|
||||
<Accounts />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Account } from "./Auth";
|
||||
import { ago } from "./format";
|
||||
import { Icon } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
@@ -43,6 +44,7 @@ export function Status() {
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
<Account card />
|
||||
<Card title="Wersje">
|
||||
<Row label="Aplikacja" value={__APP_VERSION__} />
|
||||
<Row label="Serwer" value={data.backendVersion} />
|
||||
|
||||
+38
-7
@@ -4,12 +4,44 @@ import type { CommandResult, RefreshAnswer } from "./types";
|
||||
const READ_TIMEOUT_MS = 15000;
|
||||
const WRITE_TIMEOUT_MS = 20000;
|
||||
|
||||
// The custom header is what the backend uses to tell this page from another site's form.
|
||||
const POST_HEADERS = { "Content-Type": "application/json", "X-Requested-With": "home-pump" };
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(url: string, readonly status: number) {
|
||||
super(`${url}: ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Every call below reports a 401 here, so the app can go back to the login screen when the session ends.
|
||||
let onSessionExpired: () => void = () => undefined;
|
||||
export const whenSessionExpires = (listener: () => void) => {
|
||||
onSessionExpired = listener;
|
||||
};
|
||||
const checked = (response: Response) => {
|
||||
if (response.status === 401) onSessionExpired();
|
||||
return response;
|
||||
};
|
||||
|
||||
export async function getJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
|
||||
if (!response.ok) throw new Error(`${url}: ${response.status}`);
|
||||
const response = checked(await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(READ_TIMEOUT_MS) }));
|
||||
if (!response.ok) throw new HttpError(url, response.status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// The login calls: the HTTP status and the JSON body (null when there is none), status 0 when the network fails.
|
||||
// A 401 here is a wrong password, not an expired session.
|
||||
export async function postAuth(url: string, body?: unknown): Promise<{ status: number; body: any }> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST", headers: POST_HEADERS, body: JSON.stringify(body ?? {}), signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
|
||||
});
|
||||
return { status: response.status, body: await response.json().catch(() => null) };
|
||||
} catch {
|
||||
return { status: 0, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`write failed: ${status}`);
|
||||
@@ -24,11 +56,10 @@ export async function postJson<T extends { status?: string; outcome?: string } =
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
// The custom header is what the backend uses to tell this page from another site's form.
|
||||
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
|
||||
headers: POST_HEADERS,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
|
||||
}).catch(() => {
|
||||
}).then(checked, () => {
|
||||
throw new WriteError(0);
|
||||
});
|
||||
const answer = await response.json().catch(() => null);
|
||||
@@ -40,10 +71,10 @@ export async function postJson<T extends { status?: string; outcome?: string } =
|
||||
export async function postRefresh(): Promise<RefreshAnswer> {
|
||||
const response = await fetch("api/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
|
||||
headers: POST_HEADERS,
|
||||
body: "{}",
|
||||
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
|
||||
}).catch(() => {
|
||||
}).then(checked, () => {
|
||||
throw new WriteError(0);
|
||||
});
|
||||
if (!response.ok) throw new WriteError(response.status);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { AuthGate } from "./Auth";
|
||||
import "./index.css";
|
||||
import { applyTheme, storedTheme } from "./theme";
|
||||
|
||||
applyTheme(storedTheme()); // before the first paint, so a dark choice does not flash white
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><AuthGate><App /></AuthGate></StrictMode>);
|
||||
|
||||
// Offline shell (public/manifest.webmanifest, src/sw.js); not in dev, where it would serve stale modules.
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
|
||||
Reference in new issue
Block a user