Compare commits

...

11 Commits

Author SHA1 Message Date
Florian THIERRY
5e5792f17c Code moving and fix login component style. 2023-12-06 14:09:23 +01:00
Florian THIERRY
9f40a6c782 Implementation of login and logout mechanisms. 2023-12-05 14:31:07 +01:00
Florian THIERRY
c095cdab3a Add login and home components in angular app. 2023-12-05 11:32:59 +01:00
Florian THIERRY
cea35955e4 Add angular app. 2023-12-04 10:36:12 +01:00
Florian THIERRY
756953fbf9 Remove useless "final" keywords 2023-12-01 15:04:53 +01:00
Florian THIERRY
367676f6d8 Implementation of refresh token. 2023-12-01 15:01:43 +01:00
Florian THIERRY
4a7b0b2daf Add method to retrieve authenticated user. 2023-12-01 10:00:07 +01:00
Florian THIERRY
89d78e6814 Code moving. 2023-12-01 09:25:22 +01:00
Florian THIERRY
2bb46499bc Implementation of the user port for JPA processing. 2023-11-30 17:54:01 +01:00
Florian THIERRY
cb07b71a88 Creation of database foundations. 2023-11-30 17:09:00 +01:00
Florian THIERRY
a8046a1227 Add annotations to allow or deny use-cases access. 2023-11-30 16:06:01 +01:00
80 changed files with 19008 additions and 83 deletions

5
.gitignore vendored
View File

@@ -31,3 +31,8 @@ build/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
**/docker/postgresql/pgdata
**/node_modules
**/.angular

View File

@@ -8,6 +8,13 @@ services:
- "50001:5432" - "50001:5432"
networks: networks:
- "sportshub-local-network" - "sportshub-local-network"
environment:
POSTGRES_DB: sportshub_db
POSTGRES_USER: sportshub_admin
POSTGRES_PASSWORD: password
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- "./docker/postgresql/pgdata:/var/lib/postgresql/data/pgdata"
networks: networks:
sportshub-local-network: sportshub-local-network:

View File

@@ -17,6 +17,7 @@
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<jakarta.servlet-api.version>6.0.0</jakarta.servlet-api.version> <jakarta.servlet-api.version>6.0.0</jakarta.servlet-api.version>
<java-jwt.version>4.4.0</java-jwt.version> <java-jwt.version>4.4.0</java-jwt.version>
<postgresql.version>42.7.0</postgresql.version>
</properties> </properties>
<modules> <modules>
@@ -66,6 +67,11 @@
<artifactId>java-jwt</artifactId> <artifactId>java-jwt</artifactId>
<version>${java-jwt.version}</version> <version>${java-jwt.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -29,10 +29,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.auth0</groupId> <groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId> <artifactId>java-jwt</artifactId>

View File

@@ -0,0 +1,14 @@
package org.sportshub.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.sportshub.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

@@ -13,19 +13,19 @@ import org.springframework.stereotype.Service;
public class CustomUserDetailsService implements UserDetailsService { public class CustomUserDetailsService implements UserDetailsService {
private final UserUseCases userUseCases; private final UserUseCases userUseCases;
public CustomUserDetailsService(final UserUseCases userUseCases) { public CustomUserDetailsService(UserUseCases userUseCases) {
this.userUseCases = userUseCases; this.userUseCases = userUseCases;
} }
@Override @Override
public UserDetails loadUserByUsername(final String userIdAsString) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String userIdAsString) throws UsernameNotFoundException {
UUID userId = parseUserId(userIdAsString); UUID userId = parseUserId(userIdAsString);
return userUseCases.findById(userId) return userUseCases.findById(userId)
.map(CustomUserDetails::new) .map(CustomUserDetails::new)
.orElseThrow(() -> new UsernameNotFoundException(userIdAsString)); .orElseThrow(() -> new UsernameNotFoundException(userIdAsString));
} }
private UUID parseUserId(final String userIdAsString) { private UUID parseUserId(String userIdAsString) {
try { try {
return UUID.fromString(userIdAsString); return UUID.fromString(userIdAsString);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {

View File

@@ -17,8 +17,8 @@ public class JwtService {
private final int tokenExpirationDelayInMinutes; private final int tokenExpirationDelayInMinutes;
public JwtService( public JwtService(
@Value("${application.security.secretKey}") String secretKey, @Value("${application.security.jwt.secretKey}") String secretKey,
@Value("${application.security.tokenExpirationDelayInMinutes}") int tokenExpirationDelayInMinutes @Value("${application.security.jwt.expirationDelayInMinutes}") int tokenExpirationDelayInMinutes
) { ) {
algorithm = Algorithm.HMAC512(secretKey); algorithm = Algorithm.HMAC512(secretKey);
this.tokenExpirationDelayInMinutes = tokenExpirationDelayInMinutes; this.tokenExpirationDelayInMinutes = tokenExpirationDelayInMinutes;

View File

@@ -0,0 +1,14 @@
package org.sportshub.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.sportshub.domain.user.model.UserRole).ADMIN.name())")
public @interface AllowedToAdmins {
}

View File

@@ -0,0 +1,14 @@
package org.sportshub.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

@@ -11,7 +11,7 @@ import org.springframework.security.core.userdetails.UserDetails;
public class CustomUserDetails implements UserDetails { public class CustomUserDetails implements UserDetails {
private final User user; private final User user;
public CustomUserDetails(final User user) { public CustomUserDetails(User user) {
this.user = user; this.user = user;
} }

View File

@@ -1,44 +1,108 @@
package org.sportshub.application.user; package org.sportshub.application.user;
import java.time.ZonedDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.sportshub.application.security.AuthenticationFacade;
import org.sportshub.application.security.JwtService; import org.sportshub.application.security.JwtService;
import org.sportshub.application.security.annotation.AllowedToAdmins;
import org.sportshub.domain.exception.LoginFailureException; import org.sportshub.domain.exception.LoginFailureException;
import org.sportshub.domain.exception.RefreshTokenDoesNotExistException;
import org.sportshub.domain.exception.UserDoesNotExistException;
import org.sportshub.domain.user.model.RefreshToken;
import org.sportshub.domain.user.model.User; import org.sportshub.domain.user.model.User;
import org.sportshub.domain.user.model.UserAuthenticationData;
import org.sportshub.domain.user.port.UserPort; import org.sportshub.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.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class UserUseCases { public class UserUseCases {
private static final String TOKEN_TYPE = "Bearer";
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtService jwtService; private final JwtService jwtService;
private final UserPort userPort; private final UserPort userPort;
private final AuthenticationFacade authenticationFacade;
private final int refreshTokenExpirationDelayInDays;
public UserUseCases( public UserUseCases(
PasswordEncoder passwordEncoder, AuthenticationFacade authenticationFacade,
JwtService jwtService, JwtService jwtService,
UserPort userPort PasswordEncoder passwordEncoder,
UserPort userPort,
@Value("${application.security.refreshToken.expirationDelayInDays}")
int refreshTokenExpirationDelayInDays
) { ) {
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService; this.jwtService = jwtService;
this.userPort = userPort; this.userPort = userPort;
this.authenticationFacade = authenticationFacade;
this.refreshTokenExpirationDelayInDays = refreshTokenExpirationDelayInDays;
} }
public Optional<User> findById(UUID userId) { public Optional<User> findById(UUID userId) {
return userPort.findById(userId); return userPort.findById(userId);
} }
@AllowedToAdmins
public List<User> findAll() { public List<User> findAll() {
return userPort.findAll(); return userPort.findAll();
} }
public String authenticate(final UUID id, final String password) { public UserAuthenticationData authenticate(UUID userId, String password) {
return userPort.findById(id) User user = userPort.findById(userId)
.filter(user -> passwordEncoder.matches(password, user.password()))
.map(jwtService::createJwt)
.orElseThrow(LoginFailureException::new); .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;
} }
} }

View File

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

View File

@@ -0,0 +1,9 @@
package org.sportshub.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.sportshub.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.sportshub.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.sportshub.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,8 @@
package org.sportshub.domain.user.model;
public record UserAuthenticationData(
String tokenType,
String accessToken,
RefreshToken refreshToken
) {
}

View File

@@ -4,10 +4,21 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.sportshub.domain.user.model.RefreshToken;
import org.sportshub.domain.user.model.User; import org.sportshub.domain.user.model.User;
public interface UserPort { public interface UserPort {
Optional<User> findById(UUID userId); Optional<User> findById(UUID userId);
List<User> findAll(); 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);
} }

View File

@@ -1,7 +1,12 @@
package org.sportshub.exposition.configuration; package org.sportshub.exposition.configuration;
import static org.springframework.http.HttpStatus.BAD_REQUEST; 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.sportshub.domain.exception.LoginFailureException; import org.sportshub.domain.exception.LoginFailureException;
import org.sportshub.domain.exception.RefreshTokenDoesNotExistException;
import org.sportshub.domain.exception.RefreshTokenExpiredException;
import org.sportshub.domain.exception.UserDoesNotExistException;
import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
@@ -14,4 +19,22 @@ public class GlobalControllerExceptionHandler {
public void handleLoginFailureException() { public void handleLoginFailureException() {
// Do nothing. // 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

@@ -1,9 +1,10 @@
package org.sportshub.application.security; package org.sportshub.exposition.configuration.security;
import java.io.IOException; import java.io.IOException;
import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.util.ObjectUtils.isEmpty; import static org.springframework.util.ObjectUtils.isEmpty;
import org.sportshub.application.security.JwtService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
@@ -20,6 +21,7 @@ import jakarta.servlet.http.HttpServletResponse;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
private final JwtService jwtService; private final JwtService jwtService;
private final UserDetailsService userDetailsService; private final UserDetailsService userDetailsService;

View File

@@ -1,19 +1,16 @@
package org.sportshub.application.configuration; package org.sportshub.exposition.configuration.security;
import static org.sportshub.domain.user.model.UserRole.ADMIN;
import static org.springframework.http.HttpMethod.GET; import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.OPTIONS; import static org.springframework.http.HttpMethod.OPTIONS;
import static org.springframework.http.HttpMethod.POST; import static org.springframework.http.HttpMethod.POST;
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;
import org.sportshub.application.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -23,6 +20,7 @@ import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfiguration { public class SecurityConfiguration {
@Bean @Bean
public SecurityFilterChain securityFilterChain( public SecurityFilterChain securityFilterChain(
@@ -45,13 +43,10 @@ public class SecurityConfiguration {
"/api/health/check", "/api/health/check",
"/error" "/error"
).permitAll() ).permitAll()
.requestMatchers(
GET,
"/api/users"
).hasAuthority(ADMIN.name())
.requestMatchers( .requestMatchers(
POST, POST,
"/api/users/login" "/api/users/login",
"/api/users/refresh-token"
).permitAll() ).permitAll()
.requestMatchers(OPTIONS).permitAll() .requestMatchers(OPTIONS).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
@@ -59,9 +54,4 @@ public class SecurityConfiguration {
return httpSecurity.build(); return httpSecurity.build();
} }
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
} }

View File

@@ -2,9 +2,14 @@ package org.sportshub.exposition.user;
import java.util.List; import java.util.List;
import org.sportshub.application.security.annotation.AllowedToAdmins;
import org.sportshub.application.security.annotation.AllowedToAnonymous;
import org.sportshub.application.user.UserUseCases; import org.sportshub.application.user.UserUseCases;
import org.sportshub.domain.user.model.User; import org.sportshub.domain.user.model.User;
import org.sportshub.domain.user.model.UserAuthenticationData;
import org.sportshub.exposition.user.model.LoginRequest; import org.sportshub.exposition.user.model.LoginRequest;
import org.sportshub.exposition.user.model.LoginResponse;
import org.sportshub.exposition.user.model.RefreshTokenRequest;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -16,17 +21,26 @@ import org.springframework.web.bind.annotation.RestController;
public class UserController { public class UserController {
private final UserUseCases userUseCases; private final UserUseCases userUseCases;
public UserController(final UserUseCases userUseCases) { public UserController(UserUseCases userUseCases) {
this.userUseCases = userUseCases; this.userUseCases = userUseCases;
} }
@PostMapping("/login") @PostMapping("/login")
public String login(@RequestBody LoginRequest request) { @AllowedToAnonymous
return userUseCases.authenticate(request.id(), request.password()); public LoginResponse login(@RequestBody LoginRequest request) {
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.id(), request.password());
return new LoginResponse(userAuthenticationData);
} }
@GetMapping @GetMapping
@AllowedToAdmins
public List<User> findAll() { public List<User> findAll() {
return userUseCases.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,17 @@
package org.sportshub.exposition.user.model;
import org.sportshub.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.sportshub.exposition.user.model;
import java.util.UUID;
public record RefreshTokenRequest(
UUID refreshTokenValue
) {
}

View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

102
sportshub-gui/angular.json Normal file
View File

@@ -0,0 +1,102 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"sportshub-gui": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/sportshub-gui",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "sportshub-gui:build:production"
},
"development": {
"buildTarget": "sportshub-gui:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "sportshub-gui:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:jest",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}

17658
sportshub-gui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
{
"name": "sportshub-gui",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/cdk": "^17.0.2",
"@angular/common": "^17.0.0",
"@angular/compiler": "^17.0.0",
"@angular/core": "^17.0.0",
"@angular/forms": "^17.0.0",
"@angular/material": "^17.0.2",
"@angular/platform-browser": "^17.0.0",
"@angular/platform-browser-dynamic": "^17.0.0",
"@angular/router": "^17.0.0",
"ngx-cookie-service": "^17.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.0.5",
"@angular/cli": "^17.0.5",
"@angular/compiler-cli": "^17.0.0",
"@types/jasmine": "~5.1.0",
"@types/jest": "^29.5.10",
"jasmine-core": "~5.1.0",
"jest": "^29.7.0",
"jest-preset-angular": "^13.1.4",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.2.2"
},
"jest": {
"preset": "jest-preset-angular",
"setupFilesAfterEnv": [
"<rootDir>/src/setupJest.ts"
]
}
}

View File

@@ -0,0 +1,6 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false
}
}

View File

@@ -0,0 +1,2 @@
<app-header/>
<router-outlet></router-outlet>

View File

@@ -0,0 +1,8 @@
:host {
display: flex;
flex-direction: column;
app-header {
margin-bottom: 1em;
}
}

View File

@@ -0,0 +1,21 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import {HttpClientModule} from "@angular/common/http";
import {LoginModule} from "./components/login/login.module";
import {HeaderComponent} from "./header/header.component";
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
HeaderComponent,
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
title = 'sportshub-gui';
}

View File

@@ -0,0 +1,14 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideAnimations } from '@angular/platform-browser/animations';
import {provideHttpClient} from "@angular/common/http";
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
provideAnimations()
]
};

View File

@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadChildren: () => import('./components/home/home.module').then(module => module.HomeModule)
},
{
path: 'login',
loadChildren: () => import('./components/login/login.module').then(module => module.LoginModule)
},
{
path: 'logout',
loadChildren: () => import('./components/logout/logout.module').then(module => module.LogoutModule)
}
];

View File

@@ -0,0 +1,17 @@
import {AppService} from "./app.service";
describe('In the service AppService', () => {
let service: AppService;
beforeEach(() => {
service = new AppService();
});
describe('The method "test"', () => {
it('should return "true"', () => {
const result = service.test();
expect(result).toEqual(true);
});
});
});

View File

@@ -0,0 +1,8 @@
import {Injectable} from "@angular/core";
@Injectable()
export class AppService {
test(): boolean {
return true;
}
}

View File

@@ -0,0 +1 @@
<h1>Hello world!</h1>

View File

@@ -0,0 +1,10 @@
import {Component} from "@angular/core";
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss']
})
export class HomeComponent {
}

View File

@@ -0,0 +1,25 @@
import {NgModule} from "@angular/core";
import {HomeComponent} from "./home.component";
import {RouterModule} from "@angular/router";
const routes = [
{
path: '',
component: HomeComponent
}
]
@NgModule({
declarations: [
HomeComponent
],
imports: [
RouterModule.forChild(routes),
],
exports: [
HomeComponent
]
})
export class HomeModule {
}

View File

@@ -0,0 +1,13 @@
<form (ngSubmit)="onSubmit()" class="shadowed" [formGroup]="loginForm" ngNativeValidate>
<div>
<label for="id">Identifier</label>
<input id="id" name="id" formControlName="id" class="input" required/>
</div>
<div>
<label for="password">Password</label>
<input id="password" name="password" type="password" formControlName="password" class="input" required/>
</div>
<div>
<button type="submit" class="btn">Validate</button>
</div>
</form>

View File

@@ -0,0 +1,19 @@
:host {
display: flex;
justify-content: center;
form {
display: flex;
flex-direction: column;
border: solid 1px #e8e8e8;
padding: 1em;
border-radius: .5em;
gap: 1em;
div {
display: flex;
flex-direction: column;
justify-content: left;
}
}
}

View File

@@ -0,0 +1,35 @@
import {Component, OnInit} from "@angular/core";
import {FormBuilder, FormControl, FormGroup, Validators} from "@angular/forms";
import {UserRestService} from "../../core/rest-services/user.rest-service";
import {LoginRequest} from "../../core/model/login-request";
import {MatSnackBar} from "@angular/material/snack-bar";
import {LoginService} from "./login.service";
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent {
loginForm: FormGroup;
isLoginPending: boolean = false;
constructor(
private formBuilder: FormBuilder,
private loginService: LoginService,
private matSnackBar: MatSnackBar,
private userRestService: UserRestService
) {
this.loginForm = this.formBuilder.group({
id: new FormControl(undefined, [Validators.required]),
password: new FormControl(undefined, [Validators.required])
});
}
onSubmit(): void {
if (this.loginForm.valid) {
const loginRequest: LoginRequest = this.loginForm.value;
this.loginService.login(loginRequest);
}
}
}

View File

@@ -0,0 +1,34 @@
import {NgModule} from "@angular/core";
import {LoginComponent} from "./login.component";
import {CoreModule} from "../../core/core.module";
import {MatSnackBarModule} from "@angular/material/snack-bar";
import {RouterModule} from "@angular/router";
import {HttpClientModule} from "@angular/common/http";
import {LoginService} from "./login.service";
const routes = [
{
path: '',
component: LoginComponent
}
]
@NgModule({
declarations: [
LoginComponent
],
providers: [
LoginService
],
imports: [
CoreModule,
RouterModule.forChild(routes),
MatSnackBarModule
],
exports: [
LoginComponent
]
})
export class LoginModule {
}

View File

@@ -0,0 +1,37 @@
import {Injectable} from "@angular/core";
import {UserRestService} from "../../core/rest-services/user.rest-service";
import {LoginRequest} from "../../core/model/login-request";
import {Subject} from "rxjs";
import {MessageService} from "../../core/services/message.service";
import {AuthenticationService} from "../../core/services/authentication.service";
import {Router} from "@angular/router";
@Injectable()
export class LoginService {
private isLoginPending: Subject<boolean> = new Subject<boolean>();
constructor(
private authenticationService: AuthenticationService,
private messageService: MessageService,
private router: Router,
private userRestService: UserRestService
) {}
login(loginRequest: LoginRequest): void {
this.isLoginPending.next(true);
this.userRestService.login(loginRequest)
.then(loginResponse => {
this.messageService.display('Login success!');
this.authenticationService.setAuthenticated(loginResponse);
this.router.navigate(['/']);
})
.catch(error => {
if (error.status === 400) {
this.messageService.display('Login or password incorrect.')
} else {
this.messageService.display('An error occured while login.')
}
});
}
}

View File

@@ -0,0 +1,2 @@
<h1>Disconnection...</h1>
<mat-spinner></mat-spinner>

View File

@@ -0,0 +1,6 @@
:host {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

View File

@@ -0,0 +1,15 @@
import {Component, inject, OnInit} from "@angular/core";
import {LogoutService} from "./logout.service";
@Component({
selector: 'app-logout',
templateUrl: './logout.component.html',
styleUrls: ['./logout.component.scss']
})
export class LogoutComponent implements OnInit {
private logoutService = inject(LogoutService);
ngOnInit(): void {
this.logoutService.logout();
}
}

View File

@@ -0,0 +1,29 @@
import {NgModule} from "@angular/core";
import {RouterModule} from "@angular/router";
import {LogoutComponent} from "./logout.component";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
import {LogoutService} from "./logout.service";
const routes = [
{
path: '',
component: LogoutComponent
}
];
@NgModule({
declarations: [
LogoutComponent
],
providers: [
LogoutService
],
imports: [
RouterModule.forChild(routes),
MatProgressSpinnerModule
],
exports: [
LogoutComponent
]
})
export class LogoutModule {}

View File

@@ -0,0 +1,16 @@
import {Injectable} from "@angular/core";
import {AuthenticationService} from "../../core/services/authentication.service";
import {Router} from "@angular/router";
@Injectable()
export class LogoutService {
constructor(
private authenticationService: AuthenticationService,
private router: Router
) {}
logout(): void {
this.authenticationService.setAnonymous();
this.router.navigate(['/']);
}
}

View File

@@ -0,0 +1,17 @@
import {NgModule} from "@angular/core";
import {ReactiveFormsModule} from "@angular/forms";
import {HttpClientModule} from "@angular/common/http";
@NgModule({
imports: [
ReactiveFormsModule,
HttpClientModule,
],
exports: [
ReactiveFormsModule,
HttpClientModule
]
})
export class CoreModule {
}

View File

@@ -0,0 +1,4 @@
export interface LoginRequest {
id: string;
password: string;
}

View File

@@ -0,0 +1,5 @@
export interface LoginResponse {
tokenType: string;
accessToken: string;
refreshToken: string;
}

View File

@@ -0,0 +1,20 @@
import {Injectable} from "@angular/core";
import {HttpClient} from "@angular/common/http";
import {LoginResponse} from "../model/login-response";
import {firstValueFrom} from "rxjs";
import {LoginRequest} from "../model/login-request";
@Injectable({
providedIn: 'root'
})
export class UserRestService {
constructor(
private httpClient: HttpClient
) {}
login(request: LoginRequest): Promise<LoginResponse> {
return firstValueFrom(
this.httpClient.post<LoginResponse>('/api/users/login', request)
)
}
}

View File

@@ -0,0 +1,45 @@
import {Injectable} from "@angular/core";
import {CookieService} from "ngx-cookie-service";
import {LoginResponse} from "../model/login-response";
import {BehaviorSubject, Observable} from "rxjs";
const COOKIE_JWT = 'jwt';
const COOKIE_REFRESH_TOKEN = 'refreshToken';
@Injectable({
providedIn: 'root'
})
export class AuthenticationService {
private authenticationSubject: BehaviorSubject<boolean>;
constructor(
private cookieService: CookieService
) {
const isAuthenticated = this.isAuthenticated();
this.authenticationSubject = new BehaviorSubject<boolean>(isAuthenticated);
}
get isAuthenticated$(): Observable<boolean> {
return this.authenticationSubject.asObservable();
}
setAuthenticated(loginResponse: LoginResponse): void {
const jwt = loginResponse.accessToken;
this.cookieService.set(COOKIE_JWT, jwt);
const refreshToken = loginResponse.refreshToken;
this.cookieService.set(COOKIE_REFRESH_TOKEN, refreshToken);
this.authenticationSubject.next(true);
}
setAnonymous(): void {
this.cookieService.delete(COOKIE_JWT);
this.cookieService.delete(COOKIE_REFRESH_TOKEN);
this.authenticationSubject.next(false);
}
isAuthenticated(): boolean {
const jwt = this.cookieService.get(COOKIE_JWT);
return jwt?.length > 0;
}
}

View File

@@ -0,0 +1,15 @@
import {inject, Injectable} from "@angular/core";
import {MatSnackBar} from "@angular/material/snack-bar";
const MESSAGE_DURATION = 5000;
@Injectable({
providedIn: 'root'
})
export class MessageService {
private matSnackBar = inject(MatSnackBar);
display(message: string): void {
this.matSnackBar.open(message, 'Close', { duration: MESSAGE_DURATION });
}
}

View File

@@ -0,0 +1,11 @@
<a class="title" routerLink="/">SportsHub</a>
<div id="menu">
<a routerLink="/login" *ngIf="(isAuthenticated$ | async) === false" class="btn">
<mat-icon>login</mat-icon>
Login
</a>
<a routerLink="/logout" *ngIf="isAuthenticated$ | async" class="btn logout">
<mat-icon>logout</mat-icon>
Logout
</a>
</div>

View File

@@ -0,0 +1,34 @@
:host {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: .5em 1em;
background-color: #004680;
.title {
font-size: 2em;
text-decoration: none;
display: flex;
justify-content: center;
align-items: center;
padding: .5em;
color: white;
}
#menu {
display: flex;
flex-direction: row;
gap: 1em;
justify-content: center;
align-items: center;
a {
flex: 0 1;
gap: .5em;
&.logout {
background-color: #c20000;
}
}
}
}

View File

@@ -0,0 +1,28 @@
import {Component} from "@angular/core";
import {RouterLink} from "@angular/router";
import {AuthenticationService} from "../core/services/authentication.service";
import {Observable} from "rxjs";
import {AsyncPipe, NgIf} from "@angular/common";
import {MatIconModule} from "@angular/material/icon";
@Component({
selector: 'app-header',
standalone: true,
templateUrl: './header.component.html',
imports: [
RouterLink,
AsyncPipe,
NgIf,
MatIconModule
],
styleUrls: ['./header.component.scss']
})
export class HeaderComponent {
isAuthenticated$: Observable<boolean>;
constructor(
private authenticationService: AuthenticationService
) {
this.isAuthenticated$ = this.authenticationService.isAuthenticated$;
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SportshubGui</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

View File

@@ -0,0 +1,26 @@
/* You can add global styles to this file, and also import other style files */
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
.shadowed {
box-shadow: 0 3px 1px -2px #0003,0 2px 2px #00000024,0 1px 5px #0000001f;
}
.btn {
background-color: #008cff;
color: white;
border: none;
border-radius: .4em;
display: flex;
justify-content: center;
align-items: center;
padding: .5em 1em;
text-decoration: none;
}
.input {
padding: .5em;
border-radius: .4em;
border: 1px solid #d2d2d2
}

View File

@@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,32 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -25,5 +25,17 @@
<groupId>org.springframework</groupId> <groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId> <artifactId>spring-context</artifactId>
</dependency> </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> </dependencies>
</project> </project>

View File

@@ -0,0 +1,11 @@
package org.sportshub.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.sportshub.infrastructure")
@EntityScan("org.sportshub.infrastructure")
public class JpaConfiguration {
}

View File

@@ -1,44 +0,0 @@
package org.sportshub.infrastructure.user.adapter;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.sportshub.domain.user.model.UserRole.ADMIN;
import static org.sportshub.domain.user.model.UserRole.STANDARD;
import org.sportshub.domain.user.model.User;
import org.sportshub.domain.user.port.UserPort;
import org.springframework.stereotype.Component;
@Component
public class UserInMemoryAdapter implements UserPort {
private static final List<User> users = List.of(
new User(
UUID.fromString("c1a0805f-c618-47dc-bae7-bee70503644e"),
"$2a$10$WPuLOKpvaQnMotNo5ijPwegBPwmMF1C04XkTNCBpeBFo4r2YJWy.2",
List.of(STANDARD)
),
new User(
UUID.fromString("4eff194d-dd8e-463e-974f-034bfd509f84"),
"$2a$10$WPuLOKpvaQnMotNo5ijPwegBPwmMF1C04XkTNCBpeBFo4r2YJWy.2",
List.of(STANDARD)
),
new User(
UUID.fromString("c78d7d7c-0386-415d-86dc-98a470591e07"),
"$2a$10$WPuLOKpvaQnMotNo5ijPwegBPwmMF1C04XkTNCBpeBFo4r2YJWy.2",
List.of(STANDARD, ADMIN)
)
);
@Override
public Optional<User> findById(final UUID userId) {
return users.stream()
.filter(user -> userId.equals(user.id()))
.findFirst();
}
@Override
public List<User> findAll() {
return users;
}
}

View File

@@ -0,0 +1,71 @@
package org.sportshub.infrastructure.user.adapter;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.sportshub.domain.user.model.RefreshToken;
import org.sportshub.domain.user.model.User;
import org.sportshub.domain.user.port.UserPort;
import org.sportshub.infrastructure.user.model.RefreshTokenEntity;
import org.sportshub.infrastructure.user.model.UserEntity;
import org.sportshub.infrastructure.user.repository.RefreshTokenJpaRepository;
import org.sportshub.infrastructure.user.repository.UserJpaRepository;
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.sportshub.infrastructure.user.model;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.sportshub.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.sportshub.infrastructure.user.model;
import java.util.List;
import java.util.UUID;
import org.sportshub.domain.user.model.User;
import org.sportshub.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.sportshub.infrastructure.user.repository;
import java.util.Optional;
import java.util.UUID;
import org.sportshub.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.sportshub.infrastructure.user.repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.sportshub.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 sportshub_db
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE USER sportshub_user
WITH PASSWORD 'password'
NOCREATEDB;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES
IN SCHEMA public
TO sportshub_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);

View File

@@ -1,7 +1,10 @@
application: application:
security: security:
jwt:
secretKey: "secret-key" secretKey: "secret-key"
tokenExpirationDelayInMinutes: 30 expirationDelayInMinutes: 30
refreshToken:
expirationDelayInDays: 7
logging: logging:
level: level:
@@ -12,3 +15,10 @@ server:
whitelabel: whitelabel:
enabled: false # Disable html error responses. enabled: false # Disable html error responses.
include-stacktrace: never include-stacktrace: never
spring:
datasource:
driverClassName: org.postgresql.Driver
url: jdbc:postgresql://localhost:50001/sportshub_db
username: sportshub_user
password: password