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 javax.servlet.http.Cookie;
033import javax.servlet.http.HttpServletRequest;
034import javax.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                        if( log.isDebugEnabled() ) {
126                            log.debug( "Logged in cookie authenticated name={}", username );
127                        }
128
129                        // If login succeeds, commit these principals/roles
130                        m_principals.add( new WikiPrincipal( username, WikiPrincipal.LOGIN_NAME ) );
131
132                        // Tag the file so that we know that it has been accessed recently.
133                        return cookieFile.setLastModified( System.currentTimeMillis() );
134
135                    } catch( final IOException e ) {
136                        return false;
137                    }
138                }
139            }
140        } catch( final IOException e ) {
141            final String message = "IO exception; disallowing login.";
142            log.error( message, e );
143            throw new LoginException( message );
144        } catch( final UnsupportedCallbackException e ) {
145            final String message = "Unable to handle callback; disallowing login.";
146            log.error( message, e );
147            throw new LoginException( message );
148        }
149        return false;
150    }
151
152    /**
153     * Attempts to locate the cookie file.
154     *
155     * @param engine Engine
156     * @param uid An unique ID fetched from the user cookie
157     * @return A File handle, or null, if there was a problem.
158     */
159    private static File getCookieFile( final Engine engine, final String uid ) {
160        final File cookieDir = new File( engine.getWorkDir(), COOKIE_DIR );
161        if( !cookieDir.exists() ) {
162            cookieDir.mkdirs();
163        }
164        if( !cookieDir.canRead() ) {
165            log.error( "Cannot read from cookie directory! {}", cookieDir.getAbsolutePath() );
166            return null;
167        }
168        if( !cookieDir.canWrite() ) {
169            log.error( "Cannot write to cookie directory! {}", cookieDir.getAbsolutePath() );
170            return null;
171        }
172
173        //  Scrub away old files
174        final long now = System.currentTimeMillis();
175        if( now > ( c_lastScrubTime + SCRUB_PERIOD ) ) {
176            scrub( TextUtil.getIntegerProperty( engine.getWikiProperties(), PROP_LOGIN_EXPIRY_DAYS, DEFAULT_EXPIRY_DAYS ), cookieDir );
177            c_lastScrubTime = now;
178        }
179
180        //  Find the cookie file
181        final File file = new File( cookieDir, uid );
182        try {
183            if( file.getCanonicalPath().startsWith( cookieDir.getCanonicalPath() ) ) {
184                return file;
185            }
186        } catch( final IOException e ) {
187            log.error( "Problem retrieving login cookie, returning null: {}", e.getMessage() );
188            return null;
189        }
190        return null;
191    }
192
193    /**
194     * Extracts the login cookie UID from the servlet request.
195     *
196     * @param request The HttpServletRequest
197     * @return The UID value from the cookie, or null, if no such cookie exists.
198     */
199    private static String getLoginCookie( final HttpServletRequest request ) {
200        return HttpUtil.retrieveCookieValue( request, LOGIN_COOKIE_NAME );
201    }
202
203    /**
204     * Sets a login cookie based on properties set by the user.  This method also
205     * creates the cookie uid-username mapping in the work directory.
206     *
207     * @param engine   The Engine
208     * @param response The HttpServletResponse
209     * @param username The username for whom to create the cookie.
210     */
211    public static void setLoginCookie( final Engine engine, final HttpServletResponse response, final String username ) {
212        final UUID uid = UUID.randomUUID();
213        final int days = TextUtil.getIntegerProperty( engine.getWikiProperties(), PROP_LOGIN_EXPIRY_DAYS, DEFAULT_EXPIRY_DAYS );
214        final Cookie userId = getLoginCookie( uid.toString() );
215        userId.setMaxAge( days * 24 * 60 * 60 );
216        response.addCookie( userId );
217
218        final File cf = getCookieFile( engine, uid.toString() );
219        if( cf != null ) {
220            //  Write the cookie content to the cookie store file.
221            try( final Writer out = new BufferedWriter( new OutputStreamWriter( Files.newOutputStream( cf.toPath() ), StandardCharsets.UTF_8 ) ) ) {
222                FileUtil.copyContents( new StringReader( username ), out );
223                if( log.isDebugEnabled() ) {
224                    log.debug( "Created login cookie for user {} for {} days", username, days );
225                }
226            } catch( final IOException ex ) {
227                log.error( "Unable to create cookie file to store user id: {}", uid );
228            }
229        }
230    }
231
232    /**
233     * Clears away the login cookie, and removes the uid-username mapping file as well.
234     *
235     * @param engine   Engine
236     * @param request  Servlet request
237     * @param response Servlet response
238     */
239    public static void clearLoginCookie( final Engine engine, final HttpServletRequest request, final HttpServletResponse response ) {
240        final Cookie userId = getLoginCookie( "" );
241        userId.setMaxAge( 0 );
242        response.addCookie( userId );
243        final String uid = getLoginCookie( request );
244        if( uid != null ) {
245            final File cf = getCookieFile( engine, uid );
246            if( cf != null ) {
247                if( !cf.delete() ) {
248                    log.debug( "Error deleting cookie login {}", uid );
249                }
250            }
251        }
252    }
253
254    /**
255     * Helper function to get secure LOGIN cookie
256     *
257     * @param value of the cookie
258     */
259    private static Cookie getLoginCookie( final String value ) {
260        final Cookie c = new Cookie( LOGIN_COOKIE_NAME, value );
261        c.setHttpOnly( true ); // no browser access
262        c.setSecure( true ); // only access via encrypted https allowed
263        return c;
264    }
265
266    /**
267     * Goes through the cookie directory and removes any obsolete files.
268     * The scrubbing takes place one day after the cookie was supposed to expire.
269     * However, if the user has logged in during the expiry period, the expiry is
270     * reset, and the cookie file left here.
271     *
272     * @param days number of days that the cookie will survive
273     * @param cookieDir cookie directory
274     */
275    private static synchronized void scrub( final int days, final File cookieDir ) {
276        log.debug( "Scrubbing cookieDir..." );
277        final File[] files = cookieDir.listFiles();
278        final long obsoleteDateLimit = System.currentTimeMillis() - ( ( long )days + 1 ) * 24 * 60 * 60 * 1000L;
279        int deleteCount = 0;
280
281        for( int i = 0; i < files.length; i++ ) {
282            final File f = files[ i ];
283            final long lastModified = f.lastModified();
284            if( lastModified < obsoleteDateLimit ) {
285                if( f.delete() ) {
286                    deleteCount++;
287                } else {
288                    log.debug( "Error deleting cookie login with index {}", i );
289                }
290            }
291        }
292
293        log.debug( "Removed {} obsolete cookie logins", deleteCount );
294    }
295
296}