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