commit 494b73188525d624dfa32e9ccb5958fa00281f48 Author: Florian THIERRY Date: Fri Mar 8 13:42:28 2024 +0100 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d2334c --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/codiki-application/pom.xml b/codiki-application/pom.xml new file mode 100644 index 0000000..1e17c96 --- /dev/null +++ b/codiki-application/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + + org.codiki + codiki-parent + 0.0.1-SNAPSHOT + + + + codiki-application + + codiki-application + Demo project for Spring Boot + + jar + + + + org.codiki + codiki-domain + + + org.springframework + spring-context + + + org.springframework.boot + spring-boot-starter-security + + + com.auth0 + java-jwt + + + diff --git a/codiki-application/src/main/java/org/codiki/application/configuration/ServiceConfiguration.java b/codiki-application/src/main/java/org/codiki/application/configuration/ServiceConfiguration.java new file mode 100644 index 0000000..a3e308e --- /dev/null +++ b/codiki-application/src/main/java/org/codiki/application/configuration/ServiceConfiguration.java @@ -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(); + } +} diff --git a/codiki-application/src/main/java/org/codiki/application/security/AuthenticationFacade.java b/codiki-application/src/main/java/org/codiki/application/security/AuthenticationFacade.java new file mode 100644 index 0000000..7b7aff0 --- /dev/null +++ b/codiki-application/src/main/java/org/codiki/application/security/AuthenticationFacade.java @@ -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(); + } +} diff --git a/codiki-application/src/main/java/org/codiki/application/security/CustomUserDetailsService.java b/codiki-application/src/main/java/org/codiki/application/security/CustomUserDetailsService.java new file mode 100644 index 0000000..5388457 --- /dev/null +++ b/codiki-application/src/main/java/org/codiki/application/security/CustomUserDetailsService.java @@ -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); + } + } +} diff --git a/codiki-application/src/main/java/org/codiki/application/security/JwtService.java b/codiki-application/src/main/java/org/codiki/application/security/JwtService.java new file mode 100644 index 0000000..44d304a --- /dev/null +++ b/codiki-application/src/main/java/org/codiki/application/security/JwtService.java @@ -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(); + } +} diff --git a/codiki-application/src/main/java/org/codiki/application/security/annotation/AllowedToAdmins.java b/codiki-application/src/main/java/org/codiki/application/security/annotation/AllowedToAdmins.java new file mode 100644 index 0000000..9a4d57f --- /dev/null +++ b/codiki-application/src/main/java/org/codiki/application/security/annotation/AllowedToAdmins.java @@ -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 { +} diff --git a/codiki-application/src/main/java/org/codiki/application/security/annotation/AllowedToAnonymous.java b/codiki-application/src/main/java/org/codiki/application/security/annotation/AllowedToAnonymous.java new file mode 100644 index 0000000..e31d37d --- /dev/null +++ b/codiki-application/src/main/java/org/codiki/application/security/annotation/AllowedToAnonymous.java @@ -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 { +} diff --git a/codiki-application/src/main/java/org/codiki/application/security/model/CustomUserDetails.java b/codiki-application/src/main/java/org/codiki/application/security/model/CustomUserDetails.java new file mode 100644 index 0000000..ecf7871 --- /dev/null +++ b/codiki-application/src/main/java/org/codiki/application/security/model/CustomUserDetails.java @@ -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 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; + } +} diff --git a/codiki-application/src/main/java/org/codiki/application/user/UserUseCases.java b/codiki-application/src/main/java/org/codiki/application/user/UserUseCases.java new file mode 100644 index 0000000..18d9f3d --- /dev/null +++ b/codiki-application/src/main/java/org/codiki/application/user/UserUseCases.java @@ -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 findById(UUID userId) { + return userPort.findById(userId); + } + + @AllowedToAdmins + public List 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 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; + } +} diff --git a/codiki-domain/pom.xml b/codiki-domain/pom.xml new file mode 100644 index 0000000..d881081 --- /dev/null +++ b/codiki-domain/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + org.codiki + codiki-parent + 0.0.1-SNAPSHOT + + + + codiki-domain + + codiki-domain + Demo project for Spring Boot + + + + org.apache.maven.plugins + maven-compiler-plugin + + 16 + 16 + + + + + + jar + diff --git a/codiki-domain/src/main/java/org/codiki/domain/exception/FunctionnalException.java b/codiki-domain/src/main/java/org/codiki/domain/exception/FunctionnalException.java new file mode 100644 index 0000000..eed6d86 --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/exception/FunctionnalException.java @@ -0,0 +1,7 @@ +package org.codiki.domain.exception; + +public abstract class FunctionnalException extends RuntimeException { + public FunctionnalException(String message) { + super(message); + } +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/exception/LoginFailureException.java b/codiki-domain/src/main/java/org/codiki/domain/exception/LoginFailureException.java new file mode 100644 index 0000000..3533f0c --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/exception/LoginFailureException.java @@ -0,0 +1,7 @@ +package org.codiki.domain.exception; + +public class LoginFailureException extends FunctionnalException { + public LoginFailureException() { + super("Login or password incorrect."); + } +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/exception/RefreshTokenDoesNotExistException.java b/codiki-domain/src/main/java/org/codiki/domain/exception/RefreshTokenDoesNotExistException.java new file mode 100644 index 0000000..c0c184a --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/exception/RefreshTokenDoesNotExistException.java @@ -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)); + } +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/exception/RefreshTokenExpiredException.java b/codiki-domain/src/main/java/org/codiki/domain/exception/RefreshTokenExpiredException.java new file mode 100644 index 0000000..7808050 --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/exception/RefreshTokenExpiredException.java @@ -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)); + } +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/exception/UserDoesNotExistException.java b/codiki-domain/src/main/java/org/codiki/domain/exception/UserDoesNotExistException.java new file mode 100644 index 0000000..3281e61 --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/exception/UserDoesNotExistException.java @@ -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)); + } +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/model/RefreshToken.java b/codiki-domain/src/main/java/org/codiki/domain/user/model/RefreshToken.java new file mode 100644 index 0000000..264defa --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/user/model/RefreshToken.java @@ -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(); + } +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/model/User.java b/codiki-domain/src/main/java/org/codiki/domain/user/model/User.java new file mode 100644 index 0000000..ae42506 --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/user/model/User.java @@ -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 roles +) {} diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/model/UserAuthenticationData.java b/codiki-domain/src/main/java/org/codiki/domain/user/model/UserAuthenticationData.java new file mode 100644 index 0000000..57df8f4 --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/user/model/UserAuthenticationData.java @@ -0,0 +1,8 @@ +package org.codiki.domain.user.model; + +public record UserAuthenticationData( + String tokenType, + String accessToken, + RefreshToken refreshToken +) { +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/model/UserRole.java b/codiki-domain/src/main/java/org/codiki/domain/user/model/UserRole.java new file mode 100644 index 0000000..8b3406d --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/user/model/UserRole.java @@ -0,0 +1,6 @@ +package org.codiki.domain.user.model; + +public enum UserRole { + STANDARD, + ADMIN +} diff --git a/codiki-domain/src/main/java/org/codiki/domain/user/port/UserPort.java b/codiki-domain/src/main/java/org/codiki/domain/user/port/UserPort.java new file mode 100644 index 0000000..b6caa38 --- /dev/null +++ b/codiki-domain/src/main/java/org/codiki/domain/user/port/UserPort.java @@ -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 findById(UUID userId); + + List findAll(); + + void save(User user); + + boolean existsById(UUID userId); + + Optional findRefreshTokenByUserId(UUID userId); + + Optional findRefreshTokenById(UUID refreshTokenId); + + void save(RefreshToken refreshToken); +} diff --git a/codiki-exposition/pom.xml b/codiki-exposition/pom.xml new file mode 100644 index 0000000..dd17eec --- /dev/null +++ b/codiki-exposition/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + org.codiki + codiki-parent + 0.0.1-SNAPSHOT + + + + codiki-exposition + + codiki-exposition + Demo project for Spring Boot + + jar + + + + org.codiki + codiki-application + + + org.springframework.boot + spring-boot-starter-web + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/configuration/GlobalControllerExceptionHandler.java b/codiki-exposition/src/main/java/org/codiki/exposition/configuration/GlobalControllerExceptionHandler.java new file mode 100644 index 0000000..fbdaeb0 --- /dev/null +++ b/codiki-exposition/src/main/java/org/codiki/exposition/configuration/GlobalControllerExceptionHandler.java @@ -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. + } +} diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/JwtAuthenticationFilter.java b/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..8cf5451 --- /dev/null +++ b/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/JwtAuthenticationFilter.java @@ -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); + } +} diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/SecurityConfiguration.java b/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/SecurityConfiguration.java new file mode 100644 index 0000000..ab5fbf8 --- /dev/null +++ b/codiki-exposition/src/main/java/org/codiki/exposition/configuration/security/SecurityConfiguration.java @@ -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(); + } +} diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/healthcheck/HealthCheckController.java b/codiki-exposition/src/main/java/org/codiki/exposition/healthcheck/HealthCheckController.java new file mode 100644 index 0000000..5d6e3a8 --- /dev/null +++ b/codiki-exposition/src/main/java/org/codiki/exposition/healthcheck/HealthCheckController.java @@ -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"; + } +} diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/user/UserController.java b/codiki-exposition/src/main/java/org/codiki/exposition/user/UserController.java new file mode 100644 index 0000000..58a36a2 --- /dev/null +++ b/codiki-exposition/src/main/java/org/codiki/exposition/user/UserController.java @@ -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 findAll() { + return userUseCases.findAll(); + } + + @PostMapping("/refresh-token") + public LoginResponse refreshToken(@RequestBody RefreshTokenRequest request) { + UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.refreshTokenValue()); + return new LoginResponse(userAuthenticationData); + } +} diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginRequest.java b/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginRequest.java new file mode 100644 index 0000000..94fead2 --- /dev/null +++ b/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginRequest.java @@ -0,0 +1,8 @@ +package org.codiki.exposition.user.model; + +import java.util.UUID; + +public record LoginRequest( + UUID id, + String password +) {} diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginResponse.java b/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginResponse.java new file mode 100644 index 0000000..a111db4 --- /dev/null +++ b/codiki-exposition/src/main/java/org/codiki/exposition/user/model/LoginResponse.java @@ -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() + ); + } +} diff --git a/codiki-exposition/src/main/java/org/codiki/exposition/user/model/RefreshTokenRequest.java b/codiki-exposition/src/main/java/org/codiki/exposition/user/model/RefreshTokenRequest.java new file mode 100644 index 0000000..2e0c7ed --- /dev/null +++ b/codiki-exposition/src/main/java/org/codiki/exposition/user/model/RefreshTokenRequest.java @@ -0,0 +1,8 @@ +package org.codiki.exposition.user.model; + +import java.util.UUID; + +public record RefreshTokenRequest( + UUID refreshTokenValue +) { +} diff --git a/codiki-exposition/src/main/resources/application.yml b/codiki-exposition/src/main/resources/application.yml new file mode 100644 index 0000000..e69de29 diff --git a/codiki-infrastructure/pom.xml b/codiki-infrastructure/pom.xml new file mode 100644 index 0000000..9e183fc --- /dev/null +++ b/codiki-infrastructure/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + org.codiki + codiki-parent + 0.0.1-SNAPSHOT + + + + codiki-infrastructure + + codiki-infrastructure + Demo project for Spring Boot + + jar + + + + org.codiki + codiki-domain + + + org.springframework + spring-context + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.projectlombok + lombok + + + org.postgresql + postgresql + + + diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/configuration/JpaConfiguration.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/configuration/JpaConfiguration.java new file mode 100644 index 0000000..d75d941 --- /dev/null +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/configuration/JpaConfiguration.java @@ -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 { +} diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/adapter/UserJpaAdapter.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/adapter/UserJpaAdapter.java new file mode 100644 index 0000000..7b89494 --- /dev/null +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/adapter/UserJpaAdapter.java @@ -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 findById(UUID userId) { + return userJpaRepository.findById(userId) + .map(UserEntity::toUser); + } + + @Override + public List 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 findRefreshTokenByUserId(UUID userId) { + return refreshTokenJpaRepository.findByUserId(userId) + .map(RefreshTokenEntity::toRefreshToken); + } + + @Override + public Optional findRefreshTokenById(UUID refreshTokenId) { + return refreshTokenJpaRepository.findByValue(refreshTokenId) + .map(RefreshTokenEntity::toRefreshToken); + } + + @Override + public void save(RefreshToken refreshToken) { + RefreshTokenEntity refreshTokenEntity = new RefreshTokenEntity(refreshToken); + refreshTokenJpaRepository.save(refreshTokenEntity); + } +} diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/model/RefreshTokenEntity.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/model/RefreshTokenEntity.java new file mode 100644 index 0000000..3297e00 --- /dev/null +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/model/RefreshTokenEntity.java @@ -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); + } +} diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/model/UserEntity.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/model/UserEntity.java new file mode 100644 index 0000000..52c7aff --- /dev/null +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/model/UserEntity.java @@ -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 roles; + + public UserEntity(User user) { + id = user.id(); + password = user.password(); + roles = user.roles(); + } + + public User toUser() { + return new User( + id, + password, + roles + ); + } +} diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/RefreshTokenJpaRepository.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/RefreshTokenJpaRepository.java new file mode 100644 index 0000000..ec846fd --- /dev/null +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/RefreshTokenJpaRepository.java @@ -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 { + Optional findByUserId(UUID userId); + + Optional findByValue(UUID refreshTokenId); +} diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/UserJpaRepository.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/UserJpaRepository.java new file mode 100644 index 0000000..727bd51 --- /dev/null +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/user/repository/UserJpaRepository.java @@ -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 { + @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.id = :userId") + Optional findById(@Param("userId") UUID userId); + + @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles") + List findAll(); +} diff --git a/codiki-infrastructure/src/main/resources/sql/000-database-creation.sql b/codiki-infrastructure/src/main/resources/sql/000-database-creation.sql new file mode 100644 index 0000000..8c4c3e8 --- /dev/null +++ b/codiki-infrastructure/src/main/resources/sql/000-database-creation.sql @@ -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; diff --git a/codiki-infrastructure/src/main/resources/sql/001-initial-script-tables-creation.sql b/codiki-infrastructure/src/main/resources/sql/001-initial-script-tables-creation.sql new file mode 100644 index 0000000..fbe60ba --- /dev/null +++ b/codiki-infrastructure/src/main/resources/sql/001-initial-script-tables-creation.sql @@ -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); \ No newline at end of file diff --git a/codiki-launcher/pom.xml b/codiki-launcher/pom.xml new file mode 100644 index 0000000..affed10 --- /dev/null +++ b/codiki-launcher/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + org.codiki + codiki-parent + 0.0.1-SNAPSHOT + + + + codiki-launcher + + codiki-launcher + Demo project for Spring Boot + + jar + + + + org.codiki + codiki-exposition + + + org.codiki + codiki-application + + + org.codiki + codiki-domain + + + org.codiki + codiki-infrastructure + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/codiki-launcher/src/main/java/org/codiki/launcher/ApplicationLauncher.java b/codiki-launcher/src/main/java/org/codiki/launcher/ApplicationLauncher.java new file mode 100644 index 0000000..6c0b6f6 --- /dev/null +++ b/codiki-launcher/src/main/java/org/codiki/launcher/ApplicationLauncher.java @@ -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); + } +} diff --git a/codiki-launcher/src/main/resources/application.yml b/codiki-launcher/src/main/resources/application.yml new file mode 100644 index 0000000..3334012 --- /dev/null +++ b/codiki-launcher/src/main/resources/application.yml @@ -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 diff --git a/codiki-launcher/src/main/resources/banner.txt b/codiki-launcher/src/main/resources/banner.txt new file mode 100644 index 0000000..155c6aa --- /dev/null +++ b/codiki-launcher/src/main/resources/banner.txt @@ -0,0 +1,12 @@ + ####### + ############## + ################### + ######## ######### _________ .___.__ __ .__ +####### ############# \_ ___ \ ____ __| _/|__| | _|__| +###### ### ######## / \ \/ / _ \ / __ | | | |/ / | +####### ### ##### \ \___( <_> ) /_/ | | | <| | +######### ## ####### \______ /\____/\____ | |__|__|_ \__| + ######### ######## \/ \/ \/ + ################### + ############# + ####### \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..28a78de --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3447375 --- /dev/null +++ b/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + org.codiki + codiki-parent + 0.0.1-SNAPSHOT + + pom + + codiki + + + 21 + 21 + 21 + 6.0.0 + 4.4.0 + 42.7.0 + + + + codiki-domain + codiki-application + codiki-infrastructure + codiki-exposition + codiki-launcher + + + + + + org.springframework.boot + spring-boot-dependencies + 3.2.0 + pom + import + + + org.codiki + codiki-exposition + ${project.version} + + + org.codiki + codiki-application + ${project.version} + + + org.codiki + codiki-domain + ${project.version} + + + org.codiki + codiki-infrastructure + ${project.version} + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet-api.version} + + + com.auth0 + java-jwt + ${java-jwt.version} + + + org.postgresql + postgresql + ${postgresql.version} + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/rest-client-collection/Codiki/Users/Login.bru b/rest-client-collection/Codiki/Users/Login.bru new file mode 100644 index 0000000..c73dc28 --- /dev/null +++ b/rest-client-collection/Codiki/Users/Login.bru @@ -0,0 +1,11 @@ +meta { + name: Login + type: http + seq: 1 +} + +get { + url: + body: none + auth: none +} diff --git a/rest-client-collection/Codiki/bruno.json b/rest-client-collection/Codiki/bruno.json new file mode 100644 index 0000000..270688a --- /dev/null +++ b/rest-client-collection/Codiki/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "Codiki", + "type": "collection" +} \ No newline at end of file diff --git a/rest-client-collection/Codiki/environments/localhost.bru b/rest-client-collection/Codiki/environments/localhost.bru new file mode 100644 index 0000000..3c3d33a --- /dev/null +++ b/rest-client-collection/Codiki/environments/localhost.bru @@ -0,0 +1,3 @@ +vars { + url: http://localhost:8080 +}