    001/*
    002    Licensed to the Apache Software Foundation (ASF) under one
    003    or more contributor license agreements.  See the NOTICE file
    004    distributed with this work for additional information
    005    regarding copyright ownership.  The ASF licenses this file
    006    to you under the Apache License, Version 2.0 (the
    007    "License"); you may not use this file except in compliance
    008    with the License.  You may obtain a copy of the License at
    009
    010       http://www.apache.org/licenses/LICENSE-2.0
    011
    012    Unless required by applicable law or agreed to in writing,
    013    software distributed under the License is distributed on an
    014    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    015    KIND, either express or implied.  See the License for the
    016    specific language governing permissions and limitations
    017    under the License.
    018 */
    019package org.apache.wiki.auth.login;
    020
    021import org.apache.logging.log4j.LogManager;
    022import org.apache.logging.log4j.Logger;
    023import org.apache.wiki.api.core.Engine;
    024import org.apache.wiki.auth.WikiPrincipal;
    025import org.apache.wiki.util.FileUtil;
    026import org.apache.wiki.util.HttpUtil;
    027import org.apache.wiki.util.TextUtil;
    028
    029import javax.security.auth.callback.Callback;
    030import javax.security.auth.callback.UnsupportedCallbackException;
    031import javax.security.auth.login.LoginException;
    032import jakarta.servlet.http.Cookie;
    033import jakarta.servlet.http.HttpServletRequest;
    034import jakarta.servlet.http.HttpServletResponse;
    035import java.io.BufferedReader;
    036import java.io.BufferedWriter;
    037import java.io.File;
    038import java.io.IOException;
    039import java.io.InputStreamReader;
    040import java.io.OutputStreamWriter;
    041import java.io.Reader;
    042import java.io.StringReader;
    043import java.io.Writer;
    044import java.nio.charset.StandardCharsets;
    045import java.nio.file.Files;
    046import java.util.UUID;
    047
    048
    049/**
    050 * Logs in a user based on a cookie stored in the user's computer.  The cookie
    051 * information is stored in the <code>jspwiki.workDir</code>, under the directory
    052 * {@value #COOKIE_DIR}.  For security purposes it is a very, very good idea
    053 * to prevent access to this directory by everyone except the web server process;
    054 * otherwise people having read access to this directory may be able to spoof
    055 * other users.
    056 * <p>
    057 * The cookie directory is scrubbed of old entries at regular intervals.
    058 * <p>
    059 * This module must be used with a CallbackHandler (such as
    060 * {@link WebContainerCallbackHandler}) that supports the following Callback
    061 * types:
    062 * </p>
    063 *  <ol>
    064 *  <li>{@link HttpRequestCallback}- supplies the cookie, which should contain
    065 *      an unique id for fetching the UID.</li>
    066 *  <li>{@link WikiEngineCallback} - allows access to the Engine itself.
    067 *  </ol>
    068 * <p>
    069 * After authentication, a generic WikiPrincipal based on the username will be
    070 * created and associated with the Subject.
    071 * </p>
    072 * @see javax.security.auth.spi.LoginModule#commit()
    073 * @see CookieAssertionLoginModule
    074 * @since 2.5.62
    075 */
    076public class CookieAuthenticationLoginModule extends AbstractLoginModule {
    077
    078    private static final Logger LOG = LogManager.getLogger( CookieAuthenticationLoginModule.class );
    079    private static final String LOGIN_COOKIE_NAME = "JSPWikiUID";
    080
    081    /** The directory name under which the cookies are stored.  The value is {@value}. */
    082    protected static final String COOKIE_DIR = "logincookies";
    083
    084    /**
    085     * User property for setting how long the cookie is stored on the user's computer.
    086     * The value is {@value}.  The default expiry time is 14 days.
    087     */
    088    public static final String PROP_LOGIN_EXPIRY_DAYS = "jspwiki.cookieAuthentication.expiry";
    089
    090    /**
    091     * Built-in value for storing the cookie.
    092     */
    093    private static final int DEFAULT_EXPIRY_DAYS = 14;
    094
    095    private static long c_lastScrubTime;
    096
    097    /**
    098     * Describes how often we scrub the cookieDir directory.
    099     */
    100    private static final long SCRUB_PERIOD = 60 * 60 * 1_000L; // In milliseconds
    101
    102    /**
    103     * {@inheritDoc}
    104     *
    105     * @see javax.security.auth.spi.LoginModule#login()
    106     */
    107    @Override
    108    public boolean login() throws LoginException {
    109        // Otherwise, let's go and look for the cookie!
    110        final HttpRequestCallback hcb = new HttpRequestCallback();
    111        final WikiEngineCallback wcb = new WikiEngineCallback();
    112        final Callback[] callbacks = new Callback[] { hcb, wcb };
    113
    114        try {
    115            m_handler.handle( callbacks );
    116            final HttpServletRequest request = hcb.getRequest();
    117            final String uid = getLoginCookie( request );
    118
    119            if( uid != null ) {
    120                final Engine engine = wcb.getEngine();
    121                final File cookieFile = getCookieFile( engine, uid );
    122                if( cookieFile != null && cookieFile.exists() && cookieFile.canRead() ) {
    123                    try( final Reader in = new BufferedReader( new InputStreamReader( Files.newInputStream( cookieFile.toPath() ), StandardCharsets.UTF_8 ) ) ) {
    124                        final String username = FileUtil.readContents( in );
    125                        LOG.debug( "Logged in cookie authenticated name={}", username );
    126
    127                        // If login succeeds, commit these principals/roles
    128                        m_principals.add( new WikiPrincipal( username, WikiPrincipal.LOGIN_NAME ) );
    129
    130                        // Tag the file so that we know that it has been accessed recently.
    131                        return cookieFile.setLastModified( System.currentTimeMillis() );
    132
    133                    } catch( final IOException e ) {
    134                        LOG.debug(e.getMessage(), e);
    135                        return false;
    136                    }
    137                }
    138            }
    139        } catch( final IOException e ) {
    140            final String message = "IO exception; disallowing login.";
    141            LOG.error( message, e );
    142            throw new LoginException( message );
    143        } catch( final UnsupportedCallbackException e ) {
    144            final String message = "Unable to handle callback; disallowing login.";
    145            LOG.error( message, e );
    146            throw new LoginException( message );
    147        }
    148        return false;
    149    }
    150
    151    /**
    152     * Attempts to locate the cookie file.
    153     *
    154     * @param engine Engine
    155     * @param uid An unique ID fetched from the user cookie
    156     * @return A File handle, or null, if there was a problem.
    157     */
    158    private static File getCookieFile( final Engine engine, final String uid ) {
    159        final File cookieDir = new File( engine.getWorkDir(), COOKIE_DIR );
    160        if( !cookieDir.exists() ) {
    161            cookieDir.mkdirs();
    162        }
    163        if( !cookieDir.canRead() ) {
    164            LOG.error( "Cannot read from cookie directory! {}", cookieDir.getAbsolutePath() );
    165            return null;
    166        }
    167        if( !cookieDir.canWrite() ) {
    168            LOG.error( "Cannot write to cookie directory! {}", cookieDir.getAbsolutePath() );
    169            return null;
    170        }
    171
    172        //  Scrub away old files
    173        final long now = System.currentTimeMillis();
    174        if( now > ( c_lastScrubTime + SCRUB_PERIOD ) ) {
    175            scrub( TextUtil.getIntegerProperty( engine.getWikiProperties(), PROP_LOGIN_EXPIRY_DAYS, DEFAULT_EXPIRY_DAYS ), cookieDir );
    176            c_lastScrubTime = now;
    177        }
    178
    179        //  Find the cookie file
    180        final File file = new File( cookieDir, uid );
    181        try {
    182            if( file.getCanonicalPath().startsWith( cookieDir.getCanonicalPath() ) ) {
    183                return file;
    184            }
    185        } catch( final IOException e ) {
    186            LOG.error( "Problem retrieving login cookie, returning null: {}", e.getMessage() );
    187            return null;
    188        }
    189        return null;
    190    }
    191
    192    /**
    193     * Extracts the login cookie UID from the servlet request.
    194     *
    195     * @param request The HttpServletRequest
    196     * @return The UID value from the cookie, or null, if no such cookie exists.
    197     */
    198    private static String getLoginCookie( final HttpServletRequest request ) {
    199        return HttpUtil.retrieveCookieValue( request, LOGIN_COOKIE_NAME );
    200    }
    201
    202    /**
    203     * Sets a login cookie based on properties set by the user.  This method also
    204     * creates the cookie uid-username mapping in the work directory.
    205     *
    206     * @param engine   The Engine
    207     * @param response The HttpServletResponse
    208     * @param username The username for whom to create the cookie.
    209     */
    210    public static void setLoginCookie( final Engine engine, final HttpServletResponse response, final String username ) {
    211        final UUID uid = UUID.randomUUID();
    212        final int days = TextUtil.getIntegerProperty( engine.getWikiProperties(), PROP_LOGIN_EXPIRY_DAYS, DEFAULT_EXPIRY_DAYS );
    213        final Cookie userId = getLoginCookie( uid.toString() );
    214        userId.setMaxAge( days * 24 * 60 * 60 );
    215        response.addCookie( userId );
    216
    217        final File cf = getCookieFile( engine, uid.toString() );
    218        if( cf != null ) {
    219            //  Write the cookie content to the cookie store file.
    220            try( final Writer out = new BufferedWriter( new OutputStreamWriter( Files.newOutputStream( cf.toPath() ), StandardCharsets.UTF_8 ) ) ) {
    221                FileUtil.copyContents( new StringReader( username ), out );
    222                LOG.debug( "Created login cookie for user {} for {} days", username, days );
    223            } catch( final IOException ex ) {
    224                LOG.error( "Unable to create cookie file to store user id: {}", uid );
    225            }
    226        }
    227    }
    228
    229    /**
    230     * Clears away the login cookie, and removes the uid-username mapping file as well.
    231     *
    232     * @param engine   Engine
    233     * @param request  Servlet request
    234     * @param response Servlet response
    235     */
    236    public static void clearLoginCookie( final Engine engine, final HttpServletRequest request, final HttpServletResponse response ) {
    237        final Cookie userId = getLoginCookie( "" );
    238        userId.setMaxAge( 0 );
    239        response.addCookie( userId );
    240        final String uid = getLoginCookie( request );
    241        if( uid != null ) {
    242            final File cf = getCookieFile( engine, uid );
    243            if( cf != null ) {
    244                if( !cf.delete() ) {
    245                    LOG.debug( "Error deleting cookie login {}", uid );
    246                }
    247            }
    248        }
    249    }
    250
    251    /**
    252     * Helper function to get secure LOGIN cookie
    253     *
    254     * @param value of the cookie
    255     */
    256    private static Cookie getLoginCookie( final String value ) {
    257        final Cookie c = new Cookie( LOGIN_COOKIE_NAME, value );
    258        c.setHttpOnly( true ); // no browser access
    259        c.setSecure( true ); // only access via encrypted https allowed
    260        return c;
    261    }
    262
    263    /**
    264     * Goes through the cookie directory and removes any obsolete files.
    265     * The scrubbing takes place one day after the cookie was supposed to expire.
    266     * However, if the user has logged in during the expiry period, the expiry is
    267     * reset, and the cookie file left here.
    268     *
    269     * @param days number of days that the cookie will survive
    270     * @param cookieDir cookie directory
    271     */
    272    private static synchronized void scrub( final int days, final File cookieDir ) {
    273        LOG.debug( "Scrubbing cookieDir..." );
    274        final File[] files = cookieDir.listFiles();
    275        final long obsoleteDateLimit = System.currentTimeMillis() - ( ( long )days + 1 ) * 24 * 60 * 60 * 1000L;
    276        int deleteCount = 0;
    277
    278        for( int i = 0; i < files.length; i++ ) {
    279            final File f = files[ i ];
    280            final long lastModified = f.lastModified();
    281            if( lastModified < obsoleteDateLimit ) {
    282                if( f.delete() ) {
    283                    deleteCount++;
    284                } else {
    285                    LOG.debug( "Error deleting cookie login with index {}", i );
    286                }
    287            }
    288        }
    289
    290        LOG.debug( "Removed {} obsolete cookie logins", deleteCount );
    291    }
    292
    293}