ReportProtocol.java
/*
** Module : ReportProtocol.java
** Abstract : Provides a protocol for communications between the report server and a web client.
**
** Copyright (c) 2017-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description----------------------------------
** 001 ECF 20170428 First version.
** 002 ECF 20181117 Reduce memory consumption by sending JSON response in chunks.
** 003 TJD 20220504 Java 11 compatibility minor changes
** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 005 GBB 20240430 Catching all throwables from instantiating api classes.
** 006 TJD 20240508 Fixed compiler warnings related with dependencies updates
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
** Under Section 7 of the GNU Affero GPL version 3, the following additional
** terms apply to the works covered under the License. These additional terms
** are non-permissive additional terms allowed under Section 7 of the GNU
** Affero GPL version 3 and may not be removed by you.
**
** 0. Attribution Requirement.
**
** You must preserve all legal notices or author attributions in the covered
** work or Appropriate Legal Notices displayed by works containing the covered
** work. You may not remove from the covered work any author or developer
** credit already included within the covered work.
**
** 1. No License To Use Trademarks.
**
** This license does not grant any license or rights to use the trademarks
** Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
** of Golden Code Development Corporation. You are not authorized to use the
** name Golden Code, FWD, or the names of any author or contributor, for
** publicity purposes without written authorization.
**
** 2. No Misrepresentation of Affiliation.
**
** You may not represent yourself as Golden Code Development Corporation or FWD.
**
** You may not represent yourself for publicity purposes as associated with
** Golden Code Development Corporation, FWD, or any author or contributor to
** the covered work, without written authorization.
**
** 3. No Misrepresentation of Source or Origin.
**
** You may not represent the covered work as solely your work. All modified
** versions of the covered work must be marked in a reasonable way to make it
** clear that the modified work is not originating from Golden Code Development
** Corporation or FWD. All modified versions must contain the notices of
** attribution required in this license.
*/
package com.goldencode.p2j.report.server;
import java.io.*;
import java.lang.reflect.*;
import java.security.*;
import java.security.spec.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.logging.*;
import com.goldencode.p2j.convert.*;
import org.eclipse.jetty.websocket.api.*;
import org.eclipse.jetty.websocket.api.annotations.*;
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
/**
* A protocol for communications between the report server and a web client over a web socket.
* Parameters and return data are marshalled and unmarshalled using Jackson. API calls are
* dispatched using reflection, and parameters and returned objects are marshalled/unmarshalled
* according to the method signatures of the API methods invoked. If an exception is thrown
* during the processing of an API request, that exception is marshalled and returned as the
* payload of a response message.
*/
@WebSocket
public class ReportProtocol
{
/** Base value for message types */
public static final int MSG_BASE = 0;
/** Logger. */
private static final ConversionStatus LOG = ConversionStatus.get(ReportProtocol.class);
/** Maximum JSON chunk length sent as a response */
private static final int JSON_MAX_CHUNK = 8 * 1024 * 1024;
/** Map of API types to API handler methods */
private static final Map<Integer, Method> requestApi = new HashMap<>();
/** Set of classes which contain API handler methods */
private static final Set<Class<?>> requestApiClasses = new HashSet<>();
/** Next available session ID */
private static AtomicLong nextSessionId = new AtomicLong(0L);
static
{
registerRequestApi(Services.class, false);
}
/** Map of API handler classes to instances of these classes which perform API work */
private final Map<Class<?>, Object> requestWorkers = new HashMap<>();
/** Active web socket session */
private Session session = null;
/** Current session ID */
private long sessionId = -1L;
/** Logged on user ({@code null} if no user is currently logged in) */
private User user = null;
/**
* Default constructor.
*/
public ReportProtocol()
{
}
/**
* Register an API handler class. All methods marked with the {@link WebApi} annotation will
* be registered as request API handler methods.
*
* @param apiClass
* A class which contains one or more request API handler methods.
*/
public static void registerRequestApi(Class<?> apiClass)
{
registerRequestApi(apiClass, true);
}
/**
* Register an API handler class. All methods marked with the {@link WebApi} annotation will
* be registered as request API handler methods.
*
* @param apiClass
* A class which contains one or more request API handler methods.
* @param autoInstantiate
* {@code true} to automatically instantiate the API class using reflection when the
* web socket connects; {@code false} if reflection should not be used (only relevant
* for the {@code Services} inner class).
*/
private static void registerRequestApi(Class<?> apiClass, boolean autoInstantiate)
{
if (autoInstantiate)
{
requestApiClasses.add(apiClass);
}
for (Method method : apiClass.getDeclaredMethods())
{
if (!method.isAnnotationPresent(WebApi.class))
{
continue;
}
int type = method.getAnnotation(WebApi.class).type();
if (requestApi.containsKey(type))
{
// TODO: proper exception
throw new RuntimeException(
"Cannot register request handler '" +
method +
"'; API type " +
type +
" already registered to '" +
requestApi.get(type) +
"'");
}
requestApi.put(type, method);
}
}
/**
* This method is called when the remote peer open a web socket connection.
*
* @param session
* An active link of communications with a Remote WebSocket Endpoint.
*
*/
@OnWebSocketConnect
public void onConnect(Session session)
{
this.session = session;
sessionId = nextSessionId.incrementAndGet();
try
{
// special handling for inner services class
requestWorkers.put(Services.class, new Services());
for (Class<?> apiClass : requestApiClasses)
{
Object worker = apiClass.getDeclaredConstructor().newInstance();
requestWorkers.put(apiClass, worker);
}
}
catch (Throwable t)
{
LOG.log(Level.SEVERE, "Can't instantiate api class.", t);
// TODO: proper exception
throw new RuntimeException(t);
}
}
/**
* This method is called when the connection is closed.
*
* @param statusCode
* The status integer code.
* @param reason
* A string representing the reason.
*/
@OnWebSocketClose
public void onClose(int statusCode, String reason)
{
cleanup();
}
/**
* This method is called when a text message is received.
*
* @param message
* The text message.
*/
@OnWebSocketMessage
public void onMessage(String message)
{
if (session == null || !session.isOpen())
{
// TODO: log
return;
}
processIncoming(message);
}
/**
* This method is called when a binary message is received.
*
* @param message
* Message content as an byte array.
* @param offset
* Message offset.
* @param length
* Message size.
*/
@OnWebSocketMessage
public void onMessage(final byte[] message, final int offset, final int length)
{
// not used
}
/**
* This method is called on communication errors.
*
* @param session
* An active link of communications with a Remote WebSocket Endpoint.
* @param error
* A {@code Throwable} instance representing the error condition.
*/
@OnWebSocketError
public void onError(Session session, Throwable error)
{
session = null;
}
/**
* Process an incoming JSON message string for a request API. This involves:
* <ol>
* <li>Deserializing the JSON message, which consists of a message header and optional payload.
* If present, the payload will consist of an array of serialized objects and/or
* primitives.</li>
* <li>Invoking the appropriate API handler method.</li>
* <li>Serializing the value (if any) returned by the API worker method into a response JSON
* string message.</li>
* <li>Sending the response message to the remote side of the web socket.</li>
* </ol>
*
* @param message
* Incoming JSON message.
*/
private void processIncoming(String message)
{
// TODO: add security check
JsonParser parser = null;
JsonGenerator gen = null;
String jsonResponse = null;
int msgId = -1;
int msgType = -1;
Throwable error = null;
ObjectMapper mapper = new ObjectMapper();
JsonFactory factory = mapper.getFactory();
// create a buffered writer to stream the JSON response to the remote endpoint in chunks,
// to avoid memory spikes for very large responses
Writer writer = new BufferedWriter(new ResponseWriter(session.getRemote()), JSON_MAX_CHUNK);
try
{
// parse incoming message
parser = factory.createParser(message);
// parse message header
parseHeader:
while (parser.nextToken() != JsonToken.END_OBJECT)
{
String field = parser.currentName();
if (field == null)
{
continue;
}
switch (field)
{
case "id":
parser.nextToken();
msgId = parser.getIntValue();
break;
case "type":
parser.nextToken();
msgType = parser.getIntValue();
break;
case "pay":
break parseHeader;
default:
break;
}
}
// sanity check
if (msgId < 0 || msgType < MSG_BASE)
{
// TODO: log and throw proper exception
throw new RuntimeException("malformed JSON for incoming message:\n" + message);
}
if (msgType != Services.MSG_AUTH && user == null)
{
throw new SecurityException("not logged on");
}
// use the message type to look up the worker method which handles this request
Method handler = requestApi.get(msgType);
if (handler == null)
{
throw new RuntimeException("Unknown message type: " + msgType);
}
// use the handler method's signature to determine the expected parameters in the
// payload array
Object[] args = null;
if (parser.nextToken() == JsonToken.START_ARRAY)
{
Parameter[] parms = handler.getParameters();
int len = parms.length;
args = new Object[len];
for (int i = 0; i < len && parser.nextToken() != JsonToken.END_ARRAY; i++)
{
Class<?> type = parms[i].getType();
args[i] = parser.readValueAs(type);
}
}
parser.close();
parser = null;
// look up the object instance associated with the handler method and invoke the method
// on it
Object worker = requestWorkers.get(handler.getDeclaringClass());
Object result = null;
if (args != null)
{
result = handler.invoke(worker, args);
}
else
{
result = handler.invoke(worker);
}
// generate JSON message for return to client
gen = factory.createGenerator(writer);
generateJsonResponse(gen, msgId, msgType, true, result);
gen = null;
}
catch (IOException | IllegalAccessException | IllegalArgumentException exc)
{
LOG.log(Level.SEVERE, "", exc);
error = exc;
}
catch (InvocationTargetException exc)
{
LOG.log(Level.SEVERE, "", exc);
error = exc.getCause();
}
finally
{
if (parser != null)
{
try
{
parser.close();
}
catch (IOException exc)
{
error = exc.getCause();
LOG.log(Level.SEVERE, "", exc);
}
}
if (gen != null)
{
try
{
gen.close();
}
catch (IOException exc)
{
error = exc.getCause();
LOG.log(Level.SEVERE, "", exc);
}
}
if (error != null)
{
try
{
gen = factory.createGenerator(writer);
generateJsonResponse(gen, msgId, msgType, false, error);
gen = null;
}
catch (IOException exc)
{
LOG.log(Level.SEVERE, "", exc);
}
}
try
{
writer.close();
}
catch (IOException exc)
{
LOG.log(Level.SEVERE, "", exc);
}
// close websocket if authentication failed; this can't happen in Services.authenticate
// because we need to return failure information to the client before closing the
// session/socket
if (user == null && session != null && session.isOpen())
{
try
{
if (msgType == Services.MSG_AUTH)
{
session.close(1000, "Unable to authenticate session");
}
else
{
session.close();
}
}
finally
{
cleanup();
}
}
}
}
/**
* Generate a JSON message response which includes a message header and payload.
*
* @param gen
* Jackson JSON generator.
* @param msgId
* Message identifier sent with message request.
* @param msgType
* Type of message request to which this is the response.
* @param success
* {@code True} if the API was successfully executed; {@code false} if there was an
* error.
* @param payload
* Response payload, which will be the normal payload if the API was successful, or
* a {@code Throwable} if an exception occurred.
*
* @throws IOException
* if there is an error serializing the response to JSON.
*/
private void generateJsonResponse(JsonGenerator gen,
int msgId,
int msgType,
boolean success,
Object payload)
throws IOException
{
gen.writeStartObject();
// write response header (same as the incoming header, but with success indicator)
gen.writeFieldName("id");
gen.writeNumber(msgId);
gen.writeFieldName("type");
gen.writeNumber(msgType);
gen.writeFieldName("success");
gen.writeBoolean(success);
// write response payload, if any
gen.writeFieldName("pay");
if (payload == null)
{
gen.writeNull();
}
else
{
gen.writeObject(payload);
}
gen.writeEndObject();
gen.close();
}
/**
* Clean up after a session ends.
*/
private void cleanup()
{
session = null;
user = null;
}
/**
* Writer to which a JSON generator streams JSON output, and which writes it to the remote
* endpoint as it is generated. This writer should be attached to a {@code BufferedWriter}
* to stream the JSON to the enpoint in chunks. This prevents huge JSON strings from being
* created that would consume too much memory.
*/
private class ResponseWriter
extends Writer
{
/** Remote endpoint to which JSON is streamed as it is generated */
private final RemoteEndpoint endpoint;
/** Next string or partial string of JSON output to be written to the remote endpoint */
private String pending = null;
/**
* Constructor.
*
* @param endpoint
* Remote endpoint to which JSON is streamed as it is generated.
*/
ResponseWriter(RemoteEndpoint endpoint)
{
super();
this.endpoint = endpoint;
}
/**
* Write method invoked when the next string or partial string of JSON output has been
* generated. The string is saved in the {@code pending} field, to be sent on the next
* call to this method or when the writer is closed.
* <p>
* We don't send the current buffer's contents immediately, because we don't yet know
* whether the current chunk is the last one to be sent. The jetty API to send partial
* strings to the remote endpoint needs to be told whether the string being sent is the
* last one in a series. We only know this when this method is called with a 0-length
* string or upon the writer being closed.
*
* @throws IOException
* if there is an error writing the pending response data to the endpoint.
*/
public void write(char[] buf, int off, int len)
throws IOException
{
sendPendingResponse(len == 0);
if (len > 0)
{
pending = new String(buf, off, len);
}
}
/**
* No-op.
*
* @throws IOException
* never.
*/
public void flush()
throws IOException
{
}
/**
* Send any pending response as the last in the series.
*
* @throws IOException
* if there is an error writing the pending response data to the endpoint.
*/
public void close()
throws IOException
{
sendPendingResponse(true);
}
/**
* Send the pending response, if any, to the remote endpoint.
*
* @param isLast
* {@code true} if the response to be sent is the last in the series;
* {@code false} if additional reponses are expected after this one.
*
* @throws IOException
* if there is an error writing the pending response data to the endpoint.
*/
private void sendPendingResponse(boolean isLast)
throws IOException
{
if (pending != null)
{
endpoint.sendPartialString(pending, isLast);
pending = null;
}
}
}
/**
* API services that are specific to the protocol and user session.
*/
private class Services
{
/** Authentication message type */
private static final int MSG_AUTH = MSG_BASE + 1;
/** Session close message type */
private static final int MSG_CLOSE = MSG_BASE + 2;
/** Request to change password */
private static final int MSG_CHANGE_PASS = MSG_BASE + 3;
/**
* Default constructor.
*/
public Services()
{
}
/**
* Authenticate a user's credentials and log into a new session.
*
* @param name
* User name
* @param pass
* User password
*
* @return A boolean array with two elements:
* <ul>
* <li>0: {@code True} if user has admin rights, else {@code false}.</li>
* <li>1: {@code True} if user's password has expired, else {@code false}.</li>
* </ul>
*
* @throws RuntimeException
* if there was an error authenticating.
*/
@WebApi(type = MSG_AUTH)
public boolean[] authenticate(String name, String pass)
{
Authentication auth = null;
try
{
ReportApi worker = (ReportApi) requestWorkers.get(ReportApi.class);
auth = worker.logIn(name, pass, sessionId);
if (auth == null)
{
throw new RuntimeException("Invalid user name or password.");
}
user = auth.getUser();
}
catch (NoSuchAlgorithmException | InvalidKeySpecException exc)
{
throw new RuntimeException(exc);
}
boolean pwExpired = auth.getPasswordChanged() == null;
boolean[] response = new boolean[] { user.isAdmin(), pwExpired };
return response;
}
/**
* Change the given user's password, first authenticating with the old password.
*
* @param name
* User name.
* @param oldPass
* Existing password with which to authenticate request.
* @param newPass1
* New password.
* @param newPass2
* New password (for verification; should be the same as {@code newPass1}.
*
* @throws RuntimeException
* if the old or new passwords were not valid or if there was an encryption or
* database problem.
*/
@WebApi(type = MSG_CHANGE_PASS)
public void changePassword(String name, String oldPass, String newPass1, String newPass2)
{
try
{
ReportApi worker = (ReportApi) requestWorkers.get(ReportApi.class);
worker.changePassword(name, oldPass, newPass1, newPass2);
}
catch (NoSuchAlgorithmException | InvalidKeySpecException exc)
{
throw new RuntimeException("Error encrypting new password", exc);
}
}
/**
* Close the session normally, performing any session-specific protocol cleanup needed.
*/
@WebApi(type = MSG_CLOSE)
public void closeNormally()
{
// TODO: any cleanup needed upon closing the socket? Dispatch this to individual API
// handler classes?
}
}
}