diff --git a/docs/web-cookie-auth.md b/docs/web-cookie-auth.md index 5584363abc..cbd0efaac5 100644 --- a/docs/web-cookie-auth.md +++ b/docs/web-cookie-auth.md @@ -9,9 +9,10 @@ | 요청 | 입력 | 성공 응답 | | --- | --- | --- | | `POST /v2/web/auth/login` | JSON `login_id`, `login_pw`, `auto_login` | 201, 인증 쿠키 2개와 CSRF 일반 쿠키, `user_type`·`csrf_token` | -| `GET /v2/web/auth/csrf` | refresh 쿠키 | 200, CSRF 일반 쿠키 복구, `csrf_token` | | `POST /v2/web/auth/refresh` | refresh 쿠키, `X-CSRF-Token` | 201, 교체된 인증 쿠키 2개와 CSRF 일반 쿠키, `user_type`·`csrf_token` | | `POST /v2/web/auth/logout` | refresh 쿠키, `X-CSRF-Token` | 204, 현재 웹 세션 폐기 및 쿠키 삭제 | +| `GET /v2/web/auth/session` | access·refresh 쿠키(둘 다 선택) | 200 항상. `authenticated`, `user_type`, `csrf_token`. 유효하면 CSRF 쿠키 복구 | +| `GET /v3/users/me` | access 쿠키(또는 Bearer) | 200, 일반·학생·총학생회 공통 내 정보. 학생은 `student_number`, `major` 포함 | 웹 인증 API는 허용된 `Origin` 또는 `Referer`가 필요하다. 로그인 본문은 `application/json`만 받는다. `login_pw`는 기존 로그인과 동일하게 SHA-256 처리한 비밀번호를 전달한다. @@ -23,7 +24,7 @@ HMAC-SHA256 서명으로 구성되며 세션 ID는 CSRF 토큰에 노출하지 현재 Redis 세션의 토큰을 함께 검증한다. 쿠키에 담긴 값만으로 인증하거나 쿠키와 헤더의 단순 일치만 확인하지 않는다. 웹 코드는 CSRF 일반 쿠키를 읽어 쿠키 인증 POST/PUT/PATCH/DELETE 요청의 `X-CSRF-Token` 헤더에 넣는다. CSRF 토큰을 별도 state나 localStorage에 저장할 필요는 없다. 기존 JSON의 `csrf_token`은 호환성을 위해 유지한다. -CSRF 쿠키가 없으면 `/csrf`를 호출해 복구한다. 단순 새로고침마다 이 API를 호출할 필요는 없다. +CSRF 쿠키가 없으면 `/session`을 호출해 복구한다(세션이 유효할 때 CSRF 쿠키를 다시 발급한다). 단순 새로고침마다 호출할 필요는 없다. 로그인 시에는 기존 세션이 없으므로 허용 출처 검사와 JSON Content-Type 제한으로 로그인 CSRF를 방어한다. @@ -36,7 +37,7 @@ CSRF 쿠키가 없으면 `/csrf`를 호출해 복구한다. 단순 새로고침 5. access 만료 시 `/refresh`를 한 번 호출한 뒤 원래 요청을 재시도한다. 6. 로그아웃은 서버 `/logout`의 성공을 확인한 뒤 화면의 로그인 상태를 비운다. -로그인·재발급·로그아웃·CSRF 조회는 만료된 access 쿠키의 영향을 받지 않는다. +로그인·재발급·로그아웃·세션 조회는 만료된 access 쿠키의 영향을 받지 않는다. 일반 기능 API에서는 명시적인 `Authorization` 헤더가 있으면 기존 헤더 인증만 사용한다. 잘못된 헤더를 쿠키 인증으로 대체하지 않으므로, 웹 전환 시 예전 헤더 주입 코드도 제거해야 한다. 기존 앱 토큰을 웹 쿠키로 넣거나 웹 토큰을 기존 앱 인증 경로로 전달하는 것은 허용하지 않는다. @@ -52,6 +53,23 @@ API 주소 직접 입력은 403이 될 수 있다. SSR/프록시도 모든 쿠 `/user/check/login`처럼 토큰을 query로 받는 기존 전용 API는 쿠키 인증으로 전환하지 않는다. 웹의 로그인 상태 확인은 쿠키와 함께 `/user/auth`를 사용한다. +### 세션 조회 (`/v2/web/auth/session`) + +웹은 로그인 상태를 알기 위해 이 API를 사용한다. 비로그인도 오류가 아니라 `authenticated: false`인 200으로 응답하므로 +401을 정상 응답으로 해석하거나 `/user/auth` 401 → `/refresh` → 재시도를 클라이언트가 조합할 필요가 없다. + +- refresh 쿠키의 세션이 유효하면 access가 만료되었어도 `authenticated: true`이고, 일치하지 않으면 access 쿠키로 확인한다. 토큰은 회전하지 않는다. access는 이후 API 요청이 401일 때 `/refresh`로 재발급한다. +- 세션이 유효하면 CSRF 일반 쿠키를 복구한다. 이 API가 기존 `/csrf` 조회를 대체한다. +- 세션이 저장소에서 사라졌거나(만료·로그아웃·비밀번호 변경·탈퇴) 쿠키 형식이 잘못되면 `authenticated: false`로 응답하며 access·refresh·CSRF 쿠키를 만료시킨다. 이후 "CSRF 쿠키 없음 = 세션 없음"으로 판단할 수 있다. +- refresh 값이 세션과 일치하지 않는 경우는 다른 탭이 이미 회전시켰을 수 있으므로 쿠키를 지우지 않는다. +- 다른 웹 인증 API와 같이 허용된 `Origin` 또는 `Referer`가 필요하고 `Cache-Control: no-store`로 응답한다. + +### 내 정보 조회 (`/v3/users/me`) + +일반(`GENERAL`)·학생(`STUDENT`)·총학생회(`COUNCIL`)가 같은 엔드포인트로 조회한다. 학생·총학생회는 `student_number`, `major`가 함께 내려오고 일반 회원은 두 필드가 null이다. +기존 `/user/student/me`, `/v2/users/me`와 앱 계약은 그대로 유지한다. + + ## 쿠키와 만료 설정 | 항목 | 운영(`prod`) 기본값 | @@ -105,7 +123,7 @@ refresh 원문은 저장하지 않고 SHA-256 해시를 저장한다. 재발급 웹 access JWT에도 세션 ID를 넣고 요청마다 Redis 세션을 확인하므로 웹 로그아웃 후 남은 access도 사용할 수 없다. 웹 인증 시 DB에서도 계정의 존재 여부를 확인하므로 `@UserId` API도 탈퇴 계정의 쿠키를 받지 않는다. 이 때문에 쿠키 인증은 Redis와 사용자 DB 가용성에 의존하며, 조회 실패 시 인증을 허용하지 않는다. -Redis 전용 CSRF 조회·로그아웃에는 SQL 트랜잭션을 만들지 않고, access 인증도 Redis 대기 전에 SQL 트랜잭션을 시작하지 않는다. +Redis 전용 로그아웃과 세션 조회(사용자 조회는 트랜잭션 없이 단건 읽기)에는 SQL 트랜잭션을 만들지 않고, access 인증도 Redis 대기 전에 SQL 트랜잭션을 시작하지 않는다. Redis의 원자적 비교·교체로 같은 refresh의 동시 갱신은 하나만 성공한다. 먼저 읽은 상태가 다른 요청에 의해 변경되면 409, 이미 교체된 refresh를 새로 제출하면 401이다. 실패 응답은 쿠키를 삭제하거나 덮어쓰지 않는다. diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java index 8c04f77a56..d5fcd2c245 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java @@ -25,6 +25,7 @@ import in.koreatech.koin.domain.user.dto.UserRegisterRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordByEmailRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordBySmsRequest; +import in.koreatech.koin.domain.user.dto.UserProfileResponse; import in.koreatech.koin.domain.user.dto.UserResponse; import in.koreatech.koin.domain.user.dto.UserTypeResponse; import in.koreatech.koin.domain.user.dto.UserUpdatePasswordRequest; @@ -57,6 +58,24 @@ ResponseEntity getUser( @Auth(permit = {GENERAL}) Integer userId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation( + summary = "내 정보 조회 V2 (회원 유형 무관)", + description = "일반·학생·총학생회 모두 이 엔드포인트로 조회합니다. 학생·총학생회는 student_number, major가 함께 내려오고 일반 회원은 null입니다." + ) + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/v3/users/me") + ResponseEntity getProfile( + @Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java index 34aeb28b7e..076d3e89f0 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java @@ -27,6 +27,7 @@ import in.koreatech.koin.domain.user.dto.UserRegisterRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordByEmailRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordBySmsRequest; +import in.koreatech.koin.domain.user.dto.UserProfileResponse; import in.koreatech.koin.domain.user.dto.UserResponse; import in.koreatech.koin.domain.user.dto.UserTypeResponse; import in.koreatech.koin.domain.user.dto.UserUpdatePasswordRequest; @@ -50,6 +51,14 @@ public ResponseEntity getUser( return ResponseEntity.ok().body(userResponse); } + @GetMapping("/v3/users/me") + public ResponseEntity getProfile( + @Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId + ) { + UserProfileResponse response = userService.getProfile(userId); + return ResponseEntity.ok().body(response); + } + @GetMapping("/user/auth") public ResponseEntity getUserType( @Auth(permit = {GENERAL, STUDENT, OWNER, COOP, COUNCIL}) Integer userId diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserProfileResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserProfileResponse.java new file mode 100644 index 0000000000..b54f34566a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserProfileResponse.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.domain.user.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +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 io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record UserProfileResponse( + @Schema(example = "1", description = "사용자 고유 id") + Integer id, + + @Schema(example = "example12", description = "로그인 id") + String loginId, + + @Schema(description = "익명 닉네임", example = "익명_1676688416361") + String anonymousNickname, + + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr") + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1") + Integer gender, + + @Schema(description = "이름", example = "최준호") + String name, + + @Schema(description = "닉네임", example = "juno") + String nickname, + + @Schema(description = "휴대폰 번호", example = "010-0000-0000") + String phoneNumber, + + @Schema(description = "사용자 타입. 학생·총학생회는 학생 정보가 함께 내려옵니다.", example = "STUDENT") + UserType userType, + + @Schema(description = "학번. 학생·총학생회만 존재합니다.", example = "2029136012", nullable = true) + String studentNumber, + + @Schema(description = "전공(학부). 학생·총학생회만 존재합니다.", example = "컴퓨터공학부", nullable = true) + String major +) { + + public static UserProfileResponse from(User user) { + return of(user, null, null); + } + + public static UserProfileResponse from(Student student) { + String major = student.getDepartment() == null ? null : student.getDepartment().getName(); + return of(student.getUser(), student.getStudentNumber(), major); + } + + private static UserProfileResponse of(User user, String studentNumber, String major) { + return new UserProfileResponse( + user.getId(), + user.getLoginId(), + user.getAnonymousNickname(), + user.getEmail(), + user.getGender() != null ? user.getGender().ordinal() : null, + user.getName(), + user.getNickname(), + user.getPhoneNumber(), + user.getUserType(), + studentNumber, + major + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index 22c0b8c5ce..135e6182f6 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -1,5 +1,6 @@ package in.koreatech.koin.domain.user.model; +import static in.koreatech.koin.domain.user.model.UserType.KOIN_STUDENT_TYPES; import static lombok.AccessLevel.PROTECTED; import java.time.LocalDateTime; @@ -254,4 +255,8 @@ public String getDisplayNickname() { } return "익명 사용자"; } + + public boolean isKoinStudent() { + return KOIN_STUDENT_TYPES.contains(this.getUserType()); + } } 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 02490922c2..92e0e8c6b3 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 @@ -9,6 +9,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.global.auth.JwtProvider; import in.koreatech.koin.common.event.UserDeleteEvent; import in.koreatech.koin.common.event.UserMarketingAgreementEvent; @@ -28,6 +29,7 @@ import in.koreatech.koin.domain.user.dto.UserRegisterRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordByEmailRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordBySmsRequest; +import in.koreatech.koin.domain.user.dto.UserProfileResponse; import in.koreatech.koin.domain.user.dto.UserResponse; import in.koreatech.koin.domain.user.dto.UserTypeResponse; import in.koreatech.koin.domain.user.dto.UserUpdatePasswordRequest; @@ -59,6 +61,16 @@ public UserResponse getUser(Integer userId) { return UserResponse.from(user); } + /** 회원 유형과 무관한 내 정보. 학생·총학생회는 학생 정보(학번·전공)를 포함한다. */ + public UserProfileResponse getProfile(Integer userId) { + User user = userRepository.getById(userId); + if (user.isKoinStudent()) { + Student student = studentRepository.getById(userId); + return UserProfileResponse.from(student); + } + return UserProfileResponse.from(user); + } + public UserTypeResponse getUserType(Integer userId) { User user = userRepository.getById(userId); return UserTypeResponse.from(user); diff --git a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java index f507cfbd12..6478b5ceb8 100644 --- a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java @@ -9,8 +9,8 @@ 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.domain.user.web.dto.WebSessionResponse; import in.koreatech.koin.global.auth.WebAuthRequestValidator; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.code.ApiResponseCodes; @@ -94,18 +94,19 @@ ResponseEntity logout( ); @Operation( - summary = "웹 CSRF 토큰 조회", + summary = "웹 세션 조회", description = """ - refresh 쿠키로 현재 웹 세션의 CSRF 토큰을 조회합니다. - 동일한 CSRF 토큰을 일반 쿠키로 다시 발급합니다. - access 토큰과 refresh 토큰은 재발급하지 않습니다. + 현재 브라우저의 웹 로그인 세션 상태를 조회합니다. 비로그인도 오류가 아니라 authenticated=false인 200으로 응답합니다. + refresh 쿠키의 세션이 유효하면 access가 만료되었더라도 authenticated=true입니다(access는 이후 요청에서 재발급). + 세션이 유효하면 CSRF 일반 쿠키를 복구합니다. 토큰은 재발급(회전)하지 않습니다. + 세션이 서버 저장소에서 사라졌거나 쿠키가 형식에 맞지 않으면 access·refresh·CSRF 쿠키를 만료시킵니다. + refresh 값이 세션과 일치하지 않는 경우(다른 탭이 이미 회전)에는 쿠키를 지우지 않습니다. """ ) - @ApiResponse(responseCode = "200", description = "CSRF 토큰 조회 성공", + @ApiResponse(responseCode = "200", description = "세션 조회 성공", 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, HttpServletResponse response); + schema = @Schema(implementation = WebSessionResponse.class))) + @ApiResponseCodes({ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.INTERNAL_SERVER_ERROR}) + @GetMapping("/session") + ResponseEntity getSession(HttpServletRequest request, HttpServletResponse response); } 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 index a2d17ae8d6..dfca8ac774 100644 --- 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 @@ -12,11 +12,12 @@ 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.dto.WebSessionResponse; import in.koreatech.koin.domain.user.web.service.WebAuthService; import in.koreatech.koin.domain.user.web.service.WebAuthTokens; -import in.koreatech.koin.domain.user.web.service.WebCsrfToken; +import in.koreatech.koin.domain.user.web.service.WebSessionResult; +import in.koreatech.koin.domain.user.web.service.WebSessionService; import in.koreatech.koin.global.auth.WebAuthCookieManager; import in.koreatech.koin.global.auth.WebAuthRequestValidator; import jakarta.servlet.http.HttpServletRequest; @@ -30,6 +31,7 @@ public class WebAuthController implements WebAuthApi { private final WebAuthService webAuthService; + private final WebSessionService webSessionService; private final WebAuthCookieManager cookieManager; @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) @@ -64,10 +66,18 @@ public ResponseEntity logout( return ResponseEntity.noContent().build(); } - @GetMapping("/csrf") - public ResponseEntity getCsrfToken(HttpServletRequest request, HttpServletResponse response) { - WebCsrfToken csrfToken = webAuthService.getCsrfToken(cookieManager.getRefreshToken(request)); - cookieManager.writeCsrfToken(response, csrfToken); - return ResponseEntity.ok(new WebCsrfTokenResponse(csrfToken.value())); + @GetMapping("/session") + public ResponseEntity getSession(HttpServletRequest request, HttpServletResponse response) { + WebSessionResult result = webSessionService.getSession( + cookieManager.getRefreshToken(request), cookieManager.getAccessToken(request)); + switch (result.status()) { + case AUTHENTICATED -> { + cookieManager.writeCsrfToken(response, result.csrfToken()); + return ResponseEntity.ok(new WebSessionResponse(true, result.userType(), result.csrfToken().value())); + } + case SESSION_LOST -> cookieManager.clear(response); + case ANONYMOUS -> { } + } + return ResponseEntity.ok(WebSessionResponse.anonymous()); } } 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 deleted file mode 100644 index 9d5b499fa9..0000000000 --- a/src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -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 = "CSRF 일반 쿠키와 동일한 세션별 HMAC 토큰. 상태 변경 요청의 X-CSRF-Token 헤더에 전달합니다.", requiredMode = REQUIRED) - String csrfToken -) { - -} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/dto/WebSessionResponse.java b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebSessionResponse.java new file mode 100644 index 0000000000..d60641f4d1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebSessionResponse.java @@ -0,0 +1,26 @@ +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.NOT_REQUIRED; +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 WebSessionResponse( + @Schema(description = "웹 로그인 세션이 유효한지 여부. 비로그인은 오류가 아니라 false로 응답합니다.", example = "true", requiredMode = REQUIRED) + boolean authenticated, + + @Schema(description = "회원 유형. authenticated가 true일 때만 존재합니다.", example = "STUDENT", requiredMode = NOT_REQUIRED, nullable = true) + String userType, + + @Schema(description = "CSRF 일반 쿠키와 동일한 세션별 HMAC 토큰. authenticated가 true일 때만 존재합니다.", requiredMode = NOT_REQUIRED, nullable = true) + String csrfToken +) { + + public static WebSessionResponse anonymous() { + return new WebSessionResponse(false, null, null); + } +} 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 index f90a74b33b..1cffe9afe2 100644 --- 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 @@ -5,6 +5,8 @@ import java.security.MessageDigest; import java.time.Instant; +import com.fasterxml.jackson.annotation.JsonIgnore; + import in.koreatech.koin.global.auth.exception.AuthenticationException; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.exception.CustomException; @@ -25,14 +27,23 @@ public void requireRefreshToken(WebRefreshToken token) { } } + public boolean matchesRefreshToken(WebRefreshToken token) { + return matches(refreshTokenHash, token.hash()); + } + public void requireCsrfToken(String token) { if (!matches(csrfToken, token)) { throw CustomException.of(ApiResponseCode.INVALID_CSRF_TOKEN); } } + @JsonIgnore + public boolean isExpired() { + return !expiresAt.isAfter(Instant.now()); + } + public void requireNotExpired() { - if (!expiresAt.isAfter(Instant.now())) { + if (isExpired()) { throw AuthenticationException.withDetail("웹 로그인 유지 기간이 만료되었습니다."); } } diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java index b20e362f84..57e1e416ea 100644 --- a/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -87,26 +88,33 @@ public void logout(String value, String csrfToken) { } } - public WebCsrfToken getCsrfToken(String value) { - WebRefreshToken refreshToken = WebRefreshToken.parse(value); - WebAuthSession session = getSession(refreshToken.sessionId()); - session.requireRefreshToken(refreshToken); - return new WebCsrfToken(session.csrfToken(), session.expiresAt(), session.autoLogin()); + public WebAuthSession authenticate(String accessToken) { + WebAuthSession session = getSessionByAccessToken(accessToken); + // @UserId만 사용하는 API에서도 탈퇴한 계정의 쿠키를 인증하지 않는다. + if (!userRepository.existsById(session.userId())) { + throw AuthenticationException.withDetail("웹 로그인 사용자가 존재하지 않습니다."); + } + return session; } - public WebAuthSession authenticate(String accessToken) { + private WebAuthSession getSessionByAccessToken(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; } + /** 사용자 존재 여부까지는 확인하지 않는 access 토큰 → 세션 조회. 유효하지 않으면 비어 있다. */ + public Optional findSessionByAccessToken(String accessToken) { + try { + return Optional.of(getSessionByAccessToken(accessToken)); + } catch (AuthenticationException e) { + return Optional.empty(); + } + } + private WebAuthSession getSession(String sessionId) { WebAuthSession session = sessionRepository.findById(sessionId) .orElseThrow(() -> AuthenticationException.withDetail( diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionResult.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionResult.java new file mode 100644 index 0000000000..98b5d37692 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionResult.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.domain.user.web.service; + +/** + * 웹 세션 조회 결과. 세션의 도메인 상태만 담고, 쿠키를 어떻게 다룰지는 호출한 쪽(컨트롤러)이 정한다. + * + * @param status 세션 상태 + * @param userType AUTHENTICATED일 때의 회원 유형 + * @param csrfToken AUTHENTICATED일 때의 세션별 CSRF 토큰(쿠키 수명 정보 포함) + */ +public record WebSessionResult(Status status, String userType, WebCsrfToken csrfToken) { + + public enum Status { + /** 유효한 세션이다. */ + AUTHENTICATED, + /** 인증 정보가 없거나 현재 판단할 수 없다. 브라우저의 쿠키는 그대로 둔다. */ + ANONYMOUS, + /** 쿠키는 남았지만 서버 세션이 확정적으로 사라졌거나 쿠키 형식이 잘못되었다. 쿠키는 더 쓸모가 없다. */ + SESSION_LOST + } + + static WebSessionResult authenticated(String userType, WebCsrfToken csrfToken) { + return new WebSessionResult(Status.AUTHENTICATED, userType, csrfToken); + } + + static WebSessionResult anonymous() { + return new WebSessionResult(Status.ANONYMOUS, null, null); + } + + static WebSessionResult sessionLost() { + return new WebSessionResult(Status.SESSION_LOST, null, null); + } + + @Override + public String toString() { + return "WebSessionResult[REDACTED]"; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionService.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionService.java new file mode 100644 index 0000000000..d5765967a8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionService.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.user.web.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +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.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.exception.AuthenticationException; +import lombok.RequiredArgsConstructor; + +/** + * 웹 로그인 세션 상태를 조회하는 읽기 전용 서비스. 토큰을 회전하지 않고 세션도 변경하지 않는다. + * SQL 트랜잭션 안에서 Redis를 기다리지 않도록 트랜잭션을 열지 않는다. + */ +@Service +@RequiredArgsConstructor +public class WebSessionService { + + private final WebAuthService webAuthService; + private final WebAuthSessionRedisRepository sessionRepository; + private final UserRepository userRepository; + + /** + * refresh 쿠키의 세션을 우선 보고, 일치하지 않으면 access 쿠키로 확인한다. refresh만 남은 상태(access 만료)도 + * 세션이 유효하므로 AUTHENTICATED이며, access는 이후 API 요청의 401에서 재발급된다. + * SESSION_LOST는 세션이 저장소에서 확정적으로 사라졌거나 쿠키가 형식에 맞지 않을 때뿐이다. + * refresh 값이 세션과 어긋나는 경우는 다른 탭이 이미 회전시켰을 수 있어 ANONYMOUS로 남긴다. + */ + public WebSessionResult getSession(String refreshValue, String accessValue) { + RefreshLookup refresh = lookupByRefreshToken(refreshValue); + WebAuthSession session = refresh.session(); + if (session == null && StringUtils.hasText(accessValue)) { + session = webAuthService.findSessionByAccessToken(accessValue).orElse(null); + } + if (session == null) { + return hasCredentials(refreshValue, accessValue) && !refresh.rotated() + ? WebSessionResult.sessionLost() : WebSessionResult.anonymous(); + } + User user = userRepository.findById(session.userId()).orElse(null); + if (user == null) { + return WebSessionResult.sessionLost(); + } + return WebSessionResult.authenticated(user.getUserType().getValue(), + new WebCsrfToken(session.csrfToken(), session.expiresAt(), session.autoLogin())); + } + + private boolean hasCredentials(String refreshValue, String accessValue) { + return StringUtils.hasText(refreshValue) || StringUtils.hasText(accessValue); + } + + private RefreshLookup lookupByRefreshToken(String value) { + if (!StringUtils.hasText(value)) { + return RefreshLookup.NONE; + } + try { + WebRefreshToken refreshToken = WebRefreshToken.parse(value); + Optional found = sessionRepository.findById(refreshToken.sessionId()); + if (found.isEmpty() || found.get().isExpired()) { + return RefreshLookup.NONE; + } + return found.get().matchesRefreshToken(refreshToken) ? new RefreshLookup(found.get(), false) + : new RefreshLookup(null, true); + } catch (AuthenticationException e) { + return RefreshLookup.NONE; + } + } + + /** rotated: 세션은 있지만 refresh 값이 다르다(다른 탭이 이미 회전시켰을 수 있다). */ + private record RefreshLookup(WebAuthSession session, boolean rotated) { + + static final RefreshLookup NONE = new RefreshLookup(null, false); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java b/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java index cb47cb5a3e..48228d8999 100644 --- a/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java +++ b/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java @@ -43,8 +43,8 @@ public void customise(OpenAPI openApi) { 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"); + configureOperation(openApi, "session", PathItem.HttpMethod.GET, + List.of(cookieSecurity(), new SecurityRequirement()), "200"); } private void configureOperation(OpenAPI openApi, String endpoint, PathItem.HttpMethod method, @@ -117,7 +117,7 @@ private Header cookieHeader(boolean clear, boolean csrfOnly) { String description = clear ? "access·refresh·CSRF 쿠키 만료 (Max-Age=0)" : "access·refresh는 HttpOnly, CSRF는 일반 쿠키로 발급합니다. auto_login=true이면 Max-Age를 설정합니다."; String csrf = cookie(properties.csrfCookieName(), clear ? "" : "SIGNED_CSRF_TOKEN_PLACEHOLDER", "/", clear, false); - return new Header().description((csrfOnly ? "기존 세션의 CSRF 쿠키만 복구합니다." : description) + return new Header().description((csrfOnly ? "세션이 유효하면 CSRF 쿠키만 복구합니다(토큰은 회전하지 않음). 세션이 사라졌으면 세 쿠키를 만료시킵니다." : description) + " refresh는 API 호스트 전용이며 access·CSRF의 Domain과 쿠키 이름·Secure·SameSite는 서버 설정에 따릅니다.") .schema(new ArraySchema().items(new StringSchema())) .example(csrfOnly ? List.of(csrf) : List.of( diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/StudentApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/StudentApiTest.java index 62ba490499..ce2ed69383 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/StudentApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/StudentApiTest.java @@ -113,6 +113,33 @@ void setup() { """)); } + @Test + void 학생은_회원_유형과_무관한_내_정보_API로_학번과_전공까지_조회한다() throws Exception { + Department department = departmentFixture.컴퓨터공학부(); + Student student = userFixture.준호_학생(department, null); + String token = userFixture.getToken(student.getUser()); + + mockMvc.perform( + get("/v3/users/me") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "anonymous_nickname": "익명_주노", + "email": "juno@koreatech.ac.kr", + "gender": 0, + "major": "컴퓨터공학부", + "name": "테스트용_준호", + "nickname": "준호", + "phone_number": "01012345678", + "student_number": "2019136135", + "user_type": "STUDENT" + } + """)); + } + @Test void 올바른_학생계정인지_확인한다_토큰_정보가_올바르지_않으면_401() throws Exception { Department department = departmentFixture.컴퓨터공학부(); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java index 9932ec9ddd..f33f936013 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java @@ -149,11 +149,13 @@ void setUp() { @ParameterizedTest @ValueSource(booleans = {true, false}) - void csrf_조회는_기존_세션의_쿠키만_복구하고_refresh를_회전하지_않는다(boolean autoLogin) throws Exception { + void 세션_조회는_기존_세션의_CSRF_쿠키만_복구하고_refresh를_회전하지_않는다(boolean autoLogin) throws Exception { WebLogin login = login(autoLogin); - MvcResult result = mockMvc.perform(get(AUTH_PATH + "/csrf").header("Origin", ORIGIN).cookie(login.refresh())) - .andExpect(status().isOk()).andExpect(jsonPath("$.csrf_token").value(login.csrfToken())).andReturn(); + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN).cookie(login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andExpect(jsonPath("$.csrf_token").value(login.csrfToken())).andReturn(); assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(1); Cookie restored = result.getResponse().getCookie(properties.csrfCookieName()); @@ -272,12 +274,13 @@ void setUp() { } @Test - void 만료된_access_쿠키가_있어도_csrf_조회와_재발급이_가능하다() throws Exception { + void 만료된_access_쿠키가_있어도_세션_조회와_재발급이_가능하다() throws Exception { WebLogin login = login(true); Cookie expired = expiredAccess(login); - mockMvc.perform(get(AUTH_PATH + "/csrf").header("Origin", ORIGIN).cookie(expired, login.refresh())) + mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN).cookie(expired, login.refresh())) .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) .andExpect(jsonPath("$.csrf_token").value(login.csrfToken())) .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")); @@ -485,6 +488,116 @@ void setUp() { .andExpect(header().string("Access-Control-Allow-Credentials", "true")); } + @Test + void 쿠키가_없으면_세션_조회는_오류가_아니라_비로그인으로_응답한다() throws Exception { + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andExpect(jsonPath("$.user_type").doesNotExist()) + .andExpect(jsonPath("$.csrf_token").doesNotExist()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).isEmpty(); + } + + @Test + void 세션_조회는_유효한_세션이면_회원_유형을_주고_CSRF_쿠키만_복구한다() throws Exception { + WebLogin login = login(true); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN) + .cookie(login.access(), login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andExpect(jsonPath("$.csrf_token").value(login.csrfToken())) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(1); + assertThat(result.getResponse().getCookie(properties.csrfCookieName()).getValue()).isEqualTo(login.csrfToken()); + refresh(login).andExpect(status().isCreated()); + } + + @Test + void access가_만료되어도_refresh_세션이_유효하면_로그인으로_응답하고_회전하지_않는다() throws Exception { + WebLogin login = login(true); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN) + .cookie(expiredAccess(login), login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(1); + assertThat(result.getResponse().getCookie(properties.refreshCookieName())).isNull(); + refresh(login).andExpect(status().isCreated()); + } + + @Test + void 세션이_사라졌으면_비로그인으로_응답하고_인증_쿠키를_모두_만료시킨다() throws Exception { + WebLogin login = login(true); + logout(login).andExpect(status().isNoContent()); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN) + .cookie(login.access(), login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(3); + assertThat(result.getResponse().getCookie(properties.accessCookieName()).getMaxAge()).isZero(); + assertThat(result.getResponse().getCookie(properties.refreshCookieName()).getMaxAge()).isZero(); + assertThat(result.getResponse().getCookie(properties.csrfCookieName()).getMaxAge()).isZero(); + } + + @Test + void 형식이_잘못된_refresh_쿠키는_비로그인으로_응답하고_쿠키를_만료시킨다() throws Exception { + Cookie broken = new Cookie(properties.refreshCookieName(), "not-a-refresh-token"); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN).cookie(broken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(3); + } + + @Test + void 다른_탭이_이미_회전시킨_이전_refresh는_비로그인으로_보지만_쿠키를_지우지_않는다() throws Exception { + WebLogin login = login(true); + refresh(login).andExpect(status().isCreated()); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN) + .cookie(expiredAccess(login), login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).isEmpty(); + } + + @Test + void 세션_조회도_허용되지_않은_출처는_거부한다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", "https://evil.example").cookie(login.refresh())) + .andExpect(status().isForbidden()); + } + + @Test + void 일반_회원은_회원_유형과_무관한_내_정보_API로_조회한다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get("/v3/users/me").header("Origin", ORIGIN).cookie(login.access())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andExpect(jsonPath("$.login_id").value("web-test")) + .andExpect(jsonPath("$.anonymous_nickname").exists()) + .andExpect(jsonPath("$.student_number").doesNotExist()) + .andExpect(jsonPath("$.major").doesNotExist()); + } + private WebLogin login(boolean autoLogin) throws Exception { MvcResult result = mockMvc.perform(post(AUTH_PATH + "/login").header("Origin", ORIGIN) .header("User-Agent", MOBILE_USER_AGENT) diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthHttpTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthHttpTest.java index 34e006144b..3c400e443e 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthHttpTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthHttpTest.java @@ -80,7 +80,7 @@ class WebAuthHttpTest extends AcceptanceTest { assertThat(missingCsrf.statusCode()).isEqualTo(403); assertThat(missingCsrf.headers().allValues("Set-Cookie")).isEmpty(); - HttpResponse restored = send(request(AUTH_PATH + "/csrf").header("Cookie", refresh).GET()); + HttpResponse restored = send(request(AUTH_PATH + "/session").header("Cookie", refresh).GET()); assertThat(restored.statusCode()).isEqualTo(200); assertThat(restored.headers().allValues("Set-Cookie")).hasSize(1); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java index ef4c8b41d7..03ec25639b 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java @@ -33,7 +33,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { JsonNode openApi = openApi(LOGIN_GROUP); JsonNode nativeLogin = openApi.at("/paths/~1v2~1users~1login/post"); - for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + for (String endpoint : List.of("login", "refresh", "logout", "session")) { assertThat(operation(openApi, endpoint).path("tags")).isEqualTo(nativeLogin.path("tags")); } assertThat(nativeLogin.path("tags").get(0).asText()).isEqualTo("(Normal) User: 유저"); @@ -45,7 +45,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { void 웹_인증_API는_회원_그룹에_중복_노출하지_않는다() throws Exception { JsonNode openApi = openApi("4. User API"); - for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + for (String endpoint : List.of("login", "refresh", "logout", "session")) { assertThat(operation(openApi, endpoint).isMissingNode()).isTrue(); } assertThat(openApi.at("/paths/~1v2~1users~1login/post").isMissingNode()).isFalse(); @@ -57,7 +57,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { void 웹_인증_출처와_refresh_쿠키를_명세한다() throws Exception { JsonNode openApi = openApi(LOGIN_GROUP); - for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + for (String endpoint : List.of("login", "refresh", "logout", "session")) { JsonNode operation = operation(openApi, endpoint); assertThat(parameter(operation, "Origin").path("in").asText()).isEqualTo("header"); assertThat(parameter(operation, "Referer").path("in").asText()).isEqualTo("header"); @@ -65,7 +65,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { 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")) { + for (String endpoint : List.of("refresh", "session")) { assertThat(operation(openApi, endpoint).path("security").get(0).has(WEB_REFRESH_COOKIE)).isTrue(); } } @@ -81,7 +81,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { 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")) + assertThat(operation(openApi, "session").path("parameters")) .noneMatch(parameter -> parameter.path("name").asText().equals("X-CSRF-Token")); } @@ -111,7 +111,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { assertThat(properties).hasSize(2); assertThat(properties.has("user_type")).isTrue(); assertThat(properties.has("csrf_token")).isTrue(); - assertThat(operation(openApi, "csrf").at("/responses/200/headers/Set-Cookie/example")).hasSize(1); + assertThat(operation(openApi, "session").at("/responses/200/headers/Set-Cookie/example")).hasSize(1); } @Test @@ -127,7 +127,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { .isTrue(); assertThat(login.at("/responses/415/content/application~1json/schema/$ref").asText()) .endsWith("ErrorResponse"); - for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + for (String endpoint : List.of("login", "refresh", "logout", "session")) { 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()) @@ -147,7 +147,7 @@ private JsonNode openApi(String group) throws Exception { } private JsonNode operation(JsonNode openApi, String endpoint) { - String method = endpoint.equals("csrf") ? "get" : "post"; + String method = endpoint.equals("session") ? "get" : "post"; return openApi.path("paths").path("/v2/web/auth/" + endpoint).path(method); } 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 index 8610397ba3..208fd103d9 100644 --- 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 @@ -27,6 +27,7 @@ import in.koreatech.koin.domain.user.web.controller.WebAuthController; import in.koreatech.koin.domain.user.web.service.WebAuthService; +import in.koreatech.koin.domain.user.web.service.WebSessionService; import in.koreatech.koin.global.auth.WebAuthCookieManager; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.config.WebAuthProperties; @@ -40,31 +41,34 @@ class WebAuthControllerFailureTest { @Mock private WebAuthService service; + @Mock + private WebSessionService sessionService; + private MockMvc mockMvc; private final WebAuthProperties properties = new WebAuthProperties( Duration.ofMinutes(15), Duration.ofDays(90), true, "Lax", null, "koin-web"); @BeforeEach void setUp() { - mockMvc = MockMvcBuilders.standaloneSetup(new WebAuthController(service, new WebAuthCookieManager(properties))) + mockMvc = MockMvcBuilders.standaloneSetup(new WebAuthController(service, sessionService, new WebAuthCookieManager(properties))) .setControllerAdvice(new GlobalExceptionHandler()) .build(); } @ParameterizedTest - @ValueSource(strings = {"login", "refresh", "logout", "csrf"}) + @ValueSource(strings = {"login", "refresh", "logout", "session"}) 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); + case "session" -> when(sessionService.getSession(any(), any())).thenThrow(failure); default -> throw new IllegalArgumentException("잘못된 테스트 경로"); } - MockHttpServletRequestBuilder request = "csrf".equals(endpoint) - ? get("/v2/web/auth/csrf") : post("/v2/web/auth/" + endpoint); + MockHttpServletRequestBuilder request = "session".equals(endpoint) + ? get("/v2/web/auth/session") : 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\"}")) 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 index 69329d394c..b830be23af 100644 --- 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 @@ -86,17 +86,6 @@ void setUp() { 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(); @@ -197,16 +186,6 @@ void setUp() { 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)); diff --git a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebSessionServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebSessionServiceTest.java new file mode 100644 index 0000000000..3f47b5a1b1 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebSessionServiceTest.java @@ -0,0 +1,170 @@ +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.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +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.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.WebSessionResult; +import in.koreatech.koin.domain.user.web.service.WebSessionResult.Status; +import in.koreatech.koin.domain.user.web.service.WebSessionService; +import in.koreatech.koin.global.auth.WebCsrfTokenProvider; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class WebSessionServiceTest { + + @Mock + private WebAuthService webAuthService; + + @Mock + private WebAuthSessionRedisRepository sessionRepository; + + @Mock + private UserRepository userRepository; + + private final User user = UserFixture.id_설정_코인_유저(1); + private final WebCsrfTokenProvider csrfTokenProvider = new WebCsrfTokenProvider("csrf-test-signing-key-at-least-32-bytes"); + private WebSessionService service; + private WebRefreshToken token; + private WebAuthSession session; + + @BeforeEach + void setUp() { + service = new WebSessionService(webAuthService, sessionRepository, userRepository); + token = WebRefreshToken.create(); + session = new WebAuthSession(token.sessionId(), user.getId(), token.hash(), + csrfTokenProvider.createToken(token.sessionId()), WebRefreshToken.hash(user.getLoginPw()), + Instant.now().plusSeconds(3600), true); + } + + @Test + void 쿠키가_없으면_저장소를_조회하지_않고_익명이다() { + WebSessionResult result = service.getSession(null, null); + + assertThat(result.status()).isEqualTo(Status.ANONYMOUS); + verifyNoInteractions(webAuthService, sessionRepository, userRepository); + } + + @Test + void 유효한_refresh_세션이면_회원_유형과_CSRF를_돌려주고_세션을_변경하지_않는다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + WebSessionResult result = service.getSession(token.value(), null); + + assertThat(result.status()).isEqualTo(Status.AUTHENTICATED); + assertThat(result.userType()).isEqualTo(user.getUserType().getValue()); + assertThat(result.csrfToken().value()).isEqualTo(session.csrfToken()); + verifyNoSessionMutation(); + } + + @Test + void access가_만료되어도_refresh_세션이_유효하면_인증으로_본다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + WebSessionResult result = service.getSession(token.value(), "expired-access"); + + assertThat(result.status()).isEqualTo(Status.AUTHENTICATED); + verifyNoInteractions(webAuthService); + } + + @Test + void refresh가_없어도_유효한_access_세션이면_인증으로_본다() { + when(webAuthService.findSessionByAccessToken("valid-access")).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + WebSessionResult result = service.getSession(null, "valid-access"); + + assertThat(result.status()).isEqualTo(Status.AUTHENTICATED); + } + + @Test + void 저장소에_세션이_없으면_세션_소멸이다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.empty()); + + assertThat(service.getSession(token.value(), null).status()).isEqualTo(Status.SESSION_LOST); + verifyNoSessionMutation(); + } + + @Test + void 만료된_세션은_세션_소멸이다() { + WebAuthSession expired = new WebAuthSession(session.id(), session.userId(), token.hash(), session.csrfToken(), + session.credentialHash(), Instant.now().minusSeconds(1), session.autoLogin()); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(expired)); + + assertThat(service.getSession(token.value(), null).status()).isEqualTo(Status.SESSION_LOST); + } + + @Test + void 형식이_잘못된_refresh_쿠키는_저장소를_조회하지_않고_세션_소멸이다() { + WebSessionResult result = service.getSession("not-a-refresh-token", null); + + assertThat(result.status()).isEqualTo(Status.SESSION_LOST); + verifyNoInteractions(sessionRepository); + } + + @Test + void 다른_탭이_회전시킨_이전_refresh는_익명으로_두고_쿠키를_버리게_하지_않는다() { + WebAuthSession rotated = session.rotate(token.rotate()); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(rotated)); + + assertThat(service.getSession(token.value(), null).status()).isEqualTo(Status.ANONYMOUS); + verifyNoSessionMutation(); + } + + @Test + void 회전된_refresh여도_access가_유효하면_인증으로_본다() { + WebAuthSession rotated = session.rotate(token.rotate()); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(rotated)); + when(webAuthService.findSessionByAccessToken("valid-access")).thenReturn(Optional.of(rotated)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + assertThat(service.getSession(token.value(), "valid-access").status()).isEqualTo(Status.AUTHENTICATED); + } + + @Test + void 탈퇴한_계정의_세션은_세션_소멸이다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.empty()); + + assertThat(service.getSession(token.value(), null).status()).isEqualTo(Status.SESSION_LOST); + } + + @Test + void Redis_조회가_실패하면_익명으로_단정하지_않고_오류를_전달한다() { + RedisConnectionFailureException failure = new RedisConnectionFailureException("테스트용 Redis 연결 실패"); + when(sessionRepository.findById(session.id())).thenThrow(failure); + + assertThatThrownBy(() -> service.getSession(token.value(), null)).isSameAs(failure); + + verifyNoInteractions(userRepository); + verifyNoSessionMutation(); + } + + private void verifyNoSessionMutation() { + verify(sessionRepository, never()).save(any()); + verify(sessionRepository, never()).rotate(any(), any()); + verify(sessionRepository, never()).delete(any()); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java b/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java index 5bf13b188a..77d146236a 100644 --- a/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java +++ b/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java @@ -21,14 +21,14 @@ class WebAuthOpenApiCustomizerTest { @Test - void 공유_쿠키_설정과_CSRF_조회_복구를_문서에_반영한다() { + void 공유_쿠키_설정과_세션_조회의_CSRF_복구를_문서에_반영한다() { WebAuthProperties properties = new WebAuthProperties(Duration.ofMinutes(15), Duration.ofDays(90), true, "Lax", "example.test", "koin-stage-web"); Operation login = operation(); Operation csrf = new Operation().responses(new ApiResponses().addApiResponse("200", new ApiResponse())); OpenAPI openApi = new OpenAPI().paths(new Paths() .addPathItem("/v2/web/auth/login", new PathItem().post(login)) - .addPathItem("/v2/web/auth/csrf", new PathItem().get(csrf))); + .addPathItem("/v2/web/auth/session", new PathItem().get(csrf))); new WebAuthOpenApiCustomizer(properties).customise(openApi);