uk.ac.ed.epcc.webapp.servlet.DefaultServletService.java Source code

Java tutorial

Introduction

Here is the source code for uk.ac.ed.epcc.webapp.servlet.DefaultServletService.java

Source

//| Copyright - The University of Edinburgh 2015                            |
//|                                                                         |
//| 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 uk.ac.ed.epcc.webapp.servlet;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.Principal;
import java.security.cert.X509Certificate;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.LinkedList;
import java.util.Map;
import java.util.StringTokenizer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.apache.commons.codec.binary.Base64;

import uk.ac.ed.epcc.webapp.AppContext;
import uk.ac.ed.epcc.webapp.Feature;
import uk.ac.ed.epcc.webapp.jdbc.exception.DataException;
import uk.ac.ed.epcc.webapp.logging.Logger;
import uk.ac.ed.epcc.webapp.logging.LoggerService;
import uk.ac.ed.epcc.webapp.session.AppUser;
import uk.ac.ed.epcc.webapp.session.AppUserFactory;
import uk.ac.ed.epcc.webapp.session.AppUserNameFinder;
import uk.ac.ed.epcc.webapp.session.PasswordAuthComposite;
import uk.ac.ed.epcc.webapp.session.SessionService;
import uk.ac.ed.epcc.webapp.session.WebNameFinder;

/** A default implementation for {@link ServletService} for a HTTP servlet application.
 * 
 * This class implements the following strategy for extracting a user from a HTTP request
 * 
 * If the the feature {@link DefaultServletService#EXTERNAL_AUTH_ONLY_FEATURE} is defined then
 * web-names will be used to resolve the current user for all URLs (even if not true then a explicit authentication servlet can
 * set session in this way, which is the normal mechanism if external and password authentication is needed). web-names can be taken from the REMOTE_USER or 
 * user certificate DN if present.
 * <p>
 * If the parameter <em>basic_auth.realm</em> is defined then missing authentication will
 * trigger HTTP Basic auth with the specified realm  (obviously the login factory needs to contain a {@link PasswordAuthComposite} for this to succeed.
 <p>
 * Note that as this parameter can be set for specific servlets via serlvet init-parameters. 
 * This can be used to replace the custom login page entirely if set globally but
 * should probably not be done unless this is the only authentication mechanism. 
 * On the other hand it is a good choice for servlets that take form posts
 * and urls where the credentials should be remembered by the browser 
 * <p>
 * Failing the above authentication failure will redirect to the login page.
 * @author spb
 *
 */
public class DefaultServletService implements ServletService {
    /**
     * 
     */
    private static final String LOGOUT_REMOVE_COOKIE_PREFIX = "logout.remove_cookie.";
    public static final String BASIC_AUTH_REALM_PARAM = "basic_auth.realm";
    public static final Feature NEED_CERTIFICATE_FEATURE = new Feature("need-certificate", true,
            "try additional mechanisms to retrieve certificate DN as web-name");
    public static final String PARAMS_KEY_NAME = "Params";
    public static final String ARG_TERRMINATOR = "-";

    Pattern auth_patt = Pattern.compile("\\s*Basic\\s+(\\S*)");

    protected final AppContext conn;
    private final ServletContext ctx;
    private final ServletRequest req; // cached request may be null
    private final ServletResponse res;
    private String web_path = "";
    private int max_forward = 10; // maximum number of calls to forward per request
    /** Feature to allow external auth logins if a webname is present. As this applies to all locations
     * only a single default realm can be used.
     * 
     */
    public static final Feature ALLOW_EXTERNAL_AUTH_FEATURE = new Feature("allow_external_auth", false,
            "container level authorisation can be used on any url if a web-name is present");
    /** Feature to require external auth logins for all sessions.
     * 
     */
    public static final Feature EXTERNAL_AUTH_ONLY_FEATURE = new Feature("external_auth", false,
            "Default authorisation is using external container level authorisation on all URLs");

    public static final Feature EXTERNAL_AUTH_VIA_lOGIN_FEATURE = new Feature("external_auth.use_login", false,
            "Mandatory external auth with only login.jsp protected externally");

    public DefaultServletService(AppContext conn, ServletContext ctx, ServletRequest req, ServletResponse res) {
        super();
        this.conn = conn;
        this.ctx = ctx;
        this.req = req;
        this.res = res;
        // check the expected charset of parameters etc.
        String ce = req.getCharacterEncoding();
        if (ce == null || ce.trim().length() == 0) {
            String default_charset = defaultCharset();
            if (default_charset != null) {
                try {
                    req.setCharacterEncoding(default_charset);
                } catch (UnsupportedEncodingException e) {
                    error(e, "Problem with default charset");
                }
            }
        }
        if (req instanceof HttpServletRequest) {
            web_path = ((HttpServletRequest) req).getContextPath();
        }
    }

    public String defaultCharset() {
        return conn.getInitParameter("request.default_charset");
    }

    public void cleanup() {

    }

    public Class<? super ServletService> getType() {
        return ServletService.class;
    }

    /**
     * encode the page request as a string for logging purposes
     * 
     * @param req
     * @return String
     */
    String encodePage(HttpServletRequest req) {
        String res, tmp;

        res = req.getServletPath();
        tmp = req.getPathInfo();
        if (tmp != null) {
            res = res + "/" + tmp;
        }
        tmp = req.getQueryString();
        if (tmp != null) {
            res = res + "?" + tmp;
        }
        return res;

    }

    /** request page when ServletAppContext was created. 
     * This uses a cached request object because the request URL will be
     * re-written if the request is forwarded to a different servlet/jsp.
     * 
     * @return String URL
     */
    public String encodePage() {
        if (req != null && req instanceof HttpServletRequest) {
            return encodePage((HttpServletRequest) req);
        }
        return "";
    }

    /** Add the context path to a url path
     * 
     * @param url
     * @return String
     */
    public String encodeURL(String url) {
        if (res != null && res instanceof HttpServletResponse) {
            return ((HttpServletResponse) res).encodeURL(web_path + url);
        } else {
            return web_path + url;
        }
    }

    /**
     * Forward request to a different page.
     * 
     * @param url
     *            String page to forward to
     * @throws ServletException
     * @throws IOException
     */
    public void forward(String url) throws ServletException, IOException {
        conn.getService(LoggerService.class).getLogger(getClass()).debug("forwarding to " + url);
        if (max_forward-- == 0) {
            error("too many calls to forward url=" + url);
            if (res instanceof HttpServletResponse) {
                ((HttpServletResponse) res).sendError(HttpServletResponse.SC_BAD_REQUEST);
            }
            return;
        }
        if (Pattern.matches("^/[a-zA-Z/_-]*(?:\\.[a-zA-Z]+)?$", url)) {
            ctx.getRequestDispatcher(url).forward(req, res);
            return;
        } else {
            error("Badly formed url in forward url=" + url);
            if (res instanceof HttpServletResponse) {
                ((HttpServletResponse) res).sendError(HttpServletResponse.SC_BAD_REQUEST);
            }
            return;
        }

    }

    public void redirect(String url) throws IOException {
        conn.getService(LoggerService.class).getLogger(getClass()).debug("redirect to " + url);
        if (req instanceof HttpServletRequest && res instanceof HttpServletResponse) {
            ((HttpServletResponse) res).sendRedirect(((HttpServletResponse) res)
                    .encodeRedirectURL(((HttpServletRequest) req).getContextPath() + url));
        }
    }

    public void message(String message, Object... args) throws IOException, ServletException {
        if (req instanceof HttpServletRequest && res instanceof HttpServletResponse) {
            WebappServlet.messageWithArgs(getContext(), (HttpServletRequest) req, (HttpServletResponse) res,
                    message, args);
        }
    }

    /**
     * Extract a Map of the request parameters from the request. This is made a
     * method on the ServletAppContext to allow access to logging/configuration
     * and to allow the application specific customisation.
     * 
     * @return Map of parameters
     */
    @SuppressWarnings("unchecked")
    public Map<String, Object> getParams() {
        Map<String, Object> h = null;
        // look for cached value
        h = (Map<String, Object>) req.getAttribute(PARAMS_KEY_NAME);
        if (h == null) {
            h = makeParams((HttpServletRequest) req);

            req.setAttribute(PARAMS_KEY_NAME, h);
        }
        return h;
        // Caching causes problems when adding params on a page forward
        //
        //return makeParams(req);
    }

    /** Get the ServletPath as a list of strings
     * A path element of "-" terminates the list. 
     * This is to allow parameters to be passed on the 
     * path at the same time as an arguemnt path.
     * @return LinkedList<String> arguments in order.
     */
    public LinkedList<String> getArgs() {
        LinkedList<String> h = new LinkedList<String>();
        if (req instanceof HttpServletRequest) {
            String path = ((HttpServletRequest) req).getPathInfo();
            if (path != null) {
                StringTokenizer st = new StringTokenizer(path, "/", false);
                while (st.hasMoreTokens()) {
                    String val = st.nextToken();
                    if (val.equals(ARG_TERRMINATOR)) {
                        return h;
                    }
                    h.add(val);
                }
            }
        }
        return h;
    }

    /**
     * get the authenticated name for the current user as provided by the
     * web-server/container authorisation layer. This will be null unless the
     * container/web-server has authorisation turned on for this URL.
     * 
     * @return String webname
     */
    public String getWebName() {
        String name = null;
        // If the web.xml defines a certificate based security-constraint then the
        // certificate info will be available from getUserPrincipal
        Principal p = null;
        if (req instanceof HttpServletRequest) {
            p = ((HttpServletRequest) req).getUserPrincipal();
        }
        if (p == null && NEED_CERTIFICATE_FEATURE.isEnabled(conn) && req.isSecure()) {
            // If clientAuth is turned on in the tomcat connector a certificate will always
            // be requested even without a security constraint and this can be obtained as follows
            Object certificateChainAtt = req.getAttribute("javax.servlet.request.X509Certificate");
            if (certificateChainAtt != null) {

                X509Certificate[] certificateChain = (X509Certificate[]) certificateChainAtt;
                if (certificateChain != null && certificateChain.length > 0) {
                    p = certificateChain[0].getSubjectX500Principal();
                }
            }
        }
        if (p != null) {
            name = p.getName();
        } else {
            // probably won't work if getUserPrincipal does not but might as well try
            if (req instanceof HttpServletRequest) {
                name = ((HttpServletRequest) req).getRemoteUser();
            }
        }
        if (name != null) {
            return name.trim();
        }
        return null;
    }

    /**
     * create a Map of request parameters from scratch. Application specific sub-classes can
     * override this to customise the behaviour. For example to parse MultiPart
     * forms in order to support file upload.
     * 
     * @param req
     * @return Map of request parameters
     */
    public Map<String, Object> makeParams(HttpServletRequest req) {
        Hashtable<String, Object> h = new Hashtable<String, Object>();

        String path = req.getPathInfo();
        if (path != null) {
            StringTokenizer st = new StringTokenizer(path, "/", false);
            for (int i = 0; st.hasMoreTokens(); i++) {
                String val = st.nextToken();
                String key, data;
                int id = val.indexOf("=");
                if (id == -1) {
                    key = "$" + i;
                    data = val;
                } else {
                    key = val.substring(0, id).trim();
                    data = val.substring(id + 1).trim();
                }
                h.put(key, data);
            }
        }
        // h.putAll(req.getParameterMap());

        // normal request parameters are higher precidence than those passed on the path
        for (Enumeration e = req.getParameterNames(); e.hasMoreElements();) {
            String key = (String) e.nextElement();

            String[] values = req.getParameterValues(key);
            if (values.length == 1) {
                h.put(key, values[0]);
            } else {
                // Consider parameters with multiple values and take the first non empty one
                // ignore pure whitespace too. This is really only used by the fall-back
                // for auto-complete inputs. If the UA respects required but does not
                // understand datalist of the JS fixup then a space can be added to the text box.
                for (String data : values) {
                    if (data != null && !data.trim().isEmpty()) {
                        h.put(key, data);
                        break;
                    }
                }
            }
        }
        return h;
    }

    public HttpSession getSession() {
        // By default don't create a session unless it already exists
        // This is to prevent a cookie being created unless the user has
        // actively logged in.
        // Note that unless you set session=false in a jsp page directive
        // jsp pages will create a session
        return getSession(false);
    }

    public HttpSession getSession(boolean make_session) {
        if (req instanceof HttpServletRequest) {
            return ((HttpServletRequest) req).getSession(make_session);
        } else {
            return null;
        }
    }

    public HttpServletRequest getRequest() {
        if (req instanceof HttpServletRequest) {
            return ((HttpServletRequest) req);
        } else {
            return null;
        }
    }

    /** Is the specified mime-type supported by the client.
     * 
     * @param type
     * @return boolean true if mime type supported
     */
    public boolean supportsMime(String type) {
        if (req instanceof HttpServletRequest) {
            String accept = ((HttpServletRequest) req).getHeader("Accept");
            if (accept != null) {
                return accept.toLowerCase().contains(type);
            }
        }
        return false;
    }

    public AppContext getContext() {
        return conn;
    }

    /* (non-Javadoc)
     * @see uk.ac.ed.epcc.webapp.servlet.ServletService#requestAuthorization()
     */
    public <A extends AppUser> void requestAuthentication(SessionService<A> sess)
            throws IOException, ServletException {
        AppUserFactory<A> factory = sess.getLoginFactory();
        @SuppressWarnings("unchecked")
        PasswordAuthComposite<A> composite = (PasswordAuthComposite<A>) factory
                .getComposite(PasswordAuthComposite.class);
        if (composite != null) {
            // Should we attempt basic auth
            String realm = conn.getInitParameter(BASIC_AUTH_REALM_PARAM);
            if (realm != null && realm.trim().length() > 0 && res instanceof HttpServletResponse) {
                ((HttpServletResponse) res).setHeader("WWW-Authenticate", "Basic realm=\"" + realm + "\"");
                ((HttpServletResponse) res).sendError(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
        }
        if (getWebName() == null && (!EXTERNAL_AUTH_VIA_lOGIN_FEATURE.isEnabled(conn))
                && (composite == null || EXTERNAL_AUTH_ONLY_FEATURE.isEnabled(getContext()))) {
            // We require external auth so must have web-name
            warn("No webname when required");
            message("access_denied", (Object[]) null);
        } else {
            // standard login page supports both custom password login and self-register for external-auth
            String login_page = LoginServlet.getLoginPage(conn);
            redirect(login_page + "?error=session&page=" + encodePage());
        }
    }

    /* (non-Javadoc)
     * @see uk.ac.ed.epcc.webapp.servlet.ServletService#populateSession(uk.ac.ed.epcc.webapp.session.SessionService)
     */
    public <A extends AppUser> void populateSession(SessionService<A> sess) {
        try {
            if (res.isCommitted()) {
                // Can't make a servlet session 
                return;
            }
            AppContext conn = getContext();
            String name = getWebName();
            if (name != null && (DefaultServletService.ALLOW_EXTERNAL_AUTH_FEATURE.isEnabled(conn)
                    || DefaultServletService.EXTERNAL_AUTH_ONLY_FEATURE.isEnabled(conn))) {
                // If there is a web-name we don't consider password login
                AppUserFactory<A> factory = sess.getLoginFactory();
                String remote_auth_realm = conn.getInitParameter(RemoteAuthServlet.REMOTE_AUTH_REALM_PROP,
                        WebNameFinder.WEB_NAME);
                AppUserNameFinder<A, ?> finder = factory.getRealmFinder(remote_auth_realm);

                if (finder != null) {

                    A person = finder.findFromString(name);
                    if (person == null) {
                        // See if we should auto-create users.
                        if (factory.autoCreate()) {
                            person = factory.makeUser();
                            if (person != null) {
                                finder.setName(person, name);
                                person.commit();
                            }
                        }
                    }
                    if (person != null) {
                        if (person.canLogin()) {

                            if (factory.mustRegister(person)) {
                                // don't populate session this will trigger redirect to
                                // the registration page
                                return;
                            }
                            sess.setCurrentPerson(person);
                        }
                    }

                }
            } else if (DefaultServletService.EXTERNAL_AUTH_ONLY_FEATURE.isEnabled(conn)) {
            } else {
                // only consider basic-auth if no webname found
                // Note we pick up the top level config service so that per-servlet parameters
                // are available
                String realm = conn.getInitParameter(BASIC_AUTH_REALM_PARAM);
                HttpServletRequest request = getRequest();
                if (request != null && realm != null && realm.trim().length() > 0
                        && !DefaultServletService.EXTERNAL_AUTH_ONLY_FEATURE.isEnabled(conn)) {

                    String auth = request.getHeader("Authorization");
                    AppUserFactory<A> factory = sess.getLoginFactory();
                    @SuppressWarnings("unchecked")
                    PasswordAuthComposite<A> comp = factory.getComposite(PasswordAuthComposite.class);
                    if (auth != null && comp != null) {
                        Matcher m = auth_patt.matcher(auth);
                        if (m.matches()) {
                            String base64 = m.group(1);
                            String userpass = decode(base64);
                            int pos = userpass.indexOf(":");
                            if (pos > 0) {
                                String user = userpass.substring(0, pos);
                                String pass = userpass.substring(pos + 1);
                                try {

                                    A person = (A) comp.findByLoginNamePassword(user, pass);
                                    if (person != null && person.canLogin()) {
                                        sess.setCurrentPerson(person);
                                    }
                                } catch (DataException e) {
                                    error(e, "Error looking up person");
                                }
                            }
                        }
                    }
                }
            }
        } catch (Exception e) {
            error(e, "Error populating sesion from request");
        }
    }

    /**invalidate the servlet session and optionally remove the session cookie.
     *
     * 
     * 
     * @param remove_cookie should cookie be removed
     * 
     */
    public void logout(boolean remove_cookie) {
        HttpSession sess = getSession();
        if (sess != null) {
            sess.invalidate();
        }
        if (remove_cookie) {
            HttpServletRequest request = getRequest();
            if (request != null) {
                Cookie[] cookies = request.getCookies();
                if (cookies != null && cookies.length > 0) {
                    for (Cookie c : cookies) {
                        if (c.getName().equalsIgnoreCase("JSESSIONID") || getContext()
                                .getBooleanParameter(LOGOUT_REMOVE_COOKIE_PREFIX + c.getName(), false)) {
                            Cookie c2 = (Cookie) c.clone();
                            c2.setMaxAge(0); // This should request a delete
                            if (c2.getPath() == null) {
                                String contextPath = request.getContextPath();
                                c2.setPath(contextPath + "/"); // browser did not include path. This will only work if path matched exactly
                            }
                            c2.setValue("");
                            ((HttpServletResponse) res).addCookie(c2);
                        }
                    }
                }
            }
        }
    }

    public void addCookie(Cookie c) {
        if (!isComitted()) {
            ((HttpServletResponse) res).addCookie(c);
        }
    }

    protected static String decode(String base64) {
        return new String(Base64.decodeBase64(base64));
    }

    /**
     * Report an application error.
     * Needs to handle the possiblity of the LoggerService not being present as
     * we can't make it a pre-requisite here
     * 
     * @param errors
     *            Text of error.
     */

    final void error(String errors) {
        LoggerService serv = getContext().getService(LoggerService.class);
        if (serv != null) {
            Logger log = serv.getLogger(getClass());
            if (log != null) {
                log.error(errors);
            }
        }
    }

    final void warn(String errors) {
        LoggerService serv = getContext().getService(LoggerService.class);
        if (serv != null) {
            Logger log = serv.getLogger(getClass());
            if (log != null) {
                log.warn(errors);
            }
        }
    }

    final void error(Throwable t, String errors) {
        LoggerService serv = getContext().getService(LoggerService.class);
        if (serv != null) {
            Logger log = serv.getLogger(getClass());
            if (log != null) {
                log.error(errors, t);
            }
        }
    }

    /* (non-Javadoc)
     * @see uk.ac.ed.epcc.webapp.servlet.ServletService#isComitted()
     */
    @Override
    public boolean isComitted() {
        return res.isCommitted();
    }
}