Username Email Password Authentication in Spring?Security

?From parent article

Motivation

I want the user to sign up by providing their username, email and password. And after successful sign up the user can authenticate into our application using either their username and password or their email and password.

Strategies

Plaintext Authentication

The user provides their username and password during signup, our application saves the users credential in plaintext.


When a user attempts to authenticate, they provide their username and password. The application then verifies the user by directly comparing the provided password with the one stored in the database. The issue with this approach is that if the username and password in the database are ever leaked to a malicious third party, the attacker can use the leaked credentials to sign in as the user, thereby compromising the user’s account.

Hashing Authentication


Instead of storing plaintext passwords in the database, the application stores hashed passwords. This way, if a malicious third party obtains a copy of the hashed passwords, they do not have access to the original unhashed passwords and cannot use the hashed passwords to sign in. This adds a layer of security, making it much harder for attackers to compromise user accounts.

Hash and Salting Authentication


Let’s add an extra level of security by generating a random string called a salt, and creating a hashed password using both the original password and the salt. This makes it significantly harder for a malicious third party to crack the password, even if they obtain the hashed version, as they would also need the unique salt.

Because of the added security it provides against password attacks, we will choose hash and salting authentication as our authentication strategy.

Username Email Password Authentication Flow


Considerations:

  • We will be using the BCryptPasswordEncoder library for encoding and decoding passwords, which will automatically handle the hashing and salting of user passwords. This means we don’t need to manage hashing, salt generation, or storage.
  • To whitelist endpoints, we define the endpoints that we want to whitelist in HttpSecurity within the security configuration.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
  http
    .authorizeHttpRequests(auth -> auth
      .requestMatchers("swagger-ui/**", "/signup").permitAll()  // Whitelist signup endpoint
      .anyRequest().authenticated();
  return http.build();
}        

During application startup, the AuthorizationManager is created with the list of whitelisted endpoints. When an unauthenticated request reaches the AuthorizationFilter, it will call the AuthorizationManager to determine if the request has access by checking if the URL of the request matches the whitelisted endpoints. If the request matches one of the whitelisted endpoints, access is granted.

Database Setup

We will be using MongoDB as our database. For setting up MongoDB refer to the following:

Our database will store the Users object that implements the UserDetails interface in the users collection. The purpose of our users object is to store the username, email and encoded password for authentication. The access and refresh tokens will be used later when we set up JWT authentication. Lets create the following Users.java class.

@Document(collection = "users")
@Data
public class User implements UserDetails {
    @Id
    private String id;
    private String username;
    private String email;
    private String password;
    private String accessToken;
    private String refreshToken;
    private Set<GrantedAuthority> authorities;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;

    @Override
    public Set<GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public boolean isAccountNonExpired() {
        return this.accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return this.accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return this.credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }
}        

Now lets create a UserRepository.java class.


public interface UserRepository extends MongoRepository<User, String>, CustomUserRepository {

    // Custom query to find employees by firstname
    @Query("{ 'username' : ?0 }")
    Optional<User> findByUsername(String username);

    @Query("{ 'email' : ?0 }")
    Optional<User> findByEmail(String email);

}        

CustomUserRepository.java

public interface CustomUserRepository {
    User updateUser(String id, User user);
}        

CustomUserRepositoryImpl.java

@Repository
public class CustomUserRepositoryImpl implements CustomUserRepository {

    @Autowired
    private MongoTemplate mongoTemplate;

    @Override
    public User updateUser(String id, User user) {
        Query query = new Query(Criteria.where("id").is(id));
        Update update = new Update()
                .set("username", user.getUsername())
                .set("email", user.getEmail())
                .set("password", user.getPassword())
                .set("refreshToken", user.getRefreshToken())
                .set("authorities", user.getAuthorities())
                .set("accountNonExpired", user.isAccountNonExpired())
                .set("accountNonLocked", user.isAccountNonLocked())
                .set("credentialsNonExpired", user.isCredentialsNonExpired())
                .set("enabled", user.isEnabled());

        return mongoTemplate.findAndModify(
                query,
                update,
                FindAndModifyOptions.options().returnNew(true),
                User.class);
    }
}        

Lets create a UserService.java class which implements the UserDetailsService.java interface.

@Service
public class UserService implements UserDetailsService {

   @Autowired
   private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    public Optional<User> getById(String id) {
        return userRepository.findById(id);
    }

    public List<User> getUsersByIds(List<String> ids) {
        return userRepository.findAllById(ids);
    }

    public Optional<User> getByUsername(String username) {
        return userRepository.findByUsername(username);
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return getByUsername(username).orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }

    public Optional<User> getByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    public boolean existsByUsername(String username) {
        return userRepository.findByUsername(username).isPresent();
    }

    public boolean existsByEmail(String email) {
        return userRepository.findByEmail(email).isPresent();
    }

    public User saveUser(User user) {
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        return userRepository.save(user);
    }
}        

Signup Process

The reason we need to whitelist the signup endpoint is that we don’t have an identity stored in the database for a new user, so authentication can’t be performed. By whitelisting the signup endpoint, we allow new users to create an account without requiring prior authentication.?

To whitelist the signup endpoint add the following to SecurityConfig.java.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
  http
    .authorizeHttpRequests(auth -> auth
      .requestMatchers("swagger-ui/**", "/signup").permitAll()  // Whitelist signup endpoint
      .anyRequest().authenticated();
  return http.build();
}        

You can append permitAll() to the endpoints that you want to whitelist, and append authenticated() to the endpoints that you want to protect.

We are whitelisting swagger-ui/** to allow access to the swagger user interface which is similar to Postman to call our endpoint.

Lets create our signup and signin handler by adding AuthenticationController.java.

@RequestMapping("/auth")
@RestController
public class AuthenticationController {

    @Autowired
    private UserService userService;

    @Autowired
    AuthenticationManager authenticationManager;

    @PostMapping("/signup")
    public ResponseEntity<?> registerUser(@RequestBody SignupRequest signupRequest) {
        if (userService.existsByUsername(signupRequest.getUsername())) {
            return ResponseEntity
                    .badRequest()
                    .body("Error: Username is already taken!");
        }

        if (userService.existsByEmail(signupRequest.getEmail())) {
            return ResponseEntity
                    .badRequest()
                    .body("Error: Email is already in use!");
        }

        // Create new user
        User user = new User();
        user.setUsername(signupRequest.getUsername());
        user.setEmail(signupRequest.getEmail());
        user.setPassword(signupRequest.getPassword());
        user.setAuthorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
        user.setAccountNonExpired(true);
        user.setAccountNonLocked(true);
        user.setCredentialsNonExpired(true);
        user.setEnabled(true);

        User savedUser = userService.saveUser(user);

        // Return success response with email, username, and userid
        return ResponseEntity.ok(new SignupResponse(savedUser.getId(), savedUser.getUsername(), savedUser.getEmail()));
    }

    @PostMapping("/signin")
    public ResponseEntity<?> authenticateUser(@RequestBody SigninRequest signinRequest) {

        return ResponseEntity.ok("Successful Authentication");
    }

}        

SignupRequest.java

@Data
public class SignupRequest {
    @NotBlank
    private String username;
    @NotBlank
    @Email
    private String email;
    @NotBlank
    private String password;
}        

SigninRequest.java

@Data
public class SigninRequest {

    private String username;

    @Email
    private String email;

    @NotBlank
    private String password;
}        

Our signup handler will save the user’s credentials to database.

Signin Process

We will perform authentication on our signin endpoint, /signin, which will invoke the custom UsernameEmailPasswordAuthenticationFilter to authenticate the user. This filter will first attempt to authenticate using the username and password; if the username is not present, it will then attempt to authenticate using the user's email and password. This filter will follow the same authentication architecture as the UsernamePasswordAuthenticationFilter discussed in

We have already provided implementations for UserDetailsService (UserService) and PasswordEncoder (BCryptPasswordEncoder). Next, we will implement the custom filter UsernameEmailPasswordAuthenticationFilter and the custom AuthenticationProvider (UsernameEmailPasswordAuthenticationProvider).

The UsernameEmailPasswordAuthenticationFilter.java will intercept a signin request, check whether the request contains either a username or email and a password, and then create a UsernameEmailPasswordAuthenticationToken. The boolean parameter withUsername in this token is determined based on whether a username or email is provided in the request. The filter then sends the authentication token to the AuthenticationManager for authentication.

@Component
public class UsernameEmailPasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Autowired
    private AuthenticationController authenticationController;
    public UsernameEmailPasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        setFilterProcessesUrl(SIGNIN_URL);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        SigninRequest signinRequest = extractSigninRequest(request);

        String username = signinRequest.getUsername();
        String email = signinRequest.getEmail();
        String password = signinRequest.getPassword();

        if ((username == null || username.isEmpty()) && (email == null || email.isEmpty())) {
            throw new AuthenticationServiceException("Authentication failed: need both username or email.");
        }

        if (password == null || password.isEmpty()) {
            throw new AuthenticationServiceException("Authentication failed: need password.");
        }

        boolean withUsername = (username != null && !username.isEmpty());
        String principal = withUsername ? username : email;

        UsernameEmailPasswordAuthenticationToken authRequest = new UsernameEmailPasswordAuthenticationToken(principal, password, withUsername);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

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

        ResponseEntity<?> responseEntity = authenticationController.authenticateUser(null, response);

        response.setContentType(CONTENT_TYPE);
        response.setStatus(responseEntity.getStatusCode().value());
        response.getWriter().write(new ObjectMapper().writeValueAsString(responseEntity.getBody()));
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        ResponseEntity<String> responseEntity = ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("Error: " + failed.getMessage());

        response.setContentType(CONTENT_TYPE);
        response.setStatus(responseEntity.getStatusCode().value());
        response.getWriter().write(new ObjectMapper().writeValueAsString(responseEntity.getBody()));
    }

    private SigninRequest extractSigninRequest(HttpServletRequest request) {
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            return objectMapper.readValue(request.getInputStream(), SigninRequest.class);
        } catch (IOException e) {
            throw new AuthenticationServiceException("Authentication failed: unable to read request body.", e);
        }
    }
}        

UsernameEmailPasswordAuthenticationToken.java

public class UsernameEmailPasswordAuthenticationToken extends UsernamePasswordAuthenticationToken {

    @Getter
    private final String id;
    private final Boolean withUsername;

    public UsernameEmailPasswordAuthenticationToken(Object principal, Object credentials, boolean withUsername) {
        super(principal, credentials);
        this.id = null;
        this.withUsername = withUsername;
    }

    public UsernameEmailPasswordAuthenticationToken(String id, Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(principal, null, authorities);
        this.id = id;
        this.withUsername = null;
    }

    public boolean isWithUsername() {
        return withUsername;
    }

}        

The UsernameEmailPasswordAuthenticationProvider.java will be the selected implementation of AuthenticationProvider because it supports the UsernameEmailPasswordAuthenticationToken. Based on the value of withUsername, it will retrieve the User from the database using either the username or email. It will then use the PasswordEncoder to compare the provided password with the stored hashed password. If the credentials are valid, the user is authenticated.

@Component
public class UsernameEmailPasswordAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private UserService userService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * If username and password are provided will check if the user and password are valid
     * If email and password are provided will check if the email and password are valid
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernameEmailPasswordAuthenticationToken authRequest = (UsernameEmailPasswordAuthenticationToken) authentication;
        String principal = (String) authRequest.getPrincipal();
        String credentials = (String) authRequest.getCredentials();
        boolean withUsername = authRequest.isWithUsername();

        User user;

        if (withUsername) {
            user = userService.getByUsername(principal).orElseThrow(
                    () -> new AuthenticationServiceException("Invalid username/email or password"));
        } else {
            user = userService.getByEmail(principal).orElseThrow(
                    () -> new AuthenticationServiceException("Invalid username/email or password"));
        }

        if (!passwordEncoder.matches(credentials, user.getPassword())) {
            throw new AuthenticationServiceException("Invalid username/email or password");
        }
        return new UsernameEmailPasswordAuthenticationToken(user.getId(), user.getUsername(), user.getAuthorities());
    }

    /**
     * The AuthenticationManager will choose which AuthenticationProvider to use based if the provider supports the
     * implementation of the Authentication token.
     * In this case, the UsernameEmailPasswordAuthenticationToken is supported. And the AuthorizationManager will
     * use this provider to authenticate the user.
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return UsernameEmailPasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}        

Now lets wire these components together in SecurityConfig.java.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public AuthenticationProvider authenticationProvider() {
    return new UsernameEmailPasswordAuthenticationProvider();
  }

  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
    return authConfig.getAuthenticationManager();
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  public UsernameEmailPasswordAuthenticationFilter usernameEmailPasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
    return new UsernameEmailPasswordAuthenticationFilter(authenticationManager);
  }

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                 UsernameEmailPasswordAuthenticationFilter usernameEmailPasswordAuthenticationFilter) throws Exception {
    http
            .csrf(csrf -> csrf.disable())  // Disable CSRF protection
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("swagger-ui/**", "/signup").permitAll()  // Whitelist signup endpoint
                    .anyRequest().authenticated()  // All other endpoints require authentication
            )
            .addFilterAt(usernameEmailPasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
  }
}        

The?.addFilterAt method allows us to replace the UsernamePasswordAuthenticationFilter filter with UsernameEmailPasswordAuthenticationFilter filter in the security filter chain.

As a result, our security filter chain will look like this:



要查看或添加评论,请登录

社区洞察

其他会员也浏览了