Securing Spring Boot Microservices with Spring Security
Overview
Basically, our microservices are secured this way :
- The user provides his credentials via a public endpoint
- If authenticated, the server returns an authentication token (JWT)
- The JWT is attached to each subsequent request via an HTTP header:
Authorization:Bearer TOKEN
You can find the source code at https://github.com/vedrax-admin/spring-microservices
User Microservice
This microservice uses a MySQL database. We begin by creating the
Below is a script to insert some dummy data:
The Account entity will be created using JPA:
The account repository:
Account
table:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
CREATE TABLE IF NOT EXISTS `metrolab_db_users`.`Account` ( | |
`id` SMALLINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, | |
`email` VARCHAR(255) NOT NULL, | |
`password` VARCHAR(255) NOT NULL, | |
`full_name` VARCHAR(60) NOT NULL, | |
`security_role` VARCHAR(45) NOT NULL, | |
PRIMARY KEY (`id`), | |
UNIQUE INDEX `User_u1` (`email` ASC)) | |
ENGINE = InnoDB ; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
INSERT INTO metrolab_db_users.Account (email, password, full_name, security_role) VALUES | |
('finance@vedrax.com','$2a$10$.6QjV9sL8OukcnF0aHZxgOCBe7iX29xoJ4dURBx9i8dx8MdhVQpHK','Penchenat Remy','ADMIN'); |
The Account entity will be created using JPA:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.user.domain; | |
import java.io.Serializable; | |
import javax.persistence.Column; | |
import javax.persistence.Entity; | |
import javax.persistence.GeneratedValue; | |
import javax.persistence.GenerationType; | |
import javax.persistence.Id; | |
import org.apache.commons.lang3.builder.EqualsBuilder; | |
import org.apache.commons.lang3.builder.HashCodeBuilder; | |
import org.apache.commons.lang3.builder.ReflectionToStringBuilder; | |
/** | |
* | |
* Represents a user account | |
* | |
* @author remypenchenat | |
*/ | |
@Entity | |
public class Account implements Serializable { | |
private static final long serialVersionUID = 1L; | |
@Id | |
@GeneratedValue(strategy = GenerationType.IDENTITY) | |
private Long id; | |
private String email; | |
private String password; | |
@Column(name = "full_name") | |
private String fullName; | |
@Column(name = "security_role") | |
private String securityRole; | |
public Account() { | |
} | |
public Long getId() { | |
return id; | |
} | |
public void setId(Long id) { | |
this.id = id; | |
} | |
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 String getFullName() { | |
return fullName; | |
} | |
public void setFullName(String fullName) { | |
this.fullName = fullName; | |
} | |
public String getSecurityRole() { | |
return securityRole; | |
} | |
public void setSecurityRole(String securityRole) { | |
this.securityRole = securityRole; | |
} | |
@Override | |
public int hashCode() { | |
return new HashCodeBuilder(17, 37) | |
.append(this.email) | |
.append(this.password) | |
.toHashCode(); | |
} | |
@Override | |
public boolean equals(Object obj) { | |
if (obj == null) { | |
return false; | |
} | |
if (obj == this) { | |
return true; | |
} | |
if (getClass() != obj.getClass()) { | |
return false; | |
} | |
Account object = (Account) obj; | |
return new EqualsBuilder() | |
.append(this.email, object.email) | |
.append(this.password, object.password) | |
.isEquals(); | |
} | |
@Override | |
public String toString() { | |
return new ReflectionToStringBuilder(this).toString(); | |
} | |
} |
The account repository:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.user.repository; | |
import com.vedrax.user.domain.Account; | |
import java.util.Optional; | |
import org.springframework.data.jpa.repository.JpaRepository; | |
import org.springframework.stereotype.Repository; | |
/** | |
* | |
* The account repository | |
* | |
* @author remypenchenat | |
*/ | |
@Repository | |
public interface AccountRepository extends JpaRepository<Account, Long> { | |
/** | |
* Find account by email | |
* | |
* @param email | |
* @return | |
*/ | |
Optional<Account> findByEmail(String email); | |
} |
User Principal
We create a class named
UserPrincipal
which implements UserDetails
. In order to be accessible by all microservices, this class is located in our shared module.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.security; | |
import java.util.ArrayList; | |
import java.util.Collection; | |
import java.util.List; | |
import org.apache.commons.lang3.builder.ReflectionToStringBuilder; | |
import org.springframework.security.core.GrantedAuthority; | |
import org.springframework.security.core.authority.SimpleGrantedAuthority; | |
import org.springframework.security.core.userdetails.UserDetails; | |
/** | |
* | |
* @author remypenchenat | |
*/ | |
public class UserPrincipal implements UserDetails { | |
private final static String ROLE_PREFIX = "ROLE_"; | |
private final String username; | |
private final String fullName; | |
private final String role; | |
public UserPrincipal(String username, String fullName, String role) { | |
this.username = username; | |
this.fullName = fullName; | |
this.role = role; | |
} | |
@Override | |
public Collection<? extends GrantedAuthority> getAuthorities() { | |
List<GrantedAuthority> list = new ArrayList<>(); | |
list.add(new SimpleGrantedAuthority(ROLE_PREFIX + role)); | |
return list; | |
} | |
@Override | |
public String getPassword() { | |
return null; | |
} | |
@Override | |
public String getUsername() { | |
return username; | |
} | |
public String getFullName() { | |
return fullName; | |
} | |
public String getRole() { | |
return role; | |
} | |
@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 String toString() { | |
return new ReflectionToStringBuilder(this).toString(); | |
} | |
} |
JWT Token Service
This service is responsible for creating expiring JWT. We can also parse a JWT for getting the
UserPrincipal
. We use the io.jsonwebtoken
dependency for that.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.security; | |
import static com.vedrax.security.SecurityConstants.ISSUER; | |
import static com.vedrax.security.SecurityConstants.JWT_SECRET; | |
import static com.vedrax.utils.DateUtils.addUnitToLocalDateTime; | |
import static com.vedrax.utils.DateUtils.convertToDateTime; | |
import static com.vedrax.utils.DateUtils.convertToLocalDateTime; | |
import io.jsonwebtoken.Claims; | |
import io.jsonwebtoken.JwtException; | |
import io.jsonwebtoken.Jwts; | |
import io.jsonwebtoken.SignatureAlgorithm; | |
import java.time.LocalDateTime; | |
import java.time.temporal.ChronoUnit; | |
import java.util.Optional; | |
import org.apache.commons.lang3.Validate; | |
import org.springframework.stereotype.Service; | |
/** | |
* | |
* Service for creating security token (JWT) | |
* | |
* @author remypenchenat | |
*/ | |
@Service | |
public class TokenServiceImpl implements TokenService { | |
/** | |
* Parse the provided JWT | |
* | |
* @param token the JWT to be parsed | |
* @return an optional {@link UserPrincipal} | |
*/ | |
@Override | |
public Optional<UserPrincipal> parseToken(String token) { | |
try { | |
Claims body = getClaimsWithToken(token); | |
UserPrincipal userPrincipal = claimsToPrincipal(body); | |
return Optional.of(userPrincipal); | |
} catch (JwtException | ClassCastException e) { | |
return Optional.empty(); | |
} | |
} | |
/** | |
* Get {@link Claims} with the provided JWT | |
* | |
* @param token | |
* @return | |
*/ | |
private Claims getClaimsWithToken(String token) { | |
return Jwts.parser() | |
.setSigningKey(JWT_SECRET) | |
.requireIssuer(ISSUER) | |
.parseClaimsJws(token) | |
.getBody(); | |
} | |
/** | |
* Get {@link UserPrincipal} with the provided {@link Claims} | |
* | |
* @param body | |
* @return | |
*/ | |
private UserPrincipal claimsToPrincipal(Claims body) { | |
String username = body.getSubject(); | |
String fullName = (String) body.get(UserPrincipal.FULL_NAME); | |
String role = (String) body.get(UserPrincipal.ROLE); | |
return new UserPrincipal(username, fullName, role); | |
} | |
/** | |
* Creates an expiring JWT - 24 hours | |
* | |
* @param user the {@link UserPrincipal} | |
* @return | |
*/ | |
@Override | |
public String expiring(UserPrincipal user) { | |
return createToken(user, 86400);//24hours | |
} | |
/** | |
* Creates an expiring JWT | |
* | |
* @param user the {@link UserPrincipal} | |
* @param expiresInSec the expiration in seconds | |
* @return | |
*/ | |
private String createToken(final UserPrincipal user, final int expiresInSec) { | |
Validate.notNull(user, "user should not be null"); | |
Claims claims = setClaims(); | |
setDuration(claims, expiresInSec); | |
setAttributes(claims, user); | |
return Jwts.builder() | |
.setClaims(claims) | |
.signWith(SignatureAlgorithm.HS512, JWT_SECRET) | |
.compact(); | |
} | |
/** | |
* Set a new @{link Claims} | |
* | |
* @return | |
*/ | |
private Claims setClaims() { | |
LocalDateTime now = LocalDateTime.now(); | |
return Jwts | |
.claims() | |
.setIssuer(ISSUER) | |
.setIssuedAt(convertToDateTime(now)); | |
} | |
/** | |
* Set the expiration time | |
* | |
* @param claims the @{link Claims} | |
* @param expiresInSec the expiration in seconds | |
*/ | |
private void setDuration(Claims claims, int expiresInSec) { | |
if (expiresInSec > 0) { | |
LocalDateTime issuedAt = convertToLocalDateTime(claims.getIssuedAt()); | |
LocalDateTime expiresAt = addUnitToLocalDateTime(issuedAt, expiresInSec, ChronoUnit.SECONDS); | |
claims.setExpiration(convertToDateTime(expiresAt)); | |
} | |
} | |
/** | |
* Set the attributes of the provided {@link Claims} with the | |
* {@link UserPrincipal} | |
* | |
* @param claims the @{link Claims} | |
* @param user the {@link UserPrincipal} | |
*/ | |
private void setAttributes(Claims claims, UserPrincipal user) { | |
claims.setSubject(user.getUsername()); | |
claims.put(UserPrincipal.FULL_NAME, user.getFullName()); | |
claims.put(UserPrincipal.ROLE, user.getRole()); | |
} | |
} |
Account Service
The account service via the login method logs in a user and returns a JWT. This service is located in the user module.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.user.service; | |
import com.vedrax.exception.ApiException; | |
import com.vedrax.security.TokenService; | |
import com.vedrax.security.UserPrincipal; | |
import com.vedrax.user.domain.Account; | |
import com.vedrax.user.dto.AccountDto; | |
import java.util.Optional; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.security.crypto.password.PasswordEncoder; | |
import org.springframework.stereotype.Service; | |
import org.apache.commons.lang3.Validate; | |
import com.vedrax.user.repository.AccountRepository; | |
/** | |
* | |
* An {@link AccountService} implementation for managing user account | |
* | |
* @author remypenchenat | |
*/ | |
@Service | |
public class AccountServiceImpl implements AccountService { | |
private final AccountRepository accountRepository; | |
private final TokenService tokenService; | |
private final PasswordEncoder passwordEncoder; | |
@Autowired | |
public AccountServiceImpl(AccountRepository accountRepository, | |
TokenService tokenService, | |
PasswordEncoder passwordEncoder) { | |
this.accountRepository = accountRepository; | |
this.tokenService = tokenService; | |
this.passwordEncoder = passwordEncoder; | |
} | |
/** | |
* Sign up a new user with the provided DTO | |
* | |
* @param accountDto | |
* @return | |
*/ | |
@Override | |
public Account register(AccountDto accountDto) { | |
Validate.notNull(accountDto, "accountDto should be provided"); | |
validateIfNotRegistered(accountDto.getEmail()); | |
Account account = constructAccountWithDto(accountDto); | |
return accountRepository.save(account); | |
} | |
/** | |
* Throws an {@link ApiException} if the user is already registered | |
* | |
* @param email | |
*/ | |
private void validateIfNotRegistered(String email) { | |
Validate.notNull(email, "email should be provided"); | |
Optional<Account> userOpt = accountRepository.findByEmail(email); | |
if (userOpt.isPresent()) { | |
throw new ApiException("User with email [" + email + "] already registered."); | |
} | |
} | |
/** | |
* Constructs an account with the provided account DTO | |
* | |
* @param accountDto | |
* @return | |
*/ | |
private Account constructAccountWithDto(AccountDto accountDto) { | |
Account account = new Account(); | |
account.setEmail(accountDto.getEmail()); | |
account.setFullName(accountDto.getFullName()); | |
account.setSecurityRole(accountDto.getSecurityRole()); | |
account.setPassword(passwordEncoder.encode(accountDto.getPassword())); | |
return account; | |
} | |
/** | |
* Gain access to a user with the provided credentials. If the user is | |
* authenticated returns a security token | |
* | |
* @param username | |
* @param password | |
* @return The security token if authenticated | |
*/ | |
@Override | |
public Optional<String> login(String username, String password) { | |
Validate.notNull(username, "username should be provided"); | |
Validate.notNull(password, "password should be provided"); | |
return accountRepository | |
.findByEmail(username) | |
.filter(user -> passwordEncoder.matches(password, user.getPassword())) | |
.map(user -> createToken(user)); | |
} | |
/** | |
* Create a security token with the provided account | |
* | |
* @param account | |
* @return | |
*/ | |
private String createToken(Account account) { | |
UserPrincipal userPrincipal = new UserPrincipal(account.getEmail(), | |
account.getFullName(), account.getSecurityRole()); | |
return tokenService.expiring(userPrincipal); | |
} | |
} |
Authentication Filter
The authentication filter is responsible of extracting the JWT from the
Authorization
header.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.security; | |
import static com.vedrax.utils.ServletUtils.getHeader; | |
import java.io.IOException; | |
import java.util.Optional; | |
import javax.servlet.FilterChain; | |
import javax.servlet.ServletException; | |
import javax.servlet.http.HttpServletRequest; | |
import javax.servlet.http.HttpServletResponse; | |
import org.springframework.security.authentication.BadCredentialsException; | |
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | |
import org.springframework.security.core.Authentication; | |
import org.springframework.security.core.AuthenticationException; | |
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; | |
import org.springframework.security.web.util.matcher.RequestMatcher; | |
/** | |
* The authentication filter is responsible of retrieving the security token | |
* (JWT) from the <code>Authorization</code> header | |
* | |
* @author remypenchenat | |
*/ | |
public class AuthenticationFilter extends AbstractAuthenticationProcessingFilter { | |
/** | |
* Filter enables only for a given set of URLs. | |
* | |
* @param requiresAuth | |
*/ | |
public AuthenticationFilter(final RequestMatcher requiresAuth) { | |
super(requiresAuth); | |
} | |
@Override | |
public Authentication attemptAuthentication(HttpServletRequest request, | |
HttpServletResponse response) throws AuthenticationException, IOException, ServletException { | |
String token = safeGetSecurityToken(request); | |
//The security token will be available both in principal and credentials attributes | |
Authentication auth = new UsernamePasswordAuthenticationToken(token, token); | |
return getAuthenticationManager().authenticate(auth); | |
} | |
/** | |
* Retrieves the security token from the <code>Authorization</code> header | |
* if any, otherwise throws {@link BadCredentialsException} | |
* | |
* @param request | |
* @return | |
*/ | |
private String safeGetSecurityToken(HttpServletRequest request) { | |
Optional<String> headerOpt = getHeader(request, SecurityConstants.AUTHORIZATION_HEADER); | |
return headerOpt | |
.map(header -> extractSecurityToken(header)) | |
.orElseThrow(() -> new BadCredentialsException("Missing Authentication token")); | |
} | |
private String extractSecurityToken(String header) { | |
//Thrown exception if the token has only bearer or if it does not start with the prefix | |
if (header.length() <= 7 || !header.startsWith(SecurityConstants.TOKEN_PREFIX)) { | |
throw new BadCredentialsException("Authorization header is not valid"); | |
} | |
return header.substring(7); | |
} | |
@Override | |
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { | |
super.successfulAuthentication(request, response, chain, authResult); | |
chain.doFilter(request, response); | |
} | |
} |
Authentication Provider
The authentication provider is responsible of validating the JWT. If the token is valid, we return a
UserPrincipal
otherwise we throw an exception. As you can see we don't access the database at all (our stateless solution!). In order to be accessible by all microservices, this class is also located in our shared module.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.security; | |
import java.util.Optional; | |
import org.apache.commons.lang3.Validate; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | |
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider; | |
import org.springframework.security.core.AuthenticationException; | |
import org.springframework.security.core.userdetails.UserDetails; | |
import org.springframework.security.core.userdetails.UsernameNotFoundException; | |
import org.springframework.stereotype.Component; | |
/** | |
* | |
* @author remypenchenat | |
*/ | |
@Component | |
public class AuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { | |
private final TokenService tokenService; | |
@Autowired | |
public AuthenticationProvider(TokenService tokenService) { | |
this.tokenService = tokenService; | |
} | |
@Override | |
protected void additionalAuthenticationChecks(UserDetails ud, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { | |
} | |
/** | |
* Retrieve authenticated user with the provided security token | |
* | |
* @param string | |
* @param authentication | |
* @return | |
* @throws AuthenticationException | |
*/ | |
@Override | |
protected UserDetails retrieveUser(String string, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { | |
String token = getSecurityToken(authentication); | |
return tokenService.parseToken(token) | |
.orElseThrow(() -> new UsernameNotFoundException("JWT Token [" + token + "] is not valid")); | |
} | |
/** | |
* Extracts security token from {@link UsernamePasswordAuthenticationToken} | |
* | |
* @param authentication | |
* @return | |
*/ | |
private String getSecurityToken(UsernamePasswordAuthenticationToken authentication) { | |
Validate.notNull(authentication, "A UsernamePasswordAuthenticationToken must be provided"); | |
Object token = authentication.getCredentials(); | |
return Optional | |
.ofNullable(token) | |
.map(String::valueOf) | |
.orElseThrow(() -> new UsernameNotFoundException("Cannot find security token for [" + token + "].")); | |
} | |
} |
Security Config
We will configure in the next configuration class all the Spring security staff. In order to be available to all modules, this configuration will be placed in our shared module.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.security; | |
import java.util.Objects; | |
import org.springframework.boot.web.servlet.FilterRegistrationBean; | |
import org.springframework.context.annotation.Bean; | |
import org.springframework.context.annotation.Configuration; | |
import static org.springframework.http.HttpStatus.FORBIDDEN; | |
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; | |
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; | |
import org.springframework.security.config.annotation.web.builders.HttpSecurity; | |
import org.springframework.security.config.annotation.web.builders.WebSecurity; | |
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; | |
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; | |
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; | |
import org.springframework.security.web.AuthenticationEntryPoint; | |
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; | |
import org.springframework.security.web.authentication.HttpStatusEntryPoint; | |
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; | |
import org.springframework.security.web.util.matcher.AntPathRequestMatcher; | |
import org.springframework.security.web.util.matcher.NegatedRequestMatcher; | |
import org.springframework.security.web.util.matcher.OrRequestMatcher; | |
import org.springframework.security.web.util.matcher.RequestMatcher; | |
/** | |
* | |
* The Spring Boot Security configuration | |
* | |
* @author remypenchenat | |
*/ | |
@Configuration | |
@EnableWebSecurity | |
@EnableGlobalMethodSecurity(prePostEnabled = true) | |
public class SecurityConfig extends WebSecurityConfigurerAdapter { | |
private static final RequestMatcher PUBLIC_URLS = new OrRequestMatcher( | |
new AntPathRequestMatcher("/public/**") | |
); | |
private static final RequestMatcher PROTECTED_URLS = new NegatedRequestMatcher(PUBLIC_URLS); | |
AuthenticationProvider provider; | |
public SecurityConfig(final AuthenticationProvider provider) { | |
super(); | |
this.provider = Objects.requireNonNull(provider); | |
} | |
@Override | |
protected void configure(final AuthenticationManagerBuilder auth) { | |
auth.authenticationProvider(provider); | |
} | |
@Override | |
public void configure(final WebSecurity web) { | |
web.ignoring().requestMatchers(PUBLIC_URLS); | |
} | |
@Override | |
protected void configure(final HttpSecurity http) throws Exception { | |
http | |
// use stateless session | |
.sessionManagement().sessionCreationPolicy(STATELESS) | |
.and() | |
// when you request a protected page and you are not yet authenticated | |
.exceptionHandling().defaultAuthenticationEntryPointFor(forbiddenEntryPoint(), PROTECTED_URLS) | |
.and() | |
// Add our custom JWT authenticate provider | |
.authenticationProvider(provider) | |
// Add our custom security filter | |
.addFilterBefore(authenticationFilter(), AnonymousAuthenticationFilter.class) | |
// authorization requests config | |
.authorizeRequests() | |
// Set Admin URL | |
.antMatchers("/administrator/**").hasRole("ADMIN") | |
// Set other Urls | |
.requestMatchers(PROTECTED_URLS) | |
.authenticated() | |
.and() | |
.csrf().disable() | |
.formLogin().disable() | |
.httpBasic().disable() | |
.logout().disable(); | |
} | |
@Bean | |
public AuthenticationEntryPoint forbiddenEntryPoint() { | |
return new HttpStatusEntryPoint(FORBIDDEN); | |
} | |
@Bean | |
public AuthenticationFilter authenticationFilter() throws Exception { | |
final AuthenticationFilter filter = new AuthenticationFilter(PROTECTED_URLS); | |
filter.setAuthenticationManager(authenticationManager()); | |
filter.setAuthenticationSuccessHandler(successHandler()); | |
return filter; | |
} | |
@Bean | |
public SimpleUrlAuthenticationSuccessHandler successHandler() { | |
final SimpleUrlAuthenticationSuccessHandler successHandler = new SimpleUrlAuthenticationSuccessHandler(); | |
successHandler.setRedirectStrategy(new NoRedirectStrategy()); | |
return successHandler; | |
} | |
@Bean | |
public FilterRegistrationBean disableAutoRegistration(final AuthenticationFilter filter) { | |
final FilterRegistrationBean registration = new FilterRegistrationBean(filter); | |
registration.setEnabled(false); | |
return registration; | |
} | |
} |
As you can see, all non public endpoints are protected.
Public Controllers
In the user module, we create a controller for logging in a user into the application.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.user.controller; | |
import com.vedrax.user.dto.LoginDto; | |
import javax.validation.Valid; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.web.bind.annotation.PostMapping; | |
import org.springframework.web.bind.annotation.RequestBody; | |
import org.springframework.web.bind.annotation.RequestMapping; | |
import org.springframework.web.bind.annotation.RestController; | |
import com.vedrax.user.service.AccountService; | |
import org.springframework.security.authentication.BadCredentialsException; | |
/** | |
* | |
* Public Endpoint for user authentication | |
* | |
* @author remypenchenat | |
*/ | |
@RestController | |
@RequestMapping("/public/auth") | |
public class AuthenticationController { | |
private final AccountService accountService; | |
@Autowired | |
public AuthenticationController(AccountService accountService) { | |
this.accountService = accountService; | |
} | |
/** | |
* Sign in a user with the provided credentials | |
* | |
* @param loginDto The credentials | |
* @return Security token if the user is authenticated | |
*/ | |
@PostMapping("/login") | |
public String login(@Valid @RequestBody LoginDto loginDto) { | |
return accountService | |
.login(loginDto.getUsername(), loginDto.getPassword()) | |
.orElseThrow(() -> new BadCredentialsException("Invalid credentials")); | |
} | |
} |
Protected Controllers
The authenticated user can be accessed via theAuthenticationPrincipal
annotation.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.user.controller; | |
import com.vedrax.security.UserPrincipal; | |
import com.vedrax.user.domain.Account; | |
import com.vedrax.user.dto.AccountDto; | |
import javax.validation.Valid; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
import org.springframework.web.bind.annotation.GetMapping; | |
import org.springframework.web.bind.annotation.PostMapping; | |
import org.springframework.web.bind.annotation.RequestBody; | |
import org.springframework.web.bind.annotation.RequestMapping; | |
import org.springframework.web.bind.annotation.RestController; | |
import com.vedrax.user.service.AccountService; | |
/** | |
* | |
* Endpoints for creating and retrieving user | |
* | |
* @author remypenchenat | |
*/ | |
@RestController | |
@RequestMapping("/accounts") | |
public class AccountController { | |
private final AccountService userService; | |
@Autowired | |
public AccountController(AccountService userService) { | |
this.userService = userService; | |
} | |
/** | |
* Registers a new user | |
* | |
* @param accountDto The account data | |
* @return The created account | |
*/ | |
@PostMapping() | |
public Account registerNewAccount(@Valid @RequestBody AccountDto accountDto) { | |
return userService.register(accountDto); | |
} | |
/** | |
* Get the current authenticated user | |
* | |
* @param user The authenticated user | |
* @return The account information extracted from the security token | |
*/ | |
@GetMapping("/current") | |
public UserPrincipal getCurrent(@AuthenticationPrincipal final UserPrincipal user) { | |
return user; | |
} | |
} |
Testing
You can test a protected controller this way :
Thanks for sharing...
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vedrax.user.controller; | |
import com.fasterxml.jackson.databind.ObjectMapper; | |
import com.vedrax.security.TokenService; | |
import com.vedrax.security.UserPrincipal; | |
import com.vedrax.user.Application; | |
import com.vedrax.user.domain.Account; | |
import com.vedrax.user.dto.AccountDto; | |
import org.junit.After; | |
import org.junit.Before; | |
import org.junit.Test; | |
import org.junit.runner.RunWith; | |
import static org.mockito.Mockito.*; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
import org.springframework.boot.test.context.SpringBootTest; | |
import org.springframework.boot.test.mock.mockito.MockBean; | |
import org.springframework.http.MediaType; | |
import org.springframework.test.context.junit4.SpringRunner; | |
import org.springframework.test.web.servlet.MockMvc; | |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; | |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; | |
import com.vedrax.user.service.AccountService; | |
import com.vedrax.user.util.AccountUtility; | |
import static org.hamcrest.Matchers.*; | |
/** | |
* | |
* @author remypenchenat | |
*/ | |
@RunWith(SpringRunner.class) | |
@SpringBootTest(classes = Application.class) | |
@AutoConfigureMockMvc | |
public class AccountControllerUnitTest { | |
private final String BASE_URL = "/accounts"; | |
private final String AUTHORIZATION = "Authorization"; | |
@Autowired | |
private MockMvc mvc; | |
@Autowired | |
private ObjectMapper objectMapper; | |
@Autowired | |
private TokenService tokenService; | |
@MockBean | |
private AccountService accountService; | |
private String securityToken; | |
private String validAccountDtoStr; | |
private String invalidAccountDtoStr; | |
private UserPrincipal userPrincipal; | |
@Before | |
public void setUp() throws Exception { | |
validAccountDtoStr = mockAccount("e.robert@vedrax.com", "Elodie Robert"); | |
invalidAccountDtoStr = mockAccount("invalid", "Elodie Robert"); | |
//create JWT | |
userPrincipal = new UserPrincipal("finance@vedrax.com", "Remy Penchenat", "ADMIN"); | |
securityToken = "Bearer " + tokenService.expiring(userPrincipal); | |
} | |
private String mockAccount(String email, String fullName) throws Exception { | |
AccountDto accountDto = AccountUtility.buildAccountDto(email, fullName); | |
Account account = AccountUtility.getAccount(accountDto); | |
when(accountService.register(accountDto)).thenReturn(account); | |
return objectMapper.writeValueAsString(accountDto); | |
} | |
@After | |
public void tearDown() { | |
validAccountDtoStr = null; | |
invalidAccountDtoStr = null; | |
securityToken = null; | |
} | |
@Test | |
public void whenRegistrationIsValid_thenReturnsStatus200() throws Exception { | |
mvc.perform(post(BASE_URL) | |
.header(AUTHORIZATION, securityToken) | |
.contentType(MediaType.APPLICATION_JSON) | |
.content(validAccountDtoStr)) | |
.andExpect(status().isOk()); | |
} | |
@Test | |
public void whenRegistrationHasNoSecurityToken_thenReturnsStatus401() throws Exception { | |
mvc.perform(post(BASE_URL) | |
.contentType(MediaType.APPLICATION_JSON) | |
.content(validAccountDtoStr)) | |
.andExpect(status().isUnauthorized()); | |
} | |
@Test | |
public void whenRegistrationIsInvalid_thenReturnsStatus400() throws Exception { | |
mvc.perform(post(BASE_URL) | |
.header(AUTHORIZATION, securityToken) | |
.contentType(MediaType.APPLICATION_JSON) | |
.content(invalidAccountDtoStr)) | |
.andExpect(status().isBadRequest()) | |
.andReturn(); | |
} | |
@Test | |
public void whenGettingCurrentUserWithSecurityToken_thenReturnsStatus200() throws Exception { | |
mvc.perform(get(BASE_URL + "/current") | |
.header(AUTHORIZATION, securityToken) | |
.contentType(MediaType.APPLICATION_JSON)) | |
.andExpect(status().isOk()) | |
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) | |
.andExpect(jsonPath("$.username", is("finance@vedrax.com"))); | |
} | |
@Test | |
public void whenGettingCurrentUserWitoutSecurityToken_thenReturnsStatus401() throws Exception { | |
mvc.perform(get(BASE_URL + "/current")) | |
.andExpect(status().isUnauthorized()); | |
} | |
} |
Thanks for sharing...
Comments
Post a Comment