From c99a820a75bf9ea17443d649cd8f8e3c0bb1ccaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:39:44 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9B=B9=20=EC=A0=84=EC=9A=A9=20Ht?= =?UTF-8?q?tpOnly=20=EC=BF=A0=ED=82=A4=20=EC=9D=B8=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/web-cookie-auth.md | 109 +++++ .../user/service/RefreshTokenService.java | 13 +- .../koin/domain/user/service/UserService.java | 8 +- .../user/web/controller/WebAuthApi.java | 81 ++++ .../web/controller/WebAuthController.java | 71 +++ .../domain/user/web/dto/WebAuthResponse.java | 19 + .../user/web/dto/WebCsrfTokenResponse.java | 16 + .../domain/user/web/dto/WebLoginRequest.java | 36 ++ .../domain/user/web/model/WebAuthSession.java | 52 +++ .../user/web/model/WebRefreshToken.java | 64 +++ .../WebAuthSessionRedisRepository.java | 79 ++++ .../user/web/service/WebAuthService.java | 123 +++++ .../user/web/service/WebAuthTokens.java | 20 + .../koin/global/auth/JwtProvider.java | 54 ++- .../global/auth/WebAuthCookieManager.java | 80 ++++ .../global/auth/WebAuthRequestValidator.java | 58 +++ .../WebCookieAuthenticationInterceptor.java | 57 +++ .../koin/global/code/ApiResponseCode.java | 3 + .../koin/global/config/WebAuthProperties.java | 33 ++ .../koin/global/config/WebConfig.java | 12 +- .../exception/GlobalExceptionHandler.java | 10 +- src/main/resources/application-local.yml | 4 + src/main/resources/application.yml | 7 + .../acceptance/domain/WebAuthApiTest.java | 429 ++++++++++++++++++ .../WebAuthSessionRedisRepositoryTest.java | 140 ++++++ .../domain/user/web/WebAuthServiceTest.java | 155 +++++++ .../global/auth/WebAuthCookieManagerTest.java | 50 ++ .../unit/global/auth/WebAuthLoggingTest.java | 50 ++ .../auth/WebAuthRequestValidatorTest.java | 96 ++++ .../unit/global/auth/WebJwtProviderTest.java | 56 +++ 30 files changed, 1970 insertions(+), 15 deletions(-) create mode 100644 docs/web-cookie-auth.md create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthController.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/dto/WebAuthResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/dto/WebLoginRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/model/WebAuthSession.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/model/WebRefreshToken.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/repository/WebAuthSessionRedisRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthTokens.java create mode 100644 src/main/java/in/koreatech/koin/global/auth/WebAuthCookieManager.java create mode 100644 src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java create mode 100644 src/main/java/in/koreatech/koin/global/auth/WebCookieAuthenticationInterceptor.java create mode 100644 src/main/java/in/koreatech/koin/global/config/WebAuthProperties.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/WebAuthSessionRedisRepositoryTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthServiceTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/global/auth/WebAuthCookieManagerTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/global/auth/WebJwtProviderTest.java diff --git a/docs/web-cookie-auth.md b/docs/web-cookie-auth.md new file mode 100644 index 0000000000..7ed44d7ff0 --- /dev/null +++ b/docs/web-cookie-auth.md @@ -0,0 +1,109 @@ +# 웹 HttpOnly 쿠키 인증 + +기존 `/v2/users/login`, `/user/login`, `/user/refresh`, `/user/logout`의 앱용 계약은 유지한다. +웹(PC·모바일 브라우저)은 아래 API로 전환한다. User-Agent의 PC/Mobile 분류로 인증 방식을 선택하지 않는다. +이번 변경은 일반·학생·총학생회 사용자의 웹 인증을 대상으로 하며 사장님·영양사·관리자 로그인은 기존 API를 사용한다. + +## API 계약 + +| 요청 | 입력 | 성공 응답 | +| --- | --- | --- | +| `POST /v2/web/auth/login` | JSON `login_id`, `login_pw`, `auto_login` | 201, 인증 쿠키 2개, `user_type`·`csrf_token` | +| `GET /v2/web/auth/csrf` | refresh 쿠키 | 200, `csrf_token` | +| `POST /v2/web/auth/refresh` | refresh 쿠키, `X-CSRF-Token` | 201, 교체된 인증 쿠키 2개, `user_type`·`csrf_token` | +| `POST /v2/web/auth/logout` | refresh 쿠키, `X-CSRF-Token` | 204, 현재 웹 세션 폐기 및 쿠키 삭제 | + +웹 인증 API는 허용된 `Origin` 또는 `Referer`가 필요하다. 로그인 본문은 `application/json`만 받는다. +`login_pw`는 기존 로그인과 동일하게 SHA-256 처리한 비밀번호를 전달한다. +`auto_login`은 생략하면 false이며, false일 때는 두 쿠키 모두 브라우저 세션 쿠키다. +브라우저의 세션 복원 기능 때문에 브라우저 종료가 서버 세션의 즉시 폐기를 의미하지는 않는다. + +access·refresh 값은 JSON 응답에 포함하지 않는다. `csrf_token`은 인증 토큰과 별개의 난수로, +웹 코드가 메모리에 보관하고 쿠키로 인증하는 POST/PUT/PATCH/DELETE 요청의 `X-CSRF-Token` 헤더에 넣는다. +새로고침으로 메모리의 CSRF 토큰을 잃으면 `/csrf`에서 다시 조회한다. 로그인 시에는 기존 세션이 없으므로 +허용 출처 검사와 JSON Content-Type 제한으로 로그인 CSRF를 방어한다. + +## 웹 연동 순서 + +1. 모든 웹 API 요청에 `credentials: 'include'` 또는 Axios의 `withCredentials: true`를 설정한다. +2. 로그인 성공 시 `user_type`과 `csrf_token`만 메모리에 보관한다. +3. 기존 `document.cookie`, localStorage, Zustand의 access·refresh 저장 및 Bearer 헤더 주입을 제거한다. +4. 쿠키 인증의 상태 변경 요청에는 CSRF 헤더를 추가한다. 조회 요청은 쿠키만으로 인증한다. +5. access 만료 시 `/refresh`를 한 번 호출한 뒤 원래 요청을 재시도한다. +6. 로그아웃은 서버 `/logout`의 성공을 확인한 뒤 화면의 로그인 상태를 비운다. + +로그인·재발급·로그아웃·CSRF 조회는 만료된 access 쿠키의 영향을 받지 않는다. +일반 기능 API에서는 명시적인 `Authorization` 헤더가 있으면 기존 헤더 인증만 사용한다. +잘못된 헤더를 쿠키 인증으로 대체하지 않으므로, 웹 전환 시 예전 헤더 주입 코드도 제거해야 한다. +기존 앱 토큰을 웹 쿠키로 넣거나 웹 토큰을 기존 앱 인증 경로로 전달하는 것은 허용하지 않는다. +일반 API의 쿠키 인증은 기존 `@Auth`·`@UserId` 파라미터가 있는 메서드에 적용한다. +회원가입·비밀번호 재설정 등 비인증 API는 남아 있는 쿠키 때문에 인증이나 CSRF 헤더를 요구하지 않는다. + +## 쿠키와 만료 설정 + +| 항목 | 운영 기본값 | +| --- | --- | +| access 쿠키 | `__Host-koin-web-access`, Path `/`, 기본 15분 | +| refresh 쿠키 | `__Secure-koin-web-refresh`, Path `/v2/web/auth`, 기본 90일 | +| 공통 속성 | HttpOnly, Secure, SameSite=Lax, Domain 미지정(host-only) | +| 자동 로그인 false | Max-Age 없는 세션 쿠키. 서버의 만료 시간은 그대로 적용 | + +`WEB_AUTH_ACCESS_TOKEN_TTL`, `WEB_AUTH_REFRESH_TOKEN_TTL`, `WEB_AUTH_COOKIE_SECURE`, +`WEB_AUTH_COOKIE_SAME_SITE` 환경변수로 설정한다. 기존 앱의 JWT 만료 설정은 바꾸지 않는다. +`local` 프로필에서는 HTTP 개발을 위해 Secure=false와 `koin-web-access`·`koin-web-refresh` 이름을 사용한다. +운영에서는 Secure를 유지해야 하며 SameSite=None은 Secure 없이 설정할 수 없다. +`CORS_ALLOWED_ORIGINS`에는 실제 웹의 정확한 origin을 넣는다. `*`나 도메인 접미사 비교로 웹 요청을 허용하지 않는다. + +## 세션과 동시 요청 + +웹은 로그인마다 `webAuthSession:<랜덤 세션 ID>`를 생성한다. 앱의 기존 Redis 키와 독립적이며 DB 마이그레이션은 없다. +refresh 원문은 저장하지 않고 SHA-256 해시를 저장한다. 재발급 시 refresh를 교체하되 로그인 시 정한 절대 만료 시간은 연장하지 않는다. +웹 access JWT에도 세션 ID를 넣고 요청마다 Redis 세션을 확인하므로 웹 로그아웃 후 남은 access도 사용할 수 없다. +이 때문에 쿠키 인증은 Redis 가용성에 의존하며, Redis 조회 실패 시 인증을 허용하지 않는다. + +Redis의 원자적 비교·교체로 같은 refresh의 동시 갱신은 하나만 성공한다. 먼저 읽은 상태가 다른 요청에 의해 변경되면 409, +이미 교체된 refresh를 새로 제출하면 401이다. 실패 응답은 쿠키를 삭제하거나 덮어쓰지 않는다. +웹은 여러 탭까지 고려해 갱신을 직렬화하고, 갱신과 로그아웃도 동시에 보내지 않아야 한다. +409는 진행 중인 요청을 기다린 뒤 최신 쿠키로 제한적으로 재시도하고, +다른 요청의 갱신이 확인된 401도 최신 쿠키로 한 번만 재시도한다. 재시도에도 인증되지 않으면 재로그인한다. +응답 자체를 받지 못해 교체된 refresh 쿠키를 잃은 경우에는 재로그인이 필요하다. + +비밀번호가 바뀌면 다음 재발급에서 기존 웹 세션을 거부한다. 비밀번호 변경과 동시에 모든 access가 폐기되는 정책은 아니다. +회원의 기기 전체 로그아웃 목록·관리 API는 이번 변경에 포함하지 않는다. + +## SSR·웹뷰 연동 범위 + +쿠키는 API 호스트에만 저장한다. 프런트 서버가 다른 호스트에 있다면 프런트 SSR 요청에서 이 쿠키를 바로 받을 수 없다. +현재 웹의 SSR 인증은 배포 도메인 구성에 맞춰 별도 연동해야 하며, 프런트와 같은 origin의 프록시/BFF를 사용하는 방안 등을 검토한다. +프록시는 인증 경로와 Cookie/Set-Cookie를 올바르게 전달하고, 서버 요청에는 설정된 웹 origin을 전달해야 한다. +SSR props, hydration, React Query 캐시 등 브라우저로 직렬화하는 데이터에 인증 토큰을 넣으면 안 된다. + +앱 내부 웹뷰에서 토큰을 쿠키나 localStorage에 직접 주입하는 기존 연결은 별도 전환이 필요할 수 있다. +이 브랜치는 앱 웹뷰의 자동 로그인 교환이나 STOMP 쿠키 인증을 추가하지 않는다. 기존 STOMP 헤더 인증은 유지한다. +실제 브라우저·SSR·웹뷰·stage 도메인 통합 검증은 해당 클라이언트 전환 후 수행해야 한다. + +## 확인 방법 + +Java 17과 Docker를 사용한다. + +```sh +./gradlew build +``` + +`WebAuthApiTest`는 실제 테스트 DB·Redis를 연결한 MockMvc로 쿠키 속성, 앱 호환성, +권한 검사, 만료·재발급·로그아웃, CSRF·CORS, 토큰 전달 경로 분리를 검증한다. +`WebAuthSessionRedisRepositoryTest`는 실제 Redis에서 동시 갱신과 로그아웃 후 세션 복구 방지를 검증한다. +단위 테스트는 출처 검사, JWT 경로 분리, 세션 검증, 로컬 쿠키 설정과 로그 마스킹을 확인한다. + +### 로컬 검증 결과 (2026-09-10) + +- 기준 브랜치: `origin/develop`의 `3d14db39` +- Java 17, 실제 테스트 MySQL·Redis·MongoDB를 사용한 전체 `build` 성공 +- 전체 1,216개 중 1,213개 통과, 기존 비활성 테스트 3개 건너뜀, 실패 0개 +- 이번에 추가한 인증 테스트 67개 모두 통과 +- 로컬 `build/`에 중복된 `Test 2.class` 산출물이 발견되어, 검증에서는 임시 Gradle init script로 + buildDirectory만 `/tmp/koin-web-auth-build-20260910`으로 분리했다. 프로젝트의 Gradle 빌드 설정은 변경하지 않았다. +- 실제 웹 화면·SSR·앱 웹뷰 연동과 운영/stage 검증은 수행하지 않았다. + +쿠키 동작은 [MDN Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie), +요청 위조 방어는 [OWASP CSRF 방어 지침](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)을 참고했다. diff --git a/src/main/java/in/koreatech/koin/domain/user/service/RefreshTokenService.java b/src/main/java/in/koreatech/koin/domain/user/service/RefreshTokenService.java index 38ec8bd355..3cb7de02b9 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/RefreshTokenService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/RefreshTokenService.java @@ -33,7 +33,7 @@ public void verifyRefreshToken(Integer userId, String platform, String refreshTo String key = RefreshToken.generateKey(userId, platform); String savedRefreshToken = refreshTokenRedisRepository.getById(key).getToken(); if (!Objects.equals(savedRefreshToken, refreshToken)) { - throw CustomException.of(ApiResponseCode.NOT_MATCHED_REFRESH_TOKEN, "refreshToken: " + refreshToken); + throw CustomException.of(ApiResponseCode.NOT_MATCHED_REFRESH_TOKEN); } } @@ -51,10 +51,17 @@ public void deleteAllRefreshTokens(Integer userId) { } public Integer extractUserId(String refreshToken) { + if (refreshToken == null || refreshToken.contains(".")) { + throw CustomException.of(ApiResponseCode.INVALID_REFRESH_TOKEN); + } String[] split = refreshToken.split("-"); if (split.length == 0) { - throw CustomException.of(ApiResponseCode.INVALID_REFRESH_TOKEN, "refreshToken: " + refreshToken); + throw CustomException.of(ApiResponseCode.INVALID_REFRESH_TOKEN); + } + try { + return Integer.parseInt(split[split.length - 1]); + } catch (NumberFormatException e) { + throw CustomException.of(ApiResponseCode.INVALID_REFRESH_TOKEN); } - return Integer.parseInt(split[split.length - 1]); } } diff --git a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java index 2b0429b237..02490922c2 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java @@ -103,6 +103,12 @@ public void updatePassword(Integer userId, UserUpdatePasswordRequest request) { @Transactional public UserLoginResponse loginV2(UserLoginRequestV2 request, UserAgentInfo userAgentInfo) { + User user = authenticate(request); + return createLoginResponse(user, userAgentInfo); + } + + @Transactional + public User authenticate(UserLoginRequestV2 request) { User user; String loginId = request.loginId(); if (loginId.matches("^\\d{11}$")) { @@ -112,7 +118,7 @@ public UserLoginResponse loginV2(UserLoginRequestV2 request, UserAgentInfo userA } user.requireSameLoginPw(passwordEncoder, request.loginPw()); user.updateLastLoggedTime(LocalDateTime.now()); - return createLoginResponse(user, userAgentInfo); + return user; } @Transactional diff --git a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java new file mode 100644 index 0000000000..7298fe559b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java @@ -0,0 +1,81 @@ +package in.koreatech.koin.domain.user.web.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; + +import in.koreatech.koin.domain.user.web.dto.WebAuthResponse; +import in.koreatech.koin.domain.user.web.dto.WebCsrfTokenResponse; +import in.koreatech.koin.domain.user.web.dto.WebLoginRequest; +import in.koreatech.koin.global.auth.WebAuthRequestValidator; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.code.ApiResponseCodes; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; + +@Tag( + name = "(Normal) Web Auth: 웹 인증", + description = "HttpOnly 쿠키 기반 인증. 허용된 Origin 또는 Referer가 필요합니다." +) +@RequestMapping("/v2/web/auth") +public interface WebAuthApi { + + @Operation( + summary = "웹 로그인", + description = "토큰은 Set-Cookie로만 발급합니다. 요청에는 credentials를 포함해야 합니다." + ) + @ApiResponse(responseCode = "201", description = "쿠키 발급 성공") + @ApiResponseCodes({ApiResponseCode.INVALID_REQUEST_BODY, ApiResponseCode.FORBIDDEN_WEB_ORIGIN}) + @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity login( + @RequestBody @Valid WebLoginRequest request, + HttpServletResponse response + ); + + @Operation( + summary = "웹 토큰 재발급", + description = "refresh 쿠키와 CSRF 헤더가 필요합니다. refresh 토큰을 교체하며 로그인 유지 기간은 연장하지 않습니다." + ) + @ApiResponse(responseCode = "201", description = "쿠키 재발급 성공") + @ApiResponseCodes({ApiResponseCode.UNAUTHORIZED_USER, ApiResponseCode.INVALID_CSRF_TOKEN, + ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.WEB_AUTH_SESSION_CONFLICT}) + @PostMapping("/refresh") + ResponseEntity refresh( + HttpServletRequest request, + HttpServletResponse response, + @Parameter(description = "로그인 또는 /csrf 응답으로 받은 CSRF 토큰") + @RequestHeader(value = WebAuthRequestValidator.CSRF_HEADER, required = false) String csrfToken + ); + + @Operation( + summary = "웹 로그아웃", + description = "현재 웹 세션을 폐기하고 쿠키를 삭제합니다. 다른 웹 세션과 앱 로그인은 유지합니다." + ) + @ApiResponse(responseCode = "204", description = "로그아웃 성공") + @ApiResponseCodes({ApiResponseCode.UNAUTHORIZED_USER, ApiResponseCode.INVALID_CSRF_TOKEN, + ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.WEB_AUTH_SESSION_CONFLICT}) + @PostMapping("/logout") + ResponseEntity logout( + HttpServletRequest request, + HttpServletResponse response, + @RequestHeader(value = WebAuthRequestValidator.CSRF_HEADER, required = false) String csrfToken + ); + + @Operation( + summary = "웹 CSRF 토큰 조회", + description = "페이지 새로고침 후 refresh 쿠키로 CSRF 토큰을 조회합니다. access 토큰 만료 여부와 무관합니다." + ) + @ApiResponse(responseCode = "200", description = "CSRF 토큰 조회 성공") + @ApiResponseCodes({ApiResponseCode.UNAUTHORIZED_USER, ApiResponseCode.FORBIDDEN_WEB_ORIGIN}) + @GetMapping("/csrf") + ResponseEntity getCsrfToken(HttpServletRequest request); +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthController.java b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthController.java new file mode 100644 index 0000000000..311f5dcf15 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthController.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.domain.user.web.controller; + +import java.net.URI; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.user.web.dto.WebAuthResponse; +import in.koreatech.koin.domain.user.web.dto.WebCsrfTokenResponse; +import in.koreatech.koin.domain.user.web.dto.WebLoginRequest; +import in.koreatech.koin.domain.user.web.service.WebAuthService; +import in.koreatech.koin.domain.user.web.service.WebAuthTokens; +import in.koreatech.koin.global.auth.WebAuthCookieManager; +import in.koreatech.koin.global.auth.WebAuthRequestValidator; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/web/auth") +public class WebAuthController implements WebAuthApi { + + private final WebAuthService webAuthService; + private final WebAuthCookieManager cookieManager; + + @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity login( + @RequestBody @Valid WebLoginRequest request, + HttpServletResponse response + ) { + WebAuthTokens tokens = webAuthService.login(request); + cookieManager.write(response, tokens); + return ResponseEntity.created(URI.create("/")).body(tokens.response()); + } + + @PostMapping("/refresh") + public ResponseEntity refresh( + HttpServletRequest request, + HttpServletResponse response, + @RequestHeader(value = WebAuthRequestValidator.CSRF_HEADER, required = false) String csrfToken + ) { + WebAuthTokens tokens = webAuthService.refresh(cookieManager.getRefreshToken(request), csrfToken); + cookieManager.write(response, tokens); + return ResponseEntity.created(URI.create("/")).body(tokens.response()); + } + + @PostMapping("/logout") + public ResponseEntity logout( + HttpServletRequest request, + HttpServletResponse response, + @RequestHeader(value = WebAuthRequestValidator.CSRF_HEADER, required = false) String csrfToken + ) { + webAuthService.logout(cookieManager.getRefreshToken(request), csrfToken); + cookieManager.clear(response); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/csrf") + public ResponseEntity getCsrfToken(HttpServletRequest request) { + String csrfToken = webAuthService.getCsrfToken(cookieManager.getRefreshToken(request)); + return ResponseEntity.ok(new WebCsrfTokenResponse(csrfToken)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/dto/WebAuthResponse.java b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebAuthResponse.java new file mode 100644 index 0000000000..556b983614 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebAuthResponse.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.user.web.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record WebAuthResponse( + @Schema(description = "회원 유형", example = "STUDENT", requiredMode = REQUIRED) + String userType, + + @Schema(description = "상태 변경 요청의 X-CSRF-Token 헤더에 전달할 값", requiredMode = REQUIRED) + String csrfToken +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java new file mode 100644 index 0000000000..3952800fc7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.user.web.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record WebCsrfTokenResponse( + @Schema(description = "상태 변경 요청의 X-CSRF-Token 헤더에 전달할 값", requiredMode = REQUIRED) + String csrfToken +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/dto/WebLoginRequest.java b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebLoginRequest.java new file mode 100644 index 0000000000..3525b71c44 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebLoginRequest.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.domain.user.web.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.user.dto.UserLoginRequestV2; +import in.koreatech.koin.global.validation.NotEmoji; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record WebLoginRequest( + @Schema(description = "아이디 또는 전화번호", example = "example1", requiredMode = REQUIRED) + @NotBlank(message = "아이디 또는 전화번호를 입력해주세요.") + @NotEmoji + String loginId, + + @Schema(description = "비밀번호 (SHA 256 해싱된 값)", requiredMode = REQUIRED) + @NotBlank(message = "비밀번호를 입력해주세요.") + String loginPw, + + @Schema(description = "자동 로그인 여부. false이면 브라우저 세션 쿠키를 사용합니다.", defaultValue = "false") + boolean autoLogin +) { + + public UserLoginRequestV2 toLoginRequest() { + return new UserLoginRequestV2(loginId, loginPw); + } + + @Override + public String toString() { + return "WebLoginRequest[REDACTED]"; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/model/WebAuthSession.java b/src/main/java/in/koreatech/koin/domain/user/web/model/WebAuthSession.java new file mode 100644 index 0000000000..f90a74b33b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/model/WebAuthSession.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.domain.user.web.model; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.security.MessageDigest; +import java.time.Instant; + +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; + +public record WebAuthSession( + String id, + Integer userId, + String refreshTokenHash, + String csrfToken, + String credentialHash, + Instant expiresAt, + boolean autoLogin +) { + + public void requireRefreshToken(WebRefreshToken token) { + if (!matches(refreshTokenHash, token.hash())) { + throw AuthenticationException.withDetail("웹 재발급 토큰이 일치하지 않습니다."); + } + } + + public void requireCsrfToken(String token) { + if (!matches(csrfToken, token)) { + throw CustomException.of(ApiResponseCode.INVALID_CSRF_TOKEN); + } + } + + public void requireNotExpired() { + if (!expiresAt.isAfter(Instant.now())) { + throw AuthenticationException.withDetail("웹 로그인 유지 기간이 만료되었습니다."); + } + } + + public WebAuthSession rotate(WebRefreshToken token) { + return new WebAuthSession(id, userId, token.hash(), csrfToken, credentialHash, expiresAt, autoLogin); + } + + private boolean matches(String expected, String actual) { + return actual != null && MessageDigest.isEqual(expected.getBytes(UTF_8), actual.getBytes(UTF_8)); + } + + @Override + public String toString() { + return "WebAuthSession[REDACTED]"; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/model/WebRefreshToken.java b/src/main/java/in/koreatech/koin/domain/user/web/model/WebRefreshToken.java new file mode 100644 index 0000000000..28ed7b2cec --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/model/WebRefreshToken.java @@ -0,0 +1,64 @@ +package in.koreatech.koin.domain.user.web.model; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import java.util.UUID; +import java.util.regex.Pattern; + +import in.koreatech.koin.global.auth.exception.AuthenticationException; + +public record WebRefreshToken(String sessionId, String secret) { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Pattern TOKEN_PATTERN = Pattern.compile( + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.[A-Za-z0-9_-]{43}" + ); + + public static WebRefreshToken create() { + return new WebRefreshToken(UUID.randomUUID().toString(), createSecret()); + } + + public static WebRefreshToken parse(String value) { + if (value == null || !TOKEN_PATTERN.matcher(value).matches()) { + throw AuthenticationException.withDetail("올바르지 않은 웹 재발급 토큰입니다."); + } + int separator = value.indexOf('.'); + return new WebRefreshToken(value.substring(0, separator), value.substring(separator + 1)); + } + + public WebRefreshToken rotate() { + return new WebRefreshToken(sessionId, createSecret()); + } + + public String value() { + return sessionId + "." + secret; + } + + public String hash() { + return hash(value()); + } + + public static String createSecret() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public static String hash(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256을 사용할 수 없습니다.", e); + } + } + + @Override + public String toString() { + return "WebRefreshToken[REDACTED]"; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/repository/WebAuthSessionRedisRepository.java b/src/main/java/in/koreatech/koin/domain/user/web/repository/WebAuthSessionRedisRepository.java new file mode 100644 index 0000000000..11287191d6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/repository/WebAuthSessionRedisRepository.java @@ -0,0 +1,79 @@ +package in.koreatech.koin.domain.user.web.repository; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WebAuthSessionRedisRepository { + + private static final String KEY_PREFIX = "webAuthSession:"; + private static final DefaultRedisScript ROTATE_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('GET', KEYS[1]) == ARGV[1] then + redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') + return 1 + end + return 0 + """, Long.class); + private static final DefaultRedisScript DELETE_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) + end + return 0 + """, Long.class); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public void save(WebAuthSession session) { + Duration ttl = Duration.between(Instant.now(), session.expiresAt()); + if (!Boolean.TRUE.equals(redisTemplate.opsForValue() + .setIfAbsent(KEY_PREFIX + session.id(), serialize(session), ttl))) { + throw new IllegalStateException("웹 로그인 세션을 생성할 수 없습니다."); + } + } + + public Optional findById(String sessionId) { + String value = redisTemplate.opsForValue().get(KEY_PREFIX + sessionId); + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(value, WebAuthSession.class)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("웹 로그인 세션을 읽을 수 없습니다."); + } + } + + public boolean rotate(WebAuthSession previous, WebAuthSession next) { + return Long.valueOf(1).equals(redisTemplate.execute( + ROTATE_SCRIPT, List.of(KEY_PREFIX + previous.id()), serialize(previous), serialize(next) + )); + } + + public boolean delete(WebAuthSession session) { + return Long.valueOf(1).equals(redisTemplate.execute( + DELETE_SCRIPT, List.of(KEY_PREFIX + session.id()), serialize(session) + )); + } + + private String serialize(WebAuthSession session) { + try { + return objectMapper.writeValueAsString(session); + } catch (JsonProcessingException e) { + throw new IllegalStateException("웹 로그인 세션을 저장할 수 없습니다."); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java new file mode 100644 index 0000000000..2ffce123b2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java @@ -0,0 +1,123 @@ +package in.koreatech.koin.domain.user.web.service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.service.UserService; +import in.koreatech.koin.domain.user.web.dto.WebAuthResponse; +import in.koreatech.koin.domain.user.web.dto.WebLoginRequest; +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.domain.user.web.model.WebRefreshToken; +import in.koreatech.koin.domain.user.web.repository.WebAuthSessionRedisRepository; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.config.WebAuthProperties; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WebAuthService { + + private final UserService userService; + private final UserRepository userRepository; + private final WebAuthSessionRedisRepository sessionRepository; + private final JwtProvider jwtProvider; + private final WebAuthProperties properties; + + @Transactional + public WebAuthTokens login(WebLoginRequest request) { + User user = userService.authenticate(request.toLoginRequest()); + WebRefreshToken refreshToken = WebRefreshToken.create(); + WebAuthSession session = new WebAuthSession( + refreshToken.sessionId(), user.getId(), refreshToken.hash(), WebRefreshToken.createSecret(), + WebRefreshToken.hash(user.getLoginPw()), + Instant.now().plus(properties.refreshTokenTtl()).truncatedTo(ChronoUnit.SECONDS), request.autoLogin() + ); + WebAuthTokens tokens = createTokens(user, session, refreshToken); + sessionRepository.save(session); + return tokens; + } + + public WebAuthTokens refresh(String value, String csrfToken) { + WebRefreshToken refreshToken = WebRefreshToken.parse(value); + WebAuthSession session = getSession(refreshToken.sessionId()); + session.requireRefreshToken(refreshToken); + session.requireCsrfToken(csrfToken); + + User user = userRepository.findById(session.userId()) + .orElseThrow(() -> AuthenticationException.withDetail("웹 로그인 사용자가 존재하지 않습니다.")); + if (!session.credentialHash().equals(WebRefreshToken.hash(user.getLoginPw()))) { + sessionRepository.delete(session); + throw AuthenticationException.withDetail("비밀번호가 변경되었습니다. 다시 로그인해주세요."); + } + + WebRefreshToken nextToken = refreshToken.rotate(); + WebAuthSession nextSession = session.rotate(nextToken); + WebAuthTokens tokens = createTokens(user, nextSession, nextToken); + if (!sessionRepository.rotate(session, nextSession)) { + throw CustomException.of(ApiResponseCode.WEB_AUTH_SESSION_CONFLICT); + } + return tokens; + } + + public void logout(String value, String csrfToken) { + if (!StringUtils.hasText(value)) { + return; + } + WebRefreshToken refreshToken = WebRefreshToken.parse(value); + WebAuthSession session = sessionRepository.findById(refreshToken.sessionId()).orElse(null); + if (session == null) { + return; + } + session.requireRefreshToken(refreshToken); + session.requireCsrfToken(csrfToken); + if (!sessionRepository.delete(session)) { + throw CustomException.of(ApiResponseCode.WEB_AUTH_SESSION_CONFLICT); + } + } + + public String getCsrfToken(String value) { + WebRefreshToken refreshToken = WebRefreshToken.parse(value); + WebAuthSession session = getSession(refreshToken.sessionId()); + session.requireRefreshToken(refreshToken); + return session.csrfToken(); + } + + public WebAuthSession authenticate(String accessToken) { + JwtProvider.WebTokenClaims claims = jwtProvider.getWebTokenClaims(accessToken); + WebAuthSession session = getSession(claims.sessionId()); + if (!session.userId().equals(claims.userId())) { + throw AuthenticationException.withDetail("웹 로그인 사용자 정보가 일치하지 않습니다."); + } + return session; + } + + private WebAuthSession getSession(String sessionId) { + WebAuthSession session = sessionRepository.findById(sessionId) + .orElseThrow(() -> AuthenticationException.withDetail( + "웹 로그인 정보가 만료되었거나 로그아웃되었습니다.")); + session.requireNotExpired(); + return session; + } + + private WebAuthTokens createTokens(User user, WebAuthSession session, WebRefreshToken refreshToken) { + Instant accessExpiresAt = Instant.now().plus(properties.accessTokenTtl()).truncatedTo(ChronoUnit.SECONDS); + if (accessExpiresAt.isAfter(session.expiresAt())) { + accessExpiresAt = session.expiresAt(); + } + return new WebAuthTokens( + jwtProvider.createWebToken(user, session.id(), accessExpiresAt), refreshToken.value(), + accessExpiresAt, session.expiresAt(), session.autoLogin(), + new WebAuthResponse(user.getUserType().getValue(), session.csrfToken()) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthTokens.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthTokens.java new file mode 100644 index 0000000000..d9c9f207e7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthTokens.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.web.service; + +import java.time.Instant; + +import in.koreatech.koin.domain.user.web.dto.WebAuthResponse; + +public record WebAuthTokens( + String accessToken, + String refreshToken, + Instant accessExpiresAt, + Instant refreshExpiresAt, + boolean autoLogin, + WebAuthResponse response +) { + + @Override + public String toString() { + return "WebAuthTokens[REDACTED]"; + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/JwtProvider.java b/src/main/java/in/koreatech/koin/global/auth/JwtProvider.java index f597edb10d..977023d596 100644 --- a/src/main/java/in/koreatech/koin/global/auth/JwtProvider.java +++ b/src/main/java/in/koreatech/koin/global/auth/JwtProvider.java @@ -15,6 +15,7 @@ import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserType; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; @@ -22,6 +23,8 @@ @Component public class JwtProvider { + private static final String WEB_SESSION_ID = "web_session_id"; + private final String secretKey; private final Long expirationTime; @@ -34,6 +37,14 @@ public JwtProvider( } public String createToken(User user) { + return createToken(user, null, Instant.now().plusMillis(expirationTime)); + } + + public String createWebToken(User user, String sessionId, Instant expiresAt) { + return createToken(user, sessionId, expiresAt); + } + + private String createToken(User user, String sessionId, Instant expiresAt) { if (user == null) { throw CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user: " + null); } @@ -45,7 +56,8 @@ public String createToken(User user) { .add("alg", key.getAlgorithm()) .and() .claim("id", user.getId()) - .expiration(Date.from(Instant.now().plusMillis(expirationTime))) + .claim(WEB_SESSION_ID, sessionId) + .expiration(Date.from(expiresAt)) .compact(); } @@ -66,20 +78,46 @@ public String createTemporaryToken() { } public Integer getUserId(String token) { + Claims claims = getClaims(token); + if (claims.containsKey(WEB_SESSION_ID)) { + throw AuthenticationException.withDetail("웹 토큰은 쿠키 인증에서만 사용할 수 있습니다."); + } + return getUserId(claims); + } + + public WebTokenClaims getWebTokenClaims(String token) { + Claims claims = getClaims(token); + Object sessionId = claims.get(WEB_SESSION_ID); + if (!(sessionId instanceof String value) || value.isBlank()) { + throw AuthenticationException.withDetail("웹 로그인 세션 정보가 없습니다."); + } + return new WebTokenClaims(getUserId(claims), value); + } + + private Claims getClaims(String token) { try { - String userId = Jwts.parser() + return Jwts.parser() .verifyWith(getSecretKey()) .build() .parseSignedClaims(token) - .getPayload() - .get("id") - .toString(); - return Integer.parseInt(userId); - } catch (JwtException e) { - throw AuthenticationException.withDetail("token: " + token); + .getPayload(); + } catch (JwtException | IllegalArgumentException e) { + throw AuthenticationException.withDetail("토큰 검증에 실패했습니다."); } } + private Integer getUserId(Claims claims) { + try { + return Integer.valueOf(String.valueOf(claims.get("id"))); + } catch (NumberFormatException e) { + throw AuthenticationException.withDetail("토큰의 사용자 정보가 올바르지 않습니다."); + } + } + + public record WebTokenClaims(Integer userId, String sessionId) { + + } + private SecretKey getSecretKey() { String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes()); return Keys.hmacShaKeyFor(encoded.getBytes()); diff --git a/src/main/java/in/koreatech/koin/global/auth/WebAuthCookieManager.java b/src/main/java/in/koreatech/koin/global/auth/WebAuthCookieManager.java new file mode 100644 index 0000000000..ac151b28f0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/WebAuthCookieManager.java @@ -0,0 +1,80 @@ +package in.koreatech.koin.global.auth; + +import java.time.Duration; +import java.time.Instant; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.user.web.service.WebAuthTokens; +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import in.koreatech.koin.global.config.WebAuthProperties; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class WebAuthCookieManager { + + public static final String AUTH_PATH = "/v2/web/auth"; + + private final WebAuthProperties properties; + + public String getAccessToken(HttpServletRequest request) { + return getCookie(request, properties.accessCookieName()); + } + + public String getRefreshToken(HttpServletRequest request) { + return getCookie(request, properties.refreshCookieName()); + } + + public void write(HttpServletResponse response, WebAuthTokens tokens) { + addCookie(response, properties.accessCookieName(), tokens.accessToken(), "/", + maxAge(tokens.accessExpiresAt(), tokens.autoLogin())); + addCookie(response, properties.refreshCookieName(), tokens.refreshToken(), AUTH_PATH, + maxAge(tokens.refreshExpiresAt(), tokens.autoLogin())); + } + + public void clear(HttpServletResponse response) { + addCookie(response, properties.accessCookieName(), "", "/", 0); + addCookie(response, properties.refreshCookieName(), "", AUTH_PATH, 0); + } + + private String getCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + String value = null; + boolean found = false; + for (Cookie cookie : cookies) { + if (!name.equals(cookie.getName())) { + continue; + } + if (found) { + throw AuthenticationException.withDetail("중복된 웹 인증 쿠키입니다."); + } + found = true; + value = cookie.getValue(); + } + return value; + } + + private long maxAge(Instant expiresAt, boolean autoLogin) { + return autoLogin ? Math.max(0, Duration.between(Instant.now(), expiresAt).toSeconds()) : -1; + } + + private void addCookie(HttpServletResponse response, String name, String value, String path, long maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(properties.secure()) + .sameSite(properties.sameSite()) + .path(path) + .maxAge(maxAge) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java b/src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java new file mode 100644 index 0000000000..b5d383839c --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java @@ -0,0 +1,58 @@ +package in.koreatech.koin.global.auth; + +import java.net.URI; +import java.util.Set; + +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.config.CorsProperties; +import in.koreatech.koin.global.exception.CustomException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class WebAuthRequestValidator { + + public static final String CSRF_HEADER = "X-CSRF-Token"; + private static final Set SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS"); + + private final CorsProperties corsProperties; + + public void validate(HttpServletRequest request, WebAuthSession session) { + if (!SAFE_METHODS.contains(request.getMethod())) { + requireTrustedOrigin(request); + session.requireCsrfToken(request.getHeader(CSRF_HEADER)); + } + } + + public void requireTrustedOrigin(HttpServletRequest request) { + String origin = request.getHeader(HttpHeaders.ORIGIN); + if (origin == null) { + origin = originFromReferer(request.getHeader(HttpHeaders.REFERER)); + } + if (origin == null || "null".equals(origin) || "*".equals(origin) + || corsProperties.allowedOrigins() == null || !corsProperties.allowedOrigins().contains(origin)) { + throw CustomException.of(ApiResponseCode.FORBIDDEN_WEB_ORIGIN); + } + } + + private String originFromReferer(String referer) { + if (referer == null) { + return null; + } + try { + URI uri = URI.create(referer); + if (uri.getHost() == null || uri.getUserInfo() != null + || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()))) { + return null; + } + return uri.getScheme() + "://" + uri.getRawAuthority(); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/WebCookieAuthenticationInterceptor.java b/src/main/java/in/koreatech/koin/global/auth/WebCookieAuthenticationInterceptor.java new file mode 100644 index 0000000000..f2b222a274 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/WebCookieAuthenticationInterceptor.java @@ -0,0 +1,57 @@ +package in.koreatech.koin.global.auth; + +import java.util.Arrays; + +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import in.koreatech.koin.domain.user.web.controller.WebAuthController; +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.domain.user.web.service.WebAuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class WebCookieAuthenticationInterceptor implements HandlerInterceptor { + + private final WebAuthService webAuthService; + private final WebAuthCookieManager cookieManager; + private final WebAuthRequestValidator requestValidator; + private final AuthContext authContext; + private final UserIdContext userIdContext; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod) || "OPTIONS".equals(request.getMethod())) { + return true; + } + if (WebAuthController.class.isAssignableFrom(handlerMethod.getBeanType())) { + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + requestValidator.requireTrustedOrigin(request); + return true; + } + // 명시적인 Authorization 헤더가 있으면 기존 인증만 사용하고 쿠키로 대체하지 않는다. + if (request.getHeader(HttpHeaders.AUTHORIZATION) != null) { + return true; + } + boolean usesAuthentication = Arrays.stream(handlerMethod.getMethodParameters()) + .anyMatch(parameter -> parameter.hasParameterAnnotation(Auth.class) + || parameter.hasParameterAnnotation(UserId.class)); + if (!usesAuthentication) { + return true; + } + String accessToken = cookieManager.getAccessToken(request); + if (accessToken != null) { + response.setHeader(HttpHeaders.CACHE_CONTROL, "private, no-store"); + WebAuthSession session = webAuthService.authenticate(accessToken); + requestValidator.validate(request, session); + authContext.setUserId(session.userId()); + userIdContext.setUserId(session.userId()); + } + return true; + } +} diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index ee1ca4b361..b043a310eb 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -107,6 +107,8 @@ public enum ApiResponseCode { * 403 Forbidden (인가 필요) */ FORBIDDEN_USER_TYPE(HttpStatus.FORBIDDEN, "인가되지 않은 유저 타입입니다."), + INVALID_CSRF_TOKEN(HttpStatus.FORBIDDEN, "올바르지 않은 CSRF 토큰입니다."), + FORBIDDEN_WEB_ORIGIN(HttpStatus.FORBIDDEN, "허용되지 않은 웹 요청입니다."), FORBIDDEN_OWNER(HttpStatus.FORBIDDEN, "관리자 인증 대기중입니다."), FORBIDDEN_STUDENT(HttpStatus.FORBIDDEN, "아우누리에서 인증메일을 확인해주세요."), FORBIDDEN_ADMIN(HttpStatus.FORBIDDEN, "PL 인증 대기중입니다."), @@ -163,6 +165,7 @@ public enum ApiResponseCode { * 409 CONFLICT (중복 혹은 충돌) */ DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "이미 존재하는 로그인 아이디입니다."), + WEB_AUTH_SESSION_CONFLICT(HttpStatus.CONFLICT, "다른 요청에서 로그인 정보가 변경되었습니다. 다시 시도해주세요."), DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."), DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), DUPLICATE_PHONE_NUMBER(HttpStatus.CONFLICT, "이미 존재하는 전화번호입니다."), diff --git a/src/main/java/in/koreatech/koin/global/config/WebAuthProperties.java b/src/main/java/in/koreatech/koin/global/config/WebAuthProperties.java new file mode 100644 index 0000000000..db90053bfe --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/WebAuthProperties.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.global.config; + +import java.time.Duration; +import java.util.Set; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "auth.web") +public record WebAuthProperties( + @DefaultValue("15m") Duration accessTokenTtl, + @DefaultValue("90d") Duration refreshTokenTtl, + @DefaultValue("true") boolean secure, + @DefaultValue("Lax") String sameSite +) { + + public WebAuthProperties { + if (accessTokenTtl.toSeconds() < 1 || refreshTokenTtl.compareTo(accessTokenTtl) < 0) { + throw new IllegalArgumentException("웹 토큰 만료 시간은 양수이며 refresh가 access 이상이어야 합니다."); + } + if (!Set.of("Lax", "Strict", "None").contains(sameSite) || ("None".equals(sameSite) && !secure)) { + throw new IllegalArgumentException("올바른 SameSite 설정이 필요하며 None은 Secure 쿠키만 허용합니다."); + } + } + + public String accessCookieName() { + return secure ? "__Host-koin-web-access" : "koin-web-access"; + } + + public String refreshCookieName() { + return secure ? "__Secure-koin-web-refresh" : "koin-web-refresh"; + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/WebConfig.java b/src/main/java/in/koreatech/koin/global/config/WebConfig.java index 903aff76de..a35b7eec9b 100644 --- a/src/main/java/in/koreatech/koin/global/config/WebConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/WebConfig.java @@ -19,6 +19,7 @@ import in.koreatech.koin.global.auth.AuthArgumentResolver; import in.koreatech.koin.global.auth.ExtractAuthenticationInterceptor; import in.koreatech.koin.global.auth.UserIdArgumentResolver; +import in.koreatech.koin.global.auth.WebCookieAuthenticationInterceptor; import in.koreatech.koin.domain.notification.controller.NotificationSubscribeTypeConverter; import in.koreatech.koin.infrastructure.s3.convertor.ImageUploadDomainEnumConverter; import in.koreatech.koin.global.host.ServerURLArgumentResolver; @@ -36,6 +37,7 @@ public class WebConfig implements WebMvcConfigurer { private final ExtractAuthenticationInterceptor extractAuthenticationInterceptor; + private final WebCookieAuthenticationInterceptor webCookieAuthenticationInterceptor; private final IpAddressArgumentResolver ipAddressArgumentResolver; private final ServerURLInterceptor serverURLInterceptor; @@ -51,13 +53,19 @@ public class WebConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(extractAuthenticationInterceptor) .addPathPatterns("/**") + .excludePathPatterns("/v2/web/auth/**") .order(0); - registry.addInterceptor(ipAddressInterceptor) + registry.addInterceptor(webCookieAuthenticationInterceptor) .addPathPatterns("/**") + .excludePathPatterns("/v2/users/login", "/user/login", "/user/refresh", "/user/logout", + "/student/login", "/owner/login", "/coop/login", "/admin/user/login") .order(1); - registry.addInterceptor(serverURLInterceptor) + registry.addInterceptor(ipAddressInterceptor) .addPathPatterns("/**") .order(2); + registry.addInterceptor(serverURLInterceptor) + .addPathPatterns("/**") + .order(3); } @Override diff --git a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java index 52ce333ee9..649727e179 100644 --- a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java @@ -4,8 +4,10 @@ import java.util.Enumeration; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; import org.apache.catalina.connector.ClientAbortException; @@ -47,6 +49,8 @@ @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private static final Set SENSITIVE_HEADERS = Set.of("authorization", "cookie", "x-csrf-token"); + // 커스텀 예외 @ExceptionHandler(CustomException.class) @@ -273,7 +277,8 @@ private Map getHeaders(HttpServletRequest request) { Enumeration headerArray = request.getHeaderNames(); while (headerArray.hasMoreElements()) { String headerName = headerArray.nextElement(); - headerMap.put(headerName, request.getHeader(headerName)); + headerMap.put(headerName, SENSITIVE_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)) + ? "[REDACTED]" : request.getHeader(headerName)); } return headerMap; } @@ -287,6 +292,9 @@ private String getQueryString(HttpServletRequest httpRequest) { } private String getRequestBody(HttpServletRequest request) { + if (request.getRequestURI().contains("/v2/web/auth/")) { + return "[REDACTED]"; + } var wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class); if (wrapper == null) { return " - "; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 262659040d..b89bb8a07a 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,3 +1,7 @@ +auth: + web: + secure: false + jwt: secret-key: local-jwt-secret-key-example-32chars!! access-token: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d17568159a..c6857b58a4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,10 @@ +auth: + web: + access-token-ttl: ${WEB_AUTH_ACCESS_TOKEN_TTL:15m} + refresh-token-ttl: ${WEB_AUTH_REFRESH_TOKEN_TTL:90d} + secure: ${WEB_AUTH_COOKIE_SECURE:true} + same-site: ${WEB_AUTH_COOKIE_SAME_SITE:Lax} + jwt: secret-key: ${JWT_SECRET_KEY} access-token: diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java new file mode 100644 index 0000000000..9762741be3 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java @@ -0,0 +1,429 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.RefreshTokenRedisRepository; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.web.model.WebRefreshToken; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.config.WebAuthProperties; +import jakarta.servlet.http.Cookie; + +class WebAuthApiTest extends AcceptanceTest { + + private static final String ORIGIN = "http://localhost:3000"; + private static final String AUTH_PATH = "/v2/web/auth"; + private static final String MOBILE_USER_AGENT = "koin/1.0 (Android 14) OKHttp/4.12.0"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenRedisRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private WebAuthProperties properties; + + private User user; + + @BeforeEach + void setUp() { + user = userRepository.save(User.builder() + .loginId("web-test") + .loginPw(passwordEncoder.encode("1234")) + .name("웹인증테스트") + .nickname("웹인증") + .phoneNumber("01055556666") + .email("web-auth@koreatech.ac.kr") + .userType(UserType.GENERAL) + .isAuthed(true) + .isDeleted(false) + .build()); + } + + @Test + void 웹은_access와_refresh를_HttpOnly_쿠키로만_발급한다() throws Exception { + WebLogin login = login(true); + + assertThat(login.access().isHttpOnly()).isTrue(); + assertThat(login.refresh().isHttpOnly()).isTrue(); + assertThat(login.access().getSecure()).isTrue(); + assertThat(login.refresh().getSecure()).isTrue(); + assertThat(login.access().getPath()).isEqualTo("/"); + assertThat(login.refresh().getPath()).isEqualTo(AUTH_PATH); + assertThat(login.access().getDomain()).isNull(); + assertThat(login.refresh().getDomain()).isNull(); + assertThat(login.access().getMaxAge()).isBetween(1, 900); + assertThat(login.refresh().getMaxAge()).isBetween(7_775_900, 7_776_000); + assertThat(login.result().getResponse().getHeaders(HttpHeaders.SET_COOKIE)) + .hasSize(2).allSatisfy(value -> assertThat(value).contains("SameSite=Lax")); + assertThat(objectMapper.readTree(login.result().getResponse().getContentAsString()).size()).isEqualTo(2); + } + + @Test + void 자동로그인을_선택하지_않으면_브라우저_세션_쿠키를_발급한다() throws Exception { + WebLogin login = login(false); + + assertThat(login.access().getMaxAge()).isEqualTo(-1); + assertThat(login.refresh().getMaxAge()).isEqualTo(-1); + } + + @Test + void 웹_로그인은_UserAgent_없이도_가능하다() throws Exception { + mockMvc.perform(post(AUTH_PATH + "/login").header("Origin", ORIGIN) + .contentType(MediaType.APPLICATION_JSON).content(loginBody(false))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.user_type").value("GENERAL")); + } + + @Test + void 비인증_API는_남아있는_쿠키_때문에_인증이나_csrf를_요구하지_않는다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(post("/v2/users/register").cookie(expiredAccess(login)) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()); + mockMvc.perform(post("/v2/users/register").cookie(login.access()) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + void 기존_일반_API를_쿠키로_인증하고_권한_검사를_재사용한다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get("/v2/users/me").cookie(login.access())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.login_id").value("web-test")); + mockMvc.perform(get("/user/student/me").cookie(login.access())) + .andExpect(status().isForbidden()); + } + + @Test + void 모바일웹_로그인과_로그아웃이_앱의_로그인_정보를_덮어쓰지_않는다() throws Exception { + JsonNode nativeLogin = nativeLogin(); + String nativeRefresh = nativeLogin.get("refresh_token").asText(); + WebLogin webLogin = login(true); + + assertThat(refreshTokenRepository.getById(user.getId() + ":Mobile").getToken()).isEqualTo(nativeRefresh); + logout(webLogin).andExpect(status().isNoContent()); + + mockMvc.perform(post("/user/refresh") + .header("User-Agent", MOBILE_USER_AGENT) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("refresh_token", nativeRefresh)))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").value(nativeRefresh)) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + } + + @Test + void 앱_로그아웃은_웹_세션을_폐기하지_않는다() throws Exception { + JsonNode nativeLogin = nativeLogin(); + WebLogin webLogin = login(true); + + mockMvc.perform(post("/user/logout") + .header("Authorization", "Bearer " + nativeLogin.get("token").asText()) + .header("User-Agent", MOBILE_USER_AGENT)) + .andExpect(status().isOk()); + mockMvc.perform(get("/user/auth").cookie(webLogin.access())).andExpect(status().isOk()); + } + + @Test + void 한_브라우저의_로그아웃이_다른_웹_세션을_폐기하지_않는다() throws Exception { + WebLogin first = login(true); + WebLogin second = login(true); + + logout(first).andExpect(status().isNoContent()); + mockMvc.perform(get("/user/auth").cookie(first.access())).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/user/auth").cookie(second.access())).andExpect(status().isOk()); + } + + @Test + void 만료된_access_쿠키가_있어도_csrf_조회와_재발급이_가능하다() throws Exception { + WebLogin login = login(true); + Cookie expired = expiredAccess(login); + + mockMvc.perform(get(AUTH_PATH + "/csrf").header("Origin", ORIGIN).cookie(expired, login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.csrf_token").value(login.csrfToken())) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")); + + mockMvc.perform(post(AUTH_PATH + "/refresh") + .header("Origin", ORIGIN) + .header("Authorization", "Bearer expired-migration-header") + .header("X-CSRF-Token", login.csrfToken()) + .cookie(expired, login.refresh())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.token").doesNotExist()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()); + } + + @Test + void 재발급한_refresh는_교체되고_이전_refresh는_다시_사용할_수_없다() throws Exception { + WebLogin login = login(true); + MvcResult refreshed = refresh(login).andExpect(status().isCreated()).andReturn(); + Cookie nextRefresh = refreshed.getResponse().getCookie(properties.refreshCookieName()); + + assertThat(nextRefresh.getValue()).isNotEqualTo(login.refresh().getValue()); + assertThat(nextRefresh.getMaxAge()).isLessThanOrEqualTo(login.refresh().getMaxAge()); + refresh(login).andExpect(status().isUnauthorized()).andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + mockMvc.perform(post(AUTH_PATH + "/refresh").header("Origin", ORIGIN) + .header("X-CSRF-Token", login.csrfToken()).cookie(nextRefresh)) + .andExpect(status().isCreated()); + } + + @Test + void 상태_변경에는_origin과_세션에_맞는_csrf가_필요하다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(put("/users/password").header("Origin", ORIGIN).cookie(login.access()) + .contentType(MediaType.APPLICATION_JSON).content("{\"new_password\":\"changed-password\"}")) + .andExpect(status().isForbidden()); + assertThat(passwordEncoder.matches("1234", user.getLoginPw())).isTrue(); + + mockMvc.perform(put("/users/password").header("Origin", ORIGIN).cookie(login.access()) + .header("X-CSRF-Token", login.csrfToken()) + .contentType(MediaType.APPLICATION_JSON).content("{\"new_password\":\"changedpassword\"}")) + .andExpect(status().isOk()); + assertThat(passwordEncoder.matches("changedpassword", user.getLoginPw())).isTrue(); + refresh(login).andExpect(status().isUnauthorized()); + } + + @Test + void 다른_웹_세션의_csrf_토큰은_사용할_수_없다() throws Exception { + WebLogin first = login(true); + WebLogin second = login(true); + + mockMvc.perform(post(AUTH_PATH + "/refresh").header("Origin", ORIGIN) + .header("X-CSRF-Token", second.csrfToken()).cookie(first.refresh())) + .andExpect(status().isForbidden()).andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + refresh(first).andExpect(status().isCreated()); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "https://evil.example", "http://localhost.evil.example:3000"}) + void 허용되지_않은_출처의_로그인은_거부한다(String origin) throws Exception { + mockMvc.perform(post(AUTH_PATH + "/login").header("Origin", origin) + .contentType(MediaType.APPLICATION_JSON).content(loginBody(true))) + .andExpect(status().isForbidden()).andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + } + + @Test + void 출처가_없는_로그인은_거부한다() throws Exception { + mockMvc.perform(post(AUTH_PATH + "/login") + .contentType(MediaType.APPLICATION_JSON).content(loginBody(true))) + .andExpect(status().isForbidden()); + } + + @Test + void 로그인은_브라우저의_단순_form_요청을_받지_않는다() throws Exception { + mockMvc.perform(post(AUTH_PATH + "/login").header("Origin", ORIGIN) + .contentType(MediaType.APPLICATION_FORM_URLENCODED).content("login_id=web-test&login_pw=1234")) + .andExpect(status().isUnsupportedMediaType()); + } + + @Test + void 잘못된_비밀번호로_쿠키를_발급하지_않는다() throws Exception { + mockMvc.perform(post(AUTH_PATH + "/login").header("Origin", ORIGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"login_id\":\"web-test\",\"login_pw\":\"wrong\"}")) + .andExpect(status().isBadRequest()).andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + } + + @Test + void 로그아웃은_만료된_access와_무관하며_쿠키와_웹_세션을_폐기한다() throws Exception { + WebLogin login = login(true); + + MvcResult result = mockMvc.perform(post(AUTH_PATH + "/logout").header("Origin", ORIGIN) + .header("X-CSRF-Token", login.csrfToken()).cookie(expiredAccess(login), login.refresh())) + .andExpect(status().isNoContent()).andReturn(); + + Cookie access = result.getResponse().getCookie(properties.accessCookieName()); + Cookie refresh = result.getResponse().getCookie(properties.refreshCookieName()); + assertThat(access.getMaxAge()).isZero(); + assertThat(refresh.getMaxAge()).isZero(); + assertThat(access.getPath()).isEqualTo(login.access().getPath()); + assertThat(refresh.getPath()).isEqualTo(login.refresh().getPath()); + assertThat(access.isHttpOnly()).isTrue(); + assertThat(refresh.getSecure()).isTrue(); + mockMvc.perform(get("/user/auth").cookie(login.access())).andExpect(status().isUnauthorized()); + refresh(login).andExpect(status().isUnauthorized()); + logout(login).andExpect(status().isNoContent()); + } + + @Test + void csrf가_없는_로그아웃은_쿠키나_세션을_삭제하지_않는다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(post(AUTH_PATH + "/logout").header("Origin", ORIGIN).cookie(login.refresh())) + .andExpect(status().isForbidden()).andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + mockMvc.perform(get("/user/auth").cookie(login.access())).andExpect(status().isOk()); + } + + @Test + void 웹_쿠키로_기존_앱_로그아웃을_호출할_수_없다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(post("/user/logout").header("User-Agent", MOBILE_USER_AGENT).cookie(login.access())) + .andExpect(status().isUnauthorized()); + } + + @Test + void 웹_토큰을_앱_인증이나_앱_재발급으로_바꿔_사용할_수_없다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get("/user/auth").header("Authorization", "Bearer " + login.access().getValue())) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post("/user/refresh").header("User-Agent", MOBILE_USER_AGENT) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("refresh_token", login.refresh().getValue())))) + .andExpect(status().isBadRequest()); + } + + @Test + void 앱_토큰을_웹_쿠키로_사용할_수_없다() throws Exception { + JsonNode nativeLogin = nativeLogin(); + + mockMvc.perform(get("/user/auth") + .cookie(new Cookie(properties.accessCookieName(), nativeLogin.get("token").asText()))) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post(AUTH_PATH + "/refresh").header("Origin", ORIGIN) + .header("X-CSRF-Token", "any") + .cookie(new Cookie(properties.refreshCookieName(), nativeLogin.get("refresh_token").asText()))) + .andExpect(status().isUnauthorized()); + } + + @Test + void 웹_재발급은_본문으로_전달한_토큰을_사용하지_않는다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(post(AUTH_PATH + "/refresh").header("Origin", ORIGIN) + .header("X-CSRF-Token", login.csrfToken()).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("refresh_token", login.refresh().getValue())))) + .andExpect(status().isUnauthorized()); + } + + @Test + void 명시적_헤더가_있으면_쿠키_사용자로_대체하지_않는다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get("/user/auth").header("Authorization", "invalid").cookie(login.access())) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/user/auth").header("Authorization", "Bearer " + jwtProvider.createToken(user)) + .cookie(new Cookie(properties.accessCookieName(), "invalid-cookie"))) + .andExpect(status().isOk()); + } + + @Test + void 중복된_인증_쿠키를_임의로_선택하지_않는다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get("/user/auth").cookie(login.access(), new Cookie(properties.accessCookieName(), "another"))) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post(AUTH_PATH + "/refresh").header("Origin", ORIGIN) + .header("X-CSRF-Token", login.csrfToken()) + .cookie(login.refresh(), new Cookie(properties.refreshCookieName(), "another"))) + .andExpect(status().isUnauthorized()); + } + + @Test + void 허용된_웹의_쿠키와_csrf_헤더에_대한_CORS_preflight를_허용한다() throws Exception { + mockMvc.perform(options(AUTH_PATH + "/refresh").header("Origin", ORIGIN) + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "X-CSRF-Token")) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", ORIGIN)) + .andExpect(header().string("Access-Control-Allow-Credentials", "true")); + } + + private WebLogin login(boolean autoLogin) throws Exception { + MvcResult result = mockMvc.perform(post(AUTH_PATH + "/login").header("Origin", ORIGIN) + .header("User-Agent", MOBILE_USER_AGENT) + .contentType(MediaType.APPLICATION_JSON).content(loginBody(autoLogin))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andExpect(jsonPath("$.csrf_token").isNotEmpty()) + .andExpect(jsonPath("$.token").doesNotExist()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andReturn(); + return new WebLogin(result.getResponse().getCookie(properties.accessCookieName()), + result.getResponse().getCookie(properties.refreshCookieName()), + objectMapper.readTree(result.getResponse().getContentAsString()).get("csrf_token").asText(), result); + } + + private JsonNode nativeLogin() throws Exception { + MvcResult result = mockMvc.perform(post("/v2/users/login").header("User-Agent", MOBILE_USER_AGENT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"login_id\":\"web-test\",\"login_pw\":\"1234\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").isNotEmpty()) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()); + } + + private String loginBody(boolean autoLogin) { + return """ + {"login_id":"web-test","login_pw":"1234","auto_login":%s} + """.formatted(autoLogin); + } + + private ResultActions refresh(WebLogin login) throws Exception { + return mockMvc.perform(post(AUTH_PATH + "/refresh").header("Origin", ORIGIN) + .header("X-CSRF-Token", login.csrfToken()).cookie(login.refresh())); + } + + private ResultActions logout(WebLogin login) throws Exception { + return mockMvc.perform(post(AUTH_PATH + "/logout").header("Origin", ORIGIN) + .header("X-CSRF-Token", login.csrfToken()).cookie(login.refresh())); + } + + private Cookie expiredAccess(WebLogin login) { + String sessionId = WebRefreshToken.parse(login.refresh().getValue()).sessionId(); + return new Cookie(properties.accessCookieName(), jwtProvider.createWebToken(user, sessionId, Instant.now().minusSeconds(1))); + } + + private record WebLogin(Cookie access, Cookie refresh, String csrfToken, MvcResult result) { + + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthSessionRedisRepositoryTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthSessionRedisRepositoryTest.java new file mode 100644 index 0000000000..b61b4430d1 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthSessionRedisRepositoryTest.java @@ -0,0 +1,140 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.domain.user.web.model.WebRefreshToken; +import in.koreatech.koin.domain.user.web.repository.WebAuthSessionRedisRepository; + +@Testcontainers +class WebAuthSessionRedisRepositoryTest { + + @Container + private static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse("redis:7.0.9")) + .withExposedPorts(6379); + + private static LettuceConnectionFactory connectionFactory; + private static StringRedisTemplate redisTemplate; + private static WebAuthSessionRedisRepository repository; + + @BeforeAll + static void setUp() { + connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); + connectionFactory.afterPropertiesSet(); + redisTemplate = new StringRedisTemplate(connectionFactory); + ObjectMapper mapper = new ObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + repository = new WebAuthSessionRedisRepository(redisTemplate, mapper); + } + + @AfterAll + static void tearDown() { + if (connectionFactory != null) { + connectionFactory.destroy(); + } + } + + @Test + void 동시에_같은_refresh를_갱신하면_하나만_성공한다() throws Exception { + WebRefreshToken token = WebRefreshToken.create(); + WebAuthSession session = session(token); + repository.save(session); + Long previousTtl = redisTemplate.getExpire("webAuthSession:" + session.id()); + WebAuthSession stored = repository.findById(session.id()).orElseThrow(); + ExecutorService executor = Executors.newFixedThreadPool(8); + CountDownLatch ready = new CountDownLatch(8); + CountDownLatch start = new CountDownLatch(1); + List> results = new ArrayList<>(); + try { + for (int i = 0; i < 8; i++) { + WebAuthSession next = stored.rotate(token.rotate()); + results.add(executor.submit(() -> { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("동시 갱신 테스트 대기 시간이 초과되었습니다."); + } + return repository.rotate(stored, next); + })); + } + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + int succeeded = 0; + for (Future result : results) { + if (result.get(10, TimeUnit.SECONDS)) { + succeeded++; + } + } + + assertThat(succeeded).isEqualTo(1); + WebAuthSession refreshed = repository.findById(session.id()).orElseThrow(); + assertThat(refreshed.refreshTokenHash()).isNotEqualTo(session.refreshTokenHash()); + assertThat(refreshed.expiresAt()).isEqualTo(session.expiresAt()); + assertThat(redisTemplate.getExpire("webAuthSession:" + session.id())).isPositive().isLessThanOrEqualTo(previousTtl); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + @Test + void 로그아웃으로_삭제한_세션을_진행중인_재발급이_복구하지_않는다() { + WebRefreshToken token = WebRefreshToken.create(); + WebAuthSession session = session(token); + repository.save(session); + WebAuthSession snapshot = repository.findById(session.id()).orElseThrow(); + + assertThat(repository.delete(snapshot)).isTrue(); + assertThat(repository.rotate(snapshot, snapshot.rotate(token.rotate()))).isFalse(); + assertThat(repository.findById(session.id())).isEmpty(); + } + + @Test + void 이전_스냅샷의_로그아웃이_새로_갱신된_정보를_임의로_삭제하지_않는다() { + WebRefreshToken token = WebRefreshToken.create(); + WebAuthSession session = session(token); + repository.save(session); + WebAuthSession next = session.rotate(token.rotate()); + + assertThat(repository.rotate(session, next)).isTrue(); + assertThat(repository.delete(session)).isFalse(); + assertThat(repository.findById(session.id())).contains(next); + } + + @Test + void 서버에서_만료된_세션은_다시_갱신할_수_없다() { + WebRefreshToken token = WebRefreshToken.create(); + WebAuthSession session = session(token); + repository.save(session); + redisTemplate.expireAt("webAuthSession:" + session.id(), Instant.now().minusSeconds(1)); + + assertThat(repository.findById(session.id())).isEmpty(); + assertThat(repository.rotate(session, session.rotate(token.rotate()))).isFalse(); + } + + private WebAuthSession session(WebRefreshToken token) { + return new WebAuthSession(token.sessionId(), 1, token.hash(), WebRefreshToken.createSecret(), + "credential-hash", Instant.now().plusSeconds(120), true); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthServiceTest.java new file mode 100644 index 0000000000..e69aaface8 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthServiceTest.java @@ -0,0 +1,155 @@ +package in.koreatech.koin.unit.domain.user.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.service.UserService; +import in.koreatech.koin.domain.user.web.dto.WebLoginRequest; +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.domain.user.web.model.WebRefreshToken; +import in.koreatech.koin.domain.user.web.repository.WebAuthSessionRedisRepository; +import in.koreatech.koin.domain.user.web.service.WebAuthService; +import in.koreatech.koin.domain.user.web.service.WebAuthTokens; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.config.WebAuthProperties; +import in.koreatech.koin.global.exception.CustomException; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class WebAuthServiceTest { + + @Mock + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private WebAuthSessionRedisRepository sessionRepository; + + private final User user = UserFixture.id_설정_코인_유저(1); + private final JwtProvider jwtProvider = new JwtProvider("web-auth-unit-test-key-32-characters", 600_000L); + private WebAuthService service; + private WebRefreshToken token; + private WebAuthSession session; + + @BeforeEach + void setUp() { + service = new WebAuthService(userService, userRepository, sessionRepository, jwtProvider, + new WebAuthProperties(Duration.ofMinutes(15), Duration.ofDays(90), true, "Lax")); + token = WebRefreshToken.create(); + session = new WebAuthSession(token.sessionId(), 1, token.hash(), "csrf-token", + WebRefreshToken.hash(user.getLoginPw()), Instant.now().plusSeconds(3600), true); + } + + @Test + void 로그인마다_독립된_웹_세션을_발급하고_refresh는_해시만_저장한다() { + WebLoginRequest request = new WebLoginRequest("test_id2", "test_pw2", true); + when(userService.authenticate(request.toLoginRequest())).thenReturn(user); + + WebAuthTokens first = service.login(request); + WebAuthTokens second = service.login(request); + + assertThat(WebRefreshToken.parse(first.refreshToken()).sessionId()) + .isNotEqualTo(WebRefreshToken.parse(second.refreshToken()).sessionId()); + ArgumentCaptor captor = ArgumentCaptor.forClass(WebAuthSession.class); + verify(sessionRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues().get(0).refreshTokenHash()).isEqualTo(WebRefreshToken.parse(first.refreshToken()).hash()); + assertThat(captor.getAllValues().get(0).refreshTokenHash()).doesNotContain(first.refreshToken()); + } + + @Test + void 로그인_실패시_웹_세션을_저장하지_않는다() { + when(userService.authenticate(any())).thenThrow(CustomException.of(ApiResponseCode.NOT_MATCHED_PASSWORD)); + + assertThatThrownBy(() -> service.login(new WebLoginRequest("id", "wrong", false))) + .isInstanceOf(CustomException.class); + verify(sessionRepository, never()).save(any()); + } + + @Test + void 재발급은_토큰만_교체하고_세션과_자동로그인_기한을_유지한다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(1)).thenReturn(Optional.of(user)); + when(sessionRepository.rotate(any(), any())).thenReturn(true); + + WebAuthTokens refreshed = service.refresh(token.value(), session.csrfToken()); + + assertThat(refreshed.refreshToken()).isNotEqualTo(token.value()); + assertThat(WebRefreshToken.parse(refreshed.refreshToken()).sessionId()).isEqualTo(session.id()); + assertThat(refreshed.refreshExpiresAt()).isEqualTo(session.expiresAt()); + assertThat(refreshed.response().csrfToken()).isEqualTo(session.csrfToken()); + assertThat(refreshed.autoLogin()).isTrue(); + } + + @Test + void 다른_요청이_먼저_갱신한_세션을_덮어쓰지_않는다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(1)).thenReturn(Optional.of(user)); + when(sessionRepository.rotate(any(), any())).thenReturn(false); + + assertThatThrownBy(() -> service.refresh(token.value(), session.csrfToken())) + .isInstanceOf(CustomException.class).hasMessage(ApiResponseCode.WEB_AUTH_SESSION_CONFLICT.getMessage()); + verify(sessionRepository, never()).delete(any()); + } + + @Test + void 비밀번호가_변경되면_기존_웹_세션으로_재발급하지_않는다() { + WebAuthSession previousPasswordSession = new WebAuthSession(session.id(), 1, token.hash(), session.csrfToken(), + "previous-password-hash", session.expiresAt(), true); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(previousPasswordSession)); + when(userRepository.findById(1)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.refresh(token.value(), session.csrfToken())).isInstanceOf(AuthenticationException.class); + verify(sessionRepository).delete(previousPasswordSession); + verify(sessionRepository, never()).rotate(any(), any()); + } + + @Test + void csrf_토큰이_틀리면_세션을_변경하지_않는다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.refresh(token.value(), "wrong")) + .isInstanceOf(CustomException.class).hasMessage(ApiResponseCode.INVALID_CSRF_TOKEN.getMessage()); + verify(sessionRepository, never()).rotate(any(), any()); + } + + @Test + void 만료된_세션은_access_토큰이_유효해도_거부한다() { + WebAuthSession expired = new WebAuthSession(session.id(), 1, token.hash(), session.csrfToken(), + session.credentialHash(), Instant.now().minusSeconds(1), true); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(expired)); + String accessToken = jwtProvider.createWebToken(user, session.id(), Instant.now().plusSeconds(600)); + + assertThatThrownBy(() -> service.authenticate(accessToken)).isInstanceOf(AuthenticationException.class); + } + + @Test + void 토큰의_사용자와_세션의_사용자가_다르면_거부한다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + String accessToken = jwtProvider.createWebToken( + UserFixture.id_설정_코인_유저(2), session.id(), Instant.now().plusSeconds(60)); + + assertThatThrownBy(() -> service.authenticate(accessToken)).isInstanceOf(AuthenticationException.class); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthCookieManagerTest.java b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthCookieManagerTest.java new file mode 100644 index 0000000000..985f33d4dd --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthCookieManagerTest.java @@ -0,0 +1,50 @@ +package in.koreatech.koin.unit.global.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletResponse; + +import in.koreatech.koin.domain.user.web.dto.WebAuthResponse; +import in.koreatech.koin.domain.user.web.service.WebAuthTokens; +import in.koreatech.koin.global.auth.WebAuthCookieManager; +import in.koreatech.koin.global.config.WebAuthProperties; + +class WebAuthCookieManagerTest { + + @Test + void 로컬_HTTP에서는_보안_접두어_없는_HttpOnly_쿠키를_사용한다() { + WebAuthProperties properties = new WebAuthProperties(Duration.ofMinutes(15), Duration.ofDays(90), false, "Lax"); + WebAuthCookieManager manager = new WebAuthCookieManager(properties); + MockHttpServletResponse response = new MockHttpServletResponse(); + + manager.write(response, new WebAuthTokens("access", "refresh", Instant.now().plusSeconds(900), + Instant.now().plusSeconds(3600), true, new WebAuthResponse("GENERAL", "csrf"))); + + assertThat(response.getCookie("koin-web-access").isHttpOnly()).isTrue(); + assertThat(response.getCookie("koin-web-access").getSecure()).isFalse(); + assertThat(response.getCookie("koin-web-refresh").getPath()).isEqualTo("/v2/web/auth"); + } + + @Test + void SameSite_None은_Secure_없이는_설정할_수_없다() { + assertThatThrownBy(() -> new WebAuthProperties(Duration.ofMinutes(15), Duration.ofDays(90), false, "None")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void refresh_기간보다_긴_access_기간은_설정할_수_없다() { + assertThatThrownBy(() -> new WebAuthProperties(Duration.ofDays(91), Duration.ofDays(90), true, "Lax")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 잘못된_SameSite_설정을_거부한다() { + assertThatThrownBy(() -> new WebAuthProperties(Duration.ofMinutes(15), Duration.ofDays(90), true, "invalid")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java new file mode 100644 index 0000000000..19500f97ed --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java @@ -0,0 +1,50 @@ +package in.koreatech.koin.unit.global.auth; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.stream.Collectors.joining; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.util.ContentCachingRequestWrapper; + +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 in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import in.koreatech.koin.global.exception.GlobalExceptionHandler; + +class WebAuthLoggingTest { + + @Test + void 인증_실패_로그에_쿠키와_csrf_토큰과_로그인_본문을_노출하지_않는다() { + Logger logger = (Logger)LoggerFactory.getLogger(GlobalExceptionHandler.class); + Level previousLevel = logger.getLevel(); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.DEBUG); + try { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/v2/web/auth/login"); + request.addHeader("AUTHORIZATION", "Bearer access-secret"); + request.addHeader("Cookie", "koin-web-refresh=refresh-secret"); + request.addHeader("X-CSRF-Token", "csrf-secret"); + request.setContent("{\"login_pw\":\"password-secret\"}".getBytes(UTF_8)); + + new GlobalExceptionHandler().handleCustomException(new ContentCachingRequestWrapper(request), + CustomException.of(ApiResponseCode.FORBIDDEN_WEB_ORIGIN)); + + String messages = appender.list.stream().map(ILoggingEvent::getFormattedMessage) + .collect(joining("\n")); + assertThat(messages).contains("[REDACTED]") + .doesNotContain("access-secret", "refresh-secret", "csrf-secret", "password-secret"); + } finally { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + appender.stop(); + } + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java new file mode 100644 index 0000000000..e71d5335cf --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java @@ -0,0 +1,96 @@ +package in.koreatech.koin.unit.global.auth; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; + +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.global.auth.WebAuthRequestValidator; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.config.CorsProperties; +import in.koreatech.koin.global.exception.CustomException; + +class WebAuthRequestValidatorTest { + + private static final String ORIGIN = "https://koreatech.in"; + private final WebAuthRequestValidator validator = new WebAuthRequestValidator(new CorsProperties(List.of(ORIGIN))); + private final WebAuthSession session = new WebAuthSession( + "session", 1, "refresh-hash", "csrf-secret", "credential-hash", Instant.now().plusSeconds(3600), true + ); + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"null", "*", "https://evil.example", "https://koreatech.in.evil.example", "https://stage.koreatech.in"}) + void 허용하지_않은_origin은_거부한다(String origin) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/users/password"); + if (origin != null) { + request.addHeader("Origin", origin); + } + request.addHeader("X-CSRF-Token", session.csrfToken()); + + assertThatThrownBy(() -> validator.validate(request, session)) + .isInstanceOf(CustomException.class) + .hasMessage(ApiResponseCode.FORBIDDEN_WEB_ORIGIN.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"POST", "PUT", "PATCH", "DELETE", "TRACE"}) + void 상태_변경_요청은_세션의_csrf_토큰도_필요하다(String method) { + MockHttpServletRequest request = new MockHttpServletRequest(method, "/users/password"); + request.addHeader("Origin", ORIGIN); + request.addHeader("X-CSRF-Token", "another-session-token"); + + assertThatThrownBy(() -> validator.validate(request, session)) + .isInstanceOf(CustomException.class) + .hasMessage(ApiResponseCode.INVALID_CSRF_TOKEN.getMessage()); + } + + @Test + void origin과_csrf_토큰이_일치하면_허용한다() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/users/password"); + request.addHeader("Origin", ORIGIN); + request.addHeader("X-CSRF-Token", session.csrfToken()); + + assertThatCode(() -> validator.validate(request, session)).doesNotThrowAnyException(); + } + + @Test + void origin이_없는_동일_출처_요청은_referer로_확인한다() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Referer", ORIGIN + "/login?next=/"); + + assertThatCode(() -> validator.requireTrustedOrigin(request)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = {"https://koreatech.in@evil.example/", "https://evil.example/koreatech.in", "not a uri"}) + void 위장한_referer는_거부한다(String referer) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Referer", referer); + + assertThatThrownBy(() -> validator.requireTrustedOrigin(request)).isInstanceOf(CustomException.class); + } + + @Test + void 잘못된_origin을_정상_referer로_우회할_수_없다() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Origin", "null"); + request.addHeader("Referer", ORIGIN + "/"); + + assertThatThrownBy(() -> validator.requireTrustedOrigin(request)).isInstanceOf(CustomException.class); + } + + @Test + void 조회는_csrf_헤더_없이_허용한다() { + assertThatCode(() -> validator.validate(new MockHttpServletRequest("GET", "/user/auth"), session)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/auth/WebJwtProviderTest.java b/src/test/java/in/koreatech/koin/unit/global/auth/WebJwtProviderTest.java new file mode 100644 index 0000000000..9848c3ebc9 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebJwtProviderTest.java @@ -0,0 +1,56 @@ +package in.koreatech.koin.unit.global.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import in.koreatech.koin.unit.fixture.UserFixture; + +class WebJwtProviderTest { + + private final JwtProvider jwtProvider = new JwtProvider("web-auth-unit-test-key-32-characters", 600_000L); + private final User user = UserFixture.id_설정_코인_유저(1); + + @Test + void 기존_앱_토큰은_기존_검증을_통과하고_웹_토큰으로는_인정하지_않는다() { + String token = jwtProvider.createToken(user); + + assertThat(jwtProvider.getUserId(token)).isEqualTo(user.getId()); + assertThatThrownBy(() -> jwtProvider.getWebTokenClaims(token)).isInstanceOf(AuthenticationException.class); + } + + @Test + void 웹_토큰을_기존_헤더_인증으로_우회할_수_없다() { + String token = jwtProvider.createWebToken(user, "web-session", Instant.now().plusSeconds(600)); + + assertThat(jwtProvider.getWebTokenClaims(token)).isEqualTo(new JwtProvider.WebTokenClaims(1, "web-session")); + assertThatThrownBy(() -> jwtProvider.getUserId(token)).isInstanceOf(AuthenticationException.class); + } + + @Test + void 만료된_웹_토큰을_거부한다() { + String token = jwtProvider.createWebToken(user, "web-session", Instant.now().minusSeconds(1)); + + assertThatThrownBy(() -> jwtProvider.getWebTokenClaims(token)).isInstanceOf(AuthenticationException.class); + } + + @Test + void 변조된_토큰을_거부하고_예외에_토큰을_노출하지_않는다() { + String token = "invalid-sensitive-token"; + + assertThatThrownBy(() -> jwtProvider.getWebTokenClaims(token)) + .isInstanceOfSatisfying(AuthenticationException.class, + exception -> assertThat(exception.getFullMessage()).doesNotContain(token)); + } + + @Test + void 임시_가입_토큰의_기존_계약을_유지한다() { + assertThat(jwtProvider.getUserId(jwtProvider.createTemporaryToken())).isZero(); + } +} From 364654f8bc11e73d5233005d0b40e701d307fc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:30:29 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=9B=B9=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=98=88=EC=99=B8=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EC=99=80=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20(#2424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/web-cookie-auth.md | 34 ++- .../user/web/service/WebAuthService.java | 6 +- .../global/auth/WebAuthRequestValidator.java | 3 +- .../exception/GlobalExceptionHandler.java | 18 +- .../acceptance/domain/WebAuthApiTest.java | 39 ++- .../domain/WebAuthCompatibilityTest.java | 177 +++++++++++++ .../web/WebAuthControllerFailureTest.java | 85 +++++++ .../domain/user/web/WebAuthFailureTest.java | 238 ++++++++++++++++++ .../unit/global/auth/WebAuthLoggingTest.java | 32 +++ .../auth/WebAuthRequestValidatorTest.java | 20 +- 10 files changed, 635 insertions(+), 17 deletions(-) create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/WebAuthCompatibilityTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthControllerFailureTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthFailureTest.java diff --git a/docs/web-cookie-auth.md b/docs/web-cookie-auth.md index 7ed44d7ff0..0c6d981435 100644 --- a/docs/web-cookie-auth.md +++ b/docs/web-cookie-auth.md @@ -28,7 +28,7 @@ access·refresh 값은 JSON 응답에 포함하지 않는다. `csrf_token`은 1. 모든 웹 API 요청에 `credentials: 'include'` 또는 Axios의 `withCredentials: true`를 설정한다. 2. 로그인 성공 시 `user_type`과 `csrf_token`만 메모리에 보관한다. 3. 기존 `document.cookie`, localStorage, Zustand의 access·refresh 저장 및 Bearer 헤더 주입을 제거한다. -4. 쿠키 인증의 상태 변경 요청에는 CSRF 헤더를 추가한다. 조회 요청은 쿠키만으로 인증한다. +4. 쿠키 인증의 상태 변경 요청에는 CSRF 헤더를 추가한다. 조회 요청은 CSRF 헤더가 필요하지 않지만 허용된 출처여야 한다. 5. access 만료 시 `/refresh`를 한 번 호출한 뒤 원래 요청을 재시도한다. 6. 로그아웃은 서버 `/logout`의 성공을 확인한 뒤 화면의 로그인 상태를 비운다. @@ -39,6 +39,15 @@ access·refresh 값은 JSON 응답에 포함하지 않는다. `csrf_token`은 일반 API의 쿠키 인증은 기존 `@Auth`·`@UserId` 파라미터가 있는 메서드에 적용한다. 회원가입·비밀번호 재설정 등 비인증 API는 남아 있는 쿠키 때문에 인증이나 CSRF 헤더를 요구하지 않는다. +쿠키로 인증하는 일반 API도 GET/HEAD를 포함해 허용된 `Origin` 또는 `Referer`가 필요하다. +기존 채팅 조회 API에는 읽음 상태 변경이 포함되어 있어, 외부 링크 이동만으로 해당 동작이 실행되지 않도록 출처를 확인한다. +브라우저의 정상 fetch/XHR은 Origin 또는 Referer를 전송하지만, `no-referrer` 정책을 적용한 동일 출처 GET이나 +API 주소 직접 입력은 403이 될 수 있다. SSR/프록시도 모든 쿠키 인증 요청에 설정된 웹 Origin을 전달해야 한다. +이 조건은 기존 앱 Bearer 요청이나 쿠키 없는 공개 조회에는 적용하지 않는다. + +`/user/check/login`처럼 토큰을 query로 받는 기존 전용 API는 쿠키 인증으로 전환하지 않는다. +웹의 로그인 상태 확인은 쿠키와 함께 `/user/auth`를 사용한다. + ## 쿠키와 만료 설정 | 항목 | 운영 기본값 | @@ -59,7 +68,9 @@ access·refresh 값은 JSON 응답에 포함하지 않는다. `csrf_token`은 웹은 로그인마다 `webAuthSession:<랜덤 세션 ID>`를 생성한다. 앱의 기존 Redis 키와 독립적이며 DB 마이그레이션은 없다. refresh 원문은 저장하지 않고 SHA-256 해시를 저장한다. 재발급 시 refresh를 교체하되 로그인 시 정한 절대 만료 시간은 연장하지 않는다. 웹 access JWT에도 세션 ID를 넣고 요청마다 Redis 세션을 확인하므로 웹 로그아웃 후 남은 access도 사용할 수 없다. -이 때문에 쿠키 인증은 Redis 가용성에 의존하며, Redis 조회 실패 시 인증을 허용하지 않는다. +웹 인증 시 DB에서도 계정의 존재 여부를 확인하므로 `@UserId` API도 탈퇴 계정의 쿠키를 받지 않는다. +이 때문에 쿠키 인증은 Redis와 사용자 DB 가용성에 의존하며, 조회 실패 시 인증을 허용하지 않는다. +Redis 전용 CSRF 조회·로그아웃에는 SQL 트랜잭션을 만들지 않고, access 인증도 Redis 대기 전에 SQL 트랜잭션을 시작하지 않는다. Redis의 원자적 비교·교체로 같은 refresh의 동시 갱신은 하나만 성공한다. 먼저 읽은 상태가 다른 요청에 의해 변경되면 409, 이미 교체된 refresh를 새로 제출하면 401이다. 실패 응답은 쿠키를 삭제하거나 덮어쓰지 않는다. @@ -94,16 +105,27 @@ Java 17과 Docker를 사용한다. 권한 검사, 만료·재발급·로그아웃, CSRF·CORS, 토큰 전달 경로 분리를 검증한다. `WebAuthSessionRedisRepositoryTest`는 실제 Redis에서 동시 갱신과 로그아웃 후 세션 복구 방지를 검증한다. 단위 테스트는 출처 검사, JWT 경로 분리, 세션 검증, 로컬 쿠키 설정과 로그 마스킹을 확인한다. +`WebAuthCompatibilityTest`는 기존 이메일 로그인, 관리자 접근 거부, 선택 인증의 개인화·익명 응답, +학생 쿠키 로그인과 학생 정보 조회를 확인한다. + +Redis 장애는 서비스 mock 예외 주입과 컨트롤러의 500/409·Set-Cookie 미발급으로 검증한다. +실제 Redis 중단·복구나 네트워크 지연, 동시 HTTP 응답 순서, SQL 연결 풀 부하 시험은 수행하지 않았다. +탈퇴 계정 검증은 테스트 DB의 사용자 삭제·flush·clear 후 쿠키 접근과 재발급 거부를 확인한 것이며, +실제 탈퇴 API의 커밋·이벤트까지 포함한 E2E는 아니다. ### 로컬 검증 결과 (2026-09-10) +- 이슈: [KOIN_API_V2 #2424](https://github.com/BCSDLab/KOIN_API_V2/issues/2424) +- 로컬 브랜치: `feat/2424-web-httponly-auth` - 기준 브랜치: `origin/develop`의 `3d14db39` - Java 17, 실제 테스트 MySQL·Redis·MongoDB를 사용한 전체 `build` 성공 -- 전체 1,216개 중 1,213개 통과, 기존 비활성 테스트 3개 건너뜀, 실패 0개 -- 이번에 추가한 인증 테스트 67개 모두 통과 +- 전체 1,243개 중 1,240개 통과, 기존 비활성 테스트 3개 건너뜀, 실패 0개 +- 이번에 추가한 웹 인증 테스트 94개 모두 통과 +- GET/HEAD 출처 검사와 잘못된 JSON 로그 마스킹 회귀 테스트 3개는 수정 전 실패, 수정 후 통과 확인 +- 보안·세션·호환성 에이전트의 1차 검토를 반영하고 수정 후 재검토 완료. 추가 차단 결함 없음 - 로컬 `build/`에 중복된 `Test 2.class` 산출물이 발견되어, 검증에서는 임시 Gradle init script로 - buildDirectory만 `/tmp/koin-web-auth-build-20260910`으로 분리했다. 프로젝트의 Gradle 빌드 설정은 변경하지 않았다. -- 실제 웹 화면·SSR·앱 웹뷰 연동과 운영/stage 검증은 수행하지 않았다. + buildDirectory만 `/tmp/koin-backend-2424-build`로 분리했다. 프로젝트의 Gradle 빌드 설정은 변경하지 않았다. +- 이번 작업에서는 프론트·앱 코드를 변경하지 않았고 실제 웹 화면·SSR·앱 웹뷰 연동과 운영/stage 검증은 수행하지 않았다. 쿠키 동작은 [MDN Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie), 요청 위조 방어는 [OWASP CSRF 방어 지침](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)을 참고했다. diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java index 2ffce123b2..24c2c02eec 100644 --- a/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java @@ -24,7 +24,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class WebAuthService { private final UserService userService; @@ -47,6 +46,7 @@ public WebAuthTokens login(WebLoginRequest request) { return tokens; } + @Transactional(readOnly = true) public WebAuthTokens refresh(String value, String csrfToken) { WebRefreshToken refreshToken = WebRefreshToken.parse(value); WebAuthSession session = getSession(refreshToken.sessionId()); @@ -98,6 +98,10 @@ public WebAuthSession authenticate(String accessToken) { if (!session.userId().equals(claims.userId())) { throw AuthenticationException.withDetail("웹 로그인 사용자 정보가 일치하지 않습니다."); } + // @UserId만 사용하는 API에서도 탈퇴한 계정의 쿠키를 인증하지 않는다. + if (!userRepository.existsById(session.userId())) { + throw AuthenticationException.withDetail("웹 로그인 사용자가 존재하지 않습니다."); + } return session; } diff --git a/src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java b/src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java index b5d383839c..53fb3e52e7 100644 --- a/src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java +++ b/src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java @@ -23,8 +23,9 @@ public class WebAuthRequestValidator { private final CorsProperties corsProperties; public void validate(HttpServletRequest request, WebAuthSession session) { + // 기존 조회 API 중 읽음 상태를 갱신하는 요청도 있으므로 GET도 출처를 확인한다. + requireTrustedOrigin(request); if (!SAFE_METHODS.contains(request.getMethod())) { - requireTrustedOrigin(request); session.requireCsrfToken(request.getHeader(CSRF_HEADER)); } } diff --git a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java index 649727e179..eaf6f72f99 100644 --- a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import org.springframework.web.util.ContentCachingRequestWrapper; @@ -194,7 +195,7 @@ public ResponseEntity handleException( HttpServletRequest request, Exception e ) { - String errorMessage = e.getMessage(); + String errorMessage = isWebAuthRequest(request) ? "[REDACTED]" : e.getMessage(); String errorFile = e.getStackTrace()[0].getFileName(); int errorLine = e.getStackTrace()[0].getLineNumber(); String errorName = e.getClass().getSimpleName(); @@ -231,7 +232,9 @@ private void requestLogging( String errorMessage, String errorTraceId ) { - log.warn("[{}] {} | errorTraceId={}", httpStatus, errorMessage, errorTraceId); + // 파싱/검증 예외의 메시지에도 로그인 본문 일부가 포함될 수 있다. + String safeMessage = isWebAuthRequest(request) ? "[REDACTED]" : errorMessage; + log.warn("[{}] {} | errorTraceId={}", httpStatus, safeMessage, errorTraceId); log.debug("Request: {} {}", request.getMethod(), request.getRequestURI()); log.debug("Headers: {}", getHeaders(request)); log.debug("Query String: {}", getQueryString(request)); @@ -284,6 +287,9 @@ private Map getHeaders(HttpServletRequest request) { } private String getQueryString(HttpServletRequest httpRequest) { + if (isWebAuthRequest(httpRequest)) { + return "[REDACTED]"; + } String queryString = httpRequest.getQueryString(); if (queryString == null) { return " - "; @@ -291,8 +297,14 @@ private String getQueryString(HttpServletRequest httpRequest) { return queryString; } + private boolean isWebAuthRequest(HttpServletRequest request) { + Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + String path = pattern == null ? request.getRequestURI() : pattern.toString(); + return path.endsWith("/v2/web/auth") || path.contains("/v2/web/auth/"); + } + private String getRequestBody(HttpServletRequest request) { - if (request.getRequestURI().contains("/v2/web/auth/")) { + if (isWebAuthRequest(request)) { return "[REDACTED]"; } var wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java index 9762741be3..1000931110 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java @@ -128,13 +128,42 @@ void setUp() { void 기존_일반_API를_쿠키로_인증하고_권한_검사를_재사용한다() throws Exception { WebLogin login = login(true); - mockMvc.perform(get("/v2/users/me").cookie(login.access())) + mockMvc.perform(get("/v2/users/me").header("Origin", ORIGIN).cookie(login.access())) .andExpect(status().isOk()) .andExpect(jsonPath("$.login_id").value("web-test")); - mockMvc.perform(get("/user/student/me").cookie(login.access())) + mockMvc.perform(get("/user/student/me").header("Origin", ORIGIN).cookie(login.access())) .andExpect(status().isForbidden()); } + @Test + void 쿠키_조회는_외부_사이트_이동을_거부하고_허용된_referer는_통과한다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get("/user/auth").cookie(login.access())) + .andExpect(status().isForbidden()) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + mockMvc.perform(get("/user/auth").header("Referer", "https://evil.example/").cookie(login.access())) + .andExpect(status().isForbidden()); + mockMvc.perform(get("/user/auth").header("Referer", ORIGIN + "/profile").cookie(login.access())) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "private, no-store")); + } + + @Test + void 탈퇴한_계정은_UserId_API와_재발급에서도_웹_쿠키를_사용하지_못한다() throws Exception { + WebLogin login = login(true); + userRepository.delete(user); + entityManager.flush(); + entityManager.clear(); + + mockMvc.perform(put("/callvan/posts/999999/reopen") + .header("Origin", ORIGIN).header("X-CSRF-Token", login.csrfToken()).cookie(login.access())) + .andExpect(status().isUnauthorized()) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + refresh(login).andExpect(status().isUnauthorized()) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + } + @Test void 모바일웹_로그인과_로그아웃이_앱의_로그인_정보를_덮어쓰지_않는다() throws Exception { JsonNode nativeLogin = nativeLogin(); @@ -163,7 +192,7 @@ void setUp() { .header("Authorization", "Bearer " + nativeLogin.get("token").asText()) .header("User-Agent", MOBILE_USER_AGENT)) .andExpect(status().isOk()); - mockMvc.perform(get("/user/auth").cookie(webLogin.access())).andExpect(status().isOk()); + mockMvc.perform(get("/user/auth").header("Origin", ORIGIN).cookie(webLogin.access())).andExpect(status().isOk()); } @Test @@ -173,7 +202,7 @@ void setUp() { logout(first).andExpect(status().isNoContent()); mockMvc.perform(get("/user/auth").cookie(first.access())).andExpect(status().isUnauthorized()); - mockMvc.perform(get("/user/auth").cookie(second.access())).andExpect(status().isOk()); + mockMvc.perform(get("/user/auth").header("Origin", ORIGIN).cookie(second.access())).andExpect(status().isOk()); } @Test @@ -295,7 +324,7 @@ void setUp() { mockMvc.perform(post(AUTH_PATH + "/logout").header("Origin", ORIGIN).cookie(login.refresh())) .andExpect(status().isForbidden()).andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); - mockMvc.perform(get("/user/auth").cookie(login.access())).andExpect(status().isOk()); + mockMvc.perform(get("/user/auth").header("Origin", ORIGIN).cookie(login.access())).andExpect(status().isOk()); } @Test diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthCompatibilityTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthCompatibilityTest.java new file mode 100644 index 0000000000..48bd51be0a --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthCompatibilityTest.java @@ -0,0 +1,177 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MvcResult; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.fixture.DepartmentAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.DiningAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.model.DiningLikes; +import in.koreatech.koin.domain.dining.repository.DiningLikesRepository; +import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.config.WebAuthProperties; +import jakarta.servlet.http.Cookie; + +class WebAuthCompatibilityTest extends AcceptanceTest { + + private static final String ORIGIN = "http://localhost:3000"; + private static final String MOBILE_USER_AGENT = "koin/1.0 (Android 14) OKHttp/4.12.0"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private WebAuthProperties properties; + + @Autowired + private DiningAcceptanceFixture diningFixture; + + @Autowired + private DiningLikesRepository diningLikesRepository; + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + private User user; + + @BeforeEach + void setUp() { + user = userRepository.save(User.builder() + .loginId("web-compatibility") + .loginPw(passwordEncoder.encode("1234")) + .name("웹호환성테스트") + .nickname("웹호환성") + .phoneNumber("01055557777") + .email("web-compatibility@koreatech.ac.kr") + .userType(UserType.GENERAL) + .isAuthed(true) + .isDeleted(false) + .build()); + } + + @Test + void 기존_이메일_로그인은_잘못된_웹_쿠키가_남아도_JSON_토큰을_발급한다() throws Exception { + MvcResult result = mockMvc.perform(post("/user/login") + .header(HttpHeaders.USER_AGENT, MOBILE_USER_AGENT) + .cookie(new Cookie(properties.accessCookieName(), "invalid-web-access"), + new Cookie(properties.refreshCookieName(), "invalid-web-refresh")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"web-compatibility@koreatech.ac.kr","password":"1234"} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").isNotEmpty()) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)) + .andReturn(); + + JsonNode response = objectMapper.readTree(result.getResponse().getContentAsString()); + assertThat(response.size()).isEqualTo(3); + mockMvc.perform(get("/user/auth") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + response.get("token").asText())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_type").value("GENERAL")); + } + + @Test + void 일반_사용자의_웹_쿠키로_관리자_API를_호출할_수_없다() throws Exception { + Cookie access = login(); + + mockMvc.perform(get("/admin/users/{id}", user.getId()).header(HttpHeaders.ORIGIN, ORIGIN).cookie(access)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_USER_TYPE.getCode())); + } + + @Test + void 학생도_웹_쿠키로_로그인하고_기존_학생_API를_사용한다() throws Exception { + Student student = userFixture.성빈_학생(departmentFixture.컴퓨터공학부()); + MvcResult result = mockMvc.perform(post("/v2/web/auth/login").header(HttpHeaders.ORIGIN, ORIGIN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("login_id", student.getUser().getLoginId(), "login_pw", "1234")))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.user_type").value("STUDENT")) + .andExpect(jsonPath("$.token").doesNotExist()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()) + .andReturn(); + + Cookie access = result.getResponse().getCookie(properties.accessCookieName()); + mockMvc.perform(get("/user/student/me").header(HttpHeaders.ORIGIN, ORIGIN).cookie(access)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(student.getId())) + .andExpect(jsonPath("$.user_type").value("STUDENT")); + } + + @Test + void 선택_인증_API는_쿠키와_Bearer_사용자를_인식하고_익명_조회도_허용한다() throws Exception { + Dining dining = diningFixture.A코너_점심(LocalDate.of(2026, 9, 10)); + dining.likesDining(); + diningLikesRepository.save(DiningLikes.builder().diningId(dining.getId()).userId(user.getId()).build()); + Cookie access = login(); + + mockMvc.perform(get("/dinings").param("date", "260910").header(HttpHeaders.ORIGIN, ORIGIN).cookie(access)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(dining.getId())) + .andExpect(jsonPath("$[0].is_liked").value(true)); + + mockMvc.perform(get("/dinings").param("date", "260910")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(dining.getId())) + .andExpect(jsonPath("$[0].is_liked").value(false)); + + mockMvc.perform(get("/dinings").param("date", "260910") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + jwtProvider.createToken(user))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(dining.getId())) + .andExpect(jsonPath("$[0].is_liked").value(true)); + } + + private Cookie login() throws Exception { + MvcResult result = mockMvc.perform(post("/v2/web/auth/login").header(HttpHeaders.ORIGIN, ORIGIN) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"login_id":"web-compatibility","login_pw":"1234"} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andReturn(); + return result.getResponse().getCookie(properties.accessCookieName()); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthControllerFailureTest.java b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthControllerFailureTest.java new file mode 100644 index 0000000000..9573404ae1 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthControllerFailureTest.java @@ -0,0 +1,85 @@ +package in.koreatech.koin.unit.domain.user.web; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import in.koreatech.koin.domain.user.web.controller.WebAuthController; +import in.koreatech.koin.domain.user.web.service.WebAuthService; +import in.koreatech.koin.global.auth.WebAuthCookieManager; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.config.WebAuthProperties; +import in.koreatech.koin.global.exception.CustomException; +import in.koreatech.koin.global.exception.GlobalExceptionHandler; +import jakarta.servlet.http.Cookie; + +@ExtendWith(MockitoExtension.class) +class WebAuthControllerFailureTest { + + @Mock + private WebAuthService service; + + private MockMvc mockMvc; + private final WebAuthProperties properties = new WebAuthProperties( + Duration.ofMinutes(15), Duration.ofDays(90), true, "Lax" + ); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new WebAuthController(service, new WebAuthCookieManager(properties))) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @ParameterizedTest + @ValueSource(strings = {"login", "refresh", "logout", "csrf"}) + void Redis_장애는_서버_오류를_반환하고_기존_쿠키를_덮거나_삭제하지_않는다(String endpoint) throws Exception { + RedisConnectionFailureException failure = new RedisConnectionFailureException("테스트용 Redis 연결 실패"); + switch (endpoint) { + case "login" -> when(service.login(any())).thenThrow(failure); + case "refresh" -> when(service.refresh(anyString(), anyString())).thenThrow(failure); + case "logout" -> doThrow(failure).when(service).logout(anyString(), anyString()); + case "csrf" -> when(service.getCsrfToken(anyString())).thenThrow(failure); + default -> throw new IllegalArgumentException("잘못된 테스트 경로"); + } + + MockHttpServletRequestBuilder request = "csrf".equals(endpoint) + ? get("/v2/web/auth/csrf") : post("/v2/web/auth/" + endpoint); + mockMvc.perform(request.header("Origin", "http://localhost:3000").header("X-CSRF-Token", "test-csrf") + .cookie(new Cookie(properties.refreshCookieName(), "test-refresh")) + .contentType(MediaType.APPLICATION_JSON).content("{\"login_id\":\"test\",\"login_pw\":\"test-password\"}")) + .andExpect(status().isInternalServerError()) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + } + + @Test + void 동시_재발급_충돌은_409를_반환하고_성공한_요청의_쿠키를_덮지_않는다() throws Exception { + when(service.refresh(anyString(), anyString())).thenThrow(CustomException.of(ApiResponseCode.WEB_AUTH_SESSION_CONFLICT)); + + mockMvc.perform(post("/v2/web/auth/refresh").header("X-CSRF-Token", "test-csrf") + .cookie(new Cookie(properties.refreshCookieName(), "test-refresh"))) + .andExpect(status().isConflict()) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthFailureTest.java b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthFailureTest.java new file mode 100644 index 0000000000..ed9e7575e6 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthFailureTest.java @@ -0,0 +1,238 @@ +package in.koreatech.koin.unit.domain.user.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.RedisConnectionFailureException; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.service.UserService; +import in.koreatech.koin.domain.user.web.dto.WebLoginRequest; +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.domain.user.web.model.WebRefreshToken; +import in.koreatech.koin.domain.user.web.repository.WebAuthSessionRedisRepository; +import in.koreatech.koin.domain.user.web.service.WebAuthService; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.config.WebAuthProperties; +import in.koreatech.koin.global.exception.CustomException; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class WebAuthFailureTest { + + @Mock + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private WebAuthSessionRedisRepository sessionRepository; + + private final User user = UserFixture.id_설정_코인_유저(1); + private final JwtProvider jwtProvider = new JwtProvider("web-auth-unit-test-key-32-characters", 600_000L); + private WebAuthService service; + private WebRefreshToken token; + private WebAuthSession session; + + @BeforeEach + void setUp() { + service = new WebAuthService(userService, userRepository, sessionRepository, jwtProvider, + new WebAuthProperties(Duration.ofMinutes(15), Duration.ofDays(90), true, "Lax")); + token = WebRefreshToken.create(); + session = new WebAuthSession(token.sessionId(), user.getId(), token.hash(), "test-csrf-token", + WebRefreshToken.hash(user.getLoginPw()), Instant.now().plusSeconds(3600), true); + } + + @Test + void access_인증_중_Redis_조회가_실패하면_사용자_조회로_진행하지_않는다() { + RedisConnectionFailureException failure = redisFailure(); + when(sessionRepository.findById(session.id())).thenThrow(failure); + + assertThatThrownBy(() -> service.authenticate(accessToken())).isSameAs(failure); + + verifyNoInteractions(userRepository, userService); + verifyNoSessionMutation(); + } + + @Test + void 재발급_중_Redis_조회가_실패하면_사용자_조회나_회전을_진행하지_않는다() { + RedisConnectionFailureException failure = redisFailure(); + when(sessionRepository.findById(session.id())).thenThrow(failure); + + assertThatThrownBy(() -> service.refresh(token.value(), session.csrfToken())).isSameAs(failure); + + verifyNoInteractions(userRepository, userService); + verifyNoSessionMutation(); + } + + @Test + void csrf_조회_중_Redis_조회가_실패하면_토큰을_반환하지_않는다() { + RedisConnectionFailureException failure = redisFailure(); + when(sessionRepository.findById(session.id())).thenThrow(failure); + + assertThatThrownBy(() -> service.getCsrfToken(token.value())).isSameAs(failure); + + verifyNoInteractions(userRepository, userService); + verifyNoSessionMutation(); + } + + @Test + void 로그아웃_중_Redis_조회가_실패하면_성공으로_처리하지_않는다() { + RedisConnectionFailureException failure = redisFailure(); + when(sessionRepository.findById(session.id())).thenThrow(failure); + + assertThatThrownBy(() -> service.logout(token.value(), session.csrfToken())).isSameAs(failure); + + verifyNoInteractions(userRepository, userService); + verifyNoSessionMutation(); + } + + @Test + void 로그인_중_Redis_저장이_실패하면_인증_결과를_반환하지_않는다() { + WebLoginRequest request = new WebLoginRequest("test_id2", "test_pw2", true); + RedisConnectionFailureException failure = redisFailure(); + when(userService.authenticate(request.toLoginRequest())).thenReturn(user); + doThrow(failure).when(sessionRepository).save(any()); + + assertThatThrownBy(() -> service.login(request)).isSameAs(failure); + + verify(sessionRepository).save(any()); + verify(sessionRepository, never()).rotate(any(), any()); + verify(sessionRepository, never()).delete(any()); + verifyNoInteractions(userRepository); + } + + @Test + void 재발급_중_Redis_회전이_실패하면_새_토큰을_반환하거나_세션을_삭제하지_않는다() { + RedisConnectionFailureException failure = redisFailure(); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(sessionRepository.rotate(any(), any())).thenThrow(failure); + + assertThatThrownBy(() -> service.refresh(token.value(), session.csrfToken())).isSameAs(failure); + + verify(sessionRepository).rotate(any(), any()); + verify(sessionRepository, never()).save(any()); + verify(sessionRepository, never()).delete(any()); + } + + @Test + void 로그아웃_중_Redis_삭제가_실패하면_성공으로_처리하지_않는다() { + RedisConnectionFailureException failure = redisFailure(); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(sessionRepository.delete(session)).thenThrow(failure); + + assertThatThrownBy(() -> service.logout(token.value(), session.csrfToken())).isSameAs(failure); + + verify(sessionRepository).delete(session); + verify(sessionRepository, never()).save(any()); + verify(sessionRepository, never()).rotate(any(), any()); + verifyNoInteractions(userRepository, userService); + } + + @Test + void 유효한_JWT와_Redis_세션이_있어도_탈퇴한_사용자는_인증하지_않는다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.existsById(user.getId())).thenReturn(false); + + assertThatThrownBy(() -> service.authenticate(accessToken())).isInstanceOf(AuthenticationException.class); + + verify(userRepository).existsById(user.getId()); + verify(sessionRepository, never()).save(any()); + verify(sessionRepository, never()).rotate(any(), any()); + } + + @Test + void 유효한_JWT와_Redis_세션이_있고_사용자가_존재하면_인증한다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.existsById(user.getId())).thenReturn(true); + + assertThat(service.authenticate(accessToken())).isEqualTo(session); + + verify(userRepository).existsById(user.getId()); + verifyNoSessionMutation(); + } + + @Test + void csrf가_틀린_로그아웃은_세션을_삭제하지_않는다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.logout(token.value(), "different-test-csrf-token")) + .isInstanceOf(CustomException.class).hasMessage(ApiResponseCode.INVALID_CSRF_TOKEN.getMessage()); + + verifyNoInteractions(userRepository, userService); + verifyNoSessionMutation(); + } + + @Test + void 만료된_세션은_refresh와_csrf가_일치해도_재발급하지_않는다() { + WebAuthSession expired = expiredSession(); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(expired)); + + assertThatThrownBy(() -> service.refresh(token.value(), expired.csrfToken())) + .isInstanceOf(AuthenticationException.class); + + verifyNoInteractions(userRepository, userService); + verifyNoSessionMutation(); + } + + @Test + void 만료된_세션으로는_csrf_토큰을_조회할_수_없다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(expiredSession())); + + assertThatThrownBy(() -> service.getCsrfToken(token.value())).isInstanceOf(AuthenticationException.class); + + verifyNoInteractions(userRepository, userService); + verifyNoSessionMutation(); + } + + @Test + void 재발급_시_사용자가_없으면_세션을_회전하거나_다시_저장하지_않는다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.refresh(token.value(), session.csrfToken())) + .isInstanceOf(AuthenticationException.class); + + verify(sessionRepository, never()).save(any()); + verify(sessionRepository, never()).rotate(any(), any()); + } + + private String accessToken() { + return jwtProvider.createWebToken(user, session.id(), Instant.now().plusSeconds(600)); + } + + private WebAuthSession expiredSession() { + return new WebAuthSession(session.id(), session.userId(), token.hash(), session.csrfToken(), + session.credentialHash(), Instant.now().minusSeconds(1), session.autoLogin()); + } + + private RedisConnectionFailureException redisFailure() { + return new RedisConnectionFailureException("테스트용 Redis 연결 실패"); + } + + private void verifyNoSessionMutation() { + verify(sessionRepository, never()).save(any()); + verify(sessionRepository, never()).rotate(any(), any()); + verify(sessionRepository, never()).delete(any()); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java index 19500f97ed..c79523298a 100644 --- a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java @@ -3,22 +3,54 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.stream.Collectors.joining; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.util.ContentCachingRequestWrapper; 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 in.koreatech.koin.domain.user.web.dto.WebLoginRequest; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.exception.CustomException; import in.koreatech.koin.global.exception.GlobalExceptionHandler; class WebAuthLoggingTest { + @Test + void 잘못된_JSON의_파싱_오류에도_로그인_비밀값을_기록하지_않는다() throws Exception { + Logger logger = (Logger)LoggerFactory.getLogger(GlobalExceptionHandler.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/v2/web/auth/login"); + request.setContentType("application/json"); + request.setContent("{\"login_id\":\"test\",\"login_pw\":malformedPasswordMarker}".getBytes(UTF_8)); + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + Throwable error = catchThrowable(() -> converter.read(WebLoginRequest.class, new ServletServerHttpRequest(request))); + assertThat(error).isInstanceOf(HttpMessageNotReadableException.class); + + new GlobalExceptionHandler().handleException((Exception)error, + new ServletWebRequest(request, new MockHttpServletResponse())); + + String messages = appender.list.stream().map(ILoggingEvent::getFormattedMessage).collect(joining("\n")); + assertThat(messages).isNotBlank().doesNotContain("malformedPasswordMarker"); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + @Test void 인증_실패_로그에_쿠키와_csrf_토큰과_로그인_본문을_노출하지_않는다() { Logger logger = (Logger)LoggerFactory.getLogger(GlobalExceptionHandler.class); diff --git a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java index e71d5335cf..61b9a0df17 100644 --- a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java @@ -90,7 +90,25 @@ class WebAuthRequestValidatorTest { @Test void 조회는_csrf_헤더_없이_허용한다() { - assertThatCode(() -> validator.validate(new MockHttpServletRequest("GET", "/user/auth"), session)) + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/user/auth"); + request.addHeader("Referer", ORIGIN + "/"); + + assertThatCode(() -> validator.validate(request, session)) .doesNotThrowAnyException(); } + + @ParameterizedTest + @ValueSource(strings = {"GET", "HEAD"}) + void 조회여도_출처가_없거나_외부_사이트이면_거부한다(String method) { + MockHttpServletRequest request = new MockHttpServletRequest(method, "/user/auth"); + + assertThatThrownBy(() -> validator.validate(request, session)) + .isInstanceOf(CustomException.class) + .hasMessage(ApiResponseCode.FORBIDDEN_WEB_ORIGIN.getMessage()); + + request.addHeader("Referer", "https://evil.example/"); + assertThatThrownBy(() -> validator.validate(request, session)) + .isInstanceOf(CustomException.class) + .hasMessage(ApiResponseCode.FORBIDDEN_WEB_ORIGIN.getMessage()); + } } From 8defd803cd38826d3746f365408ae2c14964876a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:02:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EC=9B=B9=20=EC=9D=B8=EC=A6=9D=20Sw?= =?UTF-8?q?agger=20=EB=AA=85=EC=84=B8=20=EB=B0=8F=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EC=99=84=20(#2424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/web/controller/WebAuthApi.java | 59 +++++-- .../global/config/SwaggerGroupConfig.java | 15 +- .../config/WebAuthOpenApiCustomizer.java | 136 +++++++++++++++ .../acceptance/domain/WebAuthApiTest.java | 15 ++ .../domain/WebAuthOpenApiContractTest.java | 159 ++++++++++++++++++ .../config/WebAuthOpenApiCustomizerTest.java | 86 ++++++++++ 6 files changed, 448 insertions(+), 22 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java diff --git a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java index 7298fe559b..12e93c08e4 100644 --- a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java @@ -14,27 +14,37 @@ import in.koreatech.koin.global.auth.WebAuthRequestValidator; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.code.ApiResponseCodes; +import in.koreatech.koin.global.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; -@Tag( - name = "(Normal) Web Auth: 웹 인증", - description = "HttpOnly 쿠키 기반 인증. 허용된 Origin 또는 Referer가 필요합니다." -) +@Tag(name = "(Normal) User: 유저", description = "유저 관련 API") @RequestMapping("/v2/web/auth") public interface WebAuthApi { @Operation( summary = "웹 로그인", - description = "토큰은 Set-Cookie로만 발급합니다. 요청에는 credentials를 포함해야 합니다." + description = """ + 일반인/학생/총학생회 사용자의 웹 로그인을 처리합니다. + access 토큰과 refresh 토큰은 HttpOnly 쿠키로 발급하고, 회원 유형과 CSRF 토큰을 반환합니다. + """ ) - @ApiResponse(responseCode = "201", description = "쿠키 발급 성공") - @ApiResponseCodes({ApiResponseCode.INVALID_REQUEST_BODY, ApiResponseCode.FORBIDDEN_WEB_ORIGIN}) + @ApiResponse(responseCode = "201", description = "쿠키 발급 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WebAuthResponse.class))) + @ApiResponse(responseCode = "415", description = "application/json 이외의 Content-Type", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class))) + @ApiResponseCodes({ApiResponseCode.INVALID_REQUEST_BODY, ApiResponseCode.NOT_MATCHED_PASSWORD, + ApiResponseCode.NOT_READABLE_HTTP_MESSAGE, ApiResponseCode.NOT_FOUND_USER, + ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.INTERNAL_SERVER_ERROR}) @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) ResponseEntity login( @RequestBody @Valid WebLoginRequest request, @@ -43,39 +53,56 @@ ResponseEntity login( @Operation( summary = "웹 토큰 재발급", - description = "refresh 쿠키와 CSRF 헤더가 필요합니다. refresh 토큰을 교체하며 로그인 유지 기간은 연장하지 않습니다." + description = """ + refresh 쿠키와 CSRF 토큰으로 access 토큰과 refresh 토큰을 재발급합니다. + refresh 토큰의 최초 만료 시각과 CSRF 토큰은 유지됩니다. + """ ) - @ApiResponse(responseCode = "201", description = "쿠키 재발급 성공") + @ApiResponse(responseCode = "201", description = "쿠키 재발급 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WebAuthResponse.class))) @ApiResponseCodes({ApiResponseCode.UNAUTHORIZED_USER, ApiResponseCode.INVALID_CSRF_TOKEN, - ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.WEB_AUTH_SESSION_CONFLICT}) + ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.WEB_AUTH_SESSION_CONFLICT, + ApiResponseCode.INTERNAL_SERVER_ERROR}) @PostMapping("/refresh") ResponseEntity refresh( HttpServletRequest request, HttpServletResponse response, - @Parameter(description = "로그인 또는 /csrf 응답으로 받은 CSRF 토큰") + @Parameter(description = "로그인 또는 CSRF 토큰 조회 응답으로 받은 CSRF 토큰", required = true) @RequestHeader(value = WebAuthRequestValidator.CSRF_HEADER, required = false) String csrfToken ); @Operation( summary = "웹 로그아웃", - description = "현재 웹 세션을 폐기하고 쿠키를 삭제합니다. 다른 웹 세션과 앱 로그인은 유지합니다." + description = """ + 현재 웹 세션을 삭제하고 인증 쿠키를 만료시킵니다. 삭제된 세션의 access 토큰은 사용할 수 없습니다. + refresh 쿠키가 없으면 쿠키만 만료시키며 서버 세션은 삭제하지 않습니다. + """ ) @ApiResponse(responseCode = "204", description = "로그아웃 성공") @ApiResponseCodes({ApiResponseCode.UNAUTHORIZED_USER, ApiResponseCode.INVALID_CSRF_TOKEN, - ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.WEB_AUTH_SESSION_CONFLICT}) + ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.WEB_AUTH_SESSION_CONFLICT, + ApiResponseCode.INTERNAL_SERVER_ERROR}) @PostMapping("/logout") ResponseEntity logout( HttpServletRequest request, HttpServletResponse response, + @Parameter(description = "CSRF 토큰. refresh 쿠키가 없거나 세션이 이미 삭제된 경우 생략 가능") @RequestHeader(value = WebAuthRequestValidator.CSRF_HEADER, required = false) String csrfToken ); @Operation( summary = "웹 CSRF 토큰 조회", - description = "페이지 새로고침 후 refresh 쿠키로 CSRF 토큰을 조회합니다. access 토큰 만료 여부와 무관합니다." + description = """ + refresh 쿠키로 현재 웹 세션의 CSRF 토큰을 조회합니다. + access 토큰과 refresh 토큰은 재발급하지 않습니다. + """ ) - @ApiResponse(responseCode = "200", description = "CSRF 토큰 조회 성공") - @ApiResponseCodes({ApiResponseCode.UNAUTHORIZED_USER, ApiResponseCode.FORBIDDEN_WEB_ORIGIN}) + @ApiResponse(responseCode = "200", description = "CSRF 토큰 조회 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WebCsrfTokenResponse.class))) + @ApiResponseCodes({ApiResponseCode.UNAUTHORIZED_USER, ApiResponseCode.FORBIDDEN_WEB_ORIGIN, + ApiResponseCode.INTERNAL_SERVER_ERROR}) @GetMapping("/csrf") ResponseEntity getCsrfToken(HttpServletRequest request); } diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java index 086d79919a..dd574d6df8 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java @@ -19,7 +19,7 @@ public SwaggerGroupConfig(ApiResponseCodesOperationCustomizer customizer) { public GroupedOpenApi loginApi() { return GroupedOpenApi.builder() .group("0. Login API") - .pathsToMatch("/**/login") + .pathsToMatch("/**/login", "/v2/web/auth/**") .addOperationCustomizer(customizer) .build(); } @@ -70,9 +70,9 @@ public GroupedOpenApi campusApi() { @Bean public GroupedOpenApi userApi() { - return createGroupedOpenApi( - "4. User API", - new String[] { + return GroupedOpenApi.builder() + .group("4. User API") + .packagesToScan( "in.koreatech.koin.domain.user", "in.koreatech.koin.domain.student", "in.koreatech.koin.domain.timetable", @@ -80,8 +80,11 @@ public GroupedOpenApi userApi() { "in.koreatech.koin.domain.timetableV3", "in.koreatech.koin.domain.course_registration", "in.koreatech.koin.domain.dept", - "in.koreatech.koin.domain.graduation", - }); + "in.koreatech.koin.domain.graduation" + ) + .pathsToExclude("/v2/web/auth/**") + .addOperationCustomizer(customizer) + .build(); } @Bean diff --git a/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java b/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java new file mode 100644 index 0000000000..9274bafc84 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java @@ -0,0 +1,136 @@ +package in.koreatech.koin.global.config; + +import java.util.List; + +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.global.auth.WebAuthCookieManager; +import in.koreatech.koin.global.auth.WebAuthRequestValidator; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.headers.Header; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.HeaderParameter; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class WebAuthOpenApiCustomizer implements GlobalOpenApiCustomizer { + + private static final String WEB_REFRESH_COOKIE = "WebRefreshCookie"; + private static final String AUTH_PATH = WebAuthCookieManager.AUTH_PATH; + + private final WebAuthProperties properties; + + @Override + public void customise(OpenAPI openApi) { + if (openApi.getPaths() == null) { + return; + } + configureOperation(openApi, "login", PathItem.HttpMethod.POST, List.of(), "201"); + configureOperation(openApi, "refresh", PathItem.HttpMethod.POST, + List.of(cookieSecurity()), "201"); + configureOperation(openApi, "logout", PathItem.HttpMethod.POST, + List.of(cookieSecurity(), new SecurityRequirement()), "204"); + configureOperation(openApi, "csrf", PathItem.HttpMethod.GET, + List.of(cookieSecurity()), "200"); + } + + private void configureOperation(OpenAPI openApi, String endpoint, PathItem.HttpMethod method, + List security, String successStatus) { + PathItem path = openApi.getPaths().get(AUTH_PATH + "/" + endpoint); + Operation operation = path == null ? null : path.readOperationsMap().get(method); + if (operation == null) { + return; + } + // 웹 인증 API에만 적용하고 기존 앱 API의 전역 Bearer 설정은 유지한다. + operation.setSecurity(security); + if (!security.isEmpty()) { + if (openApi.getComponents() == null) { + openApi.setComponents(new Components()); + } + openApi.getComponents().addSecuritySchemes(WEB_REFRESH_COOKIE, new SecurityScheme() + .type(SecurityScheme.Type.APIKEY).in(SecurityScheme.In.COOKIE) + .name(properties.refreshCookieName()) + .description("웹 refresh 토큰 (브라우저가 관리하는 HttpOnly 쿠키)")); + } + addHeader(operation, HttpHeaders.ORIGIN, + "허용된 웹 출처. Origin 또는 Referer 필수이며 Origin을 우선 검증합니다."); + addHeader(operation, HttpHeaders.REFERER, + "Origin 헤더가 없는 경우 검증할 요청 출처"); + if (endpoint.equals("refresh") && operation.getParameters() != null) { + // 런타임의 required=false는 누락 시 기존 403 오류를 반환하기 위해 유지한다. + operation.getParameters().stream() + .filter(parameter -> WebAuthRequestValidator.CSRF_HEADER.equals(parameter.getName())) + .forEach(parameter -> parameter.setRequired(true)); + } + configureResponses(operation, successStatus); + } + + private void addHeader(Operation operation, String name, String description) { + if (operation.getParameters() != null && operation.getParameters().stream() + .anyMatch(parameter -> name.equals(parameter.getName()) && "header".equals(parameter.getIn()))) { + return; + } + operation.addParametersItem(new HeaderParameter().name(name).description(description) + .required(false).schema(new StringSchema())); + } + + private void configureResponses(Operation operation, String successStatus) { + if (operation.getResponses() == null) { + return; + } + ApiResponse success = operation.getResponses().get(successStatus); + if (success != null) { + success.addHeaderObject(HttpHeaders.CACHE_CONTROL, + new Header().description("인증 응답 캐시 방지").schema(new StringSchema()).example("no-store")); + if (successStatus.equals("201") || successStatus.equals("204")) { + success.addHeaderObject(HttpHeaders.SET_COOKIE, cookieHeader(successStatus.equals("204"))); + } + } + ApiResponse forbidden = operation.getResponses().get("403"); + if (forbidden != null) { + if (forbidden.getContent() == null) { + forbidden.setContent(new Content()); + } + forbidden.getContent().addMediaType("text/plain", new MediaType().schema(new StringSchema()) + .example("Invalid CORS request")); + String description = "CORS 검사에서 차단된 Origin은 text/plain으로 응답합니다."; + if (forbidden.getDescription() == null || !forbidden.getDescription().contains(description)) { + forbidden.setDescription(forbidden.getDescription() == null ? description + : forbidden.getDescription() + "\n" + description); + } + } + } + + private Header cookieHeader(boolean clear) { + String description = clear ? "access·refresh 쿠키 만료 (Max-Age=0)" + : "access·refresh HttpOnly 쿠키 발급. auto_login=true이면 Max-Age를 설정합니다."; + return new Header().description(description + " Domain 미지정. 쿠키 이름과 Secure·SameSite는 서버 설정에 따릅니다.") + .schema(new ArraySchema().items(new StringSchema())) + .example(List.of( + cookie(properties.accessCookieName(), clear ? "" : "ACCESS_TOKEN_PLACEHOLDER", "/", clear), + cookie(properties.refreshCookieName(), clear ? "" : "REFRESH_TOKEN_PLACEHOLDER", AUTH_PATH, clear) + )); + } + + private String cookie(String name, String value, String path, boolean clear) { + return ResponseCookie.from(name, value).path(path).httpOnly(true) + .secure(properties.secure()).sameSite(properties.sameSite()).maxAge(clear ? 0 : -1).build().toString(); + } + + private SecurityRequirement cookieSecurity() { + return new SecurityRequirement().addList(WEB_REFRESH_COOKIE); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java index 1000931110..9cd0b15497 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java @@ -318,6 +318,21 @@ void setUp() { logout(login).andExpect(status().isNoContent()); } + @Test + void refresh가_없는_로그아웃은_쿠키만_정리하고_서버_세션을_폐기하지_않는다() throws Exception { + WebLogin login = login(true); + + MvcResult result = mockMvc.perform(post(AUTH_PATH + "/logout").header("Origin", ORIGIN) + .cookie(login.access())) + .andExpect(status().isNoContent()).andReturn(); + + assertThat(result.getResponse().getCookie(properties.accessCookieName()).getMaxAge()).isZero(); + assertThat(result.getResponse().getCookie(properties.refreshCookieName()).getMaxAge()).isZero(); + mockMvc.perform(get("/user/auth").header("Origin", ORIGIN).cookie(login.access())) + .andExpect(status().isOk()); + refresh(login).andExpect(status().isCreated()); + } + @Test void csrf가_없는_로그아웃은_쿠키나_세션을_삭제하지_않는다() throws Exception { WebLogin login = login(true); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java new file mode 100644 index 0000000000..9da0424313 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java @@ -0,0 +1,159 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.support.JsonAssertions; + +class WebAuthOpenApiContractTest extends AcceptanceTest { + + private static final String LOGIN_GROUP = "0. Login API"; + private static final String WEB_REFRESH_COOKIE = "WebRefreshCookie"; + + @Test + void 웹_로그인은_Bearer를_요구하지_않고_기존_앱_설정은_유지한다() throws Exception { + JsonNode openApi = openApi(LOGIN_GROUP); + + assertThat(operation(openApi, "login").path("security").isArray()).isTrue(); + assertThat(operation(openApi, "login").path("security")).isEmpty(); + assertThat(openApi.path("security").get(0).has("Jwt Authentication")).isTrue(); + assertThat(openApi.at("/paths/~1v2~1users~1login/post").isMissingNode()).isFalse(); + } + + @Test + void 웹_인증_API는_기존_유저_로그인과_같은_태그에_포함한다() throws Exception { + JsonNode openApi = openApi(LOGIN_GROUP); + JsonNode nativeLogin = openApi.at("/paths/~1v2~1users~1login/post"); + + for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + assertThat(operation(openApi, endpoint).path("tags")).isEqualTo(nativeLogin.path("tags")); + } + assertThat(nativeLogin.path("tags").get(0).asText()).isEqualTo("(Normal) User: 유저"); + assertThat(openApi.path("tags")) + .noneMatch(tag -> tag.path("name").asText().equals("(Normal) Web Auth: 웹 인증")); + } + + @Test + void 웹_인증_API는_회원_그룹에_중복_노출하지_않는다() throws Exception { + JsonNode openApi = openApi("4. User API"); + + for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + assertThat(operation(openApi, endpoint).isMissingNode()).isTrue(); + } + assertThat(openApi.at("/paths/~1v2~1users~1login/post").isMissingNode()).isFalse(); + assertThat(openApi.at("/paths/~1v2~1users~1me/get").isMissingNode()).isFalse(); + assertThat(openApi.path("security").get(0).has("Jwt Authentication")).isTrue(); + } + + @Test + void 웹_인증_출처와_refresh_쿠키를_명세한다() throws Exception { + JsonNode openApi = openApi(LOGIN_GROUP); + + for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + JsonNode operation = operation(openApi, endpoint); + assertThat(parameter(operation, "Origin").path("in").asText()).isEqualTo("header"); + assertThat(parameter(operation, "Referer").path("in").asText()).isEqualTo("header"); + } + JsonNode cookieScheme = openApi.path("components").path("securitySchemes").path(WEB_REFRESH_COOKIE); + assertThat(cookieScheme.path("in").asText()).isEqualTo("cookie"); + assertThat(cookieScheme.path("name").asText()).isEqualTo("__Secure-koin-web-refresh"); + for (String endpoint : List.of("refresh", "csrf")) { + assertThat(operation(openApi, endpoint).path("security").get(0).has(WEB_REFRESH_COOKIE)).isTrue(); + } + } + + @Test + void 재발급과_로그아웃의_CSRF_조건을_구분한다() throws Exception { + JsonNode openApi = openApi(LOGIN_GROUP); + JsonNode refresh = operation(openApi, "refresh"); + JsonNode logout = operation(openApi, "logout"); + + assertThat(parameter(refresh, "X-CSRF-Token").path("required").asBoolean()).isTrue(); + assertThat(parameter(logout, "X-CSRF-Token").path("required").asBoolean()).isFalse(); + assertThat(logout.path("security")).hasSize(2); + assertThat(logout.path("security").get(1)).isEmpty(); + assertThat(logout.path("description").asText()).contains("서버 세션은 삭제하지 않습니다"); + assertThat(operation(openApi, "csrf").path("parameters")) + .noneMatch(parameter -> parameter.path("name").asText().equals("X-CSRF-Token")); + } + + @Test + void 쿠키_발급과_삭제_응답을_명세하고_토큰은_JSON에_노출하지_않는다() throws Exception { + JsonNode openApi = openApi(LOGIN_GROUP); + for (String endpoint : List.of("login", "refresh")) { + JsonNode response = operation(openApi, endpoint).at("/responses/201"); + JsonNode cookie = response.at("/headers/Set-Cookie"); + assertThat(cookie.path("schema").path("type").asText()).isEqualTo("array"); + assertThat(cookie.path("example")).hasSize(2); + assertThat(cookie.path("example").get(0).asText()) + .contains("__Host-koin-web-access", "Path=/", "HttpOnly", "Secure", "SameSite=Lax"); + assertThat(cookie.path("example").get(1).asText()) + .contains("__Secure-koin-web-refresh", "Path=/v2/web/auth", "HttpOnly"); + assertThat(response.at("/content/application~1json/schema/$ref").asText()).endsWith("WebAuthResponse"); + } + JsonNode logout = operation(openApi, "logout").at("/responses/204"); + assertThat(logout.has("content")).isFalse(); + assertThat(logout.at("/headers/Set-Cookie/example")) + .allSatisfy(cookie -> assertThat(cookie.asText()).contains("Max-Age=0")); + String schemaRef = operation(openApi, "login").at("/responses/201/content/application~1json/schema/$ref") + .asText(); + JsonNode properties = openApi.at(schemaRef.substring(1)).path("properties"); + assertThat(properties).hasSize(2); + assertThat(properties.has("user_type")).isTrue(); + assertThat(properties.has("csrf_token")).isTrue(); + } + + @Test + void 실제_로그인_실패와_갱신_충돌_오류를_명세한다() throws Exception { + JsonNode openApi = openApi(LOGIN_GROUP); + JsonNode login = operation(openApi, "login"); + + assertThat(login.at("/responses/400/content/application~1json/examples").has("NOT_MATCHED_PASSWORD")) + .isTrue(); + assertThat(login.at("/responses/400/content/application~1json/examples").has("NOT_READABLE_HTTP_MESSAGE")) + .isTrue(); + assertThat(login.at("/responses/404/content/application~1json/examples").has("NOT_FOUND_USER")) + .isTrue(); + assertThat(login.at("/responses/415/content/application~1json/schema/$ref").asText()) + .endsWith("ErrorResponse"); + for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + assertThat(operation(openApi, endpoint).at("/responses/500/content/application~1json/examples") + .has("INTERNAL_SERVER_ERROR")).isTrue(); + assertThat(operation(openApi, endpoint).at("/responses/403/content/text~1plain/example").asText()) + .isEqualTo("Invalid CORS request"); + } + for (String endpoint : List.of("refresh", "logout")) { + assertThat(operation(openApi, endpoint).at("/responses/409/content/application~1json/examples") + .has("WEB_AUTH_SESSION_CONFLICT")).isTrue(); + assertThat(operation(openApi, endpoint).at("/responses/403/content/application~1json/examples") + .has("INVALID_CSRF_TOKEN")).isTrue(); + } + } + + private JsonNode openApi(String group) throws Exception { + return JsonAssertions.convertJsonNode(mockMvc.perform(get("/v3/api-docs/{group}", group)) + .andExpect(status().isOk()).andReturn()); + } + + private JsonNode operation(JsonNode openApi, String endpoint) { + String method = endpoint.equals("csrf") ? "get" : "post"; + return openApi.path("paths").path("/v2/web/auth/" + endpoint).path(method); + } + + private JsonNode parameter(JsonNode operation, String name) { + for (JsonNode parameter : operation.path("parameters")) { + if (parameter.path("name").asText().equals(name)) { + return parameter; + } + } + throw new AssertionError("명세에 요청 헤더가 없습니다: " + name); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java b/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java new file mode 100644 index 0000000000..2d9e695d13 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java @@ -0,0 +1,86 @@ +package in.koreatech.koin.unit.global.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.global.config.WebAuthOpenApiCustomizer; +import in.koreatech.koin.global.config.WebAuthProperties; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; + +class WebAuthOpenApiCustomizerTest { + + private final WebAuthOpenApiCustomizer customizer = new WebAuthOpenApiCustomizer( + new WebAuthProperties(Duration.ofMinutes(15), Duration.ofDays(90), false, "Strict")); + + @Test + void 실행_환경의_쿠키_이름과_속성을_문서에_반영한다() { + Operation refresh = operation(); + OpenAPI openApi = new OpenAPI().paths(new Paths() + .addPathItem("/v2/web/auth/refresh", new PathItem().post(refresh))); + + customizer.customise(openApi); + + assertThat(openApi.getComponents().getSecuritySchemes().get("WebRefreshCookie").getName()) + .isEqualTo("koin-web-refresh"); + assertThat(refresh.getResponses().get("201").getHeaders().get("Set-Cookie").getExample().toString()) + .contains("koin-web-access=", "koin-web-refresh=", "HttpOnly", "SameSite=Strict") + .doesNotContain("__Host-", "__Secure-", "; Secure", "Domain="); + } + + @Test + void 웹_인증이_없는_그룹과_기존_Bearer_인증은_변경하지_않는다() { + Operation nativeLogin = operation(); + List bearer = List.of(new SecurityRequirement().addList("Jwt Authentication")); + OpenAPI openApi = new OpenAPI().security(bearer).paths(new Paths() + .addPathItem("/v2/users/login", new PathItem().post(nativeLogin))); + + customizer.customise(openApi); + + assertThat(openApi.getSecurity()).isEqualTo(bearer); + assertThat(openApi.getComponents()).isNull(); + assertThat(nativeLogin.getParameters()).isNull(); + assertThat(nativeLogin.getSecurity()).isNull(); + assertThat(nativeLogin.getResponses().get("201").getHeaders()).isNull(); + } + + @Test + void 반복_적용해도_요청_헤더와_오류_설명이_중복되지_않는다() { + Operation login = operation(); + OpenAPI openApi = new OpenAPI().paths(new Paths() + .addPathItem("/v2/web/auth/login", new PathItem().post(login))); + + customizer.customise(openApi); + String forbidden = login.getResponses().get("403").getDescription(); + customizer.customise(openApi); + + assertThat(login.getParameters()).extracting("name").containsExactly("Origin", "Referer"); + assertThat(login.getResponses().get("403").getDescription()).isEqualTo(forbidden); + assertThat(login.getSecurity()).isEmpty(); + } + + @Test + void 경로나_응답이_없는_문서에도_적용할_수_있다() { + assertThatCode(() -> customizer.customise(new OpenAPI())).doesNotThrowAnyException(); + OpenAPI openApi = new OpenAPI().paths(new Paths() + .addPathItem("/v2/web/auth/login", new PathItem().post(new Operation()))); + + assertThatCode(() -> customizer.customise(openApi)).doesNotThrowAnyException(); + } + + private Operation operation() { + return new Operation().responses(new ApiResponses() + .addApiResponse("201", new ApiResponse().description("성공")) + .addApiResponse("403", new ApiResponse().description("출처 거부"))); + } +}