Add authentication on HTTP API.

Add LoginHandler and register.

Add method to create user in AppConfig.

Add login check in Server.
This commit is contained in:
天クマ 2026-03-27 22:05:22 -03:00
commit d54826b66b
14 changed files with 184 additions and 33 deletions

View file

@ -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

9
shell.nix Normal file
View file

@ -0,0 +1,9 @@
# shell.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
javaPackages.compiler.openjdk21
];
JAVA_HOME = pkgs.javaPackages.compiler.openjdk21 + "/lib/openjdk";
}

View file

@ -30,6 +30,7 @@ public class AppConfig {
public void setUsers(Map<String, UserConfig> 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;

View file

@ -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;

View file

@ -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<Handler, String> map = new HashMap<>();
private static final HashMap<Handler, String> 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<Handler, String> getAll() {

View file

@ -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<>());
}

View file

@ -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<String, String> 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<String, String> 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();

View file

@ -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");
}
}
}

View file

@ -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;

View file

@ -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");
}
}

View file

@ -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();
}
}

View file

@ -5,7 +5,7 @@ import org.adrianvictor.livingroom.web.pages.*;
import java.util.HashMap;
public class Pages {
private static HashMap<String, Page> map = new HashMap<>();
private static final HashMap<String, Page> map = new HashMap<>();
static {
Index index = new Index();

View file

@ -12,9 +12,6 @@ public class Login implements Page {
@Override
public String result(Configuration cfg, String baseAddress, String path, Map<String, Object> 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();

View file

@ -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 {