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