Spring Security's architecture and the documentation can be a bit hard to digest, it took me a lot of time to even start feeling like I understand it, and what I found is that there are many misleading third party copy paste tutorials which don't follow the intended patterns from spring security and can cause you more trouble than good. So my advice is to stick to the official docs. There is a lot, and I feel like a lot of it needs to be updated, since who the f*** uses xml configuration in 2022, but there are also good, well explained parts.
Get yourself familiar about the filters and filter chains part because I won't be talking about that here, but instead I will focus on the authentication part.
Here is a docs link if you want to see detailed explanation of the authentication architecture.
There are 3 main things we should think about
-
AbstractAuthenticationProcessingFilter
- abstract class -
Authentication
- Interface -
AuthenticationProvider
- Interface
When the request comes in it goes through a list of filters, we want to place a filter at a certain spot which will be responsible for authenticating the user. When creating this filter we should extend AbstractAuthenticationProcessingFilter
. When we do that there is one method we need to implement Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
What we want to do inside this method is to try and create our specific Authentication
implementation and delegate the authentication to the AuthenticationManager
.
You might say "I have no clue what you just said" and that's ok, keep reading.
First, AuthenticationManager
, here's what the docs say:
AuthenticationManager is the API that defines how Spring Security’s Filters perform authentication. The Authentication that is returned is then set on the SecurityContextHolder by the controller (i.e. Spring Security’s Filterss) that invoked the AuthenticationManager. If you are not integrating with Spring Security’s Filterss you can set the SecurityContextHolder directly and are not required to use an AuthenticationManager.
While the implementation of AuthenticationManager could be anything, the most common implementation is ProviderManager.
As you've read, the Authentication
that is returned from the AuthenticationManager.authenticate()
is set on the SecurityContextHolder
, you should check what this means also on the official documentation as I won't be explaining in details, but in short SecurityContextHolder
is where the Authentication
(authenticated user) information is stored.
But lets track back a bit, I said that we should create a specific Authentication
inside AbstractAuthenticationProcessingFilter.attemptAuthentication()
, and why is that?
Well the implementation of the AuthenticationManager
which you're likely to use unless you want to loose your hair and implement your own, is a ProviderManager
.
This is important because ProviderManager
can have multiple AuthenticationProvider
's.
When some type of Authentication
is sent to ProviderManager
it checks if it has a provider which knows how to authenticate that specific type of Authentication
.
It does so with the boolean supports(Class<?> authentication)
method
If the Authentication
is supported then the Authentication AuthenticationProvider.authenticate(Authentication authenticaiton)
method will be called.
Here is where we should either return Authentication
or throw AuthenticationException
Let's see an example, maybe it will make more sense.
We want to create 2 Authentication implementations.
-
AdminAuthentication
- Will authenticate based on jwt token -
PluginAuthentication
- Will authenticate based on plugin id and secret
We should also have 2 different filters handling each Authentication type.
AdminAuthenticationFilter
PluginAuthenticationFilter
Below you can see the most important part of Spring security's
AbstractAuthenticationProcessingFilter
...
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (!this.requiresAuthentication(request, response)) {
chain.doFilter(request, response);
} else {
try {
Authentication authenticationResult = this.attemptAuthentication(request, response);
if (authenticationResult == null) {
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
this.successfulAuthentication(request, response, chain, authenticationResult);
} catch (InternalAuthenticationServiceException var5) {
this.logger.error("An internal error occurred while trying to authenticate the user.", var5);
this.unsuccessfulAuthentication(request, response, var5);
} catch (AuthenticationException var6) {
this.unsuccessfulAuthentication(request, response, var6);
}
}
}
...
First it checks if the request requires authentication, there is a setter you can use to set requiresAuthenticationRequestMatcher
which will be checked against the request.
You can also initialize this when creating an instance of the filter, there are 4 different types of constructor you can choose from, one has defaultFilterProcessesUrl
passed as string which will be translated into requiresAuthenticationRequestMatcher
as RequestMatcher
, and the others also offer you possibility to pass RequestMatcher
directly.
Next authenticationResult
is checked, if it's null it just returns which will not continue the filterChain
, it will go backwards, executing the code after each filters doFilter()
method.
Then there's this part with the session, which I'm not interested in since I'm not using sessions, and there's a property you can set which would allow the filterChain
to continue before completing authentication, which I don't know why would be useful.
And finally we have a call to successfullAuthentication()
and some catch blocks where you can see AuthenticationException
is handled, hence we throw that to call AbstractAuthenticationProcessingFilter.unsuccessfulAuthentication()
Here are the default successfulAuthentication
and unsuccessfulAuthentication
methods of AbstractAuthenticationProcessingFilter
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
this.logger.trace("Failed to process authentication request", failed);
this.logger.trace("Cleared SecurityContextHolder");
this.logger.trace("Handling authentication failure");
this.rememberMeServices.loginFail(request, response);
this.failureHandler.onAuthenticationFailure(request, response, failed);
}
You will later see we override the successfulAuthentication()
and we also register our own NoRedirectSuccessHandler
.
You can also override unsuccessfulAuthentication
, but in my case it's fine, it only sends a 401 response.
If we strip all these details what we're trying to do, is send some authentication implementation from some filter to a custom provider which will then authenticate or not. ProviderManager
and the AbstractAuthenticationProcessingFilter
take care of most of the work but you can easily configure and override any parts you want.
There's a lot more details but I don't want to copy all the source code of all spring security's classes here.
It's important to understand this architecture and how things are connected, then you can easily debug these classes and see exactly what's happening.
Let's see the complete implementation, without any Spring Security managed code.
AdminAuthenticationFilter
:
public class AdminAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public AdminAuthenticationFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
super(defaultFilterProcessesUrl, authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
String token = request.getHeader("Authorization");
if(token == null || !token.startsWith("Bearer ")){
throw new AuthenticationServiceException("Token not provided or is invalid");
}
return getAuthenticationManager().authenticate(new PawshopeAdminAuthentication(token.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);
}
}
PluginAuthenticationFilter
public class PluginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public PluginAuthenticationFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
super(defaultFilterProcessesUrl, authenticationManager);
}
protected PluginAuthenticationFilter(String defaultFilterProcessesUrl) {
super(defaultFilterProcessesUrl);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
PluginAuthentication authentication;
String pluginId = request.getHeader("PLUGIN_ID");
String secret = request.getHeader("PLUGIN_SECRET");
if((pluginId == null || pluginId.isBlank()) && (secret == null || secret.isBlank()))
throw new AuthenticationServiceException("Invalid credentials provided");
authentication = new PluginAuthentication(secret, pluginId);
return getAuthenticationManager().authenticate(authentication);
}
@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);
}
}
AdminAuthentication
public class AdminAuthentication implements Authentication {
private AuthenticatedAdmin principal;
private boolean isAuthenticated;
private List<SimpleGrantedAuthority> authorities;
public PawshopeAdminAuthentication(String token){
this.token = token;
}
public PawshopeAdminAuthentication(AuthenticatedAdmin principal, boolean isAuthenticated){
this.principal = principal;
this.isAuthenticated = isAuthenticated;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getDetails() {
return null;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public boolean isAuthenticated() {
return this.isAuthenticated;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
this.isAuthenticated = isAuthenticated;
}
@Override
public String getName() {
return this.principal.getUsername();
}
}
PluginAuthentication
public class PluginAuthentication implements Authentication {
@Getter
@Setter
private String secret;
@Getter
@Setter
private String pluginId;
private AuthenticatedPlugin principal;
private boolean isAuthenticated;
private List<SimpleGrantedAuthority> authorities;
public PluginAuthentication(String secret, String pluginId) {
this.secret = secret;
this.pluginId = pluginId;
}
public PluginAuthentication(AuthenticatedPlugin principal, boolean isAuthenticated) {
this.principal = principal;
this.isAuthenticated = isAuthenticated;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getDetails() {
return null;
}
@Override
public Object getPrincipal() {
return principal;
}
@Override
public boolean isAuthenticated() {
return isAuthenticated;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
this.isAuthenticated = isAuthenticated;
}
@Override
public String getName() {
return principal.getPluginId();
}
}
AdminAuthenticationProvider
public class AdminAuthenticationProvider implements AuthenticationProvider {
@Autowired
TokenService tokenService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
PawshopeAdminAuthentication auth = (PawshopeAdminAuthentication) authentication;
try {
tokenService.validateToken(auth.getToken());
}catch (Exception e){
throw new AuthenticationServiceException("Invalid token signature");
}
var decoded = tokenService.decodeToken(auth.getToken());
AuthenticatedAdmin authenticatedAdmin = new AuthenticatedAdmin();
authenticatedAdmin.setUsername(decoded.getSubject());
authenticatedAdmin.setRoles(decoded.getClaim("roles").asList(String.class));
authenticatedAdmin.setUserId(decoded.getClaim("userId").asString());
if(!authenticatedAdmin.getRoles().contains("ADMIN")){
log.error("Authenticated user is not ADMIN, access denied, username: {}", authenticatedAdmin.getUsername());
throw new AuthorizationServiceException("Unauthorized, missing authorities");
}
return new PawshopeAdminAuthentication(authenticatedAdmin, true);
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(PawshopeAdminAuthentication.class);
}
}
PluginAuthenticationProvider
public class PluginAuthenticationProvider implements AuthenticationProvider {
@Autowired
PluginRepository pluginRepository;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
PluginAuthentication credentials = (PluginAuthentication) authentication;
if(credentials.getPluginId() == null || credentials.getPluginId().isBlank()){
throw new AuthenticationServiceException("Invalid credentials");
}
var plugin = pluginRepository.findPluginByPluginId(UUID.fromString(credentials.getPluginId()));
if(plugin.isEmpty()){
log.error("Unauthorized, no plugin with id: {}", credentials.getPluginId());
throw new AuthenticationServiceException("Invalid pluginId");
}
if(plugin.get().getSecret().equals(credentials.getSecret())){
return new PluginAuthentication(new AuthenticatedPlugin(credentials.getPluginId()), true);
}
log.error("Unauthorized, invalid secret for plugin with id: {}", credentials.getPluginId());
throw new AuthenticationServiceException("Wrong secret");
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(PluginAuthentication.class);
}
}
SecurityConfig
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
PluginAuthenticationProvider provider;
@Autowired
AdminAuthenticationProvider adminAuthenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.authenticationProvider(provider)
.authenticationProvider(adminAuthenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http = http.cors().and().csrf().disable();
http.authenticationManager(authenticationManager());
http = http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and();
http
.anonymous()
.and()
.authorizeRequests()
.antMatchers("/api/v1/anonymous/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(pluginAuthenticationFilter(), LogoutFilter.class);
http.addFilterBefore(adminAuthenticationFilter(), LogoutFilter.class);
}
@Bean
public AdminAuthenticationFilter adminAuthenticationFilter() throws Exception {
var filter = new AdminAuthenticationFilter("/api/v1/plugins/**", authenticationManager());
filter.setAuthenticationSuccessHandler(successHandler());
return filter;
}
@Bean
public PluginAuthenticationFilter pluginAuthenticationFilter() throws Exception {
var filter = new PluginAuthenticationFilter("/api/v1/wallet/**", authenticationManager());
filter.setAuthenticationSuccessHandler(successHandler());
return filter;
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Bean
SimpleUrlAuthenticationSuccessHandler successHandler() {
final SimpleUrlAuthenticationSuccessHandler successHandler = new SimpleUrlAuthenticationSuccessHandler();
successHandler.setRedirectStrategy(new NoRedirectStrategy());
return successHandler;
}
}
NoRedirectStrategy
public class NoRedirectStrategy implements RedirectStrategy {
@Override
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
//Do nothing, no redirects in REST
}
}
And of course, to prove what I wrote works here are some tests :)
Testing wallet controller
@Test
void getBalance_success() {
var wallet = persistWallet();
var response = template.exchange("/api/v1/anonymous/wallet/"+wallet.getShelterId()+"/balance", HttpMethod.GET,
HttpEntity.EMPTY, String.class);
var responseBody = gson.fromJson(response.getBody(), BalanceResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseBody.getAmount()).isEqualTo(new BigDecimal("10.00"));
}
@Test
void createTransaction_success() {
var shelterWallet = persistWallet();
var plugin = persistPlugin();
var reqBody = new CreateTransactionRequest();
reqBody.setShelterId(shelterWallet.getShelterId());
reqBody.setAmount(BigDecimal.TEN);
HttpHeaders headers = new HttpHeaders();
headers.add("PLUGIN_ID", plugin.getPluginId().toString());
headers.add("PLUGIN_SECRET", plugin.getSecret());
HttpEntity<CreateTransactionRequest> req = new HttpEntity<>(reqBody, headers);
var response = template.exchange("/api/v1/wallet/transactions", HttpMethod.POST, req, String.class);
var responseBody = gson.fromJson(response.getBody(), TransactionResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
@Test
void cancelLTransaction_401() {
var response = template.exchange("/api/v1/wallet/transactions", HttpMethod.POST, HttpEntity.EMPTY, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
Testing plugin controller
@Test
void createPlugin_success() {
var reqBody = new CreatePluginRequest();
reqBody.setName("Skrill");
HttpHeaders headers = new HttpHeaders();
//Real bearer from admin user created on authorization service
headers.add("Authorization", "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuZXJtaW5rbyIsInJvbGVzIjpbIkFETUlOIl0sImlzcyI6InBhd3Nob3BlLWF1dGgiLCJleHAiOjE2NDg5MjAxMzQsInVzZXJJZCI6ImIxOGFmODhlLTZhMTMtNDBhYy1iYmM0LTFhNzg4ZjFmZGM3YSJ9.8QU8R6GDkLSWfRAnpvaWvVclCO9ZB8pXcLBx7aoDCao");
HttpEntity<CreatePluginRequest> req = new HttpEntity<>(reqBody, headers);
var response = template.exchange("/api/v1/plugins", HttpMethod.POST, req, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
@Test
void createPlugin_401() {
var reqBody = new CreatePluginRequest();
reqBody.setName("Skrill");
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "");
HttpEntity<CreatePluginRequest> req = new HttpEntity<>(reqBody, headers);
var response = template.exchange("/api/v1/plugins", HttpMethod.POST, req, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
For the sake of this not being 2000 lines, a lot of code is stripped, but the important stuff is there.
Top comments (0)