Skip to content

Commit 53bfeda

Browse files
committed
로그아웃
1 parent f16df88 commit 53bfeda

File tree

7 files changed

+172
-10
lines changed

7 files changed

+172
-10
lines changed

backend/src/main/java/com/backend/domain/user/controller/AuthController.java

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import com.backend.domain.user.service.EmailService;
44
import com.backend.domain.user.service.JwtService;
5+
import com.backend.domain.user.util.JwtUtil;
56
import com.backend.global.exception.ErrorCode;
67
import com.backend.global.response.ApiResponse;
78
import jakarta.mail.MessagingException;
89
import jakarta.servlet.http.Cookie;
10+
import jakarta.servlet.http.HttpServletRequest;
911
import jakarta.servlet.http.HttpServletResponse;
1012
import jakarta.validation.Valid;
1113
import jakarta.validation.constraints.Email;
@@ -21,6 +23,7 @@
2123
public class AuthController {
2224
private final EmailService emailService;
2325
private final JwtService jwtService;
26+
private final JwtUtil jwtUtil;
2427

2528
@Value("${jwt.access-token-expiration-in-milliseconds}")
2629
private int tokenValidityMilliSeconds;
@@ -43,6 +46,11 @@ public ApiResponse<String> sendAuthCode(@RequestBody SendRequest sendRequest) th
4346
return ApiResponse.success("이메일 인증 코드 발송 성공");
4447
}
4548

49+
/**
50+
* 인증코드 검증
51+
* @param email
52+
* @param code
53+
*/
4654

4755
record VerifyRequest(
4856
String email,
@@ -58,7 +66,7 @@ public ApiResponse<String> verifyAuthCode(@RequestBody VerifyRequest request)
5866
//인증 성공시
5967
return ApiResponse.success("이메일 인증 성공");
6068
}else{
61-
return ApiResponse.error(ErrorCode.VALIDATION_FAILED);
69+
return ApiResponse.error(ErrorCode.Email_verify_Failed);
6270
}
6371

6472
}
@@ -87,15 +95,67 @@ public ApiResponse<String> login(
8795
){
8896
String token = jwtService.login(loginRequest.email, loginRequest.password);
8997

90-
Cookie cookie = new Cookie("token", token);
91-
cookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 공격 방어)
92-
cookie.setSecure(true); //HTTPS 통신에서만 전송
98+
if(token != null) {
99+
Cookie cookie = new Cookie("token", token);
100+
cookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 공격 방어)
101+
cookie.setSecure(true); //HTTPS 통신에서만 전송
102+
cookie.setPath("/");
103+
104+
cookie.setMaxAge(tokenValidityMilliSeconds);
105+
106+
response.addCookie(cookie); //응답에 쿠키 추가
107+
108+
return ApiResponse.success("success");
109+
}else{
110+
return ApiResponse.error(ErrorCode.Login_Failed);
111+
}
112+
}
113+
114+
/**
115+
* 로그아웃
116+
*/
117+
118+
@PostMapping("/api/logout")
119+
public ApiResponse<String> logout(
120+
HttpServletRequest request,
121+
HttpServletResponse response
122+
) {
123+
//쿠키 만료 명령
124+
Cookie cookie = new Cookie("token", null);
125+
cookie.setHttpOnly(true);
126+
cookie.setSecure(true);
93127
cookie.setPath("/");
94128

95-
cookie.setMaxAge(tokenValidityMilliSeconds);
129+
cookie.setMaxAge(0);
130+
131+
response.addCookie(cookie);
132+
133+
//redis에 블랙리스트로 등록
134+
String jwtToken = getJwtToken(request);
135+
if(jwtToken != null) {
136+
long expiration = jwtUtil.getExpiration(jwtToken);
137+
if(expiration > 0) {
138+
jwtService.logout(jwtToken, expiration);
139+
}
140+
141+
}
96142

97-
response.addCookie(cookie); //응답에 쿠키 추가
98143

99144
return ApiResponse.success("success");
100145
}
146+
147+
public String getJwtToken(HttpServletRequest request){
148+
Cookie[] cookies = request.getCookies(); //
149+
if(cookies != null) {
150+
for(Cookie c : cookies) {
151+
if(c.getName().equals("token")) {
152+
return c.getValue();
153+
}
154+
}
155+
}
156+
return null;
157+
}
158+
159+
160+
101161
}

backend/src/main/java/com/backend/domain/user/service/JwtService.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.backend.domain.user.entity.User;
44
import com.backend.domain.user.repository.UserRepository;
55
import com.backend.domain.user.util.JwtUtil;
6+
import com.backend.domain.user.util.RedisUtil;
67
import jakarta.validation.constraints.Email;
78
import jakarta.validation.constraints.NotBlank;
89
import lombok.RequiredArgsConstructor;
@@ -13,6 +14,7 @@
1314
public class JwtService {
1415
private final UserRepository userRepository;
1516
private final JwtUtil jwtUtil;
17+
private final RedisUtil redisUtil;
1618

1719
public String login(@NotBlank(message = "이메일은 필수 입력값 입니다.") @Email(message = "이메일 형식이 아닙니다.") String email, @NotBlank(message = "비밀번호는 필수 입력값 입니다.") String password) {
1820
User user = userRepository.findByEmail(email).orElse(null);
@@ -23,4 +25,22 @@ public String login(@NotBlank(message = "이메일은 필수 입력값 입니다
2325
return null;
2426
}
2527
}
28+
29+
public boolean logout(String token, long expiration) {
30+
//jtw받아서 redis 블랙리스트에 추가
31+
String key = "jwt:blacklist:"+token;
32+
33+
redisUtil.setData(key, "logout", expiration);
34+
35+
return true;
36+
}
37+
38+
/**
39+
* 토큰이 블랙리스트에 있는지 확인합니다.
40+
*
41+
*/
42+
public boolean isBlacklisted(String token){
43+
String key = "jwt:blacklist:"+token;
44+
return redisUtil.hasKey(key);
45+
}
2646
}

backend/src/main/java/com/backend/domain/user/util/JwtUtil.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.backend.domain.user.util;
22

33
import io.jsonwebtoken.Claims;
4+
import io.jsonwebtoken.ExpiredJwtException;
45
import io.jsonwebtoken.Jwts;
56
import io.jsonwebtoken.SignatureAlgorithm;
67
import io.jsonwebtoken.security.Keys;
@@ -55,6 +56,35 @@ public Claims parseClaims(String token) {
5556
.build()
5657
.parseSignedClaims(token)
5758
.getPayload();
59+
}
60+
61+
//만료시간을 계산하기 위해 서명 검증은 하지 않고 Claims만 읽어오기
62+
private Claims getClaimsWithoutVerification(String token) {
63+
try{
64+
return Jwts.parser()
65+
.verifyWith(key)
66+
.build()
67+
.parseSignedClaims(token)
68+
.getPayload();
69+
}catch (ExpiredJwtException e){//만료된 토큰인 경우에도 Claims반환
70+
return e.getClaims();
71+
}
72+
}
73+
74+
//블랙리스트 용 만료시간(현재 시간 - 만료시간) 추출
75+
public long getExpiration(String token) {
76+
Claims claims = getClaimsWithoutVerification(token);
77+
78+
Date expiration = claims.getExpiration();
79+
80+
long now = new Date().getTime();
81+
long expirationTime = expiration.getTime();
82+
long remain = expirationTime - now;
5883

84+
//만료되었을 경우(현재시간이 만료시간을 넘은경우) 0 리턴
85+
if(remain < 0){
86+
return 0;
87+
}
88+
return remain;
5989
}
6090
}

backend/src/main/java/com/backend/domain/user/util/RedisUtil.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ public void setData(String key, String value, long duration) {
2323

2424
/**
2525
* Redis에서 데이터를 가져옵니다.
26-
* @param key 이메일 주소
2726
* @return 저장된 인증 코드
2827
*/
2928
public String getData(String key) {
@@ -38,4 +37,13 @@ public String getData(String key) {
3837
public Boolean deleteData(String key) {
3938
return redisTemplate.delete(key);
4039
}
40+
41+
/**
42+
* Redis에서 데이터가 있는지 확인만 합니다.
43+
* 값을 네트로워크로 전송하지 않아서 get()보다 오버헤드가 작습니다.
44+
*
45+
*/
46+
public Boolean hasKey(String key) {
47+
return redisTemplate.hasKey(key);
48+
}
4149
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ public enum ErrorCode {
1313
INVALID_TYPE_VALUE("CMN004", HttpStatus.BAD_REQUEST, "잘못된 타입의 값입니다."),
1414
MISSING_REQUEST_PARAMETER("CMN005", HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."),
1515

16+
// ========== user 도메인 에러 ==========
17+
Login_Failed("U001", HttpStatus.BAD_REQUEST, "로그인에 실패했습니다."),
18+
Email_verify_Failed("U002", HttpStatus.BAD_REQUEST, "이메일 인증코드가 일치하지 않습니다"),
19+
1620
// ========== analysis 도메인 에러 ==========
1721
INVALID_GITHUB_URL("A001", HttpStatus.BAD_REQUEST, "올바른 GitHub 저장소 URL이 아닙니다."),
1822
INVALID_REPOSITORY_PATH("A002", HttpStatus.BAD_REQUEST, "저장소 URL 형식이 잘못되었습니다. 예: https://github.com/{owner}/{repo}"),

backend/src/main/java/com/backend/global/security/JwtAuthenticationFilter.java

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.backend.global.security;
22

3+
import com.backend.domain.user.service.JwtService;
34
import com.backend.domain.user.util.JwtUtil;
45
import com.fasterxml.jackson.databind.ObjectMapper;
56
import io.jsonwebtoken.Claims;
@@ -19,25 +20,62 @@
1920
import java.time.LocalDateTime;
2021
import java.util.Collections;
2122
import java.util.HashMap;
23+
import java.util.List;
2224
import java.util.Map;
2325

2426
@RequiredArgsConstructor
2527
public class JwtAuthenticationFilter extends OncePerRequestFilter {
2628
private final JwtUtil jwtUtil;
29+
private final JwtService jwtService;
2730

31+
private record ExcludedRequest(String path, String method) {}
2832

2933
@Override
3034
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
35+
String requestURI = request.getRequestURI();
36+
String method = request.getMethod();
37+
38+
// JWT 검증이 필요 없는 URL (회원가입, 로그인, 이메일 인증코드 발송,이메일 인증코드 검증)
39+
List<ExcludedRequest> excludedRequests = List.of(
40+
new ExcludedRequest("/api/login", "POST"),
41+
new ExcludedRequest("/api/auth", "POST"),
42+
new ExcludedRequest("/api/verify", "POST"),
43+
new ExcludedRequest("/api/user", "POST")
44+
);
45+
46+
// 요청 경로 + 메서드가 일치하는 경우 필터 스킵
47+
boolean excluded = excludedRequests.stream()
48+
.anyMatch(ex -> requestURI.startsWith(ex.path()) && ex.method().equalsIgnoreCase(method));
49+
50+
if (excluded) {
51+
filterChain.doFilter(request, response);
52+
return;
53+
}
54+
55+
//
3156
String authorizationHeader = request.getHeader("Authorization");
32-
String token = null;
57+
System.out.println("AuthorizationHeader: " + authorizationHeader);
58+
3359

60+
String token = null;
61+
3462
//"Bearer " 제거
3563
if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")){
3664
token = authorizationHeader.substring(7);
3765
}
66+
//token이 null이거나 비어있다면 JWT가 입력되지 않은것으로 판단
67+
if(token==null||token.isEmpty()){
68+
sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "Token error : JWT가 입력되지 않았습니다.");
69+
return;
70+
}
3871

3972
//토큰이 있다면 검증 및 인증
4073
if(token != null) {
74+
//블랙리스트를 조회하여 토큰이 무효화 되었는지 확인
75+
if(jwtService.isBlacklisted(token)){
76+
sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "Token Expired : 토큰이 무효되어 있습니다.(로그아웃 상태입니다.)");
77+
return; //요청 차단
78+
}
4179
try {
4280
Claims claims = jwtUtil.parseClaims(token);
4381

@@ -70,6 +108,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
70108
filterChain.doFilter(request, response);
71109
}
72110

111+
//에러 발생시 메세지 처리
73112
private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
74113
response.setContentType("application/json;charset=UTF-8");
75114
response.setStatus(status);

backend/src/main/java/com/backend/global/security/SecurityConfig.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.backend.global.security;
22

3+
import com.backend.domain.user.service.JwtService;
34
import com.backend.domain.user.util.JwtUtil;
45
import lombok.RequiredArgsConstructor;
56
import org.springframework.context.annotation.Bean;
@@ -14,8 +15,8 @@
1415
@EnableWebSecurity
1516
@RequiredArgsConstructor
1617
public class SecurityConfig {
17-
1818
private final JwtUtil jwtUtil;
19+
private final JwtService jwtService;
1920

2021
@Bean
2122
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@@ -57,7 +58,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
5758
.formLogin(login -> login.disable())
5859
.httpBasic(basic -> basic.disable())
5960
//커스텀 JWT 필터를 등록
60-
.addFilterBefore(new JwtAuthenticationFilter(jwtUtil),
61+
.addFilterBefore(new JwtAuthenticationFilter(jwtUtil, jwtService),
6162
UsernamePasswordAuthenticationFilter.class);
6263

6364
return http.build();

0 commit comments

Comments
 (0)