Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How Spring Security's custom login works

I'm trying to get through Spring Security. I have to implement a custom login form, so I need to understand very well what my configurations mean.

spring-security.xml

<beans:beans xmlns="http://www.springframework.org/schema/security"
    xmlns:beans="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
    http://www.springframework.org/schema/security
    http://www.springframework.org/schema/security/spring-security-4.0.xsd">

    <http auto-config="true">
        <intercept-url pattern="/user**" access="isAuthenticated()" />
        <form-login authentication-failure-url="/login" login-page="/login"
            login-processing-url="/login" default-target-url="/user" />
        <logout invalidate-session="true" logout-success-url="/index"
            logout-url="/logout" />
    </http>

    <authentication-manager id="custom-auth">
        <authentication-provider>
            <user-service>
                <user name="my_username" password="my_password"
                    authorities="ROLE_USER" />
            </user-service>
        </authentication-provider>
    </authentication-manager>

LoginController

@Controller
public class LoginController {
    [....]

    @RequestMapping(value = "/login", method = RequestMethod.POST)
    public ModelAndView doLogin() {
        System.out.println("***LOGIN_POST***");
        return new ModelAndView("users/home");
    }

    @RequestMapping(value = "/logout", method = RequestMethod.POST)
    public ModelAndView doLogout() {
        System.out.println("***LOGOUT_POST***");
        return new ModelAndView("index");
    }
}

I know I can map the /login URL with RequestMethod.GET, but when I try to intercept the POST after form submit it doesn't work.

  1. I believe, but need to confirm, that is because Security is doing something behind the scenes: gets username and password values from the posted form and compare them with the ones in the authentication provider: if these match, default-target-url is shown, else user must repeat the login. Is it right?
  2. Then my problem is: I need username and password values typed in the Security's login form, because I have to send an HTTP request to an external server to verify if these match. Before to introduce Security I developed this mechanism using /login GET and /login POST, with @ModelAttribute annotation. How can I do now?
  3. Changing the authentication-provider, using a class which implements UserDetailsService, what happens? I believe that, in this case, username and password typed in the login form are compared with the ones retrieved from the db, as these are assigned to the User object. Is it right?

UserDetailsServiceImpl

    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
        @Autowired
        private CustomerDao customerDao;

        @Override
        public UserDetails loadUserByUsername(String username)
                throws UsernameNotFoundException {
            Customer customer = customerDao.findCustomerByUsername(username);
            return new User(customer.getUsername(), customer.getPassword(), true, true, true, true,
                Arrays.asList(new SimpleGrantedAuthority(customer.getRole())));
        }
    }

N.B. User's data are not in my db at first, that's because I'm not sure about the UserDetailsService solution (in which UserDetails are loaded simply by username). To retrieve my Customer object I need both username and password (to send to a specific external URL) then, if the JSON response is positive (username and password are correct), I have to send 2 others HTTP request to get Customer's data as firstname, lastname, nationality, etc. At this point my user can be considered logged in.

Any suggestions? Thanks in advance.

like image 940
andy Avatar asked Feb 05 '23 22:02

andy


1 Answers

  1. I believe, but need to confirm, that is because Security is doing something behind the scenes: gets username and password values from the posted form and compare them with the ones in the authentication provider: if these match, default-target-url is shown, else user must repeat the login. Is it right?

That's right. When you declare a <login-form> element in the security config you are configuring a UsernamePasswordAuthenticationFilter.

There you configure some url's:

  • login-page="/login" : The url which points to a @RequestMapping which returns the login form
  • login-processing-url="/login": The url wich triggers the UsernamePasswordAuthenticationFilter. This is in spring-security the equivalent to build a post processing controller method.
  • default-target-url="/user": the default page where the user will be redirected after providing a valid user credentials.
  • authentication-failure-url="/login" : The url where the user will be redirected in case of trying to login with invalid credentials.

While login-processing-url, default-target-url and authentication-failure-url must be valid RequestMappings, the login-processing-url won't reach the Spring MVC controller layer as it is executed before hitting the Spring MVC dispatcher servlet.

So the

@RequestMapping(value = "/login", method = RequestMethod.POST)
    public ModelAndView doLogin() {
        System.out.println("***LOGIN_POST***");
        return new ModelAndView("users/home");
    }

won't be never reached.

When a POST is made to /login uri, the UsernamePasswordAuthenticationFilter will perform it's doFilter() method to catch the user provided credentials, build a UsernamePasswordAuthenticationToken and delegate it to the AuthenticationManager, where this Authentication will be executed in the matching AuthenticationProvider.

  1. Then my problem is: I need username and password values typed in the Security's login form, because I have to send an HTTP request to an external server to verify if these match. Before to introduce Security I developed this mechanism using /login GET and /login POST, with @ModelAttribute annotation. How can I do now? I suppose when you used to perform the authentication to the external server you did it by delegating to a class from the POST /login RequestMapping.

So simply create a custom AuthenticationProvider which delegates the user validation stuff to your old logic:

public class ThirdPartyAuthenticationProvider implements AuthenticationProvider {
    
    private Class<? extends Authentication> supportingClass = UsernamePasswordAuthenticationToken.class;
    
    // This represents your existing username/password validation class
    // Bind it with an @Autowired or set it in your security config
    private ExternalAuthenticationValidator externalAuthenticationValidator;

    /* (non-Javadoc)
     * @see org.springframework.security.authentication.AuthenticationProvider#authenticate(org.springframework.security.core.Authentication)
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        boolean validated = this.externalAuthenticationValidator.validate(authentication.getName(), authentication.getCredentials().toString());
        if(!validated){
            throw new BadCredentialsException("username and/or password not valid");
        }
        Collection<? extends GrantedAuthority> authorities = null; 
        // you must fill this authorities collection
        return new UsernamePasswordAuthenticationToken(
                    authentication.getName(),
                    authentication.getCredentials(),
                    authorities
                );      
    }

    /* (non-Javadoc)
     * @see org.springframework.security.authentication.AuthenticationProvider#supports(java.lang.Class)
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return this.supportingClass.isAssignableFrom(authentication);
    }

    public ExternalAuthenticationValidator getExternalAuthenticationValidator() {
        return externalAuthenticationValidator;
    }

    public void setExternalAuthenticationValidator(ExternalAuthenticationValidator externalAuthenticationValidator) {
        this.externalAuthenticationValidator = externalAuthenticationValidator;
    }   

}

And the security config xml:

    <beans:bean id="thirdPartyAuthenticationProvider" class="com.xxx.yyy.ThirdPartyAuthenticationProvider">
        <!-- here set your external authentication validator in case you can't autowire it -->
        <beans:property name="externalAuthenticationValidator" ref="yourExternalAuthenticationValidator" />
    </beans:bean>
    
    <security:authentication-manager id="custom-auth">
        <security:authentication-provider ref="thirdPartyAuthenticationProvider" />
    </security:authentication-manager>
    
    <security:http auto-config="true" authentication-manager-ref="custom-auth">
        <security:intercept-url pattern="/user**" access="isAuthenticated()" />
        <security:form-login authentication-failure-url="/login" login-page="/login"
            login-processing-url="/login" default-target-url="/user" />
        <security:logout invalidate-session="true" logout-success-url="/index"
            logout-url="/logout" />
        <!-- in spring security 4.x CSRF filter is enabled by default. Disable it if 
             you don't plan to use it, or at least in the first attempts -->
        <security:csrf disabled="true"/>
    </security:http>
  1. Changing the authentication-provider, using a class which implements UserDetailsService, what happens? I believe that, in this case, username and password typed in the login form are compared with the ones retrieved from the db, as these are assigned to the User object. Is it right?

As you said that you must send both username and password, I don't think that UserServiceDetails schema fits your requirements. I thin you should do it as I suggested in point 2.

EDIT:

One last thing: now I'm sending HTTP request in authenticate method, if credentials are correct I receive a token in the response, which I need to get access to other external server services. How can I pass it in my Spring controller?

To receive and handle the received token, I would do it like this:

The ExternalAuthenticationValidator interface:

public interface ExternalAuthenticationValidator {

    public abstract ThirdPartyValidationResponse validate(String name, String password);

}

The ThirdPartyValidationResponse model interface:

public interface ThirdPartyValidationResponse{
    
    public boolean isValid();
    
    public Serializable getToken();

}

Then, change the way the Provider handles and manages it:

public class ThirdPartyAuthenticationProvider implements AuthenticationProvider {
    
    private Class<? extends Authentication> supportingClass = UsernamePasswordAuthenticationToken.class;
    
    private ExternalAuthenticationValidator externalAuthenticationValidator;

    /* (non-Javadoc)
     * @see org.springframework.security.authentication.AuthenticationProvider#authenticate(org.springframework.security.core.Authentication)
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ThirdPartyValidationResponse response = this.externalAuthenticationValidator.validate(authentication.getName(), authentication.getCredentials().toString());
        if(!response.isValid()){
            throw new BadCredentialsException("username and/or password not valid");
        }
        Collection<? extends GrantedAuthority> authorities = null; 
        // you must fill this authorities collection
        UsernamePasswordAuthenticationToken authenticated =  
                new UsernamePasswordAuthenticationToken(
                    authentication.getName(),
                    authentication.getCredentials(),
                    authorities
                );
        authenticated.setDetails(response);
        return authenticated;
    }

    /* (non-Javadoc)
     * @see org.springframework.security.authentication.AuthenticationProvider#supports(java.lang.Class)
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return this.supportingClass.isAssignableFrom(authentication);
    }

    public ExternalAuthenticationValidator getExternalAuthenticationValidator() {
        return externalAuthenticationValidator;
    }

    public void setExternalAuthenticationValidator(ExternalAuthenticationValidator externalAuthenticationValidator) {
        this.externalAuthenticationValidator = externalAuthenticationValidator;
    }   

}

Now, you must use this code snippet to retrieve the token from the userDetails:

        SecurityContext context = SecurityContextHolder.getContext();
        Authentication auth = context.getAuthentication();
        if(auth == null){
            throw new IllegalAccessException("Authentication is null in SecurityContext");
        }
        if(auth instanceof UsernamePasswordAuthenticationToken){
            Object details = auth.getDetails();
            if(details != null && details instanceof ThirdPartyValidationResponse){
                return ((ThirdPartyValidationResponse)details).getToken();
            }
        }
        return null;

Instead of including it everywhere you need it, it may be better to create a class which retrieves it from the details of the authentication:

public class SecurityContextThirdPartyTokenRetriever {
    
    public Serializable getThirdPartyToken() throws IllegalAccessException{
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication auth = context.getAuthentication();
        if(auth == null){
            throw new IllegalAccessException("Authentication is null in SecurityContext");
        }
        if(auth instanceof UsernamePasswordAuthenticationToken){
            Object details = auth.getDetails();
            if(details != null && details instanceof ThirdPartyValidationResponse){
                return ((ThirdPartyValidationResponse)details).getToken();
            }
        }
        return null;
    }

}

In case you chose this last way, just declare it in the security xml config (or annotate with a @Service, etc annotation):

<beans:bean id="tokenRetriever" class="com.xxx.yyy.SecurityContextThirdPartyTokenRetriever" />

There are other approaches, such as extending UsernamePasswordAuthenticationToken to include the token as a field on it, but this is the easiest one I think.

like image 76
jlumietu Avatar answered Feb 08 '23 15:02

jlumietu