diff --git a/pom.xml b/pom.xml index fc81fa4..74a01df 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,11 @@ 6.0.0 provided + + org.nanohttpd + nanohttpd + 2.3.1 + de.mkammerer diff --git a/src/main/java/com/threerings/user/AuthException.java b/src/main/java/com/threerings/user/AuthException.java new file mode 100644 index 0000000..34aa483 --- /dev/null +++ b/src/main/java/com/threerings/user/AuthException.java @@ -0,0 +1,47 @@ +package com.threerings.user; + +/** + * Exception thrown by authosaurus client and server classes to communicate authentication errors. + */ +public class AuthException extends Exception +{ + /** The email address is not associated with any accounts. */ + public static final String UNKNOWN_EMAIL_ADDRESS = "e.unknown_email_address"; + + /** The session token has expired. */ + public static final String TOKEN_EXPIRED = "e.token_expired"; + + /** The session token is invalid. */ + public static final String INVALID_TOKEN = "e.invalid_token"; + + /** No accounts were found for the session. */ + public static final String NO_ACCOUNTS = "e.no_accounts"; + + /** The specified account is not authorized by the session token. */ + public static final String UNAUTHORIZED_ACCOUNT = "e.unauthorized_account"; + + /** Too many login attempts; rate limited. */ + public static final String RATE_LIMITED = "e.rate_limited"; + + /** A network error occurred while communicating with the auth server. */ + public static final String NETWORK_ERROR = "e.network_error"; + + /** An unexpected error occurred. */ + public static final String UNEXPECTED_ERROR = "e.unexpected_error"; + + public AuthException (String code) + { + super(code); + } + + public AuthException (String code, Throwable cause) + { + super(code, cause); + } + + /** Returns the error code for this exception (same as {@link #getMessage()}). */ + public String getCode () + { + return getMessage(); + } +} diff --git a/src/main/java/com/threerings/user/AuthosaurusClient.java b/src/main/java/com/threerings/user/AuthosaurusClient.java new file mode 100644 index 0000000..8071bcb --- /dev/null +++ b/src/main/java/com/threerings/user/AuthosaurusClient.java @@ -0,0 +1,307 @@ +package com.threerings.user; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.prefs.Preferences; + +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.NanoHTTPD.Response.Status; + +import static com.threerings.user.Log.log; + +/** + * Manages game client interaction with the authosaurus authentication service. Provides methods to + * start/stop a local HTTP listener for receiving session tokens, initiate login, and query + * accounts. + */ +public class AuthosaurusClient +{ + /** + * Creates an authosaurus client that communicates with the specified base URL. + * + * @param authBaseUrl the base URL of the authosaurus server (e.g. "https://auth.puzzlepirates.com"). + */ + public AuthosaurusClient (String authBaseUrl) + { + _authBaseUrl = authBaseUrl.endsWith("/") + ? authBaseUrl.substring(0, authBaseUrl.length() - 1) : authBaseUrl; + } + + /** + * Returns any currently saved session token, or null if no token has been stored. + */ + public String getSessionToken () + { + return _prefs.get(PREF_SESSION_TOKEN, null); + } + + /** + * Registers a callback that will be notified when a new session token is received by the auth + * listener. The callback may be invoked on the HTTP server's request-handling thread; the + * caller is responsible for transferring control to the appropriate UI thread. + */ + public void onSessionStarted (Consumer callback) + { + _listeners.add(callback); + } + + /** + * Starts a local HTTP listener for receiving the session token delivered during the login + * flow. A random port in the range 49152–65535 is chosen on first use and persisted via Java + * preferences so that the same port is reused for future login sessions. + * + * @return the port on which the listener is accepting connections. + */ + public int startAuthListener () + { + int port = getOrCreateListenerPort(); + + if (_server != null) { + log.warning("Auth listener already running on port " + port); + return port; + } + + try { + _server = new NanoHTTPD("127.0.0.1", port) { + @Override public Response serve (IHTTPSession session) { + if (!"/auth".equals(session.getUri())) { + return newFixedLengthResponse(Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Not found"); + } + return handleAuthRequest(session); + } + }; + _server.start(); + log.info("Auth listener started on port " + port); + } catch (IOException e) { + log.warning("Failed to start auth listener on port " + port, e); + } + + return port; + } + + /** + * Stops any active auth listener. If the listener is not running, logs a warning and no-ops. + */ + public void stopAuthListener () + { + if (_server == null) { + log.warning("Auth listener is not running."); + return; + } + + _server.stop(); + _server = null; + log.info("Auth listener stopped."); + } + + /** + * Initiates a login session by calling the authosaurus /login endpoint with the supplied email + * address and port. + * + * @param email the user's email address. + * @param port the local listener port to which the session token should be delivered. + * @throws AuthException if the login request fails. + */ + public void startLogin (String email, int port) throws AuthException + { + try { + URL url = new URL(_authBaseUrl + "/login"); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setDoOutput(true); + log.info("Requesting login link", "url", url); + + String json = "{\"email\":" + jsonString(email) + ",\"port\":" + port + "}"; + try (OutputStream os = conn.getOutputStream()) { + os.write(json.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == 200) { + return; // success + } + + // Read the error response body + String body = readResponseBody(conn, /* error */ true); + throw mapErrorResponse(status, body); + } catch (AuthException ae) { + throw ae; + } catch (IOException e) { + throw new AuthException(AuthException.NETWORK_ERROR, e); + } + } + + /** + * Makes a synchronous HTTP request to the authosaurus /accounts endpoint to obtain the list of + * account names authorized by the supplied session token. + * + * @param token the session token. + * @return the list of account usernames. + * @throws AuthException if a network error occurs or the endpoint returns an error. + */ + public List getAccounts (String token) throws AuthException + { + return fetchAccounts(_authBaseUrl, token); + } + + /** + * Fetches the list of accounts for the given token from the specified authosaurus base URL. + * Shared between client and manager. + */ + static List fetchAccounts (String baseUrl, String token) throws AuthException + { + try { + URL url = new URL(baseUrl + "/accounts/" + token); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestMethod("GET"); + log.info("Requesting session accounts", "url", url); + + int status = conn.getResponseCode(); + String body = readResponseBody(conn, status >= 400); + + if (status == 200) { + List accounts = new ArrayList<>(); + for (String line : body.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.isEmpty()) { + accounts.add(trimmed); + } + } + return accounts; + } + + // Map known error responses + if (status == 404) { + throw new AuthException(AuthException.INVALID_TOKEN); + } else if (status == 410) { + throw new AuthException(AuthException.TOKEN_EXPIRED); + } else { + throw new AuthException(AuthException.UNEXPECTED_ERROR); + } + } catch (IOException e) { + throw new AuthException(AuthException.NETWORK_ERROR, e); + } + } + + private NanoHTTPD.Response handleAuthRequest (NanoHTTPD.IHTTPSession session) + { + NanoHTTPD.Method method = session.getMethod(); + + // Handle CORS preflight + if (NanoHTTPD.Method.OPTIONS.equals(method)) { + NanoHTTPD.Response response = NanoHTTPD.newFixedLengthResponse( + Status.NO_CONTENT, NanoHTTPD.MIME_PLAINTEXT, ""); + addCorsHeaders(response); + return response; + } + + if (!NanoHTTPD.Method.POST.equals(method)) { + return NanoHTTPD.newFixedLengthResponse( + Status.METHOD_NOT_ALLOWED, NanoHTTPD.MIME_PLAINTEXT, "Method not allowed"); + } + + String token; + try { + // NanoHTTPD requires parsing body into files map for POST + Map bodyMap = new java.util.HashMap<>(); + session.parseBody(bodyMap); + // For plain text/non-form POSTs, the body is in "postData" + token = bodyMap.getOrDefault("postData", "").trim(); + } catch (IOException | NanoHTTPD.ResponseException e) { + log.warning("Failed to read auth request body", e); + return NanoHTTPD.newFixedLengthResponse( + Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "Error reading body"); + } + + if (token.isEmpty()) { + return NanoHTTPD.newFixedLengthResponse( + Status.BAD_REQUEST, NanoHTTPD.MIME_PLAINTEXT, "Empty token"); + } + + // Store the session token + _prefs.put(PREF_SESSION_TOKEN, token); + log.info("Session token received and stored."); + + NanoHTTPD.Response response = NanoHTTPD.newFixedLengthResponse( + Status.OK, NanoHTTPD.MIME_PLAINTEXT, ""); + addCorsHeaders(response); + + // Notify listeners + for (Consumer listener : _listeners) { + try { + listener.accept(token); + } catch (Exception e) { + log.warning("Error in session listener", e); + } + } + + return response; + } + + private static void addCorsHeaders (NanoHTTPD.Response response) + { + response.addHeader("Access-Control-Allow-Origin", "*"); + response.addHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + response.addHeader("Access-Control-Allow-Headers", "Content-Type"); + } + + private int getOrCreateListenerPort () + { + int port = _prefs.getInt(PREF_LISTENER_PORT, -1); + if (port == -1) { + port = 49152 + (int)(Math.random() * (65535 - 49152 + 1)); + _prefs.putInt(PREF_LISTENER_PORT, port); + } + return port; + } + + private static AuthException mapErrorResponse (int status, String body) + { + if (status == 404 && body != null && body.contains("No accounts")) { + return new AuthException(AuthException.UNKNOWN_EMAIL_ADDRESS); + } else if (status == 429) { + return new AuthException(AuthException.RATE_LIMITED); + } else { + return new AuthException(AuthException.UNEXPECTED_ERROR); + } + } + + private static String readResponseBody (HttpURLConnection conn, boolean error) + throws IOException + { + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + error ? conn.getErrorStream() : conn.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + return sb.toString(); + } + } + + /** Produce a JSON-encoded string value. */ + private static String jsonString (String value) + { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private final String _authBaseUrl; + private final Preferences _prefs = Preferences.userNodeForPackage(AuthosaurusClient.class); + private final CopyOnWriteArrayList> _listeners = new CopyOnWriteArrayList<>(); + private NanoHTTPD _server; + + private static final String PREF_LISTENER_PORT = "authListenerPort"; + private static final String PREF_SESSION_TOKEN = "sessionToken"; +} diff --git a/src/main/java/com/threerings/user/AuthosaurusManager.java b/src/main/java/com/threerings/user/AuthosaurusManager.java new file mode 100644 index 0000000..0e21004 --- /dev/null +++ b/src/main/java/com/threerings/user/AuthosaurusManager.java @@ -0,0 +1,48 @@ +package com.threerings.user; + +import java.util.List; + +import static com.threerings.user.Log.log; + +/** + * Handles server-side validation of session token + account name authentication provided by game + * clients against the authosaurus service. + */ +public class AuthosaurusManager +{ + /** + * Creates a manager that validates sessions against the specified authosaurus server. + * + * @param authBaseUrl the base URL of the authosaurus server + * (e.g. "https://auth.puzzlepirates.com"). + */ + public AuthosaurusManager (String authBaseUrl) + { + _authBaseUrl = authBaseUrl.endsWith("/") + ? authBaseUrl.substring(0, authBaseUrl.length() - 1) : authBaseUrl; + } + + /** + * Validates that the specified account name is authorized by the supplied session token. + * Operates synchronously by calling the authosaurus /accounts endpoint. + * + * @param token the session token provided by the client. + * @param accountName the account name the client claims to be. + * @throws AuthException if the token is invalid, expired, or does not authorize the specified + * account, or if a network error occurs. + */ + public void validateSession (String token, String accountName) throws AuthException + { + List accounts = AuthosaurusClient.fetchAccounts(_authBaseUrl, token); + + for (String account : accounts) { + if (account.equalsIgnoreCase(accountName)) { + return; // validated successfully + } + } + + throw new AuthException(AuthException.UNAUTHORIZED_ACCOUNT); + } + + private final String _authBaseUrl; +}