Search code examples
javaspringspring-mvcauthenticationspring-security

Using Java configuration for n-factor authentication


In a Spring MVC app using Spring Security, I want to use a custom AuthenticationProvider to check n-number of additional fields beyond the default username and password. For example, if a user wants to authenticate, I want her to have to supplement her username and password with a pin code she receives via email, a pincode she receives via text, and n number of other credentials. However, to keep this question narrow, let's just focus on adding one additional pin to the login, but let's set it up in a way that enables us to add n-other credentials easily afterwards.

I want to use Java configuration.

I have created a custom AuthenticationProvider, a custom AuthenticationFilter, custom UserDetailsService, and a few other changes.

But the app is granting access when a user tries to log in whether or not the user has valid credentials, as shown in a screen shot in the instructions for reproducing the problem below. What specific changes need to be made to the code that I am sharing so that the custom n-factor authentication can function properly?

The structure of my test project is shown in the following screen shots:

Here is the Java code structure in eclipse project explorer:

{ Image host not available }

The XML config files can be located by scrolling down in project explorer to show the following:

{ Image host not available }

The view code can be found by scrolling a little further down in project explorer as follows:

{ Image host not available }

You can download and explore all this code in a working Eclipse project:

{ File now deleted }

CustomAuthenticationProvider.java is:

package my.app.config;

import java.util.ArrayList;
import java.util.List;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

public class CustomAuthenticationProvider implements AuthenticationProvider{

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String name = authentication.getName();
        String password = authentication.getCredentials().toString();
        List<GrantedAuthority> grantedAuths = new ArrayList<>();
        if (name.equals("admin") && password.equals("system")) {
            grantedAuths.add(new SimpleGrantedAuthority("ROLE_ADMIN"));  
        } 
        if(pincodeEntered(name)){
            grantedAuths.add(new SimpleGrantedAuthority("registered"));  
        }
        Authentication auth = new UsernamePasswordAuthenticationToken(name, password, grantedAuths);
        return auth;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

    private boolean pincodeEntered(String userName){
        // do your check here
        return true;
    }
}

MessageSecurityWebApplicationInitializer.java is:

package my.app.config;

import org.springframework.core.annotation.Order;
import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer;

@Order(2)
public class MessageSecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer {
}

TwoFactorAuthenticationFilter.java is:

package my.app.config;

import javax.servlet.http.HttpServletRequest;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

public class TwoFactorAuthenticationFilter extends UsernamePasswordAuthenticationFilter
{
    private String extraParameter = "extra";
    private String delimiter = ":";


    /**
     * Given an {@link HttpServletRequest}, this method extracts the username and the extra input
     * values and returns a combined username string of those values separated by the delimiter
     * string.
     *
     * @param request The {@link HttpServletRequest} containing the HTTP request variables from
     *   which the username client domain values can be extracted
     */
    @Override
    protected String obtainUsername(HttpServletRequest request){
        String username = request.getParameter(getUsernameParameter());
        String extraInput = request.getParameter(getExtraParameter());
        String combinedUsername = username + getDelimiter() + extraInput;
        System.out.println("Combined username = " + combinedUsername);
        return combinedUsername;
    }

    /**
     * @return The parameter name which will be used to obtain the extra input from the login request
     */
    public String getExtraParameter(){
        return this.extraParameter;
    }

    /**
     * @param extraParameter The parameter name which will be used to obtain the extra input from the login request
     */
    public void setExtraParameter(String extraParameter){
        this.extraParameter = extraParameter;
    }

    /**
     * @return The delimiter string used to separate the username and extra input values in the
     * string returned by <code>obtainUsername()</code>
     */
    public String getDelimiter(){
        return this.delimiter;
    }

    /**
     * @param delimiter The delimiter string used to separate the username and extra input values in the
     * string returned by <code>obtainUsername()</code>
     */
    public void setDelimiter(String delimiter){
        this.delimiter = delimiter;
    }
}

SecurityConfig.java is:

package my.app.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;

@Configuration
@EnableWebMvcSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    public void registerGlobalAuthentication(AuthenticationManagerBuilder auth) throws Exception {
            auth.authenticationProvider(customAuthenticationProvider());
    }

    @Bean
    AuthenticationProvider customAuthenticationProvider() {
            CustomAuthenticationProvider impl = new CustomAuthenticationProvider();
            return impl ;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/secure-home")
                .usernameParameter("j_username")
                .passwordParameter("j_password")
                .loginProcessingUrl("/j_spring_security_check")
                .failureUrl("/login")
                .permitAll()
                .and()
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login")
                .and()
            .authorizeRequests()
                .antMatchers("/secure-home").hasAuthority("registered")
                .antMatchers("/j_spring_security_check").permitAll()
                .and()
            .userDetailsService(userDetailsService());
    }
}

User.java is:

package my.app.model;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;
import javax.persistence.ManyToMany;
import javax.persistence.Table;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

@Entity
@Table(name="users")
public class User implements UserDetails{

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue
    private Integer id;

    @Column(name= "email", unique=true, nullable=false)
    private String login;//must be a valid email address

    @Column(name = "password")
    private String password;

    @Column(name = "phone")
    private String phone;

    @Column(name = "pin")
    private String pin;

    @Column(name = "sessionid")
    private String sessionId;

    @ManyToMany(cascade=CascadeType.ALL, fetch=FetchType.EAGER)
    @JoinTable(name="user_roles",
        joinColumns = {@JoinColumn(name="user_id", referencedColumnName="id")},
        inverseJoinColumns = {@JoinColumn(name="role_id", referencedColumnName="id")}
    )
    private Set<Role> roles;

    public Integer getId() {return id;}
    public void setId(Integer id) { this.id = id;}

    public String getPhone(){return phone;}
    public void setPhone(String pn){phone = pn;}

    public String getPin(){return pin;}
    public void setPin(String pi){pin = pi;}

    public String getSessionId(){return sessionId;}
    public void setSessionId(String sd){sessionId = sd;}

    public String getLogin() {
        return login;
    }

    public void setLogin(String login) {
        this.login = login;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    //roles methods
    public void addRole(Role alg) {roles.add(alg);}
    public Set<Role> getRoles(){
        if(this.roles==null){this.roles = new HashSet<Role>();}
        return this.roles;
    }
    public void setRoles(Set<Role> alg){this.roles = alg;}
    public boolean isInRoles(int aid){
        ArrayList<Role> mylgs = new ArrayList<Role>();
        mylgs.addAll(this.roles);
        for(int a=0;a<mylgs.size();a++){if(mylgs.get(a).getId()==aid){return true;}}
        return false;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public String getUsername() {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public boolean isAccountNonExpired() {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public boolean isEnabled() {
        // TODO Auto-generated method stub
        return false;
    }
}

The xml config is in business-config.xml and is:

<beans profile="default,spring-data-jpa">
    <!-- lots of other stuff -->
    <bean class="my.app.config.SecurityConfig"></bean>
</beans>  

<!-- lots of unrelated stuff -->

In addition, mvc-core-config.xml contains the following:

<!-- lots of other stuff -->
<mvc:view-controller path="/" view-name="welcome" />
<mvc:view-controller path="/login" view-name="login" />

And login.jsp looks like this:

<?xml version="1.0" encoding="ISO-8859-1" ?>
<%@taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ page language="java" contentType="text/html; charset=ISO-8859-1"    pageEncoding="ISO-8859-1"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1" />
    <title>Custom Login page</title>
    <style>.error {color: red;}</style>
</head>
<body>
    <div class="container">
        <h1>Custom Login page</h1>
        <p>
        <c:if test="${error == true}">
            <b class="error">Invalid login or password or pin.</b>
        </c:if>
        </p>
        <form method="post" action="<c:url value='j_spring_security_check'/>" >
        <table>
        <tbody>
            <tr>
                <td>Login:</td>
                <td><input type="text" name="j_username" id="j_username"size="30" maxlength="40"  /></td>
            </tr>
            <tr>
                <td>Password:</td>
                <td><input type="password" name="j_password" id="j_password" size="30" maxlength="32" /></td>
            </tr>
            <tr>
                <td>Pin:</td>
                <td><input type="text" name="pin" id="pin"size="30" maxlength="40"  /></td>
            </tr>
            <tr>
            <td colspan=2>
                  <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
            </td>
            </tr>
            <tr>
                <td></td>
                <td><input type="submit" value="Login" /></td>
            </tr>
        </tbody>
        </table>
    </form> 
    </div>
    </body>
</html>  

The spring security dependencies in 'pom.xml' are:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-core</artifactId>
    <version>3.2.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-web</artifactId>
    <version>3.2.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
    <version>3.2.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-taglibs</artifactId>
    <version>3.2.2.RELEASE</version>
</dependency>

Download and reproduce on your machine

I have also uploaded a working Eclipse project which contains the bare minimum code required to reproduce the problem on your local devbox. You can download the Eclipse project here:

{ File now deleted }

Once you have downloaded the zipped project, you can reproduce the problem on your machine by following these steps:

1.) Unzip the zip file to a new folder

2.) In Eclipse, do File > Import > Existing Maven Projects

3.) Click Next. Browse to folder of unzipped project. Complete wizard to import project.

4.) Right click on project name in eclipse and do Maven > Download sources

5.) Right click on project name again in eclipse and do Maven > Update project

6.) Open MySQL and create an empty new database called somedb

7.) In the Eclipse project, open data-access.properties as shown in the following picture, and change someusername and somepassword to your real username and password for your MySQL.

{ Image host not available }

8.) In Eclipse, right click the project and chose Run As .. Run on server.. . This should launch the app so that you see the following in your browser at the http://localhost:8080/n_factor_auth/ url:

{ Image host not available }

9.) Change the URL to http://localhost:8080/n_factor_auth/secure-home to see that you were redirected to http://localhost:8080/n_factor_auth/login which serves the sample custom login page, which requires a pin in addition to the username and password. Note that the result needs to accommodate n-factors and not simply adding a single pin code:

{ Image host not available }

10.) Insert test credentials into the MySQL database by running the following SQL commands, which you could put in a .sql file and run from the MySQL command line using the source command. Note that the database objects will be deleted and recreated empty every time the app starts because hbm2ddl is enabled to simplify this example. Thus, the following SQL commands will need to be re-run every time you reload the app in Eclipse.

SET FOREIGN_KEY_CHECKS=0;
INSERT INTO `roles` VALUES (100,'registered');
INSERT INTO `user_roles` VALUES (100,100);
INSERT INTO `users` (id, email,password, phone, pin) VALUES (100,'[email protected]','somepassword','xxxxxxxxxx', 'yyyy');
SET FOREIGN_KEY_CHECKS=1;  

11.) Try to login using any credentials (valid or invalid), and get the following successful login screen (Note that the user gets logged in whether or not they give valid credentials):

{ Image host not available }

That's it. You now have the problem recreated on your machine, including all the code shown above, but in a working minimalist eclipse project. So now how do you answer the OP above? What changes do you make to the code above, and what else do you do in order to get the custom authenticator to engage upon login?

I am interested to learn what specific changes need to be made to the minimalist download app in order to enable n-factor authentication. I will validate by checking your suggestions in the sample app on my machine.

Thanks to various people (including M.Deinum) who have suggested deleting redundant XML config to create the current version shown in this posting.


Solution

  • The easiest way to use java config for n-factor authentication is to start with a working example of single-factor authentication (username and password) that uses java config. Then you only have to make a few very minor changes: Assuming that you have a working single factor authentication app using java configuration, the steps are simply:

    First, define layered roles, with one role for each factor. If you only have two factor authentication, keep your existing one role in the database, but then create a second role with full-access that you only assign at runtime. Thus, when the user logs in, they are logged in to the minimal role stored in the database, and that minimal role is only given access to one view, which is a form allowing them to enter a pin code that your controller just sent them via text or email or some other method. These layered roles get defined in SecurityConfig.java, as follows:

    @Configuration
    @EnableWebMvcSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        private UserDetailsService userDetailsService;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
          http
            .csrf().disable()
            .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/getpin")
                .usernameParameter("j_username")
                .passwordParameter("j_password")
                .loginProcessingUrl("/j_spring_security_check")
                .failureUrl("/login")
                .permitAll()
                .and()
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login")
                .and()
            .authorizeRequests()
                .antMatchers("/getpin").hasAuthority("get_pin")
                .antMatchers("/securemain/**").hasAuthority("full_access")
                .antMatchers("/j_spring_security_check").permitAll()
                .and()
            .userDetailsService(userDetailsService);
        }
    }
    

    Second, add code that upgrades the user's role to full-access upon successful entry of the correct pin code to the controller code that handles the pin code entry form POST. The code to manually assign full access in the controller is:

    Role rl2 = new Role();rl2.setRole("full-access");//Don't save this one because we will manually assign it on login.
    Set<Role> rls = new HashSet<Role>();
    rls.add(rl2);
    CustomUserDetailsService user = new CustomUserDetailsService(appService);
    Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities(rls));
    SecurityContextHolder.getContext().setAuthentication(authentication);
    return "redirect:/securemain";  
    

    You can add as many layers as you want to after /getpin. You can also support multiple authorization roles and make it as complicated as you want to. But this answer gives the simplest way to get it running with java config.