    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}