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;
020
021import org.apache.logging.log4j.LogManager;
022import org.apache.logging.log4j.Logger;
023import org.apache.wiki.api.core.Engine;
024import org.apache.wiki.api.core.Session;
025import org.apache.wiki.api.exceptions.WikiException;
026import org.apache.wiki.api.spi.Wiki;
027import org.apache.wiki.auth.authorize.WebAuthorizer;
028import org.apache.wiki.auth.authorize.WebContainerAuthorizer;
029import org.apache.wiki.auth.login.AnonymousLoginModule;
030import org.apache.wiki.auth.login.CookieAssertionLoginModule;
031import org.apache.wiki.auth.login.CookieAuthenticationLoginModule;
032import org.apache.wiki.auth.login.UserDatabaseLoginModule;
033import org.apache.wiki.auth.login.WebContainerCallbackHandler;
034import org.apache.wiki.auth.login.WebContainerLoginModule;
035import org.apache.wiki.auth.login.WikiCallbackHandler;
036import org.apache.wiki.event.WikiEventListener;
037import org.apache.wiki.event.WikiEventManager;
038import org.apache.wiki.event.WikiSecurityEvent;
039import org.apache.wiki.util.TextUtil;
040import org.apache.wiki.util.TimedCounterList;
041
042import javax.security.auth.Subject;
043import javax.security.auth.callback.CallbackHandler;
044import javax.security.auth.login.LoginException;
045import javax.security.auth.spi.LoginModule;
046import javax.servlet.http.HttpServletRequest;
047import javax.servlet.http.HttpSession;
048import java.lang.reflect.InvocationTargetException;
049import java.security.Principal;
050import java.util.Collections;
051import java.util.HashMap;
052import java.util.HashSet;
053import java.util.Map;
054import java.util.Properties;
055import java.util.Set;
056
057
058/**
059 * Default implementation for {@link AuthenticationManager}
060 *
061 * {@inheritDoc}
062 * 
063 * @since 2.3
064 */
065public class DefaultAuthenticationManager implements AuthenticationManager {
066
067    /** How many milliseconds the logins are stored before they're cleaned away. */
068    private static final long LASTLOGINS_CLEANUP_TIME = 10 * 60 * 1_000L; // Ten minutes
069
070    private static final long MAX_LOGIN_DELAY = 20 * 1_000L; // 20 seconds
071
072    private static final Logger log = LogManager.getLogger( DefaultAuthenticationManager.class );
073
074    /** Empty Map passed to JAAS {@link #doJAASLogin(Class, CallbackHandler, Map)} method. */
075    protected static final Map< String, String > EMPTY_MAP = Collections.unmodifiableMap( new HashMap<>() );
076
077    /** Class (of type LoginModule) to use for custom authentication. */
078    protected Class< ? extends LoginModule > m_loginModuleClass = UserDatabaseLoginModule.class;
079
080    /** Options passed to {@link LoginModule#initialize(Subject, CallbackHandler, Map, Map)};
081     * initialized by {@link #initialize(Engine, Properties)}. */
082    protected final Map< String, String > m_loginModuleOptions = new HashMap<>();
083
084    /** The default {@link LoginModule} class name to use for custom authentication. */
085    private static final String DEFAULT_LOGIN_MODULE = "org.apache.wiki.auth.login.UserDatabaseLoginModule";
086
087    /** Empty principal set. */
088    private static final Set<Principal> NO_PRINCIPALS = new HashSet<>();
089
090    /** Static Boolean for lazily-initializing the "allows assertions" flag */
091    private boolean m_allowsCookieAssertions = true;
092
093    private boolean m_throttleLogins = true;
094
095    /** Static Boolean for lazily-initializing the "allows cookie authentication" flag */
096    private boolean m_allowsCookieAuthentication;
097
098    private Engine m_engine;
099
100    /** If true, logs the IP address of the editor */
101    private boolean m_storeIPAddress = true;
102
103    /** Keeps a list of the usernames who have attempted a login recently. */
104    private final TimedCounterList< String > m_lastLoginAttempts = new TimedCounterList<>();
105
106    /**
107     * {@inheritDoc}
108     */
109    @Override
110    public void initialize( final Engine engine, final Properties props ) throws WikiException {
111        m_engine = engine;
112        m_storeIPAddress = TextUtil.getBooleanProperty( props, PROP_STOREIPADDRESS, m_storeIPAddress );
113
114        // Should we allow cookies for assertions? (default: yes)
115        m_allowsCookieAssertions = TextUtil.getBooleanProperty( props, PROP_ALLOW_COOKIE_ASSERTIONS,true );
116
117        // Should we allow cookies for authentication? (default: no)
118        m_allowsCookieAuthentication = TextUtil.getBooleanProperty( props, PROP_ALLOW_COOKIE_AUTH, false );
119
120        // Should we throttle logins? (default: yes)
121        m_throttleLogins = TextUtil.getBooleanProperty( props, PROP_LOGIN_THROTTLING, true );
122
123        // Look up the LoginModule class
124        final String loginModuleClassName = TextUtil.getStringProperty( props, PROP_LOGIN_MODULE, DEFAULT_LOGIN_MODULE );
125        try {
126            m_loginModuleClass = ( Class< ? extends LoginModule > )Class.forName( loginModuleClassName );
127        } catch( final ClassNotFoundException e ) {
128            log.error( e.getMessage(), e );
129            throw new WikiException( "Could not instantiate LoginModule class.", e );
130        }
131
132        // Initialize the LoginModule options
133        initLoginModuleOptions( props );
134    }
135
136    /**
137     * {@inheritDoc}
138     */
139    @Override
140    public boolean isContainerAuthenticated() {
141        try {
142            final Authorizer authorizer = m_engine.getManager( AuthorizationManager.class ).getAuthorizer();
143            if ( authorizer instanceof WebContainerAuthorizer ) {
144                 return ( ( WebContainerAuthorizer )authorizer ).isContainerAuthorized();
145            }
146        } catch ( final WikiException e ) {
147            // It's probably ok to fail silently...
148        }
149        return false;
150    }
151
152    /**
153     * {@inheritDoc}
154     */
155    @Override
156    public boolean login( final HttpServletRequest request ) throws WikiSecurityException {
157        final HttpSession httpSession = request.getSession();
158        final Session session = SessionMonitor.getInstance( m_engine ).find( httpSession );
159        final AuthenticationManager authenticationMgr = m_engine.getManager( AuthenticationManager.class );
160        final AuthorizationManager authorizationMgr = m_engine.getManager( AuthorizationManager.class );
161        CallbackHandler handler = null;
162        final Map< String, String > options = EMPTY_MAP;
163
164        // If user not authenticated, check if container logged them in, or if there's an authentication cookie
165        if ( !session.isAuthenticated() ) {
166            // Create a callback handler
167            handler = new WebContainerCallbackHandler( m_engine, request );
168
169            // Execute the container login module, then (if that fails) the cookie auth module
170            Set< Principal > principals = authenticationMgr.doJAASLogin( WebContainerLoginModule.class, handler, options );
171            if ( principals.size() == 0 && authenticationMgr.allowsCookieAuthentication() ) {
172                principals = authenticationMgr.doJAASLogin( CookieAuthenticationLoginModule.class, handler, options );
173            }
174
175            // If the container logged the user in successfully, tell the Session (and add all of the Principals)
176            if ( principals.size() > 0 ) {
177                fireEvent( WikiSecurityEvent.LOGIN_AUTHENTICATED, getLoginPrincipal( principals ), session );
178                for( final Principal principal : principals ) {
179                    fireEvent( WikiSecurityEvent.PRINCIPAL_ADD, principal, session );
180                }
181
182                // Add all appropriate Authorizer roles
183                injectAuthorizerRoles( session, authorizationMgr.getAuthorizer(), request );
184            }
185        }
186
187        // If user still not authenticated, check if assertion cookie was supplied
188        if ( !session.isAuthenticated() && authenticationMgr.allowsCookieAssertions() ) {
189            // Execute the cookie assertion login module
190            final Set< Principal > principals = authenticationMgr.doJAASLogin( CookieAssertionLoginModule.class, handler, options );
191            if ( principals.size() > 0 ) {
192                fireEvent( WikiSecurityEvent.LOGIN_ASSERTED, getLoginPrincipal( principals ), session);
193            }
194        }
195
196        // If user still anonymous, use the remote address
197        if( session.isAnonymous() ) {
198            final Set< Principal > principals = authenticationMgr.doJAASLogin( AnonymousLoginModule.class, handler, options );
199            if( principals.size() > 0 ) {
200                fireEvent( WikiSecurityEvent.LOGIN_ANONYMOUS, getLoginPrincipal( principals ), session );
201                return true;
202            }
203        }
204
205        // If by some unusual turn of events the Anonymous login module doesn't work, login failed!
206        return false;
207    }
208
209    /**
210     * {@inheritDoc}
211     */
212    @Override
213    public boolean login( final Session session, final HttpServletRequest request, final String username, final String password ) throws WikiSecurityException {
214        if ( session == null ) {
215            log.error( "No wiki session provided, cannot log in." );
216            return false;
217        }
218
219        // Protect against brute-force password guessing if configured to do so
220        if ( m_throttleLogins ) {
221            delayLogin( username );
222        }
223
224        final CallbackHandler handler = new WikiCallbackHandler( m_engine, null, username, password );
225
226        // Execute the user's specified login module
227        final Set< Principal > principals = doJAASLogin( m_loginModuleClass, handler, m_loginModuleOptions );
228        if( principals.size() > 0 ) {
229            fireEvent(WikiSecurityEvent.LOGIN_AUTHENTICATED, getLoginPrincipal( principals ), session );
230            for ( final Principal principal : principals ) {
231                fireEvent( WikiSecurityEvent.PRINCIPAL_ADD, principal, session );
232            }
233
234            // Add all appropriate Authorizer roles
235            injectAuthorizerRoles( session, m_engine.getManager( AuthorizationManager.class ).getAuthorizer(), null );
236
237            return true;
238        }
239        return false;
240    }
241
242    /**
243     *  This method builds a database of login names that are being attempted, and will try to delay if there are too many requests coming
244     *  in for the same username.
245     *  <p>
246     *  The current algorithm uses 2^loginattempts as the delay in milliseconds, i.e. at 10 login attempts it'll add 1.024 seconds to the login.
247     *
248     *  @param username The username that is being logged in
249     */
250    private void delayLogin( final String username ) {
251        try {
252            m_lastLoginAttempts.cleanup( LASTLOGINS_CLEANUP_TIME );
253            final int count = m_lastLoginAttempts.count( username );
254
255            final long delay = Math.min( 1L << count, MAX_LOGIN_DELAY );
256            log.debug( "Sleeping for " + delay + " ms to allow login." );
257            Thread.sleep( delay );
258
259            m_lastLoginAttempts.add( username );
260        } catch( final InterruptedException e ) {
261            // FALLTHROUGH is fine
262        }
263    }
264
265    /**
266     * {@inheritDoc}
267     */
268    @Override
269    public void logout( final HttpServletRequest request ) {
270        if( request == null ) {
271            log.error( "No HTTP reqest provided; cannot log out." );
272            return;
273        }
274
275        final HttpSession session = request.getSession();
276        final String sid = ( session == null ) ? "(null)" : session.getId();
277        if( log.isDebugEnabled() ) {
278            log.debug( "Invalidating Session for session ID=" + sid );
279        }
280        // Retrieve the associated Session and clear the Principal set
281        final Session wikiSession = Wiki.session().find( m_engine, request );
282        final Principal originalPrincipal = wikiSession.getLoginPrincipal();
283        wikiSession.invalidate();
284
285        // Remove the wikiSession from the WikiSession cache
286        Wiki.session().remove( m_engine, request );
287
288        // We need to flush the HTTP session too
289        if( session != null ) {
290            session.invalidate();
291        }
292
293        // Log the event
294        fireEvent( WikiSecurityEvent.LOGOUT, originalPrincipal, null );
295    }
296
297    /**
298     * {@inheritDoc}
299     */
300    @Override
301    public boolean allowsCookieAssertions() {
302        return m_allowsCookieAssertions;
303    }
304
305    /**
306     * {@inheritDoc}
307     */
308    @Override
309    public boolean allowsCookieAuthentication() {
310        return m_allowsCookieAuthentication;
311    }
312
313    /**
314     * {@inheritDoc}
315     */
316    @Override
317    public Set< Principal > doJAASLogin( final Class< ? extends LoginModule > clazz,
318                                         final CallbackHandler handler,
319                                         final Map< String, String > options ) throws WikiSecurityException {
320        // Instantiate the login module
321        final LoginModule loginModule;
322        try {
323            loginModule = clazz.getDeclaredConstructor().newInstance();
324        } catch( final InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException e ) {
325            throw new WikiSecurityException( e.getMessage(), e );
326        }
327
328        // Initialize the LoginModule
329        final Subject subject = new Subject();
330        loginModule.initialize( subject, handler, EMPTY_MAP, options );
331
332        // Try to log in:
333        boolean loginSucceeded = false;
334        boolean commitSucceeded = false;
335        try {
336            loginSucceeded = loginModule.login();
337            if( loginSucceeded ) {
338                commitSucceeded = loginModule.commit();
339            }
340        } catch( final LoginException e ) {
341            // Login or commit failed! No principal for you!
342        }
343
344        // If we successfully logged in & committed, return all the principals
345        if( loginSucceeded && commitSucceeded ) {
346            return subject.getPrincipals();
347        }
348        return NO_PRINCIPALS;
349    }
350
351    // events processing .......................................................
352
353    /**
354     * {@inheritDoc}
355     */
356    @Override
357    public synchronized void addWikiEventListener( final WikiEventListener listener ) {
358        WikiEventManager.addWikiEventListener( this, listener );
359    }
360
361    /**
362     * {@inheritDoc}
363     */
364    @Override
365    public synchronized void removeWikiEventListener( final WikiEventListener listener ) {
366        WikiEventManager.removeWikiEventListener( this, listener );
367    }
368
369    /**
370     * Initializes the options Map supplied to the configured LoginModule every time it is invoked. The properties and values extracted from
371     * <code>jspwiki.properties</code> are of the form <code>jspwiki.loginModule.options.<var>param</var> = <var>value</var>, where
372     * <var>param</var> is the key name, and <var>value</var> is the value.
373     *
374     * @param props the properties used to initialize JSPWiki
375     * @throws IllegalArgumentException if any of the keys are duplicated
376     */
377    private void initLoginModuleOptions( final Properties props ) {
378        for( final Object key : props.keySet() ) {
379            final String propName = key.toString();
380            if( propName.startsWith( PREFIX_LOGIN_MODULE_OPTIONS ) ) {
381                // Extract the option name and value
382                final String optionKey = propName.substring( PREFIX_LOGIN_MODULE_OPTIONS.length() ).trim();
383                if( !optionKey.isEmpty() ) {
384                    final String optionValue = props.getProperty( propName );
385
386                    // Make sure the key is unique before stashing the key/value pair
387                    if ( m_loginModuleOptions.containsKey( optionKey ) ) {
388                        throw new IllegalArgumentException( "JAAS LoginModule key " + propName + " cannot be specified twice!" );
389                    }
390                    m_loginModuleOptions.put( optionKey, optionValue );
391                }
392            }
393        }
394    }
395
396    /**
397     * After successful login, this method is called to inject authorized role Principals into the Session. To determine which roles
398     * should be injected, the configured Authorizer is queried for the roles it knows about by calling  {@link Authorizer#getRoles()}.
399     * Then, each role returned by the authorizer is tested by calling {@link Authorizer#isUserInRole(Session, Principal)}. If this
400     * check fails, and the Authorizer is of type WebAuthorizer, the role is checked again by calling
401     * {@link WebAuthorizer#isUserInRole(HttpServletRequest, Principal)}). Any roles that pass the test are injected into the Subject by
402     * firing appropriate authentication events.
403     *
404     * @param session the user's current Session
405     * @param authorizer the Engine's configured Authorizer
406     * @param request the user's HTTP session, which may be <code>null</code>
407     */
408    private void injectAuthorizerRoles( final Session session, final Authorizer authorizer, final HttpServletRequest request ) {
409        // Test each role the authorizer knows about
410        for( final Principal role : authorizer.getRoles() ) {
411            // Test the Authorizer
412            if( authorizer.isUserInRole( session, role ) ) {
413                fireEvent( WikiSecurityEvent.PRINCIPAL_ADD, role, session );
414                if( log.isDebugEnabled() ) {
415                    log.debug( "Added authorizer role " + role.getName() + "." );
416                }
417            // If web authorizer, test the request.isInRole() method also
418            } else if ( request != null && authorizer instanceof WebAuthorizer ) {
419                final WebAuthorizer wa = ( WebAuthorizer )authorizer;
420                if ( wa.isUserInRole( request, role ) ) {
421                    fireEvent( WikiSecurityEvent.PRINCIPAL_ADD, role, session );
422                    if ( log.isDebugEnabled() ) {
423                        log.debug( "Added container role " + role.getName() + "." );
424                    }
425                }
426            }
427        }
428    }
429
430}