DatabaseAuthenticationHook.java

/*
** Module   : DatabaseAuthenticationHook.java
** Abstract : auth hook that asks the user for credentials for accessing a database
**
** Copyright (c) 2005-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- -------------------------------- Description --------------------------------
** 001 OM  20130927 First commit with initial implementation.
** 002 EVL 20140217 Fixed javadoc comments to be able to compile in Windows 1252 CP.
** 003 GES 20140626 Renamed color attributes to match naming standards.
** 004 EVL 20140829 All widgets in login screen must have predefined ID.  Including space and skip
**                  never leaving -1 as ID when passing widget config to ScreenDefinition class.
**                  We can not use the new widget ID allocation here because there is no
**                  LogicalTerminal at this time.  Size of ScreenBuffer changed to be
**                  LAST_CONFIG_COMP - EDIT_FID to store last skip into buffer as well.
** 006 CA  20140926 Added shared widget configuration support, to allow GUI/ChUI concrete
**                  implementation for the same widget ID.  Refactored the widget configuration
**                  classes: all fields were made public; these classes need to be as dumb as
**                  possible, all logic related to setting/getting a certain field should be at
**                  the widget or at the caller. Refs #2254
** 007 VIG 20140911 Changed method clientFinalize() with statusInput method instead of 
**                  removed statusInputRevert(). Added an argument of messagebox method call. 
** 008 CA  20141119 Refactored drawing code to enable multiple OS windows, for GUI support.  Misc
**                  changes/fixes related to drawing on multiple windows.
** 009 HC  20150405 Refactoring of config coordinate fields.
** 010 EVL 20160224 Javadoc fixes to make compatible with Oracle Java 8 for Solaris 10.
** 011 IAS 20160314 DEBUG-ALERT support.
** 012 EVL 20160726 Now we use screen buffer array in waitFor() result instead of single buffer.
** 013 SBI 20161101 Added tab item list as a parameter to the enable, view, display,
**                  displayAndDown and promptFor methods provided by ClientExports.
** 014 CA  20180620 Ensure both on/off calls for an invalidation bracket are always called.
** 016 IAS 20220713 Added 'lock' argument to the ConnectionManager.setAuthenticatedUserid call.
** 017 GBB 20230825 checkCallerAbort moved to SecurityUtil & calls updated.
*/ 
/*
** 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.directory.DirectoryService;
import com.goldencode.p2j.net.*;
import com.goldencode.p2j.net.Session;
import com.goldencode.p2j.persist.ConnectionManager;
import com.goldencode.p2j.ui.*;
import com.goldencode.p2j.ui.chui.ThinClient;
import com.goldencode.p2j.ui.client.*;
import com.goldencode.p2j.ui.client.widget.*;
import com.goldencode.p2j.util.*;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.util.*;
import static com.goldencode.p2j.ui.LogicalTerminal.ALERT_MESSAGE;
import static com.goldencode.p2j.ui.LogicalTerminal.BTN_OK;

/**
 * This class is a database authentication hook implementation. If the database requires
 * authentication (ie. the _User meta-table is populated) this hook is used to prompt the user
 * for a userid/password combination. If the credentials cannot be validated, the user is
 * informed and allows to enter the combination again for maximum three times; after that the
 * session is closed.
 * However, the user is allowed to [Cancel] the authorization process and continue as "blank"
 * user (if the database allows this.).
 */
public class DatabaseAuthenticationHook
implements  Authenticator
{
   /** Edit frame ID. */
   private static final int EDIT_FID = 100;

   /** ID for message on 1st line. */
   private static final int MSG1_ID = EDIT_FID + 2; // 1 + skip

   /** ID for message on 2nd line. */
   private static final int MSG2_ID = EDIT_FID + 4;

   /** User ID field ID. */
   private static final int USER_ID = EDIT_FID + 6;

   /** Password field ID. */
   private static final int PASSWORD_ID = EDIT_FID + 8;
   
   /** OK button field ID. */
   private static final int BUTTON_OK_ID = EDIT_FID + 10;
   
   /** Cancel button field ID. */
   private static final int BUTTON_CANCEL_ID = EDIT_FID + 11;

   /** Keep the id of the last component. */
   private static final int LAST_CONFIG_COMP = EDIT_FID + 12; /* including skip(1) after buttons*/
   
   /** First line of the [Login] dialog. */
   private static final String STD_MSG_1 = "Please enter a User Id and Password for";

   /** First line of the [Login] dialog. The name of teh database will be concatenated. */
   private static final String STD_MSG_2 = "database: ";

   /** The text on OK button from the [Login] dialog */
   private static final String DLG_BTN_OK_TEXT = "OK";

   /** The text on OK button from the [Login] dialog */
   private static final String DLG_BTN_CANCEL_TEXT = "Cancel";

   /** Invalid user status message. */
   private static final String INVALID_CREDENTIALS = "Userid/Password is incorrect.";

   /** Client interface. */
   private ThinClient client = null;

   /**
    * The screen buffer for the editing frame.
    * NOTE: numWidgets is important and must be exactly the numbers of widget configs defined.
    */
   private ScreenBuffer editsb = null;

   /** The map of authenticated userids for each database. */
   private Map<String, String> authUsers = null;

   /** The value of "option" node from AuthPlugin for this Authenticator */
   private String p2jAuthUser = null;

   /**
    * The default constructor.
    */
   public DatabaseAuthenticationHook()
   {
      client = ThinClient.getInstance();
   }

   /**
    * Implements client side authentication logic.
    *
    * @param    parameters
    *           Additional configuration parameters.
    * @param    code
    *           The result of the most recent attempt to authenticate or
    *           <code>AUTH_RESULT_NONE</code> if this is the first attempt.
    *
    * @return   Array of bytes to be transmitted to the server as the authorization input.
    */
   @Override
   public byte[] clientAuthHook(Map<String, Object> parameters, int code)
   {
      OutputManager<?> tk = OutputManager.instance();
      String database = (String) parameters.get("option"); // the processed entity

      if (editsb == null)
      {
         //lazy initialization
         editsb = new ScreenBuffer(EDIT_FID, LAST_CONFIG_COMP - EDIT_FID);
      }

      if (code == AUTH_RESULT_NONE)
      {
         // push in the frame definition the first time:
         createFrameDefinition(database);
      }
      else
      {
         // Some kind of error occurred, most likely AUTH_RESULT_INVALID_PASSWORD.
         displayWrongPassword(null);
      }

      String id = null;
      String pw = null;
      EventList el = new EventList(true);
      ScreenBuffer[] sbf = null;

      // get the widget
      Widget<?> edit = client.getWidget(EDIT_FID);
      
      // drive the logon frame I/O
      tk.setInvalidate(true);
      int[] activeWidgets = new int[] { USER_ID, PASSWORD_ID, BUTTON_OK_ID, BUTTON_CANCEL_ID };
      int[] tabItemList   = new int[] { USER_ID, PASSWORD_ID, BUTTON_OK_ID, BUTTON_CANCEL_ID };
      try
      {
         client.enable(EDIT_FID, activeWidgets, new ScreenBuffer[] {editsb}, true,
                       WindowConfig.RESOLVE_WINDOW, tabItemList);
         client.clear(EDIT_FID, true);
         client.view(EDIT_FID, new ScreenBuffer[] {editsb}, null, true, 
                     WindowConfig.RESOLVE_WINDOW, tabItemList);
         editsb.resetChanged();
      }
      finally
      {
         tk.setInvalidate(edit, false);
      }

      try
      {
         client.setAuthMode(true);
         sbf = client.waitFor(el, USER_ID, -1, new ScreenBuffer[] {editsb});
      }
      catch (ConditionException cx)
      {
         // EndConditionException: END key function executed
         return null;
      }
      finally
      {
         client.setAuthMode(false);
      }

      tk.setInvalidate(true);
      try
      {
         client.enable(EDIT_FID, null, new ScreenBuffer[] {editsb}, false,
                       WindowConfig.RESOLVE_WINDOW, tabItemList);
      }
      finally
      {
         tk.setInvalidate(edit, false);
      }

      // inspect the input; exit out if requested
      if (Keyboard.keyFunction(sbf[0].getKeyCode()).equals("END-ERROR"))
      {
         return null;
      }

      character idc = (character) sbf[0].getWidgetValue(USER_ID);
      id = (idc == null) ? "" : idc.getValue().trim();

      character pwc = (character) sbf[0].getWidgetValue(PASSWORD_ID);
      pw = (pwc == null) ? "" : pwc.getValue();

      // return a byte array
      return SecurityUtil.packageIdPassword(id, pw);
   }

   /**
    * Implements server side authentication logic.
    * <p>
    * Accepts the byte array produced by the client side authentication hook
    * as authentication input as well as any custom parameters.
    *
    * @param    auth
    *           The authorization input from the client.
    * @param    ldbName
    *           Additional configuration parameters taken from the directory.
    *
    * @return   The result of the authentication processing.
    */
   @Override
   public AuthenticationResponse serverAuthHook(byte[] auth, String ldbName)
   {
      if (auth == null)
      {
         // user cancelled the authentication for this entity
         // we shoud do: addAuthUser(ldbName, SecurityOps.BLANK_USER);
         // however, this is not necessary, as the default userid is already BLANK_USER

         // from p2j SM authentication point, this is a success
         return new AuthenticationResponse(p2jAuthUser, AUTH_RESULT_SUCCESS);
      }
      // parse the byte array input
      ByteArrayInputStream bis = new ByteArrayInputStream(auth);
      DataInputStream dis = new DataInputStream(bis);

      String userId;
      String password;

      try
      {
         userId   = dis.readUTF();
         password = dis.readUTF();
      }
      catch (IOException ioe)
      {
         return new AuthenticationResponse(null, AUTH_RESULT_UNSPECIFIED_FAILURE);
      }

      if (ConnectionManager.authenticate(userId, password, ldbName))
      {
         addAuthUser(ldbName, userId);

         // This is a small workaround in order to allow successful database authentication.
         // The authentication is successful but we need convince SecurityManager that we
         // trustworthy, so we use the p2j userid that was configured in directory for this
         // purpose.
         return new AuthenticationResponse(p2jAuthUser, AUTH_RESULT_SUCCESS);
      }
      else
      {
         return new AuthenticationResponse(null, AUTH_RESULT_INVALID_PASSWORD);
      }
   }

   /**
    * Returns a set of databases that need to be authenticated.
    *
    * @return  The set of databases that requires the user to authenticate to.
    */
   @Override
   public Set<String> getAuthenticationEntities()
   {
      DirectoryService ds = DirectoryService.getInstance();
      final HashSet<String> entities = new HashSet<>();

      // We could use temporary context to access scoped directory location for p2jAuthUser
      // but access to SecurityManager.createSecurityContext(p2jAuthUser) is needed.
      // So we will use a scopeless search. Perhaps this is rather logical as there is only a
      // dedicated p2j user whose userid will be used tu authenticate to P2J SecurityManager.
      int n = ConnectionManager.getConnectedDbCount();
      for (int k = 0; k < n; k++)
      {
         final String ldbName = ConnectionManager.ldbName(k + 1).toStringMessage(); // 1 - base
         int authLevel = ConnectionManager.getAuthLevel(ldbName);
         if (authLevel == SecurityOps.FREE_ACCESS)
         {
            addAuthUser(ldbName, SecurityOps.getDefaultUserid(ldbName));
            /* or OS username or blank will be completed by ConnManager */
         }
         else
         {
            // the client should authenticate to database:
            //    check if credentials are stored in the directory.
            final String userid = Utils.getDirectoryNodeString(ds,
                                 "databases-auth/" + ldbName + "/userid", "", false);
            final String password = Utils.getDirectoryNodeString(ds,
                                 "databases-auth/" + ldbName + "/password", "", false);

            if (userid == null || userid.isEmpty())
            {
               // store the database for user-interactive authentication session
               entities.add(ldbName);
            }
            else if (ConnectionManager.authenticate(userid, password, ldbName))
            {
               addAuthUser(ldbName, userid);
            }
            else
            {
               // the credentials read from directory are incorrect: bail out:
               // NOTE: replace the RuntimeException with a customized Exception ?
               throw new RuntimeException("Authentication with " + userid +
                            "to " + ldbName + " using directory-saved values failed");
            }
         }
      }

      return entities;
   }

   /**
    * Configures the Authenticator by setting the "option" parameter from directory.xml.
    *
    * @param   p2jUser
    *          The value of userid stored that will be used for authentication to p2j
    *          SecurityManager.
    */
   @Override
   public void configure(String p2jUser)
   {
      this.p2jAuthUser = p2jUser;
   }

   /**
    * Adds an authenticated user to the internal list list.
    *
    * @param   ldbName
    *          The logical name of teh database that authenticated the user.
    * @param   userid
    *          The authenticated user.
    */
   private void addAuthUser(String ldbName, String userid)
   {
      if (authUsers == null)
      {
         // lazy initialization is preferred because the same class is run on client
         authUsers = new HashMap<>();
      }
      authUsers.put(ldbName, userid);
   }

   /**
    * A session listener will be returned only if any user was successfully authenticated
    * to a database.
    * The initialization callback will set the authenticated userid into the correct context,
    * after it will have been created.
    * The termination callback does nothing.
    *
    * @return  The current session listener (if there is any).
    */
   @Override
   public SessionListener getSessionListener()
   {
      if (authUsers == null)
      {
         // if no user has been authenticated no listener is needed
         return null;
      }

      return new SessionListener() {
         @Override
         public void terminate(Session session)
         {
            // not interested in this event
         }

         @Override
         public void initialize(Session session)
         {
            for (String ldbname : authUsers.keySet())
            {
               ConnectionManager.setAuthenticatedUserid(ldbname, authUsers.get(ldbname), true, true);
            }
         }
      };
   }

   /**
    * Finalizes any resources allocated during authentication by the client.
    */
   @Override
   public void clientFinalize()
   {
      client.statusInputRevert(WindowConfig.RESOLVE_WINDOW);
      client.hideAll(true, WindowConfig.RESOLVE_WINDOW);
      client.destroyFrame(EDIT_FID);
   }

   /**
    * Displays a dialog informing the user that the entered database credentials are invalid.
    * If <code>userid</code> is null, the displayed dialog will simply print
    *        +------------ Error ------------+
    *        | Userid/Password is incorrect. |
    *        | ----------------------------- |
    *        |             &lt;OK&gt;              |
    *        +-------------------------------+
    *
    * Otherwise will print the P2J counter-part message of the 710 dialog of P4GL.
    * +---------------------- Error ----------------------+
    * | ** Your Password and Userid x do not match. (710) |
    * |                                                   |
    * | ------------------------------------------------- |
    * |                       &lt;OK&gt;                        |
    * +---------------------------------------------------+
    *
    * @param   userid
    *          The userid that was used in an authentication attempt.
    */
   public static void displayWrongPassword(String userid)
   {
      String message = (userid == null)
            ? INVALID_CREDENTIALS
            : "** Your Password and Userid " + userid + " do not match. (710)";

      ThinClient.getInstance().messageBox(new Object[]{message}, null, ALERT_MESSAGE, 
                                          BTN_OK, " Error ", null, WindowConfig.RESOLVE_WINDOW,
                                          null /* TODO: is it always correct? */);

   }

   /**
    * Helper method, adds specified number of empty lines to a ScreenDefinition.
    *
    * @param   sd
    *          The frame to which to add a skip.
    * @param   lines
    *          Lines to skip.
    * @param   id
    *          Widget ID to be used for this skip widget.
    */
   private static void addSkip(ScreenDefinition sd, int lines, int id)
   {
      SkipConfig sc = new SkipConfig();
      sc.vertical = true;   // skip not space
      sc.length = lines;
      sd.addConfig(sc, id);
   }

   /**
    * Create the user interface definition for frame that holds the standard dialog that prompts
    * the user for userid/password combination.
    * The dialog is centered on the screen and looks like
    * <pre>
    * +------------------- Login -------------------+
    * |                                             |
    * | Please enter a User Id and Password for     |
    * | database: &lt;ldbname&gt;                         |
    * |                                             |
    * |    User Id: ________________                |
    * |   Password: ________________                |
    * |                                             |
    * |              &lt;OK&gt;   &lt;Cancel&gt;                |
    * +---------------------------------------------+
    * </pre>
    *
    * @param   database
    *          The name of the database for which the userid/password is requested.
    */
   private void createFrameDefinition(String database)
   {
      // remove any widgets created in a previous database dialog
      clientFinalize();

      // create the frame definition on the fly
      ScreenDefinition editsd = new ScreenDefinition(EDIT_FID);

      // edit frame configuration
      FrameConfig frameConfig = new FrameConfig();
      frameConfig.row = 10;
      frameConfig.box = true;
      frameConfig.centered = true;
      frameConfig.overlay = true;
      frameConfig.dcolor = Color.NORMAL;
      frameConfig.sideLabels = true;
      frameConfig.title = " Login ";
      frameConfig.widthChars = 47;
      editsd.addConfig(frameConfig, EDIT_FID);

      // set next widget position
      addSkip(editsd, 1, EDIT_FID + 1); // id = EDIT_FID + 1

      ControlTextConfig mess1Fic = new FillInConfig();
      mess1Fic.dataType = "character";
      mess1Fic.column = 2;
      mess1Fic.flagStatic = true;
      editsd.addConfig(mess1Fic, MSG1_ID);

      // set next widget position
      addSkip(editsd, 0, MSG1_ID + 1); // id = EDIT_FID + 3

      ControlTextConfig mess2Fic = new FillInConfig();
      mess2Fic.dataType = "character";
      mess2Fic.flagStatic = true;
      mess2Fic.column = 2;
      editsd.addConfig(mess2Fic, MSG2_ID);

      // set next widget position
      addSkip(editsd, 1, MSG2_ID + 1);  // id = EDIT_FID + 5

      // user ID entry field
      FillInConfig userFic = new FillInConfig();
      userFic.dataType = "character";
      userFic.format = "x(16)";
      userFic.column = 4;
      userFic.label = " User ID";
      editsd.addConfig(userFic, USER_ID);

      // set next widget position
      addSkip(editsd, 0, USER_ID + 1);  // id = EDIT_FID + 7

      // password entry field
      FillInConfig passFic = new FillInConfig();
      passFic.dataType = "character";
      passFic.format = "x(16)";
      passFic.column = 4;
      passFic.label = "Password";
      passFic.blank = true;  // this is a password; should not be visible.
      editsd.addConfig(passFic, PASSWORD_ID);

      // set next widget position
      addSkip(editsd, 1, PASSWORD_ID + 1);  // id = EDIT_FID + 9

      ButtonConfig bOk = new ButtonConfig();
      bOk.autoGo = true;
      bOk.column = 15;
      bOk.label = DLG_BTN_OK_TEXT;
      editsd.addConfig(bOk, BUTTON_OK_ID);

      ButtonConfig bCancel = new ButtonConfig();
      bCancel.autoEndKey = true;
      bCancel.column = 22;
      bCancel.label = DLG_BTN_CANCEL_TEXT;
      editsd.addConfig(bCancel, BUTTON_CANCEL_ID);

      addSkip(editsd, 1, BUTTON_CANCEL_ID + 1);  // id = EDIT_FID + 12

      // instantiate screen data
      client.pushScreenDefinition(new ScreenDefinition[] {editsd});

      editsb.putWidgetValue(MSG1_ID, new character(STD_MSG_1));
      editsb.putWidgetValue(MSG2_ID, new character(STD_MSG_2 + database));
   }
}