From d54826b66bb067554ac037fca13d19a4b37c439d Mon Sep 17 00:00:00 2001 From: Adrian Victor Date: Fri, 27 Mar 2026 22:05:22 -0300 Subject: [PATCH] Add authentication on HTTP API. Add LoginHandler and register. Add method to create user in AppConfig. Add login check in Server. --- README.md | 10 ++- shell.nix | 9 +++ .../livingroom/config/AppConfig.java | 1 + .../livingroom/config/ConfigLoader.java | 1 - .../livingroom/http/Handlers.java | 5 +- .../livingroom/http/HttpResponse.java | 8 +++ .../adrianvictor/livingroom/http/Server.java | 48 +++++++------ .../http/handlers/LoginHandler.java | 69 +++++++++++++++++++ .../livingroom/http/handlers/WebHandler.java | 1 - .../http/handlers/WebRedirectHandler.java | 13 ++++ .../http/utils/AuthenticationHelper.java | 44 ++++++++++++ .../adrianvictor/livingroom/web/Pages.java | 2 +- .../livingroom/web/pages/Login.java | 3 - .../livingroom/web/pages/Remove.java | 3 - 14 files changed, 184 insertions(+), 33 deletions(-) create mode 100644 shell.nix create mode 100644 src/main/java/org/adrianvictor/livingroom/http/handlers/LoginHandler.java create mode 100644 src/main/java/org/adrianvictor/livingroom/http/handlers/WebRedirectHandler.java create mode 100644 src/main/java/org/adrianvictor/livingroom/http/utils/AuthenticationHelper.java diff --git a/README.md b/README.md index 0c257dd..fa5ae1b 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,14 @@ This is a work-in-progress kinda of game launcher. LivingRoom is a server softwa The server includes a (working but very WIP) HTTP API for third-party clients and a web interface. ## Features +### Server +- [x] Game scanner + - [ ] Automatically deindex removed game +- [x] SQLite DB +- [x] Metadata + ### HTTP API -- [ ] Authentication +- [x] Authentication - [x] Library info - [x] Game info - [x] Downloads @@ -24,7 +30,7 @@ The server includes a (working but very WIP) HTTP API for third-party clients an - [x] Deindex game - [x] Trigger new scan - [x] Downloads (from the API) -- [x] User Management +- [ ] User Management ## Stack - **Freemarker** for web rendering diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..3be3f3e --- /dev/null +++ b/shell.nix @@ -0,0 +1,9 @@ +# shell.nix +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = with pkgs; [ + javaPackages.compiler.openjdk21 + ]; + JAVA_HOME = pkgs.javaPackages.compiler.openjdk21 + "/lib/openjdk"; +} diff --git a/src/main/java/org/adrianvictor/livingroom/config/AppConfig.java b/src/main/java/org/adrianvictor/livingroom/config/AppConfig.java index b08aa6c..172f39b 100644 --- a/src/main/java/org/adrianvictor/livingroom/config/AppConfig.java +++ b/src/main/java/org/adrianvictor/livingroom/config/AppConfig.java @@ -30,6 +30,7 @@ public class AppConfig { public void setUsers(Map users) { this.users = (users != null) ? users : new HashMap<>(); } + public void addUser(String name, UserConfig config) { this.users.put(name, config); } public void hashPlaintextPasswords() { if (users == null) return; diff --git a/src/main/java/org/adrianvictor/livingroom/config/ConfigLoader.java b/src/main/java/org/adrianvictor/livingroom/config/ConfigLoader.java index 267758f..e028bf9 100644 --- a/src/main/java/org/adrianvictor/livingroom/config/ConfigLoader.java +++ b/src/main/java/org/adrianvictor/livingroom/config/ConfigLoader.java @@ -13,7 +13,6 @@ public class ConfigLoader { private static AppConfig config; - @SuppressWarnings("unchecked") public static AppConfig load(String path) throws Exception { if (config != null) return config; diff --git a/src/main/java/org/adrianvictor/livingroom/http/Handlers.java b/src/main/java/org/adrianvictor/livingroom/http/Handlers.java index c1a14af..92f931f 100644 --- a/src/main/java/org/adrianvictor/livingroom/http/Handlers.java +++ b/src/main/java/org/adrianvictor/livingroom/http/Handlers.java @@ -1,12 +1,11 @@ package org.adrianvictor.livingroom.http; import org.adrianvictor.livingroom.http.handlers.*; -import org.adrianvictor.livingroom.web.pages.Remove; import java.util.HashMap; public class Handlers { - private static HashMap map = new HashMap<>(); + private static final HashMap map = new HashMap<>(); static { map.put(new CatalogHandler(), "/catalog"); @@ -14,6 +13,8 @@ public class Handlers { map.put(new FileHandler(), "/download"); map.put(new WebHandler(), "/web"); map.put(new StaticWebHandler(), "/static"); + map.put(new WebRedirectHandler(), "/"); + map.put(new LoginHandler(), "/login"); } public static HashMap getAll() { diff --git a/src/main/java/org/adrianvictor/livingroom/http/HttpResponse.java b/src/main/java/org/adrianvictor/livingroom/http/HttpResponse.java index c42e0b0..b2c7206 100644 --- a/src/main/java/org/adrianvictor/livingroom/http/HttpResponse.java +++ b/src/main/java/org/adrianvictor/livingroom/http/HttpResponse.java @@ -16,6 +16,14 @@ public record HttpResponse( ); } + public static HttpResponse text(int status, String text) { + return new HttpResponse( + 400, + text.getBytes(), + Map.of() + ); + } + public static HttpResponse ok(byte[] body, String contentType) { return ok(body, contentType, new HashMap<>()); } diff --git a/src/main/java/org/adrianvictor/livingroom/http/Server.java b/src/main/java/org/adrianvictor/livingroom/http/Server.java index d186e55..d634716 100644 --- a/src/main/java/org/adrianvictor/livingroom/http/Server.java +++ b/src/main/java/org/adrianvictor/livingroom/http/Server.java @@ -5,9 +5,9 @@ import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import org.adrianvictor.livingroom.Logger; import org.adrianvictor.livingroom.Main; -import org.adrianvictor.livingroom.http.handlers.FileHandler; -import org.adrianvictor.livingroom.http.handlers.ImageHandler; -import org.adrianvictor.livingroom.http.handlers.StaticWebHandler; +import org.adrianvictor.livingroom.auth.Session; +import org.adrianvictor.livingroom.http.handlers.*; +import org.adrianvictor.livingroom.http.utils.AuthenticationHelper; import java.io.IOException; import java.io.OutputStream; @@ -42,7 +42,7 @@ public class Server { public void registerHandler(Handler handler, String path) { httpServer.createContext(path, new HttpHandler() { @Override - public void handle(HttpExchange exchange) throws IOException { + public void handle(HttpExchange exchange) { try { String fullPath = exchange.getRequestURI().getPath(); @@ -68,29 +68,37 @@ public class Server { exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); - if (handler instanceof FileHandler) { - ((FileHandler) handler).handleStream(exchange, decoded); - } else if (handler instanceof ImageHandler) { - ((ImageHandler) handler).handleStream(exchange, decoded); - } else if (handler instanceof StaticWebHandler) { - ((StaticWebHandler) handler).handleStream(exchange, decoded); - } else { - HttpResponse result = handler.result(baseAddress, decoded, exchange); - - for (Map.Entry header : result.headers().entrySet()) { - exchange.getResponseHeaders().set(header.getKey(), header.getValue()); + if (!(handler instanceof WebHandler) && !(handler instanceof LoginHandler)) { + Session session = AuthenticationHelper.getAuthenticatedSession(exchange); + if (session == null) { + try { + AuthenticationHelper.sendUnauthorized(exchange, null); + } catch (IOException ignored) {} } + } - exchange.sendResponseHeaders(result.status(), result.body().length); + switch (handler) { + case FileHandler fileHandler -> fileHandler.handleStream(exchange, decoded); + case ImageHandler imageHandler -> imageHandler.handleStream(exchange, decoded); + case StaticWebHandler staticWebHandler -> staticWebHandler.handleStream(exchange, decoded); + default -> { + HttpResponse result = handler.result(baseAddress, decoded, exchange); - OutputStream os = exchange.getResponseBody(); - os.write(result.body()); - os.close(); + for (Map.Entry header : result.headers().entrySet()) { + exchange.getResponseHeaders().set(header.getKey(), header.getValue()); + } + + exchange.sendResponseHeaders(result.status(), result.body().length); + + OutputStream os = exchange.getResponseBody(); + os.write(result.body()); + os.close(); + } } } catch (IOException ignored) { } catch (Exception e) { Logger.error("Handler error: " + e.getMessage()); - e.printStackTrace(); + //e.printStackTrace(); try { exchange.sendResponseHeaders(500, 0); exchange.close(); diff --git a/src/main/java/org/adrianvictor/livingroom/http/handlers/LoginHandler.java b/src/main/java/org/adrianvictor/livingroom/http/handlers/LoginHandler.java new file mode 100644 index 0000000..0adf0e5 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/handlers/LoginHandler.java @@ -0,0 +1,69 @@ +package org.adrianvictor.livingroom.http.handlers; + +import com.sun.net.httpserver.HttpExchange; +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.auth.Session; +import org.adrianvictor.livingroom.auth.SessionManager; +import org.adrianvictor.livingroom.http.Handler; +import org.adrianvictor.livingroom.http.HttpResponse; +import org.adrianvictor.livingroom.http.utils.AuthenticationHelper; +import org.adrianvictor.livingroom.services.UserService; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class LoginHandler implements Handler { + + @Override + public HttpResponse result(String baseAddress, String path, HttpExchange exchange) { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + return HttpResponse.text(400, "Not post."); + } + + Session session = AuthenticationHelper.getAuthenticatedSession(exchange); + + if (session != null) { + return HttpResponse.text(400, "You're already logged in."); + } + + try { + JSONParser parser = new JSONParser(); + byte[] body = exchange.getRequestBody().readAllBytes(); + String bodyString = new String(body, StandardCharsets.UTF_8); + + JSONObject json = (JSONObject) parser.parse(bodyString); + String username = (String) json.get("username"); + String password = (String) json.get("password"); + + if (password == null || username == null) { + return HttpResponse.text(400, "You must provide an username and password."); + } + + try { + if (UserService.getInstance().getUser(username).auth(password)) { + Session s = SessionManager.getInstance().createSession(username); + + exchange.getResponseHeaders().add("Set-Cookie", + "SESSIONID=" + s.getSessionId() + "; Path=/; HttpOnly; SameSite=Strict"); + + String jsonResponse = "{\"success\": true, \"sessionId\": \"" + s.getSessionId() + "\"}"; + return HttpResponse.ok(jsonResponse.getBytes(StandardCharsets.UTF_8), "application/json"); + } else { + return HttpResponse.text(401, "Invalid username or password"); + } + } catch (Exception e) { + Logger.error("User lookup error: " + e.getMessage()); + return HttpResponse.text(401, "Invalid username or password"); + } + } catch (IOException e) { + Logger.error("Error reading request body: " + e.getMessage()); + return HttpResponse.text(400, "Invalid request"); + } catch (ParseException e) { + Logger.error("Error parsing JSON: " + e.getMessage()); + return HttpResponse.text(400, "Invalid JSON format"); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/http/handlers/WebHandler.java b/src/main/java/org/adrianvictor/livingroom/http/handlers/WebHandler.java index 38f833c..013c7e0 100644 --- a/src/main/java/org/adrianvictor/livingroom/http/handlers/WebHandler.java +++ b/src/main/java/org/adrianvictor/livingroom/http/handlers/WebHandler.java @@ -83,7 +83,6 @@ public class WebHandler implements Handler { if ("POST".equals(method)) { try { - // Read POST body String body = new String(exchange.getRequestBody().readAllBytes()); String[] params = body.split("&"); String username = null; diff --git a/src/main/java/org/adrianvictor/livingroom/http/handlers/WebRedirectHandler.java b/src/main/java/org/adrianvictor/livingroom/http/handlers/WebRedirectHandler.java new file mode 100644 index 0000000..9ff9633 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/handlers/WebRedirectHandler.java @@ -0,0 +1,13 @@ +package org.adrianvictor.livingroom.http.handlers; + +import com.sun.net.httpserver.HttpExchange; +import org.adrianvictor.livingroom.http.Handler; +import org.adrianvictor.livingroom.http.HttpResponse; + +public class WebRedirectHandler implements Handler { + + @Override + public HttpResponse result(String baseAddress, String path, HttpExchange exchange) { + return HttpResponse.redirect("/web"); + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/http/utils/AuthenticationHelper.java b/src/main/java/org/adrianvictor/livingroom/http/utils/AuthenticationHelper.java new file mode 100644 index 0000000..de6be59 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/utils/AuthenticationHelper.java @@ -0,0 +1,44 @@ +package org.adrianvictor.livingroom.http.utils; + +import com.sun.net.httpserver.HttpExchange; +import org.adrianvictor.livingroom.auth.Session; +import org.adrianvictor.livingroom.auth.SessionManager; +import org.adrianvictor.livingroom.utils.net.Cookie; + +import java.io.IOException; + +public class AuthenticationHelper { + public static Session getAuthenticatedSession(HttpExchange exchange) { + String id = Cookie.getSessionIdFromCookies(exchange); + if (id != null) { + Session session = SessionManager.getInstance().getSession(id); + if (session != null) { + return session; + } + } + + String bearerToken = getBearerToken(exchange); + if (bearerToken != null) { + Session session = SessionManager.getInstance().getSession(bearerToken); + if (session != null) { + return session; + } + } + return null; + } + + private static String getBearerToken(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + if (auth != null && auth.startsWith("Bearer ")) { + return auth.substring(7); + } + return null; + } + + public static void sendUnauthorized(HttpExchange exchange, String message) throws IOException { + byte[] response = (message == null) ? "Not authorized.".getBytes() : message.getBytes(); + exchange.sendResponseHeaders(401, response.length); + exchange.getResponseBody().write(response); + exchange.getResponseBody().close(); + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/web/Pages.java b/src/main/java/org/adrianvictor/livingroom/web/Pages.java index c409eb7..9ccc0ce 100644 --- a/src/main/java/org/adrianvictor/livingroom/web/Pages.java +++ b/src/main/java/org/adrianvictor/livingroom/web/Pages.java @@ -5,7 +5,7 @@ import org.adrianvictor.livingroom.web.pages.*; import java.util.HashMap; public class Pages { - private static HashMap map = new HashMap<>(); + private static final HashMap map = new HashMap<>(); static { Index index = new Index(); diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/Login.java b/src/main/java/org/adrianvictor/livingroom/web/pages/Login.java index 19d0aed..68b106b 100644 --- a/src/main/java/org/adrianvictor/livingroom/web/pages/Login.java +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/Login.java @@ -12,9 +12,6 @@ public class Login implements Page { @Override public String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange) { try { - // Check if this is a POST request - // For now, just show the login form - freemarker.template.Template template = cfg.getTemplate("login.ftl"); StringWriter writer = new StringWriter(); diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/Remove.java b/src/main/java/org/adrianvictor/livingroom/web/pages/Remove.java index 1869257..65d93f4 100644 --- a/src/main/java/org/adrianvictor/livingroom/web/pages/Remove.java +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/Remove.java @@ -3,13 +3,10 @@ package org.adrianvictor.livingroom.web.pages; import com.sun.net.httpserver.HttpExchange; import freemarker.template.Configuration; import org.adrianvictor.livingroom.Logger; -import org.adrianvictor.livingroom.Main; import org.adrianvictor.livingroom.data.Database; -import org.adrianvictor.livingroom.data.Indexer; import org.adrianvictor.livingroom.web.Page; import org.adrianvictor.livingroom.web.QuickResponses; -import java.io.File; import java.util.Map; public class Remove implements Page {