Move backend files into a sub folder.

This commit is contained in:
Florian THIERRY
2024-03-27 10:28:22 +01:00
parent 39663e914d
commit 431d365d20
131 changed files with 3633 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.codiki</groupId>
<artifactId>codiki-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<artifactId>codiki-application</artifactId>
<name>codiki-application</name>
<description>Demo project for Spring Boot</description>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,7 @@
package org.codiki.application.category;
import org.springframework.stereotype.Component;
@Component
public class CategoryCreationValidator {
}

View File

@@ -0,0 +1,107 @@
package org.codiki.application.category;
import static java.util.Collections.emptyList;
import static java.util.Objects.isNull;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.category.model.builder.CategoryBuilder.aCategory;
import org.codiki.domain.category.exception.CategoryDeletionException;
import org.codiki.domain.category.exception.CategoryEditionException;
import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.category.model.Category;
import org.codiki.domain.category.model.builder.CategoryBuilder;
import org.codiki.domain.category.port.CategoryPort;
import org.springframework.stereotype.Service;
@Service
public class CategoryUseCases {
private final CategoryPort categoryPort;
public CategoryUseCases(CategoryPort categoryPort) {
this.categoryPort = categoryPort;
}
public Optional<Category> findById(UUID categoryId) {
return categoryPort.findById(categoryId);
}
public boolean existsById(UUID categoryId) {
return categoryPort.existsById(categoryId);
}
public Category createCategory(String name, List<UUID> subCategoryIds) {
if (isNull(name)) {
throw new CategoryEditionException("name can not be empty");
}
List<Category> subCategories = emptyList();
if (!isNull(subCategoryIds)) {
try {
subCategories = categoryPort.findAllByIds(subCategoryIds);
} catch (CategoryNotFoundException exception) {
throw new CategoryEditionException(exception);
}
}
Category newCategory = aCategory()
.withId(UUID.randomUUID())
.withName(name)
.withSubCategories(subCategories)
.build();
categoryPort.save(newCategory);
return newCategory;
}
public Category updateCategory(UUID categoryId, String name, List<UUID> subCategoryIds) {
if (isNull(name) && isNull(subCategoryIds)) {
throw new CategoryEditionException("no any field is filled");
}
Category categoryToUpdate = categoryPort.findById(categoryId)
.orElseThrow(() -> new CategoryNotFoundException(categoryId));
CategoryBuilder categoryBuilder = aCategory()
.basedOn(categoryToUpdate);
if (!isNull(name)) {
categoryBuilder.withName(name);
}
if (!isNull(subCategoryIds)) {
List<Category> subCategories = emptyList();
if (!subCategoryIds.isEmpty()) {
try {
subCategories = categoryPort.findAllByIds(subCategoryIds);
} catch (CategoryNotFoundException exception) {
throw new CategoryEditionException(exception);
}
}
categoryBuilder.withSubCategories(subCategories);
}
Category updatedCategory = categoryBuilder.build();
categoryPort.save(updatedCategory);
return updatedCategory;
}
public void deleteCategory(UUID categoryId) {
if (!categoryPort.existsById(categoryId)) {
throw new CategoryNotFoundException(categoryId);
}
if (categoryPort.existsAnyAssociatedPublication(categoryId)) {
throw new CategoryDeletionException(categoryId, "some publications are associated to the category");
}
categoryPort.deleteById(categoryId);
}
public List<Category> getAll() {
return categoryPort.findAll();
}
}

View File

@@ -0,0 +1,21 @@
package org.codiki.application.configuration;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class ServiceConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
}

View File

@@ -0,0 +1,51 @@
package org.codiki.application.picture;
import java.io.File;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.picture.model.builder.PictureBuilder.aPicture;
import org.codiki.application.user.UserUseCases;
import org.codiki.domain.exception.AuthenticationRequiredException;
import org.codiki.domain.picture.model.Picture;
import org.codiki.domain.picture.port.PicturePort;
import org.codiki.domain.user.model.User;
import org.springframework.stereotype.Service;
@Service
public class PictureUseCases {
private final PicturePort picturePort;
private final UserUseCases userUseCases;
public PictureUseCases(PicturePort picturePort, UserUseCases userUseCases) {
this.picturePort = picturePort;
this.userUseCases = userUseCases;
}
public Picture createPicture(File pictureFile) {
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
Picture newPicture = aPicture()
.withId(UUID.randomUUID())
.withPublisher(authenticatedUser)
.withContentFile(pictureFile)
.build();
picturePort.save(newPicture);
return newPicture;
}
public void deletePicture(UUID pictureId) {
picturePort.deleteById(pictureId);
}
public Optional<Picture> findById(UUID pictureId) {
return picturePort.findById(pictureId);
}
public boolean existsById(UUID pictureId) {
return picturePort.existsById(pictureId);
}
}

View File

@@ -0,0 +1,23 @@
package org.codiki.application.publication;
import java.security.SecureRandom;
import org.springframework.stereotype.Component;
@Component
public class KeyGenerator {
private static final String ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final int KEY_LENGTH = 14;
public String generateKey() {
SecureRandom random = new SecureRandom();
StringBuilder code = new StringBuilder();
for (int i = 0; i < KEY_LENGTH; i++) {
int randomIndex = random.nextInt(ALLOWED_CHARACTERS.length());
code.append(ALLOWED_CHARACTERS.charAt(randomIndex));
}
return code.toString();
}
}

View File

@@ -0,0 +1,26 @@
package org.codiki.application.publication;
import org.codiki.domain.publication.exception.PublicationEditionException;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.springframework.stereotype.Component;
@Component
public class PublicationCreationRequestValidator {
public void isValid(PublicationEditionRequest request) {
if (request.title() == null) {
throw new PublicationEditionException("title cannot be null");
}
if (request.text() == null) {
throw new PublicationEditionException("text cannot be null");
}
if (request.description() == null) {
throw new PublicationEditionException("description cannot be null");
}
if (request.illustrationId() == null) {
throw new PublicationEditionException("illustrationId cannot be null");
}
}
}

View File

@@ -0,0 +1,132 @@
package org.codiki.application.publication;
import static java.util.stream.Collectors.toSet;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.ComparisonType.EQUALS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_PSEUDO;
import static org.codiki.domain.publication.model.search.PublicationSearchField.CATEGORY_ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION;
import static org.codiki.domain.publication.model.search.PublicationSearchField.ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.KEY;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.model.search.PublicationSearchField;
import org.springframework.stereotype.Component;
@Component
public class PublicationSearchCriteriaFactory {
private static final Pattern ACCENT_LETTER_REGEX = Pattern.compile("[à-ü]|[À-Ü]");
private static final List<PublicationSearchField> ID_SEARCH_FIELDS = List.of(ID, CATEGORY_ID, AUTHOR_ID);
public List<PublicationSearchCriterion> buildCriteria(String searchQuery) {
Set<String> stringCriteria = Set.of(searchQuery.split(" "));
return stringCriteria.stream()
.map(this::buildPublicationSearchCriterion)
.flatMap(List::stream)
.toList();
}
private List<PublicationSearchCriterion> buildPublicationSearchCriterion(String criterion) {
List<PublicationSearchCriterion> result;
if (criterion.contains("=")) {
String[] criterionParts = criterion.split("=");
if (criterionParts.length > 2) {
result = buildDefaultContainsCriteria(criterion);
} else {
String criterionSearchFieldAsString = criterionParts[0];
String criterionValue = criterionParts[1];
result = PublicationSearchField.from(criterionSearchFieldAsString)
.map(searchField -> {
List<PublicationSearchCriterion> criteria;
if (ID_SEARCH_FIELDS.contains(searchField)) {
criteria = convertToUuid(criterionValue)
.map(uuidCriterion -> new PublicationSearchCriterion(searchField, EQUALS, uuidCriterion))
.map(List::of)
.orElse(buildDefaultContainsCriteria(criterion));
} else {
criteria = List.of(new PublicationSearchCriterion(searchField, EQUALS, criterionValue));
}
return criteria;
})
.orElse(buildDefaultContainsCriteria(criterion));
}
} else {
result = buildDefaultContainsCriteria(criterion);
}
return result;
}
private Optional<UUID> convertToUuid(String uuidValue) {
Optional<UUID> result;
try {
result = Optional.of(UUID.fromString(uuidValue));
} catch (IllegalArgumentException exception) {
result = Optional.empty();
}
return result;
}
private List<PublicationSearchCriterion> buildDefaultContainsCriteria(String criterion) {
return List.of(
new PublicationSearchCriterion(KEY, CONTAINS, criterion),
new PublicationSearchCriterion(TITLE, CONTAINS, criterion),
new PublicationSearchCriterion(TEXT, CONTAINS, criterion),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, criterion),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, criterion)
);
}
Set<String> splitAndSanitizeSearchCriterion(String searchQuery) {
Set<String> result = new HashSet<>();
for (String fragment : searchQuery.split(" ")) {
Set<String> subFragmentsFromAccentedCharactersSplitting = splitSubFragmentByAccentedCharacters(fragment);
if (isEmpty(subFragmentsFromAccentedCharactersSplitting)) {
result.add(fragment);
} else {
result.addAll(subFragmentsFromAccentedCharactersSplitting);
}
}
return result;
}
private Set<String> splitSubFragmentByAccentedCharacters(String fragment) {
Set<String> result = new HashSet<>();
Matcher accentsMatcher = ACCENT_LETTER_REGEX.matcher(fragment);
Set<String> accentedCharacters = new HashSet<>();
while (accentsMatcher.find()) {
accentedCharacters.add(accentsMatcher.group());
}
if (!isEmpty(accentedCharacters)) {
String joinedAccentedCharacters = String.join("", accentedCharacters);
String[] subFragments = fragment.split(String.format("[%s]", joinedAccentedCharacters));
result = Stream.of(subFragments)
.filter(subFragment -> subFragment.length() > 1)
.collect(toSet());
}
return result;
}
}

View File

@@ -0,0 +1,22 @@
package org.codiki.application.publication;
import static java.util.Objects.isNull;
import org.codiki.domain.publication.exception.PublicationEditionException;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.springframework.stereotype.Component;
@Component
public class PublicationUpdateRequestValidator {
public void isValid(PublicationEditionRequest request) {
if (
isNull(request.title()) &&
isNull(request.text()) &&
isNull(request.description()) &&
isNull(request.illustrationId()) &&
isNull(request.categoryId())
) {
throw new PublicationEditionException("no any field is filled");
}
}
}

View File

@@ -0,0 +1,174 @@
package org.codiki.application.publication;
import static java.util.Objects.isNull;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor;
import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication;
import org.codiki.application.category.CategoryUseCases;
import org.codiki.application.picture.PictureUseCases;
import org.codiki.application.user.UserUseCases;
import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.exception.AuthenticationRequiredException;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.publication.exception.PublicationEditionException;
import org.codiki.domain.publication.exception.PublicationNotFoundException;
import org.codiki.domain.publication.exception.PublicationUpdateForbiddenException;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.codiki.domain.publication.model.builder.PublicationBuilder;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.port.PublicationPort;
import org.codiki.domain.user.model.User;
import org.springframework.stereotype.Service;
@Service
public class PublicationUseCases {
private final CategoryUseCases categoryUseCases;
private final Clock clock;
private final KeyGenerator keyGenerator;
private final PictureUseCases pictureUseCases;
private final PublicationCreationRequestValidator publicationCreationRequestValidator;
private final PublicationPort publicationPort;
private final PublicationSearchCriteriaFactory publicationSearchCriteriaFactory;
private final PublicationUpdateRequestValidator publicationUpdateRequestValidator;
private final UserUseCases userUseCases;
public PublicationUseCases(
CategoryUseCases categoryUseCases,
Clock clock,
KeyGenerator keyGenerator,
PictureUseCases pictureUseCases,
PublicationCreationRequestValidator publicationCreationRequestValidator,
PublicationPort publicationPort,
PublicationSearchCriteriaFactory publicationSearchCriteriaFactory,
PublicationUpdateRequestValidator publicationUpdateRequestValidator,
UserUseCases userUseCases
) {
this.categoryUseCases = categoryUseCases;
this.clock = clock;
this.keyGenerator = keyGenerator;
this.publicationCreationRequestValidator = publicationCreationRequestValidator;
this.publicationPort = publicationPort;
this.publicationUpdateRequestValidator = publicationUpdateRequestValidator;
this.userUseCases = userUseCases;
this.pictureUseCases = pictureUseCases;
this.publicationSearchCriteriaFactory = publicationSearchCriteriaFactory;
}
public Publication createPublication(PublicationEditionRequest request) {
publicationCreationRequestValidator.isValid(request);
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
if (!categoryUseCases.existsById(request.categoryId())) {
throw new PublicationEditionException(
new CategoryNotFoundException(request.categoryId())
);
}
if (!pictureUseCases.existsById(request.illustrationId())) {
throw new PublicationEditionException(
new PictureNotFoundException(request.illustrationId())
);
}
Publication newPublication = aPublication()
.withId(UUID.randomUUID())
.withKey(keyGenerator.generateKey())
.withTitle(request.title())
.withText(request.text())
.withDescription(request.description())
.withCreationDate(ZonedDateTime.now(clock))
.withIllustrationId(request.illustrationId())
.withCategoryId(request.categoryId())
.withAuthor(anAuthor().basedOn(authenticatedUser).build())
.build();
publicationPort.save(newPublication);
return newPublication;
}
public Publication updatePublication(UUID publicationId, PublicationEditionRequest request) {
publicationUpdateRequestValidator.isValid(request);
Publication publicationToUpdate = publicationPort.findById(publicationId)
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
if (!publicationToUpdate.author().id().equals(authenticatedUser.id())) {
throw new PublicationUpdateForbiddenException();
}
PublicationBuilder publicationBuilder = aPublication().basedOn(publicationToUpdate);
if (!isNull(request.title())) {
publicationBuilder.withTitle(request.title());
}
if (!isNull(request.text())) {
publicationBuilder.withText(request.text());
}
if (!isNull(request.description())) {
publicationBuilder.withDescription(request.description());
}
if (!isNull(request.illustrationId())) {
if (!pictureUseCases.existsById(request.illustrationId())) {
throw new PublicationEditionException(
new PictureNotFoundException(request.illustrationId())
);
}
publicationBuilder.withIllustrationId(request.illustrationId());
}
if (!isNull(request.categoryId())) {
if (!categoryUseCases.existsById(request.categoryId())) {
throw new PublicationEditionException(
new CategoryNotFoundException(request.categoryId())
);
}
publicationBuilder.withCategoryId(request.categoryId());
}
Publication updatedPublication = publicationBuilder.build();
publicationPort.save(updatedPublication);
return updatedPublication;
}
public void deletePublication(UUID publicationId) {
Publication publicationToDelete = publicationPort.findById(publicationId)
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
if (!publicationToDelete.author().id().equals(authenticatedUser.id())) {
throw new PublicationUpdateForbiddenException();
}
publicationPort.delete(publicationToDelete);
}
public Optional<Publication> findById(UUID publicationId) {
return publicationPort.findById(publicationId);
}
public List<Publication> searchPublications(String searchQuery) {
List<PublicationSearchCriterion> criteria = publicationSearchCriteriaFactory.buildCriteria(searchQuery);
return publicationPort.search(criteria);
}
}

View File

@@ -0,0 +1,13 @@
package org.codiki.application.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
// This class allow to retrieve connected user information through "Authentication" object.
@Component
public class AuthenticationFacade {
public Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
}

View File

@@ -0,0 +1,35 @@
package org.codiki.application.security;
import java.util.UUID;
import org.codiki.application.user.UserUseCases;
import org.codiki.application.security.model.CustomUserDetails;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserUseCases userUseCases;
public CustomUserDetailsService(UserUseCases userUseCases) {
this.userUseCases = userUseCases;
}
@Override
public UserDetails loadUserByUsername(String userIdAsString) throws UsernameNotFoundException {
UUID userId = parseUserId(userIdAsString);
return userUseCases.findById(userId)
.map(CustomUserDetails::new)
.orElseThrow(() -> new UsernameNotFoundException(userIdAsString));
}
private UUID parseUserId(String userIdAsString) {
try {
return UUID.fromString(userIdAsString);
} catch (IllegalArgumentException exception) {
throw new UsernameNotFoundException(userIdAsString);
}
}
}

View File

@@ -0,0 +1,109 @@
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 {
private final Algorithm algorithm;
private final JWTVerifier jwtVerifier;
private final int tokenExpirationDelayInMinutes;
public JwtService(
@Value("${application.security.jwt.secretKey}") String secretKey,
@Value("${application.security.jwt.expirationDelayInMinutes}") int tokenExpirationDelayInMinutes
) {
algorithm = Algorithm.HMAC512(secretKey);
this.tokenExpirationDelayInMinutes = tokenExpirationDelayInMinutes;
jwtVerifier = JWT.require(algorithm).build();
}
public String createJwt(User user) {
ZonedDateTime expirationDate = ZonedDateTime.now().plusMinutes(tokenExpirationDelayInMinutes);
return JWT.create()
.withSubject(user.id().toString())
.withExpiresAt(expirationDate.toInstant())
.withPayload(user.toJwtPayload())
.sign(algorithm);
}
public boolean isValid(String token) {
boolean result;
try {
jwtVerifier.verify(token);
result = true;
} catch (JWTVerificationException exception) {
result = false;
}
return result;
}
public Optional<User> extractUser(String token) {
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

@@ -0,0 +1,14 @@
package org.codiki.application.security.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.security.access.prepost.PreAuthorize;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAuthority('ROLE_' + T(org.codiki.domain.user.model.UserRole).ADMIN.name())")
public @interface AllowedToAdmins {
}

View File

@@ -0,0 +1,14 @@
package org.codiki.application.security.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.security.access.prepost.PreAuthorize;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("permitAll()")
public @interface AllowedToAnonymous {
}

View File

@@ -0,0 +1,57 @@
package org.codiki.application.security.model;
import java.util.Collection;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserRole;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.roles()
.stream()
.map(UserRole::name)
.map(role -> "ROLE_" + role)
.map(SimpleGrantedAuthority::new)
.toList();
}
@Override
public String getUsername() {
return user.id().toString();
}
@Override
public String getPassword() {
return user.password();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

View File

@@ -0,0 +1,137 @@
package org.codiki.application.user;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.user.model.UserRole.STANDARD;
import static org.codiki.domain.user.model.builder.UserBuilder.anUser;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.security.AuthenticationFacade;
import org.codiki.application.security.JwtService;
import org.codiki.application.security.annotation.AllowedToAdmins;
import org.codiki.application.security.model.CustomUserDetails;
import org.codiki.domain.exception.LoginFailureException;
import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
import org.codiki.domain.exception.UserDoesNotExistException;
import org.codiki.domain.user.exception.UserAlreadyExistsException;
import org.codiki.domain.user.exception.UserCreationException;
import org.codiki.domain.user.model.RefreshToken;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserAuthenticationData;
import org.codiki.domain.user.port.UserPort;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserUseCases {
private static final String TOKEN_TYPE = "Bearer";
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final UserPort userPort;
private final AuthenticationFacade authenticationFacade;
private final int refreshTokenExpirationDelayInDays;
public UserUseCases(
AuthenticationFacade authenticationFacade,
JwtService jwtService,
PasswordEncoder passwordEncoder,
UserPort userPort,
@Value("${application.security.refreshToken.expirationDelayInDays}")
int refreshTokenExpirationDelayInDays
) {
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.userPort = userPort;
this.authenticationFacade = authenticationFacade;
this.refreshTokenExpirationDelayInDays = refreshTokenExpirationDelayInDays;
}
public Optional<User> findById(UUID userId) {
return userPort.findById(userId);
}
@AllowedToAdmins
public List<User> findAll() {
return userPort.findAll();
}
public UserAuthenticationData authenticate(String userEmail, String password) {
User user = userPort.findByEmail(userEmail)
.orElseThrow(LoginFailureException::new);
if (!passwordEncoder.matches(password, user.password())) {
throw new LoginFailureException();
}
return generateAuthenticationData(user);
}
public UserAuthenticationData authenticate(UUID refreshTokenValue) {
RefreshToken refreshToken = userPort.findRefreshTokenById(refreshTokenValue)
.filter(RefreshToken::isNotExpired)
.orElseThrow(() -> new RefreshTokenDoesNotExistException(refreshTokenValue));
User user = userPort.findById(refreshToken.userId())
.orElseThrow(() -> new UserDoesNotExistException(refreshToken.userId()));
return generateAuthenticationData(user);
}
public Optional<User> getAuthenticatedUser() {
return Optional.of(authenticationFacade.getAuthentication())
.map(Authentication::getPrincipal)
.filter(CustomUserDetails.class::isInstance)
.map(CustomUserDetails.class::cast)
.map(CustomUserDetails::getUsername)
.map(UUID::fromString)
.flatMap(userPort::findById);
}
private UserAuthenticationData generateAuthenticationData(User user) {
String accessToken = jwtService.createJwt(user);
RefreshToken newRefreshToken = createNewRefreshToken(user);
return new UserAuthenticationData(
TOKEN_TYPE,
accessToken,
newRefreshToken
);
}
private RefreshToken createNewRefreshToken(User user) {
RefreshToken refreshToken = new RefreshToken(
user.id(),
ZonedDateTime.now().plusDays(refreshTokenExpirationDelayInDays)
);
userPort.save(refreshToken);
return refreshToken;
}
public User createUser(String pseudo, String email, String password) {
if (isEmpty(pseudo) || isEmpty(email) || isEmpty(password)) {
throw new UserCreationException();
}
if (userPort.existsByEmail(email)) {
throw new UserAlreadyExistsException();
}
User newUser = anUser()
.withId(UUID.randomUUID())
.withPseudo(pseudo)
.withEmail(email)
.withPassword(passwordEncoder.encode(password))
.withRole(STANDARD)
.build();
userPort.save(newUser);
return newUser;
}
}

View File

@@ -0,0 +1,31 @@
package org.codiki.application.publication;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class KeyGeneratorTest {
private KeyGenerator generator;
@BeforeEach
void setUp() {
generator = new KeyGenerator();
}
@Test
public void generateKey_should_generate_random_keys_with_alphanumeric_characters() {
Pattern validationRegex = Pattern.compile("^[0-9A-Z]{10}$");
IntStream.range(0, 1000)
.forEach(index -> {
String result = generator.generateKey();
assertThat(validationRegex.matcher(result).matches()).isTrue();
});
}
}

View File

@@ -0,0 +1,137 @@
package org.codiki.application.publication;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.ComparisonType.EQUALS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.*;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class PublicationSearchCriteriaFactoryTest {
private PublicationSearchCriteriaFactory factory;
@BeforeEach
void setUp() {
factory = new PublicationSearchCriteriaFactory();
}
@Nested
public class BuildCriteria {
@ParameterizedTest
@MethodSource("arguments_of_should_build_criteria_from_search_query")
void should_build_criteria_from_search_query(String searchQuery, List<PublicationSearchCriterion> expectedResult) {
// when
List<PublicationSearchCriterion> result = factory.buildCriteria(searchQuery);
// then
assertThat(result).isEqualTo(expectedResult);
}
private static Stream<Arguments> arguments_of_should_build_criteria_from_search_query() {
return Stream.of(
Arguments.of(
"criterion",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "criterion"),
new PublicationSearchCriterion(TITLE, CONTAINS, "criterion"),
new PublicationSearchCriterion(TEXT, CONTAINS, "criterion"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "criterion"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "criterion")
)
),
Arguments.of(
"key=value=crap",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(TITLE, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(TEXT, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "key=value=crap")
)
),
Arguments.of(
"key=abcd",
List.of(new PublicationSearchCriterion(KEY, EQUALS, "abcd"))
),
Arguments.of(
"crappyFieldName=abcd",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(TITLE, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(TEXT, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "crappyFieldName=abcd")
)
),
Arguments.of(
"id=abcd",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(TITLE, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(TEXT, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "id=abcd")
)
),
Arguments.of(
"id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
),
Arguments.of(
"category_id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(CATEGORY_ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
),
Arguments.of(
"author_id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(AUTHOR_ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
)
);
}
}
@Nested
public class SplitAndSanitizeSearchCriterion {
@Test
void should_split_criteria_and_remove_duplicates() {
// given
String searchQuery = "criterion1 criterion2 criterion1";
// when
Set<String> result = factory.splitAndSanitizeSearchCriterion(searchQuery);
// then
assertThat(result).containsExactlyInAnyOrder("criterion1", "criterion2");
}
@ParameterizedTest
@MethodSource("arguments_of_should_remove_accents_and_split_criteria")
void should_remove_accents_and_split_criteria(String searchQuery, Set<String> expectedResult) {
// when
Set<String> result = factory.splitAndSanitizeSearchCriterion(searchQuery);
// then
assertThat(result).containsExactlyInAnyOrderElementsOf(expectedResult);
}
private static Stream<Arguments> arguments_of_should_remove_accents_and_split_criteria() {
return Stream.of(
Arguments.of("critère", Set.of("crit", "re")),
Arguments.of("recherchés", Set.of("recherch")),
Arguments.of("abcdéfghîjklmnöp", Set.of("abcd", "fgh", "jklmn")),
Arguments.of("ædf", Set.of("df"))
);
}
}
}