Skip to content

Commit 6be7455

Browse files
Merge pull request #17 from SoongSilComputingClub/feat/#16-socialsuccesshandler-implementation-and-jwtservice
[Feat/#16] socialsuccesshandler 및 jwtservice 구현
2 parents 6a34f33 + 8411567 commit 6be7455

11 files changed

Lines changed: 376 additions & 0 deletions

File tree

src/main/java/com/example/ssccwebbe/domain/preuser/repository/PreUserRefreshRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,11 @@
77
import com.example.ssccwebbe.domain.preuser.entity.PreUserRefreshEntity;
88

99
public interface PreUserRefreshRepository extends JpaRepository<PreUserRefreshEntity, Long> {
10+
Boolean existsByRefresh(String refreshToken);
11+
12+
void deleteByRefresh(String refresh);
13+
14+
void deleteByUsername(String username);
15+
1016
void deleteByCreatedDateBefore(LocalDateTime createdDateBefore);
1117
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.example.ssccwebbe.global.security.handler;
2+
3+
import java.io.IOException;
4+
import java.util.Map;
5+
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.http.Cookie;
8+
import jakarta.servlet.http.HttpServletRequest;
9+
import jakarta.servlet.http.HttpServletResponse;
10+
11+
import org.springframework.beans.factory.annotation.Qualifier;
12+
import org.springframework.beans.factory.annotation.Value;
13+
import org.springframework.security.core.Authentication;
14+
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
15+
import org.springframework.stereotype.Component;
16+
17+
import com.example.ssccwebbe.global.security.UserRoleType;
18+
import com.example.ssccwebbe.global.security.jwt.service.JwtService;
19+
import com.example.ssccwebbe.global.security.jwt.util.JwtUtil;
20+
21+
@Component
22+
@Qualifier("SocialSuccessHandler")
23+
public class SocialSuccessHandler implements AuthenticationSuccessHandler {
24+
25+
private final Map<UserRoleType, JwtService> jwtServiceMap;
26+
27+
@Value("${frontend.url}")
28+
private String frontendUrl;
29+
30+
@Value("${frontend.cookie.secure}")
31+
private boolean cookieSecure;
32+
33+
public SocialSuccessHandler(@Qualifier("preJwtService") JwtService preJwtService) {
34+
// JWT Service 매핑 (Strategy 패턴)
35+
this.jwtServiceMap =
36+
Map.of(
37+
UserRoleType.PREUSER, preJwtService
38+
// UserRoleType.USER, userJwtService // 나중에 추가
39+
// UserRoleType.ADMIN, adminJwtService // 나중에 추가
40+
);
41+
}
42+
43+
// 소셜 로그인 성공시 동작 메서드
44+
@Override
45+
public void onAuthenticationSuccess(
46+
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
47+
throws IOException, ServletException {
48+
49+
// username, role
50+
String username = authentication.getName();
51+
String role = authentication.getAuthorities().iterator().next().getAuthority();
52+
53+
// UserRoleType 변환 및 JwtService 선택 (Strategy 패턴)
54+
UserRoleType roleType = parseRoleType(role);
55+
JwtService jwtService =
56+
jwtServiceMap.getOrDefault(
57+
roleType, jwtServiceMap.get(UserRoleType.PREUSER)); // 기본값: PREUSER
58+
59+
// JWT(Refresh) 발급 => 소셜 로그인의 경우 브라우저 리다이렉트 방식으로 토큰 발급이 쿠키 방식으로만 가능
60+
String refreshToken = JwtUtil.createJwt(username, "ROLE_" + role, false);
61+
62+
// 발급한 Refresh DB 테이블 저장 (Refresh whitelist)
63+
jwtService.addRefresh(username, refreshToken);
64+
65+
// 응답
66+
Cookie refreshCookie = new Cookie("refreshToken", refreshToken);
67+
refreshCookie.setHttpOnly(true);
68+
refreshCookie.setSecure(cookieSecure);
69+
refreshCookie.setPath("/");
70+
refreshCookie.setMaxAge(10); // 10초 (프론트에서 발급 후 바로 헤더 전환 로직 진행 예정)
71+
72+
response.addCookie(refreshCookie);
73+
response.sendRedirect(frontendUrl + "/cookie"); // 프론트 주소로 redirect
74+
}
75+
76+
/** "ROLE_PREUSER" -> UserRoleType.PREUSER 변환 */
77+
private UserRoleType parseRoleType(String roleString) {
78+
String role = roleString.replace("ROLE_", ""); // "ROLE_PREUSER" -> "PREUSER"
79+
try {
80+
return UserRoleType.valueOf(role); // "PREUSER" -> UserRoleType.PREUSER
81+
} catch (IllegalArgumentException e) {
82+
return UserRoleType.PREUSER; // 기본값
83+
}
84+
}
85+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.example.ssccwebbe.global.security.jwt.code;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import com.example.ssccwebbe.global.apipayload.code.error.ErrorCode;
6+
7+
import lombok.AllArgsConstructor;
8+
import lombok.Getter;
9+
10+
@Getter
11+
@AllArgsConstructor
12+
public enum JwtErrorCode implements ErrorCode {
13+
14+
// JWT 관련 401 UNAUTHORIZED 에러
15+
COOKIE_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT4001", "쿠키가 존재하지 않습니다."),
16+
REFRESH_TOKEN_COOKIE_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT4002", "refreshToken 쿠키가 없습니다."),
17+
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "JWT4003", "유효하지 않은 refreshToken입니다."),
18+
REFRESH_TOKEN_NOT_IN_WHITELIST(
19+
HttpStatus.UNAUTHORIZED, "JWT4004", "화이트리스트에 없는 refreshToken입니다."),
20+
21+
// JWT 관련 403 FORBIDDEN 에러
22+
EXPIRED_TOKEN(HttpStatus.FORBIDDEN, "JWT4031", "만료된 토큰입니다."),
23+
INVALID_TOKEN(HttpStatus.FORBIDDEN, "JWT4032", "유효하지 않은 토큰입니다.");
24+
25+
private final HttpStatus httpStatus;
26+
private final String code;
27+
private final String message;
28+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.example.ssccwebbe.global.security.jwt.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
import lombok.ToString;
6+
7+
@AllArgsConstructor
8+
@Getter
9+
@ToString
10+
public class JwtResponseDto {
11+
private final String accessToken;
12+
private final String refreshToken;
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.example.ssccwebbe.global.security.jwt.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
import lombok.Getter;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
public class RefreshRequestDto {
11+
12+
@NotBlank private String refreshToken;
13+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.example.ssccwebbe.global.security.jwt.service;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
6+
import com.example.ssccwebbe.global.security.jwt.dto.JwtResponseDto;
7+
import com.example.ssccwebbe.global.security.jwt.dto.RefreshRequestDto;
8+
9+
public interface JwtService {
10+
11+
/**
12+
* 소셜 로그인 성공 후 쿠키로 발급해준 Refresh 토큰을 Refresh 토큰과 Access 토큰을 헤더에 담아 한번에 재발급 해주는 메서드
13+
*
14+
* @param request HTTP 요청
15+
* @param response HTTP 응답
16+
* @return JWT 응답 (Access Token, Refresh Token)
17+
*/
18+
JwtResponseDto cookie2Header(HttpServletRequest request, HttpServletResponse response);
19+
20+
/**
21+
* Refresh 토큰으로 새로운 Access 토큰과 Refresh 토큰을 재발급해주는 로직 (Rotate 포함)
22+
*
23+
* @param dto Refresh 요청 DTO
24+
* @return JWT 응답 (Access Token, Refresh Token)
25+
*/
26+
JwtResponseDto refreshRotate(RefreshRequestDto dto);
27+
28+
/**
29+
* JWT Refresh 토큰 발급 후 저장 메소드
30+
*
31+
* @param username 사용자명
32+
* @param refreshToken Refresh 토큰
33+
*/
34+
void addRefresh(String username, String refreshToken);
35+
36+
/**
37+
* JWT Refresh 존재 확인 메소드
38+
*
39+
* @param refreshToken Refresh 토큰
40+
* @return 존재 여부
41+
*/
42+
Boolean existsRefresh(String refreshToken);
43+
44+
/**
45+
* JWT Refresh 토큰 삭제 메소드
46+
*
47+
* @param refreshToken Refresh 토큰
48+
*/
49+
void removeRefresh(String refreshToken);
50+
51+
/**
52+
* 특정 유저 Refresh 토큰 모두 삭제 (탈퇴)
53+
*
54+
* @param username 사용자명
55+
*/
56+
void removeRefreshUser(String username);
57+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.example.ssccwebbe.global.security.jwt.service;
2+
3+
import jakarta.servlet.http.Cookie;
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import jakarta.servlet.http.HttpServletResponse;
6+
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
import com.example.ssccwebbe.domain.preuser.entity.PreUserRefreshEntity;
12+
import com.example.ssccwebbe.domain.preuser.repository.PreUserRefreshRepository;
13+
import com.example.ssccwebbe.global.apipayload.exception.GeneralException;
14+
import com.example.ssccwebbe.global.security.jwt.code.JwtErrorCode;
15+
import com.example.ssccwebbe.global.security.jwt.dto.JwtResponseDto;
16+
import com.example.ssccwebbe.global.security.jwt.dto.RefreshRequestDto;
17+
import com.example.ssccwebbe.global.security.jwt.util.JwtUtil;
18+
19+
import lombok.RequiredArgsConstructor;
20+
21+
@Service("preJwtService")
22+
@RequiredArgsConstructor
23+
public class PreJwtServiceImpl implements JwtService {
24+
25+
private final PreUserRefreshRepository preUserRefreshRepository;
26+
27+
@Value("${frontend.cookie.secure}")
28+
private boolean cookieSecure;
29+
30+
// 소셜 로그인 성공 후 쿠키로 발급해준 Refresh 토큰을 Refresh 토큰과 Access 토큰을 헤더에 담아 한번에 재발급 해주는 메서드
31+
@Transactional
32+
public JwtResponseDto cookie2Header(HttpServletRequest request, HttpServletResponse response) {
33+
34+
// 쿠키 리스트
35+
Cookie[] cookies = request.getCookies();
36+
37+
// 쿠키가 없는 경우
38+
if (cookies == null) {
39+
throw new GeneralException(JwtErrorCode.COOKIE_NOT_FOUND);
40+
}
41+
42+
// Refresh 토큰 획득
43+
String refreshToken = null;
44+
for (Cookie cookie : cookies) {
45+
if ("refreshToken".equals(cookie.getName())) {
46+
refreshToken = cookie.getValue();
47+
break;
48+
}
49+
}
50+
51+
// Refresh 토큰이 없는 경우
52+
if (refreshToken == null) {
53+
throw new GeneralException(JwtErrorCode.REFRESH_TOKEN_COOKIE_NOT_FOUND);
54+
}
55+
56+
// Refresh 토큰 검증
57+
Boolean isValid = JwtUtil.isValid(refreshToken, false);
58+
if (!isValid) {
59+
throw new GeneralException(JwtErrorCode.INVALID_REFRESH_TOKEN);
60+
}
61+
62+
// 정보 추출
63+
String username = JwtUtil.getUsername(refreshToken);
64+
String role = JwtUtil.getRole(refreshToken);
65+
66+
// 토큰 생성
67+
String newAccessToken = JwtUtil.createJwt(username, role, true);
68+
String newRefreshToken = JwtUtil.createJwt(username, role, false);
69+
70+
// 기존 Refresh 토큰 DB 삭제 후 신규 추가
71+
PreUserRefreshEntity newRefreshEntity =
72+
PreUserRefreshEntity.builder().username(username).refresh(newRefreshToken).build();
73+
74+
removeRefresh(refreshToken);
75+
preUserRefreshRepository.flush(); // 같은 트랜잭션 내부라 : 삭제 -> 생성 문제 해결
76+
preUserRefreshRepository.save(newRefreshEntity);
77+
78+
// 기존 쿠키 제거
79+
Cookie refreshCookie = new Cookie("refreshToken", null);
80+
refreshCookie.setHttpOnly(true);
81+
refreshCookie.setSecure(cookieSecure);
82+
refreshCookie.setPath("/");
83+
refreshCookie.setMaxAge(10);
84+
response.addCookie(refreshCookie);
85+
86+
return new JwtResponseDto(newAccessToken, newRefreshToken);
87+
}
88+
89+
// Refresh 토큰으로 새로운 Access 토큰 과 Refresh 토큰을 재발급해주는 로직 (Rotate 포함)
90+
@Transactional
91+
public JwtResponseDto refreshRotate(RefreshRequestDto dto) {
92+
93+
String refreshToken = dto.getRefreshToken();
94+
95+
// Refresh 토큰 검증
96+
Boolean isValid = JwtUtil.isValid(refreshToken, false);
97+
if (!isValid) {
98+
throw new GeneralException(JwtErrorCode.INVALID_REFRESH_TOKEN);
99+
}
100+
101+
// RefreshEntity 존재 확인 (화이트리스트)
102+
if (!existsRefresh(refreshToken)) {
103+
throw new GeneralException(JwtErrorCode.REFRESH_TOKEN_NOT_IN_WHITELIST);
104+
}
105+
106+
// 정보 추출
107+
String username = JwtUtil.getUsername(refreshToken);
108+
String role = JwtUtil.getRole(refreshToken);
109+
110+
// 토큰 생성
111+
String newAccessToken = JwtUtil.createJwt(username, role, true);
112+
String newRefreshToken = JwtUtil.createJwt(username, role, false);
113+
114+
// 기존 Refresh 토큰 DB 삭제 후 신규 추가
115+
PreUserRefreshEntity newRefreshEntity =
116+
PreUserRefreshEntity.builder().username(username).refresh(newRefreshToken).build();
117+
118+
removeRefresh(refreshToken);
119+
preUserRefreshRepository.save(newRefreshEntity);
120+
121+
return new JwtResponseDto(newAccessToken, newRefreshToken);
122+
}
123+
124+
// JWT Refresh 토큰 발급 후 저장 메소드
125+
@Transactional
126+
public void addRefresh(String username, String refreshToken) {
127+
PreUserRefreshEntity entity =
128+
PreUserRefreshEntity.builder().username(username).refresh(refreshToken).build();
129+
130+
preUserRefreshRepository.save(entity);
131+
}
132+
133+
// JWT Refresh 존재 확인 메소드
134+
@Transactional(readOnly = true)
135+
public Boolean existsRefresh(String refreshToken) {
136+
return preUserRefreshRepository.existsByRefresh(refreshToken);
137+
}
138+
139+
// JWT Refresh 토큰 삭제 메소드
140+
@Transactional
141+
public void removeRefresh(String refreshToken) {
142+
preUserRefreshRepository.deleteByRefresh(refreshToken);
143+
}
144+
145+
// 특정 유저 Refresh 토큰 모두 삭제 (탈퇴)
146+
@Transactional
147+
public void removeRefreshUser(String username) {
148+
preUserRefreshRepository.deleteByUsername(username);
149+
}
150+
}

src/main/resources/application-local.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ refresh-token:
2222
ttl-days: 8
2323
cleanup-cron: "0 0 3 * * *" # 3AM
2424

25+
# Frontend Configuration
26+
frontend:
27+
url: http://localhost:5173
28+
cookie:
29+
secure: false
30+
2531
logging:
2632
level:
2733
root: INFO

src/main/resources/application-prod.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ jwt:
2020
access-token-expires-in: ${JWT_ACCESS_TOKEN_EXPIRES_IN:3600000}
2121
refresh-token-expires-in: ${JWT_REFRESH_TOKEN_EXPIRES_IN:604800000}
2222

23+
# Frontend Configuration
24+
frontend:
25+
url: ${FRONTEND_URL:http://localhost:5173}
26+
cookie:
27+
secure: true
28+
2329
logging:
2430
level:
2531
root: INFO

0 commit comments

Comments
 (0)