diff --git a/docs/web-cookie-auth.md b/docs/web-cookie-auth.md new file mode 100644 index 0000000000..0c6d981435 --- /dev/null +++ b/docs/web-cookie-auth.md @@ -0,0 +1,131 @@ +# 웹 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 헤더를 추가한다. 조회 요청은 CSRF 헤더가 필요하지 않지만 허용된 출처여야 한다. +5. access 만료 시 `/refresh`를 한 번 호출한 뒤 원래 요청을 재시도한다. +6. 로그아웃은 서버 `/logout`의 성공을 확인한 뒤 화면의 로그인 상태를 비운다. + +로그인·재발급·로그아웃·CSRF 조회는 만료된 access 쿠키의 영향을 받지 않는다. +일반 기능 API에서는 명시적인 `Authorization` 헤더가 있으면 기존 헤더 인증만 사용한다. +잘못된 헤더를 쿠키 인증으로 대체하지 않으므로, 웹 전환 시 예전 헤더 주입 코드도 제거해야 한다. +기존 앱 토큰을 웹 쿠키로 넣거나 웹 토큰을 기존 앱 인증 경로로 전달하는 것은 허용하지 않는다. +일반 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`를 사용한다. + +## 쿠키와 만료 설정 + +| 항목 | 운영 기본값 | +| --- | --- | +| 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도 사용할 수 없다. +웹 인증 시 DB에서도 계정의 존재 여부를 확인하므로 `@UserId` API도 탈퇴 계정의 쿠키를 받지 않는다. +이 때문에 쿠키 인증은 Redis와 사용자 DB 가용성에 의존하며, 조회 실패 시 인증을 허용하지 않는다. +Redis 전용 CSRF 조회·로그아웃에는 SQL 트랜잭션을 만들지 않고, access 인증도 Redis 대기 전에 SQL 트랜잭션을 시작하지 않는다. + +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 경로 분리, 세션 검증, 로컬 쿠키 설정과 로그 마스킹을 확인한다. +`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,243개 중 1,240개 통과, 기존 비활성 테스트 3개 건너뜀, 실패 0개 +- 이번에 추가한 웹 인증 테스트 94개 모두 통과 +- GET/HEAD 출처 검사와 잘못된 JSON 로그 마스킹 회귀 테스트 3개는 수정 전 실패, 수정 후 통과 확인 +- 보안·세션·호환성 에이전트의 1차 검토를 반영하고 수정 후 재검토 완료. 추가 차단 결함 없음 +- 로컬 `build/`에 중복된 `Test 2.class` 산출물이 발견되어, 검증에서는 임시 Gradle init script로 + 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/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..12e93c08e4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java @@ -0,0 +1,108 @@ +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 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) User: 유저", description = "유저 관련 API") +@RequestMapping("/v2/web/auth") +public interface WebAuthApi { + + @Operation( + summary = "웹 로그인", + description = """ + 일반인/학생/총학생회 사용자의 웹 로그인을 처리합니다. + access 토큰과 refresh 토큰은 HttpOnly 쿠키로 발급하고, 회원 유형과 CSRF 토큰을 반환합니다. + """ + ) + @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, + HttpServletResponse response + ); + + @Operation( + summary = "웹 토큰 재발급", + description = """ + refresh 쿠키와 CSRF 토큰으로 access 토큰과 refresh 토큰을 재발급합니다. + refresh 토큰의 최초 만료 시각과 CSRF 토큰은 유지됩니다. + """ + ) + @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.INTERNAL_SERVER_ERROR}) + @PostMapping("/refresh") + ResponseEntity refresh( + HttpServletRequest request, + HttpServletResponse response, + @Parameter(description = "로그인 또는 CSRF 토큰 조회 응답으로 받은 CSRF 토큰", required = true) + @RequestHeader(value = WebAuthRequestValidator.CSRF_HEADER, required = false) String csrfToken + ); + + @Operation( + summary = "웹 로그아웃", + 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.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 토큰과 refresh 토큰은 재발급하지 않습니다. + """ + ) + @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/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..24c2c02eec --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java @@ -0,0 +1,127 @@ +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 +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; + } + + @Transactional(readOnly = true) + 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("웹 로그인 사용자 정보가 일치하지 않습니다."); + } + // @UserId만 사용하는 API에서도 탈퇴한 계정의 쿠키를 인증하지 않는다. + if (!userRepository.existsById(session.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..53fb3e52e7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/WebAuthRequestValidator.java @@ -0,0 +1,59 @@ +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) { + // 기존 조회 API 중 읽음 상태를 갱신하는 요청도 있으므로 GET도 출처를 확인한다. + requireTrustedOrigin(request); + if (!SAFE_METHODS.contains(request.getMethod())) { + 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/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/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..eaf6f72f99 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; @@ -24,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; @@ -47,6 +50,8 @@ @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private static final Set SENSITIVE_HEADERS = Set.of("authorization", "cookie", "x-csrf-token"); + // 커스텀 예외 @ExceptionHandler(CustomException.class) @@ -190,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(); @@ -227,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)); @@ -273,12 +280,16 @@ 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; } private String getQueryString(HttpServletRequest httpRequest) { + if (isWebAuthRequest(httpRequest)) { + return "[REDACTED]"; + } String queryString = httpRequest.getQueryString(); if (queryString == null) { return " - "; @@ -286,7 +297,16 @@ 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 (isWebAuthRequest(request)) { + 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..9cd0b15497 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java @@ -0,0 +1,473 @@ +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").header("Origin", ORIGIN).cookie(login.access())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.login_id").value("web-test")); + 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(); + 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").header("Origin", ORIGIN).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").header("Origin", ORIGIN).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 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); + + 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").header("Origin", ORIGIN).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/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/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/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/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/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..c79523298a --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java @@ -0,0 +1,82 @@ +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 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); + 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..61b9a0df17 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthRequestValidatorTest.java @@ -0,0 +1,114 @@ +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_헤더_없이_허용한다() { + 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()); + } +} 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(); + } +} 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("출처 거부"))); + } +}