diff --git a/README.md b/README.md index 05e98b80b025c0cca47a8c7a6c8a935e5f485fa4..af934ff3daa9093e924e25f6dd61ae0776b25ebf 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,12 @@ curl -s -D - -H "Accept: application/json" \ Removes an author ```shell -curl -s -D - -X DELETE "http://localhost:9998/myapp/biblio/authors/1" +curl -s -D - -X DELETE "http://localhost:9998/myapp/biblio/auteurs/1" ``` Removes all authors ```shell -curl -s -D - -X DELETE "http://localhost:9998/myapp/biblio/authors" +curl -s -D - -X DELETE "http://localhost:9998/myapp/biblio/auteurs" ``` Adds an author @@ -77,8 +77,13 @@ Filter resources with query parameters : curl -v -H "Accept: application/json" \ "http://127.0.0.1:9998/myapp/biblio/auteurs/filter?nom=Durand&prenomā¼Marie" ``` + Control sort key with header param (default value "nom") : ```shell curl -v -H "Accept: application/json" -H "sortKey: prenom"\ "http://127.0.0.1:9998/myapp/biblio/auteurs/filter" +``` +Login and get a Java Web Token +```shell +TOKEN=$(curl -v --user "john.doe@nowhere.com:admin" "http://localhost:9998/myapp/biblio/login") ``` \ No newline at end of file diff --git a/queries/sample-requests.rest b/queries/sample-requests.rest new file mode 100644 index 0000000000000000000000000000000000000000..ad3082fd5dec0b7aec7142b401c088a48d5bff47 --- /dev/null +++ b/queries/sample-requests.rest @@ -0,0 +1,91 @@ +# curl -D - http://localhost:9998/myapp/biblio +### Get a Hello message +GET http://localhost:9998/myapp/biblio + +### Init the database with two authors +PUT http://localhost:9998/myapp/biblio/init + +### Get author 1 in JSON +GET http://localhost:9998/myapp/biblio/auteurs/1 +Accept: application/json + +### Get author 2 in XML +GET http://localhost:9998/myapp/biblio/auteurs/2 +Accept: text/xml + +### Get authors in JSON +GET http://localhost:9998/myapp/biblio/auteurs +Accept: application/json + +### Removes an author +DELETE http://localhost:9998/myapp/biblio/auteurs/1 + +### Removes all authors +DELETE http://localhost:9998/myapp/biblio/auteurs + +### Adds an author +POST http://localhost:9998/myapp/biblio/auteurs/ +Accept: application/json +Content-type: application/json + +{"nom":"John","prenom":"Smith","biographie":"My life"} + +### Fully update an author +PUT http://localhost:9998/myapp/biblio/auteurs/1 +Accept: application/json +Content-type: application/json + +{"nom":"Martin","prenom":"Jean","biographie":"ma vie"} + +### If a resource doesn't exist an exception is raised, and the 404 http status code is returned +GET http://localhost:9998/myapp/biblio/auteurs/1000 +Accept: application/json + +### Filter resources with query parameters : +GET http://localhost:9998/myapp/biblio/auteurs/filter?nom=Durand&prenomā¼Marie +Accept: application/json + +### Control sort key with header param (default value "nom") : +GET http://127.0.0.1:9998/myapp/biblio/auteurs/filter +Accept: application/json +sortKey: prenom + +### Init the database with 10k random authors +PUT http://localhost:9998/myapp/biblio/init/10000 + +### Get page 3 with page size of 10 authors sorted by lastname +GET http://localhost:9998/myapp/biblio/auteurs/page?pageSize=10&page=3 +Accept: application/json +sortKey: prenom + +### Authorization by token, part 1. Retrieve and save token with Basic Authentication +# TOKEN=$(curl -v --user "john.doe@nowhere.com:admin" "http://localhost:9998/myapp/biblio/login") +GET http://localhost:9998/myapp/biblio/login +Authorization: Basic john.doe@nowhere.com admin + +> {% client.global.set("auth_token", response.body); %} + +### Authorization by token, part 2. Use token to authorize. Admin & User OK +# curl -H "Authorization: Bearer $TOKEN" -v "http://localhost:9998/myapp/biblio/secured" +GET http://localhost:9998/myapp/biblio/secured +Authorization: Bearer {{auth_token}} + +### Authorization by token, part 2. Use token to authorize. Admin OK +GET http://localhost:9998/myapp/biblio/secured/admin +Authorization: Bearer {{auth_token}} + +### Authorization with another user. +# TOKEN=$(curl -v --user "mary.roberts@here.net:user" "http://localhost:9998/myapp/biblio/login") +GET http://localhost:9998/myapp/biblio/login +Authorization: Basic mary.roberts@here.net user + +> {% client.global.set("auth_token", response.body); %} + +### Authorization by token, part 2. Use token to authorize. Admin & User OK. +# curl -H "Authorization: Bearer $TOKEN" -v "http://localhost:9998/myapp/biblio/secured" +GET http://localhost:9998/myapp/biblio/secured +Authorization: Bearer {{auth_token}} + +### Authorization by token, part 2. Use token to authorize. Admin KO. +GET http://localhost:9998/myapp/biblio/secured/admin +Authorization: Bearer {{auth_token}} \ No newline at end of file diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/model/BiblioModel.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/model/BiblioModel.java index a1386c3e07ed48c6544fc2922e4f23ece80818e7..5df3f2b3e0112414adb71a74bf3e10a9ddd48b8b 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/model/BiblioModel.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/model/BiblioModel.java @@ -3,9 +3,6 @@ package fr.univtln.bruno.samples.jaxrs.model; import fr.univtln.bruno.samples.jaxrs.exceptions.IllegalArgumentException; import fr.univtln.bruno.samples.jaxrs.exceptions.NotFoundException; import fr.univtln.bruno.samples.jaxrs.resources.PaginationInfo; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.*; import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlAttribute; @@ -27,16 +24,29 @@ import java.util.stream.Stream; import static fr.univtln.bruno.samples.jaxrs.model.BiblioModel.Field.valueOf; + +/** + * The type Biblio model. + */ @Log @Getter @FieldDefaults(level = AccessLevel.PRIVATE) @NoArgsConstructor(staticName = "of") public class BiblioModel { - private static AtomicLong lastId = new AtomicLong(0); + public enum Field {NOM, PRENOM, BIOGRAPHIE} + + private static final AtomicLong lastId = new AtomicLong(0); @Delegate final MutableLongObjectMap<Auteur> auteurs = LongObjectMaps.mutable.empty(); + /** + * Add auteur auteur. + * + * @param auteur the auteur + * @return the auteur + * @throws IllegalArgumentException the illegal argument exception + */ public Auteur addAuteur(Auteur auteur) throws IllegalArgumentException { if (auteur.id != 0) throw new IllegalArgumentException(); auteur.id = lastId.incrementAndGet(); @@ -44,6 +54,15 @@ public class BiblioModel { return auteur; } + /** + * Update auteur auteur. + * + * @param id the id + * @param auteur the auteur + * @return the auteur + * @throws NotFoundException the not found exception + * @throws IllegalArgumentException the illegal argument exception + */ public Auteur updateAuteur(long id, Auteur auteur) throws NotFoundException, IllegalArgumentException { if (auteur.id != 0) throw new IllegalArgumentException(); auteur.id = id; @@ -52,33 +71,63 @@ public class BiblioModel { return auteur; } + /** + * Remove auteur. + * + * @param id the id + * @throws NotFoundException the not found exception + */ public void removeAuteur(long id) throws NotFoundException { if (!auteurs.containsKey(id)) throw new NotFoundException(); auteurs.remove(id); } + /** + * Gets auteur. + * + * @param id the id + * @return the auteur + * @throws NotFoundException the not found exception + */ public Auteur getAuteur(long id) throws NotFoundException { if (!auteurs.containsKey(id)) throw new NotFoundException(); return auteurs.get(id); } + /** + * Gets auteur size. + * + * @return the auteur size + */ public int getAuteurSize() { return auteurs.size(); } + + /** + * Returns a sorted, filtered and paginated list of authors. + * + * @param paginationInfo the pagination info + * @return the sorted, filtered page. + */ public List<Auteur> getWithFilter(PaginationInfo paginationInfo) { + //We build a author stream, first we add sorting Stream<Auteur> auteurStream = auteurs.stream() .sorted(Comparator.comparing(auteur -> switch (valueOf(paginationInfo.getSortKey().toUpperCase())) { case NOM -> auteur.getNom(); case PRENOM -> auteur.getPrenom(); default -> throw new InvalidParameterException(); })); - if (paginationInfo.getNom() != null) + + //The add filters according to parameters + if (paginationInfo.getNom()!=null) auteurStream = auteurStream.filter(auteur -> auteur.getNom().equalsIgnoreCase(paginationInfo.getNom())); - if (paginationInfo.getPrenom() != null) + if (paginationInfo.getPrenom()!=null) auteurStream = auteurStream.filter(auteur -> auteur.getPrenom().equalsIgnoreCase(paginationInfo.getPrenom())); - if (paginationInfo.getBiographie() != null) + if (paginationInfo.getBiographie()!=null) auteurStream = auteurStream.filter(auteur -> auteur.getBiographie().contains(paginationInfo.getBiographie())); + + //Finally add pagination instructions. if ((paginationInfo.getPage() > 0) && (paginationInfo.getPageSize() > 0)) { auteurStream = auteurStream .skip(paginationInfo.getPageSize() * (paginationInfo.getPage() - 1)) @@ -93,8 +142,9 @@ public class BiblioModel { lastId.set(0); } - public enum Field {NOM, PRENOM, BIOGRAPHIE} - + /** + * The type Auteur. + */ @Builder @Getter @Setter diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/resources/BiblioResource.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/resources/BiblioResource.java index 13596286d94cb7052becf09e9fdb12b7cc8e1f40..a1fef79cd22cfdba3227dfe3b9911c27ad95a97c 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/resources/BiblioResource.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/resources/BiblioResource.java @@ -5,10 +5,10 @@ import fr.univtln.bruno.samples.jaxrs.exceptions.IllegalArgumentException; import fr.univtln.bruno.samples.jaxrs.exceptions.NotFoundException; import fr.univtln.bruno.samples.jaxrs.model.BiblioModel; import fr.univtln.bruno.samples.jaxrs.model.BiblioModel.Auteur; -import fr.univtln.bruno.samples.jaxrs.security.BasicAuth; -import fr.univtln.bruno.samples.jaxrs.security.JWTAuth; +import fr.univtln.bruno.samples.jaxrs.security.annotations.BasicAuth; +import fr.univtln.bruno.samples.jaxrs.security.annotations.JWTAuth; import fr.univtln.bruno.samples.jaxrs.security.User; -import fr.univtln.bruno.samples.jaxrs.security.UserDatabase; +import fr.univtln.bruno.samples.jaxrs.security.InMemoryLoginModule; import fr.univtln.bruno.samples.jaxrs.status.Status; import io.jsonwebtoken.Jwts; import jakarta.annotation.security.RolesAllowed; @@ -18,12 +18,11 @@ import lombok.extern.java.Log; import javax.naming.AuthenticationException; import java.security.SecureRandom; -import java.sql.Date; -import java.text.ParseException; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.Collection; +import java.util.Date; import java.util.List; @Log @@ -86,7 +85,7 @@ public class BiblioResource { * * @param auteur The author to be added without its id. * @return The added author with its id. - * @throws IllegalArgumentException + * @throws IllegalArgumentException if the author has an explicit id (id!=0). */ @POST @Status(Status.CREATED) @@ -145,10 +144,9 @@ public class BiblioResource { @GET @Path("context") @RolesAllowed("ADMIN") - public String getContext(@Context UriInfo uriInfo, @Context HttpHeaders httpHeaders, @Context Request request, @Context SecurityContext securityContext) throws ParseException { + public String getContext(@Context UriInfo uriInfo, @Context HttpHeaders httpHeaders, @Context Request request, @Context SecurityContext securityContext) { return "UriInfo: (" + uriInfo.getRequestUri().toString() + ")\n HttpHeaders(" + httpHeaders.getRequestHeaders().toString() - //+")\n Request Precondition("+request.evaluatePreconditions(new SimpleDateFormat("dd/MM/yyyy-HH:mm:ss").parse("03/02/2021-10:30:00")) + ")\n SecurityContext(Auth.scheme: [" + securityContext.getAuthenticationScheme() + "] user: [" + securityContext.getUserPrincipal().getName() + "] secured: [" + securityContext.isSecure() + "] )"; @@ -158,31 +156,43 @@ public class BiblioResource { @Path("adminsonly") @RolesAllowed("ADMIN") @BasicAuth - public String getRestrictedToAdmins() { - return "secret for admins !"; + public String getRestrictedToAdmins(@Context SecurityContext securityContext) { + return "secret for admins !" + securityContext.getUserPrincipal().getName(); } @GET @Path("usersonly") @RolesAllowed("USER") @BasicAuth - public String getRestrictedToUsers() { - return "secret for users !"; + public String getRestrictedToUsers(@Context SecurityContext securityContext) { + return "secret for users ! to " + securityContext.getUserPrincipal().getName(); } @GET @Path("secured") @RolesAllowed({"USER", "ADMIN"}) @JWTAuth + @Produces({MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, MediaType.TEXT_XML}) public String securedByJWT(@Context SecurityContext securityContext) { - log.info("USER ACCESS :"+securityContext.getUserPrincipal().getName()); - return "Access with JWT ok for "+securityContext.getUserPrincipal().getName(); + log.info("USER ACCESS :" + securityContext.getUserPrincipal().getName()); + return "Access with JWT ok for " + securityContext.getUserPrincipal().getName(); + } + + @GET + @Path("secured/admin") + @RolesAllowed({"ADMIN"}) + @JWTAuth + @Produces({MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, MediaType.TEXT_XML}) + public String securedByJWTAdminOnly(@Context SecurityContext securityContext) { + log.info("ADMIN ACCESS :" + securityContext.getUserPrincipal().getName()); + return "Access with JWT ok for " + securityContext.getUserPrincipal().getName(); } @GET @Path("login") @RolesAllowed({"USER", "ADMIN"}) @BasicAuth + @Produces({MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, MediaType.TEXT_XML}) public String login(@Context SecurityContext securityContext) { if (securityContext.isSecure() && securityContext.getUserPrincipal() instanceof User) { User user = (User) securityContext.getUserPrincipal(); @@ -194,7 +204,7 @@ public class BiblioResource { .claim("lastname", user.getLastName()) .claim("roles", user.getRoles()) .setExpiration(Date.from(LocalDateTime.now().plus(15, ChronoUnit.MINUTES).atZone(ZoneId.systemDefault()).toInstant())) - .signWith(UserDatabase.KEY).compact(); + .signWith(InMemoryLoginModule.KEY).compact(); } throw new WebApplicationException(new AuthenticationException()); } diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/resources/PaginationInfo.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/resources/PaginationInfo.java index 6e4c2cd65cc28d59a2153773a2b6293247b196e4..40801ab75d644aebdf5a9f1a93a12ec7b613931f 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/resources/PaginationInfo.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/resources/PaginationInfo.java @@ -13,18 +13,20 @@ import lombok.experimental.FieldDefaults; @NoArgsConstructor @AllArgsConstructor public class PaginationInfo { - @HeaderParam("sortKey") - @DefaultValue("nom") - String sortKey; - + @SuppressWarnings("FieldMayBeFinal") @QueryParam("page") @Builder.Default long page = 1; + @SuppressWarnings("FieldMayBeFinal") @QueryParam("pageSize") @Builder.Default long pageSize = 10; + @HeaderParam("sortKey") + @DefaultValue("nom") + String sortKey; + @QueryParam("nom") String nom; diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/InMemoryLoginModule.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/InMemoryLoginModule.java new file mode 100644 index 0000000000000000000000000000000000000000..6ba281c3b92b05fc470ad098fe575ea4dbee4468 --- /dev/null +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/InMemoryLoginModule.java @@ -0,0 +1,137 @@ +package fr.univtln.bruno.samples.jaxrs.security; + +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import lombok.AccessLevel; +import lombok.ToString; +import lombok.experimental.FieldDefaults; +import lombok.extern.java.Log; + +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.*; + +/** + * The type User database. + */ +@Log +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE) +public class InMemoryLoginModule { + /** + * The constant USER_DATABASE mocks a user database in memory. + */ + public static final InMemoryLoginModule USER_DATABASE = new InMemoryLoginModule(); + + /** + * The constant KEY is used as a signing key for the bearer JWT token. + * It is used to check that the token hasn't been modified. + */ + public static final Key KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256); + + //We add three demo users. + static { + try { + USER_DATABASE.addUser("John", "Doe", "john.doe@nowhere.com", "admin", EnumSet.of(Role.ADMIN)); + USER_DATABASE.addUser("William", "Smith", "william.smith@here.net", "user", EnumSet.of(Role.USER)); + USER_DATABASE.addUser("Mary", "Robert", "mary.roberts@here.net", "user", EnumSet.of(Role.USER)); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + log.severe("In memory user database error "+e.getLocalizedMessage()); + } + } + + final Map<String, User> users = new HashMap<>(); + + public static boolean isInRoles(Set<Role> rolesSet, String username) { + return !(Collections.disjoint(rolesSet, InMemoryLoginModule.USER_DATABASE.getUserRoles(username))); + } + + /** + * Add user. + * + * @param firstname the firstname + * @param lastname the lastname + * @param email the email + * @param password the password + * @param roles the roles + * @throws InvalidKeySpecException the invalid key spec exception + * @throws NoSuchAlgorithmException the no such algorithm exception + */ + public void addUser(String firstname, String lastname, String email, String password, Set<Role> roles) + throws InvalidKeySpecException, NoSuchAlgorithmException { + users.put(email, User.builder().firstName(firstname).lastName(lastname).email(email).password(password).roles(roles).build()); + } + + /** + * Gets users. + * + * @return the users + */ + public Map<String, User> getUsers() { + return Collections.unmodifiableMap(users); + } + + /** + * Remove user. + * + * @param email the email + */ + public void removeUser(String email) { + users.remove(email); + } + + /** + * Check password boolean. + * + * @param email the email + * @param password the password + * @return the boolean + */ + public boolean login(String email, String password) { + return users.get(email).checkPassword(password); + } + + /** + * Gets user. + * + * @param email the email + * @return the user + */ + public User getUser(String email) { + return users.get(email); + } + + /** + * Gets user roles. + * + * @param email the email + * @return the user roles + */ + public Set<Role> getUserRoles(String email) { + return users.get(email).getRoles(); + } + + @SuppressWarnings("SameReturnValue") + public boolean logout() { + return false; + } + + /** + * The enum Role. + */ + public enum Role { + /** + * Admin role. + */ + ADMIN, + /** + * User role. + */ + USER, + /** + * Guest role. + */ + GUEST + } +} diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/JsonWebTokenFilter.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/JsonWebTokenFilter.java deleted file mode 100644 index 1d19aeb0fea9a9b2073db51961f3e86f252270ea..0000000000000000000000000000000000000000 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/JsonWebTokenFilter.java +++ /dev/null @@ -1,128 +0,0 @@ -package fr.univtln.bruno.samples.jaxrs.security; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jws; -import io.jsonwebtoken.Jwts; -import jakarta.annotation.Priority; -import jakarta.annotation.security.DenyAll; -import jakarta.annotation.security.PermitAll; -import jakarta.annotation.security.RolesAllowed; -import jakarta.ws.rs.Priorities; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.container.ContainerRequestFilter; -import jakarta.ws.rs.container.ResourceInfo; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.SecurityContext; -import jakarta.ws.rs.ext.Provider; -import lombok.SneakyThrows; -import lombok.extern.java.Log; - -import java.lang.reflect.Method; -import java.security.Principal; -import java.util.Arrays; -import java.util.Collections; -import java.util.EnumSet; -import java.util.stream.Collectors; - -@JWTAuth -@Provider -@Priority(Priorities.AUTHENTICATION) -@Log -/** - * This class if a filter for JAX-RS to perform authentication via JWT. - */ -public class JsonWebTokenFilter implements ContainerRequestFilter { - private static final String AUTHORIZATION_PROPERTY = "Authorization"; - private static final String AUTHENTICATION_SCHEME = "Bearer"; - - //We inject the data from the acceded resource. - @Context - private ResourceInfo resourceInfo; - - @SneakyThrows - @Override - public void filter(ContainerRequestContext requestContext) { - //We use reflection on the acceded method to look for security annotations. - Method method = resourceInfo.getResourceMethod(); - //if its PermitAll access is granted - //otherwise if its DenyAll the access is refused - if (!method.isAnnotationPresent(PermitAll.class)) { - if (method.isAnnotationPresent(DenyAll.class)) { - requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) - .entity("Access denied to all users").build()); - return; - } - - //We get the authorization header - final String authorization = requestContext.getHeaderString(AUTHORIZATION_PROPERTY); - - - if (method.isAnnotationPresent(RolesAllowed.class)) { - RolesAllowed rolesAnnotation = method.getAnnotation(RolesAllowed.class); - EnumSet<UserDatabase.Role> rolesSet = - Arrays.stream(rolesAnnotation.value()) - .map(r -> UserDatabase.Role.valueOf(r)) - .collect(Collectors.toCollection(() -> EnumSet.noneOf(UserDatabase.Role.class))); - - - //We check the credentials presence - if (authorization == null || authorization.isEmpty()) { - requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) - .entity("Please provide your credentials").build()); - return; - } - - //Gets the token - log.info("AUTH: "+authorization); - final String compactJwt = authorization.substring(AUTHENTICATION_SCHEME.length()).trim(); - if (!authorization.contains(AUTHENTICATION_SCHEME) || compactJwt == null || compactJwt.isEmpty()) { - requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) - .entity("Please provide your credentials").build()); - return; - } - log.info("JWT: "+compactJwt); - - Jws<Claims> jws = Jwts.parserBuilder() - .setSigningKey(UserDatabase.KEY) - .build() - .parseClaimsJws(compactJwt); - log.info("JWT decoded: "+jws.toString()); - - final String username = jws.getBody().getSubject(); - - //We check if the role is allowed - if (Collections.disjoint(rolesSet, UserDatabase.USER_DATABASE.getUserRoles(username))) { - requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) - .entity("Roles not allowed").build()); - return; - } - - //We build a new securitycontext to transmit the security data to JAX-RS - requestContext.setSecurityContext(new SecurityContext() { - - @Override - public Principal getUserPrincipal() { - return UserDatabase.USER_DATABASE.getUser(username); - } - - @Override - public boolean isUserInRole(String role) { - return UserDatabase.USER_DATABASE.getUserRoles(username).contains(UserDatabase.Role.valueOf(role)); - } - - @Override - public boolean isSecure() { - return true; - } - - @Override - public String getAuthenticationScheme() { - return AUTHENTICATION_SCHEME; - } - }); - - } - } - } -} \ No newline at end of file diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/MySecurityContext.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/MySecurityContext.java new file mode 100644 index 0000000000000000000000000000000000000000..300894cb462197ec35c6d3f84078ec0571280fdd --- /dev/null +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/MySecurityContext.java @@ -0,0 +1,35 @@ +package fr.univtln.bruno.samples.jaxrs.security; + +import jakarta.ws.rs.core.SecurityContext; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.experimental.FieldDefaults; + +import java.security.Principal; + +@FieldDefaults(level = AccessLevel.PRIVATE) +@AllArgsConstructor(staticName = "newInstance") +public class MySecurityContext implements SecurityContext { + private final String authenticationScheme; + private final String username; + + @Override + public Principal getUserPrincipal() { + return InMemoryLoginModule.USER_DATABASE.getUser(username); + } + + @Override + public boolean isUserInRole(String role) { + return InMemoryLoginModule.USER_DATABASE.getUserRoles(username).contains(InMemoryLoginModule.Role.valueOf(role)); + } + + @Override + public boolean isSecure() { + return true; + } + + @Override + public String getAuthenticationScheme() { + return authenticationScheme; + } +} diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/User.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/User.java index a18f358ebe052175de82944a4ba9f18e935b39b4..6ea65b190be52fe4d00250db86f0aa38322753c7 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/User.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/User.java @@ -23,17 +23,19 @@ import java.util.*; @EqualsAndHashCode(of = "email") public class User implements Principal { UUID uuid = UUID.randomUUID(); - String firstName, lastName, email; + String firstName; + String lastName; + String email; byte[] passwordHash; byte[] salt = new byte[16]; @Delegate - EnumSet<UserDatabase.Role> roles; + Set<InMemoryLoginModule.Role> roles; SecureRandom random = new SecureRandom(); @Builder - public User(String firstName, String lastName, String email, String password, EnumSet<UserDatabase.Role> roles) + public User(String firstName, String lastName, String email, String password, Set<InMemoryLoginModule.Role> roles) throws NoSuchAlgorithmException, InvalidKeySpecException { this.firstName = firstName; this.lastName = lastName; @@ -55,7 +57,8 @@ public class User implements Principal { return email + "" + Base64.getEncoder().encodeToString(passwordHash); } - public boolean checkPassword(String password) throws NoSuchAlgorithmException, InvalidKeySpecException { + @SneakyThrows + public boolean checkPassword(String password) { KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 65536, 128); SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); byte[] submittedPasswordHash = factory.generateSecret(spec).getEncoded(); diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/UserDatabase.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/UserDatabase.java deleted file mode 100644 index 7e0cc79d4b46dc5011cbf5f295947d4e030f74b7..0000000000000000000000000000000000000000 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/UserDatabase.java +++ /dev/null @@ -1,66 +0,0 @@ -package fr.univtln.bruno.samples.jaxrs.security; - -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.security.Keys; -import lombok.ToString; -import lombok.extern.java.Log; - -import java.security.Key; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.util.*; - -@Log -@ToString -public class UserDatabase { - public static final UserDatabase USER_DATABASE = new UserDatabase(); - - // We need a signing key for the id token, so we'll create one just for this example. Usually - // the key would be read from your application configuration instead. - public static final Key KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256); - - static { - try { - USER_DATABASE.addUser("John", "Doe", "john.doe@nowhere.com", "admin", EnumSet.of(Role.ADMIN)); - USER_DATABASE.addUser("William", "Smith", "william.smith@here.net", "user", EnumSet.of(Role.USER)); - USER_DATABASE.addUser("Mary", "Robert", "mary.roberts@here.net", "user", EnumSet.of(Role.USER)); - } catch (InvalidKeySpecException e) { - e.printStackTrace(); - } catch (NoSuchAlgorithmException e) { - e.printStackTrace(); - } - } - - private final Map<String, User> users = new HashMap<>(); - - public static void main(String[] args) { - USER_DATABASE.users.values().forEach(u->log.info(u.toString())); - } - - public void addUser(String firstname, String lastname, String email, String password, EnumSet<Role> roles) - throws InvalidKeySpecException, NoSuchAlgorithmException { - users.put(email, User.builder().firstName(firstname).lastName(lastname).email(email).password(password).roles(roles).build()); - } - - public Map<String, User> getUsers() { - return Collections.unmodifiableMap(users); - } - - public void removeUser(String email) { - users.remove(email); - } - - public boolean checkPassword(String email, String password) throws InvalidKeySpecException, NoSuchAlgorithmException { - return users.get(email).checkPassword(password); - } - - public User getUser(String email) { - return users.get(email); - } - - public Set<Role> getUserRoles(String email) { - return users.get(email).getRoles(); - } - - public enum Role {ADMIN, USER, GUEST} -} diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/BasicAuth.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/annotations/BasicAuth.java similarity index 85% rename from src/main/java/fr/univtln/bruno/samples/jaxrs/security/BasicAuth.java rename to src/main/java/fr/univtln/bruno/samples/jaxrs/security/annotations/BasicAuth.java index 458387ed9684cd9a8e37c0ac84d345abae5dd93b..d61e29aec135533a41da0aff596d348e381e455e 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/BasicAuth.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/annotations/BasicAuth.java @@ -1,4 +1,4 @@ -package fr.univtln.bruno.samples.jaxrs.security; +package fr.univtln.bruno.samples.jaxrs.security.annotations; import jakarta.ws.rs.NameBinding; diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/JWTAuth.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/annotations/JWTAuth.java similarity index 85% rename from src/main/java/fr/univtln/bruno/samples/jaxrs/security/JWTAuth.java rename to src/main/java/fr/univtln/bruno/samples/jaxrs/security/annotations/JWTAuth.java index 00b75227200f20567e1938404897ee454879f9e5..ed69c43ef952d063180796e86d3dd2322cbf4870 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/JWTAuth.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/annotations/JWTAuth.java @@ -1,4 +1,4 @@ -package fr.univtln.bruno.samples.jaxrs.security; +package fr.univtln.bruno.samples.jaxrs.security.annotations; import jakarta.ws.rs.NameBinding; diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/AuthenticationFilter.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/filter/BasicAuthenticationFilter.java similarity index 55% rename from src/main/java/fr/univtln/bruno/samples/jaxrs/security/AuthenticationFilter.java rename to src/main/java/fr/univtln/bruno/samples/jaxrs/security/filter/BasicAuthenticationFilter.java index 17d48f3bd0dddcbeb3cd7d9114e592bb56424267..4cc921a0ac07b60626bb2a7320f9cbaf4bc3c23d 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/AuthenticationFilter.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/filter/BasicAuthenticationFilter.java @@ -1,5 +1,8 @@ -package fr.univtln.bruno.samples.jaxrs.security; +package fr.univtln.bruno.samples.jaxrs.security.filter; +import fr.univtln.bruno.samples.jaxrs.security.MySecurityContext; +import fr.univtln.bruno.samples.jaxrs.security.annotations.BasicAuth; +import fr.univtln.bruno.samples.jaxrs.security.InMemoryLoginModule; import jakarta.annotation.Priority; import jakarta.annotation.security.DenyAll; import jakarta.annotation.security.PermitAll; @@ -10,24 +13,24 @@ import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.ResourceInfo; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.SecurityContext; import jakarta.ws.rs.ext.Provider; import lombok.SneakyThrows; import lombok.extern.java.Log; import java.lang.reflect.Method; -import java.security.Principal; import java.util.*; import java.util.stream.Collectors; +/** + * The type Authentication filter is a JAX-RS filter (@Provider with implements ContainerRequestFilter) is applied to every request whose method is annotated with @BasicAuth + * as it is itself annotated with @BasicAuth (a personal annotation). + * It performs authentication and check permissions against the acceded method with a basic authentication. + */ @BasicAuth @Provider @Priority(Priorities.AUTHENTICATION) @Log -/** - * This class if a filter for JAX-RS to perform authentication and to check permissions against the acceded method. - */ -public class AuthenticationFilter implements ContainerRequestFilter { +public class BasicAuthenticationFilter implements ContainerRequestFilter { private static final String AUTHORIZATION_PROPERTY = "Authorization"; private static final String AUTHENTICATION_SCHEME = "Basic"; @@ -40,8 +43,8 @@ public class AuthenticationFilter implements ContainerRequestFilter { public void filter(ContainerRequestContext requestContext) { //We use reflection on the acceded method to look for security annotations. Method method = resourceInfo.getResourceMethod(); - //if its PermitAll access is granted - //otherwise if its DenyAll the access is refused + //if it is PermitAll access is granted + //otherwise if it is DenyAll the access is refused if (!method.isAnnotationPresent(PermitAll.class)) { if (method.isAnnotationPresent(DenyAll.class)) { requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) @@ -59,64 +62,40 @@ public class AuthenticationFilter implements ContainerRequestFilter { return; } - //Get encoded username and password + //We extract the username and password encoded in base64 final String encodedUserPassword = authorization.substring(AUTHENTICATION_SCHEME.length()).trim(); - //Decode username and password - String usernameAndPassword = new String(Base64.getDecoder().decode(encodedUserPassword.getBytes())); + //We Decode username and password (username:password) + String[] usernameAndPassword = new String(Base64.getDecoder().decode(encodedUserPassword.getBytes())).split(":"); - //Split username and password tokens - final StringTokenizer tokenizer = new StringTokenizer(usernameAndPassword, ":"); - final String username = tokenizer.nextToken(); - final String password = tokenizer.nextToken(); + final String username = usernameAndPassword[0]; + final String password = usernameAndPassword[1]; - log.info(username + " tries to log in with " + password); + log.info(username + " tries to log in"); - //Verify user access + //We verify user access rights according to roles + //After Authentication we are doing Authorization if (method.isAnnotationPresent(RolesAllowed.class)) { RolesAllowed rolesAnnotation = method.getAnnotation(RolesAllowed.class); - EnumSet<UserDatabase.Role> rolesSet = + EnumSet<InMemoryLoginModule.Role> rolesSet = Arrays.stream(rolesAnnotation.value()) - .map(r -> UserDatabase.Role.valueOf(r)) - .collect(Collectors.toCollection(() -> EnumSet.noneOf(UserDatabase.Role.class))); + .map(InMemoryLoginModule.Role::valueOf) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(InMemoryLoginModule.Role.class))); //We check to login/password - if (!UserDatabase.USER_DATABASE.checkPassword(username, password)) { + if (!InMemoryLoginModule.USER_DATABASE.login(username, password)) { requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) .entity("Wrong username or password").build()); return; } //We check if the role is allowed - if (Collections.disjoint(rolesSet, UserDatabase.USER_DATABASE.getUserRoles(username))) { + if (!InMemoryLoginModule.isInRoles(rolesSet, username)) requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) .entity("Roles not allowed").build()); - return; - } - - //We build a new securitycontext to transmit the security data to JAX-RS - requestContext.setSecurityContext(new SecurityContext() { - - @Override - public Principal getUserPrincipal() { - return UserDatabase.USER_DATABASE.getUser(username); - } - - @Override - public boolean isUserInRole(String role) { - return UserDatabase.USER_DATABASE.getUserRoles(username).contains(UserDatabase.Role.valueOf(role)); - } - - @Override - public boolean isSecure() { - return true; - } - - @Override - public String getAuthenticationScheme() { - return AUTHENTICATION_SCHEME; - } - }); + //We build a new SecurityContext Class to transmit the security data + // for this login attempt to JAX-RS + requestContext.setSecurityContext(MySecurityContext.newInstance(AUTHENTICATION_SCHEME, username)); } } diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/security/filter/JsonWebTokenFilter.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/filter/JsonWebTokenFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..088b2b26a2d42d0ee89d00b4802f2d059cf64d69 --- /dev/null +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/filter/JsonWebTokenFilter.java @@ -0,0 +1,110 @@ +package fr.univtln.bruno.samples.jaxrs.security.filter; + +import fr.univtln.bruno.samples.jaxrs.security.InMemoryLoginModule; +import fr.univtln.bruno.samples.jaxrs.security.MySecurityContext; +import fr.univtln.bruno.samples.jaxrs.security.annotations.JWTAuth; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import jakarta.annotation.Priority; +import jakarta.annotation.security.DenyAll; +import jakarta.annotation.security.PermitAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import lombok.extern.java.Log; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.stream.Collectors; + +/** + * This class if a filter for JAX-RS to perform authentication via JWT. + */ +@JWTAuth +@Provider +@Priority(Priorities.AUTHENTICATION) +@Log +public class JsonWebTokenFilter implements ContainerRequestFilter { + private static final String AUTHORIZATION_PROPERTY = "Authorization"; + private static final String AUTHENTICATION_SCHEME = "Bearer"; + + //We inject the data from the acceded resource. + @Context + private ResourceInfo resourceInfo; + + @Override + public void filter(ContainerRequestContext requestContext) { + //We use reflection on the acceded method to look for security annotations. + Method method = resourceInfo.getResourceMethod(); + + //if its PermitAll access is granted (without specific security context) + if (method.isAnnotationPresent(PermitAll.class)) return; + + //otherwise if its DenyAll the access is refused + if (method.isAnnotationPresent(DenyAll.class)) { + requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) + .entity("Access denied to all users").build()); + return; + } + + //We get the authorization header from the request + final String authorization = requestContext.getHeaderString(AUTHORIZATION_PROPERTY); + + //We check the credentials presence + if (authorization == null || authorization.isEmpty()) { + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) + .entity("Please provide your credentials").build()); + return; + } + + //We get the token + final String compactJwt = authorization.substring(AUTHENTICATION_SCHEME.length()).trim(); + if (!authorization.contains(AUTHENTICATION_SCHEME) || compactJwt.isEmpty()) { + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) + .entity("Please provide correct credentials").build()); + return; + } + + String username = null; + + //We check the validity of the token + try { + Jws<Claims> jws = Jwts.parserBuilder() + .requireIssuer("sample-jaxrs") + .setSigningKey(InMemoryLoginModule.KEY) + .build() + .parseClaimsJws(compactJwt); + username = jws.getBody().getSubject(); + + //We build a new securitycontext to transmit the security data to JAX-RS + requestContext.setSecurityContext(MySecurityContext.newInstance(AUTHENTICATION_SCHEME, username)); + } catch (JwtException e) { + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) + .entity("Wrong JWT token. " + e.getLocalizedMessage()).build()); + } + + + //If present we extract the allowed roles annotation. + if (method.isAnnotationPresent(RolesAllowed.class)) { + RolesAllowed rolesAnnotation = method.getAnnotation(RolesAllowed.class); + EnumSet<InMemoryLoginModule.Role> rolesSet = + Arrays.stream(rolesAnnotation.value()) + .map(InMemoryLoginModule.Role::valueOf) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(InMemoryLoginModule.Role.class))); + + //We check if the role is allowed + if (!InMemoryLoginModule.isInRoles(rolesSet, username)) + requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) + .entity("Roles not allowed").build()); + + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/univtln/bruno/samples/jaxrs/ServerIT.java b/src/test/java/fr/univtln/bruno/samples/jaxrs/ServerIT.java index 2a67e62898831ed24fa7e4ed332214022f9bb20a..aca5882296cd7449729e65014214faaa4c8492e8 100644 --- a/src/test/java/fr/univtln/bruno/samples/jaxrs/ServerIT.java +++ b/src/test/java/fr/univtln/bruno/samples/jaxrs/ServerIT.java @@ -1,7 +1,13 @@ package fr.univtln.bruno.samples.jaxrs; import fr.univtln.bruno.samples.jaxrs.model.BiblioModel.Auteur; +import fr.univtln.bruno.samples.jaxrs.security.InMemoryLoginModule; import fr.univtln.bruno.samples.jaxrs.server.BiblioServer; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.ClientBuilder; import jakarta.ws.rs.client.Entity; @@ -13,7 +19,11 @@ import org.glassfish.grizzly.http.server.HttpServer; import org.glassfish.jersey.message.internal.MediaTypes; import org.junit.*; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.Collection; +import java.util.Date; import java.util.List; import static org.junit.Assert.*; @@ -205,4 +215,72 @@ public class ServerIT { assertEquals(1, auteurs.size()); assertEquals("Marie", auteurs.get(0).getPrenom()); } + + @Test + public void refusedLogin() { + Response result = webTarget.path("biblio/login") + .request() + .get(); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), result.getStatus()); + } + + @Test + public void acceptedLogin() { + String email="john.doe@nowhere.com"; + String password="admin"; + Response result = webTarget.path("biblio/login") + .request() + .accept(MediaType.TEXT_PLAIN) + .header("Authorization", "Basic "+java.util.Base64.getEncoder().encodeToString((email+":"+password).getBytes())) + .get(); + + String entity = result.readEntity(String.class); + assertEquals(Response.Status.OK.getStatusCode(), result.getStatus()); + Jws<Claims> jws = Jwts.parserBuilder() + .setSigningKey(InMemoryLoginModule.KEY) + .build() + .parseClaimsJws(entity); + assertEquals(email,jws.getBody().getSubject()); + } + + @Test + public void jwtAccess() { + //Log in to get the token + String email="john.doe@nowhere.com"; + String password="admin"; + String token = webTarget.path("biblio/login") + .request() + .accept(MediaType.TEXT_PLAIN) + .header("Authorization", "Basic "+java.util.Base64.getEncoder().encodeToString((email+":"+password).getBytes())) + .get(String.class); + + //We access a JWT protected URL with the token + Response result = webTarget.path("biblio/secured") + .request() + .header( "Authorization", "Bearer "+token) + .get(); + assertEquals(Response.Status.OK.getStatusCode(), result.getStatus()); + assertEquals("Access with JWT ok for Doe, John <john.doe@nowhere.com>",result.readEntity(String.class)); + } + + @Test + public void jwtAccessDenied() { + String forgedToken = Jwts.builder() + .setIssuer("sample-jaxrs") + .setIssuedAt(Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant())) + .setSubject("john.doe@nowhere.com") + .claim("firstname", "John") + .claim("lastname", "Doe") + .setExpiration(Date.from(LocalDateTime.now().plus(15, ChronoUnit.MINUTES).atZone(ZoneId.systemDefault()).toInstant())) + //A RANDOM KEY DIFFERENT FROM THE SERVER + .signWith( Keys.secretKeyFor(SignatureAlgorithm.HS256)).compact(); + + //We access a JWT protected URL with the token + Response result = webTarget.path("biblio/secured") + .request() + .header( "Authorization", "Bearer "+forgedToken) + .get(); + assertNotEquals(Response.Status.OK.getStatusCode(), result.getStatus()); + } + } diff --git a/src/test/java/fr/univtln/bruno/samples/jaxrs/security/UserTest.java b/src/test/java/fr/univtln/bruno/samples/jaxrs/security/UserTest.java new file mode 100644 index 0000000000000000000000000000000000000000..49eb303cf0ebb96a3d68b0f35654d652f4763454 --- /dev/null +++ b/src/test/java/fr/univtln/bruno/samples/jaxrs/security/UserTest.java @@ -0,0 +1,27 @@ + +package fr.univtln.bruno.samples.jaxrs.security; + +import org.junit.Test; + +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.EnumSet; + +import static org.junit.Assert.assertTrue; + +public class UserTest { + + @Test + public void testBuilder() throws InvalidKeySpecException, NoSuchAlgorithmException { + String lastname="Doe", firstname="John", email="j.d@here.com", password="mypass"; + User user = User.builder() + .lastName(lastname) + .firstName(firstname) + .email(email) + .password(password) + .roles(EnumSet.of(InMemoryLoginModule.Role.ADMIN)) + .build(); + assertTrue(user.checkPassword(password)); + assertTrue(user.contains(InMemoryLoginModule.Role.valueOf("ADMIN"))); + } +} \ No newline at end of file