Java code for talking to authosaurus server.
This allows games to plug into the "email a login link" auth system.
This commit is contained in:
@@ -63,6 +63,11 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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<String> 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<String> 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<String, String> 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<String> 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<Consumer<String>> _listeners = new CopyOnWriteArrayList<>();
|
||||||
|
private NanoHTTPD _server;
|
||||||
|
|
||||||
|
private static final String PREF_LISTENER_PORT = "authListenerPort";
|
||||||
|
private static final String PREF_SESSION_TOKEN = "sessionToken";
|
||||||
|
}
|
||||||
@@ -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<String> 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user