Reputation: 7719
In my spring boot app, there are REST endpoints that are protected using a subclass of AbstractAuthenticationProcessingFilter
. I don't use sessions, I use Java web tokens (JWT):
public class JwtAuthenticationFilter extends
AbstractAuthenticationProcessingFilter {
@Autowired
private UserAccountService userAccountService;
protected JwtAuthenticationFilter() {
super(SecurityConstants.DEFAULT_FILTER_PROCESS_URL);
}
/*
* Parses the request header and returns authentication token if credentials
* are valid
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException,
IOException, ServletException {
String token = request
.getHeader(SecurityConstants.AUTHENTICATION_HEADER);
if (token != null) {
// parse the token
String username = null;
try {
username = Jwts
.parser()
.setSigningKey(SecurityConstants.JWT_SECRET)
.parseClaimsJws(
token.replace(SecurityConstants.BEARER, ""))
.getBody().getSubject();
} catch (Exception e) {
throw new BadCredentialsException(
"Bad username/password presented");
}
UserAccountEntity userAccount = userAccountService.loadUserByUsername(username);
if (userAccount != null) {
return new UsernamePasswordAuthenticationToken(
userAccount.getUsername(), userAccount.getPassword(),
userAccount.getAuthorities());
}
}
throw new BadCredentialsException("Bad username/password presented");
}
/*
* we must set authentication manager for our custom filter, otherwise it
* errors out
*/
@Override
@Autowired
public void setAuthenticationManager(
AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SecurityContextHolder.getContext().setAuthentication(authResult);
chain.doFilter(request, response);
}}
And the app security configuration class is the following:
@EnableWebSecurity
public class AppSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserAccountService userAccountService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// TODO re-enable csrf after dev is done
.csrf()
.disable()
// we must specify ordering for our custom filter, otherwise it
// doesn't work
.addFilterAfter(jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class)
// we don't need Session, as we are using jwt instead. Sessions
// are harder to scale and manage
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
/*
* Ignores the authentication endpoints (signup and login)
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/api/authentication/**").and().ignoring()
.antMatchers(HttpMethod.OPTIONS, "/**");
}
/*
* Set user details services and password encoder
*/
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.userDetailsService(userAccountService).passwordEncoder(
passwordEncoder());
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
/*
* By default, spring boot adds custom filters to the filter chain which
* affects all requests this should be disabled.
*/
@Bean
public FilterRegistrationBean<JwtAuthenticationFilter> rolesAuthenticationFilterRegistrationDisable(
JwtAuthenticationFilter filter) {
FilterRegistrationBean<JwtAuthenticationFilter> registration = new FilterRegistrationBean<JwtAuthenticationFilter>(
filter);
registration.setEnabled(false);
return registration;
}}
I want my Websocket connection to be authenticated on handshake. The subsequent communication should be ok, as they follow an authenticated handshake.
I tried MANY different ways including setting handshake handler:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
public static final String IP_ADDRESS = "IP_ADDRESS";
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic"); /*Enable a simple in-memory broker for the clients to subscribe to channels and receive messages*/
config.setApplicationDestinationPrefixes("/ws"); /*The prefix for the controller's endpoints*/
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/wsocket").setAllowedOrigins("http://localhost:8080").setHandshakeHandler(new DefaultHandshakeHandler() { /* Websocket handshake endpoint*/
@Override
protected Principal determineUser(ServerHttpRequest request,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
Principal principal = request.getPrincipal();
if (principal == null) {
throw new InvalidCredentialsException();
}
return super.determineUser(request, wsHandler, attributes);
}
}).withSockJS().setInterceptors(httpSessionHandshakeInterceptor());
}
@Bean
public HandshakeInterceptor httpSessionHandshakeInterceptor() {
return new HandshakeInterceptor() {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
attributes.put(IP_ADDRESS, servletRequest.getRemoteAddress());
}
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
};}}
Which I couldn't make it work.
I also tried the following:
@Configuration
public class WebSocketSecurityConfiguration extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages
.simpDestMatchers("/wsocket/*").authenticated();
}
/**
* Disables CSRF for Websockets.
*/
@Override
protected boolean sameOriginDisabled() {
return true;
}}
I expect my JWTAuthenticationFilter.attemptAuthentication()
to be hit, but doesn't.
The following is the websocket configuration class:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
public static final String IP_ADDRESS = "IP_ADDRESS";
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic"); /*Enable a simple in-memory broker for the clients to subscribe to channels and receive messages*/
config.setApplicationDestinationPrefixes("/ws"); /*The prefix for the controller's endpoints*/
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/wsocket").setAllowedOrigins("http://localhost:8080").withSockJS();
}}
What am I doing wrong here? Is there a way to authenticate the websocket handshake request (which is a HTTP request) with JWT?
TL;DR There is a websocket endpoint, which I would like to be secured by a JWT authentication filter. As far as I know, the websocket handkshake is a regular HTTP request and this should be possible.
Upvotes: 2
Views: 3480
Reputation: 7719
I found a way to make it work. I got rid of DefaultHandshakeHandler
, and also WebSocketSecurityConfiguration
sub-class.
I changed the websocket URL from /wsocket
to /api/wsocket
in order to match with the SecurityConstants.DEFAULT_FILTER_PROCESS_URL
that is passed to AbstractAuthenticationProcessingFilter
from JWTAuthenticationFilter
.
I pass the jwt token as a request parameter from the UI. In JWTAuthenticationFilter
request parameters are accessible, which makes it possible to authenticate the request.
Upvotes: 1
Reputation: 508
I faced the exact same problem some time ago. From what I recall, the first step is the browser trying to stablish a connection with the server, and as far as I remember, the issue was that the browser does not send the authentication header on that request. That is why authentication for websocket is handled separatedly instead of authenticating everything on the same place. I honestly do not remember the details. What follows is my approach to solving that problem. Feel free to ask for clarification and I will try to remember.
1) Allow requests to your websocket endpoint:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
JWTService jWTService;
@Override
protected void configure(final HttpSecurity http) throws Exception {
http.
httpBasic().disable().
csrf().disable().
authorizeRequests().antMatchers("/ws/**").permitAll().and().
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
2) Use a channel interceptor to validate WS requests:
public class ChannelSubscriptionInterceptor extends ChannelInterceptorAdapter {
JWTService jWTService;
public ChannelSubscriptionInterceptor(JWTService jwtService) {
this.jWTService = jwtService;
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
Authentication authenticatedUser = null;
List<String> authorizationHeader = headerAccessor.getNativeHeader("Authorization");
if(authorizationHeader != null) {
authenticatedUser = authenticateUser(authorizationHeader.get(0));
accessor.setUser(authenticatedUser);
}
if (StompCommand.SUBSCRIBE.equals(headerAccessor.getCommand())) {
if(!validateSubscription(authenticatedUser, headerAccessor.getDestination())) {
throw new MessagingException("No tiene permiso para suscribirse a este tópico");
}
}
return message;
}
private boolean validateSubscription(Principal user, String channel) {
//User not authenticated
if (user == null) {
return false;
}
//User trying to subscribe to a channel that doesn't belong to him
if(channel.startsWith("/user") && !channel.startsWith("/user/" + user.getName() + "/")) {
return false;
}
return true;
}
public Authentication authenticateUser(String authHeader) {
try {
JWToken token = new JWToken(authHeader);
Authentication authentication = this.jWTService.validateAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication;
} catch (Exception e) {
return null;
}
}
3) Register interceptor:
@Order(HIGHEST_PRECEDENCE + 99)
public class WebSocketMessageBrokerConfigurer extends AbstractWebSocketMessageBrokerConfigurer {
@Autowired
JWTService jWTService;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue", "/user");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(new ChannelSubscriptionInterceptor(this.jWTService));
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/ws")
.setAllowedOrigins("*")
.withSockJS()
;
}
}
4) Make sure messages are authenticated:
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketSecurityConfig
extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages
.simpTypeMatchers(SimpMessageType.CONNECT).permitAll()
.anyMessage().authenticated();
}
@Override
protected boolean sameOriginDisabled() {
return true;
}
}
The
@Order(HIGHEST_PRECEDENCE + 99)
was used because we had some conflicts with other configuration classes, but you might not need it.
Upvotes: 1
Reputation: 331
Are you sure that SecurityConstants.DEFAULT_FILTER_PROCESS_URL
is actually set to the endpoint your websocket is exposed to? I saw in another post of yours that you had issues with the values not being set on startup. Maybe just hard code it for testing.
attemptAuthentication will only be hit if the url matches the one set in the constructor.
However, I never used AbstractAuthenticationProcessingFilter but usually extend from OncePerRequestFilter. Maybe that could work for you too.
Upvotes: 1