LegacyWebSecurityManager.java

/*
 ** Module   : LegacyWebSecurityManager.java
 ** Abstract : Security methods supporting legacy web (SOAP, REST, WebServiceHandler).
 **
 ** Copyright (c) 2023-2024, Golden Code Development Corporation.
 **
 ** -#- -I- --Date-- ------------------------------------Description-------------------------------------------
 ** 001 GBB 20230825 Initial version.
 ** 002 RAA 20240604 Added an executeInContext method with useInitial parameter.
 */
/*
 ** 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.security;

import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;

import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
import java.util.logging.*;

import static com.goldencode.p2j.security.SecurityManager.*;

/**
 ** Wrapper class for all method related to legacy services.
 * {@link com.goldencode.p2j.main.WebServiceHandler}
 * {@link com.goldencode.p2j.soap.SoapHandler}
 * {@link com.goldencode.p2j.rest.RestHandler}
 * <p>
 * Using it through {@link SecurityManager#legacyWebSm} allows resetting SecurityManager instance.
 */
public class LegacyWebSecurityManager
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(LegacyWebSecurityManager.class);

   /** SecurityManager instance. */
   private final SecurityManager sm;

   /**
    * Public constructor. Accepts a SecurityManager instance to avoid deadlocks.
    * 
    * @param    sm
    *           SecurityManager instance.
    */
   LegacyWebSecurityManager(SecurityManager sm)
   {
      this.sm = sm;
   }
   
   /**
    * The map of currently active web requests, with their context data.  The key is the token resolved after
    * the initial authentication was performed.
    */
   private Map<String, WebRequestContext> webRequestContexts = new ConcurrentHashMap<>();

   /**
    * A thread used to cleanup the expired {@link #webRequestContexts}.  Only contexts which are not active
    * being used will be cleaned.
    */
   private Thread webServiceContextCleaner = null;

   /**
    * The work which does the authentication and authorization requires a server context.  As it can 
    * originate either from the web request thread or from the web service worker thread, an 
    * {@link AssociatedThread} will be started if any web services has enabled authentication, and this is
    * where the work will be performed. 
    */
   private WebServiceLogin webServiceLogin = null;
   
   /**
    * Check if the given credentials (user account and {@link UserAccount#getWebServiceToken() token}) is 
    * allowed to access FWD web services.  Authorization will be done later, via {@link #checkAuthorization}.
    *
    * @param    account
    *           The FWD user account.
    * @param    webServiceToken
    *           The web service token.
    *
    * @return   <code>true</code> if there is a FWD user account with that name and web service token.
    *
    * @throws   RestrictedUseException
    *           If this method is called from restricted context
    */
   public boolean authWebRequest(String account, String webServiceToken)
   throws RestrictedUseException
   {
      SecurityUtil.checkCallerAbort("com.goldencode.p2j.main.BasicAuth.authenticate");

      if (!sm.isServer())
      {
         return false;
      }

      // safely query cached data
      SecurityCache sc = sm.getCache();

      Account acc = sc.getAccountById(account);
      if (acc == null || acc.getAccountType() != Account.ACC_USER)
      {
         return false;
      }

      if (!webServiceToken.equals(((UserAccount) acc).getWebServiceToken()))
      {
         return false;
      }

      // we allow tracking of these users.
      Object[] res = sm.allowAccount(null, acc.getSubjectId(), true);

      return res == null;
   }
   
   /**
    * Authenticate and create the FWD context 
    *
    * @param    account
    *           The FWD user account.
    * @param    webServiceToken
    *           The web service token.
    * @param    timeout
    *           The timeout after which the FWD context will be {@link #destroyWebRequestContext destroyed}.
    *           If set to zero, the context will live until a logout operation is performed.
    *
    * @return   The authentication token which can be used for other calls.
    *
    * @throws   RestrictedUseException
    *           If this method is called from restricted context
    */
   public String createWebRequestContext(String account, String webServiceToken, int timeout)
   throws RestrictedUseException
   {
      SecurityUtil.checkCallerAbort("com.goldencode.p2j.main.BasicAuth.login");

      if (!sm.isServer())
      {
         return null;
      }

      // safely query cached data
      SecurityCache sc = sm.getCache();

      Account acc = sc.getAccountById(account);
      if (acc == null || acc.getAccountType() != Account.ACC_USER)
      {
         return null;
      }

      if (!webServiceToken.equals(((UserAccount) acc).getWebServiceToken()))
      {
         return null;
      }

      RestrictedUseException[] errors = new RestrictedUseException[1];
      CountDownLatch wait = new CountDownLatch(1);
      String[] token = new String[1];

      Runnable login = () ->
      {
         try
         {
            ContextSwitcher switcher = new ContextSwitcher();
            token[0] = UUID.randomUUID().toString();
            WebRequestContext context = new WebRequestContext(switcher, token[0], account, timeout);

            // we allow tracking of these users.
            Object[] res;
            try
            {
               res = sm.postAuthenticateWorker(context, null, acc.getSubjectId(), true);
            }
            catch (RestrictedUseException e)
            {
               token[0] = null;
               errors[0] = e;
               return;
            }

            int authResult = (int) res[0];
            if (authResult != AUTH_RESULT_SUCCESS)
            {
               token[0] = null;
               return;
            }

            SecurityContext newContext = (SecurityContext) res[3];

            if (newContext == null || sc != sm.getCache())
            {
               LOG.finer("createWebRequestContext: newer security cache generation available; " +
                            "authentication aborted.");
               token[0] = null;
               return;
            }

            webRequestContexts.put(token[0], context);
         }
         finally
         {
            wait.countDown();
         }
      };

      // this needs to be executed on a server context thread
      webServiceLogin.addTask(login);

      try
      {
         wait.await();
      }
      catch (InterruptedException e)
      {
         // ignore
      }

      if (errors[0] != null)
      {
         throw errors[0];
      }

      return token[0];
   }

   /**
    * Start the {@link #webServiceContextCleaner} and {@link #webServiceLogin} threads, using the server 
    * context.
    */
   public void startWebServiceContextThreads()
   {
      if (sm.isServer())
      {
         // create the thread for cleaning the web request contexts
         webServiceContextCleaner = new AssociatedThread(this::cleanExpiredWebRequestContexts);
         webServiceContextCleaner.setName("WebServiceContextCleaner");
         webServiceContextCleaner.setDaemon(true);
         webServiceContextCleaner.start();

         webServiceLogin = new WebServiceLogin();
         Thread t = new AssociatedThread(webServiceLogin);
         t.setName("WebServiceLogin");
         t.setDaemon(true);
         t.start();
      }
   }
   
   /**
    * Destroy the context for the web request identified by the specified token.
    *
    * @param    token
    *           The authentication token.
    *
    * @return   <code>true</code> if {@link LegacyWebSecurityManager#webRequestContexts} has this token and
    *           the FWD session was terminated.
    */
   public boolean destroyWebRequestContext(String token)
   {
      WebRequestContext context = webRequestContexts.remove(token);

      if (context == null)
      {
         return false;
      }

      try
      {
         sm.sessionSm.terminateSessionById(context);
      }
      catch (RestrictedUseException e)
      {
         LOG.log(LOG.isLoggable(Level.FINE) ? Level.FINE : Level.WARNING,
                 "Could not destroy context for token: " + token,
                 e);
         return false;
      }

      return true;
   }
   
   /**
    * Check if the specified token is associated with an active and not expired web request context.
    * <p>
    * If the web request is expired, it will be {@link #destroyWebRequestContext destroyed}.
    *
    * @param    token
    *           The authentication token.
    *
    * @return   <code>true</code> if the token exists and the web request is not expired.
    */
   public boolean hasWebRequestContext(String token)
   {
      WebRequestContext context = webRequestContexts.get(token);

      if (context != null)
      {
         if (context.isExpired())
         {
            destroyWebRequestContext(token);
            return false;
         }

         return true;
      }

      return false;
   }

   /**
    * Execute the specified task in the context associated with the web request identified via the specified
    * authentication token.
    *
    * @param    token
    *           The authentication token.
    * @param    task
    *           The task to be performed.
    */
   public void executeInContext(String token, Runnable task)
   {
      executeInContext(token, task, false);
   }

   /**
    * Execute the specified task in the context associated with the web request identified via the specified
    * authentication token.
    *
    * @param    token
    *           The authentication token.
    * @param    task
    *           The task to be performed.
    * @param    useInitial
    *           Flag indicating to create an initial security context.
    */
   public void executeInContext(String token, Runnable task, boolean useInitial)
   {
      WebRequestContext context = webRequestContexts.get(token);

      if (context == null)
      {
         return;
      }

      ContextSwitcher switcher = context.getContextSwitcher();
      try
      {
         switcher.setContext(context, useInitial);
         task.run();
      }
      finally
      {
         switcher.releaseContext(useInitial);
      }
   }
   
   /**
    * Execute the given task in the {@link LegacyWebSecurityManager#webServiceLogin}  thread, so that it 
    * can check if the web request identified with the given authentication token is authorized for that 
    * web request.
    *
    * @param    token
    *           The authentication token.
    * @param    task
    *           The authorization task.
    *
    * @return   <code>true</code> if the web request token is authorized.
    */
   public boolean checkAuthorization(String token, Supplier<Boolean> task)
   {
      WebRequestContext context = webRequestContexts.get(token);

      if (context == null)
      {
         return false;
      }

      CountDownLatch wait = new CountDownLatch(1);
      boolean[] res = new boolean[1];
      Runnable work = () ->
      {
         ContextSwitcher switcher = context.getContextSwitcher();
         try
         {
            switcher.setContext(context, false);
            res[0] = task.get();
         }
         finally
         {
            switcher.releaseContext(false);
            wait.countDown();
         }
      };

      webServiceLogin.addTask(work);

      try
      {
         wait.await();
      }
      catch (InterruptedException e)
      {
         // ignore
      }

      return res[0];
   }
   
   /**
    * Given an authentication related code, return a {@link ContextSwitcher} instance, if the
    * authentication was performed.
    * <p>
    * This switcher allows a thread to establish the context, if it knows a session ID.
    *
    * @param    auth
    *           The authentication code.
    *
    * @return   A context switcher instance.
    */
   public ContextSwitcher getContextSwitcher(Runnable auth)
   {
      auth.run();

      return sm.contextSm.hasContext() ? new ContextSwitcher() : null;
   }

   /**
    * The main work performed by {@link #webServiceContextCleaner}, it goes through the 
    * {@link #webRequestContexts} every 10 seconds and cleans any contexts which are not in use and are
    * expired.  
    */
   private void cleanExpiredWebRequestContexts()
   {
      Object lock = new Object();
      while (true)
      {
         try
         {
            synchronized (lock)
            {
               // TODO: configure this?
               lock.wait(10 * 1000);  // 10 seconds wait
            }
         }
         catch (InterruptedException e)
         {
            break;
         }

         List<String> destroy = new ArrayList<>();
         for (WebRequestContext req : webRequestContexts.values())
         {
            if (req.isExpired() && !AppServerManager.isWebServiceTokenInUse(req.getToken()))
            {
               destroy.add(req.getToken());
            }
         }

         for (String token : destroy)
         {
            destroyWebRequestContext(token);
         }
      }
   }

   /**
    * A thread inheriting the FWD server context, used to perform authentication and authorization work for
    * web requests.
    */
   private static class WebServiceLogin 
   implements Runnable
   {
      /** The task to perform. */
      private List<Runnable> tasks = new ArrayList<>();

      /**
       * Main work loop.
       */
      public void run()
      {
         LinkedList<Runnable> work = new LinkedList<>();

         while (true)
         {
            synchronized (this)
            {
               if (tasks.isEmpty())
               {
                  try
                  {
                     wait();
                  }
                  catch (InterruptedException e)
                  {
                     // ignore
                  }
               }

               work.addAll(tasks);
               tasks.clear();
            }

            for (Runnable task : work)
            {
               try
               {
                  task.run();
               }
               catch (Throwable t)
               {
                  // ignore
                  LOG.log(Level.SEVERE, "Error processing login task", t);
               }
            }

            work.clear();
         }
      };

      /**
       * Add a task to the {@link #tasks queue}.
       *
       * @param    task
       *           The task to perform.
       */
      synchronized void addTask(Runnable task)
      {
         tasks.add(task);
         this.notify();
      }
   }
}