Convert login by id into login by email.

This commit is contained in:
Florian THIERRY
2024-03-19 09:31:21 +01:00
parent 8d778e3571
commit 30e5ffa2eb
13 changed files with 144 additions and 39 deletions

View File

@@ -1,14 +1,24 @@
package org.codiki.application.security; package org.codiki.application.security;
import java.time.ZonedDateTime; 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.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.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.auth0.jwt.JWT; import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
@Service @Service
public class JwtService { public class JwtService {
@@ -31,6 +41,7 @@ public class JwtService {
return JWT.create() return JWT.create()
.withSubject(user.id().toString()) .withSubject(user.id().toString())
.withExpiresAt(expirationDate.toInstant()) .withExpiresAt(expirationDate.toInstant())
.withPayload(user.toJwtPayload())
.sign(algorithm); .sign(algorithm);
} }
@@ -45,7 +56,54 @@ public class JwtService {
return result; return result;
} }
public String extractUsername(String token) { public Optional<User> extractUser(String token) {
return JWT.decode(token).getSubject(); Map<String, Claim> 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<List<String>> extractRoles(Map<String, Claim> 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;
} }
} }

View File

@@ -60,8 +60,8 @@ public class UserUseCases {
return userPort.findAll(); return userPort.findAll();
} }
public UserAuthenticationData authenticate(UUID userId, String password) { public UserAuthenticationData authenticate(String userEmail, String password) {
User user = userPort.findById(userId) User user = userPort.findByEmail(userEmail)
.orElseThrow(LoginFailureException::new); .orElseThrow(LoginFailureException::new);
if (!passwordEncoder.matches(password, user.password())) { if (!passwordEncoder.matches(password, user.password())) {

View File

@@ -1,6 +1,10 @@
package org.codiki.domain.user.model; 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.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
public record User( public record User(
@@ -10,4 +14,21 @@ public record User(
String password, String password,
UUID photoId, UUID photoId,
List<UserRole> roles List<UserRole> roles
) {} ) {
public Map<String, Object> toJwtPayload() {
Map<String, Object> 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;
}
}

View File

@@ -1,6 +1,15 @@
package org.codiki.domain.user.model; package org.codiki.domain.user.model;
import java.util.Optional;
import java.util.stream.Stream;
public enum UserRole { public enum UserRole {
STANDARD, STANDARD,
ADMIN ADMIN;
public static Optional<UserRole> from(String roleAsString) {
return Stream.of(UserRole.values())
.filter(role -> role.name().equals(roleAsString))
.findFirst();
}
} }

View File

@@ -1,11 +1,13 @@
package org.codiki.domain.user.model.builder; package org.codiki.domain.user.model.builder;
import static java.util.Objects.isNull;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.user.exception.UserCreationException;
import org.codiki.domain.user.model.User; import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserRole; import org.codiki.domain.user.model.UserRole;
@@ -59,6 +61,14 @@ public class UserBuilder {
} }
public User build() { 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<UserRole> roles) {
return isNull(roles) || roles.isEmpty();
} }
} }

View File

@@ -10,6 +10,8 @@ import org.codiki.domain.user.model.User;
public interface UserPort { public interface UserPort {
Optional<User> findById(UUID userId); Optional<User> findById(UUID userId);
Optional<User> findByEmail(String userEmail);
List<User> findAll(); List<User> findAll();
void save(User user); void save(User user);

View File

@@ -1,14 +1,14 @@
package org.codiki.exposition.configuration.security; package org.codiki.exposition.configuration.security;
import java.io.IOException; import java.io.IOException;
import java.util.Optional;
import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.util.ObjectUtils.isEmpty; import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.security.JwtService; import org.codiki.application.security.JwtService;
import org.codiki.application.security.model.CustomUserDetails;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; 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.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@@ -23,11 +23,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
private final JwtService jwtService; private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) { public JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService; this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
} }
@Override @Override
@@ -36,25 +34,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
HttpServletResponse response, HttpServletResponse response,
FilterChain filterChain FilterChain filterChain
) throws ServletException, IOException { ) throws ServletException, IOException {
String authorizationHeader = request.getHeader(AUTHORIZATION); Optional.ofNullable(request.getHeader(AUTHORIZATION))
.filter(authorizationHeader -> !isEmpty(authorizationHeader))
if (!isEmpty(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX)) { .filter(authorizationHeader -> authorizationHeader.startsWith(BEARER_PREFIX))
String token = authorizationHeader.substring(BEARER_PREFIX.length()); .map(authorizationHeader -> authorizationHeader.substring(BEARER_PREFIX.length()))
String username = jwtService.extractUsername(token); .filter(token -> {
String authorizationHeader = request.getHeader(AUTHORIZATION);
if (!isEmpty(username) && SecurityContextHolder.getContext().getAuthentication() == null) { return !isEmpty(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX);
UserDetails userDetails = userDetailsService.loadUserByUsername(username); })
if (jwtService.isValid(token) && userDetails.getUsername().equals(username)) { .filter(jwtService::isValid)
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( .flatMap(jwtService::extractUser)
userDetails, .map(CustomUserDetails::new)
null, .map(userDetails -> new UsernamePasswordAuthenticationToken(
userDetails.getAuthorities() userDetails,
); null,
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); userDetails.getAuthorities()
SecurityContextHolder.getContext().setAuthentication(authenticationToken); ))
} .ifPresent(authenticationToken -> {
} authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
} SecurityContextHolder.getContext().setAuthentication(authenticationToken);
});
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }

View File

@@ -12,7 +12,6 @@ import org.codiki.exposition.user.model.LoginRequest;
import org.codiki.exposition.user.model.LoginResponse; import org.codiki.exposition.user.model.LoginResponse;
import org.codiki.exposition.user.model.RefreshTokenRequest; import org.codiki.exposition.user.model.RefreshTokenRequest;
import org.codiki.exposition.user.model.SignInRequestDto; 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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -32,7 +31,7 @@ public class UserController {
@PostMapping("/login") @PostMapping("/login")
@AllowedToAnonymous @AllowedToAnonymous
public LoginResponse login(@RequestBody LoginRequest request) { 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); return new LoginResponse(userAuthenticationData);
} }

View File

@@ -1,8 +1,6 @@
package org.codiki.exposition.user.model; package org.codiki.exposition.user.model;
import java.util.UUID;
public record LoginRequest( public record LoginRequest(
UUID id, String email,
String password String password
) {} ) {}

View File

@@ -32,6 +32,12 @@ public class UserJpaAdapter implements UserPort {
.map(UserEntity::toUser); .map(UserEntity::toUser);
} }
@Override
public Optional<User> findByEmail(String userEmail) {
return userJpaRepository.findByEmail(userEmail)
.map(UserEntity::toUser);
}
@Override @Override
public List<User> findAll() { public List<User> findAll() {
return userJpaRepository.findAll() return userJpaRepository.findAll()

View File

@@ -15,6 +15,9 @@ public interface UserJpaRepository extends JpaRepository<UserEntity, UUID> {
@Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.id = :userId") @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.id = :userId")
Optional<UserEntity> findById(@Param("userId") UUID userId); Optional<UserEntity> findById(@Param("userId") UUID userId);
@Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.email = :email")
Optional<UserEntity> findByEmail(@Param("email") String userEmail);
@Query("SELECT u FROM UserEntity u JOIN FETCH u.roles") @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles")
List<UserEntity> findAll(); List<UserEntity> findAll();

View File

@@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"id": "5ad462b8-8f9e-4a26-bb86-c74fef5d11b6", "email": "standard.user@codiki.org",
"password": "password" "password": "password"
} }
} }

View File

@@ -1,7 +1,7 @@
vars { vars {
url: http://localhost:8080 url: http://localhost:8080
publicationId: e23831a6-9cc0-4f3d-9efa-7a1cae191cb1 publicationId: ec76602f-5501-4091-868e-b471611e63de
bearerToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1YWQ0NjJiOC04ZjllLTRhMjYtYmI4Ni1jNzRmZWY1ZDExYjYiLCJleHAiOjE3MTA1MzkzMjd9.qskbb1_AKlY74GmcDHVzFV7wGP7nDPk1St8OlUmTy08ut4SWRvi0WnrD90Y1cBAnsiu2UTjV5v6LXkX5W_pLfg bearerToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1YWQ0NjJiOC04ZjllLTRhMjYtYmI4Ni1jNzRmZWY1ZDExYjYiLCJleHAiOjE3MTA4Mzc2ODQsInBzZXVkbyI6IlN0YW5kYXJkIHVzZXIiLCJlbWFpbCI6InN0YW5kYXJkLnVzZXJAY29kaWtpLm9yZyIsInJvbGVzIjoiU1RBTkRBUkQifQ.2HggC3T_4I14IpW02DZJiYfgYwc074kU8Y4AmuGf1mZzv0U8OUxpAw_xEhnKtn8NcaCozz_2vFv4o_CaBqS8Ag
categoryId: 172fa901-3f4b-4540-92f3-1c15820e8ec9 categoryId: 172fa901-3f4b-4540-92f3-1c15820e8ec9
pictureId: 65b660b7-66bb-4e4a-a62c-fd0ca101f972 pictureId: 65b660b7-66bb-4e4a-a62c-fd0ca101f972
} }