SessionRestHandler.java

/*
** Module   : SessionRestHandler.java
** Abstract : Collection of security administration workers and session state
**
** Copyright (c) 2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 RNC 20250108 Created initial version.
** 002 OM  20250320 SessionHandler was renamed to SessionRestHandler. The common REST features were
**                  extracted to AdminRestHandler which also handles the authentication.
*/

/*
** 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.admin.server;

import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.core.type.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import com.goldencode.p2j.admin.*;
import com.goldencode.p2j.main.*;
import com.goldencode.p2j.net.*;
import com.goldencode.p2j.util.logging.*;
import org.eclipse.jetty.server.*;
import javax.servlet.http.*;
import java.io.*;
import java.time.*;
import java.time.format.*;
import java.util.*;
import java.util.logging.*;

/**
 * A handler for administration of session resources using REST APIs.
 */
public class SessionRestHandler
extends BaseAdminRestHandler
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(SessionRestHandler.class);
   
   /** The singleton which handles tenant REST requests. */
   private static SessionRestHandler instance;
   
   /** The singleton instance for session management. */
   private final RouterSessionManager sm;
   
   /**
    * Constructs a new handler for administration of session resources using REST APIs.
    *
    * @param   ah
    *          The WebAuthHandler used for authorisation.
    */
   protected SessionRestHandler(WebAuthHandler ah)
   {
      super("/admin/sessions", ah);
      sm = (RouterSessionManager) RouterSessionManager.get();
   }
   
   /**
    * Tries to initialize this REST handler. It does this by reading the directory and creating the
    * {@code DomainRestHandler} according to the settings. 
    *
    * @param   ah
    *          The authentication handler which will be used for authorisation.
    *
    * @return  An instance of {@link BaseAdminRestHandler} on success or {@code null} if the directory is not
    *          configured with a REST handler for tenant administration.
    */
   public static SessionRestHandler initialize(WebAuthHandler ah)
   {
      if (instance == null)
      {
         instance = new SessionRestHandler(ah);
      }
      
      return instance;
   }
   
   /**
    * Implement this method for handling the GET requests. The result should be written directly to
    * {@code response}'s writer.
    *
    * @param   target
    *          The relative target of the request. From the absolute URL, the context prefix was eliminated.
    * @param   body
    *          The payload of the request (optional).
    * @param   response
    *          The response as the {@link Response} object or a wrapper of that request.
    * @param   token
    *          The authentication token.
    */
   @Override
   protected void handleGet(String target, String body, HttpServletResponse response, String token)
   {
      if (target.indexOf('/') != -1)
      {
         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
         return;
      }
      
      Runnable task;
      switch (target)
      {
         case "": // /admin/sessions/  GET the list of all sessions
            List<Integer> filter = null;
            if (!body.isEmpty())
            {
               Map<String, Object> jsonMap = null;
               try
               {
                  jsonMap = new ObjectMapper().readValue(body, new TypeReference<Map<String, Object>>() {});
               }
               catch (JsonProcessingException e)
               {
                  LOG.log(Level.WARNING, "Invalid request body in GET:/admin/sessions: " + e.getMessage());
                  response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                  return;
               }
               if (jsonMap.size() != 1              ||
                   !jsonMap.containsKey("sessions") ||
                   !(jsonMap.get("sessions") instanceof List))
               {
                  LOG.log(Level.WARNING, "Unknown format in request body for GET:/admin/sessions.");
                  response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                  return;
               }
               filter = (List) jsonMap.get("sessions");
            }
            
            List<SessionInfo[]> sessions = new ArrayList<>(1);
            task = () -> {
               AdminServerImpl.setTargetLive(true, false, true);
               sessions.add(AdminServerImpl.getSessionList());
            };
            wsm.executeInContext(token, task, true);
            
            try
            {
               response.getWriter().write(sessionsToJSON(sessions.get(0), filter));
               response.setStatus(HttpServletResponse.SC_OK);
            }
            catch (IOException e)
            {
               LOG.log(Level.WARNING, "Could not write response due to " + e.getMessage());
               response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            }
            return;
            
         case "lock": // /admin/sessions/lock  GET the [lock] status
            try
            {
               response.getWriter().write("{\"lock\":" + sm.getSessionLock() + "}");
               response.setStatus(HttpServletResponse.SC_OK);
            }
            catch (IOException e)
            {
               LOG.log(Level.WARNING, "Could not write response due to " + e.getMessage());
               response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            }
            return;
            
         default: // / admin/sessions/{session-id} - GET a specific active session
            int sessionID;
            try
            {
               sessionID = Integer.parseInt(target);
            }
            catch (NumberFormatException e)
            {
               LOG.log(Level.WARNING, "The session ID is not a valid integer: " + target);
               response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
               return;
            }
            
            SessionInfo[] session = new SessionInfo[1];
            task = () -> {
               AdminServerImpl.setTargetLive(true, false, true);
               session[0] = AdminServerImpl.getSessionById(sessionID);
            };
            
            wsm.executeInContext(token, task, true);
            try
            {
               response.getWriter().write(sessionsToJSON(session, null));
               response.setStatus(HttpServletResponse.SC_OK);
            }
            catch (IOException e)
            {
               LOG.log(Level.WARNING, "Could not write response due to " + e.getMessage());
               response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            }
      }   
   }
   
   /**
    * Implement this method for handling the PUT requests. The result should be written directly to
    * {@code response}'s writer.
    *
    * @param   target
    *          The relative target of the request. From the absolute URL, the context prefix was eliminated.
    * @param   body
    *          The payload of the request (optional).
    * @param   response
    *          The response as the {@link Response} object or a wrapper of that request.
    * @param   token
    *          The authentication token.
    */
   @Override
   protected void handlePut(String target, String body, HttpServletResponse response, String token)
   {
      // /admin/sessions/lock
      if (!"lock".equals(target))
      {
         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
         return;
      }
      
      Boolean lock = (Boolean) extractFirstLevelValue(body, "lock", Boolean.class);
      if (lock == null)
      {
         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
         return;
      }
      
      try
      {
         sm.setSessionLock(lock);
         response.getWriter().write("{\"lock\":" + sm.getSessionLock() + "}");
         response.setStatus(HttpServletResponse.SC_OK);
      }
      catch (IOException e)
      {
         LOG.log(Level.WARNING, "Could not write response due to " + e.getMessage());
         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
      }
   }
   
   /**
    * Implement this method for handling the DELETE requests. The result should be written directly to
    * {@code response}'s writer.
    *
    * @param   target
    *          The relative target of the request. From the absolute URL, the context prefix was eliminated.
    * @param   body
    *          The payload of the request (optional).
    * @param   response
    *          The response as the {@link Response} object or a wrapper of that request.
    * @param   token
    *          The authentication token.
    */
   @Override
   protected void handleDelete(String target, String body, HttpServletResponse response, String token)
   {
      if (target.indexOf('/') != -1)
      {
         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
         return;
      }
      
      if (target.isEmpty()) // /admin/sessions - DELETE sessions specified in body or ALL if body is empty
      {
         if (body.isEmpty())
         {
            Runnable task;
            boolean[] lockStatus = new boolean[1];
            boolean oldLockStatus = sm.getSessionLock();
            
            if (!oldLockStatus)
            {
               // try to lock the session launching first
               task = () -> {
                  AdminServerImpl.setTargetLive(true, false, true);
                  lockStatus[0] = sm.setSessionLock(true);
               };
               wsm.executeInContext(token, task, true);
               
               if (!lockStatus[0])
               {
                  LOG.log(Level.WARNING, "Could not lock session launching!");
                  response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                  return;
               }
            }
            
            try
            {
               List<SessionInfo[]> sessionList = new ArrayList<>();
               task = () -> {
                  AdminServerImpl.setTargetLive(true, false, true);
                  sessionList.add(AdminServerImpl.getSessionList());
               };
               wsm.executeInContext(token, task, true);
               
               if (sessionList.get(0) == null)
               {
                  LOG.log(Level.WARNING, "Insufficient rights to retrieve sessions in " +
                        "DELETE:/admin/sessions/all");
                  response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                  return;
               }
               
               SessionInfo[] sessions = sessionList.get(0);
               List<Integer> withSuccess = new ArrayList<>(sessions.length);
               task = () -> {
                  AdminServerImpl.setTargetLive(true, false, true);
                  for (SessionInfo session : sessions)
                  {
                     if (handleSessionClose(session.id))
                     {
                        withSuccess.add(session.id);
                     }
                  }
               };
               wsm.executeInContext(token, task, true);
               
               response.getWriter().write(createDeleteResponse(withSuccess));
            }
            catch (IOException e)
            {
               LOG.log(Level.WARNING, "Could not write response due to " + e.getMessage());
               response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            }
            finally
            {
               if (!oldLockStatus)
               {
                  // now try to unlock the session launching
                  task = () -> {
                     AdminServerImpl.setTargetLive(true, false, true);
                     lockStatus[0] = sm.setSessionLock(false);
                  };
                  wsm.executeInContext(token, task, true);
                  
                  if (lockStatus[0])
                  {
                     LOG.log(Level.WARNING, "Could not unlock session launching!");
                     response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                  }
               }
            }
         }
         else
         {
            Map<String, Object> jsonMap;
            try
            {
               jsonMap = new ObjectMapper().readValue(body, new TypeReference<Map<String, Object>>() {});
            }
            catch (JsonProcessingException e)
            {
               LOG.log(Level.WARNING, "Invalid request body in DELETE:/admin/sessions: " + e.getMessage());
               response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
               return;
            }
            
            if (jsonMap == null                  ||
                jsonMap.size() != 1              ||
                !jsonMap.containsKey("sessions") ||
                !(jsonMap.get("sessions") instanceof List))
            {
               LOG.log(Level.WARNING, "Unknown format in request body for DELETE:/admin/sessions.");
               response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
               return;
            }
            
            List<Object> sessions = (List<Object>) jsonMap.get("sessions");
            List<Integer> withSuccess = new ArrayList<>(sessions.size());
            
            Runnable task = () ->
            {
               AdminServerImpl.setTargetLive(true, false, true);
               for (Object session : sessions)
               {
                  if (!(session instanceof Integer))
                  {
                     continue;
                  }
                  
                  if (handleSessionClose((int) session))
                  {
                     withSuccess.add((int) session);
                  }
               }
            };
            
            wsm.executeInContext(token, task, true);
            try
            {
               response.getWriter().write(createDeleteResponse(withSuccess));
            }
            catch (IOException e)
            {
               LOG.log(Level.WARNING, "Could not write response due to " + e.getMessage());
               response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            }
         }
      }
      else
      {
         int sessionID;
         try
         {
            sessionID = Integer.parseInt(target);
         }
         catch (NumberFormatException e)
         {
            LOG.log(Level.WARNING, "The session ID is not a valid integer: " + target);
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
         }
         
         boolean[] ok = new boolean[1];
         Runnable task = () -> {
            AdminServerImpl.setTargetLive(true, false, true);
            ok[0] = handleSessionClose(sessionID);
         };
         wsm.executeInContext(token, task, true);
         
         try
         {
            response.getWriter().write(
                createDeleteResponse(ok[0] ? Collections.singletonList(sessionID) : Collections.emptyList()));
         }
         catch (IOException e)
         {
            LOG.log(Level.WARNING, "Could not write response due to " + e.getMessage());
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
         }
      }
   }
   
   /**
    * List all the sessions from the given list in a JSON format.
    *
    * @param   sessions
    *          The list with the sessions.
    * @param   filter
    *          Filter the response only to specified sessions. 
    *
    * @return  A string in JSON format with the sessions' information.
    */
   private String sessionsToJSON(SessionInfo[] sessions, List<Integer> filter)
   {
      int numberOfSessions = sessions == null ? 0 : sessions.length;
      if (numberOfSessions == 0 || sessions[0] == null)
      {
         return "{\n\"sessions\":[]\n}";
      }
      
      ObjectMapper objectMapper = mapper.get();
      ObjectNode root = objectMapper.createObjectNode();
      ArrayNode children = objectMapper.createArrayNode();
      for (int i = 0; i < numberOfSessions; i++)
      {
         SessionInfo session = sessions[i];
         if (filter == null || filter.contains(session.id))
         {
            children.add(createSessionNode(session));
         }
      }
      
      root.set("sessions", children);
      return root.toString();
   }
   
   /**
    * Method which will create a custom representation of a session.
    *
    * @param   session
    *          The session's info.
    *
    * @return  The session-specific JSON node, or {@code null} if the session is null.
    */
   private JsonNode createSessionNode(SessionInfo session)
   {
      if (session == null)
      {
         return null;
      }
      
      ZonedDateTime dateTime = Instant.ofEpochMilli(session.logonTime).atZone(ZoneId.systemDefault());
      
      ObjectMapper objectMapper = mapper.get();

      ArrayNode programTrace = objectMapper.createArrayNode();
      AdminServerImpl.convertProgramTrace(session.stacktrace, programTrace);
      
      ObjectNode sessionNode = objectMapper.createObjectNode();
      sessionNode.put("logonTime", dateTime.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
      sessionNode.put("accountDescription", session.name);
      sessionNode.put("description", session.descr);
      sessionNode.put("osUser", session.osUser);
      sessionNode.put("sessionDescription", session.sessionDescription);
      sessionNode.put("sessionTokenID", session.id == -1 ? null : String.valueOf(session.id));
      sessionNode.put("sessionUUID", session.uuid);
      sessionNode.put("relatedSessionUUID", session.relatedSessionUuid);
      sessionNode.putArray("programTrace").addAll(programTrace);
      sessionNode.put("clientWebPort", session.httpPort == -1 ? "" : String.valueOf(session.httpPort));
      sessionNode.put("clientPID", session.pid == -1 ? null : String.valueOf(session.pid));
      sessionNode.put("browserSocket", session.browserWebSocket);
      sessionNode.put("clientSocket", session.remoteNet);
      sessionNode.put("serverSocket", session.localNet);
      sessionNode.put("secure", session.secure);
      sessionNode.put("cacheGeneration", session.cacheGen == -1 ? null : String.valueOf(session.cacheGen));
      
      return sessionNode;
   }
   
   /**
    * Creates a custom JSON string response which represents a list with all the
    * sessions which were successfully terminated by a {@code DELETE} request.
    *
    * @param   sessionID
    *          The list with the session IDs which were terminated.
    *
    * @return  The custom {@code DELETE} response string.
    */
   private String createDeleteResponse(List<Integer> sessionID)
   {
      ObjectMapper objectMapper = new ObjectMapper();
      
      ArrayNode children = objectMapper.createArrayNode();
      for (Integer integer : sessionID)
      {
         children.add(integer);
      }
      
      ObjectNode root = objectMapper.createObjectNode();
      root.set("terminated", children);
      return root.toString();
   }
   
   /**
    * Handles the closing of a session by verifying if no error was thrown.
    *
    * @param   sessionID
    *          The session which needs to be terminated.
    *
    * @return  {@code true} if the session was closed successfully, {@code false} otherwise.
    *
    */
   private boolean handleSessionClose(int sessionID)
   {
      try
      {
         AdminServerImpl.terminateSession(sessionID);
         return true;
      }
      catch (RuntimeException e)
      {
         LOG.log(Level.WARNING, "Could not terminate session " + sessionID + ": " + e.getMessage());
         return false;
      }
   }

}