HTTP Basic is simple and stupid, but it is considered as a unsecure solution even you are using HTTPS protocol. A base64 encoded string is too easy to be decoded by hackers.
In these days, an popular alternative is using JWT, which uses a signed token replaces the simple base64 encoded string. JWT based token is generated by server side and usually uses a security salt or key store to improve security.
Explore the JWT website, you will find lots of Java implementations. jjwt is one of the most popular libs, which is maintained by Auth0.
Add jjwt into project dependencies.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.6.0</version>
</dependency>
Create a simple helper class to parse and generate JWT token.
@ApplicationScoped
public class JwtHelper {
private final String secret = "test123";
private final long expirationInSeconds = 3600 * 24;//one day
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + this.expirationInSeconds * 1000);
}
public String generateToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", user.getUsername());
claims.put("role", user.getRole().name());
return generateTokenFromClaims(claims);
}
public UserPrincipal parseToken(String token) throws Exception {
Claims claims = extractClaimsFromToken(token);
if (claimsIsExpired(claims)) {
throw new RuntimeException("token is expired");
}
return new JwtUser((String) claims.get("sub"), Arrays.asList(Role.valueOf((String) claims.get("role"))));
}
private boolean claimsIsExpired(Claims claims) throws Exception {
boolean isExpired = claims.getExpiration().before(new Date());
return isExpired;
}
public String refreshToken(String token) throws Exception {
Claims claims = extractClaimsFromToken(token);
if (claimsIsExpired(claims)) {
return generateTokenFromClaims(claims);
}
return token;
}
private String generateTokenFromClaims(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, this.secret)
.compact();
}
private Claims extractClaimsFromToken(String token) throws Exception {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(this.secret)
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
throw new RuntimeException("token:" + token + " is invalid");
}
return claims;
}
}
jjwt provides a Jwt
class which includes fluent APIs to create and parse JWT token.
We add username
and role
properties of a user as part of claims to build the token. In this sample, we use HS512 algorithm and a custom secret to generate it.
Next, create REST APIs to generate token, generally it is a login operation.
@RequestScoped
@Path("auth")
public class AuthResource {
private static final Logger LOG = Logger.getLogger(AuthResource.class.getName());
@Inject
private UserService service;
@Context
UriInfo uriInfo;
@POST
@Path("login")
@Consumes(value = MediaType.APPLICATION_JSON)
public Response login(@Valid Credentials form) {
LOG.log(Level.INFO, "login as@{0}", form);
return Response.ok(service.authenticate(form)).build();
}
@POST
@Path("signup")
@Consumes(value = MediaType.APPLICATION_JSON)
public Response signup(@Valid SignupForm form) {
service.registerUser(form);
return Response.ok().build();
}
@POST
@Path("logout")
public Response logout() {
LOG.info("logging out...");
return Response.ok().build();
}
}
AuthResource
includes a login
, signup
and logout
methods. Currently logout does nothing, and signup
is use for user registration, and login
accept a username and password paired bean as paramter and return the token value if user is authenticated successfully.
When a user logged in successfully, and got the token value, set “Bearer “ + token(do not forget the whitespce after “Bearer”) in the HTTP header Authorization.
Authorization:Bearer +(token)
When you access the protected resources with token, the access operation should be granted. You can use a Servlet Filter to verify the token, but Jaxrs has it own ContainerRquestFilter
which is tight with Jaxrs, which could be better than implementing a generic servlet Filter.
@Secured
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {
@Inject
JwtHelper jwtHelper;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
SecurityContext originalContext = requestContext.getSecurityContext();
String token = extractTokenFromHeader(requestContext);
try {
// Validate the token
UserPrincipal claims = jwtHelper.parseToken(token);
requestContext.setSecurityContext(new Authorizer(claims, originalContext.isSecure()));
} catch (Exception e) {
requestContext.abortWith(
Response.status(Response.Status.UNAUTHORIZED).build());
}
}
private String extractTokenFromHeader(ContainerRequestContext requestContext) throws NotAuthorizedException {
// Get the HTTP Authorization header from the request
String authorizationHeader
= requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
// Check if the HTTP Authorization header is present and formatted correctly
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
throw new NotAuthorizedException("Authorization header must be provided");
}
// Extract the token from the HTTP Authorization header
String token = authorizationHeader.substring("Bearer".length()).trim();
return token;
}
static class Authorizer implements SecurityContext {
UserPrincipal principal;
boolean isSecure;
public Authorizer(UserPrincipal principal, boolean isSecure) {
this.principal = principal;
this.isSecure = isSecure;
}
@Override
public Principal getUserPrincipal() {
return this.principal;
}
@Override
public boolean isUserInRole(String role) {
return this.principal.getRoles().contains(Role.valueOf(role));
}
@Override
public boolean isSecure() {
return this.isSecure;
}
@Override
public String getAuthenticationScheme() {
return "JWT";
}
}
}
AuthenticationFilter
implements ContainerRequestFilter
which has filter
method declaration.
public void filter(ContainerRequestContext requestContext) throws IOException {}
It is very similar with Servlet Filter.
@Provider
annotation make Jaxrs can recognize it automaticially at runtime.
@Priority(Priorities.AUTHENTICATION)
defines its order when interceptes HTTP request.
Have a look at Priorities
class. It defines several value for Jaxrs. AUTHENTICATION is the highest priority.
public final class Priorities {
private Priorities() {
// prevents construction
}
/**
* Security authentication filter/interceptor priority.
*/
public static final int AUTHENTICATION = 1000;
/**
* Security authorization filter/interceptor priority.
*/
public static final int AUTHORIZATION = 2000;
/**
* Header decorator filter/interceptor priority.
*/
public static final int HEADER_DECORATOR = 3000;
/**
* Message encoder or decoder filter/interceptor priority.
*/
public static final int ENTITY_CODER = 4000;
/**
* User-level filter/interceptor priority.
*/
public static final int USER = 5000;
}
In the filter
method of AuthenticationFilter
, we use JwtHelper
parse the token in HTTP request header, and extract the username and role info from it. Wrap these into a JwtUser
.
We also setup a custom SecurityContext
with our authenticated user. Thus we can get the authenticated user principal from SecurityContext
in Jaxrs.
@Context
SecurityContext securityContext;
//...
Principal principal = securityContext.getUserPrincipal();
If there is an exception occured, such as token parsing failed, it will send a UNAUTHORIZED
status to client immediately.
You maybe have noticed there is a Secured
annotation.
@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured {
Role[] value() default {};
}
It is a @NameBinding
which will be applied on the target Jaxrs resources to indicate it will be filtered by AuthenticationFilter
. In contrast, Servlet Filter use a url pattern to determine which url will be filtered.
@NameBinding
is very similar with CDI @Qualifier
.
Lets add Secured
to PostResource
to describe how to use it. I add @Secured({Role.USER})
at class level and ` @Secured({Role.ADMIN}) at method level. Ideally, the
Secured` configuration on delete method will omit the configuration on class.
@RequestScoped
@Path("posts")
@Secured({Role.USER})
public class PostResource {
@DELETE
@Path("{id}")
@Secured({Role.ADMIN})
public Response delete(@PathParam("id") Long id) {
//...
}
}
Till now, if user is authenticated, posts resources will be allowed to access. But it does not check the role.
Add another filer to verify the role.
@Secured
@Provider
@Priority(Priorities.AUTHORIZATION)
public class AuthorizationFilter implements ContainerRequestFilter {
@Context
private ResourceInfo resourceInfo;
@Context
private SecurityContext securityContext;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
// Get the resource class which matches with the requested URL
// Extract the roles declared by it
Class<?> resourceClass = resourceInfo.getResourceClass();
List<Role> classRoles = extractRoles(resourceClass);
// Get the resource method which matches with the requested URL
// Extract the roles declared by it
Method resourceMethod = resourceInfo.getResourceMethod();
List<Role> methodRoles = extractRoles(resourceMethod);
try {
// Check if the user is allowed to execute the method
// The method annotations override the class annotations
if (methodRoles.isEmpty()) {
checkPermissions(classRoles);
} else {
checkPermissions(methodRoles);
}
} catch (Exception e) {
requestContext.abortWith(
Response.status(Response.Status.FORBIDDEN).build());
}
}
// Extract the roles from the annotated element
private List<Role> extractRoles(AnnotatedElement annotatedElement) {
if (annotatedElement == null) {
return new ArrayList<>();
} else {
Secured secured = annotatedElement.getAnnotation(Secured.class);
if (secured == null) {
return new ArrayList<>();
} else {
Role[] allowedRoles = secured.value();
return Arrays.asList(allowedRoles);
}
}
}
private void checkPermissions(List<Role> allowedRoles) throws Exception {
// Check if the user contains one of the allowed roles
// Throw an Exception if the user has not permission to execute the method
// get user info from SecurityContext directly.
Principal principal = securityContext.getUserPrincipal();
if (principal != null && principal instanceof UserPrincipal) {
UserPrincipal up = (UserPrincipal) principal;
List<Role> userRoles = up.getRoles();
boolean allowed = false;
for (Role r : userRoles) {
for (Role checkRole : allowedRoles) {
if (r.equals(checkRole)) {
allowed = true;
}
}
}
if (!allowed) {
throw new ForbiddenException("roles:"+userRoles.toString()+" is not allowed");
}
}
}
}
Again, AuthorizationFilter
also implements ContainerRequestFilter
.
A lower priority @Priority(Priorities.AUTHORIZATION)
is applied on this filter, so it will be exeuted after AuthenticationFilter
, and the custom SecurityContext
is ready. In the filter
method, it gets user principal from SecurityContext
, check if the role of authenticated user is in the list of value of @Secured
. If not found, it will raise a ForbiddenException
.
Write a simple test to verify if it works as expected.
@RunWith(Arquillian.class)
public class JwtAuthTest {
private static final Logger LOG = Logger.getLogger(JwtAuthTest.class.getName());
@Deployment(testable = false)
public static WebArchive createDeployment() {
File[] extraJars = Maven.resolver().loadPomFromFile("pom.xml")
.resolve(
"org.projectlombok:lombok:1.16.8",
"org.modelmapper:modelmapper:0.7.5",
"org.apache.commons:commons-lang3:3.4",
"com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.6.3",
"io.jsonwebtoken:jjwt:0.6.0"
)
.withTransitivity()
.asFile();
final WebArchive war = ShrinkWrap.create(WebArchive.class, "test.war")
.addAsLibraries(extraJars)
.addClasses(DTOUtils.class, Fixtures.class)
//domain.support package.
.addPackage(AbstractEntity.class.getPackage())
//domain.convert package.
.addPackage(LocalDateConverter.class.getPackage())
.addClasses(
Post.class,
Post_.class,
PostRepository.class,
Comment.class,
Comment_.class,
CommentRepository.class
)
.addClasses(
Role.class,
User.class,
User_.class,
UserRepository.class//,
)
//add service classes
.addClasses(
BlogService.class,
ResourceNotFoundException.class,
PostForm.class,
PostDetail.class,
CommentForm.class,
CommentDetail.class
)
.addClasses(
UserService.class,
ResourceNotFoundException.class,
UsernameWasTakenException.class,
UserForm.class,
UserDetail.class,
Credentials.class,
SignupForm.class,
IdToken.class,
Credentials.class
)
//Add JAXRS resources classes
.addClasses(
JaxrsActiviator.class,
PostResource.class,
UserResource.class,
CommentResource.class,
JacksonConfig.class,
ResourceNotFoundExceptionMapper.class,
ValidationExceptionMapper.class,
ValidationError.class,
CustomBeanParamProvider.class,
AuthenticationException.class,
AuthenticationExceptionMapper.class,
AuthResource.class
)
.addPackage(PlainPasswordEncoder.class.getPackage())
.addPackage(BCryptPasswordEncoder.class.getPackage())
.addPackage(PasswordEncoder.class.getPackage())
.addClasses(
AuthenticationFilter.class,
AuthorizationFilter.class,
JwtHelper.class,
JwtUser.class,
UserPrincipal.class,
Secured.class
)
.addClasses(
Initializer.class
)
// .addAsResource("test-log4j.properties", "log4j.properties")
//Add JPA persistence configration.
//WARN: In a war package, persistence.xml should be put into /WEB-INF/classes/META-INF/, not /META-INF
.addAsResource("META-INF/test-persistence.xml", "META-INF/persistence.xml")
// Enable CDI
//WARN: In a war package, persistence.xml should be put into /WEB-INF not /META-INF
.addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml");
// .addAsWebInfResource("test-jboss-deployment-structure.xml", "jboss-deployment-structure.xml");
LOG.log(Level.INFO, "war to string @{0}", war.toString());
return war;
}
@ArquillianResource
private URL base;
private Client client;
@Before
public void setup() throws MalformedURLException {
client = ClientBuilder.newClient();
client.register(JacksonConfig.class);
client.register(ResourceNotFoundExceptionMapper.class);
client.register(ValidationExceptionMapper.class);
}
@After
public void teardown() throws MalformedURLException {
client.close();
}
@Test
@RunAsClient
public void testGetPostsWithoutAuthentication() throws MalformedURLException {
LOG.log(Level.INFO, "base url @{0}", base);
//get all posts
final WebTarget targetGetAll = client.target(URI.create(new URL(base, "api/posts").toExternalForm()));
final Response resGetAll = targetGetAll.request().accept(MediaType.APPLICATION_JSON_TYPE).get();
assertEquals(401, resGetAll.getStatus());
//You have to close the response manually... issue# RESTEASY-1120
//see https://issues.jboss.org/browse/RESTEASY-1120
resGetAll.close();
}
@Test
@RunAsClient
public void testGetPostsWithAuthentication() throws MalformedURLException {
LOG.log(Level.INFO, "base url @{0}", base);
final WebTarget targetAuthGetAll = client.target(URI.create(new URL(base, "api/auth/login").toExternalForm()));
final Response resAuthGetAll = targetAuthGetAll.request()
.accept(MediaType.APPLICATION_JSON_TYPE)
.post(Entity.json(new Credentials("testuser", "test123", true)));
assertEquals(200, resAuthGetAll.getStatus());
IdToken token = resAuthGetAll.readEntity(IdToken.class);
client.register(new JwtTokenAuthentication(token.getToken()));
//get all posts
final WebTarget targetGetAll = client.target(URI.create(new URL(base, "api/posts").toExternalForm()));
final Response resGetAll = targetGetAll.request().accept(MediaType.APPLICATION_JSON_TYPE).get();
assertEquals(200, resGetAll.getStatus());
PostDetail[] posts = resGetAll.readEntity(PostDetail[].class);
assertTrue(posts.length == 1);
Long id = posts[0].getId();
//You have to close the response manually... issue# RESTEASY-1120
//see https://issues.jboss.org/browse/RESTEASY-1120
resGetAll.close();
//get all posts
final WebTarget targetDelAll = client.target(URI.create(new URL(base, "api/posts/"+id).toExternalForm()));
final Response resDelAll = targetDelAll.request().accept(MediaType.APPLICATION_JSON_TYPE).delete();
assertEquals(403, resDelAll.getStatus());
}
}
In the test testGetPostsWithoutAuthentication()
, user is not authenticated, it will response a 401 status.
In the test testGetPostsWithAuthentication()
, firstly it tries to input user credentials and get token.
Then use a JwtTokenAuthentication
to add token to HTTP request header.
public class JwtTokenAuthentication implements ClientRequestFilter {
private final String authHeader;
public JwtTokenAuthentication(String token) {
this.authHeader = "Bearer " + token;
}
@Override
public void filter(ClientRequestContext requestContext) throws IOException {
requestContext.getHeaders().putSingle(HttpHeaders.AUTHORIZATION, authHeader);
}
}
It is a ClientRequestFilter
, Jaxrs client register this filter, and it will be applied in the further steps.
client.register(new JwtTokenAuthentication(token.getToken()));
/api/posts
is allowed now, but /api/posts/{id}
requires a ADMIN
role, it will fail and return a 403 status.
Get source codes from my Github account, and try it yourself.
https://github.com/hantsy/angularjs-ee7-sample
There is a jwt folder to demonstrate the JWT token based security.