From 35cddde2016f2bd34041d3b6d2a5df0bb7f84fe2 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Tue, 19 Aug 2025 11:25:05 +0900 Subject: [PATCH 01/26] feat: add API documentation for movie registration endpoint --- docs/specs/api/movie_register.md | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/specs/api/movie_register.md diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md new file mode 100644 index 0000000..eac4c71 --- /dev/null +++ b/docs/specs/api/movie_register.md @@ -0,0 +1,57 @@ +### 요청 + +- 메서드: `POST` +- 경로: `/api/movie` +- 헤더 + + ``` + Content-Type: application/json + Authorization: Bearer + ``` + +- 본문 + + ```json + + ``` + + +- curl 명령 예시 + + ```bash + curl -i -X POST 'http://localhost:8080/api/movie' \ + -H 'Authentication: <>' \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "인셉션", + "director": "크리스토퍼 놀란", + "runtimeMinutes": 148, + "genre": "SF", + "releaseDate": "2010-07-21", + "rating": "12세 관람가", + "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "posterUrl": "https://example.com/posters/inception.jpg", + "cast": [ + "레오나르도 디카프리오", + "조셉 고든레빗", + "엘렌 페이지" + ] + }' + ``` + +### 응답 + +- 상태코드: `200 OK` +- 본문 + + ```json + { + } + ``` + +### 테스트 + +- [ ] 올바른 요청을 보내면 status가 SUCCESS이다 +- [ ] title, director, runtimeMinutes, genre, releaseDate, rating은 비어있을 수 없다 +- [ ] runtimeMinutes은 0 이상이어야 한다 +- [ ] releaseDat는 ISO 8601 양식을 준수한다 From 1b75d05fb52d7dcbd69da66958f223eee57a1a68 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 21 Aug 2025 12:43:31 +0900 Subject: [PATCH 02/26] feat: add documentation on Spring Security filter registration process --- docs/devlog/250821.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/devlog/250821.md diff --git a/docs/devlog/250821.md b/docs/devlog/250821.md new file mode 100644 index 0000000..8a1c434 --- /dev/null +++ b/docs/devlog/250821.md @@ -0,0 +1,11 @@ +## 예찬 +Spring Security의 filter **등록** 방식에 대한 이해가 부족하다고 느껴 실제로 등록하는 과정을 디버깅을 통해 확인해봤다. Spring Bea으로 등록된 filter는 기본적으로 servlet context에 의해 filter chain에 등록되지만 상세한 filter 우선순위를 결정하는데에 불편함이 있다. 그래서 Spring Security는 해당 filter의 우선순위를 보다 명시적으로 지정하기 위해 `SecurityFilterChain`을 사용해 filter의 우선순위를 명시하고, `DelegatingFilterChainProxy`가 이를 적용해준다. 그 과정에서 bean등록이 된 filter를 `SecurityFilterChain`에 명시하게 된다면 이 과정이 두번 동작하기 때문에 등록 과정이 두번 실행된다. 헷갈리지 말아야 할 개념은 실제 bean filter를 `DelegatingFilterChainProxy`가 두개 등록할 수는 없다.(애초에 Spring Singleton Bean이라서 인스턴스가 하나밖에 없기도 하다.) 다만,`DelegatingFilterChainProxy`가 `ApplicationContext`에서 빈을 가져올때 filter의 `beanName`으로 인스턴스를 레퍼런싱 하다보니 중복 등록을 제재할 방법은 없는것이다. 하지만 filter를 직접 적용하는 과정에서 동일 bean임이 인식되기 때문에 실질적으로 동작하는 인스턴스는 한번뿐인 것이다. 정리하자면 등록할 filter을 bean으로 등록하게 되면 자동으로 filter chain에 추가되기 때문에 가능하면 이 경우에는 `SecurityFilterChain`에 등록을 하지 말던가 아니면 bean으로 등록하지 않은 상태에서 `SecurityFilterChain`에 등록하도록 객체를 생성해야 중복 등록이 발생하지 않는다. + +## 휘동 +@Component로 filter를 스프링 빈으로 만들고, filter chain에 등록하면 filter가 중복 등록되는 것을 확인함. +filter를 빈으로 만들면 자동으로 servletfilterchain에 등록되는데, securityfilterchain에 빈을 주입받아 수동으로 등록하면서 중복됨. +실제 filter 동작은 한번만 일어나다. +GenericFilterBean을 상속받은 filter는 요청/응답 시 한번씩 총 2번 동작하고, +OncePerRequestFilter를 상속받은 filter는 요청에만 동작한다. +filter의 동작이 끝날 때 filterchain.doFilter()가 없으면 다음 필터(응답 시 동작해야할 필터 포함)는 작동하지 않는다. +filterchain.doFilter()의 유무로 필터의 중단을 결정하지 말고, GenericFilterBean와 OncePerRequestFilter를 적절히 사용해 필터 적용 시기를 조절함이 바람직하다. \ No newline at end of file From 0c5530f5cfb24bed1bd2042747a5fd5d2b6962b3 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 23 Aug 2025 12:10:53 +0900 Subject: [PATCH 03/26] feat: refactor package structure for web API components --- .../adapter/security/SecurityConfig.java | 58 +++++++++++++++++++ .../webapi/ApiResponse.java | 2 +- .../{infra => adapter}/webapi/ApiStatus.java | 2 +- .../webapi/AuthController.java | 8 +-- .../webapi/CommonHttpMessageConverter.java | 2 +- .../webapi/ErrorResponse.java | 2 +- .../webapi/GlobalExceptionHandler.java | 9 +-- .../webapi/MemberController.java | 6 +- .../webapi/ResponseWrapper.java | 2 +- .../webapi/SuccessResponse.java | 2 +- ...tTokenProvider.java => JwtTokenUtils.java} | 54 ++++++++--------- .../{TokenProvider.java => TokenUtils.java} | 4 +- .../persist/MemberCommandRepository.java | 2 +- .../persist/MemberJpaRepository.java | 4 +- .../persist/MemberQueryRepository.java | 2 +- .../dto => domain/member}/AuthRequest.java | 2 +- .../member}/MemberRegisterRequest.java | 2 +- .../member}/MemberRegisterResponse.java | 4 +- .../dto => domain/member}/ReissueRequest.java | 2 +- .../dto => domain/member}/TokenHolder.java | 2 +- .../infra/security/SecurityConfig.java | 31 ---------- 21 files changed, 114 insertions(+), 88 deletions(-) create mode 100644 src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/ApiResponse.java (86%) rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/ApiStatus.java (84%) rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/AuthController.java (78%) rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/CommonHttpMessageConverter.java (98%) rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/ErrorResponse.java (88%) rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/GlobalExceptionHandler.java (75%) rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/MemberController.java (77%) rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/ResponseWrapper.java (96%) rename src/main/java/org/mandarin/booking/{infra => adapter}/webapi/SuccessResponse.java (85%) rename src/main/java/org/mandarin/booking/app/{JwtTokenProvider.java => JwtTokenUtils.java} (93%) rename src/main/java/org/mandarin/booking/app/{TokenProvider.java => TokenUtils.java} (73%) rename src/main/java/org/mandarin/booking/{infra => app}/persist/MemberCommandRepository.java (90%) rename src/main/java/org/mandarin/booking/{infra => app}/persist/MemberJpaRepository.java (70%) rename src/main/java/org/mandarin/booking/{infra => app}/persist/MemberQueryRepository.java (94%) rename src/main/java/org/mandarin/booking/{infra/webapi/dto => domain/member}/AuthRequest.java (83%) rename src/main/java/org/mandarin/booking/{infra/webapi/dto => domain/member}/MemberRegisterRequest.java (92%) rename src/main/java/org/mandarin/booking/{infra/webapi/dto => domain/member}/MemberRegisterResponse.java (77%) rename src/main/java/org/mandarin/booking/{infra/webapi/dto => domain/member}/ReissueRequest.java (78%) rename src/main/java/org/mandarin/booking/{infra/webapi/dto => domain/member}/TokenHolder.java (60%) delete mode 100644 src/main/java/org/mandarin/booking/infra/security/SecurityConfig.java diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java new file mode 100644 index 0000000..db5740e --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -0,0 +1,58 @@ +package org.mandarin.booking.adapter.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.TokenUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + private final TokenUtils tokenUtils; + private final ObjectMapper objectMapper; + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationProvider preAuthProvider) + throws Exception { + AuthenticationManager manager = preAuthProvider::authenticate; + http + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.POST, "/api/members").permitAll() + .requestMatchers("/api/auth/login").permitAll() + .requestMatchers("/api/auth/reissue").permitAll() + .anyRequest().authenticated() + ) + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .authenticationManager(manager) + .authenticationProvider(preAuthProvider) + .addFilterBefore(new JwtFilter(tokenUtils,objectMapper), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + AuthenticationProvider preAuthProvider(UserDetailsService uds) { + var wrapper = new UserDetailsByNameServiceWrapper(uds); + var p = new PreAuthenticatedAuthenticationProvider(); + p.setPreAuthenticatedUserDetailsService(wrapper); + return p; + } +} diff --git a/src/main/java/org/mandarin/booking/infra/webapi/ApiResponse.java b/src/main/java/org/mandarin/booking/adapter/webapi/ApiResponse.java similarity index 86% rename from src/main/java/org/mandarin/booking/infra/webapi/ApiResponse.java rename to src/main/java/org/mandarin/booking/adapter/webapi/ApiResponse.java index 5653b4d..bcf8995 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/ApiResponse.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ApiResponse.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import java.time.LocalDateTime; import lombok.Getter; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/ApiStatus.java b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java similarity index 84% rename from src/main/java/org/mandarin/booking/infra/webapi/ApiStatus.java rename to src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java index 18d2b9d..82f4c28 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/ApiStatus.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; /** * Centralizes API status codes to achieve type-safety and remove string duplication. diff --git a/src/main/java/org/mandarin/booking/infra/webapi/AuthController.java b/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java similarity index 78% rename from src/main/java/org/mandarin/booking/infra/webapi/AuthController.java rename to src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java index 584aaa0..a940a93 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/AuthController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java @@ -1,10 +1,10 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; import org.mandarin.booking.app.port.AuthUseCase; -import org.mandarin.booking.infra.webapi.dto.AuthRequest; -import org.mandarin.booking.infra.webapi.dto.ReissueRequest; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.domain.member.AuthRequest; +import org.mandarin.booking.domain.member.ReissueRequest; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/CommonHttpMessageConverter.java b/src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java similarity index 98% rename from src/main/java/org/mandarin/booking/infra/webapi/CommonHttpMessageConverter.java rename to src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java index f9edfb3..4e57897 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/CommonHttpMessageConverter.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/ErrorResponse.java b/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java similarity index 88% rename from src/main/java/org/mandarin/booking/infra/webapi/ErrorResponse.java rename to src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java index ba0c0c9..ffc0bb3 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/ErrorResponse.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/GlobalExceptionHandler.java b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java similarity index 75% rename from src/main/java/org/mandarin/booking/infra/webapi/GlobalExceptionHandler.java rename to src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java index ac52ecc..f4f7089 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/GlobalExceptionHandler.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import static java.util.Objects.requireNonNull; +import static org.mandarin.booking.adapter.webapi.ApiStatus.*; import org.mandarin.booking.domain.DomainException; import org.mandarin.booking.domain.member.AuthException; @@ -13,17 +14,17 @@ public class GlobalExceptionHandler { @ExceptionHandler(DomainException.class) public ErrorResponse handleJsonParseError(DomainException ex) { - return new ErrorResponse(ApiStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + return new ErrorResponse(INTERNAL_SERVER_ERROR, ex.getMessage()); } @ExceptionHandler(AuthException.class) public ErrorResponse handleAuthException(AuthException ex) { - return new ErrorResponse(ApiStatus.UNAUTHORIZED, ex.getMessage()); + return new ErrorResponse(UNAUTHORIZED, ex.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public ErrorResponse handleValidationException(MethodArgumentNotValidException ex) { - return new ErrorResponse(ApiStatus.BAD_REQUEST, + return new ErrorResponse(BAD_REQUEST, requireNonNull(ex.getBindingResult().getFieldError()).getDefaultMessage()); } } diff --git a/src/main/java/org/mandarin/booking/infra/webapi/MemberController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java similarity index 77% rename from src/main/java/org/mandarin/booking/infra/webapi/MemberController.java rename to src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java index 30d5f49..6c037fc 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/MemberController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java @@ -1,9 +1,9 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterResponse; +import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.app.port.MemberRegisterer; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterRequest; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/ResponseWrapper.java b/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java similarity index 96% rename from src/main/java/org/mandarin/booking/infra/webapi/ResponseWrapper.java rename to src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java index 92fd94f..a423cbd 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/ResponseWrapper.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/SuccessResponse.java b/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java similarity index 85% rename from src/main/java/org/mandarin/booking/infra/webapi/SuccessResponse.java rename to src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java index df4bd36..30c2533 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/SuccessResponse.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/org/mandarin/booking/app/JwtTokenProvider.java b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java similarity index 93% rename from src/main/java/org/mandarin/booking/app/JwtTokenProvider.java rename to src/main/java/org/mandarin/booking/app/JwtTokenUtils.java index c6030f7..ad21bb6 100644 --- a/src/main/java/org/mandarin/booking/app/JwtTokenProvider.java +++ b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java @@ -9,14 +9,14 @@ import java.util.Date; import java.util.Map; import javax.crypto.SecretKey; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; import org.mandarin.booking.domain.member.AuthException; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component -public class JwtTokenProvider implements TokenProvider { +public class JwtTokenUtils implements TokenUtils { private static final String USER_ID = "userId"; private static final String NICK_NAME = "nickName"; @@ -48,6 +48,29 @@ public TokenHolder generateToken(String userId, String nickName) { return new TokenHolder(accessToken, refreshToken); } + @Override + public void validateToken(String token) { + try { + parseClaims(token); + } catch (IllegalArgumentException e) { + throw new AuthException("올바르지 않은 토큰입니다."); + } catch (JwtException e) { + throw new AuthException("토큰 검증에 실패했습니다."); + } + } + + @Override + public String getClaim(String token, String claimName) { + try { + Jws claims = parseClaims(token); + return claims.getPayload().get(claimName, String.class); + } catch (JwtException e) { + throw new AuthException("토큰에서 클레임을 추출하는 데 실패했습니다."); + } catch (IllegalArgumentException e) { + throw new AuthException("올바르지 않은 토큰입니다."); + } + } + private String generateTokenInternal(String userId, String nickName, long expiration) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); @@ -67,33 +90,10 @@ private String generateTokenInternal(String userId, String nickName, long expira .compact(); } - private Jws parseClaims(String refreshToken) { + private Jws parseClaims(String token) { return Jwts.parser() .verifyWith(key) .build() - .parseSignedClaims(refreshToken); - } - - @Override - public void validateToken(String token) { - try { - parseClaims(token); - } catch (IllegalArgumentException e) { - throw new AuthException("올바르지 않은 토큰입니다."); - } catch (JwtException e) { - throw new AuthException("토큰 검증에 실패했습니다."); - } - } - - @Override - public String getClaim(String token, String claimName) { - try { - Jws claims = parseClaims(token); - return claims.getPayload().get(claimName, String.class); - } catch (JwtException e) { - throw new AuthException("토큰에서 클레임을 추출하는 데 실패했습니다."); - } catch (IllegalArgumentException e) { - throw new AuthException("올바르지 않은 토큰입니다."); - } + .parseSignedClaims(token); } } diff --git a/src/main/java/org/mandarin/booking/app/TokenProvider.java b/src/main/java/org/mandarin/booking/app/TokenUtils.java similarity index 73% rename from src/main/java/org/mandarin/booking/app/TokenProvider.java rename to src/main/java/org/mandarin/booking/app/TokenUtils.java index f07a821..44a7bed 100644 --- a/src/main/java/org/mandarin/booking/app/TokenProvider.java +++ b/src/main/java/org/mandarin/booking/app/TokenUtils.java @@ -1,8 +1,8 @@ package org.mandarin.booking.app; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.domain.member.TokenHolder; -public interface TokenProvider { +public interface TokenUtils { TokenHolder generateToken(String refreshToken); TokenHolder generateToken(String userId, String nickName); diff --git a/src/main/java/org/mandarin/booking/infra/persist/MemberCommandRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java similarity index 90% rename from src/main/java/org/mandarin/booking/infra/persist/MemberCommandRepository.java rename to src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java index b0e8210..91bf118 100644 --- a/src/main/java/org/mandarin/booking/infra/persist/MemberCommandRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.persist; +package org.mandarin.booking.app.persist; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.member.Member; diff --git a/src/main/java/org/mandarin/booking/infra/persist/MemberJpaRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java similarity index 70% rename from src/main/java/org/mandarin/booking/infra/persist/MemberJpaRepository.java rename to src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java index 6be0f43..7383b23 100644 --- a/src/main/java/org/mandarin/booking/infra/persist/MemberJpaRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java @@ -1,10 +1,10 @@ -package org.mandarin.booking.infra.persist; +package org.mandarin.booking.app.persist; import java.util.Optional; import org.mandarin.booking.domain.member.Member; import org.springframework.data.jpa.repository.JpaRepository; -interface MemberJpaRepository extends JpaRepository { +public interface MemberJpaRepository extends JpaRepository { boolean existsByUserId(String userId); boolean existsByEmail(String email); diff --git a/src/main/java/org/mandarin/booking/infra/persist/MemberQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java similarity index 94% rename from src/main/java/org/mandarin/booking/infra/persist/MemberQueryRepository.java rename to src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java index b641c62..14cf061 100644 --- a/src/main/java/org/mandarin/booking/infra/persist/MemberQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.persist; +package org.mandarin.booking.app.persist; import java.util.Optional; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/AuthRequest.java b/src/main/java/org/mandarin/booking/domain/member/AuthRequest.java similarity index 83% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/AuthRequest.java rename to src/main/java/org/mandarin/booking/domain/member/AuthRequest.java index ca89340..eb604cc 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/AuthRequest.java +++ b/src/main/java/org/mandarin/booking/domain/member/AuthRequest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; +package org.mandarin.booking.domain.member; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java similarity index 92% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterRequest.java rename to src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java index 84bffd3..e52d587 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; +package org.mandarin.booking.domain.member; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterResponse.java b/src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java similarity index 77% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterResponse.java rename to src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java index 197763d..9f0f76b 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterResponse.java +++ b/src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java @@ -1,6 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; - -import org.mandarin.booking.domain.member.Member; +package org.mandarin.booking.domain.member; public record MemberRegisterResponse( String userId, diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/ReissueRequest.java b/src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java similarity index 78% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/ReissueRequest.java rename to src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java index a18835c..d6a1610 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/ReissueRequest.java +++ b/src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; +package org.mandarin.booking.domain.member; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/TokenHolder.java b/src/main/java/org/mandarin/booking/domain/member/TokenHolder.java similarity index 60% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/TokenHolder.java rename to src/main/java/org/mandarin/booking/domain/member/TokenHolder.java index 2e4b396..f91c42a 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/TokenHolder.java +++ b/src/main/java/org/mandarin/booking/domain/member/TokenHolder.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; +package org.mandarin.booking.domain.member; public record TokenHolder(String accessToken, String refreshToken) { } diff --git a/src/main/java/org/mandarin/booking/infra/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/infra/security/SecurityConfig.java deleted file mode 100644 index 638c44b..0000000 --- a/src/main/java/org/mandarin/booking/infra/security/SecurityConfig.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.mandarin.booking.infra.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -public class SecurityConfig { - @Bean - public BCryptPasswordEncoder bCryptPasswordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST,"/api/members").permitAll() - .requestMatchers("/api/auth/login").permitAll() - .requestMatchers("/api/auth/reissue").permitAll() - .requestMatchers("/test/**").permitAll() - .anyRequest().authenticated() - ) - .csrf(csrf -> csrf.disable()); - return http.build(); - } - -} From 9ea7971b004db7cdcf3f8d1b3d24dd9240e4c051 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 23 Aug 2025 12:11:05 +0900 Subject: [PATCH 04/26] feat: implement movie registration functionality and update related components --- build.gradle | 77 +++++------ docs/specs/api/login.md | 6 +- docs/specs/api/movie_register.md | 3 +- docs/specs/domain.md | 2 +- .../booking/adapter/ObjectMapperConfig.java | 29 +++++ .../booking/adapter/security/JwtFilter.java | 64 ++++++++++ .../org/mandarin/booking/app/AuthService.java | 18 +-- .../booking/app/MemberDetailsService.java | 25 ++++ .../booking/app/MemberRegisterValidator.java | 2 +- .../mandarin/booking/app/MemberService.java | 6 +- .../booking/app/port/AuthUseCase.java | 2 +- .../booking/app/port/MemberRegisterer.java | 4 +- .../booking/domain/member/Member.java | 7 + .../domain/member/MemberAuthority.java | 14 ++ .../member/MemberAuthorityConverter.java | 39 ++++++ .../booking/domain/member/MemberDetails.java | 32 +++++ .../mandarin/booking/domain/movie/Movie.java | 87 +++++++++++++ .../domain/movie/MovieRegisterRequest.java | 10 ++ .../booking/domain/movie/package-info.java | 3 + .../mandarin/booking/domain/package-info.java | 4 + src/main/resources/application-local.yml | 9 ++ .../booking/IntegrationTestUtils.java | 2 +- .../booking/IntegrationTestUtilsSpecs.java | 2 +- .../java/org/mandarin/booking/TestConfig.java | 2 +- .../mandarin/booking/TestOnlyController.java | 2 +- .../java/org/mandarin/booking/TestResult.java | 6 +- .../adapter/security/JwtFilterTest.java | 120 ++++++++++++++++++ .../arch/HexagonalArchitectureTest.java | 26 ++++ .../booking/webapi/auth/login/POST_specs.java | 12 +- .../webapi/auth/reissue/POST_specs.java | 38 +++--- .../booking/webapi/member/POST_specs.java | 6 +- .../booking/webapi/movie/POST_specs.java | 49 +++++++ 32 files changed, 610 insertions(+), 98 deletions(-) create mode 100644 src/main/java/org/mandarin/booking/adapter/ObjectMapperConfig.java create mode 100644 src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java create mode 100644 src/main/java/org/mandarin/booking/app/MemberDetailsService.java create mode 100644 src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java create mode 100644 src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java create mode 100644 src/main/java/org/mandarin/booking/domain/member/MemberDetails.java create mode 100644 src/main/java/org/mandarin/booking/domain/movie/Movie.java create mode 100644 src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java create mode 100644 src/main/java/org/mandarin/booking/domain/movie/package-info.java create mode 100644 src/main/java/org/mandarin/booking/domain/package-info.java create mode 100644 src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java create mode 100644 src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java create mode 100644 src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java diff --git a/build.gradle b/build.gradle index 83af410..0fcf8e0 100644 --- a/build.gradle +++ b/build.gradle @@ -23,48 +23,41 @@ configurations { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - implementation("org.projectlombok:lombok:1.18.36") - annotationProcessor('org.projectlombok:lombok:1.18.36') - - // Spring web - implementation 'org.springframework.boot:spring-boot-starter-web' - - // Spring data JPA - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - - //h2 database - testRuntimeOnly 'com.h2database:h2' - - developmentOnly 'org.springframework.boot:spring-boot-docker-compose' - - implementation 'com.mysql:mysql-connector-j:8.3.0' - - // Spring validation - implementation 'org.springframework.boot:spring-boot-starter-validation' - - // Spring security - implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.security:spring-security-test' - - // Mockito inline 사용 시 필요한 Byte Buddy Agent - byteBuddyAgent "net.bytebuddy:byte-buddy-agent:1.17.6" - - // spotbugs - spotbugs 'com.github.spotbugs:spotbugs:4.9.3' - - // p6spy - implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' - - // jjwt - implementation 'io.jsonwebtoken:jjwt-api:0.12.6' - implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' - testImplementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + // ---- Spring Boot Core ---- + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // ---- Data & Database ---- + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'com.mysql:mysql-connector-j:8.3.0' + testRuntimeOnly 'com.h2database:h2' + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + + // ---- Security & Auth ---- + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + testImplementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + + // ---- Lombok ---- + implementation 'org.projectlombok:lombok:1.18.36' + annotationProcessor 'org.projectlombok:lombok:1.18.36' + + // ---- Dev Only ---- + developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + + // ---- Code Quality / Tooling ---- + spotbugs 'com.github.spotbugs:spotbugs:4.9.3' + + // ---- Testing ---- + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + byteBuddyAgent 'net.bytebuddy:byte-buddy-agent:1.17.6' } tasks.named('test') { diff --git a/docs/specs/api/login.md b/docs/specs/api/login.md index fb8ce71..ad8291b 100644 --- a/docs/specs/api/login.md +++ b/docs/specs/api/login.md @@ -26,8 +26,8 @@ -H 'Content-Type: application/json' \ -d ' { - "userId": "string", - "password": "string" + "userId": "test1234", + "password": "myPassword123" }' ``` @@ -55,4 +55,4 @@ - [x] 성공적인 로그인 후 응답에 accessToken과 refreshToken가 포함되어야 한다 - [x] 전달된 토큰은 유효한 JWT 형식이어야 한다 - [x] 전달된 토큰은 만료되지 않아야한다 -- [x] 전달된 토큰에는 사용자의 올바른 userId가 포함되어야 한다 \ No newline at end of file +- [x] 전달된 토큰에는 사용자의 올바른 userId가 포함되어야 한다 diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index eac4c71..f4c6ed8 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -20,7 +20,7 @@ ```bash curl -i -X POST 'http://localhost:8080/api/movie' \ - -H 'Authentication: <>' \ + -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU1ODQ3MzY1LCJleHAiOjE3NTU4NDc5NjV9.qivq2xlrm8me6P0oSwFLfieubmtoUB44NTSp2idDRRLG2wWE4S_4nNMJyEbEwjwaxfHpYQdzOTw0uscvNJCoKQ' \ -H 'Content-Type: application/json' \ -d '{ "title": "인셉션", @@ -52,6 +52,7 @@ ### 테스트 - [ ] 올바른 요청을 보내면 status가 SUCCESS이다 +- [ ] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [ ] title, director, runtimeMinutes, genre, releaseDate, rating은 비어있을 수 없다 - [ ] runtimeMinutes은 0 이상이어야 한다 - [ ] releaseDat는 ISO 8601 양식을 준수한다 diff --git a/docs/specs/domain.md b/docs/specs/domain.md index a608486..a48cdaf 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -22,7 +22,7 @@ - 주연 배우(Cast): 영화에 출연한 주요 배우 목록. #### 행위 -- `register()`: 새로운 영화를 등록합니다. +- `create()`: 새로운 영화를 등록합니다. ### 영화관(Cinema) - 영화 상영 시설. diff --git a/src/main/java/org/mandarin/booking/adapter/ObjectMapperConfig.java b/src/main/java/org/mandarin/booking/adapter/ObjectMapperConfig.java new file mode 100644 index 0000000..91997a5 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/ObjectMapperConfig.java @@ -0,0 +1,29 @@ +package org.mandarin.booking.adapter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.TimeZone; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObjectMapperConfig { + @Bean + public ObjectMapper objectMapper() { + var objectMapper = new ObjectMapper(); + var module = new JavaTimeModule(); + var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter)); + + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Seoul")); + objectMapper.registerModule(module); + + return objectMapper; + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java new file mode 100644 index 0000000..7dd2c32 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java @@ -0,0 +1,64 @@ +package org.mandarin.booking.adapter.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.mandarin.booking.adapter.webapi.ApiStatus; +import org.mandarin.booking.adapter.webapi.ErrorResponse; +import org.mandarin.booking.app.TokenUtils; +import org.mandarin.booking.domain.member.AuthException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + private static final String PREFIX = "Bearer "; + private final TokenUtils tokenUtils; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (isNotBearer(header)) { + filterChain.doFilter(request, response); + return; + } + + String token = header.substring(PREFIX.length()); + + try { + tokenUtils.validateToken(token); + + var userId = tokenUtils.getClaim(token, "userId"); + var preAuthToken = new PreAuthenticatedAuthenticationToken(userId, null, null); + + SecurityContextHolder.getContext().setAuthentication(preAuthToken); + filterChain.doFilter(request, response); + } catch (AuthException e) { + log.error("Authentication Error: {}", e.getMessage()); + SecurityContextHolder.clearContext(); + responseErrorMessage(response, e); + } + } + + private void responseErrorMessage(HttpServletResponse response, AuthException e) throws IOException { + var errorResponse = new ErrorResponse(ApiStatus.UNAUTHORIZED, e.getMessage()); + String valueAsString = objectMapper.writeValueAsString(errorResponse); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getOutputStream().write(valueAsString.getBytes(StandardCharsets.UTF_8)); + } + + private boolean isNotBearer(String header) { + return header == null || !header.startsWith(PREFIX); + } +} diff --git a/src/main/java/org/mandarin/booking/app/AuthService.java b/src/main/java/org/mandarin/booking/app/AuthService.java index a7ce07e..fbddeb9 100644 --- a/src/main/java/org/mandarin/booking/app/AuthService.java +++ b/src/main/java/org/mandarin/booking/app/AuthService.java @@ -1,9 +1,9 @@ package org.mandarin.booking.app; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberQueryRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; -import org.mandarin.booking.infra.persist.MemberQueryRepository; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.domain.member.TokenHolder; import org.mandarin.booking.app.port.AuthUseCase; import org.mandarin.booking.domain.member.AuthException; import org.mandarin.booking.domain.member.Member; @@ -14,28 +14,28 @@ public class AuthService implements AuthUseCase { private final SecurePasswordEncoder securePasswordEncoder; private final MemberQueryRepository queryRepository; - private final TokenProvider tokenProvider; + private final TokenUtils tokenUtils; @Override public TokenHolder login(String userId, String password) { var member = getMember(userId); checkPasswordMatch(member, password); - return tokenProvider.generateToken(member.getUserId(), member.getNickName()); + return tokenUtils.generateToken(member.getUserId(), member.getNickName()); } @Override public TokenHolder reissue(String refreshToken) { - tokenProvider.validateToken(refreshToken); - var userId = tokenProvider.getClaim(refreshToken, "userId"); + tokenUtils.validateToken(refreshToken); + var userId = tokenUtils.getClaim(refreshToken, "userId"); if(!queryRepository.existsByUserId(userId)) - throw new AuthException("Member does not exist"); - return tokenProvider.generateToken(refreshToken); + throw new AuthException("회원이 존재하지 않습니다"); + return tokenUtils.generateToken(refreshToken); } private void checkPasswordMatch(Member member, String password) { if (!member.matchesPassword(password, securePasswordEncoder)) { - throw new AuthException("Invalid userId or password"); + throw new AuthException("잘못된 userID 또는 비밀번호"); } } diff --git a/src/main/java/org/mandarin/booking/app/MemberDetailsService.java b/src/main/java/org/mandarin/booking/app/MemberDetailsService.java new file mode 100644 index 0000000..00142bb --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/MemberDetailsService.java @@ -0,0 +1,25 @@ +package org.mandarin.booking.app; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberQueryRepository; +import org.mandarin.booking.domain.member.AuthException; +import org.springframework.security.core.userdetails.User; +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.Component; + +@Component +@RequiredArgsConstructor +public class MemberDetailsService implements UserDetailsService { + private final MemberQueryRepository queryRepository; + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var member = queryRepository.findByUserId(username) + .orElseThrow(() -> new AuthException(" 해당 아이디의 사용자를 찾을 수 없습니다: " + username)); + return User.builder() + .username(member.getUserId()) + .password("N/A") + .build(); + } +} diff --git a/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java b/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java index f688b1d..6986254 100644 --- a/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java +++ b/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java @@ -1,8 +1,8 @@ package org.mandarin.booking.app; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberQueryRepository; import org.mandarin.booking.domain.member.MemberException; -import org.mandarin.booking.infra.persist.MemberQueryRepository; import org.springframework.stereotype.Component; @Component diff --git a/src/main/java/org/mandarin/booking/app/MemberService.java b/src/main/java/org/mandarin/booking/app/MemberService.java index d6485b4..2322b34 100644 --- a/src/main/java/org/mandarin/booking/app/MemberService.java +++ b/src/main/java/org/mandarin/booking/app/MemberService.java @@ -1,11 +1,11 @@ package org.mandarin.booking.app; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterRequest; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterResponse; +import org.mandarin.booking.domain.member.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.app.port.MemberRegisterer; -import org.mandarin.booking.infra.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java b/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java index 475ace3..5be54c9 100644 --- a/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java +++ b/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java @@ -1,6 +1,6 @@ package org.mandarin.booking.app.port; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.domain.member.TokenHolder; public interface AuthUseCase { TokenHolder login(String userId, String password); diff --git a/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java b/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java index 53b4d10..99867cf 100644 --- a/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java +++ b/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java @@ -1,7 +1,7 @@ package org.mandarin.booking.app.port; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterRequest; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterResponse; +import org.mandarin.booking.domain.member.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterResponse; public interface MemberRegisterer { MemberRegisterResponse register(MemberRegisterRequest request); diff --git a/src/main/java/org/mandarin/booking/domain/member/Member.java b/src/main/java/org/mandarin/booking/domain/member/Member.java index 7618519..11aa500 100644 --- a/src/main/java/org/mandarin/booking/domain/member/Member.java +++ b/src/main/java/org/mandarin/booking/domain/member/Member.java @@ -1,6 +1,9 @@ package org.mandarin.booking.domain.member; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; +import java.util.ArrayList; +import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; @@ -18,6 +21,9 @@ public class Member extends AbstractEntity { private String email; + @Convert(converter = MemberAuthorityConverter.class) + private List authorities = new ArrayList<>(); + public static Member create(MemberCreateCommand command, SecurePasswordEncoder securePasswordEncoder) { var member = new Member(); @@ -25,6 +31,7 @@ public static Member create(MemberCreateCommand command, member.userId = command.userId(); member.passwordHash = securePasswordEncoder.encode(command.password()); member.email = command.email(); + member.authorities.add(MemberAuthority.USER); return member; } diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java b/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java new file mode 100644 index 0000000..09baec7 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java @@ -0,0 +1,14 @@ +package org.mandarin.booking.domain.member; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; + +@Getter +@AllArgsConstructor +public enum MemberAuthority implements GrantedAuthority { + USER("USER"), + DISTRIBUTOR("DISTRIBUTOR"), + ADMIN("ADMIN"); + private final String authority; +} diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java b/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java new file mode 100644 index 0000000..1ffe1df --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java @@ -0,0 +1,39 @@ +package org.mandarin.booking.domain.member; + + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Converter() +public class MemberAuthorityConverter implements AttributeConverter, String> { + + private static final String DELIM = ","; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute.isEmpty()) { + return ""; + } + return attribute.stream() + .map(MemberAuthority::name) + .collect(Collectors.joining(DELIM)); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData.isBlank()) { + return new ArrayList<>(); + } + + return Arrays.stream(dbData.split(DELIM)) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(MemberAuthority::valueOf) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + } +} diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java new file mode 100644 index 0000000..43a10a1 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java @@ -0,0 +1,32 @@ +package org.mandarin.booking.domain.member; + +import java.util.Collection; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public class MemberDetails implements UserDetails { + private final String userId; + private final String password; + private final Collection authorities; + + public MemberDetails(String userId, String password, Collection authorities) { + this.userId = userId; + this.password = password; + this.authorities = authorities; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return userId; + } +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/Movie.java b/src/main/java/org/mandarin/booking/domain/movie/Movie.java new file mode 100644 index 0000000..e9eef5c --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/Movie.java @@ -0,0 +1,87 @@ +package org.mandarin.booking.domain.movie; + +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import java.time.LocalDate; +import java.util.LinkedHashSet; +import java.util.Set; +import org.mandarin.booking.domain.AbstractEntity; + +@Entity +public class Movie extends AbstractEntity { + private String title; + + private String director; + + private Integer runtimeMinutes; + + @Enumerated(EnumType.STRING) + private Genre genre; + + @Enumerated(EnumType.STRING) + private Rating rating; + + private LocalDate releaseDate; + + private String synopsis; + + private String posterUrl; + + + private Set cast = new LinkedHashSet<>(); + + protected Movie() { + } + + private Movie(String title, + String director, + Integer runtimeMinutes, + Genre genre, + LocalDate releaseDate, + Rating rating, + String synopsis, + String posterUrl, + Set cast) { + + this.title = title; + this.director = director; + this.runtimeMinutes = runtimeMinutes; + this.genre = genre; + this.releaseDate = releaseDate; + this.rating = rating; + this.synopsis = synopsis; + this.posterUrl = posterUrl; + if (cast != null) { + this.cast.addAll(cast); + } + } + + public static Movie create(String title, + String director, + Integer runtimeMinutes, + Genre genre, + LocalDate releaseDate, + Rating rating, + @Nullable + String synopsis, + @Nullable + String posterUrl, + @Nullable + Set cast) { + if (runtimeMinutes < 0) { + throw new IllegalArgumentException("Runtime minutes cannot be negative");//TODO 2025 08 19 11:35:28 : custom exception + } + + return new Movie(title, director, runtimeMinutes, genre, releaseDate, rating, synopsis, posterUrl, cast); + } + + public enum Genre { + ACTION, DRAMA, COMEDY, THRILLER, ROMANCE, SF, FANTASY, HORROR, ANIMATION, DOCUMENTARY, ETC + } + + public enum Rating { + ALL, AGE12, AGE15, AGE18 + } +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java new file mode 100644 index 0000000..60d4862 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java @@ -0,0 +1,10 @@ +package org.mandarin.booking.domain.movie; + +import java.time.LocalDate; +import java.util.List; + +public record MovieRegisterRequest(String title, String director, int runtimeMinutes, String genre, + LocalDate releaseDate, + String rating, + String synopsis, String posterUrl, List casts) { +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/package-info.java b/src/main/java/org/mandarin/booking/domain/movie/package-info.java new file mode 100644 index 0000000..baa5f7e --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/package-info.java @@ -0,0 +1,3 @@ +@NonNullApi +package org.mandarin.booking.domain.movie; +import org.springframework.lang.NonNullApi; diff --git a/src/main/java/org/mandarin/booking/domain/package-info.java b/src/main/java/org/mandarin/booking/domain/package-info.java new file mode 100644 index 0000000..d524781 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package org.mandarin.booking.domain; + +import org.springframework.lang.NonNullApi; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 88af482..cd2f37d 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -11,8 +11,17 @@ spring: dialect: org.hibernate.dialect.MySQL8Dialect hibernate: ddl-auto: create + docker: + compose: + lifecycle-management: start_only jwt: token: secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGUtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGU access: 600000 refresh: 1800000 + +logging: + level: + org.springframework.boot.web.servlet: INFO + org.springframework.security.web.FilterChainProxy: TRACE + diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index fd6cd9a..786a0be 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import org.mandarin.booking.infra.persist.MemberCommandRepository; +import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java b/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java index 043f967..4438860 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java @@ -1,7 +1,7 @@ package org.mandarin.booking; import static org.assertj.core.api.Assertions.assertThat; -import static org.mandarin.booking.infra.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import java.util.HashMap; import java.util.Map; diff --git a/src/test/java/org/mandarin/booking/TestConfig.java b/src/test/java/org/mandarin/booking/TestConfig.java index 1864842..62488bf 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/src/test/java/org/mandarin/booking/TestConfig.java @@ -1,7 +1,7 @@ package org.mandarin.booking; import com.fasterxml.jackson.databind.ObjectMapper; -import org.mandarin.booking.infra.persist.MemberCommandRepository; +import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; diff --git a/src/test/java/org/mandarin/booking/TestOnlyController.java b/src/test/java/org/mandarin/booking/TestOnlyController.java index d77d153..ef14c46 100644 --- a/src/test/java/org/mandarin/booking/TestOnlyController.java +++ b/src/test/java/org/mandarin/booking/TestOnlyController.java @@ -1,7 +1,7 @@ package org.mandarin.booking; import java.util.Map; -import org.mandarin.booking.infra.persist.MemberQueryRepository; +import org.mandarin.booking.app.persist.MemberQueryRepository; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; diff --git a/src/test/java/org/mandarin/booking/TestResult.java b/src/test/java/org/mandarin/booking/TestResult.java index f460f1a..11e8e01 100644 --- a/src/test/java/org/mandarin/booking/TestResult.java +++ b/src/test/java/org/mandarin/booking/TestResult.java @@ -2,7 +2,7 @@ import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.fail; -import static org.mandarin.booking.infra.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; @@ -11,8 +11,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import org.mandarin.booking.infra.webapi.ErrorResponse; -import org.mandarin.booking.infra.webapi.SuccessResponse; +import org.mandarin.booking.adapter.webapi.ErrorResponse; +import org.mandarin.booking.adapter.webapi.SuccessResponse; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java new file mode 100644 index 0000000..f14ddd1 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -0,0 +1,120 @@ +package org.mandarin.booking.adapter.security; + +import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController; +import org.mandarin.booking.adapter.security.JwtFilterTest.TestSecurityConfig; +import org.mandarin.booking.app.TokenUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootTest +@AutoConfigureMockMvc +@Import({TestSecurityConfig.class, TestAuthController.class}) +class JwtFilterTest { + private static final String PONG_WITHOUT_AUTH = "pong without auth"; + private static final String PONG_WITH_AUTH = "pong with auth"; + + @Autowired + MockMvc mockMvc; + + + @Test + void withoutAuth() throws Exception { + // Act & Assert + mockMvc.perform(get("/test/without-auth")) + .andExpect(status().isOk()) + .andExpect(content().string(PONG_WITHOUT_AUTH)) + .andDo(print()); + } + + @Test + void withAuth(@Autowired TokenUtils tokenUtils) throws Exception { + var userId = generateUserId(); + var nickName = generateNickName(); + var accessToken = tokenUtils.generateToken(userId, nickName).accessToken(); + + mockMvc.perform(get("/test/without-auth") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(content().string(PONG_WITHOUT_AUTH)) + .andDo(print()); + } + + @Test + void failToAuth() throws Exception { + // Arrange + var invalidToken = "invalid token"; + + // Act & Assert + mockMvc.perform(get("/test/with-auth") + .header("Authorization", "Bearer " + invalidToken)) + .andExpect(status().isUnauthorized()) + .andExpect(content().json("{status: \"UNAUTHORIZED\"}")) + .andDo(print()); + } + + @RestController + @RequestMapping("/test") + static class TestAuthController { + @GetMapping("/without-auth") + public String ping() { + return PONG_WITHOUT_AUTH; + } + + @GetMapping("/with-auth") + public String pingWithAuth() { + return PONG_WITH_AUTH; + } + } + + @TestConfiguration + static + class TestSecurityConfig { + @Autowired + ObjectMapper objectMapper; + + @Bean(name = "testSecurityFilterChain") + @Order(1) + SecurityFilterChain testSecurityFilterChain(HttpSecurity http, + TokenUtils tokenUtils, + AuthenticationProvider preAuthProvider) throws Exception { + AuthenticationManager authManager = preAuthProvider::authenticate; + return http + .securityMatcher("/test/**") + .authorizeHttpRequests(auth -> auth + .requestMatchers("/test/without-auth").permitAll() + .anyRequest().authenticated() + ) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .authenticationProvider(preAuthProvider) + .authenticationManager(authManager) + .addFilterBefore(new JwtFilter(tokenUtils, objectMapper), UsernamePasswordAuthenticationFilter.class) + .build(); + } + } +} diff --git a/src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java b/src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java new file mode 100644 index 0000000..4dfec37 --- /dev/null +++ b/src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java @@ -0,0 +1,26 @@ +package org.mandarin.booking.arch; + + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.library.Architectures; + +@AnalyzeClasses(packages = "org.mandarin.booking", importOptions = ImportOption.DoNotIncludeTests.class) +public class HexagonalArchitectureTest { + + @ArchTest + void hexagonalArchitectureTest(JavaClasses classes) { + Architectures + .layeredArchitecture() + .consideringAllDependencies() + .layer("adapter").definedBy("..adapter..") + .layer("application").definedBy("..app..") + .layer("domain").definedBy("..domain..") + .whereLayer("adapter").mayNotBeAccessedByAnyLayer() + .whereLayer("application").mayOnlyBeAccessedByLayers("adapter") + .whereLayer("domain").mayOnlyBeAccessedByLayers("adapter", "application") + .check(classes); + } +} diff --git a/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java index ea8ff52..519eed0 100644 --- a/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java @@ -6,9 +6,9 @@ import static org.mandarin.booking.JwtTestUtils.getTokenClaims; import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; -import static org.mandarin.booking.infra.webapi.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.infra.webapi.ApiStatus.SUCCESS; -import static org.mandarin.booking.infra.webapi.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; import io.jsonwebtoken.security.Keys; import java.util.Date; @@ -19,9 +19,9 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.infra.persist.MemberQueryRepository; -import org.mandarin.booking.infra.webapi.dto.AuthRequest; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.app.persist.MemberQueryRepository; +import org.mandarin.booking.domain.member.AuthRequest; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; diff --git a/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java index 01315e9..7fb1570 100644 --- a/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java @@ -6,9 +6,9 @@ import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; -import static org.mandarin.booking.infra.webapi.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.infra.webapi.ApiStatus.SUCCESS; -import static org.mandarin.booking.infra.webapi.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; import io.jsonwebtoken.security.Keys; import java.util.Date; @@ -18,9 +18,9 @@ import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.app.TokenProvider; -import org.mandarin.booking.infra.webapi.dto.ReissueRequest; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.app.TokenUtils; +import org.mandarin.booking.domain.member.ReissueRequest; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; @@ -31,13 +31,13 @@ public class POST_specs { @Test void 올바른_refresh_token으로_요청하면_200을_응답한다( @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider + @Autowired TokenUtils tokenUtils ) { // Arrange var userId = generateUserId(); var nickName = generateNickName(); testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenProvider, userId, nickName); + var validRefreshToken = getValidRefreshToken(tokenUtils, userId, nickName); var request = new ReissueRequest(validRefreshToken); // Act @@ -54,13 +54,13 @@ public class POST_specs { @Test void 올바른_refresh_token으로_요청하면_새로운_access_token과_refresh_token을_발급해_응답한다( @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider + @Autowired TokenUtils tokenUtils ) { // Arrange var userId = generateUserId(); var nickName = generateNickName(); testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenProvider, userId, nickName); + var validRefreshToken = getValidRefreshToken(tokenUtils, userId, nickName); var request = new ReissueRequest(validRefreshToken); // Act @@ -79,7 +79,7 @@ public class POST_specs { @Test void 응답받은_access_toke과_refresh_toke은_유효한_JWT_형식이다( @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider, + @Autowired TokenUtils tokenUtils, @Value("${jwt.token.secret}") String key ) { // Arrange @@ -87,7 +87,7 @@ public class POST_specs { var userId = generateUserId(); var nickName = generateNickName(); testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenProvider, userId, nickName); + var validRefreshToken = getValidRefreshToken(tokenUtils, userId, nickName); var request = new ReissueRequest(validRefreshToken); // Act @@ -156,11 +156,11 @@ class ReissueShortToken{ @Test void 만료된_refresh_token으로_요청하면_401_Unauthorize가_발생한다( @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider + @Autowired TokenUtils tokenUtils ) throws InterruptedException { // Arrange - tokenProvider.generateToken(generateUserId(), generateNickName()); - var request = new ReissueRequest(getValidRefreshToken(tokenProvider, generateUserId(), generateNickName())); + tokenUtils.generateToken(generateUserId(), generateNickName()); + var request = new ReissueRequest(getValidRefreshToken(tokenUtils, generateUserId(), generateNickName())); Thread.sleep(100); //TODO 2025 08 18 16:47:00 : 시간 의존적 코드가 테스트 속도에 영향을 미치지 않도록 개선 필요 // Act @@ -178,12 +178,12 @@ class ReissueShortToken{ @Test void 존재하지_않는_사용자의_refresh_token을_요청하면_401_Unauthorize가_발생한다( @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider + @Autowired TokenUtils tokenUtils ) { // Arrange var userId = generateUserId(); var nickName = generateNickName(); - var validRefreshToken = getValidRefreshToken(tokenProvider, userId, nickName); + var validRefreshToken = getValidRefreshToken(tokenUtils, userId, nickName); var request = new ReissueRequest(validRefreshToken); // user 생성 안함 @@ -201,7 +201,7 @@ class ReissueShortToken{ - private static String getValidRefreshToken(TokenProvider tokenProvider, String userId, String nickName) { - return tokenProvider.generateToken(userId, nickName).refreshToken(); + private static String getValidRefreshToken(TokenUtils tokenUtils, String userId, String nickName) { + return tokenUtils.generateToken(userId, nickName).refreshToken(); } } diff --git a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java index 7ea66f5..a8a0316 100644 --- a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java @@ -10,9 +10,9 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.infra.persist.MemberQueryRepository; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterRequest; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterResponse; +import org.mandarin.booking.app.persist.MemberQueryRepository; +import org.mandarin.booking.domain.member.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.mandarin.booking.fixture.MemberFixture.NicknameGenerator; import org.mandarin.booking.fixture.MemberFixture.PasswordGenerator; diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java new file mode 100644 index 0000000..6732839 --- /dev/null +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -0,0 +1,49 @@ +package org.mandarin.booking.webapi.movie; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.IntegrationTestUtils; +import org.mandarin.booking.domain.movie.MovieRegisterRequest; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +@DisplayName("POST /api/movie") +public class POST_specs { + + @Test + void 올바른_요청을_보내면_status가_SUCCESS이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new MovieRegisterRequest( + "영화 제목", + "감독 이름", + 148, + "SF", + LocalDate.of(2010, 7, 21), + "AGE12", + "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "https://example.com/posters/inception.jpg", + List.of("레오나르도 디카프리오", + "조셉 고든레빗", + "엘렌 페이지") + ); + + // Act + + var response = testUtils.post( + "/api/movie", + request + ) + .assertSuccess(Void.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } +} From 0b37e9ccdbd716c90d2a80a80e6038ab29f9da42 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 23 Aug 2025 12:36:44 +0900 Subject: [PATCH 05/26] feat: add Jackson configuration for custom date formatting and timezone --- .../adapter/JacksonCustomizerConfig.java | 16 ++++++++++ .../booking/adapter/ObjectMapperConfig.java | 29 ------------------- 2 files changed, 16 insertions(+), 29 deletions(-) create mode 100644 src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java delete mode 100644 src/main/java/org/mandarin/booking/adapter/ObjectMapperConfig.java diff --git a/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java b/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java new file mode 100644 index 0000000..c1a70af --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java @@ -0,0 +1,16 @@ +package org.mandarin.booking.adapter; + +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonCustomizerConfig { + @Bean + Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> { + builder.timeZone("Asia/Seoul"); + builder.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + }; + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/ObjectMapperConfig.java b/src/main/java/org/mandarin/booking/adapter/ObjectMapperConfig.java deleted file mode 100644 index 91997a5..0000000 --- a/src/main/java/org/mandarin/booking/adapter/ObjectMapperConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.mandarin.booking.adapter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.TimeZone; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class ObjectMapperConfig { - @Bean - public ObjectMapper objectMapper() { - var objectMapper = new ObjectMapper(); - var module = new JavaTimeModule(); - var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); - - module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter)); - - objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Seoul")); - objectMapper.registerModule(module); - - return objectMapper; - } -} From 3c32e8db10fdbd47477d7d69191b162a33d7ec5b Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Tue, 26 Aug 2025 12:15:48 +0900 Subject: [PATCH 06/26] feat: implement custom authentication and access denial handling --- build.gradle | 1 + docs/specs/api/movie_register.md | 2 +- docs/todo.md | 11 + .../security/CustomAccessDeniedHandler.java | 31 +++ .../CustomAuthenticationEntryPoint.java | 33 +++ .../CustomAuthenticationProvider.java | 41 ++++ .../CustomMemberAuthenticationToken.java | 30 +++ .../security/JwtAuthenticationException.java | 9 + .../booking/adapter/security/JwtFilter.java | 38 ++-- .../adapter/security/SecurityConfig.java | 35 ++- .../booking/adapter/webapi/ApiStatus.java | 3 +- .../adapter/webapi/MovieController.java | 20 ++ .../mandarin/booking/app/JwtTokenUtils.java | 2 +- .../java/org/mandarin/booking/app/Log.java | 12 ++ .../mandarin/booking/app/LoggingAspect.java | 98 +++++++++ .../booking/app/MemberDetailsService.java | 25 --- .../app/persist/MemberJpaRepository.java | 6 +- .../domain/member/MemberAuthority.java | 16 +- .../booking/domain/member/MemberDetails.java | 9 +- .../booking/IntegrationTestUtils.java | 34 ++- .../booking/IntegrationTestUtilsSpecs.java | 8 +- .../java/org/mandarin/booking/TestConfig.java | 5 +- .../java/org/mandarin/booking/TestResult.java | 203 ++++++++---------- .../adapter/security/JwtFilterTest.java | 99 +++------ .../adapter/security/TestSecurityConfig.java | 45 ++++ .../booking/webapi/movie/POST_specs.java | 5 +- 26 files changed, 542 insertions(+), 279 deletions(-) create mode 100644 docs/todo.md create mode 100644 src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java create mode 100644 src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java create mode 100644 src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java create mode 100644 src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java create mode 100644 src/main/java/org/mandarin/booking/adapter/security/JwtAuthenticationException.java create mode 100644 src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java create mode 100644 src/main/java/org/mandarin/booking/app/Log.java create mode 100644 src/main/java/org/mandarin/booking/app/LoggingAspect.java delete mode 100644 src/main/java/org/mandarin/booking/app/MemberDetailsService.java create mode 100644 src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java diff --git a/build.gradle b/build.gradle index 0fcf8e0..53e4f61 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-aop' // ---- Data & Database ---- implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index f4c6ed8..e4090c0 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -51,7 +51,7 @@ ### 테스트 -- [ ] 올바른 요청을 보내면 status가 SUCCESS이다 +- [x] 올바른 요청을 보내면 status가 SUCCESS이다 - [ ] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [ ] title, director, runtimeMinutes, genre, releaseDate, rating은 비어있을 수 없다 - [ ] runtimeMinutes은 0 이상이어야 한다 diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..6a0ffc0 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,11 @@ + +2025.08.25 +- [x] `올바른_요청을_보내면_status가_SUCCESS이다` 테스트의 거짓 양성 문제 해결 필요? -> 그것보단 테스트 케이스의 부실한 부분을 수정하는게 맞는듯 + - 올바르다의 기준이 명확할 필요가 있음 +- [x] `TestResult`에 인증을 위한 토큰 입력부를 추가할 필요가 있음 + +2025.08.26 +- [x] `TestResult`에 인증을 위한 토큰 입력부 추가 + +2025.08.27 +- [ ] 없는 엔드포인트에 대한 처리 어찌할지 고민 diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..8ec2f83 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package org.mandarin.booking.adapter.security; + +import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.mandarin.booking.adapter.webapi.ErrorResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + log.error("Access Denied: {}", accessDeniedException.getMessage()); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + var errorResponse = new ErrorResponse(FORBIDDEN, "Access Denied"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..b13dc55 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package org.mandarin.booking.adapter.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.adapter.webapi.ApiStatus; +import org.mandarin.booking.adapter.webapi.ErrorResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + if (response.isCommitted()) { + return; + } + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + var exception = (Exception)(request.getAttribute("exception")); + var message = exception != null ? exception.getMessage() : authException.getMessage(); + var errorResponse = new ErrorResponse(ApiStatus.UNAUTHORIZED, message); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java new file mode 100644 index 0000000..0da27ce --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java @@ -0,0 +1,41 @@ +package org.mandarin.booking.adapter.security; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberQueryRepository; +import org.mandarin.booking.domain.member.AuthException; +import org.mandarin.booking.domain.member.Member; +import org.mandarin.booking.domain.member.MemberDetails; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationProvider implements AuthenticationProvider { + private final MemberQueryRepository queryRepository; + + @Override + public boolean supports(Class authentication) { + return CustomMemberAuthenticationToken.class.isAssignableFrom(authentication); + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (authentication instanceof CustomMemberAuthenticationToken token) { + var userId = token.getName(); + var member = queryRepository.findByUserId(userId) + .orElseThrow(() -> new AuthException("해당 아이디의 사용자를 찾을 수 없습니다: " + userId)); + + specifyToken(token, member); + return token; + } + throw new AuthException("Unsupported authentication type: " + authentication.getClass()); + } + + private void specifyToken(CustomMemberAuthenticationToken token, Member member) { + MemberDetails details = MemberDetails.from(member); + token.setDetails(details);// set user details + token.setAuthenticated(true); + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java new file mode 100644 index 0000000..6611c99 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java @@ -0,0 +1,30 @@ +package org.mandarin.booking.adapter.security; + +import java.util.List; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +public class CustomMemberAuthenticationToken extends AbstractAuthenticationToken { + private final String userId; + + public CustomMemberAuthenticationToken(String userId, GrantedAuthority authority) { + super(List.of(authority)); + this.userId = userId; + } + + @Override + public String getName() { + return userId; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return this.getDetails(); + } + +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtAuthenticationException.java b/src/main/java/org/mandarin/booking/adapter/security/JwtAuthenticationException.java new file mode 100644 index 0000000..7d05021 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtAuthenticationException.java @@ -0,0 +1,9 @@ +package org.mandarin.booking.adapter.security; + +import org.springframework.security.core.AuthenticationException; + +public class JwtAuthenticationException extends AuthenticationException { + public JwtAuthenticationException(String message) { + super(message); + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java index 7dd2c32..0cb722d 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java @@ -1,20 +1,18 @@ package org.mandarin.booking.adapter.security; -import com.fasterxml.jackson.databind.ObjectMapper; +import static org.mandarin.booking.domain.member.MemberAuthority.USER; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.mandarin.booking.adapter.webapi.ApiStatus; -import org.mandarin.booking.adapter.webapi.ErrorResponse; import org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.domain.member.AuthException; +import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; import org.springframework.web.filter.OncePerRequestFilter; @Slf4j @@ -22,13 +20,15 @@ public class JwtFilter extends OncePerRequestFilter { private static final String PREFIX = "Bearer "; private final TokenUtils tokenUtils; - private final ObjectMapper objectMapper; + private final AuthenticationProvider authenticationProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header = request.getHeader("Authorization"); - if (isNotBearer(header)) { + + if (!isBearer(header)) { + //TODO 2025 08 26 09:58:01 : AnonymousAuthenticationToken 발급할거라 AccessDeniedHandler로 넘어감 filterChain.doFilter(request, response); return; } @@ -37,28 +37,20 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { tokenUtils.validateToken(token); - var userId = tokenUtils.getClaim(token, "userId"); - var preAuthToken = new PreAuthenticatedAuthenticationToken(userId, null, null); - - SecurityContextHolder.getContext().setAuthentication(preAuthToken); - filterChain.doFilter(request, response); + var authToken = new CustomMemberAuthenticationToken(userId, USER); + var auth = authenticationProvider.authenticate(authToken); + SecurityContextHolder.getContext().setAuthentication(auth); } catch (AuthException e) { log.error("Authentication Error: {}", e.getMessage()); SecurityContextHolder.clearContext(); - responseErrorMessage(response, e); + request.setAttribute("exception", e); + } finally { + filterChain.doFilter(request, response); } } - private void responseErrorMessage(HttpServletResponse response, AuthException e) throws IOException { - var errorResponse = new ErrorResponse(ApiStatus.UNAUTHORIZED, e.getMessage()); - String valueAsString = objectMapper.writeValueAsString(errorResponse); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getOutputStream().write(valueAsString.getBytes(StandardCharsets.UTF_8)); - } - - private boolean isNotBearer(String header) { - return header == null || !header.startsWith(PREFIX); + private boolean isBearer(String header) { + return header != null && header.startsWith(PREFIX); } } diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index db5740e..72aec56 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -1,28 +1,25 @@ package org.mandarin.booking.adapter.security; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.mandarin.booking.app.TokenUtils; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper; -import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider; -import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; @Configuration @RequiredArgsConstructor public class SecurityConfig { private final TokenUtils tokenUtils; - private final ObjectMapper objectMapper; + private final AuthenticationProvider authenticationProvider; @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { @@ -30,11 +27,13 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationProvider preAuthProvider) + public SecurityFilterChain securityFilterChain(HttpSecurity http, + AuthenticationEntryPoint authenticationEntryPoint, + AccessDeniedHandler accessDeniedHandler) throws Exception { - AuthenticationManager manager = preAuthProvider::authenticate; http .authorizeHttpRequests(auth -> auth +// .requestMatchers(HttpMethod.GET, "/error").permitAll() .requestMatchers(HttpMethod.POST, "/api/members").permitAll() .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/auth/reissue").permitAll() @@ -42,17 +41,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication ) .formLogin(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) - .authenticationManager(manager) - .authenticationProvider(preAuthProvider) - .addFilterBefore(new JwtFilter(tokenUtils,objectMapper), UsernamePasswordAuthenticationFilter.class); + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider), + UsernamePasswordAuthenticationFilter.class); return http.build(); } - - @Bean - AuthenticationProvider preAuthProvider(UserDetailsService uds) { - var wrapper = new UserDetailsByNameServiceWrapper(uds); - var p = new PreAuthenticatedAuthenticationProvider(); - p.setPreAuthenticatedUserDetailsService(wrapper); - return p; - } } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java index 82f4c28..d6f777b 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java @@ -8,5 +8,6 @@ public enum ApiStatus { SUCCESS, BAD_REQUEST, UNAUTHORIZED, - INTERNAL_SERVER_ERROR + INTERNAL_SERVER_ERROR, + FORBIDDEN, } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java new file mode 100644 index 0000000..c3f8ea6 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java @@ -0,0 +1,20 @@ +package org.mandarin.booking.adapter.webapi; + +import org.mandarin.booking.app.Log; +import org.mandarin.booking.domain.movie.MovieRegisterRequest; +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/movie") +public class MovieController { + + @Log + @PostMapping + public void register(@RequestBody MovieRegisterRequest request) { + // Movie registration logic would go here + + } +} diff --git a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java index ad21bb6..5d11272 100644 --- a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java +++ b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java @@ -53,7 +53,7 @@ public void validateToken(String token) { try { parseClaims(token); } catch (IllegalArgumentException e) { - throw new AuthException("올바르지 않은 토큰입니다."); + throw new AuthException("토큰이 비어있습니다."); } catch (JwtException e) { throw new AuthException("토큰 검증에 실패했습니다."); } diff --git a/src/main/java/org/mandarin/booking/app/Log.java b/src/main/java/org/mandarin/booking/app/Log.java new file mode 100644 index 0000000..39e7990 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/Log.java @@ -0,0 +1,12 @@ +package org.mandarin.booking.app; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Log { + String scope() default "INFO"; +} diff --git a/src/main/java/org/mandarin/booking/app/LoggingAspect.java b/src/main/java/org/mandarin/booking/app/LoggingAspect.java new file mode 100644 index 0000000..20e5f18 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/LoggingAspect.java @@ -0,0 +1,98 @@ +package org.mandarin.booking.app; + +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class LoggingAspect { + + @Pointcut("@within(org.mandarin.booking.app.Log) || @annotation(org.mandarin.booking.app.Log)") + public void loggableTarget() {} + + @Around("loggableTarget()") + public Object logMethodExecution(ProceedingJoinPoint joinPoint) throws Throwable { + Logger targetLogger = selectTargetLogger(joinPoint); + String level = resolveScope(joinPoint); + + LocalDateTime startAt = LocalDateTime.now(); + long startNs = System.nanoTime(); + String signature = joinPoint.getSignature().toLongString(); + + logAtLevel(targetLogger, level, "START {} at {}", signature, formatTime(startAt)); + + boolean success = false; + try { + Object result = joinPoint.proceed(); + success = true; + return result; + } catch (Throwable t) { + long elapsedMs = nanosToMillis(System.nanoTime() - startNs); + LocalDateTime endAt = LocalDateTime.now(); + if (targetLogger.isErrorEnabled()) { + targetLogger.error("END {} at {} ({} ms) with exception: {}", signature, formatTime(endAt), elapsedMs, t.toString()); + } + throw t; + } finally { + if (success) { + long elapsedMs = nanosToMillis(System.nanoTime() - startNs); + LocalDateTime endAt = LocalDateTime.now(); + logAtLevel(targetLogger, level, "END {} at {} ({} ms)", signature, formatTime(endAt), elapsedMs); + } + } + } + + private Logger selectTargetLogger(ProceedingJoinPoint joinPoint) { + MethodSignature ms = (MethodSignature) joinPoint.getSignature(); + Method method = ms.getMethod(); + Class targetClass = joinPoint.getTarget() != null ? joinPoint.getTarget().getClass() : method.getDeclaringClass(); + return LoggerFactory.getLogger(targetClass); + } + + private String resolveScope(ProceedingJoinPoint joinPoint) { + MethodSignature ms = (MethodSignature) joinPoint.getSignature(); + Method method = ms.getMethod(); + Class targetClass = joinPoint.getTarget() != null ? joinPoint.getTarget().getClass() : method.getDeclaringClass(); + try { + Method targetMethod = targetClass.getMethod(method.getName(), method.getParameterTypes()); + Log m = targetMethod.getAnnotation(Log.class); + if (m != null && !m.scope().isBlank()) return m.scope(); + } catch (NoSuchMethodException ignored) {} + Log c = targetClass.getAnnotation(Log.class); + String scope = c != null ? c.scope() : "INFO"; + return scope.isBlank() ? "INFO" : scope; + } + + private void logAtLevel(Logger logger, String level, String message, Object... args) { + String up = level.trim().toUpperCase(); + switch (up) { + case "TRACE" -> { if (logger.isTraceEnabled()) logger.trace(message, args); } + case "DEBUG" -> { if (logger.isDebugEnabled()) logger.debug(message, args); } + case "WARN" -> { if (logger.isWarnEnabled()) logger.warn(message, args); } + case "ERROR" -> { if (logger.isErrorEnabled()) logger.error(message, args); } + case "INFO" -> { if (logger.isInfoEnabled()) logger.info(message, args); } + default -> { if (logger.isInfoEnabled()) logger.info(message, args); } + } + } + + private static long nanosToMillis(long ns) { + return ns / 1_000_000L; + } + + private static String formatTime(LocalDateTime time) { + return time.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } +} diff --git a/src/main/java/org/mandarin/booking/app/MemberDetailsService.java b/src/main/java/org/mandarin/booking/app/MemberDetailsService.java deleted file mode 100644 index 00142bb..0000000 --- a/src/main/java/org/mandarin/booking/app/MemberDetailsService.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.mandarin.booking.app; - -import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.MemberQueryRepository; -import org.mandarin.booking.domain.member.AuthException; -import org.springframework.security.core.userdetails.User; -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.Component; - -@Component -@RequiredArgsConstructor -public class MemberDetailsService implements UserDetailsService { - private final MemberQueryRepository queryRepository; - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - var member = queryRepository.findByUserId(username) - .orElseThrow(() -> new AuthException(" 해당 아이디의 사용자를 찾을 수 없습니다: " + username)); - return User.builder() - .username(member.getUserId()) - .password("N/A") - .build(); - } -} diff --git a/src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java index 7383b23..b0aa5f2 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java @@ -2,12 +2,14 @@ import java.util.Optional; import org.mandarin.booking.domain.member.Member; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; -public interface MemberJpaRepository extends JpaRepository { +public interface MemberJpaRepository extends Repository { boolean existsByUserId(String userId); boolean existsByEmail(String email); Optional findByUserId(String userId); + + Member save(Member member); } diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java b/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java index 09baec7..649d5f4 100644 --- a/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java +++ b/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java @@ -1,14 +1,14 @@ package org.mandarin.booking.domain.member; -import lombok.AllArgsConstructor; -import lombok.Getter; import org.springframework.security.core.GrantedAuthority; -@Getter -@AllArgsConstructor public enum MemberAuthority implements GrantedAuthority { - USER("USER"), - DISTRIBUTOR("DISTRIBUTOR"), - ADMIN("ADMIN"); - private final String authority; + USER, + DISTRIBUTOR, + ADMIN; + + @Override + public String getAuthority() { + return "ROLE_" + name().toUpperCase(); + } } diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java index 43a10a1..94fb1db 100644 --- a/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java +++ b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java @@ -9,12 +9,19 @@ public class MemberDetails implements UserDetails { private final String password; private final Collection authorities; - public MemberDetails(String userId, String password, Collection authorities) { + private MemberDetails(String userId, String password, Collection authorities) { this.userId = userId; this.password = password; this.authorities = authorities; } + public static MemberDetails from(Member member) { + String userId = member.getUserId(); + String password = member.getPasswordHash(); + Collection authorities = member.getAuthorities(); + return new MemberDetails(userId, password, authorities); + } + @Override public Collection getAuthorities() { return authorities; diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index 786a0be..c60cb9a 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -2,45 +2,65 @@ import static org.mandarin.booking.fixture.MemberFixture.EmailGenerator.generateEmail; import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; +import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.app.persist.MemberCommandRepository; -import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; +import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.boot.test.web.client.TestRestTemplate; public class IntegrationTestUtils { private final TestRestTemplate testRestTemplate; private final MemberCommandRepository memberRepository; + private final TokenUtils tokenUtils; private final SecurePasswordEncoder securePasswordEncoder; private final ObjectMapper objectMapper; public IntegrationTestUtils(TestRestTemplate testRestTemplate, MemberCommandRepository memberRepository, + TokenUtils tokenUtils, SecurePasswordEncoder securePasswordEncoder, ObjectMapper objectMapper) { this.testRestTemplate = testRestTemplate; this.memberRepository = memberRepository; + this.tokenUtils = tokenUtils; this.securePasswordEncoder = securePasswordEncoder; this.objectMapper = objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } public Member insertDummyMember(String userId, String password) { + var command = new MemberCreateCommand( + generateNickName(), + userId, + password, + generateEmail() + ); return memberRepository.insert( - Member.create(new MemberCreateCommand( - generateNickName(), - userId, - password, - generateEmail() - ), securePasswordEncoder) + Member.create(command, securePasswordEncoder) ); } + public TestResult get(String path) { + return new TestResult(path, null) + .setContext(testRestTemplate, objectMapper); + } + public TestResult post(String path, T request) { return new TestResult(path, request) .setContext(testRestTemplate, objectMapper); } + + public String getUserToken(String userId, String nickname) { + return tokenUtils.generateToken(userId, nickname).accessToken(); + } + + public Member insertDummyMember() { + return this.insertDummyMember(generateUserId(), generatePassword()); + } } diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java b/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java index 4438860..e5d9472 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java @@ -3,14 +3,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import com.fasterxml.jackson.core.type.TypeReference; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mandarin.booking.domain.member.Member; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.ParameterizedTypeReference; @Disabled @IntegrationTest @@ -29,7 +28,7 @@ void post_echo_success( // Act var response = integrationUtils.post("/test/echo", payload) - .assertSuccess(new ParameterizedTypeReference>() {}); + .assertSuccess(new TypeReference>() {}); // Assert assertThat(response.getStatus()).isEqualTo(SUCCESS); @@ -48,9 +47,8 @@ void insertDummyMember_and_verify_exists( String password = "P@ssw0rd!"; // save member using utils - Member saved = integrationUtils.insertDummyMember(userId, password); + var saved = integrationUtils.insertDummyMember(userId, password); assertThat(saved).isNotNull(); - assertThat(saved.getId()).isNotNull(); // Act Map request = Map.of("userId", userId); diff --git a/src/test/java/org/mandarin/booking/TestConfig.java b/src/test/java/org/mandarin/booking/TestConfig.java index 62488bf..19c4f94 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/src/test/java/org/mandarin/booking/TestConfig.java @@ -1,6 +1,7 @@ package org.mandarin.booking; import com.fasterxml.jackson.databind.ObjectMapper; +import org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; @@ -13,8 +14,10 @@ public class TestConfig { @Bean public IntegrationTestUtils integrationTestUtils(@Autowired TestRestTemplate testRestTemplate, @Autowired MemberCommandRepository memberRepository, + @Autowired TokenUtils tokenUtils, @Autowired SecurePasswordEncoder securePasswordEncoder, @Autowired ObjectMapper objectMapper) { - return new IntegrationTestUtils(testRestTemplate, memberRepository, securePasswordEncoder, objectMapper); + return new IntegrationTestUtils(testRestTemplate, memberRepository, tokenUtils, securePasswordEncoder, + objectMapper); } } diff --git a/src/test/java/org/mandarin/booking/TestResult.java b/src/test/java/org/mandarin/booking/TestResult.java index 11e8e01..2adf7a5 100644 --- a/src/test/java/org/mandarin/booking/TestResult.java +++ b/src/test/java/org/mandarin/booking/TestResult.java @@ -1,171 +1,144 @@ package org.mandarin.booking; -import static java.util.Objects.requireNonNull; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.POST; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JavaType; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import org.mandarin.booking.adapter.webapi.ApiStatus; import org.mandarin.booking.adapter.webapi.ErrorResponse; import org.mandarin.booking.adapter.webapi.SuccessResponse; import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; - public class TestResult { private final String path; private final Object request; - - private TestRestTemplate testRestTemplate; - private ObjectMapper objectMapper; + private final Map headers = new HashMap<>(); public TestResult(String path, Object request) { this.path = path; this.request = request; } - public SuccessResponse assertSuccess(Class responseBodyType) { - String body = postForBody(); - - if (body == null || body.isBlank() || responseBodyType == Void.class) { - return new SuccessResponse<>(SUCCESS, null); - } + private TestRestTemplate testRestTemplate; + private ObjectMapper objectMapper; - JsonNode node = parseJson(body); - assertSuccessStatus(requireNonNull(node), body); + public SuccessResponse assertSuccess(Class responseType) { + var response = readSuccessResponse( + getResponse(), + responseType + ); - JavaType targetType = buildSuccessType(responseBodyType); + if (response == null || response.getStatus() != ApiStatus.SUCCESS) { + throw new AssertionError("Expected SUCCESS response, but got: " + response); + } - return deserializeSuccess(body, targetType, "SuccessResponse<" + responseBodyType.getSimpleName() + ">"); + return response; } - public SuccessResponse assertSuccess(ParameterizedTypeReference responseTypeRef) { - String body = postForBody(); - - if (body == null || body.isBlank()) { - return new SuccessResponse<>(SUCCESS, null); + public SuccessResponse assertSuccess(TypeReference typeReference) { + var response = readSuccessResponse( + getResponse(), + typeReference + ); + if (response == null || response.getStatus() != ApiStatus.SUCCESS) { + throw new AssertionError("Expected SUCCESS response, but got: " + response); } - JsonNode node = parseJson(body); - assertSuccessStatus(requireNonNull(node), body); - - JavaType targetType = buildSuccessType(responseTypeRef); - - return deserializeSuccess(body, targetType, "SuccessResponse with parameterized type"); + return response; } public ErrorResponse assertFailure() { - String body = postForBody(); - - JsonNode node = parseJson(body); - assertErrorStatus(requireNonNull(node), body); + var response = readErrorResponse(); + if (response == null || response.getStatus() == ApiStatus.SUCCESS) { + throw new AssertionError("Expected Error response, but got: " + response); + } + return response; + } - return deserializeError(body); + public TestResult withHeader(String headerName, String headerValue) { + headers.put(headerName, headerValue); + return this; } TestResult setContext(TestRestTemplate testRestTemplate, ObjectMapper objectMapper) { this.testRestTemplate = testRestTemplate; - this.objectMapper = objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + this.objectMapper = objectMapper; return this; } - private String postForBody() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - var resp = testRestTemplate.postForEntity(path, new HttpEntity<>(request, headers), String.class); - System.out.println(resp.getStatusCode()); - System.out.println(resp.getBody()); - return resp.getBody(); - } - - - private JsonNode parseJson(String body) { + private SuccessResponse readSuccessResponse(String raw, Class dataType) { try { - return objectMapper.readTree(body); - } catch (Exception e) { - fail("[Deserialization Failure] Expected JSON with status but failed to parse.\nActual response body: " - + body, e); - return null; // Unreachable, added to satisfy compiler - } - } - - private void assertSuccessStatus(JsonNode node, String rawBody) { - JsonNode statusNode = node.get("status"); - String status = statusNode == null ? null : statusNode.asText(); - if (status == null) { - fail("[Assertion Failure] Expected a success response but 'status' field is missing.\nActual response body: " - + rawBody); - return; - } - if (!"SUCCESS".equals(status)) { - fail("[Assertion Failure] Expected SUCCESS but was '" + status - + "'. Use assertFailure() for error responses.\nActual response body: " + rawBody); + if (looksLikeJson(raw)) { + T data = (dataType == String.class) + ? dataType.cast(raw) + : objectMapper.readValue(raw, dataType); + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } + var wrapperType = objectMapper.getTypeFactory() + .constructParametricType(SuccessResponse.class, dataType); + return objectMapper.readValue(raw, wrapperType); + } catch (JsonProcessingException e) { + fail("Failed to parse SuccessResponse with data type " + dataType.getName() + ": " + e.getMessage(), e); + return null; } } - private void assertErrorStatus(JsonNode node, String rawBody) { - JsonNode statusNode = node.get("status"); - String status = statusNode == null ? null : statusNode.asText(); - if (status == null) { - fail("[Assertion Failure] Expected an error response but 'status' field is missing.\nActual response body: " - + rawBody); - return; - } - if ("SUCCESS".equals(status)) { - fail("[Assertion Failure] Expected an error response but got SUCCESS. Use assertSuccess() for successful responses.\nActual response body: " - + rawBody); + private SuccessResponse readSuccessResponse(String raw, TypeReference typeRef) { + try { + if (looksLikeJson(raw)) { + if (typeRef.getType().getTypeName().equals("java.lang.String")) { + @SuppressWarnings("unchecked") + T data = (T) raw; + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } + fail("Raw response is plain text and cannot be deserialized to " + typeRef.getType()); + return null; + } + JavaType inner = objectMapper.getTypeFactory().constructType(typeRef); + JavaType wrapper = objectMapper.getTypeFactory() + .constructParametricType(SuccessResponse.class, inner); + return objectMapper.readValue(raw, wrapper); + } catch (JsonProcessingException e) { + fail("Failed to parse SuccessResponse with data type " + typeRef.getType() + ": " + e.getMessage(), e); + return null; } } - private JavaType buildSuccessType(Class responseBodyType) { - return objectMapper.getTypeFactory().constructParametricType(SuccessResponse.class, responseBodyType); - } - - private JavaType buildSuccessType(ParameterizedTypeReference responseTypeRef) { - var typeFactory = objectMapper.getTypeFactory(); - JavaType innerType = typeFactory.constructType(responseTypeRef.getType()); - return typeFactory.constructParametricType(SuccessResponse.class, innerType); - } - - private SuccessResponse deserializeSuccess(String body, JavaType targetType, String expectationDesc) { + private ErrorResponse readErrorResponse() { + var response = getResponse(); try { - return objectMapper.readValue(body, targetType); + return objectMapper.readValue(response, ErrorResponse.class); } catch (Exception e) { - fail("[Deserialization Failure] Expected " + expectationDesc - + " but failed to deserialize.\nActual response body: " + body, e); - return null; // Unreachable, added to satisfy compiler + fail("Failed to parse ErrorResponse: " + e.getMessage(), e); + return null; } } - private ErrorResponse deserializeError(String body) { - try { - return objectMapper.readValue(body, ErrorResponse.class); - } catch (Exception e) { - fail("[Deserialization Failure] Expected ErrorResponse but failed to deserialize.\nActual response body: " - + body, e); - return null; // Unreachable, added to satisfy compiler + private String getResponse() { + var httpHeaders = new HttpHeaders(); + for (Entry entry : headers.entrySet()) { + httpHeaders.add(entry.getKey(), entry.getValue()); } + return (request == null) + ? testRestTemplate.exchange(path, GET, new HttpEntity<>(httpHeaders), String.class).getBody() + : testRestTemplate.exchange(path, POST, new HttpEntity<>(request, httpHeaders), String.class).getBody(); } - private String describeActualDataType(JsonNode dataNode) { - if (dataNode.isObject()) { - List fieldNames = new ArrayList<>(); - dataNode.fieldNames().forEachRemaining(fieldNames::add); - Collections.sort(fieldNames); - return "an object with fields " + fieldNames; - } - if (dataNode.isArray()) { - return "an array"; + private static boolean looksLikeJson(String s) { + if (s == null) { + return true; } - // NodeType을 소문자로 변환하여 "a string value", "a number value" 등으로 표현합니다. - return "a " + dataNode.getNodeType().toString().toLowerCase() + " value"; + var t = s.trim(); + return (!t.startsWith("{") || !t.endsWith("}")) && (!t.startsWith("[") || !t.endsWith("]")) + && (!t.startsWith("\"") || !t.endsWith("\"")); } } diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java index f14ddd1..162a806 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -1,79 +1,70 @@ package org.mandarin.booking.adapter.security; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.IntegrationTestUtils; import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController; -import org.mandarin.booking.adapter.security.JwtFilterTest.TestSecurityConfig; import org.mandarin.booking.app.TokenUtils; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import org.springframework.core.annotation.Order; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@SpringBootTest -@AutoConfigureMockMvc +@IntegrationTest @Import({TestSecurityConfig.class, TestAuthController.class}) class JwtFilterTest { private static final String PONG_WITHOUT_AUTH = "pong without auth"; private static final String PONG_WITH_AUTH = "pong with auth"; - @Autowired - MockMvc mockMvc; - @Test - void withoutAuth() throws Exception { + void withoutAuth(@Autowired IntegrationTestUtils testUtils) { // Act & Assert - mockMvc.perform(get("/test/without-auth")) - .andExpect(status().isOk()) - .andExpect(content().string(PONG_WITHOUT_AUTH)) - .andDo(print()); + var response = testUtils.get("/test/without-auth") + .assertSuccess(String.class); + + assertThat(response.getData()).isEqualTo(PONG_WITHOUT_AUTH); } @Test - void withAuth(@Autowired TokenUtils tokenUtils) throws Exception { + void withAuth(@Autowired IntegrationTestUtils testUtils, + @Autowired TokenUtils tokenUtils) { var userId = generateUserId(); var nickName = generateNickName(); var accessToken = tokenUtils.generateToken(userId, nickName).accessToken(); + var password = generatePassword(); + testUtils.insertDummyMember(userId, password); - mockMvc.perform(get("/test/without-auth") - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()) - .andExpect(content().string(PONG_WITHOUT_AUTH)) - .andDo(print()); + // Act & Assert + var response = testUtils.get( + "/test/with-auth" + ) + .withHeader("Authorization", "Bearer " + accessToken) + .assertSuccess(String.class); +// + assertThat(response.getStatus()).isEqualTo(SUCCESS); + assertThat(response.getData()).isEqualTo(PONG_WITH_AUTH); } @Test - void failToAuth() throws Exception { + void failToAuth(@Autowired IntegrationTestUtils testUtils) { // Arrange var invalidToken = "invalid token"; // Act & Assert - mockMvc.perform(get("/test/with-auth") - .header("Authorization", "Bearer " + invalidToken)) - .andExpect(status().isUnauthorized()) - .andExpect(content().json("{status: \"UNAUTHORIZED\"}")) - .andDo(print()); + var response = testUtils.get("/test/with-auth") + .withHeader("Authorization", "Bearer " + invalidToken) + .assertFailure(); + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("토큰 검증에 실패했습니다."); } @RestController @@ -89,32 +80,4 @@ public String pingWithAuth() { return PONG_WITH_AUTH; } } - - @TestConfiguration - static - class TestSecurityConfig { - @Autowired - ObjectMapper objectMapper; - - @Bean(name = "testSecurityFilterChain") - @Order(1) - SecurityFilterChain testSecurityFilterChain(HttpSecurity http, - TokenUtils tokenUtils, - AuthenticationProvider preAuthProvider) throws Exception { - AuthenticationManager authManager = preAuthProvider::authenticate; - return http - .securityMatcher("/test/**") - .authorizeHttpRequests(auth -> auth - .requestMatchers("/test/without-auth").permitAll() - .anyRequest().authenticated() - ) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .csrf(AbstractHttpConfigurer::disable) - .authenticationProvider(preAuthProvider) - .authenticationManager(authManager) - .addFilterBefore(new JwtFilter(tokenUtils, objectMapper), UsernamePasswordAuthenticationFilter.class) - .build(); - } - } } diff --git a/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java b/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java new file mode 100644 index 0000000..3e4dd61 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java @@ -0,0 +1,45 @@ +package org.mandarin.booking.adapter.security; + +import org.mandarin.booking.app.TokenUtils; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationProvider; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@TestConfiguration +@EnableMethodSecurity +@Order(0) +class TestSecurityConfig { + + @Bean + SecurityFilterChain testOnlyEndpoints( + HttpSecurity http, + AuthenticationProvider authenticationProvider, + AuthenticationEntryPoint authenticationEntryPoint, + AccessDeniedHandler accessDeniedHandler, TokenUtils tokenUtils) throws Exception { + http + .securityMatcher("/test/**") + .authorizeHttpRequests(a -> a + .requestMatchers("/test/without-auth").permitAll() + .requestMatchers("/test/with-auth").authenticated() + ) + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider), + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java index 6732839..c6e6262 100644 --- a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -21,6 +21,9 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange + var member = testUtils.insertDummyMember(); + var jwtToken = "Bearer " + testUtils.getUserToken(member.getUserId(), member.getNickName()); + var request = new MovieRegisterRequest( "영화 제목", "감독 이름", @@ -36,11 +39,11 @@ public class POST_specs { ); // Act - var response = testUtils.post( "/api/movie", request ) + .withHeader("Authorization", jwtToken) .assertSuccess(Void.class); // Assert From 433057e41df68eb574d27185eb08f08ba6a37390 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Tue, 26 Aug 2025 18:29:44 +0900 Subject: [PATCH 07/26] feat: update TestResult to use ApiResponse for success handling --- .../java/org/mandarin/booking/TestResult.java | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/src/test/java/org/mandarin/booking/TestResult.java b/src/test/java/org/mandarin/booking/TestResult.java index 2adf7a5..b073aec 100644 --- a/src/test/java/org/mandarin/booking/TestResult.java +++ b/src/test/java/org/mandarin/booking/TestResult.java @@ -6,11 +6,11 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; +import org.mandarin.booking.adapter.webapi.ApiResponse; import org.mandarin.booking.adapter.webapi.ApiStatus; import org.mandarin.booking.adapter.webapi.ErrorResponse; import org.mandarin.booking.adapter.webapi.SuccessResponse; @@ -31,7 +31,7 @@ public TestResult(String path, Object request) { private TestRestTemplate testRestTemplate; private ObjectMapper objectMapper; - public SuccessResponse assertSuccess(Class responseType) { + public ApiResponse assertSuccess(Class responseType) { var response = readSuccessResponse( getResponse(), responseType @@ -44,7 +44,7 @@ public SuccessResponse assertSuccess(Class responseType) { return response; } - public SuccessResponse assertSuccess(TypeReference typeReference) { + public ApiResponse assertSuccess(TypeReference typeReference) { var response = readSuccessResponse( getResponse(), typeReference @@ -75,41 +75,52 @@ TestResult setContext(TestRestTemplate testRestTemplate, ObjectMapper objectMapp return this; } - private SuccessResponse readSuccessResponse(String raw, Class dataType) { + private ApiResponse readSuccessResponse(String raw, Class dataType) { try { - if (looksLikeJson(raw)) { - T data = (dataType == String.class) - ? dataType.cast(raw) - : objectMapper.readValue(raw, dataType); - return new SuccessResponse<>(ApiStatus.SUCCESS, data); + if(objectMapper.readTree(raw).has("message")){ + fail("Expected SuccessResponse but got ErrorResponse: " + raw); } var wrapperType = objectMapper.getTypeFactory() .constructParametricType(SuccessResponse.class, dataType); return objectMapper.readValue(raw, wrapperType); - } catch (JsonProcessingException e) { - fail("Failed to parse SuccessResponse with data type " + dataType.getName() + ": " + e.getMessage(), e); - return null; + } catch (JsonProcessingException primary) { + try { + if (dataType == String.class) { + @SuppressWarnings("unchecked") + T data = (T) raw; + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } + if (dataType == Void.class) { + return new SuccessResponse<>(ApiStatus.SUCCESS, null); + } + T data = objectMapper.readValue(raw, dataType); + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } catch (Exception fallback) { + fail("Failed to parse SuccessResponse with data type " + dataType.getName() + ": " + primary.getMessage(), primary); + return null; + } } } private SuccessResponse readSuccessResponse(String raw, TypeReference typeRef) { try { - if (looksLikeJson(raw)) { - if (typeRef.getType().getTypeName().equals("java.lang.String")) { + var inner = objectMapper.getTypeFactory().constructType(typeRef); + var wrapper = objectMapper.getTypeFactory().constructParametricType(SuccessResponse.class, inner); + return objectMapper.readValue(raw, wrapper); + } catch (JsonProcessingException primary) { + try { + if ("java.lang.String".equals(typeRef.getType().getTypeName())) { @SuppressWarnings("unchecked") T data = (T) raw; return new SuccessResponse<>(ApiStatus.SUCCESS, data); } - fail("Raw response is plain text and cannot be deserialized to " + typeRef.getType()); + T data = objectMapper.readValue(raw, typeRef); + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } catch (Exception fallback) { + fail("Failed to parse SuccessResponse with data type " + + typeRef.getType() + ": " + primary.getMessage(), primary); return null; } - JavaType inner = objectMapper.getTypeFactory().constructType(typeRef); - JavaType wrapper = objectMapper.getTypeFactory() - .constructParametricType(SuccessResponse.class, inner); - return objectMapper.readValue(raw, wrapper); - } catch (JsonProcessingException e) { - fail("Failed to parse SuccessResponse with data type " + typeRef.getType() + ": " + e.getMessage(), e); - return null; } } @@ -132,13 +143,4 @@ private String getResponse() { ? testRestTemplate.exchange(path, GET, new HttpEntity<>(httpHeaders), String.class).getBody() : testRestTemplate.exchange(path, POST, new HttpEntity<>(request, httpHeaders), String.class).getBody(); } - - private static boolean looksLikeJson(String s) { - if (s == null) { - return true; - } - var t = s.trim(); - return (!t.startsWith("{") || !t.endsWith("}")) && (!t.startsWith("[") || !t.endsWith("]")) - && (!t.startsWith("\"") || !t.endsWith("\"")); - } } From 7d3a96a705633f13a68b104a15ff9631773b7722 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Tue, 26 Aug 2025 18:47:52 +0900 Subject: [PATCH 08/26] feat: simplify JWT authentication by removing unused provider and enhancing token handling --- .../adapter/security/CustomMemberAuthenticationToken.java | 1 + .../org/mandarin/booking/adapter/security/JwtFilter.java | 6 +----- .../mandarin/booking/adapter/security/SecurityConfig.java | 6 +----- .../booking/adapter/security/TestSecurityConfig.java | 5 +---- 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java index 6611c99..6c9656f 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java @@ -10,6 +10,7 @@ public class CustomMemberAuthenticationToken extends AbstractAuthenticationToken public CustomMemberAuthenticationToken(String userId, GrantedAuthority authority) { super(List.of(authority)); this.userId = userId; + super.setAuthenticated(true); } @Override diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java index 0cb722d..486f6e1 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java @@ -11,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; import org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.domain.member.AuthException; -import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; @@ -20,7 +19,6 @@ public class JwtFilter extends OncePerRequestFilter { private static final String PREFIX = "Bearer "; private final TokenUtils tokenUtils; - private final AuthenticationProvider authenticationProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -28,7 +26,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String header = request.getHeader("Authorization"); if (!isBearer(header)) { - //TODO 2025 08 26 09:58:01 : AnonymousAuthenticationToken 발급할거라 AccessDeniedHandler로 넘어감 filterChain.doFilter(request, response); return; } @@ -39,8 +36,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse tokenUtils.validateToken(token); var userId = tokenUtils.getClaim(token, "userId"); var authToken = new CustomMemberAuthenticationToken(userId, USER); - var auth = authenticationProvider.authenticate(authToken); - SecurityContextHolder.getContext().setAuthentication(auth); + SecurityContextHolder.getContext().setAuthentication(authToken); } catch (AuthException e) { log.error("Authentication Error: {}", e.getMessage()); SecurityContextHolder.clearContext(); diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index 72aec56..0fac792 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -5,7 +5,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; @@ -19,7 +18,6 @@ @RequiredArgsConstructor public class SecurityConfig { private final TokenUtils tokenUtils; - private final AuthenticationProvider authenticationProvider; @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { @@ -33,7 +31,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, throws Exception { http .authorizeHttpRequests(auth -> auth -// .requestMatchers(HttpMethod.GET, "/error").permitAll() .requestMatchers(HttpMethod.POST, "/api/members").permitAll() .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/auth/reissue").permitAll() @@ -42,11 +39,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .formLogin(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authenticationProvider(authenticationProvider) .exceptionHandling(ex -> ex .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler)) - .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider), + .addFilterBefore(new JwtFilter(tokenUtils), UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java b/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java index 3e4dd61..cb29fcd 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java +++ b/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java @@ -4,7 +4,6 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.core.annotation.Order; -import org.springframework.security.authentication.AuthenticationProvider; 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.configurers.AbstractHttpConfigurer; @@ -22,7 +21,6 @@ class TestSecurityConfig { @Bean SecurityFilterChain testOnlyEndpoints( HttpSecurity http, - AuthenticationProvider authenticationProvider, AuthenticationEntryPoint authenticationEntryPoint, AccessDeniedHandler accessDeniedHandler, TokenUtils tokenUtils) throws Exception { http @@ -34,11 +32,10 @@ SecurityFilterChain testOnlyEndpoints( .formLogin(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authenticationProvider(authenticationProvider) .exceptionHandling(ex -> ex .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler)) - .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider), + .addFilterBefore(new JwtFilter(tokenUtils), UsernamePasswordAuthenticationFilter.class); return http.build(); } From 22b5043e75c518b3dbab169c55ac54fb91ccf942 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Tue, 26 Aug 2025 18:56:38 +0900 Subject: [PATCH 09/26] feat: enhance custom authentication by adding authority handling in tokens --- .../adapter/security/CustomAuthenticationProvider.java | 1 + .../adapter/security/CustomMemberAuthenticationToken.java | 8 ++++++-- .../org/mandarin/booking/adapter/security/JwtFilter.java | 4 +--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java index 0da27ce..86bca06 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java @@ -37,5 +37,6 @@ private void specifyToken(CustomMemberAuthenticationToken token, Member member) MemberDetails details = MemberDetails.from(member); token.setDetails(details);// set user details token.setAuthenticated(true); + token.addAuthorities(details.getAuthorities()); } } diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java index 6c9656f..c909bec 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java @@ -1,5 +1,6 @@ package org.mandarin.booking.adapter.security; +import java.util.Collection; import java.util.List; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; @@ -7,8 +8,8 @@ public class CustomMemberAuthenticationToken extends AbstractAuthenticationToken { private final String userId; - public CustomMemberAuthenticationToken(String userId, GrantedAuthority authority) { - super(List.of(authority)); + public CustomMemberAuthenticationToken(String userId) { + super(List.of()); this.userId = userId; super.setAuthenticated(true); } @@ -28,4 +29,7 @@ public Object getPrincipal() { return this.getDetails(); } + public void addAuthorities(Collection authorities) { + super.getAuthorities().addAll(authorities); + } } diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java index 486f6e1..870bd48 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java @@ -1,7 +1,5 @@ package org.mandarin.booking.adapter.security; -import static org.mandarin.booking.domain.member.MemberAuthority.USER; - import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -35,7 +33,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { tokenUtils.validateToken(token); var userId = tokenUtils.getClaim(token, "userId"); - var authToken = new CustomMemberAuthenticationToken(userId, USER); + var authToken = new CustomMemberAuthenticationToken(userId); SecurityContextHolder.getContext().setAuthentication(authToken); } catch (AuthException e) { log.error("Authentication Error: {}", e.getMessage()); From 75d628ca091cae38bf41c503af40460314c3b136 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 27 Aug 2025 09:12:21 +0900 Subject: [PATCH 10/26] feat: add test case for unauthorized access handling in movie registration --- docs/specs/api/movie_register.md | 2 +- docs/todo.md | 1 + .../booking/webapi/movie/POST_specs.java | 35 ++++++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index e4090c0..9527236 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -52,7 +52,7 @@ ### 테스트 - [x] 올바른 요청을 보내면 status가 SUCCESS이다 -- [ ] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 +- [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [ ] title, director, runtimeMinutes, genre, releaseDate, rating은 비어있을 수 없다 - [ ] runtimeMinutes은 0 이상이어야 한다 - [ ] releaseDat는 ISO 8601 양식을 준수한다 diff --git a/docs/todo.md b/docs/todo.md index 6a0ffc0..598f179 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -8,4 +8,5 @@ - [x] `TestResult`에 인증을 위한 토큰 입력부 추가 2025.08.27 +- [ ] 테스트 케이스 충족 - [ ] 없는 엔드포인트에 대한 처리 어찌할지 고민 diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java index c6e6262..073d5c5 100644 --- a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; import java.time.LocalDate; import java.util.List; @@ -24,7 +25,22 @@ public class POST_specs { var member = testUtils.insertDummyMember(); var jwtToken = "Bearer " + testUtils.getUserToken(member.getUserId(), member.getNickName()); - var request = new MovieRegisterRequest( + var request = generateMovieRegisterRequest(); + + // Act + var response = testUtils.post( + "/api/movie", + request + ) + .withHeader("Authorization", jwtToken) + .assertSuccess(Void.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } + + private static MovieRegisterRequest generateMovieRegisterRequest() { + return new MovieRegisterRequest( "영화 제목", "감독 이름", 148, @@ -37,16 +53,25 @@ public class POST_specs { "조셉 고든레빗", "엘렌 페이지") ); + } + @Test + void Authorization_헤더에_유효한_accessToken이_없으면_status가_UNAUTHORIZED이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = generateMovieRegisterRequest(); + // Act var response = testUtils.post( "/api/movie", request ) - .withHeader("Authorization", jwtToken) - .assertSuccess(Void.class); - + .assertFailure(); + // Assert - assertThat(response.getStatus()).isEqualTo(SUCCESS); + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); } + + } From 600cdedbc25570cb414576a449f88dafe18956c6 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 27 Aug 2025 09:32:48 +0900 Subject: [PATCH 11/26] feat: improve error handling in TestResult for response validation --- .../java/org/mandarin/booking/TestResult.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/mandarin/booking/TestResult.java b/src/test/java/org/mandarin/booking/TestResult.java index b073aec..f5c4218 100644 --- a/src/test/java/org/mandarin/booking/TestResult.java +++ b/src/test/java/org/mandarin/booking/TestResult.java @@ -37,8 +37,10 @@ public ApiResponse assertSuccess(Class responseType) { responseType ); - if (response == null || response.getStatus() != ApiStatus.SUCCESS) { + if (response == null) { throw new AssertionError("Expected SUCCESS response, but got: " + response); + } else if (response.getStatus() != ApiStatus.SUCCESS) { + throw new AssertionError("Expected SUCCESS response, but got Error response: " + response); } return response; @@ -49,8 +51,10 @@ public ApiResponse assertSuccess(TypeReference typeReference) { getResponse(), typeReference ); - if (response == null || response.getStatus() != ApiStatus.SUCCESS) { + if (response == null) { throw new AssertionError("Expected SUCCESS response, but got: " + response); + } else if (response.getStatus() != ApiStatus.SUCCESS) { + throw new AssertionError("Expected SUCCESS response, but got Error response: " + response); } return response; @@ -58,8 +62,10 @@ public ApiResponse assertSuccess(TypeReference typeReference) { public ErrorResponse assertFailure() { var response = readErrorResponse(); - if (response == null || response.getStatus() == ApiStatus.SUCCESS) { + if (response == null) { throw new AssertionError("Expected Error response, but got: " + response); + }else if (response.getStatus() == ApiStatus.SUCCESS) { + throw new AssertionError("Expected Error response, but got SUCCESS: " + response); } return response; } @@ -127,6 +133,9 @@ private SuccessResponse readSuccessResponse(String raw, TypeReference private ErrorResponse readErrorResponse() { var response = getResponse(); try { + if(objectMapper.readTree(response).has("data")){ + fail("Expected ErrorResponse but got SuccessResponse: " + response); + } return objectMapper.readValue(response, ErrorResponse.class); } catch (Exception e) { fail("Failed to parse ErrorResponse: " + e.getMessage(), e); From 137e7c67aab602c176db6bc58aeabaa58ee76b57 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 27 Aug 2025 09:38:06 +0900 Subject: [PATCH 12/26] feat: enhance movie registration validation and error handling --- docs/specs/api/movie_register.md | 8 +- .../booking/adapter/webapi/ErrorResponse.java | 2 + .../adapter/webapi/MovieController.java | 3 +- .../adapter/webapi/SuccessResponse.java | 2 + .../domain/movie/MovieRegisterRequest.java | 28 ++++++- .../booking/webapi/movie/POST_specs.java | 84 ++++++++++++++----- 6 files changed, 99 insertions(+), 28 deletions(-) diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index 9527236..0577002 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -52,7 +52,7 @@ ### 테스트 - [x] 올바른 요청을 보내면 status가 SUCCESS이다 -- [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 -- [ ] title, director, runtimeMinutes, genre, releaseDate, rating은 비어있을 수 없다 -- [ ] runtimeMinutes은 0 이상이어야 한다 -- [ ] releaseDat는 ISO 8601 양식을 준수한다 +- [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 +- [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 +- [ ] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 +- [ ] releaseDat는 ISO 8601 양식을 준수하지 않으면 BAD_REQUEST이다 diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java b/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java index ffc0bb3..28f7cdb 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java @@ -1,8 +1,10 @@ package org.mandarin.booking.adapter.webapi; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.ToString; +@ToString public class ErrorResponse extends ApiResponse { public ErrorResponse(ApiStatus status, String message) { diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java index c3f8ea6..68e0634 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java @@ -1,5 +1,6 @@ package org.mandarin.booking.adapter.webapi; +import jakarta.validation.Valid; import org.mandarin.booking.app.Log; import org.mandarin.booking.domain.movie.MovieRegisterRequest; import org.springframework.web.bind.annotation.PostMapping; @@ -13,7 +14,7 @@ public class MovieController { @Log @PostMapping - public void register(@RequestBody MovieRegisterRequest request) { + public void register(@RequestBody @Valid MovieRegisterRequest request) { // Movie registration logic would go here } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java b/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java index 30c2533..7d7e55f 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java @@ -2,8 +2,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.ToString; @Getter +@ToString @NoArgsConstructor public class SuccessResponse extends ApiResponse { diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java index 60d4862..8b4cac0 100644 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java @@ -1,10 +1,30 @@ package org.mandarin.booking.domain.movie; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.util.List; -public record MovieRegisterRequest(String title, String director, int runtimeMinutes, String genre, - LocalDate releaseDate, - String rating, - String synopsis, String posterUrl, List casts) { +public record MovieRegisterRequest( + @NotBlank(message = "Title must not be blank") + String title, + + @NotBlank(message = "Director must not be blank") + String director, + + @NotNull(message = "Runtime minutes must not be null") + Integer runtimeMinutes, + + @NotBlank(message = "Genre must not be blank") + String genre, + + @NotNull(message = "Release date must not be blank") + LocalDate releaseDate, + + @NotBlank(message = "Rating must not be blank") + String rating, + + String synopsis, + String posterUrl, + List casts) { } diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java index 073d5c5..002cfe2 100644 --- a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -1,6 +1,7 @@ package org.mandarin.booking.webapi.movie; import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; @@ -8,6 +9,8 @@ import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; import org.mandarin.booking.domain.movie.MovieRegisterRequest; @@ -39,39 +42,82 @@ public class POST_specs { assertThat(response.getStatus()).isEqualTo(SUCCESS); } - private static MovieRegisterRequest generateMovieRegisterRequest() { - return new MovieRegisterRequest( - "영화 제목", - "감독 이름", - 148, - "SF", - LocalDate.of(2010, 7, 21), - "AGE12", - "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", - "https://example.com/posters/inception.jpg", - List.of("레오나르도 디카프리오", - "조셉 고든레빗", - "엘렌 페이지") - ); - } - @Test void Authorization_헤더에_유효한_accessToken이_없으면_status가_UNAUTHORIZED이다( @Autowired IntegrationTestUtils testUtils ) { // Arrange - var request = generateMovieRegisterRequest(); - + var request = generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"); + // Act var response = testUtils.post( "/api/movie", request ) .assertFailure(); - + // Assert assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); } + @ParameterizedTest + @MethodSource("org.mandarin.booking.webapi.movie.POST_specs#nullOrBlankElementRequests") + void title_director_runtimeMinutes_genre_releaseDate_rating이_비어있으면_BAD_REQUEST이다( + MovieRegisterRequest request, + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var member = testUtils.insertDummyMember(); + var jwtToken = "Bearer " + testUtils.getUserToken(member.getUserId(), member.getNickName()); + + // Act + var response = testUtils.post( + "/api/movie", + request + ) + .withHeader("Authorization", jwtToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + static List nullOrBlankElementRequests(){ + return List.of( + generateMovieRegisterRequest("", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("영화 제목", "", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "", LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", null, "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), ""), + generateMovieRegisterRequest(null, "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("영화 제목", null, 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, null, LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", null, "SF", LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", null, "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), null) + ); + } + + + private static MovieRegisterRequest generateMovieRegisterRequest(String title, String director, Integer runtimeMinutes, + String genre, LocalDate releaseDate, String rating) { + return new MovieRegisterRequest( + title, + director, + runtimeMinutes, + genre, + releaseDate, + rating, + "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "https://example.com/posters/inception.jpg", + List.of("레오나르도 디카프리오", + "조셉 고든레빗", + "엘렌 페이지") + ); + } + + private static MovieRegisterRequest generateMovieRegisterRequest() { + return generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"); + } } From baba91a32411400d8d272b1b4a3d23654808fc03 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 27 Aug 2025 09:41:27 +0900 Subject: [PATCH 13/26] feat: add validation for runtimeMinutes to ensure non-negative values --- docs/specs/api/movie_register.md | 4 +-- .../domain/movie/MovieRegisterRequest.java | 2 ++ .../booking/webapi/movie/POST_specs.java | 31 ++++++++++++++++--- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index 0577002..13c5e0d 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -54,5 +54,5 @@ - [x] 올바른 요청을 보내면 status가 SUCCESS이다 - [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 -- [ ] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 -- [ ] releaseDat는 ISO 8601 양식을 준수하지 않으면 BAD_REQUEST이다 +- [x] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 +- [ ] releaseDate는 ISO 8601 양식을 준수하지 않으면 BAD_REQUEST이다 diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java index 8b4cac0..72c8624 100644 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java @@ -1,5 +1,6 @@ package org.mandarin.booking.domain.movie; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; @@ -13,6 +14,7 @@ public record MovieRegisterRequest( String director, @NotNull(message = "Runtime minutes must not be null") + @Min(value = 0, message = "Runtime minutes must be non-negative") Integer runtimeMinutes, @NotBlank(message = "Genre must not be blank") diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java index 002cfe2..e20ee2b 100644 --- a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -25,8 +25,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var member = testUtils.insertDummyMember(); - var jwtToken = "Bearer " + testUtils.getUserToken(member.getUserId(), member.getNickName()); + var jwtToken = getAuthToken(testUtils); var request = generateMovieRegisterRequest(); @@ -67,9 +66,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var member = testUtils.insertDummyMember(); - var jwtToken = "Bearer " + testUtils.getUserToken(member.getUserId(), member.getNickName()); - + var jwtToken = getAuthToken(testUtils); // Act var response = testUtils.post( @@ -82,6 +79,30 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void runtimeMinutes은_0_미만이면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = getAuthToken(testUtils); + + // Act + var response = testUtils.post( + "/api/movie", + generateMovieRegisterRequest("영화 제목", "감독 이름", -1, "SF", LocalDate.of(2010, 7, 21), "AGE12") + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + private String getAuthToken(IntegrationTestUtils testUtils) { + var member = testUtils.insertDummyMember(); + return "Bearer " + testUtils.getUserToken(member.getUserId(), member.getNickName()); + } static List nullOrBlankElementRequests(){ return List.of( From 6340078132929d3b33816cd2966e26881a949466 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 27 Aug 2025 10:02:06 +0900 Subject: [PATCH 14/26] feat: update releaseDate validation to require yyyy-MM-dd format --- docs/specs/api/movie_register.md | 2 +- .../domain/movie/MovieRegisterRequest.java | 7 +-- .../booking/webapi/movie/POST_specs.java | 50 +++++++++++++------ 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index 13c5e0d..af93c00 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -55,4 +55,4 @@ - [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 - [x] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 -- [ ] releaseDate는 ISO 8601 양식을 준수하지 않으면 BAD_REQUEST이다 +- [ ] releaseDate는 yyyy-MM-dd 형태를 준수하지 않으면 BAD_REQUEST이다 diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java index 72c8624..4a2ea4a 100644 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; +import jakarta.validation.constraints.Pattern; import java.util.List; public record MovieRegisterRequest( @@ -20,8 +20,9 @@ public record MovieRegisterRequest( @NotBlank(message = "Genre must not be blank") String genre, - @NotNull(message = "Release date must not be blank") - LocalDate releaseDate, + @NotBlank(message = "Release date must not be blank") + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "releaseDate must be yyyy-MM-dd") + String releaseDate, @NotBlank(message = "Rating must not be blank") String rating, diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java index e20ee2b..6e4ecad 100644 --- a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -5,7 +5,6 @@ import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; -import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -46,7 +45,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var request = generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"); + var request = generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", "AGE12"); // Act var response = testUtils.post( @@ -90,7 +89,30 @@ public class POST_specs { // Act var response = testUtils.post( "/api/movie", - generateMovieRegisterRequest("영화 제목", "감독 이름", -1, "SF", LocalDate.of(2010, 7, 21), "AGE12") + generateMovieRegisterRequest("영화 제목", "감독 이름", -1, "SF", "2010-07-21", "AGE12") + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void releaseDate는_yyyy_MM_dd_형태를_준수하지_않으면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = getAuthToken(testUtils); + // 잘못된 날짜 형식 + var request = generateMovieRegisterRequest( + "영화 제목", "감독 이름", 148, "SF", "21-07-2010", "AGE12" + ); + + // Act + var response = testUtils.post( + "/api/movie", + request ) .withHeader("Authorization", authToken) .assertFailure(); @@ -106,23 +128,23 @@ private String getAuthToken(IntegrationTestUtils testUtils) { static List nullOrBlankElementRequests(){ return List.of( - generateMovieRegisterRequest("", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"), - generateMovieRegisterRequest("영화 제목", "", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "", LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("", "감독 이름", 148, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "", 148, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "", "2010-07-21", "AGE12"), generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", null, "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), ""), - generateMovieRegisterRequest(null, "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"), - generateMovieRegisterRequest("영화 제목", null, 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, null, LocalDate.of(2010, 7, 21), "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", null, "SF", LocalDate.of(2010, 7, 21), "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", ""), + generateMovieRegisterRequest(null, "감독 이름", 148, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", null, 148, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, null, "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", null, "SF", "2010-07-21", "AGE12"), generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", null, "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), null) + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", null) ); } private static MovieRegisterRequest generateMovieRegisterRequest(String title, String director, Integer runtimeMinutes, - String genre, LocalDate releaseDate, String rating) { + String genre, String releaseDate, String rating) { return new MovieRegisterRequest( title, director, @@ -139,6 +161,6 @@ private static MovieRegisterRequest generateMovieRegisterRequest(String title, S } private static MovieRegisterRequest generateMovieRegisterRequest() { - return generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", LocalDate.of(2010, 7, 21), "AGE12"); + return generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", "AGE12"); } } From dabe352e16998ebaaaeba90b42231dbede037d4a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 27 Aug 2025 10:02:22 +0900 Subject: [PATCH 15/26] feat: mark releaseDate validation as implemented in movie registration --- docs/specs/api/movie_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index af93c00..9de672a 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -55,4 +55,4 @@ - [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 - [x] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 -- [ ] releaseDate는 yyyy-MM-dd 형태를 준수하지 않으면 BAD_REQUEST이다 +- [x] releaseDate는 yyyy-MM-dd 형태를 준수하지 않으면 BAD_REQUEST이다 From ddf0560e244f15b31a6c236660c51ec16830da2a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 28 Aug 2025 09:51:55 +0900 Subject: [PATCH 16/26] feat: enhance JWT token generation to include user authorities --- .../CustomAuthenticationProvider.java | 1 - .../CustomMemberAuthenticationToken.java | 8 +-- .../security/JwtAuthenticationException.java | 9 --- .../booking/adapter/security/JwtFilter.java | 42 ++++++----- .../adapter/security/SecurityConfig.java | 4 +- .../org/mandarin/booking/app/AuthService.java | 3 +- .../mandarin/booking/app/JwtTokenUtils.java | 71 +++++++++++-------- .../mandarin/booking/app/LoggingAspect.java | 6 +- .../org/mandarin/booking/app/TokenUtils.java | 6 +- .../member/MemberAuthorityConverter.java | 10 ++- .../booking/IntegrationTestUtils.java | 17 ++++- .../adapter/security/JwtFilterTest.java | 60 ++++++++++++---- .../adapter/security/TestSecurityConfig.java | 42 ----------- .../webapi/auth/reissue/POST_specs.java | 47 ++++-------- .../booking/webapi/movie/POST_specs.java | 13 ++-- 15 files changed, 164 insertions(+), 175 deletions(-) delete mode 100644 src/main/java/org/mandarin/booking/adapter/security/JwtAuthenticationException.java delete mode 100644 src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java index 86bca06..0da27ce 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java @@ -37,6 +37,5 @@ private void specifyToken(CustomMemberAuthenticationToken token, Member member) MemberDetails details = MemberDetails.from(member); token.setDetails(details);// set user details token.setAuthenticated(true); - token.addAuthorities(details.getAuthorities()); } } diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java index c909bec..ac1f9d8 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java @@ -1,15 +1,14 @@ package org.mandarin.booking.adapter.security; import java.util.Collection; -import java.util.List; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; public class CustomMemberAuthenticationToken extends AbstractAuthenticationToken { private final String userId; - public CustomMemberAuthenticationToken(String userId) { - super(List.of()); + public CustomMemberAuthenticationToken(String userId, Collection authorities) { + super(authorities); this.userId = userId; super.setAuthenticated(true); } @@ -29,7 +28,4 @@ public Object getPrincipal() { return this.getDetails(); } - public void addAuthorities(Collection authorities) { - super.getAuthorities().addAll(authorities); - } } diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtAuthenticationException.java b/src/main/java/org/mandarin/booking/adapter/security/JwtAuthenticationException.java deleted file mode 100644 index 7d05021..0000000 --- a/src/main/java/org/mandarin/booking/adapter/security/JwtAuthenticationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.mandarin.booking.adapter.security; - -import org.springframework.security.core.AuthenticationException; - -public class JwtAuthenticationException extends AuthenticationException { - public JwtAuthenticationException(String message) { - super(message); - } -} diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java index 870bd48..d29d003 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java @@ -9,6 +9,8 @@ import lombok.extern.slf4j.Slf4j; import org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.domain.member.AuthException; +import org.mandarin.booking.domain.member.MemberAuthority; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; @@ -17,31 +19,37 @@ public class JwtFilter extends OncePerRequestFilter { private static final String PREFIX = "Bearer "; private final TokenUtils tokenUtils; - + private final AuthenticationManager authenticationManager; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header = request.getHeader("Authorization"); - if (!isBearer(header)) { - filterChain.doFilter(request, response); - return; + if (isBearer(header)) { + String token = header.substring(PREFIX.length()); + + try { + var userId = tokenUtils.getClaim(token, "userId"); + var roles = tokenUtils.getClaims(token, "roles"); + var authorities = roles.stream() + .map(r -> r.substring(5)) // "ROLE_" 접두사 제거 + .map(MemberAuthority::valueOf).toList(); + var authToken = new CustomMemberAuthenticationToken(userId, authorities); + + var authenticate = authenticationManager.authenticate(authToken); + SecurityContextHolder.getContext().setAuthentication(authenticate); + } catch (AuthException e) { + log.error("Authentication Error: {}", e.getMessage()); + SecurityContextHolder.clearContext(); + request.setAttribute("exception", e); + } } - String token = header.substring(PREFIX.length()); - - try { - tokenUtils.validateToken(token); - var userId = tokenUtils.getClaim(token, "userId"); - var authToken = new CustomMemberAuthenticationToken(userId); - SecurityContextHolder.getContext().setAuthentication(authToken); - } catch (AuthException e) { - log.error("Authentication Error: {}", e.getMessage()); - SecurityContextHolder.clearContext(); - request.setAttribute("exception", e); - } finally { - filterChain.doFilter(request, response); + if(SecurityContextHolder.getContext().getAuthentication() == null){ + request.setAttribute("exception", new AuthException("유효한 토큰이 없습니다.")); } + + filterChain.doFilter(request, response); } private boolean isBearer(String header) { diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index 0fac792..5ed93c8 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -5,6 +5,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; @@ -26,6 +27,7 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, + AuthenticationProvider authenticationProvider, AuthenticationEntryPoint authenticationEntryPoint, AccessDeniedHandler accessDeniedHandler) throws Exception { @@ -42,7 +44,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .exceptionHandling(ex -> ex .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler)) - .addFilterBefore(new JwtFilter(tokenUtils), + .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider::authenticate), UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/org/mandarin/booking/app/AuthService.java b/src/main/java/org/mandarin/booking/app/AuthService.java index fbddeb9..ae122a7 100644 --- a/src/main/java/org/mandarin/booking/app/AuthService.java +++ b/src/main/java/org/mandarin/booking/app/AuthService.java @@ -21,12 +21,11 @@ public TokenHolder login(String userId, String password) { var member = getMember(userId); checkPasswordMatch(member, password); - return tokenUtils.generateToken(member.getUserId(), member.getNickName()); + return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()); } @Override public TokenHolder reissue(String refreshToken) { - tokenUtils.validateToken(refreshToken); var userId = tokenUtils.getClaim(refreshToken, "userId"); if(!queryRepository.existsByUserId(userId)) throw new AuthException("회원이 존재하지 않습니다"); diff --git a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java index 5d11272..3cd727c 100644 --- a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java +++ b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java @@ -6,19 +6,26 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; import java.util.Date; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import javax.crypto.SecretKey; import org.mandarin.booking.domain.member.AuthException; +import org.mandarin.booking.domain.member.MemberAuthority; import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; @Component public class JwtTokenUtils implements TokenUtils { private static final String USER_ID = "userId"; private static final String NICK_NAME = "nickName"; + private static final String ROLES = "roles"; @Value("${jwt.token.access}") private long accessTokenExp; @@ -33,45 +40,42 @@ public void setKey(@Value("${jwt.token.secret}") String secretKey) { this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); } + @Override + public TokenHolder generateToken(String userId, String nickName, + Collection authorities) { + String accessToken = generateTokenInternal(userId, nickName, authorities, accessTokenExp); + String refreshToken = generateTokenInternal(userId, nickName, authorities, refreshTokenExp); + return new TokenHolder(accessToken, refreshToken); + } + @Override public TokenHolder generateToken(String refreshToken) { var claims = parseClaims(refreshToken); String userId = claims.getPayload().get(USER_ID).toString(); String nickName = claims.getPayload().get(NICK_NAME).toString(); - return generateToken(userId, nickName); + List authorities = Arrays.stream(claims.getPayload().get(ROLES, String.class).split(",")) + .map(s->s.substring(5)) + .map(MemberAuthority::valueOf) + .toList(); + return generateToken(userId, nickName, authorities); } - @Override - public TokenHolder generateToken(String userId, String nickName) { - String accessToken = generateTokenInternal(userId, nickName, accessTokenExp); - String refreshToken = generateTokenInternal(userId, nickName, refreshTokenExp); - return new TokenHolder(accessToken, refreshToken); - } @Override - public void validateToken(String token) { - try { - parseClaims(token); - } catch (IllegalArgumentException e) { - throw new AuthException("토큰이 비어있습니다."); - } catch (JwtException e) { - throw new AuthException("토큰 검증에 실패했습니다."); - } + public String getClaim(String token, String claimName) { + Jws claims = parseClaims(token); + return claims.getPayload().get(claimName, String.class); } @Override - public String getClaim(String token, String claimName) { - try { - Jws claims = parseClaims(token); - return claims.getPayload().get(claimName, String.class); - } catch (JwtException e) { - throw new AuthException("토큰에서 클레임을 추출하는 데 실패했습니다."); - } catch (IllegalArgumentException e) { - throw new AuthException("올바르지 않은 토큰입니다."); - } + public Collection getClaims(String token, String claimName) { + Jws claims = parseClaims(token); + var rawPayload = claims.getPayload().get(claimName, String.class); + return Arrays.stream(rawPayload.split(",")).toList(); } - private String generateTokenInternal(String userId, String nickName, long expiration) { + private String generateTokenInternal(String userId, String nickName, + Collection authorities, long expiration) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); Date exp = new Date(nowMillis + expiration); @@ -82,7 +86,8 @@ private String generateTokenInternal(String userId, String nickName, long expira .subject(userId) .claims(Map.of( USER_ID, userId, - NICK_NAME, nickName + NICK_NAME, nickName, + ROLES, authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")) )) .issuedAt(now) .expiration(exp) @@ -91,9 +96,15 @@ private String generateTokenInternal(String userId, String nickName, long expira } private Jws parseClaims(String token) { - return Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(token); + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + } catch (IllegalArgumentException e) { + throw new AuthException("토큰이 비어있습니다."); + } catch (JwtException e) { + throw new AuthException("토큰 검증에 실패했습니다."); + } } } diff --git a/src/main/java/org/mandarin/booking/app/LoggingAspect.java b/src/main/java/org/mandarin/booking/app/LoggingAspect.java index 20e5f18..c24f433 100644 --- a/src/main/java/org/mandarin/booking/app/LoggingAspect.java +++ b/src/main/java/org/mandarin/booking/app/LoggingAspect.java @@ -8,7 +8,6 @@ import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,10 +19,7 @@ @RequiredArgsConstructor public class LoggingAspect { - @Pointcut("@within(org.mandarin.booking.app.Log) || @annotation(org.mandarin.booking.app.Log)") - public void loggableTarget() {} - - @Around("loggableTarget()") + @Around("@within(org.mandarin.booking.app.Log) || @annotation(org.mandarin.booking.app.Log)") public Object logMethodExecution(ProceedingJoinPoint joinPoint) throws Throwable { Logger targetLogger = selectTargetLogger(joinPoint); String level = resolveScope(joinPoint); diff --git a/src/main/java/org/mandarin/booking/app/TokenUtils.java b/src/main/java/org/mandarin/booking/app/TokenUtils.java index 44a7bed..31a7a3d 100644 --- a/src/main/java/org/mandarin/booking/app/TokenUtils.java +++ b/src/main/java/org/mandarin/booking/app/TokenUtils.java @@ -1,13 +1,15 @@ package org.mandarin.booking.app; +import java.util.Collection; import org.mandarin.booking.domain.member.TokenHolder; +import org.springframework.security.core.GrantedAuthority; public interface TokenUtils { TokenHolder generateToken(String refreshToken); - TokenHolder generateToken(String userId, String nickName); + TokenHolder generateToken(String userId, String nickName, Collection authorities); String getClaim(String token, String claimName); - void validateToken(String token); + Collection getClaims(String token, String claimName); } diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java b/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java index 1ffe1df..2e46be4 100644 --- a/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java +++ b/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java @@ -6,9 +6,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; -@Converter() +@Converter public class MemberAuthorityConverter implements AttributeConverter, String> { private static final String DELIM = ","; @@ -19,7 +20,8 @@ public String convertToDatabaseColumn(List attribute) { return ""; } return attribute.stream() - .map(MemberAuthority::name) + .filter(Objects::nonNull) + .map(MemberAuthority::getAuthority) .collect(Collectors.joining(DELIM)); } @@ -30,8 +32,10 @@ public List convertToEntityAttribute(String dbData) { } return Arrays.stream(dbData.split(DELIM)) - .map(String::trim) .filter(s -> !s.isEmpty()) + .map(String::trim) + .map(String::toUpperCase) + .map(s -> s.substring(5)) .map(MemberAuthority::valueOf) .distinct() .collect(Collectors.toCollection(ArrayList::new)); diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index c60cb9a..a177dd9 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -7,12 +7,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import java.util.Collection; import org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; import org.mandarin.booking.domain.member.SecurePasswordEncoder; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.security.core.GrantedAuthority; public class IntegrationTestUtils { private final TestRestTemplate testRestTemplate; @@ -33,6 +36,16 @@ public IntegrationTestUtils(TestRestTemplate testRestTemplate, this.objectMapper = objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } + public String getValidRefreshToken() { + var member = insertDummyMember(generateUserId(), generatePassword()); + return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()).refreshToken(); + } + + public String getAuthToken() { + var member = this.insertDummyMember(); + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + } + public Member insertDummyMember(String userId, String password) { var command = new MemberCreateCommand( generateNickName(), @@ -55,8 +68,8 @@ public TestResult post(String path, T request) { .setContext(testRestTemplate, objectMapper); } - public String getUserToken(String userId, String nickname) { - return tokenUtils.generateToken(userId, nickname).accessToken(); + public TokenHolder getUserToken(String userId, String nickname, Collection authorities) { + return tokenUtils.generateToken(userId, nickname, authorities); } public Member insertDummyMember() { diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java index 162a806..26809bf 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -3,17 +3,27 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; -import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; -import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; -import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController; +import org.mandarin.booking.adapter.security.JwtFilterTest.TestSecurityConfig; import org.mandarin.booking.app.TokenUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationProvider; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -35,19 +45,14 @@ void withoutAuth(@Autowired IntegrationTestUtils testUtils) { } @Test - void withAuth(@Autowired IntegrationTestUtils testUtils, - @Autowired TokenUtils tokenUtils) { - var userId = generateUserId(); - var nickName = generateNickName(); - var accessToken = tokenUtils.generateToken(userId, nickName).accessToken(); - var password = generatePassword(); - testUtils.insertDummyMember(userId, password); + void withAuth(@Autowired IntegrationTestUtils testUtils) { + var accessToken = testUtils.getAuthToken(); // Act & Assert var response = testUtils.get( "/test/with-auth" ) - .withHeader("Authorization", "Bearer " + accessToken) + .withHeader("Authorization", accessToken) .assertSuccess(String.class); // assertThat(response.getStatus()).isEqualTo(SUCCESS); @@ -61,10 +66,10 @@ void failToAuth(@Autowired IntegrationTestUtils testUtils) { // Act & Assert var response = testUtils.get("/test/with-auth") - .withHeader("Authorization", "Bearer " + invalidToken) + .withHeader("Authorization", invalidToken) .assertFailure(); assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); - assertThat(response.getData()).isEqualTo("토큰 검증에 실패했습니다."); + assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); } @RestController @@ -80,4 +85,33 @@ public String pingWithAuth() { return PONG_WITH_AUTH; } } + @TestConfiguration + @EnableMethodSecurity + @Order(0) + static class TestSecurityConfig { + + @Bean + SecurityFilterChain testOnlyEndpoints( + HttpSecurity http, + AuthenticationEntryPoint authenticationEntryPoint, + AccessDeniedHandler accessDeniedHandler, TokenUtils tokenUtils, + AuthenticationProvider authenticationProvider) throws Exception { + http + .securityMatcher("/test/**") + .authorizeHttpRequests(a -> a + .requestMatchers("/test/without-auth").permitAll() + .requestMatchers("/test/with-auth").authenticated() + ) + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider::authenticate), + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + } + } diff --git a/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java b/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java deleted file mode 100644 index cb29fcd..0000000 --- a/src/test/java/org/mandarin/booking/adapter/security/TestSecurityConfig.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.mandarin.booking.adapter.security; - -import org.mandarin.booking.app.TokenUtils; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.core.annotation.Order; -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.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@TestConfiguration -@EnableMethodSecurity -@Order(0) -class TestSecurityConfig { - - @Bean - SecurityFilterChain testOnlyEndpoints( - HttpSecurity http, - AuthenticationEntryPoint authenticationEntryPoint, - AccessDeniedHandler accessDeniedHandler, TokenUtils tokenUtils) throws Exception { - http - .securityMatcher("/test/**") - .authorizeHttpRequests(a -> a - .requestMatchers("/test/without-auth").permitAll() - .requestMatchers("/test/with-auth").authenticated() - ) - .formLogin(AbstractHttpConfigurer::disable) - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(ex -> ex - .authenticationEntryPoint(authenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler)) - .addFilterBefore(new JwtFilter(tokenUtils), - UsernamePasswordAuthenticationFilter.class); - return http.build(); - } -} diff --git a/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java index 7fb1570..15894a2 100644 --- a/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java @@ -3,15 +3,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mandarin.booking.JwtTestUtils.assertJwtFormat; import static org.mandarin.booking.JwtTestUtils.getExpiration; -import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; -import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; -import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.domain.member.MemberAuthority.USER; +import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; +import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; import io.jsonwebtoken.security.Keys; import java.util.Date; +import java.util.List; import javax.crypto.SecretKey; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -30,14 +32,10 @@ public class POST_specs { @Test void 올바른_refresh_token으로_요청하면_200을_응답한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TokenUtils tokenUtils + @Autowired IntegrationTestUtils testUtils ) { // Arrange - var userId = generateUserId(); - var nickName = generateNickName(); - testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenUtils, userId, nickName); + var validRefreshToken = testUtils.getValidRefreshToken(); var request = new ReissueRequest(validRefreshToken); // Act @@ -53,14 +51,10 @@ public class POST_specs { @Test void 올바른_refresh_token으로_요청하면_새로운_access_token과_refresh_token을_발급해_응답한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TokenUtils tokenUtils + @Autowired IntegrationTestUtils testUtils ) { // Arrange - var userId = generateUserId(); - var nickName = generateNickName(); - testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenUtils, userId, nickName); + var validRefreshToken = testUtils.getValidRefreshToken(); var request = new ReissueRequest(validRefreshToken); // Act @@ -79,15 +73,11 @@ public class POST_specs { @Test void 응답받은_access_toke과_refresh_toke은_유효한_JWT_형식이다( @Autowired IntegrationTestUtils testUtils, - @Autowired TokenUtils tokenUtils, @Value("${jwt.token.secret}") String key ) { // Arrange SecretKey secretKey = Keys.hmacShaKeyFor(key.getBytes()); - var userId = generateUserId(); - var nickName = generateNickName(); - testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenUtils, userId, nickName); + var validRefreshToken = testUtils.getValidRefreshToken(); var request = new ReissueRequest(validRefreshToken); // Act @@ -155,12 +145,11 @@ public class POST_specs { class ReissueShortToken{ @Test void 만료된_refresh_token으로_요청하면_401_Unauthorize가_발생한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TokenUtils tokenUtils + @Autowired IntegrationTestUtils testUtils ) throws InterruptedException { // Arrange - tokenUtils.generateToken(generateUserId(), generateNickName()); - var request = new ReissueRequest(getValidRefreshToken(tokenUtils, generateUserId(), generateNickName())); + var request = new ReissueRequest( + testUtils.getValidRefreshToken()); Thread.sleep(100); //TODO 2025 08 18 16:47:00 : 시간 의존적 코드가 테스트 속도에 영향을 미치지 않도록 개선 필요 // Act @@ -181,9 +170,7 @@ class ReissueShortToken{ @Autowired TokenUtils tokenUtils ) { // Arrange - var userId = generateUserId(); - var nickName = generateNickName(); - var validRefreshToken = getValidRefreshToken(tokenUtils, userId, nickName); + var validRefreshToken = tokenUtils.generateToken(generateUserId(), generateNickName(), List.of(USER)).refreshToken(); var request = new ReissueRequest(validRefreshToken); // user 생성 안함 @@ -198,10 +185,4 @@ class ReissueShortToken{ // Assert assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); } - - - - private static String getValidRefreshToken(TokenUtils tokenUtils, String userId, String nickName) { - return tokenUtils.generateToken(userId, nickName).refreshToken(); - } } diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java index 6e4ecad..d461ec8 100644 --- a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -24,7 +24,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var jwtToken = getAuthToken(testUtils); + var jwtToken = testUtils.getAuthToken(); var request = generateMovieRegisterRequest(); @@ -65,7 +65,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var jwtToken = getAuthToken(testUtils); + var jwtToken = testUtils.getAuthToken(); // Act var response = testUtils.post( @@ -84,7 +84,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = getAuthToken(testUtils); + var authToken = testUtils.getAuthToken(); // Act var response = testUtils.post( @@ -103,7 +103,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = getAuthToken(testUtils); + var authToken = testUtils.getAuthToken(); // 잘못된 날짜 형식 var request = generateMovieRegisterRequest( "영화 제목", "감독 이름", 148, "SF", "21-07-2010", "AGE12" @@ -121,11 +121,6 @@ public class POST_specs { assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } - private String getAuthToken(IntegrationTestUtils testUtils) { - var member = testUtils.insertDummyMember(); - return "Bearer " + testUtils.getUserToken(member.getUserId(), member.getNickName()); - } - static List nullOrBlankElementRequests(){ return List.of( generateMovieRegisterRequest("", "감독 이름", 148, "SF", "2010-07-21", "AGE12"), From 93f78ac8d24a8fab5bc2ab2f36a5c76be67f7d4d Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 28 Aug 2025 10:16:43 +0900 Subject: [PATCH 17/26] feat: enhance integration tests for JWT authentication and user role validation --- .../mandarin/booking/app/JwtTokenUtils.java | 3 ++ .../booking/IntegrationTestUtils.java | 47 ++++++++++++++----- .../adapter/security/JwtFilterTest.java | 40 ++++++++++++++++ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java index 3cd727c..bc5b2a3 100644 --- a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java +++ b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java @@ -6,6 +6,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Date; @@ -71,6 +72,8 @@ public String getClaim(String token, String claimName) { public Collection getClaims(String token, String claimName) { Jws claims = parseClaims(token); var rawPayload = claims.getPayload().get(claimName, String.class); + if(rawPayload.isBlank()) + return new ArrayList<>(); return Arrays.stream(rawPayload.split(",")).toList(); } diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index a177dd9..2c029af 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -8,14 +8,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import java.util.Collection; +import java.util.List; import org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; +import org.mandarin.booking.domain.member.MemberAuthority; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.security.core.GrantedAuthority; +import org.springframework.test.util.ReflectionTestUtils; public class IntegrationTestUtils { private final TestRestTemplate testRestTemplate; @@ -36,6 +39,16 @@ public IntegrationTestUtils(TestRestTemplate testRestTemplate, this.objectMapper = objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } + public TestResult get(String path) { + return new TestResult(path, null) + .setContext(testRestTemplate, objectMapper); + } + + public TestResult post(String path, T request) { + return new TestResult(path, request) + .setContext(testRestTemplate, objectMapper); + } + public String getValidRefreshToken() { var member = insertDummyMember(generateUserId(), generatePassword()); return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()).refreshToken(); @@ -43,7 +56,15 @@ public String getValidRefreshToken() { public String getAuthToken() { var member = this.insertDummyMember(); - return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + } + + public String getAuthToken(Member member) { + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + } + + public TokenHolder getUserToken(String userId, String nickname, Collection authorities) { + return tokenUtils.generateToken(userId, nickname, authorities); } public Member insertDummyMember(String userId, String password) { @@ -58,18 +79,18 @@ public Member insertDummyMember(String userId, String password) { ); } - public TestResult get(String path) { - return new TestResult(path, null) - .setContext(testRestTemplate, objectMapper); - } - - public TestResult post(String path, T request) { - return new TestResult(path, request) - .setContext(testRestTemplate, objectMapper); - } - - public TokenHolder getUserToken(String userId, String nickname, Collection authorities) { - return tokenUtils.generateToken(userId, nickname, authorities); + public Member insertDummyMember(String userId, String nickName, List authorities) { + var command = new MemberCreateCommand( + nickName, + userId, + generatePassword(), + generateEmail() + ); + var member = Member.create(command, securePasswordEncoder); + ReflectionTestUtils.setField(member, "authorities", authorities); + return memberRepository.insert( + member + ); } public Member insertDummyMember() { diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java index 26809bf..39a5c93 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -1,9 +1,11 @@ package org.mandarin.booking.adapter.security; import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; +import java.util.List; import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; @@ -33,6 +35,7 @@ class JwtFilterTest { private static final String PONG_WITHOUT_AUTH = "pong without auth"; private static final String PONG_WITH_AUTH = "pong with auth"; + private static final String WITH_USER_ROLE = "pong with user role"; @Test @@ -72,6 +75,37 @@ void failToAuth(@Autowired IntegrationTestUtils testUtils) { assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); } + @Test + void failWithInvalidBearer(@Autowired IntegrationTestUtils testUtils) { + // Arrange + var invalidBearer = "Bearer invalid-token"; + + // Act + var response = testUtils.get("/test/with-auth") + .withHeader("Authorization", invalidBearer) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + } + + @Test + void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testUtils){ + // Arrange + var member = testUtils.insertDummyMember("dummy", "dummy", List.of()); + var accessToken = testUtils.getAuthToken(member); + + // Act + var response = testUtils.get("/test/with-user-role") + .withHeader("Authorization", accessToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(FORBIDDEN); + assertThat(response.getData()).isEqualTo("Access Denied"); + } + @RestController @RequestMapping("/test") static class TestAuthController { @@ -84,6 +118,11 @@ public String ping() { public String pingWithAuth() { return PONG_WITH_AUTH; } + + @GetMapping("/with-user-role") + public String pingWithUserRole() { + return WITH_USER_ROLE; + } } @TestConfiguration @EnableMethodSecurity @@ -101,6 +140,7 @@ SecurityFilterChain testOnlyEndpoints( .authorizeHttpRequests(a -> a .requestMatchers("/test/without-auth").permitAll() .requestMatchers("/test/with-auth").authenticated() + .requestMatchers("/test/with-user-role").hasAuthority("USER") ) .formLogin(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) From a3b3dff83ea5701b1e9f28236fd4fa3b98a4fb22 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 28 Aug 2025 11:52:16 +0900 Subject: [PATCH 18/26] feat: implement movie registration functionality with validation and response handling --- docs/specs/api/movie_register.md | 6 +++ .../adapter/security/SecurityConfig.java | 1 + .../adapter/webapi/MovieController.java | 13 +++-- .../mandarin/booking/app/MovieService.java | 25 +++++++++ .../app/persist/MovieCommandRepository.java | 17 ++++++ .../app/persist/MovieJpaRepository.java | 7 +++ .../booking/app/port/MovieRegisterer.java | 8 +++ .../mandarin/booking/domain/movie/Movie.java | 38 +++++--------- .../domain/movie/MovieCreateCommand.java | 52 +++++++++++++++++++ .../booking/domain/movie/MovieException.java | 9 ++++ .../domain/movie/MovieRegisterResponse.java | 4 ++ src/main/resources/application-test.yml | 4 ++ .../booking/IntegrationTestUtils.java | 5 ++ .../booking/webapi/movie/POST_specs.java | 48 ++++++++++++----- 14 files changed, 193 insertions(+), 44 deletions(-) create mode 100644 src/main/java/org/mandarin/booking/app/MovieService.java create mode 100644 src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java create mode 100644 src/main/java/org/mandarin/booking/app/persist/MovieJpaRepository.java create mode 100644 src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java create mode 100644 src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java create mode 100644 src/main/java/org/mandarin/booking/domain/movie/MovieException.java create mode 100644 src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index 9de672a..78debb8 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -46,12 +46,18 @@ ```json { + "status": "SUCCESS", + "data": { + "movieId": 1 + }, + "timestamp": "2024-06-10T12:34:56.789Z" } ``` ### 테스트 - [x] 올바른 요청을 보내면 status가 SUCCESS이다 +- [ ] 올바른 요청을 보내면 응답 본문에 movieId가 존재한다 - [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 - [x] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index 5ed93c8..4d37022 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -36,6 +36,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .requestMatchers(HttpMethod.POST, "/api/members").permitAll() .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/auth/reissue").permitAll() + .requestMatchers(HttpMethod.POST, "/api/movies").hasAuthority("ROLE_DISTRIBUTOR") .anyRequest().authenticated() ) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java index 68e0634..79da80c 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java @@ -1,21 +1,20 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; -import org.mandarin.booking.app.Log; +import org.mandarin.booking.app.port.MovieRegisterer; import org.mandarin.booking.domain.movie.MovieRegisterRequest; +import org.mandarin.booking.domain.movie.MovieRegisterResponse; 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/movie") -public class MovieController { +@RequestMapping("/api/movies") +public record MovieController(MovieRegisterer movieRegisterer) { - @Log @PostMapping - public void register(@RequestBody @Valid MovieRegisterRequest request) { - // Movie registration logic would go here - + public MovieRegisterResponse register(@RequestBody @Valid MovieRegisterRequest request) { + return movieRegisterer.register(request); } } diff --git a/src/main/java/org/mandarin/booking/app/MovieService.java b/src/main/java/org/mandarin/booking/app/MovieService.java new file mode 100644 index 0000000..1ab43ac --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/MovieService.java @@ -0,0 +1,25 @@ +package org.mandarin.booking.app; + +import static java.util.Objects.requireNonNull; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MovieCommandRepository; +import org.mandarin.booking.app.port.MovieRegisterer; +import org.mandarin.booking.domain.movie.Movie; +import org.mandarin.booking.domain.movie.MovieCreateCommand; +import org.mandarin.booking.domain.movie.MovieRegisterRequest; +import org.mandarin.booking.domain.movie.MovieRegisterResponse; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MovieService implements MovieRegisterer { + private final MovieCommandRepository commandRepository; + @Override + public MovieRegisterResponse register(MovieRegisterRequest request) { + var command = MovieCreateCommand.from(request); + var movie = Movie.create(command); + var savedMovie = commandRepository.insert(movie); + return new MovieRegisterResponse(requireNonNull(savedMovie.getId())); + } +} diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java b/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java new file mode 100644 index 0000000..4a135d6 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java @@ -0,0 +1,17 @@ +package org.mandarin.booking.app.persist; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.movie.Movie; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Transactional +@RequiredArgsConstructor +public class MovieCommandRepository { + private final MovieJpaRepository jpaRepository; + + public Movie insert(Movie movie){ + return jpaRepository.save(movie); + } +} diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieJpaRepository.java b/src/main/java/org/mandarin/booking/app/persist/MovieJpaRepository.java new file mode 100644 index 0000000..5fe959c --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/MovieJpaRepository.java @@ -0,0 +1,7 @@ +package org.mandarin.booking.app.persist; + +import org.mandarin.booking.domain.movie.Movie; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MovieJpaRepository extends JpaRepository { +} diff --git a/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java b/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java new file mode 100644 index 0000000..b781160 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.app.port; + +import org.mandarin.booking.domain.movie.MovieRegisterRequest; +import org.mandarin.booking.domain.movie.MovieRegisterResponse; + +public interface MovieRegisterer { + MovieRegisterResponse register(MovieRegisterRequest request); +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/Movie.java b/src/main/java/org/mandarin/booking/domain/movie/Movie.java index e9eef5c..99dd535 100644 --- a/src/main/java/org/mandarin/booking/domain/movie/Movie.java +++ b/src/main/java/org/mandarin/booking/domain/movie/Movie.java @@ -1,11 +1,14 @@ package org.mandarin.booking.domain.movie; -import jakarta.annotation.Nullable; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; import java.time.LocalDate; -import java.util.LinkedHashSet; +import java.util.HashSet; import java.util.Set; import org.mandarin.booking.domain.AbstractEntity; @@ -30,7 +33,10 @@ public class Movie extends AbstractEntity { private String posterUrl; - private Set cast = new LinkedHashSet<>(); + @ElementCollection + @CollectionTable(name = "movie_cast", joinColumns = @JoinColumn(name = "movie_id")) + @Column(name = "actor_name") + private Set casts = new HashSet<>(); protected Movie() { } @@ -43,7 +49,7 @@ private Movie(String title, Rating rating, String synopsis, String posterUrl, - Set cast) { + Set casts) { this.title = title; this.director = director; @@ -53,28 +59,12 @@ private Movie(String title, this.rating = rating; this.synopsis = synopsis; this.posterUrl = posterUrl; - if (cast != null) { - this.cast.addAll(cast); - } + this.casts.addAll(casts); } - public static Movie create(String title, - String director, - Integer runtimeMinutes, - Genre genre, - LocalDate releaseDate, - Rating rating, - @Nullable - String synopsis, - @Nullable - String posterUrl, - @Nullable - Set cast) { - if (runtimeMinutes < 0) { - throw new IllegalArgumentException("Runtime minutes cannot be negative");//TODO 2025 08 19 11:35:28 : custom exception - } - - return new Movie(title, director, runtimeMinutes, genre, releaseDate, rating, synopsis, posterUrl, cast); + public static Movie create(MovieCreateCommand command) { + return new Movie(command.getTitle(), command.getDirector(), command.getRuntimeMinutes(), command.getGenre(), + command.getReleaseDate(), command.getRating(), command.getSynopsis(), command.getPosterUrl(), command.getCast()); } public enum Genre { diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java new file mode 100644 index 0000000..645387a --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java @@ -0,0 +1,52 @@ +package org.mandarin.booking.domain.movie; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import lombok.Getter; +import org.mandarin.booking.domain.movie.Movie.Genre; +import org.mandarin.booking.domain.movie.Movie.Rating; + +@Getter +public class MovieCreateCommand { + private final String title; + private final Genre genre; + private final int runtimeMinutes; + private final String director; + private final String synopsis; + private final String posterUrl; + private final LocalDate releaseDate; + private final Rating rating; + private final Set cast; + + private MovieCreateCommand(String title, Genre genre, int runtimeMinutes, String director, String synopsis, + String posterUrl, LocalDate releaseDate, Rating rating, Set cast) { + if (runtimeMinutes < 0) { + throw new MovieException("running time은 음수일 수 없습니다."); + } + this.title = title; + this.genre = genre; + this.runtimeMinutes = runtimeMinutes; + this.director = director; + this.synopsis = synopsis; + this.posterUrl = posterUrl; + this.releaseDate = releaseDate; + this.rating = rating; + this.cast = cast; + } + + public static MovieCreateCommand from(MovieRegisterRequest request) { + + return new MovieCreateCommand( + request.title(), + Genre.valueOf(request.genre()), + request.runtimeMinutes(), + request.director(), + request.synopsis(), + request.posterUrl(), + LocalDate.parse(request.releaseDate()), + Rating.valueOf(request.rating()), + new HashSet<>(request.casts()) + ); + } +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieException.java b/src/main/java/org/mandarin/booking/domain/movie/MovieException.java new file mode 100644 index 0000000..a0da274 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieException.java @@ -0,0 +1,9 @@ +package org.mandarin.booking.domain.movie; + +import org.mandarin.booking.domain.DomainException; + +public class MovieException extends DomainException { + public MovieException(String message) { + super(message); + } +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java new file mode 100644 index 0000000..496e724 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java @@ -0,0 +1,4 @@ +package org.mandarin.booking.domain.movie; + +public record MovieRegisterResponse(Long movieId) { +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index f90a9a7..4273eb5 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -21,3 +21,7 @@ jwt: secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGUtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGU access: 600000 refresh: 1800000 + +logging: + level: + org.springframework.security: TRACE diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index 2c029af..e3be1fd 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -96,5 +96,10 @@ public Member insertDummyMember(String userId, String nickName, List nullOrBlankElementRequests(){ return List.of( From 50a212904876c529092548c0cb2198832e07d865 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 28 Aug 2025 11:59:26 +0900 Subject: [PATCH 19/26] feat: remove unnecessary checks in authentication entry point and response wrapper --- .../adapter/security/CustomAuthenticationEntryPoint.java | 3 --- .../org/mandarin/booking/adapter/webapi/ResponseWrapper.java | 4 ---- .../org/mandarin/booking/domain/movie/MovieCreateCommand.java | 3 --- 3 files changed, 10 deletions(-) diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java index b13dc55..8142193 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java @@ -19,9 +19,6 @@ public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { - if (response.isCommitted()) { - return; - } response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java b/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java index a423cbd..270fbb1 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java @@ -22,10 +22,6 @@ public Object beforeBodyWrite(final Object body, final MethodParameter returnTyp final MediaType selectedContentType, final Class> selectedConverterType, final ServerHttpRequest request, final ServerHttpResponse response) { - if (body instanceof ApiResponse) { - return body; - } - return new SuccessResponse<>(ApiStatus.SUCCESS, body); } } diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java index 645387a..60ba73f 100644 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java @@ -21,9 +21,6 @@ public class MovieCreateCommand { private MovieCreateCommand(String title, Genre genre, int runtimeMinutes, String director, String synopsis, String posterUrl, LocalDate releaseDate, Rating rating, Set cast) { - if (runtimeMinutes < 0) { - throw new MovieException("running time은 음수일 수 없습니다."); - } this.title = title; this.genre = genre; this.runtimeMinutes = runtimeMinutes; From 606a3f2431ab7c18a664618f47208928d61b8476 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 29 Aug 2025 10:09:14 +0900 Subject: [PATCH 20/26] feat: add NOT_FOUND status and handle NoHandlerFoundException in global exception handler --- docs/todo.md | 7 +- .../booking/adapter/security/JwtFilter.java | 13 ++-- .../adapter/security/SecurityConfig.java | 21 +++++- .../booking/adapter/webapi/ApiStatus.java | 1 + .../webapi/GlobalExceptionHandler.java | 11 ++- src/main/resources/application-local.yml | 1 + src/main/resources/application-test.yml | 1 + .../adapter/security/JwtFilterTest.java | 67 ++++++++++--------- .../webapi/GlobalExceptionHandlerTest.java | 23 +++++++ 9 files changed, 104 insertions(+), 41 deletions(-) create mode 100644 src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java diff --git a/docs/todo.md b/docs/todo.md index 598f179..3683696 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -8,5 +8,8 @@ - [x] `TestResult`에 인증을 위한 토큰 입력부 추가 2025.08.27 -- [ ] 테스트 케이스 충족 -- [ ] 없는 엔드포인트에 대한 처리 어찌할지 고민 +- [x] 테스트 케이스 충족 + + 2025.08.28 +- [x] 없는 엔드포인트에 대한 처리 어찌할지 고민 +- [ ] 리펙터링 diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java index d29d003..31d9326 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.mandarin.booking.app.TokenUtils; @@ -30,10 +31,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { var userId = tokenUtils.getClaim(token, "userId"); - var roles = tokenUtils.getClaims(token, "roles"); - var authorities = roles.stream() - .map(r -> r.substring(5)) // "ROLE_" 접두사 제거 - .map(MemberAuthority::valueOf).toList(); + var authorities = getAuthorities(token); var authToken = new CustomMemberAuthenticationToken(userId, authorities); var authenticate = authenticationManager.authenticate(authToken); @@ -52,6 +50,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } + private List getAuthorities(String token) { + var roles = tokenUtils.getClaims(token, "roles"); + return roles.stream() + .map(r -> r.substring(5)) // "ROLE_" 접두사 제거 + .map(MemberAuthority::valueOf).toList(); + } + private boolean isBearer(String header) { return header != null && header.startsWith(PREFIX); } diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index 4d37022..9d6b2b2 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.mandarin.booking.app.TokenUtils; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -26,12 +27,14 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, + @Order(1) + public SecurityFilterChain apiChain(HttpSecurity http, AuthenticationProvider authenticationProvider, AuthenticationEntryPoint authenticationEntryPoint, AccessDeniedHandler accessDeniedHandler) throws Exception { http + .securityMatcher("/api/**") .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.POST, "/api/members").permitAll() .requestMatchers("/api/auth/login").permitAll() @@ -49,4 +52,20 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + @Order(2) + SecurityFilterChain publicChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/**") + .authorizeHttpRequests(auth -> auth + .requestMatchers("/error", "/assets/**", "/favicon.ico").permitAll() + .anyRequest().permitAll() + ) + .securityContext(AbstractHttpConfigurer::disable) + .sessionManagement(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable); + + return http.build(); + } } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java index d6f777b..bba5da6 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java @@ -10,4 +10,5 @@ public enum ApiStatus { UNAUTHORIZED, INTERNAL_SERVER_ERROR, FORBIDDEN, + NOT_FOUND } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java index f4f7089..38205c9 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java @@ -1,13 +1,17 @@ package org.mandarin.booking.adapter.webapi; import static java.util.Objects.requireNonNull; -import static org.mandarin.booking.adapter.webapi.ApiStatus.*; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.webapi.ApiStatus.INTERNAL_SERVER_ERROR; +import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; import org.mandarin.booking.domain.DomainException; import org.mandarin.booking.domain.member.AuthException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; @RestControllerAdvice public class GlobalExceptionHandler { @@ -27,4 +31,9 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e return new ErrorResponse(BAD_REQUEST, requireNonNull(ex.getBindingResult().getFieldError()).getDefaultMessage()); } + + @ExceptionHandler(NoHandlerFoundException.class) + public ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException ex) { + return new ErrorResponse(NOT_FOUND, ex.getMessage()); + } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index cd2f37d..70689f7 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -14,6 +14,7 @@ spring: docker: compose: lifecycle-management: start_only + web.resources.add-mappings: false jwt: token: secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGUtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGU diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 4273eb5..8bf4f10 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -15,6 +15,7 @@ spring: format_sql: true show_sql: true + web.resources.add-mappings: false jwt: token: diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java index 39a5c93..a47b6d3 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -10,7 +10,7 @@ import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController; -import org.mandarin.booking.adapter.security.JwtFilterTest.TestSecurityConfig; +import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController.TestSecurityConfig; import org.mandarin.booking.app.TokenUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; @@ -89,9 +89,9 @@ void failWithInvalidBearer(@Autowired IntegrationTestUtils testUtils) { assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); } - + @Test - void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testUtils){ + void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testUtils) { // Arrange var member = testUtils.insertDummyMember("dummy", "dummy", List.of()); var accessToken = testUtils.getAuthToken(member); @@ -100,7 +100,7 @@ void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testU var response = testUtils.get("/test/with-user-role") .withHeader("Authorization", accessToken) .assertFailure(); - + // Assert assertThat(response.getStatus()).isEqualTo(FORBIDDEN); assertThat(response.getData()).isEqualTo("Access Denied"); @@ -123,35 +123,36 @@ public String pingWithAuth() { public String pingWithUserRole() { return WITH_USER_ROLE; } - } - @TestConfiguration - @EnableMethodSecurity - @Order(0) - static class TestSecurityConfig { - - @Bean - SecurityFilterChain testOnlyEndpoints( - HttpSecurity http, - AuthenticationEntryPoint authenticationEntryPoint, - AccessDeniedHandler accessDeniedHandler, TokenUtils tokenUtils, - AuthenticationProvider authenticationProvider) throws Exception { - http - .securityMatcher("/test/**") - .authorizeHttpRequests(a -> a - .requestMatchers("/test/without-auth").permitAll() - .requestMatchers("/test/with-auth").authenticated() - .requestMatchers("/test/with-user-role").hasAuthority("USER") - ) - .formLogin(AbstractHttpConfigurer::disable) - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(ex -> ex - .authenticationEntryPoint(authenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler)) - .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider::authenticate), - UsernamePasswordAuthenticationFilter.class); - return http.build(); + + @TestConfiguration + @EnableMethodSecurity + static class TestSecurityConfig { + + @Bean + @Order(0) + SecurityFilterChain testOnlyEndpoints( + HttpSecurity http, + AuthenticationEntryPoint authenticationEntryPoint, + AccessDeniedHandler accessDeniedHandler, TokenUtils tokenUtils, + AuthenticationProvider authenticationProvider) throws Exception { + http + .securityMatcher("/test/**") + .authorizeHttpRequests(a -> a + .requestMatchers("/test/without-auth").permitAll() + .requestMatchers("/test/with-auth").authenticated() + .requestMatchers("/test/with-user-role").hasAuthority("USER") + ) + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider::authenticate), + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } } - } + } } diff --git a/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java b/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..c67c589 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java @@ -0,0 +1,23 @@ +package org.mandarin.booking.adapter.webapi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; + +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.IntegrationTestUtils; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +class GlobalExceptionHandlerTest { + + @Test + void endpointNotFound(@Autowired IntegrationTestUtils testUtils){ + // Act + var request = testUtils.get("/not-found") + .assertFailure(); + + // Assert + assertThat(request.getStatus()).isEqualTo(NOT_FOUND); + } +} From 0edecd05c4f18258ccabf8c36c22dc59e46f88ae Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 29 Aug 2025 12:20:38 +0900 Subject: [PATCH 21/26] feat: enhance authentication handling with improved error messaging and add unit tests --- build.gradle | 1 + .../CustomAuthenticationEntryPoint.java | 9 +- .../booking/adapter/security/JwtFilter.java | 28 ++- .../mandarin/booking/app/JwtTokenUtils.java | 2 - .../booking/domain/member/MemberDetails.java | 12 +- .../booking/domain/movie/MovieException.java | 9 - .../CustomAuthenticationEntryPointTest.java | 77 +++++++ .../CustomAuthenticationProviderTest.java | 34 ++++ .../adapter/security/JwtFilterTest.java | 17 ++ .../booking/app/LoggingAspectTest.java | 192 ++++++++++++++++++ .../org.mockito.plugins.MockMaker | 1 + 11 files changed, 355 insertions(+), 27 deletions(-) delete mode 100644 src/main/java/org/mandarin/booking/domain/movie/MovieException.java create mode 100644 src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java create mode 100644 src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java create mode 100644 src/test/java/org/mandarin/booking/app/LoggingAspectTest.java create mode 100644 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/build.gradle b/build.gradle index 53e4f61..3b64665 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' byteBuddyAgent 'net.bytebuddy:byte-buddy-agent:1.17.6' + testImplementation 'org.mockito:mockito-inline:5.2.0' } tasks.named('test') { diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java index 8142193..83df390 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java @@ -23,8 +23,15 @@ public void commence(HttpServletRequest request, HttpServletResponse response, response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); var exception = (Exception)(request.getAttribute("exception")); - var message = exception != null ? exception.getMessage() : authException.getMessage(); + var message = getMessage(authException, exception); var errorResponse = new ErrorResponse(ApiStatus.UNAUTHORIZED, message); objectMapper.writeValue(response.getWriter(), errorResponse); } + + private String getMessage(AuthenticationException authException, Exception exception) { + if (exception != null) { + return exception.getMessage(); + } + return authException.getMessage(); + } } diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java index 31d9326..cc67cd3 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java @@ -19,12 +19,22 @@ @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { private static final String PREFIX = "Bearer "; + private static final String AUTHORIZATION = "Authorization"; + private static final String EXCEPTION = "exception"; + private final TokenUtils tokenUtils; private final AuthenticationManager authenticationManager; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String header = request.getHeader("Authorization"); + String header = request.getHeader(AUTHORIZATION); + if (isTokenBlank(header)) { + SecurityContextHolder.clearContext(); + request.setAttribute("exception", new AuthException("토큰이 비어있습니다.")); + filterChain.doFilter(request, response); + return; + } if (isBearer(header)) { String token = header.substring(PREFIX.length()); @@ -39,12 +49,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } catch (AuthException e) { log.error("Authentication Error: {}", e.getMessage()); SecurityContextHolder.clearContext(); - request.setAttribute("exception", e); + request.setAttribute(EXCEPTION, e); } } - if(SecurityContextHolder.getContext().getAuthentication() == null){ - request.setAttribute("exception", new AuthException("유효한 토큰이 없습니다.")); + if (isAnonymous()) { + request.setAttribute(EXCEPTION, new AuthException("유효한 토큰이 없습니다.")); } filterChain.doFilter(request, response); @@ -57,7 +67,15 @@ private List getAuthorities(String token) { .map(MemberAuthority::valueOf).toList(); } + private boolean isTokenBlank(String header) { + return header == null || header.equals("Bearer"); + } + private boolean isBearer(String header) { - return header != null && header.startsWith(PREFIX); + return header.startsWith(PREFIX); + } + + private boolean isAnonymous() { + return SecurityContextHolder.getContext().getAuthentication() == null; } } diff --git a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java index bc5b2a3..4874b77 100644 --- a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java +++ b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java @@ -104,8 +104,6 @@ private Jws parseClaims(String token) { .verifyWith(key) .build() .parseSignedClaims(token); - } catch (IllegalArgumentException e) { - throw new AuthException("토큰이 비어있습니다."); } catch (JwtException e) { throw new AuthException("토큰 검증에 실패했습니다."); } diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java index 94fb1db..b302d75 100644 --- a/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java +++ b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java @@ -1,9 +1,11 @@ package org.mandarin.booking.domain.member; import java.util.Collection; +import lombok.Getter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +@Getter public class MemberDetails implements UserDetails { private final String userId; private final String password; @@ -22,16 +24,6 @@ public static MemberDetails from(Member member) { return new MemberDetails(userId, password, authorities); } - @Override - public Collection getAuthorities() { - return authorities; - } - - @Override - public String getPassword() { - return password; - } - @Override public String getUsername() { return userId; diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieException.java b/src/main/java/org/mandarin/booking/domain/movie/MovieException.java deleted file mode 100644 index a0da274..0000000 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieException.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.mandarin.booking.domain.movie; - -import org.mandarin.booking.domain.DomainException; - -public class MovieException extends DomainException { - public MovieException(String message) { - super(message); - } -} diff --git a/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java b/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java new file mode 100644 index 0000000..dd81059 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java @@ -0,0 +1,77 @@ +package org.mandarin.booking.adapter.security; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.InsufficientAuthenticationException; + +@ExtendWith(MockitoExtension.class) +class CustomAuthenticationEntryPointTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final CustomAuthenticationEntryPoint entryPoint = new CustomAuthenticationEntryPoint(objectMapper); + + @BeforeEach + void setUp() { + objectMapper.registerModule(new JavaTimeModule()); + } + + @Test + @DisplayName("When exception attribute exists, it overrides authException message and writes UNAUTHORIZED json") + void commence_withRequestAttribute() throws IOException { + // Arrange + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + var sw = new java.io.StringWriter(); + var writer = new java.io.PrintWriter(sw, true); + + when(request.getAttribute(eq("exception"))).thenReturn(new Exception("test")); + when(response.getWriter()).thenReturn(writer); + + var authException = new InsufficientAuthenticationException("auth failed msg"); + + // Act + entryPoint.commence(request, response, authException); + writer.flush(); + + // Assert + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response).setContentType("application/json"); + verify(response).setCharacterEncoding("UTF-8"); + } + + @Test + @DisplayName("When exception attribute is null, use AuthenticationException message") + void commence_withAuthExceptionMessage() throws IOException { + // Arrange + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + var sw = new java.io.StringWriter(); + var writer = new java.io.PrintWriter(sw, true); + + when(request.getAttribute(eq("exception"))).thenReturn(null); + when(response.getWriter()).thenReturn(writer); + + var authException = new InsufficientAuthenticationException("auth failed msg"); + + // Act + entryPoint.commence(request, response, authException); + writer.flush(); + + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response).setContentType("application/json"); + verify(response).setCharacterEncoding("UTF-8"); + } +} diff --git a/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java b/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java new file mode 100644 index 0000000..530bc62 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java @@ -0,0 +1,34 @@ +package org.mandarin.booking.adapter.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +@IntegrationTest +class CustomAuthenticationProviderTest { + @Autowired + CustomAuthenticationProvider provider; + + @Test + void supports() { + var isSupported = provider.supports(CustomMemberAuthenticationToken.class); + assertThat(isSupported).isTrue(); + } + + @Test + void supportsFailure(){ + var isSupported = provider.supports(String.class); + assertThat(isSupported).isFalse(); + } + + @Test + void shouldNotAuthenticateNonCustomToken() { + var exception = assertThrows(RuntimeException.class, + () -> provider.authenticate(new UsernamePasswordAuthenticationToken("user", "pass"))); + assertTrue(exception.getMessage().contains("Unsupported authentication type")); + } +} diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java index a47b6d3..c7ffd57 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -106,6 +106,23 @@ void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testU assertThat(response.getData()).isEqualTo("Access Denied"); } + @Test + void blankTokenWillFailToAuth( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var accessToken = "Bearer "; + + // Act + var response = testUtils.get("/test/with-auth") + .withHeader("Authorization", accessToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("토큰이 비어있습니다."); + } + @RestController @RequestMapping("/test") static class TestAuthController { diff --git a/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java b/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java new file mode 100644 index 0000000..ee8972c --- /dev/null +++ b/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java @@ -0,0 +1,192 @@ +package org.mandarin.booking.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@IntegrationTest +@Import(LoggingAspectTest.TestConfig.class) +class LoggingAspectTest { + + private ListAppender listAppender; + + @Autowired + LoggingAspectTest.SampleService bean; + + @Autowired + LoggingAspectTest.BlankMethodOnlyService blankMethodOnlyService; + + @BeforeEach + void setUp() { + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + logger.setLevel(Level.TRACE); + } + + @AfterEach + void tearDown() { + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + logger.detachAppender(listAppender); + } + + private static ListAppender attachAppender(Class target) { + Logger logger = (Logger) LoggerFactory.getLogger(target); + ListAppender la = new ListAppender<>(); + la.start(); + logger.addAppender(la); + logger.setLevel(Level.TRACE); + return la; + } + + private static void detachAppender(Class target, ListAppender la) { + Logger logger = (Logger) LoggerFactory.getLogger(target); + logger.detachAppender(la); + } + + @Configuration + @Import({AopAutoConfiguration.class, LoggingAspect.class}) + static class TestConfig { + @Bean + SampleService sampleLoggedService() { + return new SampleLoggedService(); + } + @Bean + BlankMethodOnlyService blankMethodOnlyService() { return new MethodBlankOnlyService(); } + } + + interface SampleService { + String doWork(); + String doTraced(); + String fail(); + String doWarn(); + String doErrorLevel(); + String doCustom(); + } + + interface BlankMethodOnlyService { String blankOnly(); } + + static class MethodBlankOnlyService implements BlankMethodOnlyService { + @Log(scope = " ") + public String blankOnly() { return "blank"; } + } + + @Log(scope = "DEBUG") + static class SampleLoggedService implements SampleService { + public String doWork() { return "ok"; } + @Log(scope = "TRACE") + public String doTraced() { return "traced"; } + public String fail() { throw new IllegalStateException("boom"); } + @Log(scope = "WARN") + public String doWarn() { return "warned"; } + @Log(scope = "ERROR") + public String doErrorLevel() { return "erred"; } + @Log(scope = "CUSTOM") + public String doCustom() { return "custom"; } + } + + @Test + @DisplayName("Class-level @Log produces START and END logs at configured level, method inherits when not annotated") + void classLevelLog_startEnd() { + SampleService s = bean; + String res = s.doWork(); + assertThat(res).isEqualTo("ok"); + + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.DEBUG); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doWork"); + assertThat(events.get(1).getLevel()).isEqualTo(Level.DEBUG); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("("); + } + + @Test + @DisplayName("Method-level @Log overrides class level") + void methodLevelOverrides() { + SampleService s = bean; + String res = s.doTraced(); + assertThat(res).isEqualTo("traced"); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.TRACE); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doTraced"); + } + + @Test + @DisplayName("On exception, END is logged at error with exception info") + void exceptionLogging() { + SampleService s = bean; + assertThatThrownBy(s::fail).isInstanceOf(IllegalStateException.class); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(1).getLevel()).isEqualTo(Level.ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("with exception").contains("IllegalStateException"); + } + + @Test + @DisplayName("Method annotated with WARN logs at WARN") + void warnLevelMethod() { + SampleService s = bean; + String res = s.doWarn(); + assertThat(res).isEqualTo("warned"); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.WARN); + } + + @Test + @DisplayName("Method annotated with ERROR logs START/END at ERROR on success path") + void errorLevelMethod_successful() { + SampleService s = bean; + String res = s.doErrorLevel(); + assertThat(res).isEqualTo("erred"); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.ERROR); + assertThat(events.get(1).getLevel()).isEqualTo(Level.ERROR); + } + + @Test + @DisplayName("Unknown scope falls back to INFO (default branch)") + void unknownScopeDefaultsToInfo() { + SampleService s = bean; + String res = s.doCustom(); + assertThat(res).isEqualTo("custom"); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.INFO); + } + + @Test + @DisplayName("Blank method scope with no class annotation falls back to INFO") + void blankMethodScopeFallsBackToInfo() { + // Attach specific appender for MethodBlankOnlyService class + ListAppender la = attachAppender(MethodBlankOnlyService.class); + try { + String res = blankMethodOnlyService.blankOnly(); + assertThat(res).isEqualTo("blank"); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.INFO); + } finally { + detachAppender(MethodBlankOnlyService.class, la); + } + } +} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..1f0955d --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline From f00d14a74d2019ea17f686a96080322f5eda679f Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 29 Aug 2025 12:24:14 +0900 Subject: [PATCH 22/26] feat: enhance authentication handling with improved error messaging and add unit tests --- docs/specs/api/movie_register.md | 2 +- .../adapter/security/SecurityConfig.java | 4 ++-- .../adapter/webapi/MemberController.java | 2 +- .../adapter/webapi/MovieController.java | 2 +- .../booking/webapi/member/POST_specs.java | 22 +++++++++---------- .../booking/webapi/movie/POST_specs.java | 14 ++++++------ 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index 78debb8..34c25ef 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -1,7 +1,7 @@ ### 요청 - 메서드: `POST` -- 경로: `/api/movie` +- 경로: `/api/movies` - 헤더 ``` diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index 9d6b2b2..2266591 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -36,10 +36,10 @@ public SecurityFilterChain apiChain(HttpSecurity http, http .securityMatcher("/api/**") .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST, "/api/members").permitAll() + .requestMatchers(HttpMethod.POST, "/api/member").permitAll() .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/auth/reissue").permitAll() - .requestMatchers(HttpMethod.POST, "/api/movies").hasAuthority("ROLE_DISTRIBUTOR") + .requestMatchers(HttpMethod.POST, "/api/movie").hasAuthority("ROLE_DISTRIBUTOR") .anyRequest().authenticated() ) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java index 6c037fc..7483a15 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/members") +@RequestMapping("/api/member") public record MemberController(MemberRegisterer memberRegisterer) { @PostMapping diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java index 79da80c..c92eefe 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/movies") +@RequestMapping("/api/movie") public record MovieController(MovieRegisterer movieRegisterer) { @PostMapping diff --git a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java index a8a0316..8cb021a 100644 --- a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java @@ -20,7 +20,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; @IntegrationTest -@DisplayName("POST /api/members") +@DisplayName("POST /api/member") public class POST_specs { @Test @@ -33,7 +33,7 @@ public class POST_specs { // Act var response = testRestTemplate.postForEntity( - "/api/members", + "/api/member", request, Void.class ); @@ -52,7 +52,7 @@ public class POST_specs { // Act testRestTemplate.postForEntity( - "/api/members", + "/api/member", request, Void.class ); @@ -77,7 +77,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertFailure(); @@ -106,14 +106,14 @@ public class POST_specs { ); testUtils.post( - "/api/members", + "/api/member", existingRequest ) .assertSuccess(MemberRegisterResponse.class); // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertFailure(); @@ -135,7 +135,7 @@ public class POST_specs { email ); testUtils.post( - "/api/members", + "/api/member", existingRequest ) .assertSuccess(MemberRegisterResponse.class); @@ -149,7 +149,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertFailure(); @@ -179,7 +179,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertFailure(); @@ -205,7 +205,7 @@ public class POST_specs { // Act var res = testRestTemplate.postForEntity( - "/api/members", + "/api/member", request, Void.class ); @@ -226,7 +226,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertSuccess(MemberRegisterResponse.class); diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java index c712ddf..56274e9 100644 --- a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -18,7 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest -@DisplayName("POST /api/movies") +@DisplayName("POST /api/movie") public class POST_specs { @Test @@ -32,7 +32,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/movies", + "/api/movie", request ) .withHeader("Authorization", authToken) @@ -51,7 +51,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/movies", + "/api/movie", request ) .assertFailure(); @@ -71,7 +71,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/movies", + "/api/movie", request ) .withHeader("Authorization", authToken) @@ -90,7 +90,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/movies", + "/api/movie", generateMovieRegisterRequest("영화 제목", "감독 이름", -1, "SF", "2010-07-21", "AGE12") ) .withHeader("Authorization", authToken) @@ -113,7 +113,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/movies", + "/api/movie", request ) .withHeader("Authorization", authToken) @@ -133,7 +133,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/movies", + "/api/movie", request ) .withHeader("Authorization", authToken) From 2e42b79651f232c5c900e2d2acfe84fab74def13 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 29 Aug 2025 12:32:30 +0900 Subject: [PATCH 23/26] feat: update API documentation for movie registration and member registration endpoints --- docs/specs/api/member_register.md | 2 +- docs/specs/api/movie_register.md | 6 +++--- docs/todo.md | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/specs/api/member_register.md b/docs/specs/api/member_register.md index 2f48dfc..616a242 100644 --- a/docs/specs/api/member_register.md +++ b/docs/specs/api/member_register.md @@ -23,7 +23,7 @@ curl 명령 예시 ```bash - curl -i -X POST 'http://localhost:8080/api/members' \ + curl -i -X POST 'http://localhost:8080/api/member' \ -H 'Content-Type: application/json' \ -d '{ "nickName": "test", diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md index 34c25ef..1778998 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/movie_register.md @@ -20,7 +20,7 @@ ```bash curl -i -X POST 'http://localhost:8080/api/movie' \ - -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU1ODQ3MzY1LCJleHAiOjE3NTU4NDc5NjV9.qivq2xlrm8me6P0oSwFLfieubmtoUB44NTSp2idDRRLG2wWE4S_4nNMJyEbEwjwaxfHpYQdzOTw0uscvNJCoKQ' \ + -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9ESVNUUklCVVRPUiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU2NDM4MjIzLCJleHAiOjE3NTY0Mzg4MjN9.DN0wZb8BdKY-7Grd0KAALXf88KX3iF_tg6UmcfotkFOlbRoRnSuY1nNVUFfZk2TxP0hvju3A8AglK3mt_hnutQ' \ -H 'Content-Type: application/json' \ -d '{ "title": "인셉션", @@ -28,10 +28,10 @@ "runtimeMinutes": 148, "genre": "SF", "releaseDate": "2010-07-21", - "rating": "12세 관람가", + "rating": "AGE12", "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", "posterUrl": "https://example.com/posters/inception.jpg", - "cast": [ + "casts": [ "레오나르도 디카프리오", "조셉 고든레빗", "엘렌 페이지" diff --git a/docs/todo.md b/docs/todo.md index 3683696..75bcf92 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -10,6 +10,9 @@ 2025.08.27 - [x] 테스트 케이스 충족 - 2025.08.28 +2025.08.28 - [x] 없는 엔드포인트에 대한 처리 어찌할지 고민 -- [ ] 리펙터링 +- [x] 리펙터링 + +2025.08.29 +- [ ] `/api/members`와 같이 `/api`로 시작하지만 존재하지 않는 엔드포인트에 대한 처리가 `AuthenticationEntryPoint`에서 처리되고 있음 From 60c4708d1ed0c636ce9af085c37f9beeba806fbf Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 1 Sep 2025 09:05:27 +0900 Subject: [PATCH 24/26] feat: refactor member and movie repository interfaces for consistency and clarity --- docs/devlog/250829.md | 6 ++++ .../app/persist/MemberCommandRepository.java | 2 +- .../app/persist/MemberQueryRepository.java | 2 +- ...aRepository.java => MemberRepository.java} | 2 +- .../app/persist/MovieCommandRepository.java | 2 +- .../app/persist/MovieJpaRepository.java | 7 ---- .../booking/app/persist/MovieRepository.java | 8 +++++ .../mandarin/booking/domain/movie/Movie.java | 35 +++++-------------- .../domain/movie/MovieCreateCommand.java | 6 ++-- 9 files changed, 29 insertions(+), 41 deletions(-) create mode 100644 docs/devlog/250829.md rename src/main/java/org/mandarin/booking/app/persist/{MemberJpaRepository.java => MemberRepository.java} (82%) delete mode 100644 src/main/java/org/mandarin/booking/app/persist/MovieJpaRepository.java create mode 100644 src/main/java/org/mandarin/booking/app/persist/MovieRepository.java diff --git a/docs/devlog/250829.md b/docs/devlog/250829.md new file mode 100644 index 0000000..db3efa7 --- /dev/null +++ b/docs/devlog/250829.md @@ -0,0 +1,6 @@ +## 예찬 +전반적인 인증 흐름에 대한 이해가 이번 기능 구현 과정에서 미흡했던것을 인지함. 이후 Spring Security의 인증 흐름을 학습하고, 그 과정에서 기존에 구현하려 했던 방식에 문제점을 발견했고, 이를 해결하기 위해 직접 제어 가능한 수준의 인증정보 제공자를 직접 구현하는 방식으로 기능 구현, 성공적인 테스트 케이스 통과가 가능했다. +이후에 해야할건 영화 조회 아닐까 싶다. 영화 조회 기능도 일단 빠르게 Spring Data JPA의 도움을 받아서 구현하고 이후 최적화 과정을 거치는 것이 좋지 않을까 하는 생각. +추가로, 현재까지 개발을 하는 과정에서 정책을 일부 작성해왔었는데, 이부분이 좀 누락된거 같다. 작성해두는게 앞으로 문제 발생 가능성을 줄이는데에 도움이 되지 않을까... + +## 휘동 diff --git a/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java index 91bf118..51f7b4d 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java @@ -9,7 +9,7 @@ @Transactional @RequiredArgsConstructor public class MemberCommandRepository { - private final MemberJpaRepository jpaRepository; + private final MemberRepository jpaRepository; public Member insert(Member member) { return jpaRepository.save(member); diff --git a/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java index 14cf061..bb23695 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java @@ -10,7 +10,7 @@ @Transactional(readOnly = true) @RequiredArgsConstructor public class MemberQueryRepository { - private final MemberJpaRepository jpaRepository; + private final MemberRepository jpaRepository; public boolean existsByEmail(String email) { return jpaRepository.existsByEmail(email); diff --git a/src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberRepository.java similarity index 82% rename from src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java rename to src/main/java/org/mandarin/booking/app/persist/MemberRepository.java index b0aa5f2..e7ddb6a 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MemberJpaRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberRepository.java @@ -4,7 +4,7 @@ import org.mandarin.booking.domain.member.Member; import org.springframework.data.repository.Repository; -public interface MemberJpaRepository extends Repository { +public interface MemberRepository extends Repository { boolean existsByUserId(String userId); boolean existsByEmail(String email); diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java b/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java index 4a135d6..b774c9a 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java @@ -9,7 +9,7 @@ @Transactional @RequiredArgsConstructor public class MovieCommandRepository { - private final MovieJpaRepository jpaRepository; + private final MovieRepository jpaRepository; public Movie insert(Movie movie){ return jpaRepository.save(movie); diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieJpaRepository.java b/src/main/java/org/mandarin/booking/app/persist/MovieJpaRepository.java deleted file mode 100644 index 5fe959c..0000000 --- a/src/main/java/org/mandarin/booking/app/persist/MovieJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.mandarin.booking.app.persist; - -import org.mandarin.booking.domain.movie.Movie; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface MovieJpaRepository extends JpaRepository { -} diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java b/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java new file mode 100644 index 0000000..9526635 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.app.persist; + +import org.mandarin.booking.domain.movie.Movie; +import org.springframework.data.repository.Repository; + +public interface MovieRepository extends Repository { + Movie save(Movie movie); +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/Movie.java b/src/main/java/org/mandarin/booking/domain/movie/Movie.java index 99dd535..bf36e88 100644 --- a/src/main/java/org/mandarin/booking/domain/movie/Movie.java +++ b/src/main/java/org/mandarin/booking/domain/movie/Movie.java @@ -10,9 +10,14 @@ import java.time.LocalDate; import java.util.HashSet; import java.util.Set; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Movie extends AbstractEntity { private String title; @@ -23,11 +28,11 @@ public class Movie extends AbstractEntity { @Enumerated(EnumType.STRING) private Genre genre; + private LocalDate releaseDate; + @Enumerated(EnumType.STRING) private Rating rating; - private LocalDate releaseDate; - private String synopsis; private String posterUrl; @@ -38,33 +43,9 @@ public class Movie extends AbstractEntity { @Column(name = "actor_name") private Set casts = new HashSet<>(); - protected Movie() { - } - - private Movie(String title, - String director, - Integer runtimeMinutes, - Genre genre, - LocalDate releaseDate, - Rating rating, - String synopsis, - String posterUrl, - Set casts) { - - this.title = title; - this.director = director; - this.runtimeMinutes = runtimeMinutes; - this.genre = genre; - this.releaseDate = releaseDate; - this.rating = rating; - this.synopsis = synopsis; - this.posterUrl = posterUrl; - this.casts.addAll(casts); - } - public static Movie create(MovieCreateCommand command) { return new Movie(command.getTitle(), command.getDirector(), command.getRuntimeMinutes(), command.getGenre(), - command.getReleaseDate(), command.getRating(), command.getSynopsis(), command.getPosterUrl(), command.getCast()); + command.getReleaseDate(), command.getRating(), command.getSynopsis(), command.getPosterUrl(), command.getCasts()); } public enum Genre { diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java index 60ba73f..e9d8a7f 100644 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java @@ -17,10 +17,10 @@ public class MovieCreateCommand { private final String posterUrl; private final LocalDate releaseDate; private final Rating rating; - private final Set cast; + private final Set casts; private MovieCreateCommand(String title, Genre genre, int runtimeMinutes, String director, String synopsis, - String posterUrl, LocalDate releaseDate, Rating rating, Set cast) { + String posterUrl, LocalDate releaseDate, Rating rating, Set casts) { this.title = title; this.genre = genre; this.runtimeMinutes = runtimeMinutes; @@ -29,7 +29,7 @@ private MovieCreateCommand(String title, Genre genre, int runtimeMinutes, String this.posterUrl = posterUrl; this.releaseDate = releaseDate; this.rating = rating; - this.cast = cast; + this.casts = casts; } public static MovieCreateCommand from(MovieRegisterRequest request) { From e987bbb353900c1d5158f32af8874cd858bb686a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 1 Sep 2025 17:12:34 +0900 Subject: [PATCH 25/26] feat: add AI assistant rules file to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c43c326..c771de5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .gradle/ /build .idea +.aiassistant/rules/AGENTS.md From 70f77dbcf66b57dbc0b13606089d6d500fa52732 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 4 Sep 2025 10:24:03 +0900 Subject: [PATCH 26/26] feat: add architecture and authentication policy documentation --- AGENTS.md | 268 ++++++++++++++++++++++++++++ README.md | 122 +++++++++++++ docs/specs/domain.md | 85 +++++---- docs/specs/policy/application.md | 139 +++++++++++++++ docs/specs/policy/authentication.md | 99 ++++++++++ docs/specs/policy/authorization.md | 73 ++++++++ docs/specs/policy/test.md | 170 ++++++++++++++++++ 7 files changed, 924 insertions(+), 32 deletions(-) create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 docs/specs/policy/application.md create mode 100644 docs/specs/policy/authentication.md create mode 100644 docs/specs/policy/authorization.md create mode 100644 docs/specs/policy/test.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c470d45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,268 @@ +--- +적용: 항상 +--- + +Generated by IntelliJ AI based on repository scan (2025-08-31 16:52) + +# AGENTS.md — 에이전트를 위한 실행 지침서 (booking) + +본 문서는 사람용 README가 아닌, 코드 에이전트(코딩 봇)를 위한 실행 지침서입니다. 이 저장소의 실제 파일과 문서를 스캔해 확인된 사실만 기술합니다. 확인 불가능한 항목은 "확인 불가"로 명시합니다. + +## 1) Title & Scope +- 목적: 이 파일은 에이전트를 위한 지침서입니다. 개발/운영 작업 자동화 시 준수 규칙과 명령을 제공합니다. +- 적용 범위: 단일 레포지토리(single repo), 단일 모듈 구조 + - 근거: `settings.gradle`에 `rootProject.name = 'booking'`만 정의되어 있으며 서브프로젝트 선언 없음. + +## 2) Documentation Reference (docs 우선) +- 모든 작업 전/중에 `./docs` 디렉토리 문서를 우선적으로 참조하십시오. +- 주요 문서: + - 아키텍처 규칙: `docs/specs/policy/application.md` + - 도메인 개요: `docs/specs/domain.md` + - API 명세: `docs/specs/api/*.md` + - 인증/인가 정책 문서 틀: `docs/specs/policy/authentication.md`, `docs/specs/policy/authorization.md` (현재 비어 있음) + - 작업 메모/할 일: `docs/devlog/*`, `docs/todo.md` +- 세부 설계나 정책 충돌 시: `docs/specs/policy/application.md`의 규칙을 최우선으로 따르십시오. + +## 3) Project Setup +- JDK/Gradle/Spring Boot 버전 + - Java: 21 (근거: `build.gradle` → `java.toolchain.languageVersion = 21`) + - Gradle Wrapper: 8.14.3 (근거: `gradle/wrapper/gradle-wrapper.properties` → `distributionUrl`) + - Spring Boot: 3.5.4 (근거: `build.gradle` → `id 'org.springframework.boot' version '3.5.4'`) +- 빌드/의존성 설치 + - 명령: `./gradlew clean build` + - 테스트는 JUnit Platform 사용, 테스트 프로필은 Gradle test 태스크에서 `spring.profiles.active=test`로 설정됨 (근거: `build.gradle` → tasks.named('test')). +- 로컬 실행 + - 명령: `./gradlew bootRun` + - 활성 프로필: 기본 `local` (근거: `src/main/resources/application.yml` → `spring.profiles.active: local`) + - DB 및 JWT 설정은 `application-local.yml` 참고. 민감정보는 환경변수로 덮어쓰기를 권장. +- 테스트 실행 + - 기본: `./gradlew test` (JUnit5, Spring Boot Test, Mockito, Security Test, ArchUnit 포함) + - 테스트 시 프로필: `test` (근거: `build.gradle` test 태스크 설정) +- 코드 분석/품질 도구 + - SpotBugs 사용 (근거: `build.gradle` → `com.github.spotbugs` 플러그인). 일반 태스크: `spotbugsMain`, `spotbugsTest`. + - 포매터/린터(Spotless/Checkstyle 등): 확인 불가 (저장소 내 설정/플러그인 없음). + +## 4) Architecture Rules (준수 규칙) +- 전반: 헥사고날 아키텍처(Ports & Adapters) + - 근거: `docs/specs/policy/application.md` 및 `src/test/java/.../arch/HexagonalArchitectureTest.java` +- 계층 및 패키지 + - domain: `org.mandarin.booking.domain` — 순수 모델/예외/커맨드/요청/응답 (프레임워크 의존 금지) + - app: `org.mandarin.booking.app` — 유스케이스 서비스, 포트(`app/port`), 퍼시스턴스 포트/구현(`app/persist`), AOP 등 + - adapter: `org.mandarin.booking.adapter` — webapi, security 등 외부와의 접점 + - 아키텍처 테스트 강제: `HexagonalArchitectureTest` 층 규칙 참조. +- DDD/엔티티 규칙(요지) + - Aggregate Root 공개 범위 준수, 도메인 엔티티는 domain 패키지에 위치. + - DTO(요청/응답/커맨드)는 현재 domain에 위치하며 컨트롤러에서 변환하여 사용 (근거: policy 문서). +- 영속성(JPA) + - 의존성: `spring-boot-starter-data-jpa`, DB: MySQL(H2 for test), P6Spy (근거: `build.gradle`) + - 저장소 규약: Spring Data Repository 인터페이스는 `app/persist`에 위치 (예: `MemberRepository`, `MovieRepository`). + - 트랜잭션 경계는 app 서비스 (예: `MovieCommandRepository`에 `@Transactional`). +- 보안(Spring Security/JWT) + - JWT 파싱/인증: `adapter/security/JwtFilter`가 Authorization 헤더 `Bearer ` 처리 (근거 파일). + - SecurityFilterChain 설정과 우선순위: `SecurityConfig` + - @Order(1) `apiChain` → 직접 구현한 엔드포인트에 대한 인증/인가 설정. `api/**` 규율을 준수함. + - @Order(2) `publicChain` → 그 외 경로 permitAll. + - AuthenticationProvider: `adapter/security/CustomAuthenticationProvider` (구체 로직은 소스 참조). +- 메시징/캐시/검색 + - Kafka/Redis/Elasticsearch 사용: 확인 불가 (관련 의존성/설정 없음). + +자세한 규칙은 `docs/specs/policy/application.md`를 준수하십시오. + +## 5) Code Style & Readability Rules (읽기 좋은 코드 작성 규칙) + +에이전트가 코드를 작성할 때 반드시 준수해야 하는 가독성 중심 규칙입니다. + +5.1 네이밍 +- 클래스/인터페이스: 명확한 도메인 언어 사용 (예: MovieRegisterer, PaymentGatewayClient) +- 메서드: 동사+목적어 (예: registerMovie, validateToken, findMemberByEmail) +- 변수/필드: 축약 금지, 문맥 명확히 (예: memberEmail, jwtTokenSecret) +- 테스트 메서드: 시나리오 기반 네이밍 (예: shouldFailWhenPasswordIsInvalid) + +5.2 OOP 원칙 +- Tell, don’t ask: 데이터를 꺼내 연산하지 말고 객체에게 메시지를 보내라. +- SRP: 클래스는 한 가지 책임만. 메서드는 5~10줄 이내 유지. +- 불변 객체 지향: 값 객체(Value Object)는 final 필드와 팩토리 메서드 사용. +- 의미 있는 도메인 모델: Map 대신 MemberProfile 같은 타입을 정의. + +5.3 글 읽듯이 읽히는 코드 +- 한 메서드는 하나의 이야기(스토리)를 표현해야 함. +- 중첩 if/else 최소화 → 조기 반환(early return) 활용. +- 숫자/문자열 리터럴은 상수화 (MAX_RETRY_COUNT, DATE_FORMAT_PATTERN). +- 체이닝 시 가독성 유지: 각 메서드 호출을 줄바꿈 정렬. + +5.4 주석 규칙 +- 기본 원칙: 코드 자체가 의도를 설명할 수 있도록 작성 → 불필요한 주석 금지. +- 허용되는 주석 위치/용도 +- 인터페이스/추상 클래스: 계약(Contract) 설명 +- 복잡한 알고리즘/비즈니스 규칙: 수학적 공식, 근거 논문, 외부 레퍼런스 출처 표기 +- TODO/FIXME: 보완 필요시 명확한 설명과 함께 작성 +- 금지: “어떻게 동작하는지”를 코드 그대로 설명하는 주석. + +작업 시 반드시 `docs/specs/policy/application.md` 규칙을 따르십시오. + +## 6) Commands (신뢰 가능한 명령만) +- Gradle Wrapper 사용을 강제합니다. + +```bash +# 빌드 +./gradlew clean build + +# 단위/통합 테스트 (test 프로필로 실행) +./gradlew test + +# SpotBugs 정적 분석 +./gradlew spotbugsMain spotbugsTest + +# 로컬 실행 (기본 활성 프로필: local) +./gradlew bootRun + +# Docker로 MySQL 기동 (compose.yaml 기반) +docker compose up -d + +# 통합 테스트만 별도 태깅 없음 → 전체 테스트 수행 +./gradlew test +``` + +근거 파일/경로: +- `build.gradle` (plugins, dependencies, test 태스크) +- `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` +- `compose.yaml` + +## 7) Contribution & PR Rules +- 브랜치/커밋/PR 규약: `.github/pull_request_template.md` 참조 +- 권장 체크리스트(제안): + - [ ] 모든 테스트 통과 (`./gradlew test`) + - [ ] SpotBugs 통과 (`./gradlew spotbugsMain spotbugsTest`) + - [ ] 아키텍처 테스트 통과 (`HexagonalArchitectureTest`) + - [ ] docs/specs/* 업데이트 반영 +- 코드 스타일 점검 + - [ ] 코드가 글 읽듯 자연스럽게 읽히는가? (네이밍, 메서드 길이, 역할 분리 확인) + - [ ] 불필요한 주석이 없는가? 필요한 주석만 인터페이스/복잡 규칙에 존재하는가? + - [ ] 모든 테스트/SpotBugs/아키텍처 테스트 통과 여부 + +## 8) Environments +- 프로필 + - local: 기본 활성 (근거: `application.yml`), MySQL 설정 및 JWT 시크릿 포함 (근거: `application-local.yml`). + - test: H2 메모리 DB, JPA DDL auto create, JWT 설정 포함 (근거: `application-test.yml`). + - prod: 파일 존재하나 내용 비어 있음 → 확인 불가. +- 필수 환경변수(권장 키) + - `SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD` + - `JWT_TOKEN_SECRET`, `JWT_TOKEN_ACCESS`, `JWT_TOKEN_REFRESH` + - 위 값은 실제 비밀을 포함하므로, 절대 저장소에 커밋하지 말고 실행 환경에서 주입하십시오. 현재 로컬 yml에는 예시 값이 존재하나, 운영에서는 환경변수로 덮어쓰기를 권장. + +## 9) Limitations / Unknowns +- CI/CD(.github/workflows) 설정: 확인 불가. +- 코드 포매팅(Spotless/Checkstyle): 구성 없음 → 확인 불가. +- 마이그레이션 도구(Flyway/Liquibase): 확인 불가. +- 메시징(Kafka)/캐시(Redis)/검색(Elasticsearch): 사용 근거 없음 → 확인 불가. +- 배포(AWS/K8s): 설정/스크립트 부재 → 확인 불가. + +향후 TODO(사람이 보완): +- prod 프로필 구성 및 비밀 주입 전략 정의 +- CI 파이프라인 도입(.github/workflows) +- DB 마이그레이션 도구 채택 및 규약 수립 +- 인증/인가 정책 문서(`authentication.md`, `authorization.md`) 구체화 +- 코드 포매터 도입 여부 결정(Spotless/Checkstyle 등) + +## 10) Appendix +- 모듈/계층 의존 개요(텍스트) + - adapter(webapi, security) → app\(ports, services, persist adapters) → domain + - domain은 프레임워크 비의존, app은 domain에만 의존, adapter는 app의 포트에 의존 +- 주요 디렉터리 + - `src/main/java/org/mandarin/booking/BookingApplication.java` — Spring Boot 엔트리포인트 + - `src/main/java/org/mandarin/booking/adapter/webapi/*` — REST 컨트롤러/응답 래핑 + - `src/main/java/org/mandarin/booking/adapter/security/*` — 보안 구성/JWT 필터/프로바이더 + - `src/main/java/org/mandarin/booking/app/*` — 서비스, AOP, 포트, 퍼시스트 어댑터 + - `src/main/java/org/mandarin/booking/domain/*` — 도메인 모델과 DTO/커맨드 + - `src/test/java/...` — 단위/아키텍처/통합 테스트 스위트 + +## 11) Test +- TDD 원칙: 새로운 기능은 테스트부터 작성하고, `./gradlew test`로 검증하십시오. +- 테스트 정책 근거: + - JUnit Platform 활성화 및 test 프로필 지정 (근거: `build.gradle` test 태스크) + - 아키텍처 검증: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` + - Web/API 및 보안 단위/통합 테스트 예시: `src/test/java/org/mandarin/booking/webapi/**`, `adapter/security/**` +- 단위 vs 통합 테스트 + - 단위: 도메인/서비스 단위 로직 검증, 외부 의존 모킹(Mock) (예: Mockito) + - 통합: Spring Context 기동, 필터/시큐리티/컨트롤러 경로 포함. 테스트 프로필 `test`와 H2 DB 사용. +- 자세한 내용은 `./docs/specs/policy/test.md`를 참조. +--- + +## 12) Documentation Authoring Rules (문서 작성 규칙) +- 적용 범위: `./docs/specs` 디렉토리의 모든 문서(todo.md 제외). 이 규칙은 문서를 자동으로 작성/갱신하는 에이전트를 위한 것입니다. +- 공통 원칙 + - 사실만 기술하고, 확인 불가한 항목은 반드시 "확인 불가"로 명시합니다. 근거 파일/경로를 문서 내에 링크로 첨부합니다. + - 한국어를 기본으로 작성합니다. 코드/명령/경로는 코드블록으로 표시합니다. + - 변경 시 일관된 섹션 순서와 템플릿을 유지합니다. + - 예시 명령은 복사-붙여넣기 즉시 실행 가능한 상태로 제공합니다. + +- 파일/이름 규칙 + - API 스펙: `docs/specs/api/_.md` 또는 엔드포인트 의미가 드러나는 snake_case 파일명 사용 + - 근거: `docs/specs/api/login.md`, `member_register.md`, `movie_register.md`, `reissue.md` + - 정책 문서: `docs/specs/policy/.md` + - 근거: `docs/specs/policy/application.md`, `authentication.md`, `authorization.md`, `test.md` + - 도메인 설계: `docs/specs/domain.md` + +- API 스펙 문서 템플릿 + 1) 제목 생략 가능(현재 파일들은 섹션 위주). 최상단에 섹션 "요청/응답/테스트"를 포함합니다. + 2) 요청 섹션 구성 + - 메서드, 경로, 헤더 코드블록, 본문 JSON 예시(필수 필드 포맷 포함) + - 실행 가능한 curl 예시를 제공 + - 근거 예시: `docs/specs/api/login.md`, `member_register.md`, `movie_register.md`, `reissue.md` + 3) 응답 섹션 구성 + - 상태코드 명시, 응답 JSON 예시 제공(필수 필드 포함) + 4) 테스트 섹션 구성 + - 체크박스 형태의 수용 기준 리스트(`[x]/[ ]`) 사용. 실제 테스트 코드와 동기화합니다. + - 체크리스트 항목은 구체적 조건/결과를 포함합니다. + +- 도메인 문서 템플릿 + - 상단 개요(프로젝트/도메인 목적) + - 도메인 모델 섹션 + - 각 Aggregate/Entity 별로: 표제 → 역할 설명(이탤릭), 속성 목록, 행위 목록, 관련 타입 목록 + - 근거 예시: `docs/specs/domain.md` + +- 정책 문서 템플릿 + - 번호 있는 대제목(1., 2., 3. …)을 사용하여 규칙을 체계화 + - 레이어/의존/포트/보안/테스트 등 주제별 세부 항목을 불릿으로 상세화 + - 실제 코드/설정 파일 경로를 근거로 명시 + - "확인 불가"를 명확히 표기해 향후 TODO를 남김 + - 근거 예시: `docs/specs/policy/application.md`, `docs/specs/policy/test.md` + +- 링크/근거 표기 규칙 + - 저장소 상대 경로 링크를 사용합니다(예: `build.gradle`, `src/main/...`). + - 문서 말미 또는 섹션 말미에 "근거" 목록을 배치하여 신뢰 가능한 출처를 나열합니다. + - 정책/가이드 문서에는 규칙 옆에 괄호로 근거를 병기해도 됩니다. + +- 표기/형식 규칙 + - 헤더 레벨은 H2(##)부터 사용해 문서 내 구조를 안정적으로 유지합니다. + - 코드/JSON/명령은 fenced code block 사용. JSON 예시에는 실제 키를 포함하되 민감정보는 예시 값으로 대체. + - 에러/예외/상태코드는 명시적으로 표기(예: `400 Bad Request`, `401 Unauthorized`). + - 체크박스는 `[x]`(충족) / `[ ]`(미충족) 형식으로 유지. + +- 테스트와의 동기화 + - API 문서의 테스트 체크리스트는 실제 테스트(`src/test/java/...`)와 1:1로 대응하도록 작성/갱신합니다. + - 새로운 테스트가 추가되면 해당 API 문서의 체크리스트도 즉시 업데이트합니다. + - 근거: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java`, `adapter/security/*Test.java`, `arch/HexagonalArchitectureTest.java` + +- 프로필/환경 기술 시 유의사항 + - 실제 yml의 키/값/프로필을 그대로 반영하고, 운영 비밀은 "환경변수로 주입"이라고만 적습니다. + - 근거: `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` + +- 금지 사항 + - 추측성 서술 금지("추정/아마도" 금지). 알 수 없는 항목은 "확인 불가". + - 실행 불가한 모호한 명령 예시 금지. 검증되지 않은 외부 의존성 언급 금지. + +- 예시 스니펫 스타일 + - curl: 실제 경로/헤더/본문 포함(예: `docs/specs/api/movie_register.md`의 curl 예시) + - JSON: 축약하지 말고 필요한 필드를 명시, 포맷은 pretty 또는 단일 라인 일관 유지. + +--- + +근거 스니펫 링크/파일 경로 요약: +- `build.gradle`: 플러그인/의존성/테스트 태스크 +- `settings.gradle`: 단일 모듈 확인 +- `src/main/resources/application*.yml`: 프로필/환경 설정 +- `compose.yaml`: 로컬 환경에서 MySQL 기동 설정 +- `docs/specs/policy/application.md`: 아키텍처 및 작업 규칙 +- `src/main/java/.../SecurityConfig.java`, `JwtFilter.java`: 보안 규칙 +- `src/main/java/.../app/persist/*.java`: 영속성 포트/리포지토리 구조 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8755d68 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# booking — 영화 예매 시스템 (개요 중심 README) + +이 문서는 프로젝트를 어떻게 실행하는지보다는, 이 프로젝트가 무엇인지, 어떤 구성과 아키텍처를 가지는지, 어떤 기술을 왜 선택했는지, 그리고 어떤 방식으로 개발하는지를 설명합니다. 모든 주장은 저장소 내 문서/코드에 대한 링크로 근거를 제시합니다. + +- 저장소 루트: 단일 모듈(Spring Boot) 프로젝트 + +--- + +## 1. 프로젝트 소개 +영화 예매(Booking) 도메인을 다루는 학습 목적의 Spring Boot 애플리케이션입니다. 헥사고날 아키텍처(Ports & Adapters)를 채택하여 도메인 규칙을 프레임워크로부터 분리하고, 보안/웹/영속성 같은 어댑터 계층을 통해 외부 세계와 상호작용합니다. + +- 도메인 개요: [docs/specs/domain.md](docs/specs/domain.md) +- 아키텍처/개발 규칙: [docs/specs/policy/application.md](docs/specs/policy/application.md) +- 테스트 규칙: [docs/specs/policy/test.md](docs/specs/policy/test.md) +--- + +## 2. 핵심 기능 +- 추후 작성 예정 + +테스트로 검증되는 수용 기준은 각 API 문서 하단의 체크리스트를 참고하세요. + +--- + +## 3. 아키텍처 개요 (Hexagonal) +헥사고날 아키텍처를 적용하여 다음과 같은 레이어 규칙을 따릅니다. + +- domain: 순수한 도메인 모델과 비즈니스 규칙. 프레임워크 의존 금지. +- app: 유스케이스 서비스, 입력/출력 포트, 트랜잭션 경계, 검증, AOP. +- adapter: 웹 API, 보안, 영속성 등 외부 인터페이스. + +근거와 세부 규칙 +- 정책 문서: [docs/specs/policy](docs/specs/policy) +- 레이어 테스트: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` +- 패키지 구조 예 + - 도메인: `src/main/java/org/mandarin/booking/domain/*` + - 앱/포트/영속 어댑터: `src/main/java/org/mandarin/booking/app/*` (`app/persist` 포함) + - 웹/보안 어댑터: `src/main/java/org/mandarin/booking/adapter/{webapi,security}/*` + +텍스트 다이어그램: [Controllers/Security/External] → adapter → app(ports, services) → domain + +--- + +## 4. 도메인 모델 요약 +- Movie (Aggregate Root): 제목, 감독, 장르, 상영시간, 개봉일, 등급, 줄거리, 포스터URL, 출연진 등. 팩토리/커맨드 기반 생성. +- Member (Aggregate Root): 닉네임, userId, email, passwordHash, 권한 목록. 비밀번호 해시 일치 검증. + +자세한 속성과 규칙: [docs/specs/domain.md](docs/specs/domain.md) + +--- + +## 5. 기술 스택과 선택 근거 +- 추후 작성 예정 + +선택 이유(요지) +- Hexagonal: 테스트 용이성과 변경 격리를 위해 계층 경계를 명확히. 또한, 추후 모듈화 or MSA 전환시 이점을 위해 애플리케이션 아키텍처를 영역에 따라 구분. +- Spring Security + JWT: 무상태(stateless) API 인증과 확장성. +- JPA + RDB(H2/MySQL): 표준 ORM과 빠른 테스트 사이클. + +--- + +## 6. 개발 방식과 테스트 전략 +- 테스트 주도 개발(TDD) 지향: 테스트 우선, 기능 추가 시 관련 스펙 테스트 동반. +- 테스트 정책 문서: [docs/specs/policy/test.md](docs/specs/policy/test.md) +- 통합 테스트: Spring Context 기동, 보안 필터/컨트롤러/JPA 연동을 포함한 경로 검증. + - 예시: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java` +- 아키텍처 테스트: 레이어 규칙 준수 확인. + - 예시: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` + +Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profiles, JUnit Platform, ByteBuddy javaagent). + +--- + +## 7. 보안 개요 +- 필터 기반 JWT 인증: `JwtFilter`가 Authorization `Bearer `을 파싱해 SecurityContext 설정. +- 경로별 권한: `SecurityConfig`의 `@Order(1) apiChain` + - 공개: `POST /api/member`, `/api/auth/login`, `/api/auth/reissue` + - 권한 필요: `POST /api/movie`는 `ROLE_DISTRIBUTOR` +- 예외 처리: `CustomAuthenticationEntryPoint`, `CustomAccessDeniedHandler` + +근거: `src/main/java/org/mandarin/booking/adapter/security/*` + +--- + +## 8. 데이터/환경 구성 +- 프로필: `local`(기본), `test`, `prod(비어있음)` + - 근거: `src/main/resources/application.yml` 및 `application-*.yml` +- local: MySQL + JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 + - 근거: `application-local.yml`, Docker Compose: [compose.yaml](compose.yaml) +- test: H2 메모리 + MySQL 호환 모드 + JPA `ddl-auto: create` + - 근거: `application-test.yml` + +민감정보는 운영 환경에서 환경변수로 주입하는 것을 권장합니다(로컬에 예시 값 존재). + +--- + +## 9. API 문서 +- 로그인: [docs/specs/api/login.md](docs/specs/api/login.md) +- 회원 가입: [docs/specs/api/member_register.md](docs/specs/api/member_register.md) +- 토큰 재발급: [docs/specs/api/reissue.md](docs/specs/api/reissue.md) +- 영화 등록: [docs/specs/api/movie_register.md](docs/specs/api/movie_register.md) + +각 문서 하단의 테스트 체크리스트가 수용 기준입니다. + +--- + +## 10. 프로젝트 상태 및 향후 계획 +- CI/CD, 코드 포매터, 마이그레이션 도구(Flyway/Liquibase)는 현재 문서/설정 부재로 "확인 불가" 상태입니다. +- TODO/메모: [docs/devlog/*](docs/devlog), [docs/todo.md](docs/todo.md) +- 권장 향후 작업 + - prod 프로필 구성과 비밀 주입 전략 수립 + - CI 파이프라인(.github/workflows) 도입 + - DB 마이그레이션 도구 채택 및 규약 수립 + - 인증/인가 정책 문서 구체화: [docs/specs/policy/authentication.md](docs/specs/policy/authentication.md), [docs/specs/policy/authorization.md](docs/specs/policy/authorization.md) + +--- + +## 11. 버전/도구 근거 링크 +- Spring Boot/Java/Gradle 버전: [build.gradle](build.gradle), [gradle-wrapper.properties](gradle/wrapper/gradle-wrapper.properties) +- 애플리케이션 엔트리포인트: `src/main/java/org/mandarin/booking/BookingApplication.java` +- 보안 설정/필터: `src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java`, `src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java` +- 아키텍처 규칙: [docs/specs/policy/application.md](docs/specs/policy/application.md) +- 테스트 정책: [docs/specs/policy/test.md](docs/specs/policy/test.md) diff --git a/docs/specs/domain.md b/docs/specs/domain.md index a48cdaf..57407b1 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -8,57 +8,78 @@ ## 도메인 모델 ### 영화(Movie) +_Aggregate Root_ - 상영될 콘텐츠 자체. #### 속성 -- 제목(Title): 영화의 이름. -- 감독(Director): 영화를 감독한 사람. -- 상영시간(Runtime): 영화의 총 길이(분 단위). -- 장르(Genre): 영화의 장르(예: 액션, 드라마, 코미디 등). -- 개봉일(ReleaseDate): 영화가 처음 개봉한 날짜. -- 등급(Rating): 영화의 관람 등급(예: 전체 관람가, 12세 관람가 등). -- 줄거리(Synopsis): 영화의 간단한 줄거리 설명. -- 포스터(Poster): 영화의 포스터 이미지 URL. -- 주연 배우(Cast): 영화에 출연한 주요 배우 목록. +- 제목(title) +- 감독(director) +- 상영시간(runtimeMinutes, 분) +- 장르(genre: ACTION/DRAMA/COMEDY/THRILLER/ROMANCE/SF/FANTASY/HORROR/ANIMATION/DOCUMENTARY/ETC) +- 관람등급(rating: ALL/AGE12/AGE15/AGE18) +- 개봉일(releaseDate, yyyy-MM-dd) +- 줄거리(synopsis) +- 포스터 URL(posterUrl) +- 출연 배우 목록(casts: Set) #### 행위 -- `create()`: 새로운 영화를 등록합니다. +- `create(command: MovieCreateCommand)`: 커맨드로부터 영화를 생성합니다. + +#### 관련 타입 +- `MovieCreateCommand`: 영화 생성 커맨드 + - title, genre, runtimeMinutes, director, synopsis, posterUrl, releaseDate, rating, casts(Set) +- `MovieRegisterRequest` / `MovieRegisterResponse`: 웹 API 요청/응답 DTO + +--- + +### 사용자(Member) +_Aggregate Root_ +- 서비스를 사용하는 사람(회원). + +#### 속성 +- 닉네임(nickName) +- 아이디(userId) — unique +- 비밀번호 해시(passwordHash) +- 이메일(email) +- 권한(authorities: List) — 기본값 USER + +#### 행위 +- `create(command: MemberCreateCommand, encoder: SecurePasswordEncoder)`: 암호화된 비밀번호로 회원을 생성합니다. +- `matchesPassword(rawPassword, encoder)`: 주어진 평문 비밀번호가 저장된 해시와 일치하는지 확인합니다. + +#### 관련 타입 +- `MemberCreateCommand` (inner record of Member): nickName, userId, password(평문), email +- `MemberRegisterRequest` / `MemberRegisterResponse`: 웹 API 요청/응답 DTO +- `MemberAuthority`: USER/DISTRIBUTOR/ADMIN 권한 정의, 컨버터를 사용해 문자열 영속화, 추가적인 테이블 생성 방지 + +--- ### 영화관(Cinema) +_Aggregate Root_ - 영화 상영 시설. +--- + ### 상영관(ScreeningRoom) +_Entity_ - 영화관 내에서 실제로 영화가 상영되는 개별 공간. +--- + ### 상영정보(ScreeningSchedule) +_Entity_ - 특정 영화가 특정 상영관에서 특정 날짜와 시간에 상영되는 스케줄. +--- ### 좌석(Seat) +_Entity_ - 상영관 내의 개별 의자. +--- + ### 예매(Reservation) +_Aggregate Root_ - 사용자가 특정 상영정보의 특정 좌석의 구매를 확정한 기록. -### 사용자(Member) -- 서비스를 사용하는 사람. -#### 속성 -- 사용자 ID(UserId): 사용자의 고유 식별자. -- 이름(NickName): 사용자의 이름. -- 아이디(UserId): 사용자의 고유 아이디. - unique -- 비밀번호(PasswordHash): 사용자의 암호화된 비밀번호. -- Email(Email): 사용자의 이메일 주소. - -#### 행위 -- `create()`: 새로운 영화를 등록합니다. -- `matchesPassword()`: 영화의 비밀번호를 확인합니다. - -### `MemberCreateCommand` -- 새로운 사용자를 생성하기 위한 명령. - -#### 속성 -- 사용자 ID(UserId): 사용자의 고유 식별자. -- 이름(NickName): 사용자의 이름. -- 아이디(UserId): 사용자의 고유 아이디. -- 비밀번호(PasswordHash): 사용자의 암호화된 비밀번호. -- Email(Email): 사용자의 이메일 주소. +--- diff --git a/docs/specs/policy/application.md b/docs/specs/policy/application.md new file mode 100644 index 0000000..7a3cac6 --- /dev/null +++ b/docs/specs/policy/application.md @@ -0,0 +1,139 @@ +# 애플리케이션 아키텍처 규칙(헥사고날 아키텍처) + +본 문서는 booking 프로젝트가 채택한 헥사고날 아키텍처(Hexagonal Architecture, Ports & Adapters)의 규칙을 명확히 하기 위한 가이드입니다. 이 문서는 아키텍처 테스트와 코드 리뷰의 근거가 되며, 새로운 기능 추가 시 반드시 준수해야 합니다. + +## 1. 계층과 책임 + +프로젝트는 크게 세 계층으로 구성됩니다. + +- domain: 도메인 모델과 비즈니스 규칙의 순수 영역 + - 위치: `src/main/java/org/mandarin/booking/domain` + - 포함: 엔티티(를 표현하기 위한 매핑정보), 값 객체, 도메인 서비스(필요시), 도메인 예외, 도메인 전용 인터페이스(예: `SecurePasswordEncoder`), 유스케이스에 전달되는 순수 모델(`*Request`, `*Response`, `*Command` 등) + - 금지: 프레임워크/외부 라이브러리 의존(JPA/Spring/Web 등), I/O 접근, 인프라 세부 사항 + +- app: 애플리케이션 서비스(유스케이스)와 포트 인터페이스 + - 위치: `src/main/java/org/mandarin/booking/app` + - 포함: 유스케이스 서비스(`*Service`), 입력/출력 포트(`app/port`), 트랜잭션 경계, 조합/오케스트레이션 로직, 검증기(애플리케이션 수준), 크로스커팅(AOP, 로깅 등) + - 의존: domain에는 의존 가능, adapter에는 의존 금지 + +- adapter: 외부 세계와의 연결(웹, 보안, 영속성 등) + - 위치: `src/main/java/org/mandarin/booking/adapter` + - 하위 영역: + - `webapi`: REST 컨트롤러, DTO 매핑, 예외/응답 공통 처리 + - `security`: 인증/인가 컴포넌트(JwtFilter, AuthenticationProvider 등) + - `persist`: 영속성 구현은 현재 `app/persist` 패키지에 배치되어 있으며, 어댑터 구현으로 취급합니다. JPA 리포지토리와 실제 데이터 접근 로직이 위치합니다. + - 의존: app의 포트에만 의존해야 하며 domain, app 구현 내부로 직접 의존하지 않습니다(서비스 구현 클래스 참조 금지). + +텍스트 다이어그램: + +[Controllers/Security/JPA] → adapter → app(ports, services) → domain\(pure model) + +## 2. 의존성 규칙 + +- domain -> another domain +- app -> domain (OK), adapter (금지) +- adapter -> app 포트(OK), app 서비스/구현(금지), domain(읽기 전용 OK. 단, 비즈니스 수행은 app 경유) +- DTO/엔티티 경계: + - webapi의 요청/응답 DTO는 한시적으로 domain에 존재. 추후 변경 가능성 있음. + - 영속성 엔티티는 domain에만 존재. domain 엔티티와 동일 클래스로 사용. + +## 3. 포트와 어댑터 + +- 입력 포트(inbound port): 유스케이스 인터페이스. 위치: `app/port` 컨트롤러는 입력 포트를 통해서만 유스케이스 호출. +- 출력 포트(outbound port): 외부 시스템/리포지토리에 대한 인터페이스. 위치: `app/persist` 또는 `app/port` 하위에 정의 가능. +- 어댑터(adapters): 포트 인터페이스의 구현체. 위치: adapter 하위. 현재 JPA 기반 구현은 `app/persist/*Repository`를 통해 동작하며, 해당 패키지는 어댑터 계층으로 간주합니다. + +권장 네이밍: +- 입력 포트: UseCase 동사형 + er (예: Registerer, UseCase) +- 출력 포트: 리소스 + 동작 + Repository/Gateway (예: MovieCommandRepository) +- 그 외에는 해당 인터페이스가 담당한 기능의 추상적 개념을 나타내는 네이밍 + +## 4. 트랜잭션/검증/예외/로깅 규칙 + +- 트랜잭션 경계: app 계층의 유스케이스 서비스 메서드 수준에서 관리(@Transactional). 컨트롤러/어댑터에서는 트랜잭션을 시작하지 않습니다. +- 검증: + - 형태/구문 검증: adapter(webapi)에서 기본적인 바인딩/형식 검증 허용. + - 비즈니스/정책 검증: app 또는 domain에서 수행. `Validator` 등의 컴포넌트는 app에 위치. +- 예외: + - 도메인 오류는 domain 예외(`DomainException`의 자식 클래스)로 표현. + - 어댑터/기술 오류는 해당 계층에서 포착하고 app/domain 의미의 예외로 변환 또는 적절히 매핑. + - webapi는 예외를 `GlobalExceptionHandler`로 공통 변환하여 `ErrorResponse`로 응답. +- 로깅: 크로스커팅은 app 계층의 AOP(`LoggingAspect`)에서 처리. 민감 정보(비밀번호, 토큰 등)는 로그 금지. + +## 5. 패키지 구조 규칙 + +- domain: `org.mandarin.booking.domain.{boundedContext}` + - 예: `domain.member`, `domain.movie` +- app: `org.mandarin.booking.app` + - 하위: `port`, `persist`(출력 포트/구현), 서비스 클래스 +- adapter: `org.mandarin.booking.adapter.{webapi|security|...}` +- 순환 의존 금지: 위 규칙 위반 시 컴파일/테스트 단계에서 아키텍처 테스트 실패로 간주. + +## 6. 컨트롤러와 DTO 변환 규칙 + +- 컨트롤러는 입력 포트만 의존한다. +- 요청 DTO -> domain/app 요청 모델로 변환 후 유스케이스 호출. +- 유스케이스 반환값 -> web DTO로 매핑하여 응답한다. +- 컨트롤러에서 비즈니스 로직/트랜잭션 처리 금지. + +예시(영화 등록): +- `adapter/webapi/MovieController` -> `app/port/MovieRegisterer` 호출 +- `domain.movie.MovieRegisterRequest`/`MovieCreateCommand` 사용하여 유스케이스 실행 +- 결과를 `domain.movie.MovieRegisterResponse` 받아 web 응답으로 래핑(`ApiResponse`) + +## 7. 영속성 규칙(JPA) + +- JPA 엔티티는 domain에, Repository는 app(persist)에만 존재. +- app 계층은 JPA 구체 타입에 의존하지 않고, 출력 포트 인터페이스를 통해서만 데이터 접근. +- 매핑 책임은 어댑터에 위치: JPA 엔티티 <-> 도메인 엔티티/모델 변환. + +## 8. 보안 규칙 + +- 인증/인가 컴포넌트는 adapter/security에 위치: `JwtFilter`, `CustomAuthenticationProvider`, `SecurityConfig` 등. +- 보안 컨텍스트와 토큰 파싱은 어댑터에서 처리하고, app 유스케이스에는 인증된 식별자/역할만 전달. + +## 9. 테스트 규칙 + +- `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` 는 아키텍처 규칙을 자동 검증합니다. +- 규칙 위반 예: + - adapter가 app 서비스 구현 클래스에 직접 의존 + - app이 adapter 패키지에 의존 + - domain이 프레임워크에 의존 +- 새로운 모듈/클래스 추가 시 해당 테스트가 통과하는지 반드시 확인합니다. + +## 10. 확장 가이드(새 유스케이스/어댑터 추가) + +새 유스케이스(예: 영화 수정) 추가 절차: +1) domain에 필요한 모델/명세 정의(예: `MovieUpdateCommand`). +2) app/port에 입력 포트 정의(예: `MovieUpdater`). +3) app에 서비스 구현(`MovieService` 내 메서드 또는 별도 서비스) 및 트랜잭션/검증 구현. +4) 필요 시 출력 포트 정의 및 어댑터 구현(persist/JPA 등). +5) adapter/webapi에 컨트롤러 엔드포인트 추가 및 DTO 매핑. +6) 아키텍처/통합 테스트 통과 확인. + +새 어댑터(예: 외부 결제 API) 추가 절차: +1) app에 출력 포트 인터페이스 추가(예: `PaymentGateway`). +2) adapter 하위에 구현(예: `adapter/payment/PaymentGatewayHttpClient`). +3) 구성(Security/Config)과 예외 매핑 추가. + +## 11. 공통 규칙 요약(Do/Don’t) + +Do +- 유스케이스 입출력은 app 포트를 통해서만 노출/호출한다. +- 도메인 모델은 순수하게 유지한다(프레임워크 의존 금지). +- 어댑터는 포트 인터페이스를 구현한다. +- 트랜잭션과 로깅은 app에서 관리한다. + +Don’t +- 컨트롤러에서 비즈니스 로직 수행 금지. +- app에서 adapter 패키지/구현에 의존 금지. +- domain에서 JPA/Spring 등에 의존 금지. + +## 12. 용어 + +- 도메인 모델: 비즈니스 개념을 표현하는 순수 객체(`Member`, `Movie` 등) +- 유스케이스: 시스템이 제공하는 기능 단위(등록, 로그인 등) +- 포트: 유스케이스(입력) 또는 외부 의존(출력)을 추상화한 인터페이스 +- 어댑터: 포트를 구현하여 외부 세계와 연결하는 기술 계층 + +본 문서는 변경 시 PR에 포함하고, 아키텍처 테스트가 통과하는지 확인해야 합니다. diff --git a/docs/specs/policy/authentication.md b/docs/specs/policy/authentication.md new file mode 100644 index 0000000..206ac49 --- /dev/null +++ b/docs/specs/policy/authentication.md @@ -0,0 +1,99 @@ +# 인증 정책 (booking) + +본 문서는 booking 프로젝트의 인증(Authentication) 동작과 규칙을 정리합니다. 모든 내용은 저장소의 실제 코드/설정에 근거합니다. 확인 불가한 항목은 "확인 불가"로 표기합니다. + +근거 파일/경로: +- 보안 설정: `src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java` +- JWT 필터: `src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java` +- AuthenticationProvider: `src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java` +- 토큰 유틸: `src/main/java/org/mandarin/booking/app/TokenUtils.java` +- 예외 처리기: `src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java`, `CustomAccessDeniedHandler.java` +- 프로필/환경: `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` + +--- + +## 1. 인증 흐름 개요 +- 모든 `/api/**` 요청은 `@Order(1)` 체인의 보호를 받습니다. (근거: SecurityConfig.apiChain) +- 인증 헤더: `Authorization: Bearer ` (근거: JwtFilter) +- JwtFilter가 토큰을 파싱하여 사용자 식별자와 권한 정보를 추출하고, `AuthenticationManager`(= `CustomAuthenticationProvider`)를 통해 인증 토큰을 완성합니다. +- 세션 상태: Stateless (세션 생성 비활성화), CSRF 비활성화. (근거: SecurityConfig.apiChain 설정) + +텍스트 시퀀스: +1) 클라이언트 → API: Authorization 헤더 전달 +2) JwtFilter: `Bearer` 타입 여부 확인 → 토큰 파싱 → `userId`, `roles` 추출 +3) JwtFilter: `CustomMemberAuthenticationToken(userId, authorities)` 생성 → `AuthenticationManager.authenticate(...)` 위임 +4) CustomAuthenticationProvider: `MemberQueryRepository`로 사용자 조회 → `MemberDetails` 설정 → 인증 토큰 확정 +5) SecurityContext에 인증 정보 저장 → 이후 필터/컨트롤러에서 사용 + +--- + +## 2. JWT 토큰 규칙 +- 헤더 키: `Authorization` +- 포맷: `Bearer ` +- 사용 Claims (근거: JwtFilter): + - `userId`: 사용자 식별자 + - `roles`: 문자열 리스트. 예: `["ROLE_USER", "ROLE_DISTRIBUTOR"]` +- 권한 매핑: JwtFilter는 `roles`에서 `ROLE_` 접두사를 제거한 뒤 `MemberAuthority` enum으로 변환하여 `CustomMemberAuthenticationToken`에 부여합니다. (근거: JwtFilter.getAuthorities) +- 토큰 서명/TTL 설정: 프로필 별 설정 사용 + - `jwt.token.secret`: 서명 시크릿(Base64 인코딩 값) + - `jwt.token.access`: Access Token 만료(ms) + - `jwt.token.refresh`: Refresh Token 만료(ms) + - 근거: `application-local.yml`, `application-test.yml` + +주의: +- 헤더가 없거나 `Bearer` 접두사만 온 경우 익명 처리되며, 요청 속성 `exception`에 `AuthException`이 설정됩니다. (근거: JwtFilter.isTokenBlank, doFilterInternal) +- 서명 오류/만료/클레임 파싱 실패 시 `AuthException`을 설정하고 SecurityContext를 비웁니다. (근거: JwtFilter 예외 처리) + +--- + +## 3. 인증 컴포넌트 +- JwtFilter + - 위치/순서: `UsernamePasswordAuthenticationFilter` 앞 (근거: SecurityConfig.addFilterBefore) + - 역할: Authorization 헤더 파싱, 사용자 정보 추출, AuthenticationManager 위임, SecurityContext 설정 +- CustomAuthenticationProvider + - 지원 타입: `CustomMemberAuthenticationToken` (근거: supports) + - 동작: 토큰 내 userId로 회원 조회(`MemberQueryRepository.findByUserId`) → 없으면 `AuthException` → 있으면 `MemberDetails` 주입, 인증 확정 +- TokenUtils + - 역할: JWT에서 개별 클레임/리스트 클레임 추출 (근거: JwtFilter에서 사용) + +--- + +## 4. 인증 예외 처리 +- 인증 실패(미인증) 시: `CustomAuthenticationEntryPoint`가 응답 생성 (상세 형식은 클래스 구현 참고) +- 인가 실패(권한 부족) 시: `CustomAccessDeniedHandler`가 응답 생성 +- JwtFilter는 내부적으로 `request.setAttribute("exception", new AuthException(...))`로 실패 사유를 넘기며, 이후 예외 처리기가 이를 사용해 응답을 형성할 수 있습니다. + +--- + +## 5. 공개 엔드포인트와 인증 필요 엔드포인트 +- 공개(permitAll): (근거: SecurityConfig.apiChain) + - `POST /api/member` + - `POST /api/auth/login` + - `POST /api/auth/reissue` +- 인증 필요: (근거: SecurityConfig.apiChain) + - 위 공개 엔드포인트를 제외한 모든 `/api/**` 요청은 인증 필요 +- 공개 체인(@Order(2)): (근거: SecurityConfig.publicChain) + - `/error`, `/assets/**`, `/favicon.ico` 및 그 외 `/**`는 permitAll (운영상 공개 페이지용) + +--- + +## 6. 테스트/프로필 연계 +- 테스트 실행 시 프로필 `test` 활성화: Gradle test 태스크에서 설정 (근거: build.gradle) +- 테스트 프로필에서 JWT/DB 설정은 `application-test.yml`을 따른다. +- 보안 관련 통합/단위 테스트는 다음을 참조: `src/test/java/org/mandarin/booking/adapter/security/*` + +--- + +## 7. 확장 가이드(인증) +- 새로운 인증 스킴 도입 시 지켜야 할 규칙: + - JwtFilter 앞/뒤 필터 추가 시 순서 충돌 주의. 인증 헤더 파싱 필터는 반드시 UsernamePasswordAuthenticationFilter 이전. + - CustomAuthenticationProvider는 `supports`/`authenticate` 계약을 준수하여 `Authentication` 토큰 타입을 명확히 구분. + - 토큰 클레임 확장 시: `TokenUtils`와 `JwtFilter.getAuthorities()` 동기화. 권한 문자열은 `ROLE_` 접두사를 유지. +- 운영 비밀: 시크릿/TTL 값은 환경변수로 덮어쓰기를 권장. (application-prod.yml은 현재 비어 있음 → 확인 불가) + +--- + +## 8. 알 수 없는 항목(확인 불가) +- 토큰 발급/서명 구현 세부(Access/Refresh 생성 로직) 문서화 수준: 확인 불가 (해당 클래스 상세는 별도 코드 참조 필요) +- 키 회전/블랙리스트/토큰 철회 전략: 확인 불가 + diff --git a/docs/specs/policy/authorization.md b/docs/specs/policy/authorization.md new file mode 100644 index 0000000..f2147b9 --- /dev/null +++ b/docs/specs/policy/authorization.md @@ -0,0 +1,73 @@ +# 인가 정책 (booking) + +본 문서는 booking 프로젝트의 인가(Authorization) 규칙을 경로/메서드/권한 기준으로 명확히 기술합니다. 모든 내용은 실제 보안 설정 코드에 근거합니다. + +근거 파일/경로: +- 보안 설정: `src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java` +- 권한 Enum: `src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java` +- JWT 필터: `src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java` +- 예외 처리기: `src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java`, `CustomAuthenticationEntryPoint.java` + +--- + +## 1. 기본 원칙 +- 인가는 Spring Security의 `SecurityFilterChain` 규칙으로 정의됩니다. +- 권한 문자열은 `ROLE_` 접두사를 가진 형태로 JWT `roles` 클레임에 담깁니다. (예: `ROLE_USER`, `ROLE_DISTRIBUTOR`, `ROLE_ADMIN`) (근거: JwtFilter.getAuthorities, MemberAuthority) + +--- + +## 2. 경로/메서드 별 인가 규칙 +아래 표는 `SecurityConfig.apiChain`의 `authorizeHttpRequests` 설정을 반영합니다. + +- 공개(permitAll): + - `POST /api/member` + - `POST /api/auth/login` + - `POST /api/auth/reissue` + +- 권한 필요(hasAuthority): + - `POST /api/movie` → `ROLE_DISTRIBUTOR` + +- 그 외 `/api/**`: + - `anyRequest().authenticated()` → 유효한 JWT 필요(특정 권한 제한 없음). 컨트롤러/도메인 단에서 별도 검증이 필요한 경우 추가 로직으로 보완. + +- 퍼블릭 체인(@Order(2)): + - `/error`, `/assets/**`, `/favicon.ico`, 및 그 외 `/**`는 permitAll (정적/오류/기타 공개 경로) + +근거 코드 스니펫 요약: +- `http.securityMatcher("/api/**")` +- `.requestMatchers(HttpMethod.POST, "/api/member").permitAll()` +- `.requestMatchers("/api/auth/login").permitAll()` +- `.requestMatchers("/api/auth/reissue").permitAll()` +- `.requestMatchers(HttpMethod.POST, "/api/movie").hasAuthority("ROLE_DISTRIBUTOR")` +- `.anyRequest().authenticated()` + +--- + +## 3. 권한 명명 규칙과 매핑 +- Enum: `MemberAuthority`는 다음 권한을 가집니다(코드 참조). + - USER, DISTRIBUTOR, ADMIN 등 +- JWT `roles` → Spring Security 권한 변환: JwtFilter는 `roles`에서 `ROLE_` 접두사를 제거한 후 `MemberAuthority.valueOf(...)`로 Enum을 만들어 `CustomMemberAuthenticationToken`에 저장합니다. (근거: JwtFilter.getAuthorities) +- hasAuthority 비교 시에는 문자열 `ROLE_*` 형태를 사용합니다. (근거: SecurityConfig 설정) + +--- + +## 4. 예외/오류 처리 +- 인증 실패(401 Unauthorized): `CustomAuthenticationEntryPoint`가 처리 +- 권한 부족(403 Forbidden): `CustomAccessDeniedHandler`가 처리 +- JwtFilter는 유효하지 않은 토큰, 누락된 토큰 등에 대해 `request.setAttribute("exception", AuthException)`을 설정하여 원인 정보를 예외 처리기로 전달합니다. + +--- + +## 5. 확장 가이드(인가 규칙 추가 방법) +- 새로운 엔드포인트 추가 시 규칙 예시: + - 공개 엔드포인트(회원가입/로그인 유사): `.requestMatchers(HttpMethod.POST, "/api/xxx").permitAll()` + - 역할 제한 엔드포인트: `.requestMatchers(HttpMethod.PUT, "/api/movies/{id}").hasAuthority("ROLE_ADMIN")` + - 복수 권한 허용: `.requestMatchers(HttpMethod.POST, "/api/screening").hasAnyAuthority("ROLE_DISTRIBUTOR", "ROLE_ADMIN")` +- 규칙 배치 위치: `SecurityConfig.apiChain`의 `authorizeHttpRequests` 빌더에 메서드/경로/권한을 추가합니다. +- 테스트: 추가/변경 시 반드시 보안 통합 테스트를 작성하여 401/403, 성공 경로를 검증하십시오. (예: `adapter/security/*Test.java`, `webapi/**` 스펙 테스트) + +--- + +## 6. 알 수 없는 항목(확인 불가) +- 경로 별 세부 권한 정책 문서(도메인별 Role Matrix): 현재 저장소에 상세 표 없음 → 확인 불가 +- 동적 권한(도메인 데이터 소유권 기반 세분화) 정책: 확인 불가 diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md new file mode 100644 index 0000000..d8cc766 --- /dev/null +++ b/docs/specs/policy/test.md @@ -0,0 +1,170 @@ +# 테스트 정책 (booking) + +본 문서는 booking 프로젝트의 테스트 작성/실행 표준을 정의합니다. 실제 저장소의 테스트 코드, Gradle 설정, 애플리케이션 프로필을 근거로 수립되었습니다. 이 문서는 코드 변경 시 항상 최신 상태로 유지되어야 하며, 테스트 실패는 정책 위반으로 간주할 수 있습니다. + +근거 파일/경로: +- build.gradle: test 태스크, 라이브러리, JVM args 설정 +- src/main/resources/application-test.yml: 테스트 프로필 환경 +- src/test/java/**/*: 실제 테스트 코드 일체 +- docs/specs/api/*.md: API별 수용 기준(체크리스트) + +--- + +## 1. 목표와 범위 +- 목표: 기능/아키텍처/보안/도메인 규칙을 신뢰성 있게 검증한다. +- 범위: 단위 테스트(Unit), 통합 테스트(Integration), 아키텍처 테스트(ArchUnit) 전반. +- 테스트 실행 환경은 Gradle test 태스크를 표준으로 한다. + +명령: +- 전체 테스트: `./gradlew test` +- 테스트 프로필: Gradle가 자동으로 `spring.profiles.active=test`를 설정함 (build.gradle 근거). + +--- + +## 2. 테스트 종류와 원칙 + +### 2.1 단위 테스트 (Unit Test) +- 목적: 작은 단위(도메인, 유틸, 애플리케이션 서비스의 순수 로직)의 동작을 빠르고 고립적으로 검증. +- 프레임워크 의존: 가급적 없음. Spring Context를 기동하지 않는다. +- 목킹(Mock): 외부 의존성은 Mockito 등으로 대체. 저장소/네트워크/시큐리티 등 I/O 경계를 모킹한다. +- 예시 근거: + - 도메인: `src/test/java/org/mandarin/booking/domain/MemberTest.java`, `AbstractEntityTest.java` + - 보안 컴포넌트 단위: `adapter/security/JwtFilterTest.java`, `CustomAuthenticationProviderTest.java`, `CustomAuthenticationEntryPointTest.java` + - 공통/web 단위: `adapter/webapi/GlobalExceptionHandlerTest.java` +- 라이브러리/설정 근거: + - Mockito inline 사용: `build.gradle` → `testImplementation 'org.mockito:mockito-inline:5.2.0'` + - JUnit5 사용: `build.gradle` → `useJUnitPlatform()` + - ByteBuddy javaagent 사전 부착: `build.gradle` → `jvmArgs "-javaagent:${configurations.byteBuddyAgent.singleFile}"` + +권장 규칙: +- 네이밍: 테스트 클래스는 대상 클래스명 + `Test` 또는 시나리오 중심 스펙 명(`*Specs`)을 사용 가능. +- 패키지: 테스트 대상과 유사한 패키지 경로 하위에 배치하여 접근성을 높인다. +- given-when-then 주석 또는 메서드명으로 시나리오를 명확히 표현한다. +- 외부 시스템/DB 액세스 금지. 필요한 경우 포트/리포지토리를 모킹. + +### 2.2 통합 테스트 (Integration Test) +- 목적: Spring Context를 실제로 기동하여, 보안 필터/컨트롤러/시리얼라이저/예외 처리 및 JPA/H2 동작을 포함해 엔드투엔드에 가까운 경로를 검증. +- 프로필/환경: `application-test.yml` 사용. H2 메모리 DB, JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 포함. +- 보안: 실제 `SecurityConfig`와 `JwtFilter` 동작을 최대한 반영. 필요 시 테스트 전용 컨트롤러/설정 (`TestOnlyController`, `TestConfig`) 사용. +- 유틸리티: `IntegrationTest`, `IntegrationTestUtils`, `IntegrationTestUtilsSpecs`, `JwtTestUtils` 등 공용 유틸을 통해 테스트 준비/토큰 생성/컨텍스트 초기화. +- 예시 근거: + - 웹 API 스펙 테스트: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java` + - 통합 환경 유틸: `src/test/java/org/mandarin/booking/IntegrationTest*.java`, `JwtTestUtils.java` +권장 규칙: +- `@IntegrationTest` 커스텀 어노테이션 사용으로 공통 설정 +- 각 테스트는 `IntegrationTestUtils`를 사용해 작성 + - `IntegrationTestUtils` 사용 방법은 다음과 같음 + - ```java + @Test + void withoutAuth(@Autowired IntegrationTestUtils testUtils) { + // Act & Assert + var response = testUtils.get("/test/without-auth") + .assertSuccess(String.class); + + assertThat(response.getData()).isEqualTo(PONG_WITHOUT_AUTH); + } + ``` + - ```java + @Test + void failToAuth(@Autowired IntegrationTestUtils testUtils) { + // Arrange + var invalidToken = "invalid token"; + + // Act & Assert + var response = testUtils.get("/test/with-auth") + .withHeader("Authorization", invalidToken) + .assertFailure(); + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + } + ``` +- 데이터 초기화는 테스트 메서드 단위로 독립되게 유지. H2 메모리 DB가 매 테스트 클래스/메서드 기준으로 깨끗한 상태를 갖도록 설계한다. +- 인증이 필요한 엔드포인트는 `JwtTestUtils`로 유효 토큰을 발급하여 헤더 `Authorization: Bearer `를 부착. +- 예외/에러 응답은 `GlobalExceptionHandler` 정책에 맞춰 상태코드/본문을 검증. +- 아키텍처적으로 adapter → app → domain 경로를 실제 호출하여 레이어 간 계약을 검증. +- 검증은 최대한 `assertj`의 `assertThat`을 사용해 검증 통일. + +### 2.3 아키텍처 테스트 (ArchUnit) +- 목적: 헥사고날 계층 규칙 준수 보장. +- 근거 테스트: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` +- 핵심 규칙: + - adapter 레이어는 어떤 레이어에도 접근 허용되지 않음(외부에서 접근 금지). + - application 레이어는 adapter에서만 접근 가능. + - domain 레이어는 adapter, application에서만 접근 가능. + +--- + +## 3. 테스트-환경 설정 +- Gradle test 태스크가 `spring.profiles.active=test`로 실행됨: `build.gradle` 65~70행 참고. +- H2 설정: `application-test.yml` + - URL: `jdbc:h2:mem:test;MODE=MySQL;` + - Hibernate Dialect: `H2Dialect` + 테스트 중 MySQL 호환 모드 + - JPA: `ddl-auto: create`, `format_sql/show_sql: true` +- JWT 설정: `application-test.yml`의 `jwt.token.secret/access/refresh` +- 정적 분석: 필요시 `./gradlew spotbugsMain spotbugsTest` 병행 실행 가능. + +--- + +## 4. 작성 규칙 + +### 4.1 단위 테스트 작성 체크리스트 +- [ ] 단일 책임/작은 단위만 검증한다. +- [ ] 외부 의존은 Mockito로 모킹한다. +- [ ] 스프링 컨텍스트를 기동하지 않는다. +- [ ] 성공/실패 경로를 모두 검증한다(예외 포함). +- [ ] 경계값, 널/빈값 케이스 포함. + +### 4.2 통합 테스트 작성 체크리스트 +- [ ] Spring 컨텍스트 기동 및 필요한 빈 주입 확인. +- [ ] 보안 필터/JWT 인증 흐름을 실제로 검증한다. +- [ ] 컨트롤러 → 앱 서비스 → 영속성(JPA/H2) 경로를 통해 상태 변화/응답을 확인한다. +- [ ] API 명세 문서(docs/specs/api/*.md)의 체크리스트를 테스트 케이스로 반영한다. +- [ ] 테스트 독립성을 보장하고, 데이터 격리를 유지한다. + +### 4.3 공통 규칙 +- 테스트명/메서드명은 자연어에 가깝게 시나리오를 드러낸다(한글/영문 허용). +- 반복 셋업은 유틸/추상 베이스 클래스로 추출(예: `IntegrationTestUtils`). +- 민감정보(비밀번호, 토큰 원문 등)는 로그에 남기지 않는다. + +--- + +## 5. API 스펙 연동 +- 각 API 문서의 "테스트" 체크박스를 충족하는 테스트를 작성/유지한다. + - 로그인: `docs/specs/api/login.md` + - 회원가입: `docs/specs/api/member_register.md` + - 토큰 재발급: `docs/specs/api/reissue.md` + - 영화 등록: `docs/specs/api/movie_register.md` +- 체크박스는 수용 기준(acceptance criteria)로 간주하며, 누락 시 테스트 보완 또는 문서 동기화가 필요하다. + +--- + +## 6. 실행 방법과 성능 +- 표준 실행: `./gradlew test` +- 통합 테스트의 컨텍스트 초기화 비용이 크므로, 로컬 개발 중에는 대상 패키지/클래스만 선별 실행을 권장. +- 빠른 피드백: 도메인/유틸 단위 테스트 우선 실행 → 이후 통합 테스트. + +--- + +## 7. CI 연동 +- GitHub Actions 등 CI 정의는 현재 저장소에서 확인 불가. +- 향후 CI 도입 시, 최소 요구: `./gradlew clean test` + ArchUnit + SpotBugs. + +--- + +## 8. 디렉터리/네이밍 가이드 +- 테스트 루트: `src/test/java` +- 관례: + - 단위 테스트: 대상 패키지에 맞춰 배치, 클래스명 `*Test` + - 통합 테스트: 시나리오 중심 폴더 구조 사용 가능 + - 예시) + - POST `/api/auth/login`: `src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` + - GET `/api/movies`: `src/test/java/org/mandarin/booking/webapi/movies/GET_specs.java` + - 아키텍처 테스트: `arch/*` + +--- + +## 9. 부록: 참고 클래스 +- ArchUnit: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` +- 보안 테스트: `src/test/java/org/mandarin/booking/adapter/security/*.java` +- 웹 API 스펙: `src/test/java/org/mandarin/booking/webapi/**` +- 통합 유틸: `src/test/java/org/mandarin/booking/IntegrationTest*.java`, `JwtTestUtils.java`