diff --git a/codiki-application/src/main/java/org/codiki/application/security/JwtService.java b/codiki-application/src/main/java/org/codiki/application/security/JwtService.java index 44d304a..2a9fb0a 100644 --- a/codiki-application/src/main/java/org/codiki/application/security/JwtService.java +++ b/codiki-application/src/main/java/org/codiki/application/security/JwtService.java @@ -1,14 +1,24 @@ package org.codiki.application.security; import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import static org.codiki.domain.user.model.builder.UserBuilder.anUser; import org.codiki.domain.user.model.User; +import org.codiki.domain.user.model.UserRole; +import org.codiki.domain.user.model.builder.UserBuilder; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.Claim; @Service public class JwtService { @@ -31,6 +41,7 @@ public class JwtService { return JWT.create() .withSubject(user.id().toString()) .withExpiresAt(expirationDate.toInstant()) + .withPayload(user.toJwtPayload()) .sign(algorithm); } @@ -45,7 +56,54 @@ public class JwtService { return result; } - public String extractUsername(String token) { - return JWT.decode(token).getSubject(); + public Optional extractUser(String token) { + Map claims = JWT.decode(token).getClaims(); + + UserBuilder userBuilder = anUser() + .withPassword("****"); + + Optional.ofNullable(claims.get("sub")) + .map(Claim::asString) + .map(this::mapUuid) + .ifPresent(userBuilder::withId); + + Optional.ofNullable(claims.get("pseudo")) + .map(Claim::asString) + .ifPresent(userBuilder::withPseudo); + + Optional.ofNullable(claims.get("email")) + .map(Claim::asString) + .ifPresent(userBuilder::withEmail); + + Optional.ofNullable(claims.get("photoId")) + .map(Claim::asString) + .map(this::mapUuid) + .ifPresent(userBuilder::withPhotoId); + + extractRoles(claims) + .stream() + .flatMap(Collection::stream) + .map(UserRole::from) + .flatMap(Optional::stream) + .forEach(userBuilder::withRole); + + return Optional.of(userBuilder.build()); + } + + private static Optional> extractRoles(Map claims) { + return Optional.ofNullable(claims.get("roles")) + .map(Claim::asString) + .map(roles -> roles.split(",")) + .map(Arrays::asList); + } + + private UUID mapUuid(String uuidAsString) { + UUID result; + try { + result = UUID.fromString(uuidAsString); + } catch (IllegalArgumentException exception) { + result = null; + } + return result; } } diff --git a/codiki-application/src/main/java/org/codiki/application/user/UserUseCases.java b/codiki-application/src/main/java/org/codiki/application/user/UserUseCases.java index af4884b..6a4b1de 100644 --- a/codiki-application/src/main/java/org/codiki/application/user/UserUseCases.java +++ b/codiki-application/src/main/java/org/codiki/application/user/UserUseCases.java @@ -60,8 +60,8 @@ public class UserUseCases { return userPort.findAll(); } - public UserAuthenticationData authenticate(UUID userId, String password) { - User user = userPort.findById(userId) + public UserAuthenticationData authenticate(String userEmail, String password) { + User user = userPort.findByEmail(userEmail) .orElseThrow(LoginFailureException::new); if (!passwordEncoder.matches(password, user.password())) { diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/model/User.java b/codiki-domain/src/main/java/org/codiki/domain/user/model/User.java index e2bc3a2..50cc9f9 100644 --- a/codiki-domain/src/main/java/org/codiki/domain/user/model/User.java +++ b/codiki-domain/src/main/java/org/codiki/domain/user/model/User.java @@ -1,6 +1,10 @@ package org.codiki.domain.user.model; +import static java.util.Objects.isNull; +import static java.util.stream.Collectors.joining; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; public record User( @@ -10,4 +14,21 @@ public record User( String password, UUID photoId, List roles -) {} +) { + public Map toJwtPayload() { + Map result = new HashMap<>(4); + + result.put("pseudo", pseudo); + result.put("email", email); + if (!isNull(photoId)) { + result.put("photoId", photoId.toString()); + } + + String rolesAsString = roles.stream() + .map(UserRole::name) + .collect(joining(",")); + result.put("roles", rolesAsString); + + return result; + } +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/model/UserRole.java b/codiki-domain/src/main/java/org/codiki/domain/user/model/UserRole.java index 8b3406d..c8462d0 100644 --- a/codiki-domain/src/main/java/org/codiki/domain/user/model/UserRole.java +++ b/codiki-domain/src/main/java/org/codiki/domain/user/model/UserRole.java @@ -1,6 +1,15 @@ package org.codiki.domain.user.model; +import java.util.Optional; +import java.util.stream.Stream; + public enum UserRole { STANDARD, - ADMIN + ADMIN; + + public static Optional from(String roleAsString) { + return Stream.of(UserRole.values()) + .filter(role -> role.name().equals(roleAsString)) + .findFirst(); + } } diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/model/builder/UserBuilder.java b/codiki-domain/src/main/java/org/codiki/domain/user/model/builder/UserBuilder.java index ac3305a..2ff56ac 100644 --- a/codiki-domain/src/main/java/org/codiki/domain/user/model/builder/UserBuilder.java +++ b/codiki-domain/src/main/java/org/codiki/domain/user/model/builder/UserBuilder.java @@ -1,11 +1,13 @@ package org.codiki.domain.user.model.builder; +import static java.util.Objects.isNull; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Set; import java.util.UUID; +import org.codiki.domain.user.exception.UserCreationException; import org.codiki.domain.user.model.User; import org.codiki.domain.user.model.UserRole; @@ -59,6 +61,14 @@ public class UserBuilder { } public User build() { - return new User(id, pseudo,email, password, photoId, new LinkedList<>(roles)); + if (isNull(id) || isNull(pseudo) || isNull(email) || isNull(password) || isEmpty(roles)) { + throw new UserCreationException(); + } + + return new User(id, pseudo, email, password, photoId, new LinkedList<>(roles)); + } + + private static boolean isEmpty(Set roles) { + return isNull(roles) || roles.isEmpty(); } } diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/port/UserPort.java b/codiki-domain/src/main/java/org/codiki/domain/user/port/UserPort.java index 3068536..595b015 100644 --- a/codiki-domain/src/main/java/org/codiki/domain/user/port/UserPort.java +++ b/codiki-domain/src/main/java/org/codiki/domain/user/port/UserPort.java @@ -10,6 +10,8 @@ import org.codiki.domain.user.model.User; public interface UserPort { Optional findById(UUID userId); + Optional findByEmail(String userEmail); + List findAll(); void save(User user); diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/JwtAuthenticationFilter.java b/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/JwtAuthenticationFilter.java index 8cf5451..6305ed1 100644 --- a/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/JwtAuthenticationFilter.java +++ b/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/JwtAuthenticationFilter.java @@ -1,14 +1,14 @@ package org.codiki.exposition.configuration.security; import java.io.IOException; +import java.util.Optional; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.util.ObjectUtils.isEmpty; import org.codiki.application.security.JwtService; +import org.codiki.application.security.model.CustomUserDetails; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -23,11 +23,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String BEARER_PREFIX = "Bearer "; private final JwtService jwtService; - private final UserDetailsService userDetailsService; - public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) { + public JwtAuthenticationFilter(JwtService jwtService) { this.jwtService = jwtService; - this.userDetailsService = userDetailsService; } @Override @@ -36,25 +34,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { - String authorizationHeader = request.getHeader(AUTHORIZATION); - - if (!isEmpty(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX)) { - String token = authorizationHeader.substring(BEARER_PREFIX.length()); - String username = jwtService.extractUsername(token); - - if (!isEmpty(username) && SecurityContextHolder.getContext().getAuthentication() == null) { - UserDetails userDetails = userDetailsService.loadUserByUsername(username); - if (jwtService.isValid(token) && userDetails.getUsername().equals(username)) { - UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authenticationToken); - } - } - } + Optional.ofNullable(request.getHeader(AUTHORIZATION)) + .filter(authorizationHeader -> !isEmpty(authorizationHeader)) + .filter(authorizationHeader -> authorizationHeader.startsWith(BEARER_PREFIX)) + .map(authorizationHeader -> authorizationHeader.substring(BEARER_PREFIX.length())) + .filter(token -> { + String authorizationHeader = request.getHeader(AUTHORIZATION); + return !isEmpty(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX); + }) + .filter(jwtService::isValid) + .flatMap(jwtService::extractUser) + .map(CustomUserDetails::new) + .map(userDetails -> new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + )) + .ifPresent(authenticationToken -> { + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + }); filterChain.doFilter(request, response); } diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/user/UserController.java b/codiki-exposition/src/main/java/org/codiki/exposition/user/UserController.java index 0a7d4ac..87a7b49 100644 --- a/codiki-exposition/src/main/java/org/codiki/exposition/user/UserController.java +++ b/codiki-exposition/src/main/java/org/codiki/exposition/user/UserController.java @@ -12,7 +12,6 @@ import org.codiki.exposition.user.model.LoginRequest; import org.codiki.exposition.user.model.LoginResponse; import org.codiki.exposition.user.model.RefreshTokenRequest; import org.codiki.exposition.user.model.SignInRequestDto; -import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -32,7 +31,7 @@ public class UserController { @PostMapping("/login") @AllowedToAnonymous public LoginResponse login(@RequestBody LoginRequest request) { - UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.id(), request.password()); + UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.email(), request.password()); return new LoginResponse(userAuthenticationData); } diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginRequest.java b/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginRequest.java index 94fead2..e2a419d 100644 --- a/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginRequest.java +++ b/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginRequest.java @@ -1,8 +1,6 @@ package org.codiki.exposition.user.model; -import java.util.UUID; - public record LoginRequest( - UUID id, + String email, String password ) {} diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/adapter/UserJpaAdapter.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/adapter/UserJpaAdapter.java index bcc0eec..23bb529 100644 --- a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/adapter/UserJpaAdapter.java +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/adapter/UserJpaAdapter.java @@ -32,6 +32,12 @@ public class UserJpaAdapter implements UserPort { .map(UserEntity::toUser); } + @Override + public Optional findByEmail(String userEmail) { + return userJpaRepository.findByEmail(userEmail) + .map(UserEntity::toUser); + } + @Override public List findAll() { return userJpaRepository.findAll() diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/UserJpaRepository.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/UserJpaRepository.java index 29fdc7e..7fac877 100644 --- a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/UserJpaRepository.java +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/UserJpaRepository.java @@ -15,6 +15,9 @@ public interface UserJpaRepository extends JpaRepository { @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.id = :userId") Optional findById(@Param("userId") UUID userId); + @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.email = :email") + Optional findByEmail(@Param("email") String userEmail); + @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles") List findAll(); diff --git a/rest-client-collection/Codiki/Users/Login as standard user.bru b/rest-client-collection/Codiki/Users/Login as standard user.bru index bac68b3..d02a0f7 100644 --- a/rest-client-collection/Codiki/Users/Login as standard user.bru +++ b/rest-client-collection/Codiki/Users/Login as standard user.bru @@ -12,7 +12,7 @@ post { body:json { { - "id": "5ad462b8-8f9e-4a26-bb86-c74fef5d11b6", + "email": "standard.user@codiki.org", "password": "password" } } diff --git a/rest-client-collection/Codiki/environments/localhost.bru b/rest-client-collection/Codiki/environments/localhost.bru index 36381ed..a5f5d79 100644 --- a/rest-client-collection/Codiki/environments/localhost.bru +++ b/rest-client-collection/Codiki/environments/localhost.bru @@ -1,7 +1,7 @@ vars { url: http://localhost:8080 - publicationId: e23831a6-9cc0-4f3d-9efa-7a1cae191cb1 - bearerToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1YWQ0NjJiOC04ZjllLTRhMjYtYmI4Ni1jNzRmZWY1ZDExYjYiLCJleHAiOjE3MTA1MzkzMjd9.qskbb1_AKlY74GmcDHVzFV7wGP7nDPk1St8OlUmTy08ut4SWRvi0WnrD90Y1cBAnsiu2UTjV5v6LXkX5W_pLfg + publicationId: ec76602f-5501-4091-868e-b471611e63de + bearerToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1YWQ0NjJiOC04ZjllLTRhMjYtYmI4Ni1jNzRmZWY1ZDExYjYiLCJleHAiOjE3MTA4Mzc2ODQsInBzZXVkbyI6IlN0YW5kYXJkIHVzZXIiLCJlbWFpbCI6InN0YW5kYXJkLnVzZXJAY29kaWtpLm9yZyIsInJvbGVzIjoiU1RBTkRBUkQifQ.2HggC3T_4I14IpW02DZJiYfgYwc074kU8Y4AmuGf1mZzv0U8OUxpAw_xEhnKtn8NcaCozz_2vFv4o_CaBqS8Ag categoryId: 172fa901-3f4b-4540-92f3-1c15820e8ec9 pictureId: 65b660b7-66bb-4e4a-a62c-fd0ca101f972 }