Skip to content
Merged
26 changes: 22 additions & 4 deletions docs/web-cookie-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 처리한 비밀번호를 전달한다.
Expand All @@ -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를 방어한다.

Expand All @@ -36,7 +37,7 @@ CSRF 쿠키가 없으면 `/csrf`를 호출해 복구한다. 단순 새로고침
5. access 만료 시 `/refresh`를 한 번 호출한 뒤 원래 요청을 재시도한다.
6. 로그아웃은 서버 `/logout`의 성공을 확인한 뒤 화면의 로그인 상태를 비운다.

로그인·재발급·로그아웃·CSRF 조회는 만료된 access 쿠키의 영향을 받지 않는다.
로그인·재발급·로그아웃·세션 조회는 만료된 access 쿠키의 영향을 받지 않는다.
일반 기능 API에서는 명시적인 `Authorization` 헤더가 있으면 기존 헤더 인증만 사용한다.
잘못된 헤더를 쿠키 인증으로 대체하지 않으므로, 웹 전환 시 예전 헤더 주입 코드도 제거해야 한다.
기존 앱 토큰을 웹 쿠키로 넣거나 웹 토큰을 기존 앱 인증 경로로 전달하는 것은 허용하지 않는다.
Expand All @@ -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`) 기본값 |
Expand Down Expand Up @@ -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이다. 실패 응답은 쿠키를 삭제하거나 덮어쓰지 않는다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,6 +58,24 @@ ResponseEntity<UserResponse> 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<UserProfileResponse> getProfile(
@Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId
);

@ApiResponses(
value = {
@ApiResponse(responseCode = "200"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,6 +51,14 @@ public ResponseEntity<UserResponse> getUser(
return ResponseEntity.ok().body(userResponse);
}

@GetMapping("/v3/users/me")
public ResponseEntity<UserProfileResponse> getProfile(
@Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId
) {
UserProfileResponse response = userService.getProfile(userId);
return ResponseEntity.ok().body(response);
}

@GetMapping("/user/auth")
public ResponseEntity<UserTypeResponse> getUserType(
@Auth(permit = {GENERAL, STUDENT, OWNER, COOP, COUNCIL}) Integer userId
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
5 changes: 5 additions & 0 deletions src/main/java/in/koreatech/koin/domain/user/model/User.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -254,4 +255,8 @@ public String getDisplayNickname() {
}
return "익명 사용자";
}

public boolean isKoinStudent() {
return KOIN_STUDENT_TYPES.contains(this.getUserType());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,18 +94,19 @@ ResponseEntity<Void> 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<WebCsrfTokenResponse> getCsrfToken(HttpServletRequest request, HttpServletResponse response);
schema = @Schema(implementation = WebSessionResponse.class)))
@ApiResponseCodes({ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.INTERNAL_SERVER_ERROR})
@GetMapping("/session")
ResponseEntity<WebSessionResponse> getSession(HttpServletRequest request, HttpServletResponse response);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -64,10 +66,18 @@ public ResponseEntity<Void> logout(
return ResponseEntity.noContent().build();
}

@GetMapping("/csrf")
public ResponseEntity<WebCsrfTokenResponse> 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<WebSessionResponse> 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());
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading