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.ClassUtil;
040import org.apache.wiki.util.TextUtil;
041import org.apache.wiki.util.TimedCounterList;
042
043import javax.security.auth.Subject;
044import javax.security.auth.callback.CallbackHandler;
045import javax.security.auth.login.LoginException;
046import javax.security.auth.spi.LoginModule;
047import javax.servlet.http.HttpServletRequest;
048import javax.servlet.http.HttpSession;
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 = ClassUtil.findClass( "", 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 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        log.debug( "Invalidating Session for session ID= {}", sid );
278        // Retrieve the associated Session and clear the Principal set
279        final Session wikiSession = Wiki.session().find( m_engine, request );
280        final Principal originalPrincipal = wikiSession.getLoginPrincipal();
281        wikiSession.invalidate();
282
283        // Remove the wikiSession from the WikiSession cache
284        Wiki.session().remove( m_engine, request );
285
286        // We need to flush the HTTP session too
287        if( session != null ) {
288            session.invalidate();
289        }
290
291        // Log the event
292        fireEvent( WikiSecurityEvent.LOGOUT, originalPrincipal, null );
293    }
294
295    /**
296     * {@inheritDoc}
297     */
298    @Override
299    public boolean allowsCookieAssertions() {
300        return m_allowsCookieAssertions;
301    }
302
303    /**
304     * {@inheritDoc}
305     */
306    @Override
307    public boolean allowsCookieAuthentication() {
308        return m_allowsCookieAuthentication;
309    }
310
311    /**
312     * {@inheritDoc}
313     */
314    @Override
315    public Set< Principal > doJAASLogin( final Class< ? extends LoginModule > clazz,
316                                         final CallbackHandler handler,
317                                         final Map< String, String > options ) throws WikiSecurityException {
318        // Instantiate the login module
319        final LoginModule loginModule;
320        try {
321            loginModule = ClassUtil.buildInstance( clazz );
322        } catch( final ReflectiveOperationException e ) {
323            throw new WikiSecurityException( e.getMessage(), e );
324        }
325
326        // Initialize the LoginModule
327        final Subject subject = new Subject();
328        loginModule.initialize( subject, handler, EMPTY_MAP, options );
329
330        // Try to log in:
331        boolean loginSucceeded = false;
332        boolean commitSucceeded = false;
333        try {
334            loginSucceeded = loginModule.login();
335            if( loginSucceeded ) {
336                commitSucceeded = loginModule.commit();
337            }
338        } catch( final LoginException e ) {
339            // Login or commit failed! No principal for you!
340        }
341
342        // If we successfully logged in & committed, return all the principals
343        if( loginSucceeded && commitSucceeded ) {
344            return subject.getPrincipals();
345        }
346        return NO_PRINCIPALS;
347    }
348
349    // events processing .......................................................
350
351    /**
352     * {@inheritDoc}
353     */
354    @Override
355    public synchronized void addWikiEventListener( final WikiEventListener listener ) {
356        WikiEventManager.addWikiEventListener( this, listener );
357    }
358
359    /**
360     * {@inheritDoc}
361     */
362    @Override
363    public synchronized void removeWikiEventListener( final WikiEventListener listener ) {
364        WikiEventManager.removeWikiEventListener( this, listener );
365    }
366
367    /**
368     * Initializes the options Map supplied to the configured LoginModule every time it is invoked. The properties and values extracted from
369     * <code>jspwiki.properties</code> are of the form <code>jspwiki.loginModule.options.<var>param</var> = <var>value</var>, where
370     * <var>param</var> is the key name, and <var>value</var> is the value.
371     *
372     * @param props the properties used to initialize JSPWiki
373     * @throws IllegalArgumentException if any of the keys are duplicated
374     */
375    private void initLoginModuleOptions( final Properties props ) {
376        for( final Object key : props.keySet() ) {
377            final String propName = key.toString();
378            if( propName.startsWith( PREFIX_LOGIN_MODULE_OPTIONS ) ) {
379                // Extract the option name and value
380                final String optionKey = propName.substring( PREFIX_LOGIN_MODULE_OPTIONS.length() ).trim();
381                if( !optionKey.isEmpty() ) {
382                    final String optionValue = props.getProperty( propName );
383
384                    // Make sure the key is unique before stashing the key/value pair
385                    if ( m_loginModuleOptions.containsKey( optionKey ) ) {
386                        throw new IllegalArgumentException( "JAAS LoginModule key " + propName + " cannot be specified twice!" );
387                    }
388                    m_loginModuleOptions.put( optionKey, optionValue );
389                }
390            }
391        }
392    }
393
394    /**
395     * After successful login, this method is called to inject authorized role Principals into the Session. To determine which roles
396     * should be injected, the configured Authorizer is queried for the roles it knows about by calling  {@link Authorizer#getRoles()}.
397     * Then, each role returned by the authorizer is tested by calling {@link Authorizer#isUserInRole(Session, Principal)}. If this
398     * check fails, and the Authorizer is of type WebAuthorizer, the role is checked again by calling
399     * {@link WebAuthorizer#isUserInRole(HttpServletRequest, Principal)}). Any roles that pass the test are injected into the Subject by
400     * firing appropriate authentication events.
401     *
402     * @param session the user's current Session
403     * @param authorizer the Engine's configured Authorizer
404     * @param request the user's HTTP session, which may be <code>null</code>
405     */
406    private void injectAuthorizerRoles( final Session session, final Authorizer authorizer, final HttpServletRequest request ) {
407        // Test each role the authorizer knows about
408        for( final Principal role : authorizer.getRoles() ) {
409            // Test the Authorizer
410            if( authorizer.isUserInRole( session, role ) ) {
411                fireEvent( WikiSecurityEvent.PRINCIPAL_ADD, role, session );
412                log.debug( "Added authorizer role {}.", role.getName() );
413            // If web authorizer, test the request.isInRole() method also
414            } else if ( request != null && authorizer instanceof WebAuthorizer ) {
415                final WebAuthorizer wa = ( WebAuthorizer )authorizer;
416                if ( wa.isUserInRole( request, role ) ) {
417                    fireEvent( WikiSecurityEvent.PRINCIPAL_ADD, role, session );
418                    log.debug( "Added container role {}.",role.getName() );
419                }
420            }
421        }
422    }
423
424}