Initial commit.

This commit is contained in:
Florian THIERRY
2024-03-08 13:42:28 +01:00
commit 494b731885
49 changed files with 1337 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
**/docker/postgresql/pgdata
**/node_modules
**/.angular

View File

@@ -0,0 +1,37 @@
<?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>
</dependencies>
</project>

View File

@@ -0,0 +1,14 @@
package org.codiki.application.configuration;
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();
}
}

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,51 @@
package org.codiki.application.security;
import java.time.ZonedDateTime;
import org.codiki.domain.user.model.User;
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;
@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())
.sign(algorithm);
}
public boolean isValid(String token) {
boolean result;
try {
jwtVerifier.verify(token);
result = true;
} catch (JWTVerificationException exception) {
result = false;
}
return result;
}
public String extractUsername(String token) {
return JWT.decode(token).getSubject();
}
}

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,108 @@
package org.codiki.application.user;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.application.security.AuthenticationFacade;
import org.codiki.application.security.JwtService;
import org.codiki.application.security.annotation.AllowedToAdmins;
import org.codiki.domain.exception.LoginFailureException;
import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
import org.codiki.domain.exception.UserDoesNotExistException;
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(UUID userId, String password) {
User user = userPort.findById(userId)
.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(String.class::isInstance)
.map(String.class::cast)
.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;
}
}

30
codiki-domain/pom.xml Normal file
View File

@@ -0,0 +1,30 @@
<?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-domain</artifactId>
<name>codiki-domain</name>
<description>Demo project for Spring Boot</description>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>16</source>
<target>16</target>
</configuration>
</plugin>
</plugins>
</build>
<packaging>jar</packaging>
</project>

View File

@@ -0,0 +1,7 @@
package org.codiki.domain.exception;
public abstract class FunctionnalException extends RuntimeException {
public FunctionnalException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package org.codiki.domain.exception;
public class LoginFailureException extends FunctionnalException {
public LoginFailureException() {
super("Login or password incorrect.");
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.exception;
import java.util.UUID;
public class RefreshTokenDoesNotExistException extends FunctionnalException {
public RefreshTokenDoesNotExistException(UUID refreshTokenValue) {
super(String.format("Refresh token \"%s\" does not exist.", refreshTokenValue));
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.exception;
import java.util.UUID;
public class RefreshTokenExpiredException extends FunctionnalException {
public RefreshTokenExpiredException(UUID refreshTokenValue) {
super(String.format("Refresh token \"%s\" is expired.", refreshTokenValue));
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.exception;
import java.util.UUID;
public class UserDoesNotExistException extends FunctionnalException {
public UserDoesNotExistException(UUID userId) {
super(String.format("User \"%s\" does not exist.", userId));
}
}

View File

@@ -0,0 +1,22 @@
package org.codiki.domain.user.model;
import java.time.ZonedDateTime;
import java.util.UUID;
public record RefreshToken(
UUID userId,
UUID value,
ZonedDateTime expirationDate
) {
public RefreshToken(UUID userId, ZonedDateTime exporationDate) {
this(userId, UUID.randomUUID(), exporationDate);
}
public boolean isExpired() {
return ZonedDateTime.now().isAfter(expirationDate);
}
public boolean isNotExpired() {
return !isExpired();
}
}

View File

@@ -0,0 +1,10 @@
package org.codiki.domain.user.model;
import java.util.List;
import java.util.UUID;
public record User(
UUID id,
String password,
List<UserRole> roles
) {}

View File

@@ -0,0 +1,8 @@
package org.codiki.domain.user.model;
public record UserAuthenticationData(
String tokenType,
String accessToken,
RefreshToken refreshToken
) {
}

View File

@@ -0,0 +1,6 @@
package org.codiki.domain.user.model;
public enum UserRole {
STANDARD,
ADMIN
}

View File

@@ -0,0 +1,24 @@
package org.codiki.domain.user.port;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.user.model.RefreshToken;
import org.codiki.domain.user.model.User;
public interface UserPort {
Optional<User> findById(UUID userId);
List<User> findAll();
void save(User user);
boolean existsById(UUID userId);
Optional<RefreshToken> findRefreshTokenByUserId(UUID userId);
Optional<RefreshToken> findRefreshTokenById(UUID refreshTokenId);
void save(RefreshToken refreshToken);
}

52
codiki-exposition/pom.xml Normal file
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-exposition</artifactId>
<name>codiki-exposition</name>
<description>Demo project for Spring Boot</description>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-application</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-data-jpa</artifactId>-->
<!-- </dependency>-->
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-security</artifactId>-->
<!-- </dependency>-->
<!-- <dependency>-->
<!-- <groupId>org.postgresql</groupId>-->
<!-- <artifactId>postgresql</artifactId>-->
<!-- <scope>runtime</scope>-->
<!-- </dependency>-->
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-test</artifactId>-->
<!-- <scope>test</scope>-->
<!-- </dependency>-->
<!-- <dependency>-->
<!-- <groupId>org.springframework.security</groupId>-->
<!-- <artifactId>spring-security-test</artifactId>-->
<!-- <scope>test</scope>-->
<!-- </dependency>-->
</dependencies>
</project>

View File

@@ -0,0 +1,40 @@
package org.codiki.exposition.configuration;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import org.codiki.domain.exception.LoginFailureException;
import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
import org.codiki.domain.exception.RefreshTokenExpiredException;
import org.codiki.domain.exception.UserDoesNotExistException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
@ControllerAdvice
public class GlobalControllerExceptionHandler {
@ResponseStatus(BAD_REQUEST)
@ExceptionHandler(LoginFailureException.class)
public void handleLoginFailureException() {
// Do nothing.
}
@ResponseStatus(NOT_FOUND)
@ExceptionHandler(UserDoesNotExistException.class)
public void handleUserDoesNotExistException() {
// Do nothing.
}
@ResponseStatus(NOT_FOUND)
@ExceptionHandler(RefreshTokenDoesNotExistException.class)
public void handleRefreshTokenDoesNotExistException() {
// Do nothing.
}
@ResponseStatus(UNAUTHORIZED)
@ExceptionHandler(RefreshTokenExpiredException.class)
public void handleRefreshTokenExpiredException() {
// Do nothing.
}
}

View File

@@ -0,0 +1,61 @@
package org.codiki.exposition.configuration.security;
import java.io.IOException;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.security.JwtService;
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;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
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) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
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);
}
}
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,57 @@
package org.codiki.exposition.configuration.security;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.OPTIONS;
import static org.springframework.http.HttpMethod.POST;
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import static jakarta.servlet.DispatcherType.FORWARD;
import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity httpSecurity,
JwtAuthenticationFilter jwtAuthenticationFilter
) throws Exception {
httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(Customizer.withDefaults())
.exceptionHandling(configurer -> configurer
.authenticationEntryPoint((request, response, authException) -> response.sendError(SC_UNAUTHORIZED))
.accessDeniedHandler((request, response, accessDeniedException) -> response.sendError(SC_FORBIDDEN))
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(customizer -> customizer.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(requests -> requests
.dispatcherTypeMatchers(FORWARD).permitAll()
.requestMatchers(
GET,
"/api/health/check",
"/error"
).permitAll()
.requestMatchers(
POST,
"/api/users/login",
"/api/users/refresh-token"
).permitAll()
.requestMatchers(OPTIONS).permitAll()
.anyRequest().authenticated()
);
return httpSecurity.build();
}
}

View File

@@ -0,0 +1,14 @@
package org.codiki.exposition.healthcheck;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/health")
public class HealthCheckController {
@GetMapping("/check")
public String healthCheck() {
return "Ok";
}
}

View File

@@ -0,0 +1,46 @@
package org.codiki.exposition.user;
import java.util.List;
import org.codiki.application.security.annotation.AllowedToAdmins;
import org.codiki.application.security.annotation.AllowedToAnonymous;
import org.codiki.application.user.UserUseCases;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserAuthenticationData;
import org.codiki.exposition.user.model.LoginRequest;
import org.codiki.exposition.user.model.LoginResponse;
import org.codiki.exposition.user.model.RefreshTokenRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserUseCases userUseCases;
public UserController(UserUseCases userUseCases) {
this.userUseCases = userUseCases;
}
@PostMapping("/login")
@AllowedToAnonymous
public LoginResponse login(@RequestBody LoginRequest request) {
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.id(), request.password());
return new LoginResponse(userAuthenticationData);
}
@GetMapping
@AllowedToAdmins
public List<User> findAll() {
return userUseCases.findAll();
}
@PostMapping("/refresh-token")
public LoginResponse refreshToken(@RequestBody RefreshTokenRequest request) {
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.refreshTokenValue());
return new LoginResponse(userAuthenticationData);
}
}

View File

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

View File

@@ -0,0 +1,17 @@
package org.codiki.exposition.user.model;
import org.codiki.domain.user.model.UserAuthenticationData;
public record LoginResponse(
String tokenType,
String accessToken,
String refreshToken
) {
public LoginResponse(UserAuthenticationData userAuthenticationData) {
this(
userAuthenticationData.tokenType(),
userAuthenticationData.accessToken(),
userAuthenticationData.refreshToken().value().toString()
);
}
}

View File

@@ -0,0 +1,8 @@
package org.codiki.exposition.user.model;
import java.util.UUID;
public record RefreshTokenRequest(
UUID refreshTokenValue
) {
}

View File

@@ -0,0 +1,41 @@
<?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-infrastructure</artifactId>
<name>codiki-infrastructure</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-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,11 @@
package org.codiki.infrastructure.configuration;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Configuration
@EnableJpaRepositories("org.codiki.infrastructure")
@EntityScan("org.codiki.infrastructure")
public class JpaConfiguration {
}

View File

@@ -0,0 +1,71 @@
package org.codiki.infrastructure.user.adapter;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.user.model.RefreshToken;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.port.UserPort;
import org.codiki.infrastructure.user.model.RefreshTokenEntity;
import org.codiki.infrastructure.user.repository.RefreshTokenJpaRepository;
import org.codiki.infrastructure.user.repository.UserJpaRepository;
import org.codiki.infrastructure.user.model.UserEntity;
import org.springframework.stereotype.Component;
@Component
public class UserJpaAdapter implements UserPort {
private final RefreshTokenJpaRepository refreshTokenJpaRepository;
private final UserJpaRepository userJpaRepository;
public UserJpaAdapter(
RefreshTokenJpaRepository refreshTokenJpaRepository,
UserJpaRepository userJpaRepository
) {
this.refreshTokenJpaRepository = refreshTokenJpaRepository;
this.userJpaRepository = userJpaRepository;
}
@Override
public Optional<User> findById(UUID userId) {
return userJpaRepository.findById(userId)
.map(UserEntity::toUser);
}
@Override
public List<User> findAll() {
return userJpaRepository.findAll()
.stream()
.map(UserEntity::toUser)
.toList();
}
@Override
public void save(User user) {
UserEntity userEntity = new UserEntity(user);
userJpaRepository.save(userEntity);
}
@Override
public boolean existsById(UUID userId) {
return userJpaRepository.existsById(userId);
}
@Override
public Optional<RefreshToken> findRefreshTokenByUserId(UUID userId) {
return refreshTokenJpaRepository.findByUserId(userId)
.map(RefreshTokenEntity::toRefreshToken);
}
@Override
public Optional<RefreshToken> findRefreshTokenById(UUID refreshTokenId) {
return refreshTokenJpaRepository.findByValue(refreshTokenId)
.map(RefreshTokenEntity::toRefreshToken);
}
@Override
public void save(RefreshToken refreshToken) {
RefreshTokenEntity refreshTokenEntity = new RefreshTokenEntity(refreshToken);
refreshTokenJpaRepository.save(refreshTokenEntity);
}
}

View File

@@ -0,0 +1,40 @@
package org.codiki.infrastructure.user.model;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.user.model.RefreshToken;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "refresh_token")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class RefreshTokenEntity {
@Id
private UUID userId;
@Column(nullable = false)
private UUID value;
@Column(nullable = false)
private ZonedDateTime expirationDate;
public RefreshTokenEntity(RefreshToken refreshToken) {
userId = refreshToken.userId();
value = refreshToken.value();
expirationDate = refreshToken.expirationDate();
}
public RefreshToken toRefreshToken() {
return new RefreshToken(userId, value, expirationDate);
}
}

View File

@@ -0,0 +1,55 @@
package org.codiki.infrastructure.user.model;
import java.util.List;
import java.util.UUID;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserRole;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "`user`")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class UserEntity {
@Id
private UUID id;
@Column(nullable = false)
private String password;
@ElementCollection(targetClass = UserRole.class)
@CollectionTable(
name = "user_role",
joinColumns = @JoinColumn(name = "user_id")
)
@Column(name = "role")
private List<UserRole> roles;
public UserEntity(User user) {
id = user.id();
password = user.password();
roles = user.roles();
}
public User toUser() {
return new User(
id,
password,
roles
);
}
}

View File

@@ -0,0 +1,15 @@
package org.codiki.infrastructure.user.repository;
import java.util.Optional;
import java.util.UUID;
import org.codiki.infrastructure.user.model.RefreshTokenEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface RefreshTokenJpaRepository extends JpaRepository<RefreshTokenEntity, UUID> {
Optional<RefreshTokenEntity> findByUserId(UUID userId);
Optional<RefreshTokenEntity> findByValue(UUID refreshTokenId);
}

View File

@@ -0,0 +1,20 @@
package org.codiki.infrastructure.user.repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.infrastructure.user.model.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface UserJpaRepository extends JpaRepository<UserEntity, UUID> {
@Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.id = :userId")
Optional<UserEntity> findById(@Param("userId") UUID userId);
@Query("SELECT u FROM UserEntity u JOIN FETCH u.roles")
List<UserEntity> findAll();
}

View File

@@ -0,0 +1,12 @@
\c codiki_db
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE USER codiki_user
WITH PASSWORD 'password'
NOCREATEDB;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES
IN SCHEMA public
TO codiki_user;

View File

@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "user" (
id UUID NOT NULL,
password VARCHAR NOT NULL,
CONSTRAINT user_pk PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS user_role (
user_id UUID NOT NULL,
role SMALLINT,
CONSTRAINT user_role_pk PRIMARY KEY (user_id, role),
CONSTRAINT user_role_fk_user_id FOREIGN KEY (user_id) REFERENCES "user" (id)
);
CREATE INDEX user_role_fk_user_id_idx ON user_role (user_id);
CREATE TABLE IF NOT EXISTS refresh_token (
user_id UUID NOT NULL,
value UUID NOT NULL,
expiration_date TIMESTAMP NOT NULL,
CONSTRAINT refresh_token_pk PRIMARY KEY (user_id),
CONSTRAINT refresh_token_fk_user_id FOREIGN KEY (user_id) REFERENCES "user" (id)
);
CREATE INDEX refresh_token_fk_user_id_idx ON user_role (user_id);

46
codiki-launcher/pom.xml Normal file
View File

@@ -0,0 +1,46 @@
<?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-launcher</artifactId>
<name>codiki-launcher</name>
<description>Demo project for Spring Boot</description>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-exposition</artifactId>
</dependency>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-application</artifactId>
</dependency>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-domain</artifactId>
</dependency>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-infrastructure</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,18 @@
package org.codiki.launcher;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@SpringBootApplication
@ComponentScan(basePackages = {
"org.codiki.exposition",
"org.codiki.application",
"org.codiki.domain",
"org.codiki.infrastructure"
})
public class ApplicationLauncher {
public static void main(String[] args) {
SpringApplication.run(ApplicationLauncher.class, args);
}
}

View File

@@ -0,0 +1,24 @@
application:
security:
jwt:
secretKey: "secret-key"
expirationDelayInMinutes: 30
refreshToken:
expirationDelayInDays: 7
logging:
level:
org.springframework.security: DEBUG
server:
error:
whitelabel:
enabled: false # Disable html error responses.
include-stacktrace: never
spring:
datasource:
driverClassName: org.postgresql.Driver
url: jdbc:postgresql://localhost:50001/codiki_db
username: codiki_user
password: password

View File

@@ -0,0 +1,12 @@
#######
##############
###################
######## ######### _________ .___.__ __ .__
####### ############# \_ ___ \ ____ __| _/|__| | _|__|
###### ### ######## / \ \/ / _ \ / __ | | | |/ / |
####### ### ##### \ \___( <_> ) /_/ | | | <| |
######### ## ####### \______ /\____/\____ | |__|__|_ \__|
######### ######## \/ \/ \/
###################
#############
#######

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: '3.9'
services:
codiki-database:
container_name: "codiki-database"
image: "postgres:16"
ports:
- "50001:5432"
networks:
- "codiki-local-network"
environment:
POSTGRES_DB: codiki_db
POSTGRES_USER: codiki_admin
POSTGRES_PASSWORD: password
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- "./docker/postgresql/pgdata:/var/lib/postgresql/data/pgdata"
networks:
codiki-local-network:

86
pom.xml Normal file
View File

@@ -0,0 +1,86 @@
<?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>
<groupId>org.codiki</groupId>
<artifactId>codiki-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<name>codiki</name>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jakarta.servlet-api.version>6.0.0</jakarta.servlet-api.version>
<java-jwt.version>4.4.0</java-jwt.version>
<postgresql.version>42.7.0</postgresql.version>
</properties>
<modules>
<module>codiki-domain</module>
<module>codiki-application</module>
<module>codiki-infrastructure</module>
<module>codiki-exposition</module>
<module>codiki-launcher</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-exposition</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-infrastructure</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>${jakarta.servlet-api.version}</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>${java-jwt.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,11 @@
meta {
name: Login
type: http
seq: 1
}
get {
url:
body: none
auth: none
}

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "Codiki",
"type": "collection"
}

View File

@@ -0,0 +1,3 @@
vars {
url: http://localhost:8080
}