diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17c085a..c8fab6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up JDK 21 + - name: Set up JDK 23 uses: actions/setup-java@v4 with: - java-version: '21' + java-version: '23' distribution: 'temurin' - name: Grant execute permission for gradlew diff --git a/.gitignore b/.gitignore index c1f7818..f904454 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ # Gradle .gradle/ build/ @@ -17,4 +54,4 @@ out/ # Env .env -src/main/resources/application-dev.properties \ No newline at end of file +src/main/resources/application-dev.properties diff --git a/build.gradle b/build.gradle index 8eaebe0..be376b9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.3.8' + id 'org.springframework.boot' version '4.0.3' id 'io.spring.dependency-management' version '1.1.7' } @@ -10,7 +10,7 @@ description = 'Backend' java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(23) } } @@ -19,18 +19,27 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - - developmentOnly 'org.springframework.boot:spring-boot-devtools' - runtimeOnly 'org.postgresql:postgresql' - + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-security-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' +// implementation 'org.springframework.boot:spring-boot-starter-web' +// testImplementation 'org.springframework.boot:spring-boot-starter-restclient-test' +// developmentOnly 'org.springframework.boot:spring-boot-devtools' + } tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/src/main/java/com/codzilla/backend/ExampleEndpoint.java b/src/main/java/com/codzilla/backend/ExampleEndpoint.java new file mode 100644 index 0000000..bee7df9 --- /dev/null +++ b/src/main/java/com/codzilla/backend/ExampleEndpoint.java @@ -0,0 +1,15 @@ +package com.codzilla.backend; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class ExampleEndpoint { + @GetMapping("/endpoint") + public ResponseEntity> endpoint() { + return ResponseEntity.ok(Map.of("message", "info")); + } +} diff --git a/src/main/java/com/codzilla/backend/auth/AdminAccessDeniedHandler.java b/src/main/java/com/codzilla/backend/auth/AdminAccessDeniedHandler.java new file mode 100644 index 0000000..a583451 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/AdminAccessDeniedHandler.java @@ -0,0 +1,24 @@ +package com.codzilla.backend.auth; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class AdminAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.getWriter().write("{\"error\": \"Forbidden\", \"message\": \"You don't have rights to access this page.\"}"); + } +} diff --git a/src/main/java/com/codzilla/backend/auth/AdminController/AdminController.java b/src/main/java/com/codzilla/backend/auth/AdminController/AdminController.java new file mode 100644 index 0000000..e8da761 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/AdminController/AdminController.java @@ -0,0 +1,30 @@ +package com.codzilla.backend.auth.AdminController; + +import com.codzilla.backend.auth.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin") +@PreAuthorize("hasAuthority('ADMIN')") +public class AdminController { + + private final UserService userService; + + public AdminController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/info") + String getAdminInfo() { + return "Some admin info."; + } + + @GetMapping("/users") + ResponseEntity getAllUsers() { + return ResponseEntity.ok(userService.getAllUsers()); + } +} diff --git a/src/main/java/com/codzilla/backend/auth/AuthController/AuthController.java b/src/main/java/com/codzilla/backend/auth/AuthController/AuthController.java new file mode 100644 index 0000000..20c056d --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/AuthController/AuthController.java @@ -0,0 +1,131 @@ +package com.codzilla.backend.auth.AuthController; + +import com.codzilla.backend.auth.JWTUtils.JWTUtils; +import com.codzilla.backend.auth.User; +import com.codzilla.backend.auth.UserService; +import com.codzilla.backend.auth.config.Settings; +import com.codzilla.backend.auth.dto.LoginRequestDTO; +import com.codzilla.backend.auth.dto.LoginResponseDTO; +import com.codzilla.backend.auth.dto.RegisterRequestDTO; +import com.codzilla.backend.auth.dto.RegisterResponseDTO; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/auth") +public class AuthController { + private final AuthenticationManager authManager; + private final JWTUtils jwtUtils; + private final Settings settings; + private final UserService userService; + + @Autowired + public AuthController(AuthenticationManager authManager, + JWTUtils jwtUtils, Settings settings, UserService userService) { + this.userService = userService; + this.authManager = authManager; + this.jwtUtils = jwtUtils; + this.settings = settings; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequestDTO request, HttpServletResponse response) { + log.info("Auth user by password..."); + log.info(request.email() + request.rawPassword()); + Authentication auth = authManager.authenticate( + new UsernamePasswordAuthenticationToken(request.email(), request.rawPassword()) + ); + + var accessToken = jwtUtils.generateAccessToken(auth); + Cookie jwtCookie = new Cookie("jwt", accessToken); + jwtCookie.setHttpOnly(true); + jwtCookie.setSecure(false); + jwtCookie.setPath("/"); + jwtCookie.setMaxAge((int) settings.getRefreshTokenTtl().toSeconds()); + response.addCookie(jwtCookie); + + var refreshToken = jwtUtils.generateRefreshToken(auth); + Cookie refreshCookie = new Cookie("refresh_jwt", refreshToken); + refreshCookie.setPath("/"); + refreshCookie.setHttpOnly(true); + refreshCookie.setMaxAge((int) settings.getRefreshTokenTtl().toSeconds()); + refreshCookie.setSecure(false); + response.addCookie(refreshCookie); + + User user = userService.getByEmail(request.email()); + return ResponseEntity.ok(new LoginResponseDTO(user.getNickname())); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletResponse response) { + Cookie cookie = new Cookie("jwt", null); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(0); + + response.addCookie(cookie); + + return ResponseEntity.ok("Logged out successfully"); + } + + + @PostMapping("/signup") + public ResponseEntity signUp(@RequestBody RegisterRequestDTO request) { + userService.registerUser(request); + return ResponseEntity.ok(new RegisterResponseDTO(request.nickname())); + } + + @PostMapping("/refresh") + public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = null; + if (request.getCookies() != null) { + for (var cookie : request.getCookies()) { + if ("refresh_jwt".equals(cookie.getName())) { + refreshToken = cookie.getValue(); + break; + } + } + + if (refreshToken == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("No token in cookie."); + if (!jwtUtils.validateToken(refreshToken)) + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Token is wrong."); + + String email = jwtUtils.getEmailFromToken(refreshToken); + + User user = userService.getByEmail(email); + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + + var accessToken = jwtUtils.generateAccessToken(auth); + Cookie cookie = new Cookie("jwt", accessToken); + cookie.setHttpOnly(true); + cookie.setSecure(false); + cookie.setPath("/"); + cookie.setMaxAge((int) settings.getRefreshTokenTtl().toSeconds()); + response.addCookie(cookie); + return ResponseEntity.ok("Jwt access was updated."); + + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("No cookie here."); + } + + @PostMapping("/create-admin") + void createAdmin() { + userService.createAdmin(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/codzilla/backend/auth/AuthExceptionHandler.java b/src/main/java/com/codzilla/backend/auth/AuthExceptionHandler.java new file mode 100644 index 0000000..e6daa8c --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/AuthExceptionHandler.java @@ -0,0 +1,32 @@ +package com.codzilla.backend.auth; + +import com.codzilla.backend.auth.Exceptions.RestException; +import com.codzilla.backend.auth.dto.ErrorResponseDTO; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import tools.jackson.databind.ObjectMapper; + +@RestControllerAdvice +public class AuthExceptionHandler { + @Autowired + ObjectMapper objectMapper; + + @ExceptionHandler(RestException.class) + public ResponseEntity handleUserNotFound(RestException exception) { + ErrorResponseDTO dto = new ErrorResponseDTO(exception); + return new ResponseEntity<>(objectMapper.writeValueAsString(dto), exception.getStatus()); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleWrongCredentials(BadCredentialsException exception){ + ErrorResponseDTO dto = new ErrorResponseDTO( + "Wrong email or password", + HttpStatus.UNAUTHORIZED + ); + return new ResponseEntity<>(objectMapper.writeValueAsString(dto), HttpStatus.UNAUTHORIZED); + } +} diff --git a/src/main/java/com/codzilla/backend/auth/Exceptions/RestException.java b/src/main/java/com/codzilla/backend/auth/Exceptions/RestException.java new file mode 100644 index 0000000..9dfba70 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/Exceptions/RestException.java @@ -0,0 +1,14 @@ +package com.codzilla.backend.auth.Exceptions; + +import lombok.Data; +import org.springframework.http.HttpStatus; + +@Data +public class RestException extends RuntimeException { + HttpStatus status; + + RestException(HttpStatus status, String message) { + super(message); + this.status = status; + } +} diff --git a/src/main/java/com/codzilla/backend/auth/Exceptions/UserAlreadyExistsException.java b/src/main/java/com/codzilla/backend/auth/Exceptions/UserAlreadyExistsException.java new file mode 100644 index 0000000..3017e3d --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/Exceptions/UserAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.codzilla.backend.auth.Exceptions; + +import org.springframework.http.HttpStatus; + +public class UserAlreadyExistsException extends RestException { + public UserAlreadyExistsException() { + super(HttpStatus.CONFLICT, "User already exists"); + } +} diff --git a/src/main/java/com/codzilla/backend/auth/Exceptions/UserNotFoundException.java b/src/main/java/com/codzilla/backend/auth/Exceptions/UserNotFoundException.java new file mode 100644 index 0000000..a22ff43 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/Exceptions/UserNotFoundException.java @@ -0,0 +1,9 @@ +package com.codzilla.backend.auth.Exceptions; + +import org.springframework.http.HttpStatus; + +public class UserNotFoundException extends RestException { + public UserNotFoundException() { + super(HttpStatus.CONFLICT, "User not found"); + } +} diff --git a/src/main/java/com/codzilla/backend/auth/Exceptions/UsernameIsTakenException.java b/src/main/java/com/codzilla/backend/auth/Exceptions/UsernameIsTakenException.java new file mode 100644 index 0000000..c9ba336 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/Exceptions/UsernameIsTakenException.java @@ -0,0 +1,9 @@ +package com.codzilla.backend.auth.Exceptions; + +import org.springframework.http.HttpStatus; + +public class UsernameIsTakenException extends RestException { + public UsernameIsTakenException() { + super(HttpStatus.CONFLICT, "Username is taken"); + } +} diff --git a/src/main/java/com/codzilla/backend/auth/HttpStatusEntryPoint.java b/src/main/java/com/codzilla/backend/auth/HttpStatusEntryPoint.java new file mode 100644 index 0000000..bf3ebb9 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/HttpStatusEntryPoint.java @@ -0,0 +1,22 @@ +package com.codzilla.backend.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class HttpStatusEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"You are not logged in.\"}"); + } +} \ No newline at end of file diff --git a/src/main/java/com/codzilla/backend/auth/JWTRequestFilter/JWTRequestFilter.java b/src/main/java/com/codzilla/backend/auth/JWTRequestFilter/JWTRequestFilter.java new file mode 100644 index 0000000..962276a --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/JWTRequestFilter/JWTRequestFilter.java @@ -0,0 +1,73 @@ +package com.codzilla.backend.auth.JWTRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +import com.codzilla.backend.auth.JWTUtils.JWTUtils; + +@Slf4j +@Component +public class JWTRequestFilter extends OncePerRequestFilter { + JWTUtils jwtUtils; + + public JWTRequestFilter(JWTUtils jwtUtils) { + this.jwtUtils = jwtUtils; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + log.info("In filter."); + String token = null; + if (request.getCookies() != null) { + for (var cookie : request.getCookies()) { + log.info("Cookie: " + cookie.getName()); + if ("jwt".equals(cookie.getName())) { + token = cookie.getValue(); + break; + } + } + } + if (token != null) { + + log.info("Got token."); + try { + String username = jwtUtils.getEmailFromToken(token); + List roles = jwtUtils.getRolesFromToken(token); + log.info("{} has jwt. His roles: {}", username, roles); + + List authorities = roles.stream() + .map(SimpleGrantedAuthority::new) + .toList(); + + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + username, + null, + authorities + ); + + + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception e) { + log.error("dont auth user"); + } + } else { + response.setStatus(401); + + } + + filterChain.doFilter(request, response); + + } +} diff --git a/src/main/java/com/codzilla/backend/auth/JWTUtils/JWTUtils.java b/src/main/java/com/codzilla/backend/auth/JWTUtils/JWTUtils.java new file mode 100644 index 0000000..f790117 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/JWTUtils/JWTUtils.java @@ -0,0 +1,81 @@ +package com.codzilla.backend.auth.JWTUtils; + +import com.codzilla.backend.auth.config.Settings; +import io.jsonwebtoken.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +@Component +public class JWTUtils { + + private static SecretKey secret = Jwts.SIG.HS256.key().build(); + private final Settings settings; + + public JWTUtils(Settings settings) { + this.settings = settings; + } + + public String generateAccessToken(Authentication authentication) { + List roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .filter(role -> !role.equals("FACTOR_PASSWORD")) + .toList(); + + return Jwts.builder() + .subject(authentication.getName()) + .claim("roles", roles) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + settings.getAccessTokenTtl().toMillis())) + .signWith(secret) + .compact(); + } + + public String generateRefreshToken(Authentication authentication) { + return Jwts.builder() + .subject(authentication.getName()) + .setId(UUID.randomUUID().toString()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + settings.getRefreshTokenTtl().toMillis())) + .signWith(secret) + .compact(); + } + + public List getRolesFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(secret) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims.get("roles", List.class); + } + + public String getEmailFromToken(String token) { + return Jwts.parser() + .verifyWith(secret) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secret) + .build() + .parseSignedClaims(token); + return true; + } catch (Exception e) { + return false; + } + + } +} + diff --git a/src/main/java/com/codzilla/backend/auth/Repository/UserRepository.java b/src/main/java/com/codzilla/backend/auth/Repository/UserRepository.java new file mode 100644 index 0000000..84f5dce --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/Repository/UserRepository.java @@ -0,0 +1,12 @@ +package com.codzilla.backend.auth.Repository; + +import com.codzilla.backend.auth.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + boolean existsByNickname(String nickname); + Optional findByEmail(String email); +} diff --git a/src/main/java/com/codzilla/backend/auth/RepositoryUserDetailsService.java b/src/main/java/com/codzilla/backend/auth/RepositoryUserDetailsService.java new file mode 100644 index 0000000..4284e93 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/RepositoryUserDetailsService.java @@ -0,0 +1,27 @@ +package com.codzilla.backend.auth; + +import com.codzilla.backend.auth.Repository.UserRepository; +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 RepositoryUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + public RepositoryUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var user = userRepository.findByEmail(username); + + if (user.isPresent()) { + return user.get(); + } else { + throw new UsernameNotFoundException("There is no user with email: " + username); + } + } +} diff --git a/src/main/java/com/codzilla/backend/auth/User.java b/src/main/java/com/codzilla/backend/auth/User.java new file mode 100644 index 0000000..4e56aad --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/User.java @@ -0,0 +1,59 @@ +package com.codzilla.backend.auth; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + + +import java.util.Collection; +import java.util.List; + +@Data +@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "users") +public class User implements UserDetails, CredentialsContainer { + + private String nickname; + private String password; + private String email; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Builder.Default + private List authorities = AuthorityUtils.createAuthorityList("USER"); + + @Override + public void eraseCredentials() { + + } + + @Override + public Collection getAuthorities() { + if (this.authorities == null) { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + return this.authorities; + } + @Override + public @Nullable String getPassword() { + return password; + } + + // returns email + public String getUsername() { + return email; + } +} diff --git a/src/main/java/com/codzilla/backend/auth/UserService.java b/src/main/java/com/codzilla/backend/auth/UserService.java new file mode 100644 index 0000000..7265901 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/UserService.java @@ -0,0 +1,79 @@ +package com.codzilla.backend.auth; + +import com.codzilla.backend.auth.Exceptions.UserAlreadyExistsException; +import com.codzilla.backend.auth.Exceptions.UserNotFoundException; +import com.codzilla.backend.auth.Exceptions.UsernameIsTakenException; +import com.codzilla.backend.auth.Repository.UserRepository; +import com.codzilla.backend.auth.dto.RegisterRequestDTO; +import com.codzilla.backend.auth.dto.UserResponseDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; +@Slf4j +@Service +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + public void registerUser(RegisterRequestDTO dto) throws UserAlreadyExistsException { + if (userRepository.existsByEmail(dto.email())) { + throw new UserAlreadyExistsException(); + } + + if (userRepository.existsByNickname(dto.nickname())) { + throw new UsernameIsTakenException(); + } + assert (!dto.email().equals(dto.nickname())); + var user = User.builder() + .email(dto.email()) + .nickname(dto.nickname()) + .password(passwordEncoder.encode(dto.rawPassword())).build(); + userRepository.save(user); + + } + + public User getByEmail(String email) { + var user = userRepository.findByEmail(email); + + if (user.isPresent()) { + log.info("username " + user.get().getNickname()); + return user.get(); + } else { + throw new UserNotFoundException(); + } + } + + public List getAllUsers() { + return userRepository.findAll().stream() + .map(user -> new UserResponseDTO( + user.getNickname(), + user.getEmail(), + user.getId(), + user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority).toList())) + .toList(); + } + + + public void createAdmin() { + if (!userRepository.existsByNickname("a")) { + User admin = User.builder() + .nickname("a") + .email("a@gmail.com") + .password(passwordEncoder.encode("0")) + .authorities(List.of(new SimpleGrantedAuthority("ADMIN"))) + .build(); + userRepository.save(admin); + } + } + +} diff --git a/src/main/java/com/codzilla/backend/auth/config/PasswordConfig.java b/src/main/java/com/codzilla/backend/auth/config/PasswordConfig.java new file mode 100644 index 0000000..1e60b7f --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/config/PasswordConfig.java @@ -0,0 +1,15 @@ +package com.codzilla.backend.auth.config; + + +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 PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + +} diff --git a/src/main/java/com/codzilla/backend/auth/config/SecurityConfig.java b/src/main/java/com/codzilla/backend/auth/config/SecurityConfig.java new file mode 100644 index 0000000..f004316 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/config/SecurityConfig.java @@ -0,0 +1,67 @@ +package com.codzilla.backend.auth.config; + +import com.codzilla.backend.auth.AdminAccessDeniedHandler; +import com.codzilla.backend.auth.HttpStatusEntryPoint; +import com.codzilla.backend.auth.JWTRequestFilter.JWTRequestFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import tools.jackson.databind.ObjectMapper; + +import java.util.Arrays; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + private static final String[] WHITELIST = {"/auth/**", "/error"}; + + + private final HttpStatusEntryPoint unauthorizedHandler; + private final AdminAccessDeniedHandler adminAccessDeniedHandler; + + public SecurityConfig(HttpStatusEntryPoint unauthorizedHandler, AdminAccessDeniedHandler adminAccessDeniedHandler) { + this.unauthorizedHandler = unauthorizedHandler; + this.adminAccessDeniedHandler = adminAccessDeniedHandler; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, JWTRequestFilter filter) throws Exception { + return http + .cors(cors -> cors.configurationSource(request -> { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(Arrays.asList("http://localhost:5173")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(Arrays.asList("*")); + config.setAllowCredentials(true); + return config; + })) + .csrf(csrf -> csrf.disable()) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(unauthorizedHandler) + .accessDeniedHandler(adminAccessDeniedHandler) + ) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.requestMatchers(WHITELIST).permitAll().anyRequest().authenticated()) + .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class).build(); + } + + @Bean + public AuthenticationManager authManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/codzilla/backend/auth/config/Settings.java b/src/main/java/com/codzilla/backend/auth/config/Settings.java new file mode 100644 index 0000000..80ee5a7 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/config/Settings.java @@ -0,0 +1,15 @@ +package com.codzilla.backend.auth.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +@ConfigurationProperties(prefix = "app.security.jwt") +@Data +public class Settings { + private Duration accessTokenTtl; + private Duration refreshTokenTtl; +} diff --git a/src/main/java/com/codzilla/backend/auth/dto/ErrorResponseDTO.java b/src/main/java/com/codzilla/backend/auth/dto/ErrorResponseDTO.java new file mode 100644 index 0000000..bb3391d --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/dto/ErrorResponseDTO.java @@ -0,0 +1,16 @@ +package com.codzilla.backend.auth.dto; + +import com.codzilla.backend.auth.Exceptions.RestException; +import org.springframework.http.HttpStatus; + +public record ErrorResponseDTO( + String message, + HttpStatus status +) { + public ErrorResponseDTO(RestException exception) { + this( + exception.getMessage(), + exception.getStatus() + ); + } +} diff --git a/src/main/java/com/codzilla/backend/auth/dto/LoginRequestDTO.java b/src/main/java/com/codzilla/backend/auth/dto/LoginRequestDTO.java new file mode 100644 index 0000000..a73ec03 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/dto/LoginRequestDTO.java @@ -0,0 +1,7 @@ +package com.codzilla.backend.auth.dto; + +public record LoginRequestDTO( + String email, + String rawPassword +) { +} diff --git a/src/main/java/com/codzilla/backend/auth/dto/LoginResponseDTO.java b/src/main/java/com/codzilla/backend/auth/dto/LoginResponseDTO.java new file mode 100644 index 0000000..42a90c3 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/dto/LoginResponseDTO.java @@ -0,0 +1,6 @@ +package com.codzilla.backend.auth.dto; + +public record LoginResponseDTO( + String nickname +) { +} diff --git a/src/main/java/com/codzilla/backend/auth/dto/RegisterRequestDTO.java b/src/main/java/com/codzilla/backend/auth/dto/RegisterRequestDTO.java new file mode 100644 index 0000000..c21768a --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/dto/RegisterRequestDTO.java @@ -0,0 +1,8 @@ +package com.codzilla.backend.auth.dto; + +public record RegisterRequestDTO( + String nickname, + String email, + String rawPassword +) { +} diff --git a/src/main/java/com/codzilla/backend/auth/dto/RegisterResponseDTO.java b/src/main/java/com/codzilla/backend/auth/dto/RegisterResponseDTO.java new file mode 100644 index 0000000..9597a96 --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/dto/RegisterResponseDTO.java @@ -0,0 +1,6 @@ +package com.codzilla.backend.auth.dto; + +public record RegisterResponseDTO( + String nickname +) { +} diff --git a/src/main/java/com/codzilla/backend/auth/dto/UserResponseDTO.java b/src/main/java/com/codzilla/backend/auth/dto/UserResponseDTO.java new file mode 100644 index 0000000..50faeee --- /dev/null +++ b/src/main/java/com/codzilla/backend/auth/dto/UserResponseDTO.java @@ -0,0 +1,11 @@ +package com.codzilla.backend.auth.dto; + +import java.util.List; + +public record UserResponseDTO( + String nickname, + String email, + Long id, + List authorities +) { +} diff --git a/src/main/java/com/codzilla/backend/controller/Coffee.java b/src/main/java/com/codzilla/backend/controller/Coffee.java deleted file mode 100644 index 01c16ba..0000000 --- a/src/main/java/com/codzilla/backend/controller/Coffee.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.codzilla.backend.controller; - - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; - -import java.util.UUID; - -@Entity -public class Coffee { - - - @Id - private String id ; - private String name; - public void setId(String id) { - this.id = id; - } - - - @JsonCreator - public Coffee(@JsonProperty("id") String id, @JsonProperty("name") String name) { - this.id = id; - this.name = name; - } - - public Coffee() { - this.id = UUID.randomUUID().toString(); - this.name = "DEFAULT"; - - } - - public Coffee(String name) { - this(UUID.randomUUID().toString() , name) ; - } - - public String getId() { - return id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } -} diff --git a/src/main/java/com/codzilla/backend/controller/DataLoader.java b/src/main/java/com/codzilla/backend/controller/DataLoader.java deleted file mode 100644 index dad4e90..0000000 --- a/src/main/java/com/codzilla/backend/controller/DataLoader.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.codzilla.backend.controller; - -import com.codzilla.backend.repository.CoffeeRepository; -import jakarta.annotation.PostConstruct; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Component -public class DataLoader { - public final CoffeeRepository coffeeRepository; - - public DataLoader(CoffeeRepository coffeeRepository) { - this.coffeeRepository = coffeeRepository; - } - - @PostConstruct - private void loadData() { - coffeeRepository.saveAll(List.of( - new Coffee("Café Cereza"), - new Coffee("Café Ganador"), - new Coffee("Café Lareño"), - new Coffee("Café Três Pontas") - )); - } -} diff --git a/src/main/java/com/codzilla/backend/controller/RestApiDemoController.java b/src/main/java/com/codzilla/backend/controller/RestApiDemoController.java deleted file mode 100644 index e7c43a8..0000000 --- a/src/main/java/com/codzilla/backend/controller/RestApiDemoController.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.codzilla.backend.controller; - -import com.codzilla.backend.repository.CoffeeRepository; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Optional; - -@RestController -@RequestMapping("/coffee") -public class RestApiDemoController { - - private final CoffeeRepository coffeeRepository; -// private final List coffees = new ArrayList<>(); - - public RestApiDemoController(CoffeeRepository coffeeRepository) { - this.coffeeRepository = coffeeRepository; - - this.coffeeRepository.saveAll(List.of( - new Coffee("Café Cereza"), - new Coffee("Café Ganador"), - new Coffee("Café Lareño"), - new Coffee("Café Três Pontas") - )); - } - - - - @RequestMapping(value = "", method = RequestMethod.GET) - Iterable getCoffees() { - return coffeeRepository.findAll(); - } - - @GetMapping("/{id}") - Optional getCoffee(@PathVariable String id) { - return coffeeRepository.findById(id); - } - - @PostMapping - Coffee postCoffee(@RequestBody Coffee coffee) { - return coffeeRepository.save(coffee); - } - -// @PutMapping("/{id}") -// Coffee putCoffee(@PathVariable String id, @RequestBody Coffee coffee) { -// int coffeeIndex = -1; -// for (Coffee c: coffees) { -// if (c.getId().equals(id)) { -// coffeeIndex = coffees.indexOf(c); -// coffees.set(coffeeIndex, coffee); -// } -// } -// return (coffeeIndex == -1) ? postCoffee(coffee) : coffee; -// } - -// @PutMapping("/{id}") -// ResponseEntity putCoffee(@PathVariable String id, -// @RequestBody Coffee coffee) { -// int coffeeIndex = -1; -// for (Coffee c: coffees) { -// if (c.getId().equals(id)) { -// coffeeIndex = coffees.indexOf(c); -// coffees.set(coffeeIndex, coffee); -// } -// } -// return (coffeeIndex == -1) ? -// new ResponseEntity<>(postCoffee(coffee), HttpStatus.CREATED) : -// new ResponseEntity<>(coffee, HttpStatus.OK); -// } - - @PutMapping("/{id}") - ResponseEntity putCoffee(@PathVariable String id , @RequestBody Coffee coffee){ - return (!coffeeRepository.existsById(id)) ? - new ResponseEntity<>(coffeeRepository.save(coffee) , HttpStatus.CREATED) : - new ResponseEntity<>(coffeeRepository.save(coffee) , HttpStatus.OK); - - } - - @DeleteMapping("/{id}") - void deleteCoffee(@PathVariable String id) { - coffeeRepository.deleteById(id); - } -} diff --git a/src/main/java/com/codzilla/backend/repository/CoffeeRepository.java b/src/main/java/com/codzilla/backend/repository/CoffeeRepository.java deleted file mode 100644 index 5039e88..0000000 --- a/src/main/java/com/codzilla/backend/repository/CoffeeRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.codzilla.backend.repository; - -import com.codzilla.backend.controller.Coffee; -import org.springframework.data.repository.CrudRepository; - -public interface CoffeeRepository extends CrudRepository { -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7060d81..fa8a802 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,18 @@ +spring.application.name=AuthService +app.security.jwt.access-token-ttl=10s +app.security.jwt.refresh-token-ttl=60s + +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} + +#spring.datasource.url=jdbc:postgresql://localhost:5433/testingdb +#spring.datasource.username=myuser +#spring.datasource.password=secret + +spring.jpa.hibernate.ddl-auto=update spring.application.name=Backend -spring.jpa.show-sql=true \ No newline at end of file +spring.jpa.show-sql=true + + +logging.level.org.springframework.security=DEBUG \ No newline at end of file diff --git a/src/test/java/com/codzilla/backend/auth/BaseIntegrationTest.java b/src/test/java/com/codzilla/backend/auth/BaseIntegrationTest.java new file mode 100644 index 0000000..b778d13 --- /dev/null +++ b/src/test/java/com/codzilla/backend/auth/BaseIntegrationTest.java @@ -0,0 +1,24 @@ +package com.codzilla.backend.auth; + +import com.codzilla.backend.auth.Repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.ObjectMapper; + + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class BaseIntegrationTest { + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @Autowired + protected UserRepository userRepository; +} diff --git a/src/test/java/com/codzilla/backend/auth/JwtIntegrationTest.java b/src/test/java/com/codzilla/backend/auth/JwtIntegrationTest.java new file mode 100644 index 0000000..d4a4442 --- /dev/null +++ b/src/test/java/com/codzilla/backend/auth/JwtIntegrationTest.java @@ -0,0 +1,149 @@ +package com.codzilla.backend.auth; + + +import com.codzilla.backend.auth.JWTUtils.JWTUtils; +import com.codzilla.backend.auth.config.Settings; +import com.codzilla.backend.auth.dto.LoginRequestDTO; +import com.codzilla.backend.auth.dto.RegisterRequestDTO; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.Duration; +import java.util.List; +import java.util.Timer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; + +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +public class JwtIntegrationTest extends BaseIntegrationTest { + + @Autowired + JWTUtils jwtUtils; + + @Autowired + Settings settings; + + @BeforeEach + void setUp() throws Exception { + RegisterRequestDTO registerRequestDTO = new RegisterRequestDTO("nick", "email", "password"); + mockMvc.perform(post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequestDTO))) + .andExpect(status().isOk()); + } + + @Test + void testGetAccessWithJwt() throws Exception { + + RegisterRequestDTO registerRequestDTO = new RegisterRequestDTO("nick", "email", "password"); + LoginRequestDTO loginRequestDTO = new LoginRequestDTO( + registerRequestDTO.email(), + registerRequestDTO.rawPassword() + ); + + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDTO))) + .andExpect(status().isOk()) + .andExpect(cookie().exists("jwt")) + .andExpect(cookie().exists("refresh_jwt")) + .andReturn(); + + var JwtCookie = result.getResponse().getCookie("jwt"); + String token = JwtCookie.getValue(); + assertThat(jwtUtils.getEmailFromToken(token)).isEqualTo("email"); + assertThat(jwtUtils.getRolesFromToken(token)).isEqualTo(List.of("USER")); + + mockMvc.perform(get("/endpoint") + .cookie(JwtCookie) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequestDTO))) + .andExpect(status().isOk()); + } + + @Test + void testExpirationToken() throws Exception { + settings.setAccessTokenTtl(Duration.ZERO); + RegisterRequestDTO registerRequestDTO = new RegisterRequestDTO("nick", "email", "password"); + LoginRequestDTO loginRequestDTO = new LoginRequestDTO( + registerRequestDTO.email(), + registerRequestDTO.rawPassword() + ); + + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDTO))) + .andExpect(status().isOk()) + .andExpect(cookie().exists("jwt")) + .andExpect(cookie().exists("refresh_jwt")) + .andReturn(); + + var JwtCookie = result.getResponse().getCookie("jwt"); + + mockMvc.perform(get("/endpoint") + .cookie(JwtCookie) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequestDTO))) + .andExpect(status().is(401)); + } + + @Test + void testTokenShouldExpiredAndRefreshed() throws Exception { + settings.setAccessTokenTtl(Duration.ofMillis(1000)); + RegisterRequestDTO registerRequestDTO = new RegisterRequestDTO("nick", "email", "password"); + LoginRequestDTO loginRequestDTO = new LoginRequestDTO( + registerRequestDTO.email(), + registerRequestDTO.rawPassword() + ); + + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDTO))) + .andExpect(status().isOk()) + .andExpect(cookie().exists("jwt")) + .andExpect(cookie().exists("refresh_jwt")) + .andReturn(); + + var JwtAccessCookie = result.getResponse().getCookie("jwt"); + var JwtRefreshCookie = result.getResponse().getCookie("refresh_jwt"); + + + mockMvc.perform(get("/endpoint") + .cookie(JwtAccessCookie) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequestDTO))) + .andExpect(status().isOk()); + Thread.sleep(1000); + mockMvc.perform(get("/endpoint") + .cookie(JwtAccessCookie) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequestDTO))) + .andExpect(status().is(401)); + + MvcResult refresh = mockMvc.perform(post("/auth/refresh") + .cookie(JwtRefreshCookie)) + .andExpect(status().isOk()) + .andExpect(cookie().exists("jwt")) + .andReturn(); + + var newJwtAccessCookie = refresh.getResponse().getCookie("jwt"); + mockMvc.perform(get("/endpoint") + .cookie(newJwtAccessCookie) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequestDTO))) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/codzilla/backend/auth/LoginIntegrationTest.java b/src/test/java/com/codzilla/backend/auth/LoginIntegrationTest.java new file mode 100644 index 0000000..3619919 --- /dev/null +++ b/src/test/java/com/codzilla/backend/auth/LoginIntegrationTest.java @@ -0,0 +1,57 @@ +package com.codzilla.backend.auth; + +import com.codzilla.backend.auth.JWTUtils.JWTUtils; +import com.codzilla.backend.auth.dto.LoginRequestDTO; +import com.codzilla.backend.auth.dto.LoginResponseDTO; +import com.codzilla.backend.auth.dto.RegisterRequestDTO; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +public class LoginIntegrationTest extends BaseIntegrationTest { + + @Autowired + JWTUtils jwtUtils; + + @Test + void testSimpleLogin() throws Exception { + RegisterRequestDTO registerRequestDTO = new RegisterRequestDTO("nick", "email", "password"); + mockMvc.perform(post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequestDTO))) + .andExpect(status().isOk()); + + LoginRequestDTO loginRequestDTO = new LoginRequestDTO( + registerRequestDTO.email(), + registerRequestDTO.rawPassword() + ); + + LoginResponseDTO expectedResponse = new LoginResponseDTO(registerRequestDTO.nickname()); + + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDTO))) + .andExpect(status().isOk()) + .andExpect(content().json( + objectMapper.writeValueAsString(expectedResponse) + )) + .andExpect(cookie().exists("jwt")) + .andExpect(cookie().exists("refresh_jwt")) + .andReturn(); + + } +} diff --git a/src/test/java/com/codzilla/backend/auth/RegisterIntegrationTest.java b/src/test/java/com/codzilla/backend/auth/RegisterIntegrationTest.java new file mode 100644 index 0000000..e66df76 --- /dev/null +++ b/src/test/java/com/codzilla/backend/auth/RegisterIntegrationTest.java @@ -0,0 +1,71 @@ +package com.codzilla.backend.auth; + +import com.codzilla.backend.auth.Exceptions.UserAlreadyExistsException; +import com.codzilla.backend.auth.dto.ErrorResponseDTO; +import com.codzilla.backend.auth.dto.RegisterRequestDTO; +import com.codzilla.backend.auth.dto.RegisterResponseDTO; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +public class RegisterIntegrationTest extends BaseIntegrationTest { + + @Autowired + PasswordEncoder passwordEncoder; + + @Test + public void testSignUpSimple() throws Exception { + RegisterRequestDTO request = new RegisterRequestDTO("user", "email", "password"); + + RegisterResponseDTO expectedResponse = new RegisterResponseDTO("user"); + + mockMvc.perform( + post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(expectedResponse))); + + assert (userRepository.existsByEmail(request.email())); + assert (userRepository.existsByNickname(request.nickname())); + User addedUser = userRepository.findByEmail(request.email()).get(); + assert (addedUser.getAuthorities().stream().allMatch(s -> { + return "USER".equals(s.getAuthority()); + })); + assertThat(passwordEncoder.matches( + request.rawPassword(), + addedUser.getPassword() + )); + } + + @Test + public void testUserAlreadyExists() throws Exception { + RegisterRequestDTO request1 = new RegisterRequestDTO("user1", "email", "password"); + RegisterRequestDTO request2 = new RegisterRequestDTO("user2", "email", "password"); + + ErrorResponseDTO expectedError = new ErrorResponseDTO(new UserAlreadyExistsException()); + + mockMvc.perform( + post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request1))); + + mockMvc.perform(post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request2))) + .andExpect(status().isConflict()) + .andExpect(content().json(objectMapper.writeValueAsString(expectedError))); + } +} diff --git a/src/test/java/com/codzilla/backend/auth/RoleAccessIntegrationTest.java b/src/test/java/com/codzilla/backend/auth/RoleAccessIntegrationTest.java new file mode 100644 index 0000000..a3b5e97 --- /dev/null +++ b/src/test/java/com/codzilla/backend/auth/RoleAccessIntegrationTest.java @@ -0,0 +1,100 @@ +package com.codzilla.backend.auth; + + +import com.codzilla.backend.auth.dto.LoginRequestDTO; +import com.codzilla.backend.auth.dto.RegisterRequestDTO; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +public class RoleAccessIntegrationTest extends BaseIntegrationTest { + + @BeforeEach + void setUp() throws Exception { + RegisterRequestDTO user = new RegisterRequestDTO("nick", "email", "password"); + mockMvc.perform(post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(user))) + .andExpect(status().isOk()); + + LoginRequestDTO loginRequestDTO = new LoginRequestDTO( + user.email(), + user.rawPassword() + ); + + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDTO))) + .andExpect(status().isOk()) + .andExpect(cookie().exists("jwt")) + .andExpect(cookie().exists("refresh_jwt")) + .andReturn(); + + var accessCookie = result.getResponse().getCookie("jwt"); + + + mockMvc.perform(post("/auth/create-admin") + .contentType(MediaType.APPLICATION_JSON) + .cookie(accessCookie)) + .andExpect(status().isOk()); + } + + @Test + void testUserCantUseAdminEndpoint () throws Exception { + RegisterRequestDTO user = new RegisterRequestDTO("nick", "email", "password"); + LoginRequestDTO loginRequestDTO = new LoginRequestDTO( + user.email(), + user.rawPassword() + ); + + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDTO))) + .andExpect(status().isOk()) + .andExpect(cookie().exists("jwt")) + .andExpect(cookie().exists("refresh_jwt")) + .andReturn(); + + var accessCookie = result.getResponse().getCookie("jwt"); + + mockMvc.perform(get("/admin/users") + .cookie(accessCookie)) + .andExpect(status().is(403)); + + } + + @Test + void testAdminCanUseAdminMethod() throws Exception { + RegisterRequestDTO admin = new RegisterRequestDTO("admin", "a@gmail.com", "0"); + LoginRequestDTO loginRequestDTO = new LoginRequestDTO( + admin.email(), + admin.rawPassword() + ); + + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDTO))) + .andExpect(status().isOk()) + .andExpect(cookie().exists("jwt")) + .andExpect(cookie().exists("refresh_jwt")) + .andReturn(); + + var accessCookie = result.getResponse().getCookie("jwt"); + + mockMvc.perform(get("/admin/users") + .cookie(accessCookie)) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/codzilla/backend/controller/CoffeeControllerTest.java b/src/test/java/com/codzilla/backend/controller/CoffeeControllerTest.java deleted file mode 100644 index 7be3348..0000000 --- a/src/test/java/com/codzilla/backend/controller/CoffeeControllerTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.codzilla.backend.controller; - -import com.codzilla.backend.repository.CoffeeRepository; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class CoffeeControllerTest { - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private CoffeeRepository coffeeRepository; - - @BeforeEach - void setUp() { - coffeeRepository.deleteAll(); - } - - @Test - void getCoffees_shouldReturnList() { - coffeeRepository.save(new Coffee("Espresso")); - coffeeRepository.save(new Coffee("Latte")); - - ResponseEntity response = restTemplate.getForEntity("/coffee", Coffee[].class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).hasSize(2); - } - - @Test - void getCoffee_shouldReturnCoffee_whenExists() { - Coffee saved = coffeeRepository.save(new Coffee("Cappuccino")); - - ResponseEntity response = restTemplate.getForEntity("/coffee/" + saved.getId(), Coffee.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - Assertions.assertNotNull(response.getBody()); - assertThat(response.getBody().getName()).isEqualTo("Cappuccino"); - } - - @Test - void postCoffee_shouldCreateAndReturnCoffee() { - Coffee coffee = new Coffee("Mocha"); - - ResponseEntity response = restTemplate.postForEntity("/coffee", coffee, Coffee.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - Assertions.assertNotNull(response.getBody()); - assertThat(response.getBody().getName()).isEqualTo("Mocha"); - } - - @Test - void deleteCoffee_shouldRemoveCoffee() { - Coffee saved = coffeeRepository.save(new Coffee("ToDelete")); - - restTemplate.delete("/coffee/" + saved.getId()); - - assertThat(coffeeRepository.findById(saved.getId())).isEmpty(); - } -} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 4d29b3c..525401f 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,7 +1,13 @@ + spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 -spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + spring.jpa.hibernate.ddl-auto=create-drop -spring.jpa.show-sql=true \ No newline at end of file +spring.jpa.show-sql=true + +app.security.jwt.access-token-ttl=10m +app.security.jwt.refresh-token-ttl=7d + +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect