Skip to content

Commit e89ae14

Browse files
authored
main merge to publish
main merge to publish
2 parents 98e3758 + a3336c5 commit e89ae14

25 files changed

Lines changed: 622 additions & 232 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ JWT_ISSUER=bootsignal
3535
JWT_SECRET=replace-this-with-a-strong-secret-at-least-32-bytes
3636
JWT_ACCESS_TOKEN_VALIDITY_SECONDS=3600
3737
JWT_REFRESH_TOKEN_VALIDITY_SECONDS=1209600
38+
# JWT 쿠키 보안 정책. 운영 HTTPS 환경은 Secure=true를 사용한다.
39+
JWT_COOKIE_SECURE=false
40+
JWT_COOKIE_SAME_SITE=Lax
3841

3942
# Password reset and mail settings
4043
PASSWORD_RESET_VALIDITY_SECONDS=1800

src/main/java/com/bootsignal/domain/auth/controller/AuthController.java

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
import com.bootsignal.domain.auth.dto.SignupRequest;
1414
import com.bootsignal.domain.auth.dto.SignupResponse;
1515
import com.bootsignal.domain.auth.service.AuthService;
16+
import com.bootsignal.global.security.jwt.JwtTokenCookieManager;
17+
import jakarta.servlet.http.HttpServletRequest;
18+
import jakarta.servlet.http.HttpServletResponse;
1619
import jakarta.validation.Valid;
1720
import jakarta.validation.constraints.Email;
1821
import jakarta.validation.constraints.NotBlank;
@@ -22,14 +25,14 @@
2225
import org.springframework.validation.annotation.Validated;
2326
import org.springframework.web.bind.annotation.GetMapping;
2427
import org.springframework.web.bind.annotation.PostMapping;
25-
import org.springframework.web.bind.annotation.RequestParam;
2628
import org.springframework.web.bind.annotation.RequestBody;
2729
import org.springframework.web.bind.annotation.RequestMapping;
30+
import org.springframework.web.bind.annotation.RequestParam;
2831
import org.springframework.web.bind.annotation.ResponseStatus;
2932
import org.springframework.web.bind.annotation.RestController;
3033

3134
/**
32-
* 회원가입, 로그인, 소셜 로그인, 토큰 관리, 비밀번호 찾기 API를 제공하는 인증 컨트롤러입니다.
35+
* 회원가입, 로그인, 소셜 로그인, HttpOnly 쿠키 기반 토큰 관리, 비밀번호 찾기 API를 제공하는 인증 컨트롤러입니다.
3336
*/
3437
@RestController
3538
@RequestMapping("/api/auth")
@@ -38,6 +41,7 @@
3841
public class AuthController {
3942

4043
private final AuthService authService;
44+
private final JwtTokenCookieManager jwtTokenCookieManager;
4145

4246
@GetMapping("/check-email")
4347
public EmailAvailabilityResponse checkEmail(
@@ -57,23 +61,32 @@ public SignupResponse signup(@Valid @RequestBody SignupRequest request) {
5761
}
5862

5963
@PostMapping("/login")
60-
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
61-
return authService.login(request);
64+
public LoginResponse login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
65+
LoginResponse loginResponse = authService.login(request);
66+
jwtTokenCookieManager.addTokenCookies(response, loginResponse);
67+
return loginResponse;
6268
}
6369

6470
@PostMapping("/google/login")
65-
public LoginResponse googleLogin(@Valid @RequestBody GoogleLoginRequest request) {
66-
return authService.googleLogin(request);
71+
public LoginResponse googleLogin(@Valid @RequestBody GoogleLoginRequest request, HttpServletResponse response) {
72+
LoginResponse loginResponse = authService.googleLogin(request);
73+
jwtTokenCookieManager.addTokenCookies(response, loginResponse);
74+
return loginResponse;
6775
}
6876

6977
@PostMapping("/kakao/login")
70-
public LoginResponse kakaoLogin(@Valid @RequestBody KakaoLoginRequest request) {
71-
return authService.kakaoLogin(request);
78+
public LoginResponse kakaoLogin(@Valid @RequestBody KakaoLoginRequest request, HttpServletResponse response) {
79+
LoginResponse loginResponse = authService.kakaoLogin(request);
80+
jwtTokenCookieManager.addTokenCookies(response, loginResponse);
81+
return loginResponse;
7282
}
7383

7484
@PostMapping("/refresh")
75-
public LoginResponse refresh(@Valid @RequestBody RefreshTokenRequest request) {
76-
return authService.refresh(request);
85+
public LoginResponse refresh(HttpServletRequest request, HttpServletResponse response) {
86+
String refreshToken = jwtTokenCookieManager.requireRefreshToken(request);
87+
LoginResponse loginResponse = authService.refresh(new RefreshTokenRequest(refreshToken));
88+
jwtTokenCookieManager.addTokenCookies(response, loginResponse);
89+
return loginResponse;
7790
}
7891

7992
@PostMapping("/password/forgot")
@@ -88,7 +101,12 @@ public AuthActionResponse resetPassword(@Valid @RequestBody PasswordResetRequest
88101

89102
@PostMapping("/logout")
90103
@ResponseStatus(HttpStatus.NO_CONTENT)
91-
public void logout(@Valid @RequestBody RefreshTokenRequest request) {
92-
authService.logout(request);
104+
public void logout(HttpServletRequest request, HttpServletResponse response) {
105+
try {
106+
String refreshToken = jwtTokenCookieManager.requireRefreshToken(request);
107+
authService.logout(new RefreshTokenRequest(refreshToken));
108+
} finally {
109+
jwtTokenCookieManager.clearTokenCookies(response);
110+
}
93111
}
94112
}

src/main/java/com/bootsignal/domain/auth/dto/LoginResponse.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@
77
import com.fasterxml.jackson.annotation.JsonIgnore;
88

99
/**
10-
* 로그인과 토큰 재발급 성공 시 사용자 정보와 JWT 토큰 정보를 함께 반환하는 DTO입니다.
10+
* 로그인과 토큰 재발급 성공 시 쿠키 발급에 필요한 JWT와 응답 본문의 사용자 정보를 함께 운반하는 DTO입니다.
1111
*/
1212
public record LoginResponse(
13+
@JsonIgnore
1314
String accessToken,
15+
@JsonIgnore
1416
String tokenType,
17+
@JsonIgnore
1518
long expiresIn,
19+
@JsonIgnore
1620
String refreshToken,
21+
@JsonIgnore
1722
long refreshTokenExpiresIn,
1823
AuthUserResponse user
1924
) {

src/main/java/com/bootsignal/domain/calendar/controller/CalendarController.java

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
package com.bootsignal.domain.calendar.controller;
22

3+
import com.bootsignal.domain.calendar.dto.CalendarConnectResponse;
34
import com.bootsignal.domain.calendar.dto.CalendarEventListResponse;
45
import com.bootsignal.domain.calendar.dto.CalendarStatusResponse;
56
import com.bootsignal.domain.calendar.service.CalendarEventQueryService;
67
import com.bootsignal.domain.calendar.service.CalendarService;
78
import com.bootsignal.global.exception.BootSignalException;
8-
import jakarta.servlet.http.HttpServletResponse;
9-
import java.io.IOException;
109
import java.net.URLEncoder;
1110
import java.nio.charset.StandardCharsets;
1211
import java.net.URI;
@@ -34,14 +33,11 @@ public CalendarStatusResponse getStatus() {
3433
return calendarService.getStatus();
3534
}
3635

37-
/* Google Calendar OAuth 연동 시작 */
36+
/* Google Calendar OAuth 연동 시작 (redirect URL JSON 반환) */
3837
@GetMapping("/connect/google")
39-
public void startGoogleConnect(HttpServletResponse response) throws IOException {
40-
// 구글 캘린더 OAuth 인증 URL 생성
38+
public CalendarConnectResponse startGoogleConnect() {
4139
String authorizationUrl = calendarService.startGoogleConnect();
42-
43-
// 리다이렉트 응답 전송
44-
response.sendRedirect(authorizationUrl);
40+
return CalendarConnectResponse.of(authorizationUrl);
4541
}
4642

4743
/* Google Calendar OAuth callback (연동 -> 구글 -> 콜백) */
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.bootsignal.domain.calendar.dto;
2+
3+
public record CalendarConnectResponse(
4+
String redirectUrl
5+
) {
6+
7+
public static CalendarConnectResponse of(String redirectUrl) {
8+
return new CalendarConnectResponse(redirectUrl);
9+
}
10+
}

src/main/java/com/bootsignal/domain/report/entity/Report.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public class Report extends BaseEntity {
5151
private ReportStatus status = ReportStatus.PENDING;
5252

5353
@Enumerated(EnumType.STRING)
54+
@Column(length = 20)
5455
private ReportAction action;
5556

5657
private String processReason;

src/main/java/com/bootsignal/global/config/SecurityConfig.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import com.bootsignal.global.config.properties.CorsProperties;
44
import com.bootsignal.global.exception.BootSignalException;
55
import com.bootsignal.global.exception.ErrorCode;
6+
import com.bootsignal.global.security.jwt.JwtCsrfTokenFilter;
67
import com.bootsignal.global.security.jwt.JwtAuthenticationFilter;
8+
import com.bootsignal.global.security.jwt.JwtTokenCookieManager;
79
import com.bootsignal.global.security.jwt.JwtTokenProvider;
810
import java.util.List;
911
import java.util.Objects;
@@ -28,7 +30,7 @@
2830
import org.springframework.web.servlet.HandlerExceptionResolver;
2931

3032
/**
31-
* JWT 기반 인증, CORS, 공개/보호 API 경계를 설정하는 보안 구성입니다.
33+
* JWT 헤더/쿠키 기반 인증, CSRF 방어, CORS, 공개/보호 API 경계를 설정하는 보안 구성입니다.
3234
*/
3335
@Configuration
3436
@EnableWebSecurity
@@ -41,10 +43,12 @@ public class SecurityConfig {
4143
@Bean
4244
public SecurityFilterChain securityFilterChain(
4345
HttpSecurity http,
46+
JwtCsrfTokenFilter jwtCsrfTokenFilter,
4447
JwtAuthenticationFilter jwtAuthenticationFilter,
4548
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver
4649
) throws Exception {
4750
return http
51+
// JWT 쿠키 인증은 별도 double-submit CSRF 필터(JwtCsrfTokenFilter)로 검증한다.
4852
.csrf(AbstractHttpConfigurer::disable)
4953
.httpBasic(AbstractHttpConfigurer::disable)
5054
.formLogin(AbstractHttpConfigurer::disable)
@@ -95,17 +99,27 @@ public SecurityFilterChain securityFilterChain(
9599
)
96100
)
97101
)
98-
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
102+
.addFilterBefore(jwtCsrfTokenFilter, UsernamePasswordAuthenticationFilter.class)
103+
.addFilterAfter(jwtAuthenticationFilter, JwtCsrfTokenFilter.class)
99104
.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin()))
100105
.build();
101106
}
102107

108+
@Bean
109+
public JwtCsrfTokenFilter jwtCsrfTokenFilter(
110+
JwtTokenCookieManager jwtTokenCookieManager,
111+
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver
112+
) {
113+
return new JwtCsrfTokenFilter(jwtTokenCookieManager, handlerExceptionResolver);
114+
}
115+
103116
@Bean
104117
public JwtAuthenticationFilter jwtAuthenticationFilter(
105118
JwtTokenProvider jwtTokenProvider,
119+
JwtTokenCookieManager jwtTokenCookieManager,
106120
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver
107121
) {
108-
return new JwtAuthenticationFilter(jwtTokenProvider, handlerExceptionResolver);
122+
return new JwtAuthenticationFilter(jwtTokenProvider, jwtTokenCookieManager, handlerExceptionResolver);
109123
}
110124

111125
@Bean

src/main/java/com/bootsignal/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public enum ErrorCode {
1010
REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "REFRESH_TOKEN_EXPIRED", "만료된 Refresh Token입니다."),
1111
REFRESH_TOKEN_REVOKED(HttpStatus.UNAUTHORIZED, "REFRESH_TOKEN_REVOKED", "이미 폐기된 Refresh Token입니다."),
1212
REFRESH_TOKEN_REUSED(HttpStatus.UNAUTHORIZED, "REFRESH_TOKEN_REUSED", "이미 사용된 Refresh Token입니다."),
13+
CSRF_TOKEN_INVALID(HttpStatus.FORBIDDEN, "CSRF_TOKEN_INVALID", "유효하지 않은 CSRF Token입니다."),
1314
BAD_REQUEST(HttpStatus.BAD_REQUEST, "BAD_REQUEST", "잘못된 요청입니다."),
1415
VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", "요청 값이 올바르지 않습니다."),
1516
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "로그인이 필요합니다."),

src/main/java/com/bootsignal/global/security/jwt/JwtAuthenticationFilter.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,25 @@
1515
import org.springframework.web.servlet.HandlerExceptionResolver;
1616

1717
/**
18-
* Authorization 헤더의 Access Token을 검증해 SecurityContext에 인증 정보를 설정하는 필터입니다.
19-
* 인증 API는 Refresh Token 본문 검증 흐름과 충돌하지 않도록 필터 대상에서 제외합니다.
18+
* Authorization 헤더 또는 HttpOnly 쿠키의 Access Token을 검증해 SecurityContext에 인증 정보를 설정하는 필터입니다.
19+
* 인증 API는 로그인과 Refresh Token 쿠키 처리 흐름과 충돌하지 않도록 필터 대상에서 제외합니다.
2020
*/
2121
public class JwtAuthenticationFilter extends OncePerRequestFilter {
2222

2323
private static final String AUTH_API_PREFIX = "/api/auth/";
2424
private static final String BEARER_PREFIX = "Bearer ";
2525

2626
private final JwtTokenProvider jwtTokenProvider;
27+
private final JwtTokenCookieManager jwtTokenCookieManager;
2728
private final HandlerExceptionResolver handlerExceptionResolver;
2829

2930
public JwtAuthenticationFilter(
3031
JwtTokenProvider jwtTokenProvider,
32+
JwtTokenCookieManager jwtTokenCookieManager,
3133
HandlerExceptionResolver handlerExceptionResolver
3234
) {
3335
this.jwtTokenProvider = jwtTokenProvider;
36+
this.jwtTokenCookieManager = jwtTokenCookieManager;
3437
this.handlerExceptionResolver = handlerExceptionResolver;
3538
}
3639

@@ -67,7 +70,7 @@ protected void doFilterInternal(
6770
private String resolveToken(HttpServletRequest request) {
6871
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
6972
if (!StringUtils.hasText(authorizationHeader)) {
70-
return null;
73+
return jwtTokenCookieManager.resolveAccessToken(request);
7174
}
7275
if (!authorizationHeader.startsWith(BEARER_PREFIX)) {
7376
throw new BootSignalException(ErrorCode.UNAUTHORIZED, "Bearer 인증 토큰이 필요합니다.");
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.bootsignal.global.security.jwt;
2+
3+
import com.bootsignal.global.exception.BootSignalException;
4+
import com.bootsignal.global.exception.ErrorCode;
5+
import jakarta.servlet.FilterChain;
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import java.io.IOException;
10+
import java.nio.charset.StandardCharsets;
11+
import java.security.MessageDigest;
12+
import java.util.Set;
13+
import org.springframework.http.HttpHeaders;
14+
import org.springframework.util.StringUtils;
15+
import org.springframework.web.filter.OncePerRequestFilter;
16+
import org.springframework.web.servlet.HandlerExceptionResolver;
17+
18+
/**
19+
* 쿠키 기반 JWT 인증 요청의 CSRF 토큰을 검증해 브라우저 자동 쿠키 전송 공격을 차단하는 필터입니다.
20+
*/
21+
public class JwtCsrfTokenFilter extends OncePerRequestFilter {
22+
23+
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
24+
private static final String AUTH_API_PREFIX = "/api/auth/";
25+
private static final String REFRESH_API_PATH = "/api/auth/refresh";
26+
private static final String LOGOUT_API_PATH = "/api/auth/logout";
27+
28+
private final JwtTokenCookieManager jwtTokenCookieManager;
29+
private final HandlerExceptionResolver handlerExceptionResolver;
30+
31+
public JwtCsrfTokenFilter(
32+
JwtTokenCookieManager jwtTokenCookieManager,
33+
HandlerExceptionResolver handlerExceptionResolver
34+
) {
35+
this.jwtTokenCookieManager = jwtTokenCookieManager;
36+
this.handlerExceptionResolver = handlerExceptionResolver;
37+
}
38+
39+
@Override
40+
protected void doFilterInternal(
41+
HttpServletRequest request,
42+
HttpServletResponse response,
43+
FilterChain filterChain
44+
) throws ServletException, IOException {
45+
try {
46+
validateCsrfTokenIfNeeded(request);
47+
filterChain.doFilter(request, response);
48+
} catch (BootSignalException exception) {
49+
handlerExceptionResolver.resolveException(request, response, null, exception);
50+
}
51+
}
52+
53+
private void validateCsrfTokenIfNeeded(HttpServletRequest request) {
54+
if (SAFE_METHODS.contains(request.getMethod())) {
55+
return;
56+
}
57+
if (StringUtils.hasText(request.getHeader(HttpHeaders.AUTHORIZATION))) {
58+
return;
59+
}
60+
if (isPublicAuthApi(request)) {
61+
return;
62+
}
63+
if (!jwtTokenCookieManager.hasAuthCookie(request)) {
64+
return;
65+
}
66+
67+
String csrfCookie = jwtTokenCookieManager.resolveCsrfToken(request);
68+
String csrfHeader = request.getHeader(JwtTokenCookieManager.CSRF_TOKEN_HEADER_NAME);
69+
if (!StringUtils.hasText(csrfCookie) || !matches(csrfCookie, csrfHeader)) {
70+
throw new BootSignalException(ErrorCode.CSRF_TOKEN_INVALID);
71+
}
72+
}
73+
74+
private boolean isPublicAuthApi(HttpServletRequest request) {
75+
String requestPath = request.getRequestURI();
76+
String contextPath = request.getContextPath();
77+
if (StringUtils.hasText(contextPath) && requestPath.startsWith(contextPath)) {
78+
requestPath = requestPath.substring(contextPath.length());
79+
}
80+
return requestPath.startsWith(AUTH_API_PREFIX)
81+
&& !REFRESH_API_PATH.equals(requestPath)
82+
&& !LOGOUT_API_PATH.equals(requestPath);
83+
}
84+
85+
private boolean matches(String expected, String actual) {
86+
if (!StringUtils.hasText(actual)) {
87+
return false;
88+
}
89+
return MessageDigest.isEqual(
90+
expected.getBytes(StandardCharsets.UTF_8),
91+
actual.getBytes(StandardCharsets.UTF_8)
92+
);
93+
}
94+
}

0 commit comments

Comments
 (0)