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..bfbf655972055a7acf1cd1aeed2d4a0e14c82c6a --- /dev/null +++ b/queries/sample-requests.rest @@ -0,0 +1,101 @@ +# 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 + +### 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: text/xml + +### 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 + +### Returns the context of the query (without authentication). +GET http://localhost:9998/myapp/biblio/context +biblio-demo-header-1: myvalue +biblio-demo-header-2: anothervalue + +### 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}} + diff --git a/sonar.sh b/sonar.sh index 1c440608e5f82480044032fdefd895f6189366d1..d93fa7ea162627a0414f2b57aba9a8012d53c139 100755 --- a/sonar.sh +++ b/sonar.sh @@ -1 +1 @@ -mvn sonar:sonar -D sonar.branch.name="$(git rev-parse --abbrev-ref HEAD|tr / _ )" -DskipTests=true -Dsonar.language=java -Dsonar.report.export.path=sonar-report.json -Dsonar.host.url=http://localhost:9000 --activate-profiles sonar +mvn sonar:sonar -D sonar.branch.name="$(git rev-parse --abbrev-ref HEAD)" -DskipTests=true -Dsonar.language=java -Dsonar.report.export.path=sonar-report.json -Dsonar.host.url=http://localhost:9000 --activate-profiles sonar diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/client/BiblioClient.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/client/BiblioClient.java index 903059bdaddfabfc2ca7fd4f0f948f178d7366a0..6bdaf72af1ebd4695329856ba219b142083f171f 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/client/BiblioClient.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/client/BiblioClient.java @@ -34,5 +34,26 @@ public class BiblioClient { .request() .get(Auteur.class); log.info(auteur.toString()); + + //Log in to get the token with basci authentication + String email = "john.doe@nowhere.com"; + String password = "admin"; + String token = webResource.path("biblio/login") + .request() + .accept(MediaType.TEXT_PLAIN) + .header("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString((email + ":" + password).getBytes())) + .get(String.class); + if (!token.isBlank()) { + log.info("token received."); + //We access a JWT protected URL with the token + String result = webResource.path("biblio/secured") + .request() + .header("Authorization", "Bearer " + token) + .get(String.class); + + log.info(result); + } + + } } diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/BusinessException.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/BusinessException.java index ed7138b016b48f1ddd03e93ae8515bc772395bf8..fa83532d978ffef37ada52f7627f791b2261cc70 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/BusinessException.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/BusinessException.java @@ -1,12 +1,32 @@ package fr.univtln.bruno.samples.jaxrs.exceptions; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.ws.rs.core.Response; +import jakarta.xml.bind.annotation.XmlRootElement; import lombok.Getter; +import java.io.Serializable; + +/** + * The type Business exception, used add HTTP (HATEOS) capacities to exceptions. + */ @Getter -public class BusinessException extends Exception { +@JsonIgnoreProperties({"stackTrace"}) +@JsonInclude(JsonInclude.Include.NON_EMPTY) + +@XmlRootElement +public class BusinessException extends Exception implements Serializable { + /** + * The Status. + */ final Response.Status status; + /** + * Instantiates a new Business exception. + * + * @param status the status + */ public BusinessException(Response.Status status) { super(status.getReasonPhrase()); this.status = status; diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/IllegalArgumentException.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/IllegalArgumentException.java index 7a8e69d1bb7286cfb36119731c6b9838e7aabb9e..deae099b7181c328ecf1bdccf0857166e424c4f5 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/IllegalArgumentException.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/IllegalArgumentException.java @@ -1,6 +1,6 @@ package fr.univtln.bruno.samples.jaxrs.exceptions; -import static jakarta.ws.rs.core.Response.*; +import static jakarta.ws.rs.core.Response.Status; public class IllegalArgumentException extends BusinessException { public IllegalArgumentException() { diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/NotFoundException.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/NotFoundException.java index 64f65a1fd75ebcf230ae4bbc71f3da0eca79fd1d..f28fc2544258ebce1852ec5dbcf668989ae01892 100644 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/NotFoundException.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/exceptions/NotFoundException.java @@ -1,7 +1,9 @@ package fr.univtln.bruno.samples.jaxrs.exceptions; import jakarta.ws.rs.core.Response; +import jakarta.xml.bind.annotation.XmlRootElement; +@XmlRootElement public class NotFoundException extends BusinessException { public NotFoundException() { super(Response.Status.NOT_FOUND); diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/mappers/BusinessExceptionMapper.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/mappers/BusinessExceptionMapper.java index 8ddb6787412b73453c99851ae21b9fe9500c0691..a057821d46434c40ee0559cfff548a73baf205e5 100755 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/mappers/BusinessExceptionMapper.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/mappers/BusinessExceptionMapper.java @@ -1,22 +1,21 @@ package fr.univtln.bruno.samples.jaxrs.mappers; import fr.univtln.bruno.samples.jaxrs.exceptions.BusinessException; -import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.Provider; import lombok.AccessLevel; import lombok.experimental.FieldDefaults; +import lombok.extern.java.Log; @SuppressWarnings("unused") @Provider @FieldDefaults(level = AccessLevel.PRIVATE) +@Log public class BusinessExceptionMapper implements ExceptionMapper<BusinessException> { - public Response toResponse(BusinessException ex) { return Response.status(ex.getStatus()) - .entity(ex.getMessage()) - .type(MediaType.APPLICATION_JSON) + .entity(ex) .build(); } } diff --git a/src/main/java/fr/univtln/bruno/samples/jaxrs/mappers/GenericExceptionMapper.java b/src/main/java/fr/univtln/bruno/samples/jaxrs/mappers/GenericExceptionMapper.java index 6902906eb8f458dba24a1d4034470eee5e4bb148..554394ad53ee603c1dc9867a5914e7cc077b3c7a 100755 --- a/src/main/java/fr/univtln/bruno/samples/jaxrs/mappers/GenericExceptionMapper.java +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/mappers/GenericExceptionMapper.java @@ -6,10 +6,12 @@ import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.Provider; import lombok.AccessLevel; import lombok.experimental.FieldDefaults; +import lombok.extern.java.Log; @SuppressWarnings("unused") @Provider @FieldDefaults(level = AccessLevel.PRIVATE) +@Log public class GenericExceptionMapper implements ExceptionMapper<Exception> { public Response toResponse(Exception ex) { return Response.status(Response.Status.INTERNAL_SERVER_ERROR) 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..ff787698d1e8fa944a70ac3e659936b5bf855078 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,15 +3,11 @@ 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; import jakarta.xml.bind.annotation.XmlRootElement; import lombok.*; -import lombok.experimental.Delegate; import lombok.experimental.FieldDefaults; import lombok.extern.java.Log; import org.eclipse.collections.api.map.primitive.MutableLongObjectMap; @@ -27,16 +23,26 @@ import java.util.stream.Stream; import static fr.univtln.bruno.samples.jaxrs.model.BiblioModel.Field.valueOf; + +/** + * The type Biblio model. A in memory instance of a Library model. Kind of a mock. + */ @Log @Getter @FieldDefaults(level = AccessLevel.PRIVATE) @NoArgsConstructor(staticName = "of") public class BiblioModel { - private static AtomicLong lastId = new AtomicLong(0); - - @Delegate + 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 +50,15 @@ public class BiblioModel { return auteur; } + /** + * Update auteur auteur by id and data contains in a author instance (except the id). + * + * @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 +67,62 @@ public class BiblioModel { return auteur; } + /** + * Remove one auteur by id. + * + * @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 one auteur id. + * + * @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 the number of authors. + * + * @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(); })); + + //The add filters according to parameters if (paginationInfo.getNom() != null) auteurStream = auteurStream.filter(auteur -> auteur.getNom().equalsIgnoreCase(paginationInfo.getNom())); if (paginationInfo.getPrenom() != null) auteurStream = auteurStream.filter(auteur -> auteur.getPrenom().equalsIgnoreCase(paginationInfo.getPrenom())); 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)) @@ -88,13 +132,32 @@ public class BiblioModel { return auteurStream.collect(Collectors.toList()); } + /** + * Removes all authors. + */ public void supprimerAuteurs() { auteurs.clear(); lastId.set(0); } - public enum Field {NOM, PRENOM, BIOGRAPHIE} + /** + * The list of fields of author that can used in filters. + */ + public enum Field { + NOM, + /** + * Prenom field. + */ + PRENOM, + /** + * Biographie field. + */ + BIOGRAPHIE + } + /** + * The type Author. + */ @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..8cafd62722b53e0a344f08d651dc57478c7e933b 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.InMemoryLoginModule; import fr.univtln.bruno.samples.jaxrs.security.User; -import fr.univtln.bruno.samples.jaxrs.security.UserDatabase; +import fr.univtln.bruno.samples.jaxrs.security.annotations.BasicAuth; +import fr.univtln.bruno.samples.jaxrs.security.annotations.JWTAuth; import fr.univtln.bruno.samples.jaxrs.status.Status; import io.jsonwebtoken.Jwts; import jakarta.annotation.security.RolesAllowed; @@ -18,23 +18,33 @@ 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; +/** + * The Biblio resource. + * A demo JAXRS class, that manages authors and offers a secured access. + */ @Log // The Java class will be hosted at the URI path "/biblio" @Path("biblio") @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_XML}) public class BiblioResource { + //A in memory instance of a Library model. Kind of a mock. private static final BiblioModel modeleBibliotheque = BiblioModel.of(); + //A random number generator private static final SecureRandom random = new SecureRandom(); + /** + * The simpliest method that just return "hello" in plain text with GET on the default path "biblio". + * + * @return the string + */ @SuppressWarnings("SameReturnValue") @GET @Produces(MediaType.TEXT_PLAIN) @@ -42,6 +52,12 @@ public class BiblioResource { return "hello"; } + /** + * An init method that add two authors with a PUT on the default path. + * + * @return the number of generated authors. + * @throws IllegalArgumentException the illegal argument exception + */ @PUT @Path("init") public int init() throws IllegalArgumentException { @@ -51,6 +67,15 @@ public class BiblioResource { return modeleBibliotheque.getAuteurSize(); } + /** + * An init method that add a given number of random authors whose names are just random letters on PUT. + * The number of authors if given in the path avec bound to the name size. The needed format (an integer) is checked with a regular expression [0-9]+ + * The parameter is injected with @PathParam + * + * @param size the number of authors to add + * @return the int number of generated authors. + * @throws IllegalArgumentException the illegal argument exception + */ @PUT @Path("init/{size:[0-9]+}") public int init(@PathParam("size") int size) throws IllegalArgumentException { @@ -63,17 +88,30 @@ public class BiblioResource { return modeleBibliotheque.getAuteurSize(); } + /** + * A random string generator + * + * @param targetStringLength the length of the String + * @return + */ private String randomString(int targetStringLength) { int letterA = 97; int letterZ = 122; - - return random.ints(letterA, letterZ + 1) .limit(targetStringLength) .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) .toString(); } + /** + * Update an author with an given id. + * + * @param id the id injected from the path param "id" + * @param auteur a injected author made from the JSON data (@Consumes) from body of the request. This author is forbidden to havce an Id. + * @return The resulting author with its id. + * @throws NotFoundException is returned if no author has the "id". + * @throws IllegalArgumentException is returned if an "id" is also given in the request body. + */ @PUT @Path("auteurs/{id}") @Consumes(MediaType.APPLICATION_JSON) @@ -82,11 +120,12 @@ public class BiblioResource { } /** + * Adds an new author to the data. * Status annotation is a trick to fine tune 2XX status codes (see the status package). * * @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) @@ -96,30 +135,61 @@ public class BiblioResource { return modeleBibliotheque.addAuteur(auteur); } + /** + * Removes an author by id from the data. + * + * @param id the id of the author to remove + * @throws NotFoundException is returned if no author has the "id". + */ @DELETE @Path("auteurs/{id}") public void supprimerAuteur(@PathParam("id") final long id) throws NotFoundException { modeleBibliotheque.removeAuteur(id); } + /** + * Removes every authors + */ @DELETE @Path("auteurs") public void supprimerAuteurs() { modeleBibliotheque.supprimerAuteurs(); } + /** + * Find and return an author by id with a GET on the path "biblio/auteurs/{id}" where {id} is the needed id. + * The path parameter "id" is injected with @PathParam. + * + * @param id the needed author id. + * @return the auteur with id. + * @throws NotFoundException is returned if no author has the "id". + */ @GET @Path("auteurs/{id}") public Auteur getAuteur(@PathParam("id") final long id) throws NotFoundException { return modeleBibliotheque.getAuteur(id); } + /** + * Gets auteurs. + * + * @return the auteurs + */ @GET @Path("auteurs") public Collection<Auteur> getAuteurs() { return modeleBibliotheque.getAuteurs().values(); } + /** + * Gets a list of "filtered" authors. + * + * @param nom an optional exact filter on the name. + * @param prenom an optional exact filter on the firstname. + * @param biographie an optional contains filter on the biography. + * @param sortKey the sort key (prenom or nom). + * @return the filtered auteurs + */ @GET @Path("auteurs/filter") public List<Auteur> getFilteredAuteurs(@QueryParam("nom") String nom, @@ -136,53 +206,117 @@ public class BiblioResource { return modeleBibliotheque.getWithFilter(paginationInfo); } + /** + * Gets a page of authors after applying a sort. + * + * @param paginationInfo the pagination info represented as a class injected with @BeanParam. + * @return the page of authors. + */ @GET @Path("auteurs/page") public List<Auteur> getAuteursPage(@BeanParam PaginationInfo paginationInfo) { return modeleBibliotheque.getWithFilter(paginationInfo); } + /** + * A GET method to access the context of the request : The URI, the HTTP headers, the request and the security context (needs authentication see below). + * + * @param uriInfo the uri info + * @param httpHeaders the http headers + * @param request the request + * @param securityContext the security context + * @return A string representation of the available data. + */ @GET @Path("context") - @RolesAllowed("ADMIN") - public String getContext(@Context UriInfo uriInfo, @Context HttpHeaders httpHeaders, @Context Request request, @Context SecurityContext securityContext) throws ParseException { - 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() + "] )"; + public String getContext(@Context UriInfo uriInfo, @Context HttpHeaders httpHeaders, @Context Request request, @Context SecurityContext securityContext) { + String result = "UriInfo: (" + uriInfo.getRequestUri().toString() + ")\n" + + "Method: ("+request.getMethod()+")\n" + + "HttpHeaders(" + httpHeaders.getRequestHeaders().toString() + ")\n"; + + if (securityContext != null) { + result += " SecurityContext(Auth.scheme: [" + securityContext.getAuthenticationScheme() + "] \n"; + if (securityContext.getUserPrincipal() != null) + result += " user: [" + securityContext.getUserPrincipal().getName() + "] \n"; + result += " secured: [" + securityContext.isSecure() + "] )"; + } + return result; } + /** + * A GET restricted to ADMIN role with basic authentication. + * @see fr.univtln.bruno.samples.jaxrs.security.filter.BasicAuthenticationFilter + * + * @param securityContext the security context + * @return the restricted to admins + */ @GET @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(); } + /** + * A GET restricted to USER role with basic authentication (and not ADMIN !). + * @see fr.univtln.bruno.samples.jaxrs.security.filter.BasicAuthenticationFilter + * + * @param securityContext the security context + * @return the restricted to users + */ @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(); } + /** + * A GET restricted to USER & ADMIN roles, secured with a JWT Token. + * @see fr.univtln.bruno.samples.jaxrs.security.filter.JsonWebTokenFilter + * + * @param securityContext the security context + * @return the string + */ @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(); + } + + /** + * A GET restricted to ADMIN roles, secured with a JWT Token. + * + * @param securityContext the security context + * @return the string + */ + @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(); } + /** + * a GET method to obtain a JWT token with basic authentication for USER and ADMIN roles. + * + * @param securityContext the security context + * @return the base64 encoded JWT Token. + */ @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 +328,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..bc9c399dccbe71390b404529edbe4d86edbba419 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 @@ -6,6 +6,10 @@ import jakarta.ws.rs.QueryParam; import lombok.*; import lombok.experimental.FieldDefaults; +/** + * The Pagination information to be injected with @BeanPararm Filter Queries. + * Each field is annotated with a JAX-RS parameter injection. + */ @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @ToString @@ -13,18 +17,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..6245c8bd9bf73e31dae4c22c6a515006692d5e97 --- /dev/null +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/InMemoryLoginModule.java @@ -0,0 +1,138 @@ +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.*; + +/** + * this class model a simple in memory role based authentication database (RBAC). + * Password are salted and hashed. + */ +@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..1ab16f818c33ef4d37e0317160dfdd3929bb9fbc --- /dev/null +++ b/src/main/java/fr/univtln/bruno/samples/jaxrs/security/MySecurityContext.java @@ -0,0 +1,45 @@ +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; + +/** + * This class define a specific security context after an authentication with either the basic or the JWT filters. + * + * @see fr.univtln.bruno.samples.jaxrs.security.filter.BasicAuthenticationFilter + * @see fr.univtln.bruno.samples.jaxrs.security.filter.JsonWebTokenFilter + */ +@FieldDefaults(level = AccessLevel.PRIVATE) +@AllArgsConstructor(staticName = "newInstance") +public class MySecurityContext implements SecurityContext { + private final String authenticationScheme; + private final String username; + + //the authenticated user + @Override + public Principal getUserPrincipal() { + return InMemoryLoginModule.USER_DATABASE.getUser(username); + } + + //A method to check if a user belongs to a role + @Override + public boolean isUserInRole(String role) { + return InMemoryLoginModule.USER_DATABASE.getUserRoles(username).contains(InMemoryLoginModule.Role.valueOf(role)); + } + + //Say the access has been secured + @Override + public boolean isSecure() { + return true; + } + + //The authentication scheme (Basic, JWT, ...) that has been used. + @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 64% 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..f09c007d5d6f86a0c9881997be131835f43f599c 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; @@ -9,6 +9,10 @@ import static java.lang.annotation.ElementType.METHOD; import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; +/** + * A annotation for method to be secured with Basic Auth + * @see fr.univtln.bruno.samples.jaxrs.security.filter.BasicAuthenticationFilter + */ @NameBinding @Retention(RUNTIME) @Target({TYPE, METHOD}) 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 63% 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..d7834fcb1b341ebe37d09d72a2f66cb5599ac77d 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; @@ -9,6 +9,10 @@ import static java.lang.annotation.ElementType.METHOD; import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; +/** + * A annotation for method to be secured with Java Web Token (JWT) + * @see fr.univtln.bruno.samples.jaxrs.security.filter.JsonWebTokenFilter + */ @NameBinding @Retention(RUNTIME) @Target({TYPE, METHOD}) 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..a431005f235f14cd30c731e1281cb16ece95671d 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; +/** + * 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