Skip to content
Snippets Groups Projects
Commit 7f908158 authored by Emmanuel Bruno's avatar Emmanuel Bruno
Browse files

Merge branch 'feature/authentication' into develop

parents 074f5f8e f8f84fe6
No related branches found
No related tags found
No related merge requests found
Showing
with 813 additions and 99 deletions
...@@ -40,12 +40,12 @@ curl -s -D - -H "Accept: application/json" \ ...@@ -40,12 +40,12 @@ curl -s -D - -H "Accept: application/json" \
Removes an author Removes an author
```shell ```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 Removes all authors
```shell ```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 Adds an author
...@@ -77,8 +77,13 @@ Filter resources with query parameters : ...@@ -77,8 +77,13 @@ Filter resources with query parameters :
curl -v -H "Accept: application/json" \ curl -v -H "Accept: application/json" \
"http://127.0.0.1:9998/myapp/biblio/auteurs/filter?nom=Durand&prenom⁼Marie" "http://127.0.0.1:9998/myapp/biblio/auteurs/filter?nom=Durand&prenom⁼Marie"
``` ```
Control sort key with header param (default value "nom") : Control sort key with header param (default value "nom") :
```shell ```shell
curl -v -H "Accept: application/json" -H "sortKey: prenom"\ curl -v -H "Accept: application/json" -H "sortKey: prenom"\
"http://127.0.0.1:9998/myapp/biblio/auteurs/filter" "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
# 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}}
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
...@@ -34,5 +34,26 @@ public class BiblioClient { ...@@ -34,5 +34,26 @@ public class BiblioClient {
.request() .request()
.get(Auteur.class); .get(Auteur.class);
log.info(auteur.toString()); 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);
}
} }
} }
package fr.univtln.bruno.samples.jaxrs.exceptions; 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.ws.rs.core.Response;
import jakarta.xml.bind.annotation.XmlRootElement;
import lombok.Getter; import lombok.Getter;
import java.io.Serializable;
/**
* The type Business exception, used add HTTP (HATEOS) capacities to exceptions.
*/
@Getter @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; final Response.Status status;
/**
* Instantiates a new Business exception.
*
* @param status the status
*/
public BusinessException(Response.Status status) { public BusinessException(Response.Status status) {
super(status.getReasonPhrase()); super(status.getReasonPhrase());
this.status = status; this.status = status;
......
package fr.univtln.bruno.samples.jaxrs.exceptions; 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 class IllegalArgumentException extends BusinessException {
public IllegalArgumentException() { public IllegalArgumentException() {
......
package fr.univtln.bruno.samples.jaxrs.exceptions; package fr.univtln.bruno.samples.jaxrs.exceptions;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.xml.bind.annotation.XmlRootElement;
@XmlRootElement
public class NotFoundException extends BusinessException { public class NotFoundException extends BusinessException {
public NotFoundException() { public NotFoundException() {
super(Response.Status.NOT_FOUND); super(Response.Status.NOT_FOUND);
......
package fr.univtln.bruno.samples.jaxrs.mappers; package fr.univtln.bruno.samples.jaxrs.mappers;
import fr.univtln.bruno.samples.jaxrs.exceptions.BusinessException; import fr.univtln.bruno.samples.jaxrs.exceptions.BusinessException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import lombok.extern.java.Log;
@SuppressWarnings("unused") @SuppressWarnings("unused")
@Provider @Provider
@FieldDefaults(level = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE)
@Log
public class BusinessExceptionMapper implements ExceptionMapper<BusinessException> { public class BusinessExceptionMapper implements ExceptionMapper<BusinessException> {
public Response toResponse(BusinessException ex) { public Response toResponse(BusinessException ex) {
return Response.status(ex.getStatus()) return Response.status(ex.getStatus())
.entity(ex.getMessage()) .entity(ex)
.type(MediaType.APPLICATION_JSON)
.build(); .build();
} }
} }
...@@ -6,10 +6,12 @@ import jakarta.ws.rs.ext.ExceptionMapper; ...@@ -6,10 +6,12 @@ import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import lombok.extern.java.Log;
@SuppressWarnings("unused") @SuppressWarnings("unused")
@Provider @Provider
@FieldDefaults(level = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE)
@Log
public class GenericExceptionMapper implements ExceptionMapper<Exception> { public class GenericExceptionMapper implements ExceptionMapper<Exception> {
public Response toResponse(Exception ex) { public Response toResponse(Exception ex) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR) return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
......
...@@ -3,15 +3,11 @@ package fr.univtln.bruno.samples.jaxrs.model; ...@@ -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.IllegalArgumentException;
import fr.univtln.bruno.samples.jaxrs.exceptions.NotFoundException; import fr.univtln.bruno.samples.jaxrs.exceptions.NotFoundException;
import fr.univtln.bruno.samples.jaxrs.resources.PaginationInfo; 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.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlAttribute; import jakarta.xml.bind.annotation.XmlAttribute;
import jakarta.xml.bind.annotation.XmlRootElement; import jakarta.xml.bind.annotation.XmlRootElement;
import lombok.*; import lombok.*;
import lombok.experimental.Delegate;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import lombok.extern.java.Log; import lombok.extern.java.Log;
import org.eclipse.collections.api.map.primitive.MutableLongObjectMap; import org.eclipse.collections.api.map.primitive.MutableLongObjectMap;
...@@ -27,16 +23,26 @@ import java.util.stream.Stream; ...@@ -27,16 +23,26 @@ import java.util.stream.Stream;
import static fr.univtln.bruno.samples.jaxrs.model.BiblioModel.Field.valueOf; 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 @Log
@Getter @Getter
@FieldDefaults(level = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE)
@NoArgsConstructor(staticName = "of") @NoArgsConstructor(staticName = "of")
public class BiblioModel { public class BiblioModel {
private static AtomicLong lastId = new AtomicLong(0); private static final AtomicLong lastId = new AtomicLong(0);
//@Delegate
@Delegate
final MutableLongObjectMap<Auteur> auteurs = LongObjectMaps.mutable.empty(); 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 { public Auteur addAuteur(Auteur auteur) throws IllegalArgumentException {
if (auteur.id != 0) throw new IllegalArgumentException(); if (auteur.id != 0) throw new IllegalArgumentException();
auteur.id = lastId.incrementAndGet(); auteur.id = lastId.incrementAndGet();
...@@ -44,6 +50,15 @@ public class BiblioModel { ...@@ -44,6 +50,15 @@ public class BiblioModel {
return auteur; 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 { public Auteur updateAuteur(long id, Auteur auteur) throws NotFoundException, IllegalArgumentException {
if (auteur.id != 0) throw new IllegalArgumentException(); if (auteur.id != 0) throw new IllegalArgumentException();
auteur.id = id; auteur.id = id;
...@@ -52,33 +67,62 @@ public class BiblioModel { ...@@ -52,33 +67,62 @@ public class BiblioModel {
return auteur; return auteur;
} }
/**
* Remove one auteur by id.
*
* @param id the id
* @throws NotFoundException the not found exception
*/
public void removeAuteur(long id) throws NotFoundException { public void removeAuteur(long id) throws NotFoundException {
if (!auteurs.containsKey(id)) throw new NotFoundException(); if (!auteurs.containsKey(id)) throw new NotFoundException();
auteurs.remove(id); 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 { public Auteur getAuteur(long id) throws NotFoundException {
if (!auteurs.containsKey(id)) throw new NotFoundException(); if (!auteurs.containsKey(id)) throw new NotFoundException();
return auteurs.get(id); return auteurs.get(id);
} }
/**
* Gets the number of authors.
*
* @return the auteur size
*/
public int getAuteurSize() { public int getAuteurSize() {
return auteurs.size(); 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) { public List<Auteur> getWithFilter(PaginationInfo paginationInfo) {
//We build a author stream, first we add sorting
Stream<Auteur> auteurStream = auteurs.stream() Stream<Auteur> auteurStream = auteurs.stream()
.sorted(Comparator.comparing(auteur -> switch (valueOf(paginationInfo.getSortKey().toUpperCase())) { .sorted(Comparator.comparing(auteur -> switch (valueOf(paginationInfo.getSortKey().toUpperCase())) {
case NOM -> auteur.getNom(); case NOM -> auteur.getNom();
case PRENOM -> auteur.getPrenom(); case PRENOM -> auteur.getPrenom();
default -> throw new InvalidParameterException(); default -> throw new InvalidParameterException();
})); }));
//The add filters according to parameters
if (paginationInfo.getNom() != null) if (paginationInfo.getNom() != null)
auteurStream = auteurStream.filter(auteur -> auteur.getNom().equalsIgnoreCase(paginationInfo.getNom())); 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())); 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())); auteurStream = auteurStream.filter(auteur -> auteur.getBiographie().contains(paginationInfo.getBiographie()));
//Finally add pagination instructions.
if ((paginationInfo.getPage() > 0) && (paginationInfo.getPageSize() > 0)) { if ((paginationInfo.getPage() > 0) && (paginationInfo.getPageSize() > 0)) {
auteurStream = auteurStream auteurStream = auteurStream
.skip(paginationInfo.getPageSize() * (paginationInfo.getPage() - 1)) .skip(paginationInfo.getPageSize() * (paginationInfo.getPage() - 1))
...@@ -88,13 +132,32 @@ public class BiblioModel { ...@@ -88,13 +132,32 @@ public class BiblioModel {
return auteurStream.collect(Collectors.toList()); return auteurStream.collect(Collectors.toList());
} }
/**
* Removes all authors.
*/
public void supprimerAuteurs() { public void supprimerAuteurs() {
auteurs.clear(); auteurs.clear();
lastId.set(0); 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 @Builder
@Getter @Getter
@Setter @Setter
......
...@@ -5,10 +5,10 @@ import fr.univtln.bruno.samples.jaxrs.exceptions.IllegalArgumentException; ...@@ -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.exceptions.NotFoundException;
import fr.univtln.bruno.samples.jaxrs.model.BiblioModel; import fr.univtln.bruno.samples.jaxrs.model.BiblioModel;
import fr.univtln.bruno.samples.jaxrs.model.BiblioModel.Auteur; import fr.univtln.bruno.samples.jaxrs.model.BiblioModel.Auteur;
import fr.univtln.bruno.samples.jaxrs.security.BasicAuth; import fr.univtln.bruno.samples.jaxrs.security.InMemoryLoginModule;
import fr.univtln.bruno.samples.jaxrs.security.JWTAuth;
import fr.univtln.bruno.samples.jaxrs.security.User; 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 fr.univtln.bruno.samples.jaxrs.status.Status;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
...@@ -18,23 +18,33 @@ import lombok.extern.java.Log; ...@@ -18,23 +18,33 @@ import lombok.extern.java.Log;
import javax.naming.AuthenticationException; import javax.naming.AuthenticationException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.sql.Date;
import java.text.ParseException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Collection; import java.util.Collection;
import java.util.Date;
import java.util.List; import java.util.List;
/**
* The Biblio resource.
* A demo JAXRS class, that manages authors and offers a secured access.
*/
@Log @Log
// The Java class will be hosted at the URI path "/biblio" // The Java class will be hosted at the URI path "/biblio"
@Path("biblio") @Path("biblio")
@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_XML}) @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_XML})
public class BiblioResource { public class BiblioResource {
//A in memory instance of a Library model. Kind of a mock.
private static final BiblioModel modeleBibliotheque = BiblioModel.of(); private static final BiblioModel modeleBibliotheque = BiblioModel.of();
//A random number generator
private static final SecureRandom random = new SecureRandom(); 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") @SuppressWarnings("SameReturnValue")
@GET @GET
@Produces(MediaType.TEXT_PLAIN) @Produces(MediaType.TEXT_PLAIN)
...@@ -42,6 +52,12 @@ public class BiblioResource { ...@@ -42,6 +52,12 @@ public class BiblioResource {
return "hello"; 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 @PUT
@Path("init") @Path("init")
public int init() throws IllegalArgumentException { public int init() throws IllegalArgumentException {
...@@ -51,6 +67,15 @@ public class BiblioResource { ...@@ -51,6 +67,15 @@ public class BiblioResource {
return modeleBibliotheque.getAuteurSize(); 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 @PUT
@Path("init/{size:[0-9]+}") @Path("init/{size:[0-9]+}")
public int init(@PathParam("size") int size) throws IllegalArgumentException { public int init(@PathParam("size") int size) throws IllegalArgumentException {
...@@ -63,17 +88,30 @@ public class BiblioResource { ...@@ -63,17 +88,30 @@ public class BiblioResource {
return modeleBibliotheque.getAuteurSize(); return modeleBibliotheque.getAuteurSize();
} }
/**
* A random string generator
*
* @param targetStringLength the length of the String
* @return
*/
private String randomString(int targetStringLength) { private String randomString(int targetStringLength) {
int letterA = 97; int letterA = 97;
int letterZ = 122; int letterZ = 122;
return random.ints(letterA, letterZ + 1) return random.ints(letterA, letterZ + 1)
.limit(targetStringLength) .limit(targetStringLength)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString(); .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 @PUT
@Path("auteurs/{id}") @Path("auteurs/{id}")
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
...@@ -82,11 +120,12 @@ public class BiblioResource { ...@@ -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). * 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. * @param auteur The author to be added without its id.
* @return The added author with its id. * @return The added author with its id.
* @throws IllegalArgumentException * @throws IllegalArgumentException if the author has an explicit id (id!=0).
*/ */
@POST @POST
@Status(Status.CREATED) @Status(Status.CREATED)
...@@ -96,30 +135,61 @@ public class BiblioResource { ...@@ -96,30 +135,61 @@ public class BiblioResource {
return modeleBibliotheque.addAuteur(auteur); 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 @DELETE
@Path("auteurs/{id}") @Path("auteurs/{id}")
public void supprimerAuteur(@PathParam("id") final long id) throws NotFoundException { public void supprimerAuteur(@PathParam("id") final long id) throws NotFoundException {
modeleBibliotheque.removeAuteur(id); modeleBibliotheque.removeAuteur(id);
} }
/**
* Removes every authors
*/
@DELETE @DELETE
@Path("auteurs") @Path("auteurs")
public void supprimerAuteurs() { public void supprimerAuteurs() {
modeleBibliotheque.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 @GET
@Path("auteurs/{id}") @Path("auteurs/{id}")
public Auteur getAuteur(@PathParam("id") final long id) throws NotFoundException { public Auteur getAuteur(@PathParam("id") final long id) throws NotFoundException {
return modeleBibliotheque.getAuteur(id); return modeleBibliotheque.getAuteur(id);
} }
/**
* Gets auteurs.
*
* @return the auteurs
*/
@GET @GET
@Path("auteurs") @Path("auteurs")
public Collection<Auteur> getAuteurs() { public Collection<Auteur> getAuteurs() {
return modeleBibliotheque.getAuteurs().values(); 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 @GET
@Path("auteurs/filter") @Path("auteurs/filter")
public List<Auteur> getFilteredAuteurs(@QueryParam("nom") String nom, public List<Auteur> getFilteredAuteurs(@QueryParam("nom") String nom,
...@@ -136,53 +206,117 @@ public class BiblioResource { ...@@ -136,53 +206,117 @@ public class BiblioResource {
return modeleBibliotheque.getWithFilter(paginationInfo); 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 @GET
@Path("auteurs/page") @Path("auteurs/page")
public List<Auteur> getAuteursPage(@BeanParam PaginationInfo paginationInfo) { public List<Auteur> getAuteursPage(@BeanParam PaginationInfo paginationInfo) {
return modeleBibliotheque.getWithFilter(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 @GET
@Path("context") @Path("context")
@RolesAllowed("ADMIN") public String getContext(@Context UriInfo uriInfo, @Context HttpHeaders httpHeaders, @Context Request request, @Context SecurityContext securityContext) {
public String getContext(@Context UriInfo uriInfo, @Context HttpHeaders httpHeaders, @Context Request request, @Context SecurityContext securityContext) throws ParseException { String result = "UriInfo: (" + uriInfo.getRequestUri().toString() + ")\n"
return "UriInfo: (" + uriInfo.getRequestUri().toString() + "Method: ("+request.getMethod()+")\n"
+ ")\n HttpHeaders(" + httpHeaders.getRequestHeaders().toString() + "HttpHeaders(" + httpHeaders.getRequestHeaders().toString() + ")\n";
//+")\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() if (securityContext != null) {
+ "] user: [" + securityContext.getUserPrincipal().getName() result += " SecurityContext(Auth.scheme: [" + securityContext.getAuthenticationScheme() + "] \n";
+ "] secured: [" + securityContext.isSecure() + "] )"; 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 @GET
@Path("adminsonly") @Path("adminsonly")
@RolesAllowed("ADMIN") @RolesAllowed("ADMIN")
@BasicAuth @BasicAuth
public String getRestrictedToAdmins() { public String getRestrictedToAdmins(@Context SecurityContext securityContext) {
return "secret for admins !"; 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 @GET
@Path("usersonly") @Path("usersonly")
@RolesAllowed("USER") @RolesAllowed("USER")
@BasicAuth @BasicAuth
public String getRestrictedToUsers() { public String getRestrictedToUsers(@Context SecurityContext securityContext) {
return "secret for users !"; 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 @GET
@Path("secured") @Path("secured")
@RolesAllowed({"USER", "ADMIN"}) @RolesAllowed({"USER", "ADMIN"})
@JWTAuth @JWTAuth
@Produces({MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, MediaType.TEXT_XML})
public String securedByJWT(@Context SecurityContext securityContext) { public String securedByJWT(@Context SecurityContext securityContext) {
log.info("USER ACCESS :" + securityContext.getUserPrincipal().getName()); log.info("USER ACCESS :" + securityContext.getUserPrincipal().getName());
return "Access with JWT ok for " + 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 @GET
@Path("login") @Path("login")
@RolesAllowed({"USER", "ADMIN"}) @RolesAllowed({"USER", "ADMIN"})
@BasicAuth @BasicAuth
@Produces({MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, MediaType.TEXT_XML})
public String login(@Context SecurityContext securityContext) { public String login(@Context SecurityContext securityContext) {
if (securityContext.isSecure() && securityContext.getUserPrincipal() instanceof User) { if (securityContext.isSecure() && securityContext.getUserPrincipal() instanceof User) {
User user = (User) securityContext.getUserPrincipal(); User user = (User) securityContext.getUserPrincipal();
...@@ -194,7 +328,7 @@ public class BiblioResource { ...@@ -194,7 +328,7 @@ public class BiblioResource {
.claim("lastname", user.getLastName()) .claim("lastname", user.getLastName())
.claim("roles", user.getRoles()) .claim("roles", user.getRoles())
.setExpiration(Date.from(LocalDateTime.now().plus(15, ChronoUnit.MINUTES).atZone(ZoneId.systemDefault()).toInstant())) .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()); throw new WebApplicationException(new AuthenticationException());
} }
......
...@@ -6,6 +6,10 @@ import jakarta.ws.rs.QueryParam; ...@@ -6,6 +6,10 @@ import jakarta.ws.rs.QueryParam;
import lombok.*; import lombok.*;
import lombok.experimental.FieldDefaults; 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) @FieldDefaults(level = AccessLevel.PRIVATE)
@Getter @Getter
@ToString @ToString
...@@ -13,18 +17,20 @@ import lombok.experimental.FieldDefaults; ...@@ -13,18 +17,20 @@ import lombok.experimental.FieldDefaults;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class PaginationInfo { public class PaginationInfo {
@HeaderParam("sortKey") @SuppressWarnings("FieldMayBeFinal")
@DefaultValue("nom")
String sortKey;
@QueryParam("page") @QueryParam("page")
@Builder.Default @Builder.Default
long page = 1; long page = 1;
@SuppressWarnings("FieldMayBeFinal")
@QueryParam("pageSize") @QueryParam("pageSize")
@Builder.Default @Builder.Default
long pageSize = 10; long pageSize = 10;
@HeaderParam("sortKey")
@DefaultValue("nom")
String sortKey;
@QueryParam("nom") @QueryParam("nom")
String nom; String nom;
......
...@@ -2,7 +2,9 @@ package fr.univtln.bruno.samples.jaxrs.security; ...@@ -2,7 +2,9 @@ package fr.univtln.bruno.samples.jaxrs.security;
import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import lombok.AccessLevel;
import lombok.ToString; import lombok.ToString;
import lombok.experimental.FieldDefaults;
import lombok.extern.java.Log; import lombok.extern.java.Log;
import java.security.Key; import java.security.Key;
...@@ -10,57 +12,127 @@ import java.security.NoSuchAlgorithmException; ...@@ -10,57 +12,127 @@ import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException; import java.security.spec.InvalidKeySpecException;
import java.util.*; import java.util.*;
/**
* this class model a simple in memory role based authentication database (RBAC).
* Password are salted and hashed.
*/
@Log @Log
@ToString @ToString
public class UserDatabase { @FieldDefaults(level = AccessLevel.PRIVATE)
public static final UserDatabase USER_DATABASE = new UserDatabase(); public class InMemoryLoginModule {
/**
* The constant USER_DATABASE mocks a user database in memory.
*/
public static final InMemoryLoginModule USER_DATABASE = new InMemoryLoginModule();
// 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. * 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); public static final Key KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);
//We add three demo users.
static { static {
try { try {
USER_DATABASE.addUser("John", "Doe", "john.doe@nowhere.com", "admin", EnumSet.of(Role.ADMIN)); 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("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)); USER_DATABASE.addUser("Mary", "Robert", "mary.roberts@here.net", "user", EnumSet.of(Role.USER));
} catch (InvalidKeySpecException e) { } catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
e.printStackTrace(); log.severe("In memory user database error "+e.getLocalizedMessage());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} }
} }
private final Map<String, User> users = new HashMap<>(); final Map<String, User> users = new HashMap<>();
public static void main(String[] args) { public static boolean isInRoles(Set<Role> rolesSet, String username) {
USER_DATABASE.users.values().forEach(u->log.info(u.toString())); return !(Collections.disjoint(rolesSet, InMemoryLoginModule.USER_DATABASE.getUserRoles(username)));
} }
public void addUser(String firstname, String lastname, String email, String password, EnumSet<Role> roles) /**
* 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 { throws InvalidKeySpecException, NoSuchAlgorithmException {
users.put(email, User.builder().firstName(firstname).lastName(lastname).email(email).password(password).roles(roles).build()); 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() { public Map<String, User> getUsers() {
return Collections.unmodifiableMap(users); return Collections.unmodifiableMap(users);
} }
/**
* Remove user.
*
* @param email the email
*/
public void removeUser(String email) { public void removeUser(String email) {
users.remove(email); users.remove(email);
} }
public boolean checkPassword(String email, String password) throws InvalidKeySpecException, NoSuchAlgorithmException { /**
* 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); return users.get(email).checkPassword(password);
} }
/**
* Gets user.
*
* @param email the email
* @return the user
*/
public User getUser(String email) { public User getUser(String email) {
return users.get(email); return users.get(email);
} }
/**
* Gets user roles.
*
* @param email the email
* @return the user roles
*/
public Set<Role> getUserRoles(String email) { public Set<Role> getUserRoles(String email) {
return users.get(email).getRoles(); return users.get(email).getRoles();
} }
public enum Role {ADMIN, USER, GUEST} @SuppressWarnings("SameReturnValue")
public boolean logout() {
return false;
}
/**
* The enum Role.
*/
public enum Role {
/**
* Admin role.
*/
ADMIN,
/**
* User role.
*/
USER,
/**
* Guest role.
*/
GUEST
}
} }
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;
}
}
...@@ -23,17 +23,19 @@ import java.util.*; ...@@ -23,17 +23,19 @@ import java.util.*;
@EqualsAndHashCode(of = "email") @EqualsAndHashCode(of = "email")
public class User implements Principal { public class User implements Principal {
UUID uuid = UUID.randomUUID(); UUID uuid = UUID.randomUUID();
String firstName, lastName, email; String firstName;
String lastName;
String email;
byte[] passwordHash; byte[] passwordHash;
byte[] salt = new byte[16]; byte[] salt = new byte[16];
@Delegate @Delegate
EnumSet<UserDatabase.Role> roles; Set<InMemoryLoginModule.Role> roles;
SecureRandom random = new SecureRandom(); SecureRandom random = new SecureRandom();
@Builder @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 { throws NoSuchAlgorithmException, InvalidKeySpecException {
this.firstName = firstName; this.firstName = firstName;
this.lastName = lastName; this.lastName = lastName;
...@@ -55,7 +57,8 @@ public class User implements Principal { ...@@ -55,7 +57,8 @@ public class User implements Principal {
return email + "" + Base64.getEncoder().encodeToString(passwordHash); 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); KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 65536, 128);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
byte[] submittedPasswordHash = factory.generateSecret(spec).getEncoded(); byte[] submittedPasswordHash = factory.generateSecret(spec).getEncoded();
......
package fr.univtln.bruno.samples.jaxrs.security; package fr.univtln.bruno.samples.jaxrs.security.annotations;
import jakarta.ws.rs.NameBinding; import jakarta.ws.rs.NameBinding;
...@@ -9,6 +9,10 @@ import static java.lang.annotation.ElementType.METHOD; ...@@ -9,6 +9,10 @@ import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME; 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 @NameBinding
@Retention(RUNTIME) @Retention(RUNTIME)
@Target({TYPE, METHOD}) @Target({TYPE, METHOD})
......
package fr.univtln.bruno.samples.jaxrs.security; package fr.univtln.bruno.samples.jaxrs.security.annotations;
import jakarta.ws.rs.NameBinding; import jakarta.ws.rs.NameBinding;
...@@ -9,6 +9,10 @@ import static java.lang.annotation.ElementType.METHOD; ...@@ -9,6 +9,10 @@ import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME; 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 @NameBinding
@Retention(RUNTIME) @Retention(RUNTIME)
@Target({TYPE, METHOD}) @Target({TYPE, METHOD})
......
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.Priority;
import jakarta.annotation.security.DenyAll; import jakarta.annotation.security.DenyAll;
import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
...@@ -10,24 +13,24 @@ import jakarta.ws.rs.container.ContainerRequestFilter; ...@@ -10,24 +13,24 @@ import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.ResourceInfo; import jakarta.ws.rs.container.ResourceInfo;
import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.java.Log; import lombok.extern.java.Log;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.security.Principal;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; 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 @BasicAuth
@Provider @Provider
@Priority(Priorities.AUTHENTICATION) @Priority(Priorities.AUTHENTICATION)
@Log @Log
/** public class BasicAuthenticationFilter implements ContainerRequestFilter {
* This class if a filter for JAX-RS to perform authentication and to check permissions against the acceded method.
*/
public class AuthenticationFilter implements ContainerRequestFilter {
private static final String AUTHORIZATION_PROPERTY = "Authorization"; private static final String AUTHORIZATION_PROPERTY = "Authorization";
private static final String AUTHENTICATION_SCHEME = "Basic"; private static final String AUTHENTICATION_SCHEME = "Basic";
...@@ -40,8 +43,8 @@ public class AuthenticationFilter implements ContainerRequestFilter { ...@@ -40,8 +43,8 @@ public class AuthenticationFilter implements ContainerRequestFilter {
public void filter(ContainerRequestContext requestContext) { public void filter(ContainerRequestContext requestContext) {
//We use reflection on the acceded method to look for security annotations. //We use reflection on the acceded method to look for security annotations.
Method method = resourceInfo.getResourceMethod(); Method method = resourceInfo.getResourceMethod();
//if its PermitAll access is granted //if it is PermitAll access is granted
//otherwise if its DenyAll the access is refused //otherwise if it is DenyAll the access is refused
if (!method.isAnnotationPresent(PermitAll.class)) { if (!method.isAnnotationPresent(PermitAll.class)) {
if (method.isAnnotationPresent(DenyAll.class)) { if (method.isAnnotationPresent(DenyAll.class)) {
requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)
...@@ -59,64 +62,40 @@ public class AuthenticationFilter implements ContainerRequestFilter { ...@@ -59,64 +62,40 @@ public class AuthenticationFilter implements ContainerRequestFilter {
return; return;
} }
//Get encoded username and password //We extract the username and password encoded in base64
final String encodedUserPassword = authorization.substring(AUTHENTICATION_SCHEME.length()).trim(); final String encodedUserPassword = authorization.substring(AUTHENTICATION_SCHEME.length()).trim();
//Decode username and password //We Decode username and password (username:password)
String usernameAndPassword = new String(Base64.getDecoder().decode(encodedUserPassword.getBytes())); String[] usernameAndPassword = new String(Base64.getDecoder().decode(encodedUserPassword.getBytes())).split(":");
//Split username and password tokens final String username = usernameAndPassword[0];
final StringTokenizer tokenizer = new StringTokenizer(usernameAndPassword, ":"); final String password = usernameAndPassword[1];
final String username = tokenizer.nextToken();
final String password = tokenizer.nextToken();
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)) { if (method.isAnnotationPresent(RolesAllowed.class)) {
RolesAllowed rolesAnnotation = method.getAnnotation(RolesAllowed.class); RolesAllowed rolesAnnotation = method.getAnnotation(RolesAllowed.class);
EnumSet<UserDatabase.Role> rolesSet = EnumSet<InMemoryLoginModule.Role> rolesSet =
Arrays.stream(rolesAnnotation.value()) Arrays.stream(rolesAnnotation.value())
.map(r -> UserDatabase.Role.valueOf(r)) .map(InMemoryLoginModule.Role::valueOf)
.collect(Collectors.toCollection(() -> EnumSet.noneOf(UserDatabase.Role.class))); .collect(Collectors.toCollection(() -> EnumSet.noneOf(InMemoryLoginModule.Role.class)));
//We check to login/password //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) requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED)
.entity("Wrong username or password").build()); .entity("Wrong username or password").build());
return; return;
} }
//We check if the role is allowed //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) requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED)
.entity("Roles not allowed").build()); .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));
} }
} }
......
package fr.univtln.bruno.samples.jaxrs.security; 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.Claims;
import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import jakarta.annotation.Priority; import jakarta.annotation.Priority;
import jakarta.annotation.security.DenyAll; import jakarta.annotation.security.DenyAll;
...@@ -13,25 +17,21 @@ import jakarta.ws.rs.container.ContainerRequestFilter; ...@@ -13,25 +17,21 @@ import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.ResourceInfo; import jakarta.ws.rs.container.ResourceInfo;
import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import lombok.SneakyThrows;
import lombok.extern.java.Log; import lombok.extern.java.Log;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.security.Principal;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* This class if a filter for JAX-RS to perform authentication via JWT.
*/
@JWTAuth @JWTAuth
@Provider @Provider
@Priority(Priorities.AUTHENTICATION) @Priority(Priorities.AUTHENTICATION)
@Log @Log
/**
* This class if a filter for JAX-RS to perform authentication via JWT.
*/
public class JsonWebTokenFilter implements ContainerRequestFilter { public class JsonWebTokenFilter implements ContainerRequestFilter {
private static final String AUTHORIZATION_PROPERTY = "Authorization"; private static final String AUTHORIZATION_PROPERTY = "Authorization";
private static final String AUTHENTICATION_SCHEME = "Bearer"; private static final String AUTHENTICATION_SCHEME = "Bearer";
...@@ -40,32 +40,24 @@ public class JsonWebTokenFilter implements ContainerRequestFilter { ...@@ -40,32 +40,24 @@ public class JsonWebTokenFilter implements ContainerRequestFilter {
@Context @Context
private ResourceInfo resourceInfo; private ResourceInfo resourceInfo;
@SneakyThrows
@Override @Override
public void filter(ContainerRequestContext requestContext) { public void filter(ContainerRequestContext requestContext) {
//We use reflection on the acceded method to look for security annotations. //We use reflection on the acceded method to look for security annotations.
Method method = resourceInfo.getResourceMethod(); Method method = resourceInfo.getResourceMethod();
//if its PermitAll access is granted
//if its PermitAll access is granted (without specific security context)
if (method.isAnnotationPresent(PermitAll.class)) return;
//otherwise if its DenyAll the access is refused //otherwise if its DenyAll the access is refused
if (!method.isAnnotationPresent(PermitAll.class)) {
if (method.isAnnotationPresent(DenyAll.class)) { if (method.isAnnotationPresent(DenyAll.class)) {
requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)
.entity("Access denied to all users").build()); .entity("Access denied to all users").build());
return; return;
} }
//We get the authorization header //We get the authorization header from the request
final String authorization = requestContext.getHeaderString(AUTHORIZATION_PROPERTY); 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 //We check the credentials presence
if (authorization == null || authorization.isEmpty()) { if (authorization == null || authorization.isEmpty()) {
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED)
...@@ -73,56 +65,46 @@ public class JsonWebTokenFilter implements ContainerRequestFilter { ...@@ -73,56 +65,46 @@ public class JsonWebTokenFilter implements ContainerRequestFilter {
return; return;
} }
//Gets the token //We get the token
log.info("AUTH: "+authorization);
final String compactJwt = authorization.substring(AUTHENTICATION_SCHEME.length()).trim(); final String compactJwt = authorization.substring(AUTHENTICATION_SCHEME.length()).trim();
if (!authorization.contains(AUTHENTICATION_SCHEME) || compactJwt == null || compactJwt.isEmpty()) { if (!authorization.contains(AUTHENTICATION_SCHEME) || compactJwt.isEmpty()) {
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED)
.entity("Please provide your credentials").build()); .entity("Please provide correct credentials").build());
return; return;
} }
log.info("JWT: "+compactJwt);
String username = null;
//We check the validity of the token
try {
Jws<Claims> jws = Jwts.parserBuilder() Jws<Claims> jws = Jwts.parserBuilder()
.setSigningKey(UserDatabase.KEY) .requireIssuer("sample-jaxrs")
.setSigningKey(InMemoryLoginModule.KEY)
.build() .build()
.parseClaimsJws(compactJwt); .parseClaimsJws(compactJwt);
log.info("JWT decoded: "+jws.toString()); username = jws.getBody().getSubject();
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 //We build a new securitycontext to transmit the security data to JAX-RS
requestContext.setSecurityContext(new SecurityContext() { requestContext.setSecurityContext(MySecurityContext.newInstance(AUTHENTICATION_SCHEME, username));
} catch (JwtException e) {
@Override requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED)
public Principal getUserPrincipal() { .entity("Wrong JWT token. " + e.getLocalizedMessage()).build());
return UserDatabase.USER_DATABASE.getUser(username);
} }
@Override
public boolean isUserInRole(String role) {
return UserDatabase.USER_DATABASE.getUserRoles(username).contains(UserDatabase.Role.valueOf(role));
}
@Override //If present we extract the allowed roles annotation.
public boolean isSecure() { if (method.isAnnotationPresent(RolesAllowed.class)) {
return true; 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)));
@Override //We check if the role is allowed
public String getAuthenticationScheme() { if (!InMemoryLoginModule.isInRoles(rolesSet, username))
return AUTHENTICATION_SCHEME; requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)
} .entity("Roles not allowed").build());
});
} }
} }
} }
\ No newline at end of file
}
\ No newline at end of file
package fr.univtln.bruno.samples.jaxrs; package fr.univtln.bruno.samples.jaxrs;
import fr.univtln.bruno.samples.jaxrs.model.BiblioModel.Auteur; 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 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.Client;
import jakarta.ws.rs.client.ClientBuilder; import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.Entity;
...@@ -13,7 +19,11 @@ import org.glassfish.grizzly.http.server.HttpServer; ...@@ -13,7 +19,11 @@ import org.glassfish.grizzly.http.server.HttpServer;
import org.glassfish.jersey.message.internal.MediaTypes; import org.glassfish.jersey.message.internal.MediaTypes;
import org.junit.*; import org.junit.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Collection; import java.util.Collection;
import java.util.Date;
import java.util.List; import java.util.List;
import static org.junit.Assert.*; import static org.junit.Assert.*;
...@@ -205,4 +215,72 @@ public class ServerIT { ...@@ -205,4 +215,72 @@ public class ServerIT {
assertEquals(1, auteurs.size()); assertEquals(1, auteurs.size());
assertEquals("Marie", auteurs.get(0).getPrenom()); 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());
}
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment