Switch to polling-based auth integration.
It turns out that having the web page talk to the running game client via HTTP on localhost is fraught with various web-browser-dependent security limits and complexity. This polling based system doesn't require any more of the web browser than doing what it could do back in 1996.
This commit is contained in:
@@ -63,11 +63,6 @@
|
|||||||
<version>6.0.0</version>
|
<version>6.0.0</version>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.nanohttpd</groupId>
|
|
||||||
<artifactId>nanohttpd</artifactId>
|
|
||||||
<version>2.3.1</version>
|
|
||||||
</dependency>
|
|
||||||
<!-- https://mvnrepository.com/artifact/de.mkammerer/argon2-jvm -->
|
<!-- https://mvnrepository.com/artifact/de.mkammerer/argon2-jvm -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>de.mkammerer</groupId>
|
<groupId>de.mkammerer</groupId>
|
||||||
|
|||||||
@@ -9,20 +9,16 @@ import java.net.URL;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.prefs.Preferences;
|
import java.util.prefs.Preferences;
|
||||||
|
|
||||||
import fi.iki.elonen.NanoHTTPD;
|
|
||||||
import fi.iki.elonen.NanoHTTPD.Response.Status;
|
|
||||||
|
|
||||||
import static com.threerings.user.Log.log;
|
import static com.threerings.user.Log.log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages game client interaction with the authosaurus authentication service. Provides methods to
|
* 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
|
* initiate login via the browser, poll for the resulting session token, and query accounts.
|
||||||
* accounts.
|
|
||||||
*/
|
*/
|
||||||
public class AuthosaurusClient
|
public class AuthosaurusClient
|
||||||
{
|
{
|
||||||
@@ -46,9 +42,9 @@ public class AuthosaurusClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a callback that will be notified when a new session token is received by the auth
|
* Registers a callback that will be notified when a new session token is received via polling.
|
||||||
* listener. The callback may be invoked on the HTTP server's request-handling thread; the
|
* The callback will be invoked on the polling thread; the caller is responsible for
|
||||||
* caller is responsible for transferring control to the appropriate UI thread.
|
* transferring control to the appropriate UI thread.
|
||||||
*/
|
*/
|
||||||
public void onSessionStarted (Consumer<String> callback)
|
public void onSessionStarted (Consumer<String> callback)
|
||||||
{
|
{
|
||||||
@@ -56,63 +52,67 @@ public class AuthosaurusClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a local HTTP listener for receiving the session token delivered during the login
|
* Generates a new random game id for this login session. This id is used to correlate the
|
||||||
* flow. A random port in the range 49152–65535 is chosen on first use and persisted via Java
|
* browser login flow with this game client's poll requests.
|
||||||
* preferences so that the same port is reused for future login sessions.
|
|
||||||
*
|
*
|
||||||
* @return the port on which the listener is accepting connections.
|
* @return the generated game id.
|
||||||
*/
|
*/
|
||||||
public int startAuthListener ()
|
public String generateGameId ()
|
||||||
{
|
{
|
||||||
int port = getOrCreateListenerPort();
|
_gameId = UUID.randomUUID().toString();
|
||||||
|
return _gameId;
|
||||||
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.
|
* Returns the current game id, or null if {@link #generateGameId} has not been called.
|
||||||
*/
|
*/
|
||||||
public void stopAuthListener ()
|
public String getGameId ()
|
||||||
{
|
{
|
||||||
if (_server == null) {
|
return _gameId;
|
||||||
log.warning("Auth listener is not running.");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_server.stop();
|
/**
|
||||||
_server = null;
|
* Starts polling the authosaurus server for a session token associated with the current game
|
||||||
log.info("Auth listener stopped.");
|
* id. Polling runs on a background daemon thread and stops automatically once a token is
|
||||||
|
* received or the login link expires.
|
||||||
|
*
|
||||||
|
* <p>When a token is received, it is stored in preferences and all registered listeners are
|
||||||
|
* notified.</p>
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException if no game id has been generated via {@link #generateGameId}.
|
||||||
|
*/
|
||||||
|
public void startPolling ()
|
||||||
|
{
|
||||||
|
if (_gameId == null) {
|
||||||
|
throw new IllegalStateException("Call generateGameId() before startPolling().");
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
|
_polling = true;
|
||||||
|
Thread thread = new Thread(this::pollLoop, "AuthosaurusPoll");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.start();
|
||||||
|
log.info("Started polling for game auth token", "gameId", _gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops any active polling.
|
||||||
|
*/
|
||||||
|
public void stopPolling ()
|
||||||
|
{
|
||||||
|
_polling = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiates a login session by calling the authosaurus /login endpoint with the supplied email
|
* Initiates a login session by calling the authosaurus /login endpoint with the supplied email
|
||||||
* address and port.
|
* address and game id.
|
||||||
*
|
*
|
||||||
* @param email the user's email address.
|
* @param email the user's email address.
|
||||||
* @param port the local listener port to which the session token should be delivered.
|
* @param gameId the game id to associate with this login request.
|
||||||
* @throws AuthException if the login request fails.
|
* @throws AuthException if the login request fails.
|
||||||
*/
|
*/
|
||||||
public void startLogin (String email, int port) throws AuthException
|
public void startLogin (String email, String gameId) throws AuthException
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
URL url = new URL(_authBaseUrl + "/login");
|
URL url = new URL(_authBaseUrl + "/login");
|
||||||
@@ -122,7 +122,7 @@ public class AuthosaurusClient
|
|||||||
conn.setDoOutput(true);
|
conn.setDoOutput(true);
|
||||||
log.info("Requesting login link", "url", url);
|
log.info("Requesting login link", "url", url);
|
||||||
|
|
||||||
String json = "{\"email\":" + jsonString(email) + ",\"port\":" + port + "}";
|
String json = "{\"email\":" + jsonString(email) + ",\"id\":" + jsonString(gameId) + "}";
|
||||||
try (OutputStream os = conn.getOutputStream()) {
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
os.write(json.getBytes(StandardCharsets.UTF_8));
|
os.write(json.getBytes(StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
@@ -194,50 +194,48 @@ public class AuthosaurusClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private NanoHTTPD.Response handleAuthRequest (NanoHTTPD.IHTTPSession session)
|
private void pollLoop ()
|
||||||
{
|
{
|
||||||
NanoHTTPD.Method method = session.getMethod();
|
while (_polling) {
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
// NanoHTTPD requires parsing body into files map for POST
|
Thread.sleep(POLL_INTERVAL_MS);
|
||||||
Map<String, String> bodyMap = new java.util.HashMap<>();
|
} catch (InterruptedException e) {
|
||||||
session.parseBody(bodyMap);
|
break;
|
||||||
// For plain text/non-form POSTs, the body is in "postData"
|
}
|
||||||
token = bodyMap.getOrDefault("postData", "").trim();
|
if (!_polling) break;
|
||||||
} catch (IOException | NanoHTTPD.ResponseException e) {
|
|
||||||
log.warning("Failed to read auth request body", e);
|
try {
|
||||||
return NanoHTTPD.newFixedLengthResponse(
|
URL url = new URL(_authBaseUrl + "/api/auth/game-poll?id=" + _gameId);
|
||||||
Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "Error reading body");
|
HttpURLConnection conn = (HttpURLConnection)url.openConnection();
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
conn.setConnectTimeout(10_000);
|
||||||
|
conn.setReadTimeout(10_000);
|
||||||
|
|
||||||
|
int status = conn.getResponseCode();
|
||||||
|
String body = readResponseBody(conn, status >= 400);
|
||||||
|
|
||||||
|
if (status == 410) {
|
||||||
|
// Login expired
|
||||||
|
log.info("Game auth poll: login expired.");
|
||||||
|
_polling = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.isEmpty()) {
|
if (status != 200) {
|
||||||
return NanoHTTPD.newFixedLengthResponse(
|
log.warning("Game auth poll: unexpected status " + status);
|
||||||
Status.BAD_REQUEST, NanoHTTPD.MIME_PLAINTEXT, "Empty token");
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the session token
|
// Parse "status" and "token" from the JSON response.
|
||||||
|
// The response is simple enough to parse without a JSON library.
|
||||||
|
String pollStatus = extractJsonString(body, "status");
|
||||||
|
if ("complete".equals(pollStatus)) {
|
||||||
|
String token = extractJsonString(body, "token");
|
||||||
|
if (token != null && !token.isEmpty()) {
|
||||||
_prefs.put(PREF_SESSION_TOKEN, token);
|
_prefs.put(PREF_SESSION_TOKEN, token);
|
||||||
log.info("Session token received and stored.");
|
log.info("Session token received via polling and stored.");
|
||||||
|
_polling = false;
|
||||||
|
|
||||||
NanoHTTPD.Response response = NanoHTTPD.newFixedLengthResponse(
|
|
||||||
Status.OK, NanoHTTPD.MIME_PLAINTEXT, "");
|
|
||||||
addCorsHeaders(response);
|
|
||||||
|
|
||||||
// Notify listeners
|
|
||||||
for (Consumer<String> listener : _listeners) {
|
for (Consumer<String> listener : _listeners) {
|
||||||
try {
|
try {
|
||||||
listener.accept(token);
|
listener.accept(token);
|
||||||
@@ -245,25 +243,32 @@ public class AuthosaurusClient
|
|||||||
log.warning("Error in session listener", e);
|
log.warning("Error in session listener", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
return response;
|
}
|
||||||
|
}
|
||||||
|
// status == "pending", keep polling
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warning("Game auth poll error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void addCorsHeaders (NanoHTTPD.Response response)
|
/**
|
||||||
|
* Extracts a string value for the given key from a simple JSON object.
|
||||||
|
* Only handles flat objects with string values — sufficient for our poll response.
|
||||||
|
*/
|
||||||
|
private static String extractJsonString (String json, String key)
|
||||||
{
|
{
|
||||||
response.addHeader("Access-Control-Allow-Origin", "*");
|
String needle = "\"" + key + "\"";
|
||||||
response.addHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
int keyIdx = json.indexOf(needle);
|
||||||
response.addHeader("Access-Control-Allow-Headers", "Content-Type");
|
if (keyIdx == -1) return null;
|
||||||
}
|
int colonIdx = json.indexOf(':', keyIdx + needle.length());
|
||||||
|
if (colonIdx == -1) return null;
|
||||||
private int getOrCreateListenerPort ()
|
int openQuote = json.indexOf('"', colonIdx + 1);
|
||||||
{
|
if (openQuote == -1) return null;
|
||||||
int port = _prefs.getInt(PREF_LISTENER_PORT, -1);
|
int closeQuote = json.indexOf('"', openQuote + 1);
|
||||||
if (port == -1) {
|
if (closeQuote == -1) return null;
|
||||||
port = 49152 + (int)(Math.random() * (65535 - 49152 + 1));
|
return json.substring(openQuote + 1, closeQuote);
|
||||||
_prefs.putInt(PREF_LISTENER_PORT, port);
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuthException mapErrorResponse (int status, String body)
|
private static AuthException mapErrorResponse (int status, String body)
|
||||||
@@ -300,8 +305,10 @@ public class AuthosaurusClient
|
|||||||
private final String _authBaseUrl;
|
private final String _authBaseUrl;
|
||||||
private final Preferences _prefs = Preferences.userNodeForPackage(AuthosaurusClient.class);
|
private final Preferences _prefs = Preferences.userNodeForPackage(AuthosaurusClient.class);
|
||||||
private final CopyOnWriteArrayList<Consumer<String>> _listeners = new CopyOnWriteArrayList<>();
|
private final CopyOnWriteArrayList<Consumer<String>> _listeners = new CopyOnWriteArrayList<>();
|
||||||
private NanoHTTPD _server;
|
|
||||||
|
|
||||||
private static final String PREF_LISTENER_PORT = "authListenerPort";
|
private String _gameId;
|
||||||
|
private volatile boolean _polling;
|
||||||
|
|
||||||
private static final String PREF_SESSION_TOKEN = "sessionToken";
|
private static final String PREF_SESSION_TOKEN = "sessionToken";
|
||||||
|
private static final long POLL_INTERVAL_MS = 3000;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user