All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.continual.restHttp.ApiContextHelper Maven / Gradle / Ivy

There is a newer version: 0.2.54
Show newest version
/*
 *	Copyright 2021, Continual.io
 *
 *	Licensed under the Apache License, Version 2.0 (the "License");
 *	you may not use this file except in compliance with the License.
 *	You may obtain a copy of the License at
 *	
 *	http://www.apache.org/licenses/LICENSE-2.0
 *	
 *	Unless required by applicable law or agreed to in writing, software
 *	distributed under the License is distributed on an "AS IS" BASIS,
 *	WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *	See the License for the specific language governing permissions and
 *	limitations under the License.
 */

package io.continual.restHttp;

import java.io.IOException;
import java.util.LinkedList;

import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.continual.http.service.framework.context.CHttpRequestContext;
import io.continual.http.service.framework.context.CHttpResponse;
import io.continual.http.util.http.standards.HttpStatusCodes;
import io.continual.iam.IamAuthLog;
import io.continual.iam.IamServiceManager;
import io.continual.iam.access.AccessDb;
import io.continual.iam.access.Resource;
import io.continual.iam.credentials.ApiKeyCredential;
import io.continual.iam.credentials.JwtCredential;
import io.continual.iam.credentials.JwtCredential.InvalidJwtToken;
import io.continual.iam.credentials.UsernamePasswordCredential;
import io.continual.iam.exceptions.IamSvcException;
import io.continual.iam.identity.Identity;
import io.continual.iam.identity.UserContext;
import io.continual.iam.impl.common.ApiKeyAuthHelper;
import io.continual.iam.impl.common.BasicAuthHelper;
import io.continual.iam.impl.common.HeaderReader;
import io.continual.util.data.TypeConvertor;
import io.continual.util.data.json.CommentedJsonTokener;
import io.continual.util.data.json.JsonUtil;
import io.continual.util.nv.NvReadable;
import io.continual.util.standards.MimeTypes;

/**
 * Intended as a base or utility class for entry point implementations.
 * 
 * @param 
 */
public class ApiContextHelper
{
	public ApiContextHelper ()
	{
		this ( null );
	}
	
	public ApiContextHelper ( IamServiceManager accts )
	{
		fAccts = accts;
	}
	
	public static final String kSetting_ContinualProductTag = "apiKeyProductTag";
	public static final String kContinualProductTag = "continual";
	public static final String kContinualSystemsGroup = "continualSystems";

	public static final String kSetting_AuthLineHeader = "http.auth.header";
	public static final String kSetting_DateLineHeader = "http.date.header";
	public static final String kSetting_MagicLineHeader = "http.magic.header";

	public static final String kDefault_AuthLineHeader = "X-Continual-Auth";
	public static final String kDefault_DateLineHeader = "X-Continual-Date";
	public static final String kDefault_MagicLineHeader = "X-Continual-Magic";

	public interface ApiHandler
	{
		/**
		 * Handle the request as the given user and return a JSON string.
		 * 
		 * @param context the request context
		 * @param servlet the servlet
		 * @param uc the user context
		 * @throws IOException 
		 */
		void handle ( CHttpRequestContext context, HttpServlet servlet, UserContext uc ) throws IOException;
	}

	protected static void sendStatusCodeAndMessage ( CHttpRequestContext context, int statusCode, String msg )
	{
		sendJson ( context, statusCode,
			new JSONObject ()
				.put ( "statusCode", statusCode )
				.put ( "message",  msg )
		);
	}

	protected static void sendStatusOk ( CHttpRequestContext context, String msg )
	{
		sendJson ( context, HttpStatusCodes.k200_ok,
			new JSONObject ()
				.put ( "statusCode", HttpStatusCodes.k200_ok )
				.put ( "message",  msg )
		);
	}

	protected static void sendStatusOk ( CHttpRequestContext context, JSONObject msg )
	{
		sendJson ( context, HttpStatusCodes.k200_ok,
			JsonUtil.clone ( msg )
				.put ( "statusCode", HttpStatusCodes.k200_ok )
		);
	}

	protected static void sendStatusOkNoContent ( CHttpRequestContext context )
	{
		context.response ().setStatus ( HttpStatusCodes.k204_noContent );
	}

	protected static void sendNotAuth ( CHttpRequestContext context )
	{
		sendStatusCodeAndMessage ( context, HttpStatusCodes.k401_unauthorized, "Unauthorized. Check your API credentials." );
	}

	protected static void sendJson ( CHttpRequestContext context, JSONObject data )
	{
		sendJson ( context, HttpStatusCodes.k200_ok, data );
	}

	protected static void sendJson ( CHttpRequestContext context, int statusCode, JSONObject data )
	{
		// user can send a header for pretty printed JSON. otherwise we send it in dense form.
		final boolean pretty = TypeConvertor.convertToBooleanBroad ( context.request ().getFirstHeader ( "X-CioPrettyJson" ) );

		context.response ().sendErrorAndBody (
			statusCode,
			( pretty ? data.toString (4) : data.toString () ),
			MimeTypes.kAppJson
		);
	}

	public static class ResourceAccess
	{
		public ResourceAccess ( String resourceId, String operation ) { fResId=resourceId; fOp=operation; }

		public final String fResId;
		public final String fOp;
	}

	public void handleWithApiAuth ( CHttpRequestContext context, ApiHandler h )
	{
		handleWithApiAuth ( context, getInternalAccts(context), h );
	}

	public void handleWithApiAuthAndAccess ( CHttpRequestContext context, ApiHandler am, ResourceAccess... accessReqd )
	{
		handleWithApiAuthAndAccess ( context, getInternalAccts(context), am );
	}

	public static  void handleWithApiAuth ( CHttpRequestContext context, IamServiceManager am, ApiHandler h )
	{
		handleWithApiAuthAndAccess ( context, am, h );
	}

	public static  void handleWithApiAuthAndAccess ( CHttpRequestContext context, IamServiceManager am, ApiHandler h, ResourceAccess... accessReqd )
	{
		try
		{
			setupCorsHeaders ( context );

			final UserContext user = getUser ( am, context );
			if ( user == null )
			{
				sendNotAuth ( context );
				return;
			}
	
			// check for required access
			final String uid = user.getEffectiveUserId ();
			final AccessDb adb = am.getAccessDb ();
			for ( ResourceAccess ra : accessReqd )
			{
				if ( !adb.canUser ( uid, makeResource ( ra.fResId ), ra.fOp ) )
				{
					sendNotAuth ( context );
					return;
				}
			}

			// access allowed, call the handler
			h.handle ( context, (HttpServlet) context.getServlet (), user );
		}
		catch ( IOException e )
		{
			log.warn ( e.getMessage () );
			sendStatusCodeAndMessage ( context, HttpStatusCodes.k500_internalServerError, "I/O problem writing the response, but... you got it???" );
		}
		catch ( JSONException | IamSvcException e )
		{
			log.warn ( e.getMessage (), e );
			sendStatusCodeAndMessage ( context, HttpStatusCodes.k500_internalServerError, "There was a problem handling your API request." );
		}
	}

	/**
	 * Authenticate the calling user using the default accounts service (named "accounts") and return a UserContext.
	 * @param 
	 * @param context
	 * @return a UserContext or null if the user is not authenticated
	 * @throws IamSvcException
	 */
	public static  UserContext getUser ( final CHttpRequestContext context ) throws IamSvcException
	{
		return getUser ( getAccountsSvc ( context ), context );
	}

	/**
	 * Can the given user perform the given operation on the given resource?
	 * @param context
	 * @param user
	 * @param resId
	 * @param operation
	 * @return true if the user is permitted
	 */
	public boolean canUser ( CHttpRequestContext context, UserContext user, String resId, String operation ) throws IamSvcException
	{
		final boolean result = fAccts.getAccessDb ().canUser ( user.getEffectiveUserId (), makeResource ( resId ), operation );
		if ( !result )
		{
			log.info ( user.toString () + " cannot " + operation + " object " + resId );
		}
		return result;
	}

	public static Resource makeResource ( String named ) { return new Resource () { @Override public String getId () { return named; } }; }

	private interface Authenticator
	{
		I authenticate ( IamServiceManager am, CHttpRequestContext context ) throws IamSvcException;
	}

	private static class LocalHeaderReader implements HeaderReader
	{
		public LocalHeaderReader ( CHttpRequestContext context )
		{
			fContext = context;
		}
		@Override
		public String getFirstHeader ( String header )
		{
			return fContext.request ().getFirstHeader ( header );
		}
		private final CHttpRequestContext fContext;
	}

	private static class AuthList implements Authenticator
	{
		public AuthList ()
		{
			fAuthenticators = new LinkedList<>();

			// API key...
			fAuthenticators.add ( new Authenticator ()
			{
				@Override
				public I authenticate ( IamServiceManager am, final CHttpRequestContext context ) throws IamSvcException
				{
					final NvReadable ds = context.systemSettings ();
					final String systag = ds.getString ( kSetting_ContinualProductTag, kContinualProductTag );

					I authUser = null;
					final ApiKeyCredential creds = ApiKeyAuthHelper.readApiKeyCredential ( ds, new LocalHeaderReader ( context ), systag );
					if ( creds != null )
					{
						authUser = am.getIdentityDb ().authenticate ( creds );
						if ( authUser != null )
						{
							IamAuthLog.authenticationEvent ( authUser.getId (), "API Key", context.request ().getBestRemoteAddress () );
						}
					}

					return authUser;
				}
			} );

			// JWT...
			fAuthenticators.add ( new Authenticator ()
			{
				@Override
				public I authenticate ( IamServiceManager am, final CHttpRequestContext context ) throws IamSvcException
				{
					I authUser = null;
					try
					{
						JwtCredential cred = null;

						// we normally pick up the JWT token from the Auth/bearer header.
						final String authHeader = context.request ().getFirstHeader ( "Authorization" );
						if ( authHeader != null && authHeader.startsWith ( "Bearer " ) )
						{
							final String[] parts = authHeader.split ( " " );
							if ( parts.length == 2 )
							{
								cred = JwtCredential.fromHeader ( authHeader );
							}
						}
						
						// ... but we also support the token as a parameter to support some REST API
						// use cases, like background data loads
						if ( cred == null )
						{
							final String queryParam = context.request ().getParameter ( "jwt", null );
							if ( queryParam != null )
							{
								cred = new JwtCredential ( queryParam );
							}
						}

						if ( cred != null )
						{
							authUser = am.getIdentityDb ().authenticate ( cred );
							if ( authUser != null )
							{
								IamAuthLog.authenticationEvent ( authUser.getId (), "JWT", context.request ().getBestRemoteAddress () );
							}
						}
					}
					catch ( InvalidJwtToken e )
					{
						// ignore, can't authenticate this way
					}
					return authUser;
				}
			} );

			// username/password...
			fAuthenticators.add ( new Authenticator ()
			{
				@Override
				public I authenticate ( IamServiceManager am, final CHttpRequestContext context ) throws IamSvcException
				{
					final NvReadable ds = context.systemSettings ();

					I authUser = null;
					final UsernamePasswordCredential upc = BasicAuthHelper.readUsernamePasswordCredential ( ds, new LocalHeaderReader ( context ) );
					if ( upc != null )
					{
						authUser = am.getIdentityDb().authenticate ( upc );
						if ( authUser != null )
						{
							IamAuthLog.authenticationEvent ( authUser.getId (), "Username/Password", context.request ().getBestRemoteAddress () );
						}
					}
					return authUser;
				}
			} );
		}

		@Override
		public I authenticate ( IamServiceManager am, CHttpRequestContext context ) throws IamSvcException
		{
			for ( Authenticator inner : fAuthenticators )
			{
				I result = inner.authenticate ( am, context );
				if ( result != null ) return result;
			}
			return null;
		}

		private final LinkedList> fAuthenticators;
	}

	/**
	 * Authenticate the calling user and return a UserContext.
	 * @param 
	 * @param am
	 * @param context
	 * @return a UserContext or null if the user is not authenticated
	 * @throws IamSvcException
	 */
	public static  UserContext getUser ( IamServiceManager am, final CHttpRequestContext context ) throws IamSvcException
	{
		UserContext result = null;
		I authUser = null;
		try
		{
			// get this user authenticated
			authUser = new AuthList ().authenticate ( am, context );

			// if we have an authentic user, build a user context
			if ( authUser != null )
			{
				final String authFor = new LocalHeaderReader(context).getFirstHeader ( "X-AuthFor" );
				if ( authFor != null && authFor.length () > 0 && !authFor.equals ( authUser.getId () ) )
				{
					// authorized user is vouching for another...
					
					// get that user's identity
					final I authForUser = am.getIdentityManager ().loadUser( authFor );

					// if the user exists and this user is authorized...
					if ( authForUser != null && authUser.getGroup ( kContinualSystemsGroup ) != null )
					{
						// auth user is part of the special systems group
						result = new UserContext.Builder ()
							.forUser ( authForUser )
							.sponsoredByUser ( authUser )
							.build ()
						;
					}
					// else: this is a bogus request
				}
				else	// no auth-for or it's the same user
				{
					result = new UserContext.Builder ()
						.forUser ( authUser )
						.build ()
					;
				}
			}

			return result;
		}
		catch ( IamSvcException x )
		{
			log.warn ( "Error processing authentication: " + x.getMessage () );
			throw x;
		}
	}

	protected IamServiceManager getInternalAccts ( CHttpRequestContext context )
	{
		if ( fAccts != null ) return fAccts;
		return getAccountsSvc ( context );
	}
	
	/**
	 * The standard accounts service is expected to exist as "accounts"
	 * @param context
	 * @return an account service
	 */
	@SuppressWarnings("unchecked")
	protected static  IamServiceManager getAccountsSvc ( CHttpRequestContext context )
	{
		return HttpServlet.getServices ( context ).get ( "accounts", IamServiceManager.class );
	}

	protected static void setupCorsHeaders ( CHttpRequestContext context )
	{
		final CHttpResponse reply = context.response ();
		reply.writeHeader ( "Access-Control-Allow-Origin", "*");
		reply.writeHeader ( "Access-Control-Allow-Methods", "DELETE, GET, OPTIONS, PATCH, POST, PUT");
		reply.writeHeader ( "Access-Control-Max-Age", "3600");
		reply.writeHeader ( "Access-Control-Allow-Headers",
			"Content-Type, " +
			"Authorization, " +
			ApiContextHelper.kDefault_AuthLineHeader + ", " +
			ApiContextHelper.kDefault_DateLineHeader + ", " +
			ApiContextHelper.kDefault_MagicLineHeader );
	}

	protected static JSONObject readBody ( CHttpRequestContext context ) throws JSONException, IOException
	{
		return new JSONObject ( new CommentedJsonTokener ( context.request().getBodyStream () ) );
	}

	/**
	 * Read a JSON object body
	 * @param ctx
	 * @return a JSON object 
	 * @throws JSONException
	 * @throws IOException
	 */
	public static JSONObject readJsonBody ( CHttpRequestContext ctx ) throws JSONException, IOException
	{
		return readBody ( ctx );
	}

	public static class MissingInputException extends Exception
	{
		public MissingInputException ( String msg ) { super ( msg ); }
		private static final long serialVersionUID = 1L;
	}

	public static String readJsonString ( JSONObject from, String label ) throws MissingInputException
	{
		try
		{
			return from.getString ( label );
		}
		catch ( JSONException x )
		{
			throw new MissingInputException ( "Missing required field '" + label + "' in input." );
		}
	}
	
	public static JSONObject readJsonObject ( JSONObject from, String label ) throws MissingInputException
	{
		try
		{
			return from.getJSONObject ( label );
		}
		catch ( JSONException x )
		{
			throw new MissingInputException ( "Missing required field '" + label + "' in input." );
		}
	}

	private final IamServiceManager fAccts;

	private static final Logger log = LoggerFactory.getLogger ( ApiContextHelper.class );
}