diff --git a/sdks/java/src/main/java/org/byteveda/taskito/cli/Cli.java b/sdks/java/src/main/java/org/byteveda/taskito/cli/Cli.java index f3f716b0..da4d53a2 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/cli/Cli.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/cli/Cli.java @@ -261,10 +261,15 @@ static final class Dashboard implements Callable { @Option(names = "--static", description = "Directory of the prebuilt SPA.") String staticDir; + @Option( + names = "--insecure-cookies", + description = "Drop the Secure cookie attribute (for local HTTP development).") + boolean insecureCookies; + @Override public Integer call() throws Exception { try (Taskito queue = parent.open(); - DashboardServer server = DashboardServer.start(queue, port, token, staticDir)) { + DashboardServer server = DashboardServer.start(queue, port, token, staticDir, !insecureCookies)) { System.out.println("dashboard on http://localhost:" + server.port()); new CountDownLatch(1).await(); } diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java index ecee959f..d5a5e01b 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java @@ -1,83 +1,99 @@ package org.byteveda.taskito.dashboard; -import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.Executors; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; import org.byteveda.taskito.Taskito; -import org.byteveda.taskito.errors.SerializationException; -import org.byteveda.taskito.model.Job; -import org.byteveda.taskito.model.JobFilter; -import org.byteveda.taskito.model.JobStatus; +import org.byteveda.taskito.dashboard.api.CoreHandlers; +import org.byteveda.taskito.dashboard.auth.AuthHandlers; +import org.byteveda.taskito.dashboard.auth.AuthStore; +import org.byteveda.taskito.dashboard.auth.Cookies; +import org.byteveda.taskito.dashboard.auth.Policy; +import org.byteveda.taskito.dashboard.auth.RequestContext; +import org.byteveda.taskito.dashboard.auth.TokenAuth; +import org.byteveda.taskito.dashboard.routing.Req; +import org.byteveda.taskito.dashboard.routing.Router; +import org.byteveda.taskito.dashboard.store.SettingsAccess; +import org.byteveda.taskito.dashboard.support.DashboardError; +import org.byteveda.taskito.dashboard.support.Http; +import org.byteveda.taskito.logging.TaskitoLogger; /** * A read/action dashboard API + static-SPA server backed by a {@link Taskito}, * built on the JDK's {@code com.sun.net.httpserver}. The JSON contract is - * snake_case with Unix-millisecond timestamps (see {@link Contract}). + * snake_case with Unix-millisecond timestamps. * - *

When a {@code token} is configured, {@code /api/*} (except - * {@code /api/auth/status}) requires it via {@code ?token=} or a cookie. + *

Two auth modes: + *

*/ public final class DashboardServer implements AutoCloseable { - private static final ObjectMapper JSON = new ObjectMapper(); - private static final String COOKIE = "taskito_token"; - private static final long DEFAULT_LIMIT = 50; - - private static final Pattern JOB = Pattern.compile("^/api/jobs/([^/]+)$"); - private static final Pattern CANCEL = Pattern.compile("^/api/jobs/([^/]+)/cancel$"); - private static final Pattern RETRY = Pattern.compile("^/api/dead-letters/([^/]+)/retry$"); - private static final Pattern PAUSE = Pattern.compile("^/api/queues/([^/]+)/pause$"); - private static final Pattern RESUME = Pattern.compile("^/api/queues/([^/]+)/resume$"); + private static final TaskitoLogger LOG = TaskitoLogger.create("dashboard"); private final HttpServer server; private final Taskito queue; - private final String token; private final Path staticDir; + private final boolean secureCookies; + private final AuthStore authStore; + private final TokenAuth tokenAuth; + private final AuthHandlers authHandlers; + private final Router router; - private DashboardServer(HttpServer server, Taskito queue, String token, Path staticDir) { + private DashboardServer(HttpServer server, Taskito queue, Path staticDir, boolean secureCookies, String token) { this.server = server; this.queue = queue; - this.token = token; this.staticDir = staticDir; + this.secureCookies = secureCookies; + this.authStore = new AuthStore(SettingsAccess.of(queue)); + this.tokenAuth = token != null ? new TokenAuth(token) : null; + this.authHandlers = new AuthHandlers(authStore); + this.router = buildRouter(); } - /** Start on {@code port} (0 = ephemeral), serving the SPA bundled in the jar. */ + /** Start on {@code port} (0 = ephemeral) in session-auth mode. */ public static DashboardServer start(Taskito queue, int port) throws IOException { - return start(queue, port, null, null); + return start(queue, port, null, null, true); } - /** As {@link #start(Taskito, int)} but requiring {@code token} for {@code /api/*}. */ + /** Start in legacy shared-token mode; the session flow is disabled. */ public static DashboardServer start(Taskito queue, int port, String token) throws IOException { - return start(queue, port, token, null); + return start(queue, port, token, null, true); + } + + /** As {@link #start(Taskito, int, String)} but with an unpacked SPA directory. */ + public static DashboardServer start(Taskito queue, int port, String token, String staticDir) throws IOException { + return start(queue, port, token, staticDir, true); } /** - * Start on {@code port} (0 = ephemeral). {@code token} may be null. A null - * {@code staticDir} auto-discovers the SPA bundled in the jar; pass one only - * to override it with an unpacked build. + * Start on {@code port} (0 = ephemeral). A null {@code token} enables the + * session flow; a null {@code staticDir} auto-discovers the bundled SPA. + * {@code secureCookies=false} drops the {@code Secure} cookie attribute for + * local HTTP development. */ - public static DashboardServer start(Taskito queue, int port, String token, String staticDir) throws IOException { + public static DashboardServer start(Taskito queue, int port, String token, String staticDir, boolean secureCookies) + throws IOException { // Resolve assets before binding so a discovery failure can't leak a bound port. Path dir = staticDir != null ? Paths.get(staticDir).normalize() : DashboardAssets.resolveOrNull(); HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); - DashboardServer dashboard = new DashboardServer(server, queue, token, dir); + DashboardServer dashboard = new DashboardServer(server, queue, dir, secureCookies, token); + // Seed an env admin before serving so no request races the open setup endpoint. + if (token == null) { + dashboard.authStore.bootstrapAdminFromEnv(); + } server.createContext("/", dashboard::dispatch); server.setExecutor(Executors.newCachedThreadPool()); server.start(); @@ -93,156 +109,140 @@ public void close() { server.stop(0); } + // ---- dispatch ---------------------------------------------------------- + private void dispatch(HttpExchange exchange) throws IOException { try { String path = exchange.getRequestURI().getPath(); - if (path.startsWith("/api/")) { + if (path.equals("/health")) { + Http.respondJson(exchange, 200, Map.of("status", "ok")); + } else if (path.startsWith("/api/")) { handleApi(exchange, path); } else { serveStatic(exchange, path); } - } catch (RuntimeException e) { - respond(exchange, 500, error(e.getMessage() == null ? e.toString() : e.getMessage())); + } catch (DashboardError e) { + safeRespond(exchange, e.status(), Http.errorBody(e.code())); + } catch (RuntimeException | IOException e) { + // Log the detail server-side; return a generic code so an unauthenticated + // caller can't harvest internal exception text (e.g. from a malformed cookie). + LOG.warn("dashboard request failed: " + exchange.getRequestMethod() + " " + exchange.getRequestURI(), e); + safeRespond(exchange, 500, Http.errorBody("internal_error")); } finally { exchange.close(); } } private void handleApi(HttpExchange exchange, String path) throws IOException { - Map query = query(exchange); - if (path.equals("/api/auth/status")) { - respond(exchange, 200, Collections.singletonMap("auth_required", token != null)); - return; - } - if (!authorized(exchange, query)) { - respond(exchange, 401, error("unauthorized")); - return; - } - + Map query = Http.query(exchange); String method = exchange.getRequestMethod(); - if ("GET".equals(method) && handleGet(exchange, path, query)) { - return; - } - if ("POST".equals(method) && handlePost(exchange, path)) { + if (tokenAuth != null) { + handleTokenMode(exchange, path, method, query); return; } - respond(exchange, 404, error("not found")); - } - - private boolean handleGet(HttpExchange exchange, String path, Map query) throws IOException { - switch (path) { - case "/api/stats": - respond(exchange, 200, Contract.stats(queue.stats())); - return true; - case "/api/stats/queues": - respond(exchange, 200, statsByQueue()); - return true; - case "/api/queues/paused": - respond(exchange, 200, queue.listPausedQueues()); - return true; - case "/api/jobs": - respond(exchange, 200, listJobs(query)); - return true; - case "/api/dead-letters": - respond(exchange, 200, listDead(query)); - return true; - case "/api/metrics": - respond(exchange, 200, listMetrics(query)); - return true; - case "/api/workers": - respond(exchange, 200, listWorkers()); - return true; - default: - return getJob(exchange, path); + RequestContext ctx = RequestContext.build(exchange, authStore); + Policy.authorize(path, method, ctx, authStore); + if (!router.dispatch(exchange, method, path, query, ctx)) { + Http.respondError(exchange, 404, "not found"); } } - private boolean getJob(HttpExchange exchange, String path) throws IOException { - Matcher m = JOB.matcher(path); - if (!m.matches()) { - return false; + private void handleTokenMode(HttpExchange exchange, String path, String method, Map query) + throws IOException { + String queryToken = query.get("token"); + if (queryToken != null && tokenAuth.matches(queryToken)) { + exchange.getResponseHeaders().add("Set-Cookie", TokenAuth.openCookie(queryToken, secureCookies)); } - Optional job = queue.getJob(m.group(1)); - if (job.isPresent()) { - respond(exchange, 200, Contract.job(job.get())); - } else { - respond(exchange, 404, error("no such job")); + if (path.equals("/api/auth/status")) { + Http.respondJson(exchange, 200, TokenAuth.openStatus()); + return; } - return true; - } - - private boolean handlePost(HttpExchange exchange, String path) throws IOException { - Matcher cancel = CANCEL.matcher(path); - if (cancel.matches()) { - respond(exchange, 200, Collections.singletonMap("cancelled", queue.cancel(cancel.group(1)))); - return true; + if (!tokenAuth.matches(tokenAuth.presented(exchange, query))) { + Http.respondError(exchange, 401, "unauthorized"); + return; } - Matcher retry = RETRY.matcher(path); - if (retry.matches()) { - respond(exchange, 200, Collections.singletonMap("id", queue.retryDead(retry.group(1)))); - return true; + if (path.equals("/api/auth/whoami")) { + long ttl = AuthStore.DEFAULT_SESSION_TTL_SECONDS; + exchange.getResponseHeaders().add("Set-Cookie", Cookies.sessionCookie("open", secureCookies, ttl)); + exchange.getResponseHeaders().add("Set-Cookie", Cookies.csrfCookie("open", secureCookies, ttl)); + Http.respondJson(exchange, 200, TokenAuth.openWhoami()); + return; } - Matcher pause = PAUSE.matcher(path); - if (pause.matches()) { - queue.queue(pause.group(1)).pause(); - respond(exchange, 200, Collections.singletonMap("ok", true)); - return true; + if (path.startsWith("/api/auth/")) { + Http.respondError(exchange, 404, "not found"); + return; } - Matcher resume = RESUME.matcher(path); - if (resume.matches()) { - queue.queue(resume.group(1)).resume(); - respond(exchange, 200, Collections.singletonMap("ok", true)); - return true; + if (!router.dispatch(exchange, method, path, query, RequestContext.open())) { + Http.respondError(exchange, 404, "not found"); } - return false; } - private Map statsByQueue() { - Map out = new LinkedHashMap<>(); - queue.statsAllQueues().forEach((name, stats) -> out.put(name, Contract.stats(stats))); - return out; - } + // ---- routes ------------------------------------------------------------ - private List> listJobs(Map query) { - JobFilter.Builder filter = JobFilter.builder(); - if (query.containsKey("status")) { - filter.status(JobStatus.fromWire(query.get("status"))); - } - if (query.containsKey("queue")) { - filter.queue(query.get("queue")); - } - if (query.containsKey("task")) { - filter.task(query.get("task")); - } - if (query.containsKey("limit")) { - filter.limit(Integer.parseInt(query.get("limit"))); - } - if (query.containsKey("offset")) { - filter.offset(Integer.parseInt(query.get("offset"))); - } - return queue.listJobs(filter.build()).stream().map(Contract::job).collect(Collectors.toList()); - } + private Router buildRouter() { + CoreHandlers core = new CoreHandlers(queue); + long ttl = AuthStore.DEFAULT_SESSION_TTL_SECONDS; + Router r = new Router(); + + // Auth + r.get("/api/auth/status", req -> authHandlers.status()); + r.post("/api/auth/setup", req -> authHandlers.setup(req.jsonBody())); + r.post("/api/auth/login", req -> login(req, ttl)); + r.post("/api/auth/logout", this::logout); + r.get("/api/auth/whoami", req -> authHandlers.whoami(req.ctx())); + r.post("/api/auth/change-password", req -> authHandlers.changePassword(req.ctx(), req.jsonBody())); + r.get("/api/auth/providers", req -> Map.of("password_enabled", true, "providers", List.of())); + r.get("/api/auth/oauth/start/(.+)", req -> { + throw DashboardError.notFound("oauth_not_configured"); + }); + r.get("/api/auth/oauth/callback/(.+)", req -> { + throw DashboardError.notFound("oauth_not_configured"); + }); - private List> listDead(Map query) { - long limit = longParam(query, "limit", DEFAULT_LIMIT); - long offset = longParam(query, "offset", 0); - return queue.listDead(limit, offset).stream().map(Contract::dead).collect(Collectors.toList()); + // Read + r.get("/api/stats", req -> core.stats()); + r.get("/api/stats/queues", req -> core.statsByQueue()); + r.get("/api/queues/paused", req -> core.queuesPaused()); + r.get("/api/jobs", req -> core.listJobs(req.query())); + r.get("/api/jobs/([^/]+)", req -> core.job(req.param(0))); + r.get("/api/dead-letters", req -> core.listDead(req.query())); + r.get("/api/metrics", req -> core.listMetrics(req.query())); + r.get("/api/workers", req -> core.listWorkers()); + + // Action + r.post("/api/jobs/([^/]+)/cancel", req -> core.cancel(req.param(0))); + r.post("/api/dead-letters/([^/]+)/retry", req -> core.retryDead(req.param(0))); + r.post("/api/queues/([^/]+)/pause", req -> core.pause(req.param(0))); + r.post("/api/queues/([^/]+)/resume", req -> core.resume(req.param(0))); + + return r; } - private List> listMetrics(Map query) { - long since = longParam(query, "since", 0); - return queue.metrics(query.get("task"), since).stream() - .map(Contract::metric) - .collect(Collectors.toList()); + private Object login(Req req, long ttl) { + Map out = authHandlers.login(req.jsonBody()); + @SuppressWarnings("unchecked") + Map session = (Map) out.get("session"); + String token = (String) session.remove("token"); + String csrf = (String) session.get("csrf_token"); + var headers = req.exchange().getResponseHeaders(); + headers.add("Set-Cookie", Cookies.sessionCookie(token, secureCookies, ttl)); + headers.add("Set-Cookie", Cookies.csrfCookie(csrf, secureCookies, ttl)); + return out; } - private List> listWorkers() { - return queue.listWorkers().stream().map(Contract::worker).collect(Collectors.toList()); + private Object logout(Req req) { + Map out = authHandlers.logout(req.ctx()); + var headers = req.exchange().getResponseHeaders(); + headers.add("Set-Cookie", Cookies.clearSession(secureCookies)); + headers.add("Set-Cookie", Cookies.clearCsrf(secureCookies)); + return out; } + // ---- static + helpers -------------------------------------------------- + private void serveStatic(HttpExchange exchange, String path) throws IOException { if (staticDir == null) { - respond(exchange, 404, error("not found")); + Http.respondError(exchange, 404, "not found"); return; } Path target = staticDir.resolve(path.substring(1)).normalize(); @@ -250,7 +250,7 @@ private void serveStatic(HttpExchange exchange, String path) throws IOException target = staticDir.resolve("index.html"); } if (!Files.isRegularFile(target)) { - respond(exchange, 404, error("not found")); + Http.respondError(exchange, 404, "not found"); return; } byte[] body = Files.readAllBytes(target); @@ -261,68 +261,11 @@ private void serveStatic(HttpExchange exchange, String path) throws IOException } } - private boolean authorized(HttpExchange exchange, Map query) { - if (token == null) { - return true; - } - if (token.equals(query.get("token"))) { - exchange.getResponseHeaders().add("Set-Cookie", COOKIE + "=" + token + "; HttpOnly; Path=/"); - return true; - } - return token.equals(cookieToken(exchange)); - } - - private static String cookieToken(HttpExchange exchange) { - List cookies = exchange.getRequestHeaders().getOrDefault("Cookie", Collections.emptyList()); - for (String header : cookies) { - for (String pair : header.split(";")) { - String trimmed = pair.trim(); - if (trimmed.startsWith(COOKIE + "=")) { - return trimmed.substring(COOKIE.length() + 1); - } - } - } - return null; - } - - private static long longParam(Map query, String key, long fallback) { - String value = query.get(key); - return value == null ? fallback : Long.parseLong(value); - } - - private static Map query(HttpExchange exchange) { - Map out = new HashMap<>(); - String raw = exchange.getRequestURI().getRawQuery(); - if (raw == null) { - return out; - } - for (String pair : raw.split("&")) { - int eq = pair.indexOf('='); - if (eq < 0) { - continue; - } - String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8); - String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); - out.put(key, value); - } - return out; - } - - private static Map error(String message) { - return Collections.singletonMap("error", message); - } - - private static void respond(HttpExchange exchange, int status, Object body) throws IOException { - byte[] out; + private static void safeRespond(HttpExchange exchange, int status, Object body) { try { - out = JSON.writeValueAsBytes(body); - } catch (Exception e) { - throw new SerializationException("failed to encode response", e); - } - exchange.getResponseHeaders().set("Content-Type", "application/json"); - exchange.sendResponseHeaders(status, out.length); - try (OutputStream stream = exchange.getResponseBody()) { - stream.write(out); + Http.respondJson(exchange, status, body); + } catch (IOException | RuntimeException ignored) { + // Response already partially written or the client disconnected. } } diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/Contract.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/Contract.java similarity index 98% rename from sdks/java/src/main/java/org/byteveda/taskito/dashboard/Contract.java rename to sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/Contract.java index c589e83b..647ec8ca 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/Contract.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/Contract.java @@ -1,4 +1,4 @@ -package org.byteveda.taskito.dashboard; +package org.byteveda.taskito.dashboard.api; import java.util.LinkedHashMap; import java.util.Map; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/CoreHandlers.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/CoreHandlers.java new file mode 100644 index 00000000..fa7ffddf --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/CoreHandlers.java @@ -0,0 +1,101 @@ +package org.byteveda.taskito.dashboard.api; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.byteveda.taskito.Taskito; +import org.byteveda.taskito.model.JobFilter; +import org.byteveda.taskito.model.JobStatus; + +/** + * Read and action handlers for jobs, queues, dead-letters, metrics, and + * workers. Each returns the snake_case JSON body (via {@link Contract}) or + * {@code null} to signal 404. + */ +public final class CoreHandlers { + private static final long DEFAULT_LIMIT = 50; + + private final Taskito queue; + + public CoreHandlers(Taskito queue) { + this.queue = queue; + } + + public Object stats() { + return Contract.stats(queue.stats()); + } + + public Object statsByQueue() { + Map out = new LinkedHashMap<>(); + queue.statsAllQueues().forEach((name, stats) -> out.put(name, Contract.stats(stats))); + return out; + } + + public Object queuesPaused() { + return queue.listPausedQueues(); + } + + public Object listJobs(Map query) { + JobFilter.Builder filter = JobFilter.builder(); + if (query.containsKey("status")) { + filter.status(JobStatus.fromWire(query.get("status"))); + } + if (query.containsKey("queue")) { + filter.queue(query.get("queue")); + } + if (query.containsKey("task")) { + filter.task(query.get("task")); + } + if (query.containsKey("limit")) { + filter.limit(Integer.parseInt(query.get("limit"))); + } + if (query.containsKey("offset")) { + filter.offset(Integer.parseInt(query.get("offset"))); + } + return queue.listJobs(filter.build()).stream().map(Contract::job).collect(Collectors.toList()); + } + + public Object job(String id) { + return queue.getJob(id).map(Contract::job).orElse(null); + } + + public Object listDead(Map query) { + long limit = longParam(query, "limit", DEFAULT_LIMIT); + long offset = longParam(query, "offset", 0); + return queue.listDead(limit, offset).stream().map(Contract::dead).collect(Collectors.toList()); + } + + public Object listMetrics(Map query) { + long since = longParam(query, "since", 0); + return queue.metrics(query.get("task"), since).stream() + .map(Contract::metric) + .collect(Collectors.toList()); + } + + public Object listWorkers() { + return queue.listWorkers().stream().map(Contract::worker).collect(Collectors.toList()); + } + + public Object cancel(String id) { + return Map.of("cancelled", queue.cancel(id)); + } + + public Object retryDead(String id) { + return Map.of("id", queue.retryDead(id)); + } + + public Object pause(String name) { + queue.queue(name).pause(); + return Map.of("ok", true); + } + + public Object resume(String name) { + queue.queue(name).resume(); + return Map.of("ok", true); + } + + private static long longParam(Map query, String key, long fallback) { + String value = query.get(key); + return value == null ? fallback : Long.parseLong(value); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/package-info.java new file mode 100644 index 00000000..76db0b12 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/api/package-info.java @@ -0,0 +1,2 @@ +/** Feature endpoint handlers and the snake_case wire contract mappers. */ +package org.byteveda.taskito.dashboard.api; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java new file mode 100644 index 00000000..0b0502a5 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java @@ -0,0 +1,116 @@ +package org.byteveda.taskito.dashboard.auth; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.byteveda.taskito.dashboard.support.DashboardError; + +/** + * The {@code /api/auth/*} session endpoints: status, setup, login, logout, + * whoami, change-password. Handlers return the JSON body; the server applies + * cookie side effects (setting them on login, clearing them on logout) and + * redacts the raw session token from the login response. + */ +public final class AuthHandlers { + private final AuthStore store; + + public AuthHandlers(AuthStore store) { + this.store = store; + } + + public Map status() { + return Map.of("setup_required", store.countUsers() == 0); + } + + public Map setup(Map body) { + if (store.countUsers() != 0) { + throw DashboardError.badRequest("setup already complete"); + } + String username = requireField(body, "username"); + String password = requireField(body, "password"); + User user = store.createUser(username, password, AuthStore.ROLE_ADMIN); + return Map.of("user", serializeUser(user)); + } + + public Map login(Map body) { + if (store.countUsers() == 0) { + throw DashboardError.badRequest("setup_required"); + } + String username = requireField(body, "username"); + String password = requireField(body, "password"); + User user = store.authenticate(username, password); + if (user == null) { + throw DashboardError.badRequest("invalid_credentials"); + } + Session session = store.createSession(user.username(), user.role()); + Map out = new LinkedHashMap<>(); + out.put("user", serializeUser(user)); + out.put("session", serializeSessionWithToken(session)); + return out; + } + + public Map logout(RequestContext ctx) { + if (ctx.session() != null) { + store.deleteSession(ctx.session().token()); + } + return Map.of("ok", true); + } + + public Map whoami(RequestContext ctx) { + Session session = ctx.session(); + User user = store.getUser(session.username()).orElse(null); + if (user == null) { + store.deleteSession(session.token()); + throw DashboardError.notFound("not_authenticated"); + } + Map out = new LinkedHashMap<>(); + out.put("user", serializeUser(user)); + out.put("csrf_token", session.csrfToken()); + out.put("expires_at", session.expiresAt()); + return out; + } + + public Map changePassword(RequestContext ctx, Map body) { + Session session = ctx.session(); + String oldPassword = requireField(body, "old_password"); + String newPassword = requireField(body, "new_password"); + User user = store.getUser(session.username()).orElse(null); + if (user == null) { + throw DashboardError.badRequest("not_authenticated"); + } + if (!store.verifyPassword(user, oldPassword)) { + throw DashboardError.badRequest("invalid_credentials"); + } + store.updatePassword(session.username(), newPassword); + return Map.of("ok", true); + } + + // ---- serialization ----------------------------------------------------- + + private static Map serializeUser(User user) { + Map m = new LinkedHashMap<>(); + m.put("username", user.username()); + m.put("role", user.role()); + m.put("created_at", user.createdAt()); + m.put("last_login_at", user.lastLoginAt()); + return m; + } + + /** Login session body — includes the raw {@code token}, redacted by the server. */ + private static Map serializeSessionWithToken(Session session) { + Map m = new LinkedHashMap<>(); + m.put("username", session.username()); + m.put("role", session.role()); + m.put("expires_at", session.expiresAt()); + m.put("csrf_token", session.csrfToken()); + m.put("token", session.token()); + return m; + } + + private static String requireField(Map body, String key) { + Object value = body == null ? null : body.get(key); + if (value instanceof String s && !s.isEmpty()) { + return s; + } + throw DashboardError.badRequest("missing or empty field '" + key + "'"); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthStore.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthStore.java new file mode 100644 index 00000000..a45196b5 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthStore.java @@ -0,0 +1,387 @@ +package org.byteveda.taskito.dashboard.auth; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.byteveda.taskito.dashboard.store.SettingsAccess; +import org.byteveda.taskito.dashboard.support.DashboardError; +import org.byteveda.taskito.dashboard.support.Json; + +/** + * Users and sessions persisted in the settings KV store — no dedicated tables, + * so the design is backend-agnostic and shared with the other SDKs. + * + *
    + *
  • {@code auth:users} — a single JSON object {@code {username: row}}. + *
  • {@code auth:session:} — one JSON row per session; timestamps in + * seconds (user timestamps are milliseconds). + *
+ * + * Passwords use {@link PasswordHasher} (PBKDF2 600k); unknown-user logins pay a + * dummy verification so timing can't enumerate accounts. + */ +public final class AuthStore { + public static final String USERS_KEY = "auth:users"; + public static final String SESSION_PREFIX = "auth:session:"; + public static final long DEFAULT_SESSION_TTL_SECONDS = 24 * 60 * 60; + + public static final String ROLE_ADMIN = "admin"; + public static final String ROLE_VIEWER = "viewer"; + public static final Set VALID_ROLES = Set.of(ROLE_ADMIN, ROLE_VIEWER); + + public static final String ENV_ADMIN_USER = "TASKITO_DASHBOARD_ADMIN_USER"; + public static final String ENV_ADMIN_PASSWORD = "TASKITO_DASHBOARD_ADMIN_PASSWORD"; + + private static final int USERNAME_MAX_LEN = 64; + private static final int PASSWORD_MIN_LEN = 8; + private static final int PASSWORD_MAX_LEN = 256; + + private final SettingsAccess settings; + + public AuthStore(SettingsAccess settings) { + this.settings = settings; + } + + // ---- users ------------------------------------------------------------- + + public int countUsers() { + return rawUsers().size(); + } + + public Optional getUser(String username) { + Object row = rawUsers().get(username); + return row instanceof Map map ? Optional.of(toUser(username, map)) : Optional.empty(); + } + + /** + * Create a password user (default role {@code admin}). Mutations of the + * shared {@code auth:users} blob are {@code synchronized} so concurrent + * requests in this process can't lose an update; the KV store has no + * cross-process CAS, so a second writer in another process is still a + * theoretical race (bounded to first-run setup, which is single-shot). + */ + public synchronized User createUser(String username, String password, String role) { + validateUsername(username); + validatePassword(password); + validateRole(role); + if (rawUsers().containsKey(username)) { + throw DashboardError.badRequest("user already exists"); + } + String hash = PasswordHasher.hash(password); + Map users = rawUsers(); + if (users.containsKey(username)) { + throw DashboardError.badRequest("user already exists"); + } + long now = nowMillis(); + Map row = new LinkedHashMap<>(); + row.put("password_hash", hash); + row.put("role", role); + row.put("created_at", now); + row.put("last_login_at", null); + users.put(username, row); + saveUsers(users); + return new User(username, hash, role, now, null, null, null); + } + + /** Verify credentials; {@code null} on any failure. Updates last-login on success. */ + public User authenticate(String username, String password) { + Optional found = getUser(username); + if (found.isEmpty()) { + PasswordHasher.dummyVerify(password); + return null; + } + User user = found.get(); + if (!PasswordHasher.verify(password, user.passwordHash())) { + return null; + } + return touchLastLogin(user); + } + + /** Verify a password without side effects (used by change-password). */ + public boolean verifyPassword(User user, String password) { + return PasswordHasher.verify(password, user.passwordHash()); + } + + /** Change a user's password and revoke their existing sessions. */ + public synchronized void updatePassword(String username, String newPassword) { + validatePassword(newPassword); + Map users = rawUsers(); + Object rowObj = users.get(username); + if (!(rowObj instanceof Map)) { + throw DashboardError.badRequest("not_authenticated"); + } + @SuppressWarnings("unchecked") + Map row = (Map) rowObj; + row.put("password_hash", PasswordHasher.hash(newPassword)); + saveUsers(users); + // A password change must not leave stolen/older sessions valid. + deleteSessionsForUser(username); + } + + public synchronized void deleteUser(String username) { + Map users = rawUsers(); + if (users.remove(username) != null) { + saveUsers(users); + } + deleteSessionsForUser(username); + } + + private synchronized User touchLastLogin(User user) { + long now = nowMillis(); + Map users = rawUsers(); + Object rowObj = users.get(user.username()); + if (rowObj instanceof Map) { + @SuppressWarnings("unchecked") + Map row = (Map) rowObj; + row.put("last_login_at", now); + saveUsers(users); + } + return new User( + user.username(), + user.passwordHash(), + user.role(), + user.createdAt(), + now, + user.email(), + user.displayName()); + } + + // ---- OAuth users (used by the OAuth flow) ------------------------------ + + /** + * Fetch-or-provision the user behind an OAuth identity. Username is + * {@code :} (never the email). New users get a role from + * {@link #oauthBootstrapRole}; existing users keep their role but refresh + * email/display-name and last-login. + */ + public synchronized User getOrCreateOauthUser( + String slot, String subject, String email, String name, boolean emailVerified, List adminEmails) { + String username = slot + ":" + subject; + Map users = rawUsers(); + Object existing = users.get(username); + if (existing instanceof Map map) { + @SuppressWarnings("unchecked") + Map row = (Map) map; + row.put("email", email); + row.put("display_name", name); + row.put("last_login_at", nowMillis()); + saveUsers(users); + return toUser(username, row); + } + String role = oauthBootstrapRole(email, emailVerified, adminEmails, users.isEmpty()); + long now = nowMillis(); + Map row = new LinkedHashMap<>(); + row.put("password_hash", PasswordHasher.oauthSentinel(slot)); + row.put("role", role); + row.put("created_at", now); + row.put("last_login_at", now); + row.put("email", email); + row.put("display_name", name); + users.put(username, row); + saveUsers(users); + return toUser(username, row); + } + + /** Role for a freshly seen OAuth user. Admin needs a verified email. */ + public static String oauthBootstrapRole( + String email, boolean emailVerified, List adminEmails, boolean userTableEmpty) { + if (!emailVerified || email == null || email.isBlank()) { + return ROLE_VIEWER; + } + String normalised = email.toLowerCase(Locale.ROOT); + if (adminEmails != null && !adminEmails.isEmpty()) { + boolean listed = adminEmails.stream() + .anyMatch(e -> e.toLowerCase(Locale.ROOT).equals(normalised)); + return listed ? ROLE_ADMIN : ROLE_VIEWER; + } + return userTableEmpty ? ROLE_ADMIN : ROLE_VIEWER; + } + + // ---- sessions ---------------------------------------------------------- + + public Session createSession(String username, String role) { + return createSession(username, role, DEFAULT_SESSION_TTL_SECONDS); + } + + public Session createSession(String username, String role, long ttlSeconds) { + String token = Tokens.session(); + String csrf = Tokens.session(); + long now = nowSeconds(); + long expires = now + ttlSeconds; + Map row = new LinkedHashMap<>(); + row.put("username", username); + row.put("role", role); + row.put("created_at", now); + row.put("expires_at", expires); + row.put("csrf_token", csrf); + settings.setSetting(SESSION_PREFIX + token, Json.toString(row)); + return new Session(token, username, role, now, expires, csrf); + } + + /** Resolve a session token; deletes and returns empty if expired/malformed. */ + public Optional getSession(String token) { + if (token == null || token.isEmpty()) { + return Optional.empty(); + } + Optional raw = settings.getSetting(SESSION_PREFIX + token); + if (raw.isEmpty()) { + return Optional.empty(); + } + Map data = Json.parseMap(raw.get()); + if (data == null) { + return Optional.empty(); + } + Session session; + try { + session = new Session( + token, + (String) data.get("username"), + (String) data.get("role"), + asLong(data.get("created_at")), + asLong(data.get("expires_at")), + (String) data.get("csrf_token")); + } catch (RuntimeException e) { + return Optional.empty(); + } + if (session.isExpired(nowSeconds())) { + deleteSession(token); + return Optional.empty(); + } + return Optional.of(session); + } + + public void deleteSession(String token) { + settings.deleteSetting(SESSION_PREFIX + token); + } + + public int pruneExpiredSessions() { + long now = nowSeconds(); + int removed = 0; + for (Map.Entry entry : settings.listSettings().entrySet()) { + if (!entry.getKey().startsWith(SESSION_PREFIX)) { + continue; + } + Map data = Json.parseMap(entry.getValue()); + if (data == null) { + continue; + } + long expires; + try { + expires = asLong(data.get("expires_at")); + } catch (RuntimeException e) { + continue; + } + if (expires <= now) { + settings.deleteSetting(entry.getKey()); + removed++; + } + } + return removed; + } + + private void deleteSessionsForUser(String username) { + for (Map.Entry entry : settings.listSettings().entrySet()) { + if (!entry.getKey().startsWith(SESSION_PREFIX)) { + continue; + } + Map data = Json.parseMap(entry.getValue()); + if (data != null && username.equals(data.get("username"))) { + settings.deleteSetting(entry.getKey()); + } + } + } + + // ---- env bootstrap ----------------------------------------------------- + + /** + * Seed an admin from {@code TASKITO_DASHBOARD_ADMIN_USER}/{@code _PASSWORD} + * if set and the user does not yet exist. Idempotent; safe every startup. + * + *

Unlike Python/Node, the JVM cannot scrub the password out of its own + * environment (it is read-only), so the variable remains visible to the + * process — prefer first-run setup where that matters. + */ + public void bootstrapAdminFromEnv() { + bootstrapAdminFromEnv(System.getenv()); + } + + void bootstrapAdminFromEnv(Map env) { + String username = env.get(ENV_ADMIN_USER); + String password = env.get(ENV_ADMIN_PASSWORD); + if (username == null || username.isEmpty() || password == null || password.isEmpty()) { + return; + } + if (getUser(username).isPresent()) { + return; + } + createUser(username, password, ROLE_ADMIN); + } + + // ---- helpers ----------------------------------------------------------- + + private Map rawUsers() { + Optional raw = settings.getSetting(USERS_KEY); + if (raw.isEmpty()) { + return new LinkedHashMap<>(); + } + Map parsed = Json.parseMap(raw.get()); + return parsed != null ? parsed : new LinkedHashMap<>(); + } + + private void saveUsers(Map users) { + settings.setSetting(USERS_KEY, Json.toString(users)); + } + + private static User toUser(String username, Map row) { + return new User( + username, + (String) row.get("password_hash"), + (String) row.get("role"), + asLong(row.get("created_at")), + row.get("last_login_at") == null ? null : asLong(row.get("last_login_at")), + (String) row.get("email"), + (String) row.get("display_name")); + } + + private static long asLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + throw new IllegalArgumentException("expected numeric value, got " + value); + } + + private static long nowMillis() { + return System.currentTimeMillis(); + } + + private static long nowSeconds() { + return System.currentTimeMillis() / 1000; + } + + private static void validateUsername(String username) { + if (username == null || username.isEmpty() || username.length() > USERNAME_MAX_LEN) { + throw DashboardError.badRequest("username must be 1-64 characters"); + } + for (int i = 0; i < username.length(); i++) { + char c = username.charAt(i); + if (!Character.isLetterOrDigit(c) && c != '.' && c != '_' && c != '-') { + throw DashboardError.badRequest("username may only contain letters, digits, '.', '_', '-'"); + } + } + } + + private static void validatePassword(String password) { + if (password == null || password.length() < PASSWORD_MIN_LEN || password.length() > PASSWORD_MAX_LEN) { + throw DashboardError.badRequest("password must be 8-256 characters"); + } + } + + private static void validateRole(String role) { + if (!VALID_ROLES.contains(role)) { + throw DashboardError.badRequest("invalid role"); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Cookies.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Cookies.java new file mode 100644 index 00000000..ded7319f --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Cookies.java @@ -0,0 +1,79 @@ +package org.byteveda.taskito.dashboard.auth; + +import com.sun.net.httpserver.HttpExchange; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Cookie parsing and {@code Set-Cookie} formatting. The JDK has no cookie + * builder, so attributes are hand-formatted. Names and attributes match the + * reference contract: {@code taskito_session} (HttpOnly) and {@code taskito_csrf} + * (readable by the SPA), both {@code SameSite=Strict; Path=/}. + */ +public final class Cookies { + public static final String SESSION = "taskito_session"; + public static final String CSRF = "taskito_csrf"; + public static final String CSRF_HEADER = "X-CSRF-Token"; + public static final String LEGACY_TOKEN = "taskito_token"; + + private Cookies() {} + + /** Parse the {@code Cookie} header(s); first value wins for duplicate names. */ + public static Map parse(HttpExchange exchange) { + List headers = exchange.getRequestHeaders().getOrDefault("Cookie", Collections.emptyList()); + Map out = new HashMap<>(); + for (String header : headers) { + for (String pair : header.split(";")) { + int eq = pair.indexOf('='); + if (eq < 0) { + continue; + } + String name = pair.substring(0, eq).trim(); + String value = pair.substring(eq + 1).trim(); + if (!name.isEmpty()) { + out.putIfAbsent(name, value); + } + } + } + return out; + } + + public static String get(HttpExchange exchange, String name) { + return parse(exchange).get(name); + } + + public static String sessionCookie(String token, boolean secure, long maxAgeSeconds) { + return format(SESSION, token, true, secure, maxAgeSeconds); + } + + public static String csrfCookie(String csrf, boolean secure, long maxAgeSeconds) { + return format(CSRF, csrf, false, secure, maxAgeSeconds); + } + + public static String clearSession(boolean secure) { + return format(SESSION, "", true, secure, 0); + } + + public static String clearCsrf(boolean secure) { + return format(CSRF, "", false, secure, 0); + } + + public static String legacyTokenCookie(String token, boolean secure, long maxAgeSeconds) { + return format(LEGACY_TOKEN, token, true, secure, maxAgeSeconds); + } + + private static String format(String name, String value, boolean httpOnly, boolean secure, long maxAgeSeconds) { + StringBuilder sb = new StringBuilder(name).append('=').append(value); + if (httpOnly) { + sb.append("; HttpOnly"); + } + sb.append("; SameSite=Strict; Path=/"); + if (secure) { + sb.append("; Secure"); + } + sb.append("; Max-Age=").append(maxAgeSeconds); + return sb.toString(); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/PasswordHasher.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/PasswordHasher.java new file mode 100644 index 00000000..36cb63ef --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/PasswordHasher.java @@ -0,0 +1,107 @@ +package org.byteveda.taskito.dashboard.auth; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.HexFormat; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +/** + * PBKDF2-HMAC-SHA256 password hashing, byte-compatible across SDKs. + * + *

Stored format (hex, not base64): {@code pbkdf2_sha256$$$} + * — 600k iterations, 16-byte salt, 32-byte derived key. The JDK's + * {@code PBKDF2WithHmacSHA256} encodes the password as UTF-8, matching the + * reference implementation for both ASCII and non-ASCII passwords. + * + *

OAuth users store the sentinel {@code oauth:} instead of a hash; + * {@link #verify} rejects it so password login is impossible for them. + */ +final class PasswordHasher { + static final String SCHEME = "pbkdf2_sha256"; + static final int ITERATIONS = 600_000; + static final int SALT_BYTES = 16; + static final int HASH_BYTES = 32; + static final String OAUTH_PREFIX = "oauth:"; + + /** Fixed hash verified against unknown usernames to equalise login timing. */ + private static final String DUMMY_HASH = + SCHEME + "$" + ITERATIONS + "$" + "0".repeat(SALT_BYTES * 2) + "$" + "0".repeat(HASH_BYTES * 2); + + private static final HexFormat HEX = HexFormat.of(); + private static final SecureRandom RANDOM = new SecureRandom(); + + private PasswordHasher() {} + + static String hash(String password) { + byte[] salt = new byte[SALT_BYTES]; + RANDOM.nextBytes(salt); + byte[] derived = pbkdf2(password, salt, ITERATIONS, HASH_BYTES * 8); + return SCHEME + "$" + ITERATIONS + "$" + HEX.formatHex(salt) + "$" + HEX.formatHex(derived); + } + + static boolean verify(String password, String encoded) { + if (encoded == null || encoded.startsWith(OAUTH_PREFIX)) { + // Still pay a derivation so an OAuth-only or unknown user can't be + // distinguished from a password user by response timing. + dummyVerify(password); + return false; + } + String[] parts = encoded.split("\\$", -1); + if (parts.length != 4 || !SCHEME.equals(parts[0])) { + return false; + } + int iterations; + byte[] salt; + byte[] expected; + try { + iterations = Integer.parseInt(parts[1]); + salt = HEX.parseHex(parts[2]); + expected = HEX.parseHex(parts[3]); + } catch (IllegalArgumentException e) { + return false; + } + if (iterations <= 0 || salt.length == 0 || expected.length == 0) { + return false; + } + byte[] candidate; + try { + // Re-derive to the STORED hash length, mirroring the reference verifier. + candidate = pbkdf2(password, salt, iterations, expected.length * 8); + } catch (RuntimeException e) { + return false; + } + return MessageDigest.isEqual(candidate, expected); + } + + /** Run a dummy derivation so an unknown user costs the same as a known one. */ + static void dummyVerify(String password) { + verify(password, DUMMY_HASH); + } + + static String oauthSentinel(String slot) { + return OAUTH_PREFIX + slot; + } + + static boolean isOauth(String passwordHash) { + return passwordHash != null && passwordHash.startsWith(OAUTH_PREFIX); + } + + private static byte[] pbkdf2(String password, byte[] salt, int iterations, int keyLengthBits) { + PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, keyLengthBits); + try { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + .generateSecret(spec) + .getEncoded(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("PBKDF2WithHmacSHA256 unavailable", e); + } catch (InvalidKeySpecException e) { + // Empty password: the reference min length is 8, so this is a misuse. + throw new IllegalArgumentException("invalid password spec", e); + } finally { + spec.clearPassword(); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Policy.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Policy.java new file mode 100644 index 00000000..f8c3b56d --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Policy.java @@ -0,0 +1,72 @@ +package org.byteveda.taskito.dashboard.auth; + +import java.util.List; +import java.util.Set; +import org.byteveda.taskito.dashboard.support.DashboardError; + +/** + * The authorization gate: setup-required, authentication, CSRF, and RBAC. + * + *

RBAC is deliberately simple — every state-changing request is admin-only + * except the self-service endpoints (logout, change-password); all reads are + * allowed for any authenticated user. So admin vs viewer is purely write access. + */ +public final class Policy { + private static final Set PUBLIC_PATHS = + Set.of("/api/auth/status", "/api/auth/login", "/api/auth/setup", "/api/auth/providers", "/health"); + + private static final List PUBLIC_PREFIXES = List.of("/api/auth/oauth/start/", "/api/auth/oauth/callback/"); + + private static final Set SELF_SERVICE_PATHS = Set.of("/api/auth/logout", "/api/auth/change-password"); + + private static final Set CSRF_EXEMPT_PATHS = Set.of("/api/auth/login", "/api/auth/setup"); + + private Policy() {} + + public static boolean isPublicPath(String path) { + if (PUBLIC_PATHS.contains(path)) { + return true; + } + for (String prefix : PUBLIC_PREFIXES) { + if (path.startsWith(prefix)) { + return true; + } + } + return false; + } + + public static boolean isStateChanging(String method) { + return "POST".equals(method) || "PUT".equals(method) || "DELETE".equals(method) || "PATCH".equals(method); + } + + private static boolean isCsrfExempt(String path) { + return CSRF_EXEMPT_PATHS.contains(path); + } + + private static boolean requiresAdmin(String path, String method) { + return isStateChanging(method) + && path.startsWith("/api/") + && !isPublicPath(path) + && !SELF_SERVICE_PATHS.contains(path); + } + + /** Enforce the gate for an {@code /api/} request; throws on denial. */ + public static void authorize(String path, String method, RequestContext ctx, AuthStore store) { + if (!isPublicPath(path) && store.countUsers() == 0) { + throw DashboardError.serviceUnavailable("setup_required"); + } + if (isPublicPath(path)) { + return; + } + if (!ctx.authenticated()) { + throw DashboardError.unauthorized("not_authenticated"); + } + if (isStateChanging(method) && !isCsrfExempt(path) && !ctx.csrfValid()) { + throw DashboardError.forbidden("csrf_failed"); + } + if (requiresAdmin(path, method) + && !AuthStore.ROLE_ADMIN.equals(ctx.session().role())) { + throw DashboardError.forbidden("forbidden"); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/RequestContext.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/RequestContext.java new file mode 100644 index 00000000..4d6787ce --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/RequestContext.java @@ -0,0 +1,45 @@ +package org.byteveda.taskito.dashboard.auth; + +import com.sun.net.httpserver.HttpExchange; +import java.util.Map; + +/** + * Per-request auth state: the resolved {@link Session} (null when + * unauthenticated) plus the CSRF cookie and header used for double-submit + * validation. + */ +public record RequestContext(Session session, String csrfCookie, String csrfHeader) { + + public boolean authenticated() { + return session != null; + } + + /** + * Double-submit CSRF check: the {@code taskito_csrf} cookie must equal both + * the {@code X-CSRF-Token} header and the token bound to the session. The + * session binding defeats a pre-seeded cookie. + */ + public boolean csrfValid() { + return session != null + && csrfCookie != null + && !csrfCookie.isEmpty() + && csrfHeader != null + && csrfCookie.equals(csrfHeader) + && csrfCookie.equals(session.csrfToken()); + } + + public static RequestContext build(HttpExchange exchange, AuthStore store) { + Map cookies = Cookies.parse(exchange); + String sessionToken = cookies.get(Cookies.SESSION); + Session session = + sessionToken == null ? null : store.getSession(sessionToken).orElse(null); + String csrfCookie = cookies.get(Cookies.CSRF); + String csrfHeader = exchange.getRequestHeaders().getFirst(Cookies.CSRF_HEADER); + return new RequestContext(session, csrfCookie, csrfHeader); + } + + /** Fixed open identity for legacy shared-token mode. */ + public static RequestContext open() { + return new RequestContext(null, null, null); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Session.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Session.java new file mode 100644 index 00000000..320d78d3 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Session.java @@ -0,0 +1,14 @@ +package org.byteveda.taskito.dashboard.auth; + +/** + * An authenticated session. {@code createdAt}/{@code expiresAt} are Unix + * seconds (unlike user/webhook timestamps, which are milliseconds) — + * this matches the reference wire contract exactly. The {@code token} is the KV + * key suffix and is never serialised into the stored record. + */ +public record Session(String token, String username, String role, long createdAt, long expiresAt, String csrfToken) { + + public boolean isExpired(long nowSeconds) { + return nowSeconds >= expiresAt; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/TokenAuth.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/TokenAuth.java new file mode 100644 index 00000000..c3b4023b --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/TokenAuth.java @@ -0,0 +1,70 @@ +package org.byteveda.taskito.dashboard.auth; + +import com.sun.net.httpserver.HttpExchange; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Legacy shared-token mode: a single bearer token gates {@code /api/*} and every + * request runs as a fixed admin identity (no users, sessions, or CSRF). Kept for + * back-compat with {@code --token}; the session flow is the default. + * + *

The token is accepted, in order, from {@code Authorization: Bearer}, + * {@code X-Taskito-Token}, {@code ?token=}, or the {@code taskito_token} cookie. + */ +public final class TokenAuth { + private static final long OPEN_COOKIE_MAX_AGE = 24 * 60 * 60; + + private final String token; + + public TokenAuth(String token) { + this.token = token; + } + + public String presented(HttpExchange exchange, Map query) { + String authorization = exchange.getRequestHeaders().getFirst("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + return authorization.substring("Bearer ".length()).trim(); + } + String header = exchange.getRequestHeaders().getFirst("X-Taskito-Token"); + if (header != null && !header.isEmpty()) { + return header; + } + String queryToken = query.get("token"); + if (queryToken != null && !queryToken.isEmpty()) { + return queryToken; + } + return Cookies.get(exchange, Cookies.LEGACY_TOKEN); + } + + public boolean matches(String presented) { + if (presented == null) { + return false; + } + return MessageDigest.isEqual( + token.getBytes(StandardCharsets.UTF_8), presented.getBytes(StandardCharsets.UTF_8)); + } + + public static String openCookie(String token, boolean secure) { + return Cookies.legacyTokenCookie(token, secure, OPEN_COOKIE_MAX_AGE); + } + + public static Map openStatus() { + return Map.of("setup_required", false); + } + + public static Map openWhoami() { + Map user = new LinkedHashMap<>(); + user.put("username", "viewer"); + user.put("role", AuthStore.ROLE_ADMIN); + user.put("created_at", 0); + user.put("last_login_at", null); + Map out = new LinkedHashMap<>(); + out.put("user", user); + out.put("csrf_token", "open"); + out.put("expires_at", 0); + return out; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Tokens.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Tokens.java new file mode 100644 index 00000000..052a81f5 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/Tokens.java @@ -0,0 +1,33 @@ +package org.byteveda.taskito.dashboard.auth; + +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Cryptographically strong opaque tokens. Encoding matches the other SDKs' + * {@code secrets.token_urlsafe(n)} / {@code randomBytes(n).toString("base64url")}: + * {@code n} random bytes rendered as unpadded URL-safe base64 (32 bytes → 43 + * chars, 16 bytes → 22 chars). + */ +public final class Tokens { + public static final int SESSION_TOKEN_BYTES = 32; + public static final int NONCE_BYTES = 16; + public static final int STATE_TOKEN_BYTES = 32; + public static final int CODE_VERIFIER_BYTES = 32; + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private Tokens() {} + + public static String urlSafe(int numBytes) { + byte[] bytes = new byte[numBytes]; + RANDOM.nextBytes(bytes); + return URL_ENCODER.encodeToString(bytes); + } + + /** A 256-bit session or CSRF token. */ + public static String session() { + return urlSafe(SESSION_TOKEN_BYTES); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/User.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/User.java new file mode 100644 index 00000000..0d61ebbb --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/User.java @@ -0,0 +1,21 @@ +package org.byteveda.taskito.dashboard.auth; + +/** + * A dashboard user. {@code createdAt}/{@code lastLoginAt} are Unix + * milliseconds. {@code email}/{@code displayName} are populated for OAuth users + * only. Password users have a {@code pbkdf2_sha256$...} {@code passwordHash}; + * OAuth users have the {@code oauth:} sentinel. + */ +public record User( + String username, + String passwordHash, + String role, + long createdAt, + Long lastLoginAt, + String email, + String displayName) { + + public boolean isOauth() { + return PasswordHasher.isOauth(passwordHash); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/package-info.java new file mode 100644 index 00000000..f9564d89 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/package-info.java @@ -0,0 +1,5 @@ +/** + * Session authentication: KV-backed users/sessions, PBKDF2 passwords, CSRF + * double-submit cookies, admin/viewer RBAC, and the legacy shared-token mode. + */ +package org.byteveda.taskito.dashboard.auth; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Handler.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Handler.java new file mode 100644 index 00000000..ca0a7192 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Handler.java @@ -0,0 +1,13 @@ +package org.byteveda.taskito.dashboard.routing; + +import java.io.IOException; + +/** + * A route handler. Returns the JSON response body (serialised with status 200), + * or {@code null} to signal 404. Throw {@code DashboardError} for other + * statuses. May add {@code Set-Cookie} response headers before returning. + */ +@FunctionalInterface +public interface Handler { + Object handle(Req req) throws IOException; +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Req.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Req.java new file mode 100644 index 00000000..9844d128 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Req.java @@ -0,0 +1,32 @@ +package org.byteveda.taskito.dashboard.routing; + +import com.sun.net.httpserver.HttpExchange; +import java.util.List; +import java.util.Map; +import org.byteveda.taskito.dashboard.auth.RequestContext; +import org.byteveda.taskito.dashboard.support.Json; + +/** + * Everything a route handler needs: the exchange, matched path parameters + * (decoded), the parsed query, the request body (null for non-body methods), + * and the resolved auth context. + */ +public record Req( + HttpExchange exchange, + String method, + String path, + List params, + Map query, + byte[] body, + RequestContext ctx) { + + public String param(int index) { + return params.get(index); + } + + /** Parse the body as a JSON object; empty map if absent/invalid. */ + public Map jsonBody() { + Map parsed = Json.readObject(body); + return parsed != null ? parsed : Map.of(); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Router.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Router.java new file mode 100644 index 00000000..19ef1a23 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/Router.java @@ -0,0 +1,79 @@ +package org.byteveda.taskito.dashboard.routing; + +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.byteveda.taskito.dashboard.auth.Policy; +import org.byteveda.taskito.dashboard.auth.RequestContext; +import org.byteveda.taskito.dashboard.support.Http; + +/** + * An ordered method+path route table. The first route whose method and + * anchored pattern match wins, so register specific paths (e.g. + * {@code /jobs//logs}) before catch-alls ({@code /jobs/}). Capture + * groups become decoded path parameters. + */ +public final class Router { + private record Route(String method, Pattern pattern, Handler handler) {} + + private final List routes = new ArrayList<>(); + + public Router get(String pattern, Handler handler) { + return add("GET", pattern, handler); + } + + public Router post(String pattern, Handler handler) { + return add("POST", pattern, handler); + } + + public Router put(String pattern, Handler handler) { + return add("PUT", pattern, handler); + } + + public Router delete(String pattern, Handler handler) { + return add("DELETE", pattern, handler); + } + + private Router add(String method, String pattern, Handler handler) { + routes.add(new Route(method, Pattern.compile(pattern), handler)); + return this; + } + + /** + * Dispatch a request. Returns {@code false} (no route matched) so the caller + * can 404; otherwise writes the response and returns {@code true}. + */ + public boolean dispatch( + HttpExchange exchange, String method, String path, Map query, RequestContext ctx) + throws IOException { + for (Route route : routes) { + if (!route.method().equals(method)) { + continue; + } + Matcher matcher = route.pattern().matcher(path); + if (!matcher.matches()) { + continue; + } + List params = new ArrayList<>(matcher.groupCount()); + for (int i = 1; i <= matcher.groupCount(); i++) { + String group = matcher.group(i); + params.add(group == null ? null : URLDecoder.decode(group, StandardCharsets.UTF_8)); + } + byte[] body = Policy.isStateChanging(method) ? Http.readBody(exchange, Http.MAX_BODY_BYTES) : null; + Object result = route.handler().handle(new Req(exchange, method, path, params, query, body, ctx)); + if (result == null) { + Http.respondError(exchange, 404, "not found"); + } else { + Http.respondJson(exchange, 200, result); + } + return true; + } + return false; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/package-info.java new file mode 100644 index 00000000..428f34cb --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/routing/package-info.java @@ -0,0 +1,2 @@ +/** Method+path route table and request dispatch for the dashboard API. */ +package org.byteveda.taskito.dashboard.routing; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/store/SettingsAccess.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/store/SettingsAccess.java new file mode 100644 index 00000000..4e5a4466 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/store/SettingsAccess.java @@ -0,0 +1,49 @@ +package org.byteveda.taskito.dashboard.store; + +import java.util.Map; +import java.util.Optional; +import org.byteveda.taskito.Taskito; + +/** + * Narrow view of the {@code dashboard_settings} KV store — the single + * persistence primitive behind auth, OAuth state, overrides, middleware toggles + * and webhooks. Every backend already exposes it, so no schema changes are + * needed. Decoupling the stores from {@link Taskito} keeps them unit-testable + * against an in-memory map. + */ +public interface SettingsAccess { + + Optional getSetting(String key); + + void setSetting(String key, String value); + + /** @return whether a row existed. */ + boolean deleteSetting(String key); + + /** All settings; callers filter by key prefix. */ + Map listSettings(); + + static SettingsAccess of(Taskito queue) { + return new SettingsAccess() { + @Override + public Optional getSetting(String key) { + return queue.getSetting(key); + } + + @Override + public void setSetting(String key, String value) { + queue.setSetting(key, value); + } + + @Override + public boolean deleteSetting(String key) { + return queue.deleteSetting(key); + } + + @Override + public Map listSettings() { + return queue.listSettings(); + } + }; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/store/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/store/package-info.java new file mode 100644 index 00000000..7c1cfa68 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/store/package-info.java @@ -0,0 +1,2 @@ +/** The settings-KV persistence seam shared by auth, OAuth, overrides and webhooks. */ +package org.byteveda.taskito.dashboard.store; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/DashboardError.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/DashboardError.java new file mode 100644 index 00000000..8c2c66ac --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/DashboardError.java @@ -0,0 +1,51 @@ +package org.byteveda.taskito.dashboard.support; + +/** + * A handler-level failure that maps to a specific HTTP status and a stable + * machine-readable {@code error} code in the JSON body. Thrown by handlers and + * the auth gate; caught centrally by the server, which renders + * {@code {"error": code}} with {@link #status()}. + */ +public final class DashboardError extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final int status; + + private DashboardError(int status, String code) { + super(code); + this.status = status; + } + + public int status() { + return status; + } + + /** The stable error code (also the exception message). */ + public String code() { + return getMessage(); + } + + public static DashboardError of(int status, String code) { + return new DashboardError(status, code); + } + + public static DashboardError badRequest(String code) { + return new DashboardError(400, code); + } + + public static DashboardError unauthorized(String code) { + return new DashboardError(401, code); + } + + public static DashboardError forbidden(String code) { + return new DashboardError(403, code); + } + + public static DashboardError notFound(String code) { + return new DashboardError(404, code); + } + + public static DashboardError serviceUnavailable(String code) { + return new DashboardError(503, code); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Http.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Http.java new file mode 100644 index 00000000..bf1bd310 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Http.java @@ -0,0 +1,73 @@ +package org.byteveda.taskito.dashboard.support; + +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** Low-level {@code HttpExchange} helpers shared across dashboard handlers. */ +public final class Http { + public static final int MAX_BODY_BYTES = 1024 * 1024; + + private Http() {} + + public static Map query(HttpExchange exchange) { + Map out = new HashMap<>(); + String raw = exchange.getRequestURI().getRawQuery(); + if (raw == null) { + return out; + } + for (String pair : raw.split("&")) { + int eq = pair.indexOf('='); + if (eq < 0) { + out.putIfAbsent(URLDecoder.decode(pair, StandardCharsets.UTF_8), ""); + continue; + } + String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8); + String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + out.putIfAbsent(key, value); + } + return out; + } + + /** Read the request body, capped at {@code maxBytes}. */ + public static byte[] readBody(HttpExchange exchange, int maxBytes) throws IOException { + try (InputStream in = exchange.getRequestBody(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int total = 0; + int read; + while ((read = in.read(buffer)) != -1) { + total += read; + if (total > maxBytes) { + throw DashboardError.of(413, "payload too large"); + } + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + } + + public static void respondJson(HttpExchange exchange, int status, Object body) throws IOException { + byte[] out = Json.toBytes(body); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, out.length); + try (OutputStream stream = exchange.getResponseBody()) { + stream.write(out); + } + } + + public static void respondError(HttpExchange exchange, int status, String code) throws IOException { + respondJson(exchange, status, errorBody(code)); + } + + public static Map errorBody(String code) { + return Collections.singletonMap("error", code); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Json.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Json.java new file mode 100644 index 00000000..fa159b66 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Json.java @@ -0,0 +1,67 @@ +package org.byteveda.taskito.dashboard.support; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.Map; +import org.byteveda.taskito.errors.SerializationException; + +/** + * Shared JSON codec for the dashboard. Output is compact (no spaces) so that + * records written to the settings KV are byte-compatible with the other SDKs, + * which persist with the equivalent of {@code json.dumps(separators=(",", ":"))}. + */ +public final class Json { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private Json() {} + + public static byte[] toBytes(Object value) { + try { + return MAPPER.writeValueAsBytes(value); + } catch (IOException e) { + throw new SerializationException("failed to encode response", e); + } + } + + public static String toString(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (IOException e) { + throw new SerializationException("failed to encode value", e); + } + } + + /** Parse an object body; returns {@code null} for non-object or malformed input. */ + public static Map readObject(byte[] body) { + if (body == null || body.length == 0) { + return null; + } + try { + return asMap(MAPPER.readTree(body)); + } catch (IOException e) { + return null; + } + } + + /** Parse a stored JSON string into a mutable map; {@code null} if malformed/non-object. */ + public static Map parseMap(String json) { + if (json == null || json.isEmpty()) { + return null; + } + try { + return asMap(MAPPER.readTree(json)); + } catch (IOException e) { + return null; + } + } + + private static Map asMap(JsonNode node) { + if (node == null || !node.isObject()) { + return null; + } + return MAPPER.convertValue(node, MAP_TYPE); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/package-info.java new file mode 100644 index 00000000..77200e85 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/package-info.java @@ -0,0 +1,2 @@ +/** Low-level dashboard helpers: JSON codec, HTTP exchange utilities, typed errors. */ +package org.byteveda.taskito.dashboard.support; diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardAuthTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardAuthTest.java new file mode 100644 index 00000000..e83d85a2 --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardAuthTest.java @@ -0,0 +1,186 @@ +package org.byteveda.taskito.dashboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.util.List; +import org.byteveda.taskito.Taskito; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +/** End-to-end coverage of the session auth flow, RBAC, CSRF, and legacy token mode. */ +@Timeout(30) +class DashboardAuthTest { + + private static Taskito open(Path dir) { + return Taskito.builder().sqlite(dir.resolve("t.db").toString()).open(); + } + + private static HttpResponse raw(int port, String method, String path, String json) throws Exception { + HttpRequest.BodyPublisher publisher = + json == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(json); + HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .method(method, publisher) + .header("Content-Type", "application/json") + .build(); + return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + } + + @Test + void gatesUntilFirstAdminExists(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + int port = server.port(); + assertEquals(503, raw(port, "GET", "/api/stats", null).statusCode()); + assertTrue(raw(port, "GET", "/api/auth/status", null).body().contains("\"setup_required\":true")); + } + } + + @Test + void setupCreatesAdminOnce(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + int port = server.port(); + HttpResponse setup = + raw(port, "POST", "/api/auth/setup", "{\"username\":\"root\",\"password\":\"password123\"}"); + assertEquals(200, setup.statusCode()); + assertTrue(setup.body().contains("\"role\":\"admin\"")); + // Second setup rejected. + assertEquals( + 400, + raw(port, "POST", "/api/auth/setup", "{\"username\":\"x\",\"password\":\"password123\"}") + .statusCode()); + // With a user present, status flips. + assertTrue(raw(port, "GET", "/api/auth/status", null).body().contains("\"setup_required\":false")); + } + } + + @Test + void loginSetsCookiesAndRedactsToken(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + int port = server.port(); + raw(port, "POST", "/api/auth/setup", "{\"username\":\"root\",\"password\":\"password123\"}"); + HttpResponse login = + raw(port, "POST", "/api/auth/login", "{\"username\":\"root\",\"password\":\"password123\"}"); + assertEquals(200, login.statusCode()); + List cookies = login.headers().allValues("set-cookie"); + assertTrue(cookies.stream().anyMatch(c -> c.startsWith("taskito_session=") && c.contains("HttpOnly"))); + assertTrue(cookies.stream().anyMatch(c -> c.startsWith("taskito_csrf=") && !c.contains("HttpOnly"))); + // Raw session token never leaks into the JSON body. + assertFalse(login.body().contains("\"token\"")); + assertTrue(login.body().contains("\"csrf_token\"")); + } + } + + @Test + void rejectsBadCredentials(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + int port = server.port(); + raw(port, "POST", "/api/auth/setup", "{\"username\":\"root\",\"password\":\"password123\"}"); + HttpResponse bad = + raw(port, "POST", "/api/auth/login", "{\"username\":\"root\",\"password\":\"nope\"}"); + assertEquals(400, bad.statusCode()); + assertTrue(bad.body().contains("invalid_credentials")); + } + } + + @Test + void unauthenticatedApiIsRejected(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + DashboardClient.seedAdmin(queue); // a user now exists + assertEquals(401, raw(server.port(), "GET", "/api/stats", null).statusCode()); + } + } + + @Test + void authenticatedReadsSucceed(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + DashboardClient client = new DashboardClient(server.port()).as(DashboardClient.seedAdmin(queue)); + assertEquals(200, client.get("/api/stats").statusCode()); + assertEquals(200, client.get("/api/auth/whoami").statusCode()); + } + } + + @Test + void csrfIsRequiredForWrites(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + DashboardClient client = new DashboardClient(server.port()).as(DashboardClient.seedAdmin(queue)); + assertEquals( + 403, + client.postWithoutCsrf("/api/queues/emails/pause", null).statusCode()); + assertEquals(200, client.post("/api/queues/emails/pause", null).statusCode()); + } + } + + @Test + void viewersCannotWrite(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + DashboardClient.seedAdmin(queue); // keep setup satisfied + DashboardClient viewer = + new DashboardClient(server.port()).as(DashboardClient.seedUser(queue, "read-only", "viewer")); + assertEquals(200, viewer.get("/api/stats").statusCode()); + assertEquals(403, viewer.post("/api/queues/emails/pause", null).statusCode()); + } + } + + @Test + void logoutInvalidatesSession(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + DashboardClient client = new DashboardClient(server.port()).as(DashboardClient.seedAdmin(queue)); + assertEquals(200, client.post("/api/auth/logout", null).statusCode()); + assertEquals(401, client.get("/api/auth/whoami").statusCode()); + } + } + + @Test + void changePasswordRotatesCredential(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + int port = server.port(); + DashboardClient client = new DashboardClient(port).as(DashboardClient.seedUser(queue, "root", "admin")); + HttpResponse changed = client.post( + "/api/auth/change-password", "{\"old_password\":\"password123\",\"new_password\":\"brand-new-1\"}"); + assertEquals(200, changed.statusCode()); + assertEquals( + 200, + raw(port, "POST", "/api/auth/login", "{\"username\":\"root\",\"password\":\"brand-new-1\"}") + .statusCode()); + } + } + + @Test + void oauthEndpointsReport404WhenUnconfigured(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0)) { + int port = server.port(); + assertTrue(raw(port, "GET", "/api/auth/providers", null).body().contains("\"password_enabled\":true")); + assertEquals( + 404, raw(port, "GET", "/api/auth/oauth/start/google", null).statusCode()); + } + } + + @Test + void legacyTokenModeStillWorks(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0, "sekret", null)) { + int port = server.port(); + assertTrue(raw(port, "GET", "/api/auth/status", null).body().contains("\"setup_required\":false")); + assertEquals(401, raw(port, "GET", "/api/stats", null).statusCode()); + assertEquals(200, raw(port, "GET", "/api/stats?token=sekret", null).statusCode()); + } + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardClient.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardClient.java new file mode 100644 index 00000000..81eb66ac --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardClient.java @@ -0,0 +1,91 @@ +package org.byteveda.taskito.dashboard; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.byteveda.taskito.Taskito; +import org.byteveda.taskito.dashboard.auth.AuthStore; +import org.byteveda.taskito.dashboard.auth.Session; +import org.byteveda.taskito.dashboard.store.SettingsAccess; + +/** + * Test seam for the dashboard API: seeds users/sessions straight into the KV + * store (bypassing login) and attaches the session cookie + CSRF header to + * requests. Mirrors Python's {@code _testing.AuthedClient}. + */ +final class DashboardClient { + private final HttpClient http = HttpClient.newHttpClient(); + private final String base; + private String sessionToken; + private String csrfToken; + + DashboardClient(int port) { + this.base = "http://localhost:" + port; + } + + DashboardClient as(Session session) { + this.sessionToken = session.token(); + this.csrfToken = session.csrfToken(); + return this; + } + + HttpResponse get(String path) throws Exception { + HttpRequest.Builder builder = + HttpRequest.newBuilder(URI.create(base + path)).GET(); + applyCookies(builder); + return send(builder); + } + + HttpResponse post(String path, String json) throws Exception { + return body("POST", path, json, true); + } + + HttpResponse postWithoutCsrf(String path, String json) throws Exception { + return body("POST", path, json, false); + } + + private HttpResponse body(String method, String path, String json, boolean withCsrf) throws Exception { + HttpRequest.BodyPublisher publisher = + json == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(json); + HttpRequest.Builder builder = + HttpRequest.newBuilder(URI.create(base + path)).method(method, publisher); + if (json != null) { + builder.header("Content-Type", "application/json"); + } + applyCookies(builder); + if (withCsrf && csrfToken != null) { + builder.header("X-CSRF-Token", csrfToken); + } + return send(builder); + } + + private void applyCookies(HttpRequest.Builder builder) { + if (sessionToken == null) { + return; + } + String cookie = "taskito_session=" + sessionToken; + if (csrfToken != null) { + cookie += "; taskito_csrf=" + csrfToken; + } + builder.header("Cookie", cookie); + } + + private HttpResponse send(HttpRequest.Builder builder) throws Exception { + return http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + static AuthStore store(Taskito queue) { + return new AuthStore(SettingsAccess.of(queue)); + } + + static Session seedAdmin(Taskito queue) { + return seedUser(queue, "admin", "admin"); + } + + static Session seedUser(Taskito queue, String username, String role) { + AuthStore store = store(queue); + store.createUser(username, "password123", role); + return store.createSession(username, role); + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java index bf61ff83..47b99f4f 100644 --- a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java @@ -47,13 +47,13 @@ void servesSnakeCaseContract(@TempDir Path dir) throws Exception { Collections.singletonMap("a", 1), EnqueueOptions.builder().queue("emails").build()); try (DashboardServer server = DashboardServer.start(queue, 0)) { - int port = server.port(); + DashboardClient client = new DashboardClient(server.port()).as(DashboardClient.seedAdmin(queue)); - HttpResponse stats = get(port, "/api/stats"); + HttpResponse stats = client.get("/api/stats"); assertEquals(200, stats.statusCode()); assertTrue(stats.body().contains("\"pending\":1")); - HttpResponse jobs = get(port, "/api/jobs?queue=emails"); + HttpResponse jobs = client.get("/api/jobs?queue=emails"); assertTrue(jobs.body().contains("\"task_name\":\"add\"")); assertTrue(jobs.body().contains("\"status\":\"pending\"")); } diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/AuthStoreTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/AuthStoreTest.java new file mode 100644 index 00000000..7c262a68 --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/AuthStoreTest.java @@ -0,0 +1,166 @@ +package org.byteveda.taskito.dashboard.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.byteveda.taskito.dashboard.store.SettingsAccess; +import org.byteveda.taskito.dashboard.support.DashboardError; +import org.junit.jupiter.api.Test; + +class AuthStoreTest { + + private static final class MemSettings implements SettingsAccess { + private final Map map = new HashMap<>(); + + @Override + public Optional getSetting(String key) { + return Optional.ofNullable(map.get(key)); + } + + @Override + public void setSetting(String key, String value) { + map.put(key, value); + } + + @Override + public boolean deleteSetting(String key) { + return map.remove(key) != null; + } + + @Override + public Map listSettings() { + return new HashMap<>(map); + } + } + + private AuthStore store() { + return new AuthStore(new MemSettings()); + } + + @Test + void createsAndAuthenticatesUsers() { + AuthStore store = store(); + assertEquals(0, store.countUsers()); + User user = store.createUser("alice", "password123", "admin"); + assertEquals("alice", user.username()); + assertEquals("admin", user.role()); + assertNull(user.lastLoginAt()); + assertEquals(1, store.countUsers()); + + assertNotNull(store.authenticate("alice", "password123")); + assertNull(store.authenticate("alice", "wrong")); + assertNull(store.authenticate("ghost", "password123")); + + // last-login stamped on success (milliseconds). + User reloaded = store.getUser("alice").orElseThrow(); + assertNotNull(reloaded.lastLoginAt()); + assertTrue(reloaded.lastLoginAt() > 1_000_000_000_000L); + } + + @Test + void rejectsDuplicatesAndInvalidInput() { + AuthStore store = store(); + store.createUser("bob", "password123", "admin"); + assertThrows(DashboardError.class, () -> store.createUser("bob", "password123", "admin")); + assertThrows(DashboardError.class, () -> store.createUser("bad name", "password123", "admin")); + assertThrows(DashboardError.class, () -> store.createUser("carol", "short", "admin")); + assertThrows(DashboardError.class, () -> store.createUser("carol", "password123", "superuser")); + } + + @Test + void sessionsUseSecondsAndExpire() { + AuthStore store = store(); + Session session = store.createSession("alice", "admin"); + assertEquals(AuthStore.DEFAULT_SESSION_TTL_SECONDS, session.expiresAt() - session.createdAt()); + assertTrue(session.createdAt() < 1_000_000_000_000L); // seconds, not millis + assertTrue(store.getSession(session.token()).isPresent()); + + Session expired = store.createSession("alice", "admin", -10); + assertTrue(store.getSession(expired.token()).isEmpty()); + } + + @Test + void prunesExpiredSessions() { + AuthStore store = store(); + Session valid = store.createSession("alice", "admin", 3600); + store.createSession("alice", "admin", -10); + assertEquals(1, store.pruneExpiredSessions()); + assertTrue(store.getSession(valid.token()).isPresent()); + } + + @Test + void deletingUserClearsSessions() { + AuthStore store = store(); + store.createUser("alice", "password123", "admin"); + Session session = store.createSession("alice", "admin"); + store.deleteUser("alice"); + assertEquals(0, store.countUsers()); + assertTrue(store.getSession(session.token()).isEmpty()); + } + + @Test + void oauthBootstrapRolePrecedence() { + // Unverified / missing email never becomes admin. + assertEquals("viewer", AuthStore.oauthBootstrapRole("a@x.com", false, List.of(), true)); + assertEquals("viewer", AuthStore.oauthBootstrapRole(null, true, List.of(), true)); + // Admin-email list wins (case-insensitive) and disables first-user-wins. + assertEquals("admin", AuthStore.oauthBootstrapRole("A@X.com", true, List.of("a@x.com"), false)); + assertEquals("viewer", AuthStore.oauthBootstrapRole("b@x.com", true, List.of("a@x.com"), true)); + // No admin list: first verified user wins. + assertEquals("admin", AuthStore.oauthBootstrapRole("c@x.com", true, List.of(), true)); + assertEquals("viewer", AuthStore.oauthBootstrapRole("c@x.com", true, List.of(), false)); + } + + @Test + void getOrCreateOauthUserProvisionsThenRefreshes() { + AuthStore store = store(); + User created = store.getOrCreateOauthUser("google", "123", "a@x.com", "Ann", true, List.of()); + assertEquals("google:123", created.username()); + assertEquals("admin", created.role()); // first user + assertTrue(created.isOauth()); + + User refreshed = store.getOrCreateOauthUser("google", "123", "new@x.com", "Ann N", true, List.of("z@x.com")); + assertEquals("google:123", refreshed.username()); + assertEquals("admin", refreshed.role()); // role preserved + assertEquals("new@x.com", refreshed.email()); + assertEquals(1, store.countUsers()); + } + + @Test + void envBootstrapIsIdempotent() { + AuthStore store = store(); + Map env = Map.of( + AuthStore.ENV_ADMIN_USER, "root", + AuthStore.ENV_ADMIN_PASSWORD, "password123"); + store.bootstrapAdminFromEnv(env); + assertEquals(1, store.countUsers()); + store.bootstrapAdminFromEnv(env); + assertEquals(1, store.countUsers()); + assertNotNull(store.authenticate("root", "password123")); + + AuthStore empty = store(); + empty.bootstrapAdminFromEnv(Map.of()); + assertEquals(0, empty.countUsers()); + } + + @Test + void changePasswordInvalidatesOldCredential() { + AuthStore store = store(); + store.createUser("alice", "password123", "admin"); + Session existing = store.createSession("alice", "admin"); + store.updatePassword("alice", "new-password!"); + assertNull(store.authenticate("alice", "password123")); + assertNotNull(store.authenticate("alice", "new-password!")); + // Sessions from before the change are revoked. + assertTrue(store.getSession(existing.token()).isEmpty()); + assertFalse(store.getSession("missing").isPresent()); + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/PasswordHasherTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/PasswordHasherTest.java new file mode 100644 index 00000000..2a87ac38 --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/PasswordHasherTest.java @@ -0,0 +1,73 @@ +package org.byteveda.taskito.dashboard.auth; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Cross-language PBKDF2 compatibility and format correctness. */ +class PasswordHasherTest { + + // RFC-style PBKDF2-HMAC-SHA256 vector: password="password", salt="salt"(=73616c74), 1 iter, 32 bytes. + private static final String VECTOR_1_ITER = + "pbkdf2_sha256$1$73616c74$120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"; + + // A real hash produced by Python's hashlib.pbkdf2_hmac at the production 600k iterations. + private static final String PYTHON_600K = "pbkdf2_sha256$600000$000102030405060708090a0b0c0d0e0f" + + "$96a5904c2e08c8da42305dbcc5d7cf18ead2636d49f59526b606f26696281473"; + + @Test + void verifiesKnownVector() { + assertTrue(PasswordHasher.verify("password", VECTOR_1_ITER)); + assertFalse(PasswordHasher.verify("wrong", VECTOR_1_ITER)); + } + + @Test + void verifiesPythonProducedHash() { + assertTrue(PasswordHasher.verify("correct horse", PYTHON_600K)); + assertFalse(PasswordHasher.verify("correct horse ", PYTHON_600K)); + } + + @Test + void hashRoundTrips() { + String encoded = PasswordHasher.hash("hunter2!!"); + assertTrue(PasswordHasher.verify("hunter2!!", encoded)); + assertFalse(PasswordHasher.verify("hunter2!", encoded)); + } + + @Test + void hashUsesReferenceFormat() { + String[] parts = PasswordHasher.hash("some-password").split("\\$", -1); + assertTrue(parts.length == 4); + assertTrue("pbkdf2_sha256".equals(parts[0])); + assertTrue("600000".equals(parts[1])); + assertTrue(parts[2].length() == 32); // 16-byte salt, hex + assertTrue(parts[3].length() == 64); // 32-byte hash, hex + } + + @Test + void handlesNonAsciiPasswords() { + String encoded = PasswordHasher.hash("pä$$wörd–😀"); + assertTrue(PasswordHasher.verify("pä$$wörd–😀", encoded)); + } + + @Test + void rejectsOauthSentinel() { + assertFalse(PasswordHasher.verify("anything", "oauth:google")); + assertTrue(PasswordHasher.isOauth("oauth:google")); + assertFalse(PasswordHasher.isOauth(PasswordHasher.hash("x-password"))); + } + + @Test + void rejectsMalformedEncodings() { + assertFalse(PasswordHasher.verify("x", null)); + assertFalse(PasswordHasher.verify("x", "")); + assertFalse(PasswordHasher.verify("x", "pbkdf2_sha256$1$zz$zz")); + assertFalse(PasswordHasher.verify("x", "bcrypt$1$aa$bb")); + } + + @Test + void dummyVerifyDoesNotThrow() { + PasswordHasher.dummyVerify("whatever"); + } +}