Login und Logout in Spring Security

Dies ist das zehnte Kapitel der Tutorial-Beitragsreihe zu Spring Security.  Dieses Kapitel soll einen Einblick in den Login- und Logout-Mechanismus von Spring Security liefern. Dabei wird das Projekt aus dem Beitrag Tests mit Spring Security verwendet.

Einen Überblick über das gesamte Tutorial, bereits veröffentlichte Kapitel sowie den Ausblick auf kommende Kapitel ist hier zu finden. Falls ihr Fragen zum Tutorial oder Source Code habt, meldet euch einfach über das LABS Kontaktformular oder wendet euch direkt über den Micromata Github Bereich an mich, Jürgen Fast (Micromata).

Login

Wie bereits im Beitrag Spring Security Filter erwähnt vollzieht die Methode attemptAuthentication im Filter UsernamePasswordAuthenticationFilter den Login.

public Authentication attemptAuthentication(HttpServletRequest request,
        HttpServletResponse response) throws AuthenticationException {
    if (postOnly && !request.getMethod().equals("POST")) {
        throw new AuthenticationServiceException(
                "Authentication method not supported: " + request.getMethod());
    }

    String username = obtainUsername(request);
    String password = obtainPassword(request);
    ...
    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
            username, password);

    // Allow subclasses to set the "details" property
    setDetails(request, authRequest);

    return this.getAuthenticationManager().authenticate(authRequest);
}

Diese Methode stellt zunächst sicher, dass es sich um einen POST-Request handelt. Anschließend werden mit obtainUsername und obtainPassword die Logindaten aus dem Request geholt und ein UsernamePasswordAuthenticationToken erzeugt. In der Methode setDetails wird ein WebAuthenticationDetails Objekt als UsernamePasswordAuthenticationToken.details gesetzt, welches die Felder remoteAddress und sessionId aus dem Request enthält:

public WebAuthenticationDetails(HttpServletRequest request) {
    this.remoteAddress = request.getRemoteAddr();

    HttpSession session = request.getSession(false);
    this.sessionId = (session != null) ? session.getId() : null;
}

Abschließend wird mit this.getAuthenticationManager().authenticate(authRequest) die authenticate Methode von ProviderManager, einer Implementation von AuthenticationManager, aufgerufen:

public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
    Class<? extends Authentication> toTest = authentication.getClass();
    ...
    Authentication result = null;
    ...
    for (AuthenticationProvider provider : getProviders()) {
        if (!provider.supports(toTest)) {
            continue;
        }
        ...
        try {
            result = provider.authenticate(authentication);
            ...
        }
        ...
    }

    if (result == null && parent != null) {
        // Allow the parent to try.
        try {
            result = parent.authenticate(authentication);
        }
        ...
    }
    ...
}

getProviders liefert lediglich das Objekt AnonymousAuthenticationProvider zurück. Diese Implementation von AuthenticationProvider aktzeptiert jedoch nur ein AnonymousAuthenticationToken. Da unser Authentication Objekt vom Typ UsernamePasswordAuthenticationToken ist, wird die Schleife wieder verlassen. Es folgt der Aufruf von parent.authenticate(authentication). Bei parent handelt es sich wieder um einen ProviderManager. Diesmal liefert getProviders jedoch einen AuthenticationProvider vom Typ DaoAuthenticationProvider, welches das UsernamePasswordAuthenticationToken akzeptiert. Deshalb wird nun die Methode authenticate von AbstractUserDetailsAuthenticationProvider, der Elternklasse von DaoAuthenticationProvider, aufgerufen.

public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
    ...
    boolean cacheWasUsed = true;
    UserDetails user = this.userCache.getUserFromCache(username);

    if (user == null) {
        cacheWasUsed = false;
        try {
            user = retrieveUser(username,
                    (UsernamePasswordAuthenticationToken) authentication);
        }
        ...
    }
    ...
}

In dieser Methode wird zunächst versucht den User aus dem Cache zu holen. Beim Login liefert getUserFromCache jedoch null zurück, weshalb retrieveUser aus DaoAuthenticationProvider aufgerufen wird.

protected final UserDetails retrieveUser(String username,
        UsernamePasswordAuthenticationToken authentication)
        throws AuthenticationException {
    UserDetails loadedUser;

    try {
        loadedUser = this.getUserDetailsService().loadUserByUsername(username);
    }
    catch (UsernameNotFoundException notFound) {
        ...
        throw notFound;
    }
    return loadedUser;
}

Im Wesentlichen wird hier loadUserByUsername aus AuthenticatedUserService, unsere Implementierung des UserDetailsService aus dem Beitrag JPA und Spring Security, aufgerufen.

@Override
public UserDetails loadUserByUsername(String username) {
    User user = userRepository.findByUsername(username);
    if (user == null) {
        throw new UsernameNotFoundException("The user " + username + " does not exist");
    }
    return new AuthenticatedUser(user);
}

Diese Methode liefert entweder einen User zurück oder wirft eine UsernameNotFoundException, welche in AbstractUserDetailsAuthenticationProvider.authenticate abgefangen wird.

public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
    ...
    boolean cacheWasUsed = true;
    UserDetails user = this.userCache.getUserFromCache(username);

    if (user == null) {
        cacheWasUsed = false;
        try {
            user = retrieveUser(username,
                    (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (UsernameNotFoundException notFound) {
            ...
            if (hideUserNotFoundExceptions) {
                throw new BadCredentialsException(messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.badCredentials",
                        "Bad credentials"));
            }
            else {
                throw notFound;
            }
        }
        ...
    }
    ...
}

Falls hideUserNotFoundExceptions true ist, was per Standard der Fall ist, dann wird die UsernameNotFoundException nicht weiter geworfen, sondern eine BadCredentialsException, um die Information, dass der User nicht existiert, nicht preiszugeben. Denn per Default hält Spring Security die letzte Exception als Attribut in der Session unter dem Key SPRING_SECURITY_LAST_EXCEPTION fest. Wurde der User gefunden, dann geht es unter dem if-Block weiter.

public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
    ...
    if (user == null) {
        ...
    }
    try {
        preAuthenticationChecks.check(user);
        additionalAuthenticationChecks(user,
                (UsernamePasswordAuthenticationToken) authentication);
    }    
    catch (AuthenticationException exception) {
        ...
            throw exception;
        ...
    }
    ...
}

Zunächst wird die Methode check aus DefaultPreAuthenticationChecks, einer privaten Klasse von AbstractUserDetailsAuthenticationProvider, aufgerufen und anschließend die Methode additionalAuthenticationChecks, die nachfolgend zu sehen sind:

public void check(UserDetails user) {
    if (!user.isAccountNonLocked()) {
        throw new LockedException(messages.getMessage(
                "AbstractUserDetailsAuthenticationProvider.locked",
                "User account is locked"));
    }

    if (!user.isEnabled()) {
        throw new DisabledException(messages.getMessage(
                "AbstractUserDetailsAuthenticationProvider.disabled",
                "User is disabled"));
    }

    if (!user.isAccountNonExpired()) {
        throw new AccountExpiredException(messages.getMessage(
                "AbstractUserDetailsAuthenticationProvider.expired",
                "User account has expired"));
    }
}

check stellt sicher, dass der User nicht gespert, deaktiviert oder der Account abgelaufen ist.

protected void additionalAuthenticationChecks(UserDetails userDetails,
        UsernamePasswordAuthenticationToken authentication)
        throws AuthenticationException {
    ...
    String presentedPassword = authentication.getCredentials().toString();

    if (!passwordEncoder.isPasswordValid(userDetails.getPassword(),
            presentedPassword, salt)) {
        throw new BadCredentialsException(messages.getMessage(
                "AbstractUserDetailsAuthenticationProvider.badCredentials",
                "Bad credentials"));
    }
}

additionalAuthenticationChecks überprüft das Passwort. Anschließend geht es weiter mit AbstractUserDetailsAuthenticationProvider.authenticate.

public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
    ...
    try {
        ...
    }
    catch (AuthenticationException exception) {
        ...
            throw exception;
        ...
    }

    postAuthenticationChecks.check(user);

    if (!cacheWasUsed) {
        this.userCache.putUserInCache(user);
    }

    Object principalToReturn = user;

    if (forcePrincipalAsString) {
        principalToReturn = user.getUsername();
    }

    return createSuccessAuthentication(principalToReturn, authentication, user);
}

postAuthenticationChecks ist ein Object der Klasse DefaultPostAuthenticationChecks, einer weiteren privaten Klasse von AbstractUserDetailsAuthenticationProvider, und prüft lediglich, dass das Passwort nicht abgelaufen ist:

public void check(UserDetails user) {
    if (!user.isCredentialsNonExpired()) {
        throw new CredentialsExpiredException(messages.getMessage(
                "AbstractUserDetailsAuthenticationProvider.credentialsExpired",
                "User credentials have expired"));
    }
}

Da cacheWasUsed false ist, wird der User anschließend gecached. forcePrincipalAsString ist ebenfalls false, weshalb authenticate nun mit createSuccessAuthentication abgeschlossen wird:

protected Authentication createSuccessAuthentication(Object principal,
        Authentication authentication, UserDetails user) {
    UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
            principal, authentication.getCredentials(),
            authoritiesMapper.mapAuthorities(user.getAuthorities()));
    result.setDetails(authentication.getDetails());
    return result;
}

Hier wird nun ein neues UsernamePasswordAuthenticationToken angelegt, welches im Vergleich zu authentication nicht den username, sondern das komplette AuthenticatedUser Objekt als principal enthält, sowie mit authorities die Rollen des Users. Anschließend geht es weiter mit ProviderManager.authenticate:

public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
    ...
    if (result == null && parent != null) {
        try {
            result = parent.authenticate(authentication);
        }
        ...
    }

    if (result != null) {
        if (eraseCredentialsAfterAuthentication
                && (result instanceof CredentialsContainer)) {
            ((CredentialsContainer) result).eraseCredentials();
        }
        ...
        return result;
    }
    ...
}

Wenn ein valides result erzeugt werden konnte, dann wird nun eraseCredentials darauf aufgerufen. In unserem Fall wird result.credentials, was das eingegebene Passwort enthält, auf null gesetzt. Der AbstractAuthenticationProcessingFilter sorgt dann schließlich dafür, dass das zurückgegebene result in den SecurityContextHolder gesetzt wird:

protected void successfulAuthentication(HttpServletRequest request,
        HttpServletResponse response, FilterChain chain, Authentication authResult)
        throws IOException, ServletException {
    ...
    SecurityContextHolder.getContext().setAuthentication(authResult);
    ...
}

Logout

Für den Logout schauen wir uns die doFilter Methode des LogoutFilter an:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) res;

    if (requiresLogout(request, response)) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        this.handler.logout(request, response, auth);

        logoutSuccessHandler.onLogoutSuccess(request, response, auth);

        return;
    }

    chain.doFilter(request, response);
}

requiresLogout prüft, ob es sich bei der Request-URL um eine Logout-URL handelt. Ist dies der Fall, dann wird handler.logout aufgerufen. Bei handler handelt es sich um ein Objekt der Klasse CompositeLogoutHandler einer Implementierung von LogoutHandler.

@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
    for (LogoutHandler handler : this.logoutHandlers) {
        handler.logout(request, response, authentication);
    }
}

CompositeLogoutHandler.logout führt einen Logout auf alle LogutHandler aus. In unserem Fall sind dies der CsrfLogoutHandler und der SecurityContextLogoutHandler. Der CsrfLogoutHandler entfernt das CSRF-Token aus der Session, welches hier mit dem Schlüssel org.springframework.security.web.csrf .HttpSessionCsrfTokenRepository.CSRF_TOKEN als Attribut festgehalten wurde. Der SecurityContextLogoutHandler besitzt folgende logout Methode:

public void logout(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) {
    if (invalidateHttpSession) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
    }

    if (clearAuthentication) {
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication(null);
    }

    SecurityContextHolder.clearContext();
}

Zunächst wird die Session invalidiert, da invalidateHttpSession true ist. Anschließend wird das Authentication Objekt des SecurityContext auf null gesetzt, da clearAuthentication ebenfalls true ist. Zum Schluss wird SecurityContextHolder.clearContext() aufgerufen, was je nach gewählter Strategie des SecurityContextHolder die ensprechende clearContext Methode aufruft. In unserem Fall ist es ThreadLocalSecurityContextHolderStrategy.clearContext. Diese ruft ihrerseits die remove Methode des ThreadLocal auf.

Autor

Jürgen Fast arbeitet seit 2014 bei der Micromata GmbH als Softwareentwickler in verschiedenen Projekten der Logistik- und Automobilbranche. Sein Schwerpunkt liegt in der Entwicklung von javabasierten Webapplikationen.