1. Introduction
In previous lectures we have covered many aspects of the Spring/Spring Boot authentication and authorization frameworks and have mostly demonstrated that with HTTP Basic Authentication. In this lecture we are going to use what we learned about the framework to implement a different authentication strategy — JSON Web Token (JWT) and JSON Web Signature (JWS).
The focus on this lecture will be a brief introduction to JSON Web Tokens (JWT) and how they could be implemented in the Spring/Spring Boot Security Framework. The real meat of this lecture is to provide a concrete example of how to leverage and extend the provided framework.
1.1. Goals
You will learn:
-
what is a JSON Web Token (JWT) and JSON Web Secret (JWS)
-
what problems does JWT/JWS solve with API authentication and authorization
-
how to write and integrate custom authentication and authorization framework classes to implement an alternate security mechanism
-
how to leverage Spring Expression Language to evaluate parameters and properties of the
SecurityContext
1.2. Objectives
At the conclusion of this lecture and related exercises, you will be able to:
-
construct and sign a JWT with claims representing an authenticated user
-
verify a JWS signature and parse the body to obtain claims to re-instantiate an authenticated user details
-
identify the similarities and differences in flows between HTTP Basic and JWS authentication/authorization flows
-
build a custom JWS authentication filter to extract login information, authenticate the user, build a JWS bearer token, and populate the HTTP response header with its value
-
build a custom JWS authorization filter to extract the JWS bearer token from the HTTP request, verify its authenticity, and establish the authenticated identity for the current security context
-
implement custom error reporting with authentication and authorization
2. Identity and Authorities
Some key points of security are to identify the caller and determine authorities they have.
-
When using BASIC authentication, we presented credentials each time. This was all in one shot, every time on the way to the operation being invoked.
-
When using FORM authentication, we presented credentials (using a FORM) up front to establish a session and then referenced that session on subsequent calls.
The benefit to BASIC is that is stateless and can work with multiple servers — whether clustered or peer services. The bad part about BASIC is that we must present the credentials each time and the services must have access to our user details (including passwords) to be able to do anything with them.
The benefit to FORM is that we present credentials one time and then reference the work of that authentication through a session ID. The bad part of FORM is that the session is on the server and harder to share with members of a cluster and impossible to share with peer services.
What we intend to do with token-based authentication is to mimic the one-time login of FORM and stateless aspects of BASIC. To do that — we must give the client at login, information they can pass to the services hosting operations that can securely identify them (at a minimum) and potentially identify the authorities they have without having that stored on the server hosting the operation.
2.1. BASIC Authentication/Authorization
To better understand the token flow, I would like to start by reviewing the BASIC Auth flow.
-
the
BasicAuthenticationFilter
("the filter") is called in its place within theFilterChainProxy
-
the filter extracts the username/password credentials from the
Authorization
header and stages them in aUsernamePasswordAuthenticationToken
("the authRequest") -
the filter passes the authRequest to the
AuthenticationManager
to authenticate -
the
AuthenticationManager
, thru its assignedAuthenticationProvider
, successfully authenticates the request and builds an authResult -
the filter receives the successful response with the authResult hosting the user details — including username and granted authorities
-
the filter stores the authResult in the
SecurityContext
-
the filter invokes the next filter in the chain — which will eventually call the target operation
All this — authentication and user details management — must occur within the same server as the operation for BASIC Auth.
3. Tokens
With token authentication, we are going to break the flow into two parts: authentication/login and authorization/operation.
3.1. Token Authentication/Login
The following is a conceptual depiction of the authentication flow.
It differs from the BASIC Authentication flow in that nothing is
stored in the SecurityContext
during the login/authentication.
Everything needed to authorize the
follow-on operation call is encoded into a Bearer Token
and
returned to the caller in an Authorization
header. Things encoded
in the bearer token are referred to as "claims".
Step 2 extracts the username/password from a POST payload — very similar to FORM Auth. However, we could have just as easily implemented the same extract technique used by BASIC Auth.
Step 7 returns the the token representation of the authResult back to the caller that just successfully authenticated. They will present that information later when they invoke an operation in this or a different server. There is no requirement for the token returned to be used locally. The token can be used on any server that trusts tokens created by this server. The biggest requirement is that we must trust the token is built by something of trust and be able to verify that it never gets modified.
3.2. Token Authorization/Operation
To invoke the intended operation, the caller must include an Authorization
header with the bearer token returned to them from the login.
This will carry their identity (at a minimum) and authorities encoded
in the bearer token’s claims section.
-
the Token AuthorizationFilter ("the filter") is called by the
FilterChainProxy
-
the filter extracts the bearer token from the
Authorization
header and wraps that in an authRequest -
the filter passes the authRequest to the
AuthenticationManager
to authenticate -
the
AuthenticationManager
with its TokenAuthenticationProvider
are able to verify the contents of the token and re-build the necessary portions of the authResult -
the authResult is returned to the filter
-
the filter stores the authResult in the
SecurityContext
-
the filter invokes the next filter in the chain — which will eventually call the target operation
Bearer Token has Already Been Authenticated
Since the filter knows this is a bearer token, it could have
bypassed the call to the AuthenticationManager . However, by doing so — it makes the responsibilities of the classes consistent with their
original purpose and also gives the AuthenticationProvider the option
to obtain more user details for the caller.
|
3.3. Authentication Separate from Authorization
Notice the overall client to operation call was broken into two independent workflows. This enables the client to present their credentials a limited amount of times and for the operations to be spread out through the network. The primary requirement to allow this to occur is TRUST.
We need the ability for the authResult to be represented in a token, carried around by the caller, and presented later to the operations with the trust that it was not modified.
JSON Web Tokens (JWT) are a way to express the user details within the body of a token. JSON Web Signature (JWS) is a way to assure that the original token has not been modified. JSON Web Encryption (JWE) is a way to assure the original token stays private. This lecture and example will focus in JWS — but it is common to refer to the overall topic as JWT.
3.4. JWT Terms
The following table contains some key, introductory terms related to JWT.
a compact JSON claims representation that makes up the payload
of a JWS or JWE structure e.g.,
Basically — this is where we place what we want to represent. In our case, we will be representing the authenticated principal and their assigned authorities. |
|
represents content secured with a digital signature (signed with a private key and verifiable using a sharable public key) or Message Authentication Codes (MACs) (signed and verifiable using a shared, symmetric key) using JSON-based data structures |
|
represents encrypted content using JSON-based data structures |
|
a registry of required, recommended, and optional algorithms and identifiers to be used with JWS and JWE |
|
JSON Object Signing and Encryption (JOSE) Header |
JSON document containing cryptographic operations/parameters used.
e.g., |
JWS Payload |
the message to be secured — an arbitrary sequence of octets |
JWS Signature |
digital signature or MAC over the header and payload |
Unsecured JWS |
JWS without a signature ( |
JWS Compact Serialization |
a representation of the JWS as a compact, URL-safe String meant for
use in query parameters and HTTP headers |
JWS JSON Serialization |
a JSON representation where individual fields may be signed using one or more keys. There is no emphasis for compact for this use but it makes use of many of the underlying constructs of JWS. |
4. JWT Authentication
With the general workflows understood and a few concepts of JWT/JWS introduced, I want to update the diagrams slightly with real classnames from the examples and walk through how we can add JWT authentication to Spring/Spring Boot.
4.2. Example JWT Authorization/Operation Call Flow
Lets take a look at the implementation to be able to fully understand both JWT/JWS and leveraging the Spring/Spring Boot Security Framework.
5. Maven Dependencies
Spring does not provide its own standalone JWT/JWS library or contain a direct reference to any. I happen to be using the jjwt library from jsonwebtoken.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
6. JwtConfig
At the bottom of the details of our JWT/JWS authentication and authorization
example is a @ConfigurationProperties
class to represent the configuration.
@ConfigurationProperties(prefix = "jwt")
@Data
@Slf4j
public class JwtConfig {
@NotNull
private String loginUri; (1)
private String key; (2)
private String authoritiesKey = "auth"; (3)
private String headerPrefix = "Bearer "; (4)
private int expirationSecs=60*60*24; (5)
public String getKey() {
if (key==null) {
key=UUID.randomUUID().toString();
log.info("generated JWT signing key={}",key);
}
return key;
}
public SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(getKey().getBytes(Charset.forName("UTF-8")));
}
public SecretKey getVerifyKey() {
return getSigningKey();
}
}
1 | login-uri defines the URI for the JWT authentication |
2 | key defines a value to build a symmetric SecretKey |
3 | authorities-key is the JSON key for the user’s assigned authorities
within the JWT body |
4 | header-prefix defines the prefix in the Authorization header.
This will likely never change, but it is good to define it in a single,
common place |
5 | expiration-secs is the number of seconds from generation for when
the token will expire. Set this to a low value to test expiration and
large value to limit login requirements |
6.1. JwtConfig application.properties
The following shows an example set of properties defined for the
@ConfigurationProperties
class.
(1) jwt.key=123456789012345678901234567890123456789012345678901234567890 jwt.expiration-secs=300000000 jwt.login-uri=/api/login
1 | the key must remain protected — but for symmetric keys must be shared
between signer and verifiers |
7. JwtUtil
This class contains all the algorithms that are core to implementing
token authentication using JWT/JWS. It is configured by value in JwtConfig
.
@RequiredArgsConstructor
public class JwtUtil {
private final JwtConfig jwtConfig;
7.1. Dependencies on JwtUtil
The following diagram shows the dependencies on JwtUtil
and
also on JwtConfig
.
-
JwtAuthenticationFilter
needs to process requests to the loginUri, generate a JWS token for successfully authenticated users, and set that JWS token on the HTTP response -
JwtAuthorizationFilter
processes all messages in the chain and gets the JWS token from theAuthorization
header. -
JwtAuthenticationProvider
parses the String token into anAuthentication
result.
JwtUtil
handles the meat of that work relative to JWS. The other
classes deal with plugging that work into places in the security
flow.
7.2. JwtUtil: generateToken()
The following code snippet shows creating a JWS builder that will end up signing the header and payload. Individual setters are called for well-known claims. A generic claim(key, value) is used to add the authorities.
import io.jsonwebtoken.Jwts;
...
public String generateToken(Authentication authenticated) {
String token = Jwts.builder()
.setSubject(authenticated.getName()) (1)
.setIssuedAt(new Date())
.setExpiration(getExpires()) (2)
.claim(jwtConfig.getAuthoritiesKey(), getAuthorities(authenticated))
.signWith(jwtConfig.getSigningKey())
.compact();
return token;
}
1 | JWT has some well-known claim values |
2 | claim(key,value) used to set custom claim values |
7.3. JwtUtil: generateToken() Helper Methods
The following helper methods are used in setting the claim values of the JWT.
protected Date getExpires() { (1)
Instant expiresInstant = LocalDateTime.now()
.plus(jwtConfig.getExpirationSecs(), ChronoUnit.SECONDS)
.atZone(ZoneOffset.systemDefault())
.toInstant();
return Date.from(expiresInstant);
}
protected List<String> getAuthorities(Authentication authenticated) {
return authenticated.getAuthorities().stream() (2)
.map(a->a.getAuthority())
.collect(Collectors.toList());
}
1 | calculates an instant in the future — relative to local time — the token will expire |
2 | strip authorities down to String authorities to make marshalled value less verbose |
The following helper method in the JwtConfig
class generates a SecretKey
suitable for signing the JWS.
...
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
public class JwtConfig {
public SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(getKey() (1)
.getBytes(Charset.forName("UTF-8")));
}
1 | the hmacSha algorithm and the 40 character key will
generate a HS384 SecretKey for signing |
7.4. Example Encoded JWS
The following is an example of what the token value will look like. There are three base64 values separated by a period "." each. The first represents the header, the second the body, and the third the cryptographic signature of the header and body.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmcmFzaWVyIiwiaWF0IjoxNTk0ODk1Nzk3LCJleHAiOjE1OTQ4OTk1MTcsImF1dGhvcml0aWVzIjpbIlBSSUNFX0NIRUNLIiwiUk9MRV9DVVNUT01FUiJdLCJqdGkiOiI5NjQ3MzE1OS03MTNjLTRlN2EtYmE4Zi0zYWMwMzlmODhjZGQifQ.ED-j7mdO2bwNdZdI4I2Hm_88j-aSeYkrbdlEacmjotU
(1)
1 | base64(JWS Header).base64(JWS body).base64(sign(header + body)) |
There is no set limit to the size of HTTP headers. However, it has been pointed out that Apache defaults to an 8KB limit and IIS is 16KB. The default size for Tomcat is 4KB. In case you were counting, the above string is 272 characters long. |
7.5. Example Decoded JWS Header and Body
Example Decoded JWS Header and Body
|
The following is what is produced if we base64 decode the first two
sections. We can use sites like
jsonwebtoken.io
and
jwt.io to inspect JWS tokens.
The header identifies the type and signing algorithm.
The body carries the claims.
Some claims (e.g., subject/ |
7.6. JwtUtil: parseToken()
The parseToken()
method verifies the contents of the JWS has not been
modified, and re-assembles an authenticated Authentication
object
to be returned by the AuthenticationProvider
and AuthenticationManager
and placed into the SecurityContext
for when the operation is executed.
...
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
public Authentication parseToken(String token) throws JwtException {
Claims body = Jwts.parserBuilder()
.setSigningKey(jwtConfig.getVerifyKey()) (1)
.build()
.parseClaimsJws(token)
.getBody();
User user = new User(body.getSubject(), "", getGrantedAuthorities(body));
Authentication authentication=new UsernamePasswordAuthenticationToken(
user, token, (2)
user.getAuthorities());
return authentication;
}
1 | verification and signing keys are the same for symmetric algorithms |
2 | there is no real use for the token in the authResult. It was placed in the password position in the event we wanted to locate it. |
7.7. JwtUtil: parseToken() Helper Methods
The following helper method extracts the authority strings stored in the (parsed) token and wraps them in GrantedAuthority
objects to be used by the authorization framework.
protected List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
List<String> authorities = (List) claims.get(jwtConfig.getAuthoritiesKey());
return authorities==null ? Collections.emptyList() :
authorities.stream()
.map(a->new SimpleGrantedAuthority(a)) (1)
.collect(Collectors.toList());
}
1 | converting authority strings from token into GrantedAuthority objects used by Spring security framework |
The following helper method returns the verify key to be the same as the signing key.
public class JwtConfig {
public SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(getKey().getBytes(Charset.forName("UTF-8")));
}
public SecretKey getVerifyKey() {
return getSigningKey();
}
8. JwtAuthenticationFilter
The JwtAuthenticationFilter
is the target filter for generating new
bearer tokens. It accepts POSTS to a configured /api/login
URI
with the username and password, authenticates those credentials,
generates a bearer token with JWS, and returns that value in the
Authorization
header. The following is an example of making the
end-to-end authentication call. Notice the bearer token returned.
We will need this value in follow-on calls.
$ curl -v -X POST http://localhost:8080/api/login -d '{"username":"frasier", "password":"password"}'
> POST /api/login HTTP/1.1
< HTTP/1.1 200
< Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJmcmFzaWVyIiwiaWF0IjoxNTk0OTgwMTAyLCJleHAiOjE4OTQ5ODM3MDIsImF1dGgiOlsiUFJJQ0VfQ0hFQ0siLCJST0xFX0NVU1RPTUVSIl19.u2MmzTxaDoVNFGGCnrAcWBusS_NS2NndZXkaT964hLgcDTvCYAW_sXtTxRw8g_13
The JwtAuthenticationFilter
delegates much of the detail work
handling the header and JWS token to the JwtUtil
class shown earlier.
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
8.1. JwtAuthenticationFilter Relationships
The JwtAuthenticationFilter
fills out the abstract workflow of the
AbstractAuthenticationProcessingFilter
by implementing two primary
methods: attemptAuthentication()
and successfulAuthentication()
.
The attemptAuthenticate()
callback is used to perform all the steps necessary
to authenticate the caller. Unsuccessful attempts are returned the the caller
immediately with a 401/Unauthorized status.
The successfulAuthentication()
callback is used to generate the JWS
token from the authResult and return that in the response header. The
call is returned immediately to the caller with a 200/OK status and an
Authorization header containing the constructed token.
8.2. JwtAuthenticationFilter: Constructor
The filter constructor sets up the object to only listen to POSTs against
the configured loginUri. The base class we are extending holds onto the
AuthenticationManager
used during the attemptAuthentication()
callback.
public JwtAuthenticationFilter(JwtConfig jwtConfig, AuthenticationManager authm) {
super(new AntPathRequestMatcher(jwtConfig.getLoginUri(), "POST"));
this.jwtUtil = new JwtUtil(jwtConfig);
setAuthenticationManager(authm);
}
8.3. JwtAuthenticationFilter: attemptAuthentication()
The attemptAuthentication()
method has two core jobs: obtain credentials
and authenticate.
-
The credentials could have been obtained in a number of different ways. I have simply chosen to create a DTO class with username and password to carry that information.
-
The credentials are stored in an
Authentication
object that acts as the authRequest. The authResult from theAuthenticationManager
is returned from the callback.
Any failure (getCredentials()
or authenticate()
) will result in an
AuthenticationException
thrown.
@Override
public Authentication attemptAuthentication(
HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException { (1)
LoginDTO login = getCredentials(request);
UsernamePasswordAuthenticationToken authRequest =
new UsernamePasswordAuthenticationToken(login.getUsername(), login.getPassword());
Authentication authResult = getAuthenticationManager().authenticate(authRequest);
return authResult;
}
1 | any failure to obtain a successful Authentication result will throw an AuthenticationException |
8.4. JwtAuthenticationFilter: attemptAuthentication() DTO
The LoginDTO
is a simple POJO class that will get marshalled as JSON and placed
in the body of the POST.
package info.ejava.examples.svc.auth.cart.security.jwt;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class LoginDTO {
private String username;
private String password;
}
8.5. JwtAuthenticationFilter: attemptAuthentication() Helper Method
We can use the Jackson Mapper to easily unmarshal the POST payload into DTO form
any rethrown any failed parsing as a BadCredentialsException
. Unfortunately for debugging,
the default 401/Unauthorized response to the caller does not provide details we supply here
but I guess that is a good thing when dealing with credentials and login attempts.
...
import com.fasterxml.jackson.databind.ObjectMapper;
...
protected LoginDTO getCredentials(HttpServletRequest request) throws AuthenticationException {
try {
return new ObjectMapper().readValue(request.getInputStream(), LoginDTO.class);
} catch (IOException ex) {
log.info("error parsing loginDTO", ex);
throw new BadCredentialsException(ex.getMessage()); (1)
}
}
1 | BadCredentialsException extends AuthenticationException |
8.6. JwtAuthenticationFilter: successfulAuthentication()
The successfulAuthentication()
is called when authentication was successful. It has two
primary jobs: encode the authenticated result in a JWS token and set the value in the
response header.
@Override
protected void successfulAuthentication(
HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
String token = jwtUtil.generateToken(authResult); (1)
log.info("generated token={}", token);
jwtUtil.setToken(response, token); (2)
}
1 | authResult represented within the claims of the JWS |
2 | caller given the JWS token in the response header |
This callback fully overrides the parent method to eliminate setting the SecurityContext
and issuing a redirect. Neither have relevance in this situation. The authenticated caller
will not require a SecurityContext
now — this is the login. The SecurityContext
will
be set as part of the call to the operation.
9. JwtAuthorizationFilter
The JwtAuthorizationFilter
is responsible for realizing any provided JWS bearer tokens
as an authResult within the current SecurityContext
on the way to invoking an operation.
The following end-to-end operation call shows the caller supplying the bearer token in
order to identity themselves to the server implementing the operation. The example operation uses
the username of the current SecurityContext
as a key to locate information for the caller.
$ curl -v -X POST http://localhost:8080/api/carts/items?name=thing \
-H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJmcmFzaWVyIiwiaWF0IjoxNTk0OTgwMTAyLCJleHAiOjE4OTQ5ODM3MDIsImF1dGgiOlsiUFJJQ0VfQ0hFQ0siLCJST0xFX0NVU1RPTUVSIl19.u2MmzTxaDoVNFGGCnrAcWBusS_NS2NndZXkaT964hLgcDTvCYAW_sXtTxRw8g_13"
> POST /api/carts/items?name=thing HTTP/1.1
...
< HTTP/1.1 200
{"username":"frasier","items":["thing"]} (1) (2)
1 | username is encoded within the JWS token |
2 | cart with items is found by username |
The JwtAuthorizationFilter
did not seem to match any of the Spring-provided
authentication filters — so I directly extended a generic filter support class
that assures it will only get called once per request.
This class also relies on JwtUtil to implement the details of working with the JWS bearer token
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
private final AuthenticationEntryPoint failureResponse = new JwtEntryPoint();
9.1. JwtAuthorizationFilter Relationships
The JwtAuthorizationFilter
extends the generic framework of OncePerRequestFilter
and performs all of its work in the doFilterInternal()
callback.
The JwtAuthenticationFilter
obtains the raw JWS token from the request header,
wraps the token in the JwsAuthenticationToken
authRequest and requests authentication
from the AuthenticationManager
. Placing this behavior in an AuthenticationProvider
was optional but seemed to be consistent with the framework. It also provided the
opportunity to lookup further user details if ever required.
Supporting the AuthenticationManager
is the JwtAuthenticationProvider
,
which verifies the JWS token and re-builds the authResult from the JWS token claims.
The filter finishes by setting the authResult in the SecurityContext
prior to advancing
the chain further towards the operation call.
9.2. JwtAuthorizationFilter: Constructor
The JwtAuthorizationFilter
relies on the JwtUtil
helper class to implement the meat
of the JWS token details. It also accepts an AuthenticationManager
that is assumed to be
populated with the JwtAuthenticationProvider
.
public JwtAuthorizationFilter(JwtConfig jwtConfig, AuthenticationManager authenticationManager) {
jwtUtil = new JwtUtil(jwtConfig);
this.authenticationManager = authenticationManager;
}
9.3. JwtAuthorizationFilter: doFilterInternal()
Like most filters the JwtAuthorizationFilter
initially determines if there is anything to do.
If there is no Authorization
header with a "Bearer " token, the filter is quietly bypassed and
the filter chain is advanced.
If a token is found, we request authentication — where the JWS token is verified and converted
back into an Authentication
object to store in the SecurityContext
as the authResult.
Any failure to complete authentication when the token is present in the header will result in the chain terminating and an error status returned to the caller.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = jwtUtil.getToken(request);
if (token == null) { //continue on without JWS authn/authz
filterChain.doFilter(request, response); (1)
return;
}
try {
Authentication authentication = new JwtAuthenticationToken(token); (2)
Authentication authenticated = authenticationManager.authenticate(authentication);
SecurityContextHolder.getContext().setAuthentication(authenticated); (3)
filterChain.doFilter(request, response); //continue chain to operation (4)
} catch (AuthenticationException fail) {
failureResponse.commence(request, response, fail); (5)
return; //end the chain and return error to caller
}
}
1 | chain is quietly advanced forward if there is no token found in the request header |
2 | simple authRequest wrapper for the token |
3 | store the authenticated user in the SecurityContext |
4 | continue the chain with the authenticated user now present in the SecurityContext |
5 | issue an error response if token is present but we are unable to complete authentication |
9.4. JwtAuthenticationToken
The JwtAuthenticationToken
has a simple job — carry the raw JWS token string through the authentication process and be able to provide it to the JwtAuthenticationProvider
.
I am not sure whether I gained much by extending the AbstractAuthenticationToken
.
The primary requirement was to implement the Authentication
interface. As you can see, the implementation simply carries the value and returns it for just about every question asked.
It will be the job of JwtAuthenticationProvider
to turn that token into an Authentication
instance that represents the authResult, carrying authorities and other properties that have more exposed details.
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final String token;
public JwtAuthenticationToken(String token) {
super(Collections.emptyList());
this.token = token;
}
public String getToken() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
@Override
public Object getPrincipal() {
return token;
}
}
The JwtAuthenticationProvider
class implements two key methods: supports()
and authenticate()
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final JwtUtil jwtUtil;
public JwtAuthenticationProvider(JwtConfig jwtConfig) {
jwtUtil = new JwtUtil(jwtConfig);
}
@Override
public boolean supports(Class<?> authentication) {
return JwtAuthenticationToken.class.isAssignableFrom(authentication);
}
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
try {
String token = ((JwtAuthenticationToken)authentication).getToken();
Authentication authResult = jwtUtil.parseToken(token);
return authResult;
} catch (JwtException ex) {
throw new BadCredentialsException(ex.getMessage());
}
}
}
The supports()
method returns true only if the token type is the JwtAuthenticationToken
type.
The authenticate()
method obtains the raw token value, confirms its validity,
and builds an Authentication
authResult from its claims. The result is simply returned
to the AuthenticationManager
and the calling filter.
Any error in authenticate()
will result in an AuthenticationException
. The most likely
is an expired token — but could also be the result of a munged token string.
9.5. JwtEntryPoint
The JwtEntryPoint
class implements an AuthenticationEntryPoint
interface
that is used elsewhere in the framework for cases when an error handler is needed because
of an AuthenticationException
. We are using it within the JwtAuthorizationProvider
to report an error with authentication — but you will also see it show up elsewhere.
package info.ejava.examples.svc.auth.cart.security.jwt;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
public class JwtEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.sendError(HttpStatus.UNAUTHORIZED.value(), authException.getMessage());
}
}
10. API Security Configuration
With all the supporting framework classes in place, I will now show how
we can wire this up. This, of course, takes us back to the WebSecurityConfigurer
class.
-
We inject required beans into the configuration class. The only thing that is new is the
JwtConfig
@ConfigurationProperties
class. TheUserDetailsService
provides users/passwords and authorities from a database -
configure(HttpSecurity)
is where we setup ourFilterChainProxy
-
configure(AuthenticationManagerBuilder)
is where we setup ourAuthenticationManager
used by our filters in theFilterChainProxy
.
@Configuration
@Order(0)
@RequiredArgsConstructor
@EnableConfigurationProperties(JwtConfig.class) (1)
public class APIConfiguration extends WebSecurityConfigurerAdapter {
private final JwtConfig jwtConfig; (2)
private final UserDetailsService jdbcUserDetailsService; (3)
@Override
protected void configure(HttpSecurity http) throws Exception {
// details here ...
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//details here ...
}
1 | enabling the JwtConfig as a @ConfigurationProperties bean |
2 | injecting the JwtConfig bean into out configuration class |
3 | injecting a source of user details (i.e., username/password and authorities) |
10.1. API Authentication Manager Builder
The configure(AuthenticationManagerBuilder)
configures the builder with
two AuthenticationProviders
-
one containing real users/passwords and authorities
-
a second with the ability to instantiate an
Authentication
from a JWS token
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(jdbcUserDetailsService); (1)
auth.authenticationProvider(new JwtAuthenticationProvider(jwtConfig));
}
1 | configuring an AuthenticationManager with both the UserDetailsService and our
new JwtAuthenticationProvider |
The UserDetailsService
was injected because it required setup elsewhere. However, the
JwtAuthenticationProvider
is stateless — getting everything it needs from a startup
configuration and the authentication calls.
10.2. API HttpSecurity Key JWS Parts
The following snippet shows the key parts to wire in the JWS handling.
-
we register the
JwtAuthenticationFilter
to handle authentication of logins -
we register the
JwtAuthorizationFilter
to handle restoring theSecurityContext
when the caller presents a valid JWS bearer token -
not required — but we register a custom error handler that leaks some details about why the caller is being rejected when receiving a 403/Forbidden
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
http.addFilterAt(new JwtAuthenticationFilter(jwtConfig, (1)
authenticationManager()),
UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(new JwtAuthorizationFilter(jwtConfig, (2)
authenticationManager()),
JwtAuthenticationFilter.class);
http.exceptionHandling(cfg->cfg.defaultAuthenticationEntryPointFor( (3)
new JwtEntryPoint(),
new AntPathRequestMatcher("/api/**")));
http.authorizeRequests(cfg->cfg.antMatchers("/api/login").permitAll());
http.authorizeRequests(cfg->cfg.antMatchers("/api/carts/**").authenticated());
}
1 | JwtAuthenticationFilter being registered at location normally used for
UsernamePasswordAuthenticationFilter |
2 | JwtAuthorizationFilter being registered after the authn filter |
3 | adding an optional error reporter |
10.3. API HttpSecurity Full Details
The following shows the full contents of the configure(HttpSecurity)
method.
In this view you can see how FORM and BASIC Auth have been disabled and we are
operating in a stateless mode with various header/CORS options enabled.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.requestMatchers(m->m.antMatchers("/api/**"));
http.httpBasic(cfg->cfg.disable());
http.formLogin(cfg->cfg.disable());
http.headers(cfg->{
cfg.xssProtection().disable();
cfg.frameOptions().disable();
});
http.csrf(cfg->cfg.disable());
http.cors();
http.sessionManagement(cfg->cfg
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.addFilterAt(new JwtAuthenticationFilter(jwtConfig,
authenticationManager()),
UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(new JwtAuthorizationFilter(jwtConfig,
authenticationManager()),
JwtAuthenticationFilter.class);
http.exceptionHandling(cfg->cfg.defaultAuthenticationEntryPointFor(
new JwtEntryPoint(),
new AntPathRequestMatcher("/api/**")));
http.authorizeRequests(cfg->cfg.antMatchers("/api/login").permitAll());
http.authorizeRequests(cfg->cfg.antMatchers("/api/whoami").permitAll());
http.authorizeRequests(cfg->cfg.antMatchers("/api/carts/**").authenticated());
}
11. Example JWT/JWS Application
Now that we have thoroughly covered the addition of the JWT/JWS to the
security framework of our application, it is time to look at the application and
with a focus on authorizations. I have added a few unique aspects since the
previous lecture’s example use of @PreAuthorize
.
-
we are using JWT/JWS — of course
-
access annotations are applied to the service interface versus controller class
-
access annotations inspect the values of the input parameters
11.1. Roles and Role Inheritance
I have reused the same users, passwords, and role assignments from the authorities example and will demonstrate with the following users.
-
ROLE_ADMIN -
sam
-
ROLE_CLERK -
woody
-
ROLE_CUSTOMER -
norm
andfrasier
However, role inheritance is only defined for ROLE_ADMIN inheriting all accesses from ROLE_CLERK. None of the roles inherit from ROLE_CUSTOMER.
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy(StringUtils.join(Arrays.asList(
"ROLE_ADMIN > ROLE_CLERK"),System.lineSeparator()));
return roleHierarchy;
}
11.2. CartsService
We have a simple CartsService with a Web API and service implementation. The code below
shows the interface to the service. It has been annotated with @PreAuthorize
expressions
that use the Spring Expression Language to evaluate the principal from the SecurityContext
and parameters of the call.
package info.ejava.examples.svc.auth.cart.services;
import info.ejava.examples.svc.auth.cart.dto.CartDTO;
import org.springframework.security.access.prepost.PreAuthorize;
public interface CartsService {
@PreAuthorize("#username == authentication.name and hasRole('CUSTOMER')") (1)
CartDTO createCart(String username);
@PreAuthorize("#username == authentication.name or hasRole('CLERK')") (2)
CartDTO getCart(String username);
@PreAuthorize("#username == authentication.name") (3)
CartDTO addItem(String username, String item);
@PreAuthorize("#username == authentication.name or hasRole('ADMIN')") (4)
boolean removeCart(String username);
}
1 | anyone with the CUSTOMER role can create a cart but it must be for their username |
2 | anyone can get their own cart and anyone with the CLERK role can get anyone’s cart |
3 | users can only add item to their own cart |
4 | users can remove their own cart and anyone with the ADMIN role can remove anyone’s cart |
11.3. Login
The following shows creation of tokens for four example users
$ curl -v -X POST http://localhost:8080/api/login -d '{"username":"sam", "password":"password"}' (1)
> POST /api/login HTTP/1.1
< HTTP/1.1 200
< Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJzYW0iLCJpYXQiOjE1OTUwMTcwNDQsImV4cCI6MTg5NTAyMDY0NCwiYXV0aCI6WyJST0xFX0FETUlOIl19.ICzAn1r2UyrpGJQSYk9uqxMAAq9QC1Dw7GKe0NiGvCyTasMfWSStrqxV6Uit-cb4
1 | sam has role ADMIN and inherits role CLERK |
$ curl -v -X POST http://localhost:8080/api/login -d '{"username":"woody", "password":"password"}' (1)
> POST /api/login HTTP/1.1
< HTTP/1.1 200
< Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJ3b29keSIsImlhdCI6MTU5NTAxNzA1MSwiZXhwIjoxODk1MDIwNjUxLCJhdXRoIjpbIlJPTEVfQ0xFUksiXX0.kreSFPgTIr2heGMLcjHFrglydvhPZKR7Iy4F6b76WNIvAkbZVhfymbQxekuPL-Ai
1 | woody has role CLERK |
$ curl -v -X POST http://localhost:8080/api/login -d '{"username":"norm", "password":"password"}' (1)
> POST /api/login HTTP/1.1
< HTTP/1.1 200
< Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJub3JtIiwiaWF0IjoxNTk1MDE3MDY1LCJleHAiOjE4OTUwMjA2NjUsImF1dGgiOlsiUk9MRV9DVVNUT01FUiJdfQ.UX4yPDu0LzWdEAObbJliOtZ7ePU1RSIH_o_hayPrlmNxhjU5DL6XQ42iRCLLuFgw
$ curl -v -X POST http://localhost:8080/api/login -d '{"username":"frasier", "password":"password"}' (1)
> POST /api/login HTTP/1.1
< HTTP/1.1 200
< Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJmcmFzaWVyIiwiaWF0IjoxNTk1MDE3MDcxLCJleHAiOjE4OTUwMjA2NzEsImF1dGgiOlsiUFJJQ0VfQ0hFQ0siLCJST0xFX0NVU1RPTUVSIl19.ELAe5foIL_u2QyhpjwDoqQbL4Hl1Ikuir9CJPdOT8Ow2lI5Z1GQY6ZaKvW883txI
1 | norm and frasier have role CUSTOMER |
11.4. createCart()
The access rules for createCart()
require the caller be a customer and be creating a cart
for their username.
@PreAuthorize("#username == authentication.name and hasRole('CUSTOMER')") (1)
CartDTO createCart(String username); (1)
1 | #username refers to the username method parameter |
Woody is unable to create a cart because he lacks the CUSTOMER
role.
$ curl -X GET http://localhost:8080/api/whoAmI -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJ3b29keSIsImlhdCI6MTU5NTAxNzA1MSwiZXhwIjoxODk1MDIwNjUxLCJhdXRoIjpbIlJPTEVfQ0xFUksiXX0.kreSFPgTIr2heGMLcjHFrglydvhPZKR7Iy4F6b76WNIvAkbZVhfymbQxekuPL-Ai" #woody
[woody, [ROLE_CLERK]]
$ curl -X POST http://localhost:8080/api/carts -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJ3b29keSIsImlhdCI6MTU5NTAxNzA1MSwiZXhwIjoxODk1MDIwNjUxLCJhdXRoIjpbIlJPTEVfQ0xFUksiXX0.kreSFPgTIr2heGMLcjHFrglydvhPZKR7Iy4F6b76WNIvAkbZVhfymbQxekuPL-Ai" #woody
{"url":"http://localhost:8080/api/carts","message":"Forbidden","description":"caller[woody] is forbidden from making this request","timestamp":"2020-07-17T20:24:14.159507Z"}
Norm is able to create a cart because he has the CUSTOMER
role.
$ curl -X GET http://localhost:8080/api/whoAmI -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJub3JtIiwiaWF0IjoxNTk1MDE3MDY1LCJleHAiOjE4OTUwMjA2NjUsImF1dGgiOlsiUk9MRV9DVVNUT01FUiJdfQ.UX4yPDu0LzWdEAObbJliOtZ7ePU1RSIH_o_hayPrlmNxhjU5DL6XQ42iRCLLuFgw" #norm
[norm, [ROLE_CUSTOMER]]
$ curl -X POST http://localhost:8080/api/carts -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJub3JtIiwiaWF0IjoxNTk1MDE3MDY1LCJleHAiOjE4OTUwMjA2NjUsImF1dGgiOlsiUk9MRV9DVVNUT01FUiJdfQ.UX4yPDu0LzWdEAObbJliOtZ7ePU1RSIH_o_hayPrlmNxhjU5DL6XQ42iRCLLuFgw" #norm
{"username":"norm","items":[]}
11.5. addItem()
The addItem()
access rules only allow users to add items to their own cart.
@PreAuthorize("#username == authentication.name")
CartDTO addItem(String username, String item);
Frasier is forbidden from adding items to Norm’s cart because his identity does not match the username for the cart.
$ curl -X GET http://localhost:8080/api/whoAmI -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJmcmFzaWVyIiwiaWF0IjoxNTk1MDE3MDcxLCJleHAiOjE4OTUwMjA2NzEsImF1dGgiOlsiUFJJQ0VfQ0hFQ0siLCJST0xFX0NVU1RPTUVSIl19.ELAe5foIL_u2QyhpjwDoqQbL4Hl1Ikuir9CJPdOT8Ow2lI5Z1GQY6ZaKvW883txI" #frasier
[frasier, [PRICE_CHECK, ROLE_CUSTOMER]]
$ curl -X POST "http://localhost:8080/api/carts/items?username=norm&name=chardonnay" -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJmcmFzaWVyIiwiaWF0IjoxNTk1MDE3MDcxLCJleHAiOjE4OTUwMjA2NzEsImF1dGgiOlsiUFJJQ0VfQ0hFQ0siLCJST0xFX0NVU1RPTUVSIl19.ELAe5foIL_u2QyhpjwDoqQbL4Hl1Ikuir9CJPdOT8Ow2lI5Z1GQY6ZaKvW883txI" #frasier
{"url":"http://localhost:8080/api/carts/items?username=norm&name=chardonnay","message":"Forbidden","description":"caller[frasier] is forbidden from making this request","timestamp":"2020-07-17T20:40:10.451578Z"} (1)
1 | frasier received a 403/Forbidden error when attempting to add to someone else’s cart |
Norm can add items to his own cart because his username matches the username of the cart.
$ curl -X POST http://localhost:8080/api/carts/items?name=beer -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJub3JtIiwiaWF0IjoxNTk1MDE3MDY1LCJleHAiOjE4OTUwMjA2NjUsImF1dGgiOlsiUk9MRV9DVVNUT01FUiJdfQ.UX4yPDu0LzWdEAObbJliOtZ7ePU1RSIH_o_hayPrlmNxhjU5DL6XQ42iRCLLuFgw" #norm
{"username":"norm","items":["beer"]}
11.6. getCart()
The getCart()
access rules only allow users to get their own cart, but also allows users with the CLERK
role to get anyone’s cart.
@PreAuthorize("#username == authentication.name or hasRole('CLERK')") (2)
CartDTO getCart(String username);
Frasier cannot get Norm’s cart because anyone lacking the CLERK
role can only get a cart that
matches their authenticated username.
$ curl -X GET http://localhost:8080/api/carts?username=norm -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJmcmFzaWVyIiwiaWF0IjoxNTk1MDE3MDcxLCJleHAiOjE4OTUwMjA2NzEsImF1dGgiOlsiUFJJQ0VfQ0hFQ0siLCJST0xFX0NVU1RPTUVSIl19.ELAe5foIL_u2QyhpjwDoqQbL4Hl1Ikuir9CJPdOT8Ow2lI5Z1GQY6ZaKvW883txI" #frasier
{"url":"http://localhost:8080/api/carts?username=norm","message":"Forbidden","description":"caller[frasier] is forbidden from making this request","timestamp":"2020-07-17T20:44:05.899192Z"}
Norm can get his own cart because the username of the cart matches the authenticated username of his accessing the cart.
$ curl -X GET http://localhost:8080/api/carts -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJub3JtIiwiaWF0IjoxNTk1MDE3MDY1LCJleHAiOjE4OTUwMjA2NjUsImF1dGgiOlsiUk9MRV9DVVNUT01FUiJdfQ.UX4yPDu0LzWdEAObbJliOtZ7ePU1RSIH_o_hayPrlmNxhjU5DL6XQ42iRCLLuFgw" #norm
{"username":"norm","items":["beer"]}
Woody can get Norm’s cart because he has the CLERK
role.
$ curl -X GET http://localhost:8080/api/carts?username=norm -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJ3b29keSIsImlhdCI6MTU5NTAxNzA1MSwiZXhwIjoxODk1MDIwNjUxLCJhdXRoIjpbIlJPTEVfQ0xFUksiXX0.kreSFPgTIr2heGMLcjHFrglydvhPZKR7Iy4F6b76WNIvAkbZVhfymbQxekuPL-Ai" #woody
{"username":"norm","items":["beer"]}
11.7. removeCart()
The removeCart()
access rules only allow carts to be removed by their owner or by someone with the
ADMIN
role.
@PreAuthorize("#username == authentication.name or hasRole('ADMIN')")
boolean removeCart(String username);
Woody cannot remove Norm’s cart because his authenticated username does not match the cart and
he lacks the ADMIN
role.
$ curl -X DELETE http://localhost:8080/api/carts?username=norm -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJ3b29keSIsImlhdCI6MTU5NTAxNzA1MSwiZXhwIjoxODk1MDIwNjUxLCJhdXRoIjpbIlJPTEVfQ0xFUksiXX0.kreSFPgTIr2heGMLcjHFrglydvhPZKR7Iy4F6b76WNIvAkbZVhfymbQxekuPL-Ai" #woody
{"url":"http://localhost:8080/api/carts?username=norm","message":"Forbidden","description":"caller[woody] is forbidden from making this request","timestamp":"2020-07-17T20:48:40.866193Z"}
Sam can remove Norm’s cart because he has the ADMIN
role. Once Same deletes the cart,
Norm receives a 404/Not Found because it is not longer there.
$ curl -X GET http://localhost:8080/api/whoAmI -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJzYW0iLCJpYXQiOjE1OTUwMTcwNDQsImV4cCI6MTg5NTAyMDY0NCwiYXV0aCI6WyJST0xFX0FETUlOIl19.ICzAn1r2UyrpGJQSYk9uqxMAAq9QC1Dw7GKe0NiGvCyTasMfWSStrqxV6Uit-cb4" #sam
[sam, [ROLE_ADMIN]]
$ curl -X DELETE http://localhost:8080/api/carts?username=norm -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJzYW0iLCJpYXQiOjE1OTUwMTcwNDQsImV4cCI6MTg5NTAyMDY0NCwiYXV0aCI6WyJST0xFX0FETUlOIl19.ICzAn1r2UyrpGJQSYk9uqxMAAq9QC1Dw7GKe0NiGvCyTasMfWSStrqxV6Uit-cb4" #sam
$ curl -X GET http://localhost:8080/api/carts -H "Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJub3JtIiwiaWF0IjoxNTk1MDE3MDY1LCJleHAiOjE4OTUwMjA2NjUsImF1dGgiOlsiUk9MRV9DVVNUT01FUiJdfQ.UX4yPDu0LzWdEAObbJliOtZ7ePU1RSIH_o_hayPrlmNxhjU5DL6XQ42iRCLLuFgw" #norm
{"url":"http://localhost:8080/api/carts","message":"Not Found","description":"no cart found for norm","timestamp":"2020-07-17T20:50:59.465210Z"}
12. Summary
I don’t know about you — but I had fun with that!
To summarize — In this module, we learned:
-
to separate the authentication from the operation call such that the operation call could be in a separate server or even an entirely different service
-
what is a JSON Web Token (JWT) and JSON Web Secret (JWS)
-
how trust is verified using JWS
-
how to write and/or integrate custom authentication and authorization framework classes to implement an alternate security mechanism in Spring/Spring Boot
-
how to leverage Spring Expression Language to evaluate parameters and properties of the
SecurityContext