Spring Security
Last updated
Was this helpful?
Last updated
Was this helpful?
Spring Security just like a filter, it will pre-process between the user request and the controller
The secured api end point will be cover by a spring security filter. In order to access the secured api end point, user is required to login with their correct role to pass the authorization which is a kind of filter
Install the maven dependency
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.2.0.Final</version>
</dependency>
</dependencies>
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // Enable the PreAuthorize for specific api end point
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationEntryPoint unauthorizedHandler;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@Resource(name = "userDetailsServiceImpl")
private UserDetailsService userDetailsService;
// Declare the data source
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public AuthTokenFilter authenticationJwtTokenFilter(){
return new AuthTokenFilter();
}
// Create authentication to authorize the username and password whether it is correct or not
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
// Allow CORS ,cancel session, and declare which api end point path should be authenticated
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().accessDeniedHandler(accessDeniedHandler).authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests().antMatchers("/api/user/**").authenticated()
.anyRequest().permitAll();
// Apply custom filter to to do authentication
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
To declare the data source of user
To register the custom filter for authorization as a bean
To register the authentication manager which act as an entry point of the login request as a bean
To register the password encoder to do hashing for password as a bean
To allow CORS and disable CSRF (Cross-site request forgery)
To declare which API end point would be secured
To declare handling method for failed authentication not access right issue
@Configuration
@EnableWebMvc
public class CorConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**");
}
}
To allow all API end point can be cross-origin
public class AuthTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Resource(name = "userDetailsServiceImpl")
private UserDetailsService userDetailsService;
// If the secured api end point is called, the username will be obtained from jwt and check it is existed in db , successful authorization will be set manually
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
try{
String jwt = parseJwt(httpServletRequest);
logger.info("jwt: "+ jwt);
if(jwt != null && jwtUtils.validateJwtToken(jwt)){
String username = jwtUtils.getUserNameFromJwtToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
catch (Exception e){
logger.error("Failed to do user authentication");
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
private String parseJwt(HttpServletRequest request) {
String headerAuth = request.getHeader("Authorization");
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7, headerAuth.length());
}
return null;
}
}
To declare the logic of authentication which is a filter and the method of obtaining token from header of request
Obtain the token from header, then validate the token
If the token is null or not valid , the handler of failed authentication will be triggered to return the error response
If the token is valid, the authorization will be set
@Component
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
logger.error("Unauthorized error: {}", authException.getMessage());
ObjectMapper objectMapper = new ObjectMapper();
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
AuthMessageResponse authMessageResponse = new AuthMessageResponse(HttpServletResponse.SC_UNAUTHORIZED, "Failed to authorize", System.currentTimeMillis(),true);
response.getOutputStream().println(objectMapper.writeValueAsString(authMessageResponse));
}
}
To declare the handling and the return response for failed authentication including user failed to login or access secured API end point without login
@Component
public class AccessHandler implements AccessDeniedHandler {
private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class);
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
logger.error("AccessDenied error: {}", e.getMessage());
ObjectMapper objectMapper = new ObjectMapper();
httpServletResponse.setContentType("application/json");
httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
AuthMessageResponse authMessageResponse = new AuthMessageResponse(HttpServletResponse.SC_UNAUTHORIZED, "Access Denied!!!", System.currentTimeMillis(),true);
httpServletResponse.getOutputStream().println(objectMapper.writeValueAsString(authMessageResponse));
}
}
To declare the handling and the return response for the issue that user have not enough access right to access secured API end point , even though he is logged in
public class UserDetailsImpl implements UserDetails {
private String username;
private String email;
@JsonIgnore
private String password;
private Collection<? extends GrantedAuthority> authorities;
public UserDetailsImpl(String username, String email, String password, Collection<? extends GrantedAuthority> authorities) {
this.username = username;
this.email = email;
this.password = password;
this.authorities = authorities;
}
public static UserDetailsImpl build (Account account){
List<GrantedAuthority> authorities = account.getRoleList().stream().map(role -> new SimpleGrantedAuthority(role.getAuthority().name())).collect(Collectors.toList());
return new UserDetailsImpl(
account.getUsername(),
account.getEmail(),
account.getPassword(),
authorities
);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public boolean equals(Object obj) {
if (obj == null || getClass() != obj.getClass())
return false;
UserDetailsImpl user = (UserDetailsImpl) obj;
return Objects.equals(username, user.username);
}
}
User details is the data modal that will be returned if authentication is passed
Since the default user detail only include username , password and role
In order to extend the modal ( in this case, also include email), we customize the modal
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AccountRepository accountRepository;
private static final Logger logger = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("load User by Username: " + username);
Account account = accountRepository.findAccountByUsername(username).orElseThrow(()-> new UsernameNotFoundException("User not found !!!"));
return UserDetailsImpl.build(account);
}
}
User details service is to declare how to get the user details
From here, we declare the method that get the user detail by searching table on database by using username
@Component
public class JwtUtils {
private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class);
@Value("${petercheng.app.jwtSecret}")
private String jwtSecret;
public String generateJwtToken(Authentication authentication){
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public String getUserNameFromJwtToken(String token) {
return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject();
}
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException e) {
logger.error("Invalid JWT signature: {}", e.getMessage());
} catch (MalformedJwtException e) {
logger.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
logger.error("JWT claims string is empty: {}", e.getMessage());
}
return false;
}
}
To declare the method of generating token based on the user details from authentication
To declare the method of obtaining username from token
To do validate the token whether it is generated from our application or not
Declare the type of role
public enum ERole {
ROLE_USER,
ROLE_ADMIN
}
Declare the modal mapped with table of database
@Entity(name = "account")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long userId;
@Column(name = "name")
private String username;
@Column(name = "email")
private String email;
@Column(name = "password")
private String password;
@OneToMany(mappedBy = "account", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Role> roleList;
....
}
@Entity(name = "role")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long roleId;
@ManyToOne
@JoinColumn(name = "account_id")
private Account account;
@Enumerated(EnumType.STRING)
private ERole authority;
...
}
public class LoginRequest {
@NotBlank(message = "Cannot be empty !!!")
private String username;
@NotBlank(message = "Cannot be empty !!!")
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
public class SignupRequest {
@NotBlank(message = "Cannot be empty !!!")
private String username;
@NotBlank(message = "Cannot be empty !!!")
@Pattern(regexp = "^.+@([a-z]+\\.)+[a-z]{2,4}$", message = "Wrong Format")
private String email;
@NotBlank(message = "Cannot be empty !!!")
private String password;
public SignupRequest() {
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
public class AuthMessageResponse {
private Integer status;
private String message;
private Long timeStamp;
private boolean isError;
public AuthMessageResponse() {
}
public AuthMessageResponse(Integer status, String message, Long timeStamp, boolean isError) {
this.status = status;
this.message = message;
this.timeStamp = timeStamp;
this.isError = isError;
}
....
}
public class JwtResponse {
private String token;
private String username;
private String email;
private List<String> roles;
public JwtResponse(String token, String username, String email, List<String> roles) {
this.token = token;
this.username = username;
this.email = email;
this.roles = roles;
}
...
}
public class ValidationErrorException extends RuntimeException{
public ValidationErrorException(String message) {
super(message);
}
}
@ControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler({ValidationErrorException.class})
public ResponseEntity<AuthMessageResponse> handleException(Exception exception){
AuthMessageResponse authMessageResponse = new AuthMessageResponse();
authMessageResponse.setStatus(HttpStatus.BAD_REQUEST.value());
authMessageResponse.setMessage(exception.getMessage());
authMessageResponse.setTimeStamp(System.currentTimeMillis());
authMessageResponse.setError(true);
return new ResponseEntity<>(authMessageResponse, HttpStatus.BAD_REQUEST);
}
}
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
Boolean existsAccountByUsername(String username);
Boolean existsAccountByEmail(String email);
Optional<Account> findAccountByUsername(String username);
}
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
@Autowired
private AuthService authService;
@InitBinder
public void initBinder(WebDataBinder dataBinder) {
StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
}
@GetMapping("/test")
public String test(){
return "test";
}
@GetMapping("/preauthorize")
@PreAuthorize("hasAnyRole('ROLE_ADMIN')")
public String preauthorize(){
return "preauthorize";
}
@PostMapping("/signup")
public ResponseEntity<AuthMessageResponse> registerUser(@Valid @RequestBody SignupRequest signupRequest, BindingResult theBindingResult){
logger.info("SignUp Request: "+ signupRequest.toString());
// If the validation is failed, return error message object
if(theBindingResult.hasErrors()){
throw new ValidationErrorException(theBindingResult.getFieldError().getDefaultMessage());
}
authService.signUpUser(signupRequest);
return ResponseEntity.ok(new AuthMessageResponse(HttpStatus.ACCEPTED.value(), "User registered successfully", System.currentTimeMillis(), false));
}
@PostMapping("/signupAdmin")
public ResponseEntity<AuthMessageResponse> registerAdmin(@Valid @RequestBody SignupRequest signupRequest, BindingResult theBindingResult){
logger.info("SignUp Request: "+ signupRequest.toString());
if(theBindingResult.hasErrors()){
throw new ValidationErrorException(theBindingResult.getFieldError().getDefaultMessage());
}
authService.signUpAdmin(signupRequest);
return ResponseEntity.ok(new AuthMessageResponse(HttpStatus.ACCEPTED.value(), "Admin registered successfully", System.currentTimeMillis(), false));
}
@PostMapping("/login")
public ResponseEntity<JwtResponse> userLogin(@Valid @RequestBody LoginRequest loginRequest, BindingResult theBindingResult){
logger.info("Login Request: " + loginRequest.toString());
if(theBindingResult.hasErrors()){
throw new ValidationErrorException(theBindingResult.getFieldError().getDefaultMessage());
}
JwtResponse jwtResponse = authService.userLogin(loginRequest);
return ResponseEntity.ok(jwtResponse);
}
@Service
public class AuthServiceImpl implements AuthService {
private static Logger logger = LoggerFactory.getLogger(AuthServiceImpl.class);
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private AccountRepository accountRepository;
@Autowired
private PasswordEncoder encoder;
@Autowired
private JwtUtils jwtUtils;
@Override
public void signUpUser(SignupRequest signupRequest){
// Check whether the user is existed or not
if(accountRepository.existsAccountByUsername(signupRequest.getUsername())){
throw new ValidationErrorException("Error: Username is already taken ");
}
if(accountRepository.existsAccountByEmail(signupRequest.getEmail())){
throw new ValidationErrorException("Error: Email is already taken ");
}
Account account = new Account(signupRequest.getUsername(), signupRequest.getEmail(), encoder.encode(signupRequest.getPassword()));
List<Role> roleList = new ArrayList<>();
roleList.add(new Role(account, ERole.ROLE_USER));
account.setRoleList(roleList);
accountRepository.save(account);
}
@Override
public void signUpAdmin(SignupRequest signupRequest){
if(accountRepository.existsAccountByUsername(signupRequest.getUsername())){
throw new ValidationErrorException("Error: Username is already taken ");
}
if(accountRepository.existsAccountByEmail(signupRequest.getEmail())){
throw new ValidationErrorException("Error: Email is already taken ");
}
Account account = new Account(signupRequest.getUsername(), signupRequest.getEmail(), encoder.encode(signupRequest.getPassword()));
List<Role> roleList = new ArrayList<>();
roleList.add(new Role(account, ERole.ROLE_USER));
roleList.add(new Role(account, ERole.ROLE_ADMIN));
account.setRoleList(roleList);
accountRepository.save(account);
}
@Override
public JwtResponse userLogin(LoginRequest loginRequest){
logger.info("user Login");
// Do the authentication according to username and password
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
// If passed, set the state to be authenticated
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtUtils.generateJwtToken(authentication);
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(item -> item.getAuthority())
.collect(Collectors.toList());
// return the token to frontend
return new JwtResponse(jwt,
userDetails.getUsername(),
userDetails.getEmail(),
roles);
}
Post username and password
Put it into UsernamePasswordAuthToken
Do authentication by authentication manager which is help by user details service and password encoder
If failed , go to error handler
If success, return token
User make a request
Enter custom authentication filter which is a single execution for each request to api
Extract token from header of request
Validate the token
If failed, go to error handler
If success, access the API end point