feat: initialisation du projet Olhar-API en Clean Architecture

- Spring Boot 3.4.1 + Gradle Kotlin DSL, Java 21
- Clean Architecture (domain / application / infrastructure / interfaces)
- Spring Security stateless avec JWT (JJWT 0.12.6)
- Flyway + PostgreSQL (migration V1 table users)
- SpringDoc OpenAPI / Swagger UI avec auth Bearer
- Testcontainers pour les tests d'intégration
- Use cases Register et Authenticate (endpoints POST /api/v1/auth/register et /login)
- GlobalExceptionHandler avec ProblemDetail (RFC 9457)
- docker-compose.yml pour Postgres local

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 05:56:46 +02:00
commit c230a999ab
36 changed files with 1131 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Build
build/
.gradle/
out/
# IDE
.idea/
*.iml
.vscode/
# Spring Boot
*.jar
*.war
# Env
.env
*.env.local
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
+69
View File
@@ -0,0 +1,69 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
java
id("org.springframework.boot") version "3.4.1"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.olhar"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
val testcontainersVersion = "1.20.4"
dependencies {
// Spring Boot starters
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
// Database
runtimeOnly("org.postgresql:postgresql")
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-database-postgresql")
// OpenAPI / Swagger
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
// JWT
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
// Utils
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testCompileOnly("org.projectlombok:lombok")
testAnnotationProcessor("org.projectlombok:lombok")
}
dependencyManagement {
imports {
mavenBom("org.testcontainers:testcontainers-bom:${testcontainersVersion}")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.named<BootJar>("bootJar") {
archiveFileName.set("olhar-api.jar")
}
+19
View File
@@ -0,0 +1,19 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: olhar
POSTGRES_USER: olhar
POSTGRES_PASSWORD: olhar
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U olhar"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
org.gradle.daemon=true
org.gradle.parallel=true
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+1
View File
@@ -0,0 +1 @@
rootProject.name = "olhar-api"
@@ -0,0 +1,12 @@
package com.olhar.olharapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OlharApiApplication {
public static void main(String[] args) {
SpringApplication.run(OlharApiApplication.class, args);
}
}
@@ -0,0 +1,7 @@
package com.olhar.olharapi.application.port.in;
public interface AuthenticateUserUseCase {
String authenticate(Command command);
record Command(String email, String password) {}
}
@@ -0,0 +1,9 @@
package com.olhar.olharapi.application.port.in;
import com.olhar.olharapi.domain.model.User;
public interface RegisterUserUseCase {
User register(Command command);
record Command(String email, String password) {}
}
@@ -0,0 +1,32 @@
package com.olhar.olharapi.application.usecase;
import com.olhar.olharapi.application.port.in.AuthenticateUserUseCase;
import com.olhar.olharapi.application.port.out.UserRepository;
import com.olhar.olharapi.domain.exception.UserNotFoundException;
import com.olhar.olharapi.domain.model.User;
import com.olhar.olharapi.infrastructure.security.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthenticateUserService implements AuthenticateUserUseCase {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
@Override
public String authenticate(Command command) {
User user = userRepository.findByEmail(command.email())
.orElseThrow(() -> new UserNotFoundException(command.email()));
if (!passwordEncoder.matches(command.password(), user.passwordHash())) {
throw new BadCredentialsException("Invalid credentials");
}
return jwtService.generateToken(user);
}
}
@@ -0,0 +1,39 @@
package com.olhar.olharapi.application.usecase;
import com.olhar.olharapi.application.port.in.RegisterUserUseCase;
import com.olhar.olharapi.application.port.out.UserRepository;
import com.olhar.olharapi.domain.exception.EmailAlreadyUsedException;
import com.olhar.olharapi.domain.model.User;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class RegisterUserService implements RegisterUserUseCase {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public User register(Command command) {
if (userRepository.existsByEmail(command.email())) {
throw new EmailAlreadyUsedException(command.email());
}
User user = new User(
UUID.randomUUID(),
command.email(),
passwordEncoder.encode(command.password()),
User.Role.USER,
Instant.now()
);
return userRepository.save(user);
}
}
@@ -0,0 +1,7 @@
package com.olhar.olharapi.domain.exception;
public class EmailAlreadyUsedException extends RuntimeException {
public EmailAlreadyUsedException(String email) {
super("Email already in use: " + email);
}
}
@@ -0,0 +1,7 @@
package com.olhar.olharapi.domain.exception;
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String email) {
super("User not found: " + email);
}
}
@@ -0,0 +1,16 @@
package com.olhar.olharapi.domain.model;
import java.time.Instant;
import java.util.UUID;
public record User(
UUID id,
String email,
String passwordHash,
Role role,
Instant createdAt
) {
public enum Role {
USER, ADMIN
}
}
@@ -0,0 +1,31 @@
package com.olhar.olharapi.infrastructure.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
private static final String BEARER_SCHEME = "bearerAuth";
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Olhar API")
.description("API REST — Olhar")
.version("v1"))
.addSecurityItem(new SecurityRequirement().addList(BEARER_SCHEME))
.components(new Components()
.addSecuritySchemes(BEARER_SCHEME, new SecurityScheme()
.name(BEARER_SCHEME)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
@@ -0,0 +1,48 @@
package com.olhar.olharapi.infrastructure.config;
import com.olhar.olharapi.infrastructure.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/v1/auth/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/api-docs/**",
"/v3/api-docs/**"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,37 @@
package com.olhar.olharapi.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEntity {
@Id
private UUID id;
@Column(nullable = false, unique = true)
private String email;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private RoleEnum role;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
public enum RoleEnum {
USER, ADMIN
}
}
@@ -0,0 +1,29 @@
package com.olhar.olharapi.infrastructure.persistence.mapper;
import com.olhar.olharapi.domain.model.User;
import com.olhar.olharapi.infrastructure.persistence.entity.UserEntity;
import org.springframework.stereotype.Component;
@Component
public class UserPersistenceMapper {
public User toDomain(UserEntity entity) {
return new User(
entity.getId(),
entity.getEmail(),
entity.getPasswordHash(),
User.Role.valueOf(entity.getRole().name()),
entity.getCreatedAt()
);
}
public UserEntity toEntity(User user) {
return UserEntity.builder()
.id(user.id())
.email(user.email())
.passwordHash(user.passwordHash())
.role(UserEntity.RoleEnum.valueOf(user.role().name()))
.createdAt(user.createdAt())
.build();
}
}
@@ -0,0 +1,12 @@
package com.olhar.olharapi.infrastructure.persistence.repository;
import com.olhar.olharapi.infrastructure.persistence.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface JpaUserRepository extends JpaRepository<UserEntity, UUID> {
Optional<UserEntity> findByEmail(String email);
boolean existsByEmail(String email);
}
@@ -0,0 +1,38 @@
package com.olhar.olharapi.infrastructure.persistence.repository;
import com.olhar.olharapi.application.port.out.UserRepository;
import com.olhar.olharapi.domain.model.User;
import com.olhar.olharapi.infrastructure.persistence.mapper.UserPersistenceMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class UserRepositoryAdapter implements UserRepository {
private final JpaUserRepository jpaUserRepository;
private final UserPersistenceMapper mapper;
@Override
public User save(User user) {
return mapper.toDomain(jpaUserRepository.save(mapper.toEntity(user)));
}
@Override
public Optional<User> findByEmail(String email) {
return jpaUserRepository.findByEmail(email).map(mapper::toDomain);
}
@Override
public Optional<User> findById(UUID id) {
return jpaUserRepository.findById(id).map(mapper::toDomain);
}
@Override
public boolean existsByEmail(String email) {
return jpaUserRepository.existsByEmail(email);
}
}
@@ -0,0 +1,57 @@
package com.olhar.olharapi.infrastructure.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response);
return;
}
String email = jwtService.extractEmail(token);
String role = jwtService.extractClaims(token).get("role", String.class);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
email,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,59 @@
package com.olhar.olharapi.infrastructure.security;
import com.olhar.olharapi.domain.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Service
public class JwtService {
private final SecretKey secretKey;
private final long expirationMs;
public JwtService(
@Value("${security.jwt.secret}") String secret,
@Value("${security.jwt.expiration-ms}") long expirationMs
) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationMs = expirationMs;
}
public String generateToken(User user) {
Date now = new Date();
return Jwts.builder()
.subject(user.email())
.claim("role", user.role().name())
.claim("userId", user.id().toString())
.issuedAt(now)
.expiration(new Date(now.getTime() + expirationMs))
.signWith(secretKey)
.compact();
}
public Claims extractClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
public String extractEmail(String token) {
return extractClaims(token).getSubject();
}
public boolean isTokenValid(String token) {
try {
return extractClaims(token).getExpiration().after(new Date());
} catch (Exception e) {
return false;
}
}
}
@@ -0,0 +1,42 @@
package com.olhar.olharapi.interfaces.exception;
import com.olhar.olharapi.domain.exception.EmailAlreadyUsedException;
import com.olhar.olharapi.domain.exception.UserNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EmailAlreadyUsedException.class)
public ProblemDetail handleEmailAlreadyUsed(EmailAlreadyUsedException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleUserNotFound(UserNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(BadCredentialsException.class)
public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, f -> f.getDefaultMessage() != null ? f.getDefaultMessage() : "Invalid value"));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
problem.setProperty("errors", errors);
return problem;
}
}
@@ -0,0 +1,46 @@
package com.olhar.olharapi.interfaces.rest.controller;
import com.olhar.olharapi.application.port.in.AuthenticateUserUseCase;
import com.olhar.olharapi.application.port.in.RegisterUserUseCase;
import com.olhar.olharapi.domain.model.User;
import com.olhar.olharapi.interfaces.rest.dto.request.LoginRequest;
import com.olhar.olharapi.interfaces.rest.dto.request.RegisterRequest;
import com.olhar.olharapi.interfaces.rest.dto.response.AuthResponse;
import com.olhar.olharapi.interfaces.rest.dto.response.UserResponse;
import com.olhar.olharapi.interfaces.rest.mapper.UserRestMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Tag(name = "Auth", description = "Inscription et authentification")
public class AuthController {
private final RegisterUserUseCase registerUserUseCase;
private final AuthenticateUserUseCase authenticateUserUseCase;
private final UserRestMapper userRestMapper;
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Créer un compte utilisateur")
public UserResponse register(@Valid @RequestBody RegisterRequest request) {
User user = registerUserUseCase.register(
new RegisterUserUseCase.Command(request.email(), request.password())
);
return userRestMapper.toResponse(user);
}
@PostMapping("/login")
@Operation(summary = "S'authentifier et obtenir un token JWT")
public AuthResponse login(@Valid @RequestBody LoginRequest request) {
String token = authenticateUserUseCase.authenticate(
new AuthenticateUserUseCase.Command(request.email(), request.password())
);
return new AuthResponse(token);
}
}
@@ -0,0 +1,9 @@
package com.olhar.olharapi.interfaces.rest.dto.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@Email @NotBlank String email,
@NotBlank String password
) {}
@@ -0,0 +1,10 @@
package com.olhar.olharapi.interfaces.rest.dto.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@Email @NotBlank String email,
@NotBlank @Size(min = 8) String password
) {}
@@ -0,0 +1,3 @@
package com.olhar.olharapi.interfaces.rest.dto.response;
public record AuthResponse(String token) {}
@@ -0,0 +1,6 @@
package com.olhar.olharapi.interfaces.rest.dto.response;
import java.time.Instant;
import java.util.UUID;
public record UserResponse(UUID id, String email, String role, Instant createdAt) {}
@@ -0,0 +1,18 @@
package com.olhar.olharapi.interfaces.rest.mapper;
import com.olhar.olharapi.domain.model.User;
import com.olhar.olharapi.interfaces.rest.dto.response.UserResponse;
import org.springframework.stereotype.Component;
@Component
public class UserRestMapper {
public UserResponse toResponse(User user) {
return new UserResponse(
user.id(),
user.email(),
user.role().name(),
user.createdAt()
);
}
}
+32
View File
@@ -0,0 +1,32 @@
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:olhar}
username: ${DB_USER:olhar}
password: ${DB_PASSWORD:olhar}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
flyway:
enabled: true
locations: classpath:db/migration
server:
port: 8080
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operationsSorter: method
security:
jwt:
secret: ${JWT_SECRET:changeme-in-production-use-a-long-random-string-at-least-256-bits}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
@@ -0,0 +1,7 @@
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'USER',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,29 @@
package com.olhar.olharapi.infrastructure;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("olhar_test")
.withUsername("olhar")
.withPassword("olhar");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
}
}
@@ -0,0 +1,39 @@
package com.olhar.olharapi.infrastructure.security;
import com.olhar.olharapi.domain.model.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class JwtServiceTest {
private JwtService jwtService;
@BeforeEach
void setUp() {
jwtService = new JwtService(
"test-secret-key-for-testing-only-must-be-long-enough-32-chars",
3600000L
);
}
@Test
void generateToken_shouldReturnValidToken() {
User user = new User(UUID.randomUUID(), "user@example.com", "hash", User.Role.USER, Instant.now());
String token = jwtService.generateToken(user);
assertThat(token).isNotBlank();
assertThat(jwtService.isTokenValid(token)).isTrue();
assertThat(jwtService.extractEmail(token)).isEqualTo("user@example.com");
}
@Test
void isTokenValid_shouldReturnFalse_forInvalidToken() {
assertThat(jwtService.isTokenValid("not.a.valid.token")).isFalse();
}
}
@@ -0,0 +1,61 @@
package com.olhar.olharapi.interfaces;
import com.olhar.olharapi.infrastructure.AbstractIntegrationTest;
import com.olhar.olharapi.interfaces.rest.dto.request.LoginRequest;
import com.olhar.olharapi.interfaces.rest.dto.request.RegisterRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class AuthControllerIT extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void register_shouldCreateUser_whenEmailIsNew() {
RegisterRequest request = new RegisterRequest("test@example.com", "password123");
ResponseEntity<String> response = restTemplate.postForEntity("/api/v1/auth/register", request, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).contains("test@example.com");
}
@Test
void register_shouldReturnConflict_whenEmailAlreadyExists() {
RegisterRequest request = new RegisterRequest("duplicate@example.com", "password123");
restTemplate.postForEntity("/api/v1/auth/register", request, String.class);
ResponseEntity<String> response = restTemplate.postForEntity("/api/v1/auth/register", request, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
@Test
void login_shouldReturnToken_whenCredentialsAreValid() {
RegisterRequest registerRequest = new RegisterRequest("login@example.com", "password123");
restTemplate.postForEntity("/api/v1/auth/register", registerRequest, String.class);
LoginRequest loginRequest = new LoginRequest("login@example.com", "password123");
ResponseEntity<String> response = restTemplate.postForEntity("/api/v1/auth/login", loginRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("token");
}
@Test
void login_shouldReturnUnauthorized_whenPasswordIsWrong() {
RegisterRequest registerRequest = new RegisterRequest("wrongpass@example.com", "password123");
restTemplate.postForEntity("/api/v1/auth/register", registerRequest, String.class);
LoginRequest loginRequest = new LoginRequest("wrongpass@example.com", "wrongpassword");
ResponseEntity<String> response = restTemplate.postForEntity("/api/v1/auth/login", loginRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
}
+14
View File
@@ -0,0 +1,14 @@
spring:
datasource:
url: jdbc:tc:postgresql:16:///olhar_test
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
jpa:
hibernate:
ddl-auto: validate
flyway:
enabled: true
security:
jwt:
secret: test-secret-key-for-testing-only-must-be-long-enough
expiration-ms: 3600000