Skip to content

Commit

Permalink
fix: jwt 토큰 처리 관련 예외 핸들링 구현, 회원 식별 방식 변경
Browse files Browse the repository at this point in the history
- 이메일 대신 memberId나 (socialType, authId)로 회원 식별 방식을 변경하였다.
  이에 따라 JWT에 담기는 데이터 중 email도 memberId로 변경하였다. (String 타입으로 담긴다.)

- JwtAuthenticationFilter에서 인증 관련 문제가 생길 경우, 각 예외 핸들링 처리를 request 객체의
  attribute 값 세팅 후 CustomAuthenticationEntryPoint에서 핸들링하도록 하였다.
  • Loading branch information
bflykky committed Aug 13, 2024
1 parent 4606e16 commit 5d1acbe
Show file tree
Hide file tree
Showing 9 changed files with 131 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
Optional<Member> findByAuthIdAndSocialType(String authId, SocialType socialType);
Boolean existsByEmail(String email);
Optional<Member> findBySocialTypeAndAuthId(SocialType socialType, String authId);
Boolean existsBySocialTypeAndAuthId(SocialType socialType, String authId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,27 +57,27 @@ public LoginInfo signup(SignupRequest request) {
Member member = memberConverter.toEntity(request);
memberRepository.save(member);

Long memberId = member.getId();
// 회원가입 완료 후 로그인 처리를 위해 access token, refresh token 발급
// 별도 권한 정책이 없으므로 default 처리
String role = "ROLE_DEFAULT";
String email = member.getEmail();
Long memberId = member.getId();
String accessToken = jwtUtils.createJwt(email, role, ACCESS_TOKEN_VALIDITY_IN_SECONDS);
String refreshToken = jwtUtils.createJwt(email, role, REFRESH_TOKEN_VALIDITY_IN_SECONDS);
refreshTokenService.saveRefreshToken(memberId, refreshToken);
return memberConverter.toLoginInfo(memberId, accessToken, refreshToken);

return createJwtAndGetLoginInfo(memberId, role);
}


@Override
public LoginInfo login(LoginRequest request) {
Member member = findMember(request.getSocialType(), request.getAuthId());

Long memberId = member.getId();
String email = member.getEmail();
String role = "ROLE_DEFAULT";
String accessToken = jwtUtils.createJwt(email, role, ACCESS_TOKEN_VALIDITY_IN_SECONDS);
String refreshToken = jwtUtils.createJwt(email, role, REFRESH_TOKEN_VALIDITY_IN_SECONDS);

return createJwtAndGetLoginInfo(memberId, role);
}

private LoginInfo createJwtAndGetLoginInfo(Long memberId, String role) {
String accessToken = jwtUtils.createJwt(memberId, role, ACCESS_TOKEN_VALIDITY_IN_SECONDS);
String refreshToken = jwtUtils.createJwt(memberId, role, REFRESH_TOKEN_VALIDITY_IN_SECONDS);
refreshTokenService.saveRefreshToken(memberId, refreshToken);

return memberConverter.toLoginInfo(memberId, accessToken, refreshToken);
Expand Down Expand Up @@ -113,7 +113,7 @@ public Member findMember(Long memberId) {

@Override
public Member findMember(SocialType socialType, String authId) {
return memberRepository.findByAuthIdAndSocialType(authId, socialType)
return memberRepository.findBySocialTypeAndAuthId(socialType, authId)
.orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND_BY_AUTH_ID_AND_SOCIAL_TYPE));
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/umc/naoman/global/error/ErrorResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public class ErrorResponse {
private final int status;
private final String code;
private final String message;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
// @JsonInclude(JsonInclude.Include.NON_EMPTY)
// 매핑할 값이 없으면 안드로이드 쪽에서 별도로 구현해야 하기 때문에 위 어노테이션 주석 처리
private final List<ValidationError> data;


Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/umc/naoman/global/error/code/JwtErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.umc.naoman.global.error.code;

import com.umc.naoman.global.error.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum JwtErrorCode implements ErrorCode {
AUTHENTICATION_TYPE_IS_NOT_BEARER(400, "EJ000", "인증 타입이 Bearer가 아닙니다."),
ACCESS_TOKEN_IS_EXPIRED(401, "EJ000", "액세스 토큰이 만료되었습니다."),
MEMBER_NOT_FOUND(404, "EJ000", "해당 memberId를 가진 회원이 존재하지 않습니다. 탈퇴한 회원인지 확인해 주세요."),

;

private final int status;
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
package com.umc.naoman.global.security.filter;

import com.umc.naoman.global.error.ErrorCode;
import com.umc.naoman.global.security.util.JwtUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

import static com.umc.naoman.global.error.code.JwtErrorCode.*;

@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String HEALTH_CHECK_URL = "/";
private static final List<String> EXCLUDE_URL_PATTERN_LIST = List.of(
"/swagger-ui",
"/swagger-resources",
"/v3/api-docs",
"/auth");
private static final String AUTHORIZATION_TYPE = "Bearer ";
private static final String AUTHORIZATION_HEADER = "Authorization";
private final JwtUtils jwtUtils;
Expand All @@ -23,31 +36,50 @@ public JwtAuthenticationFilter(JwtUtils jwtUtils) {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authorization = request.getHeader(AUTHORIZATION_HEADER);

if (!validateJwtIsPresent(authorization)) {
if (authorization == null) {
SecurityContextHolder.clearContext();
filterChain.doFilter(request, response);
return;
}

if (!authorization.startsWith(AUTHORIZATION_TYPE)) {
handleException(request, response, filterChain, AUTHENTICATION_TYPE_IS_NOT_BEARER);
return;
}

String jwt = authorization.substring(AUTHORIZATION_TYPE.length());
System.out.println("jwt: " + jwt);
log.info("jwt: {}", jwt);

if (jwtUtils.isExpired(jwt)) {
System.out.println("토큰이 만료되었습니다.");
filterChain.doFilter(request, response);
handleException(request, response, filterChain, ACCESS_TOKEN_IS_EXPIRED);
return;
}

final Authentication authentication;
try {
authentication = jwtUtils.getAuthentication(jwt);
} catch (UsernameNotFoundException e) {
handleException(request, response, filterChain, MEMBER_NOT_FOUND);
return;
}

Authentication authentication = jwtUtils.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}

private boolean validateJwtIsPresent(String authorization) {
if (authorization == null || !authorization.startsWith(AUTHORIZATION_TYPE)) {
// System.out.println("토큰이 존재하지 않거나, 인증 타입이 Bearer가 아닙니다.");
return false;
}
private void handleException(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain, ErrorCode errorCode) throws ServletException, IOException{
SecurityContextHolder.clearContext();
request.setAttribute("authException", errorCode);
filterChain.doFilter(request, response);
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
// Swagger 관련 경로를 필터링에서 제외
return EXCLUDE_URL_PATTERN_LIST.stream()
.anyMatch(urlPattern -> path.startsWith(urlPattern)) || path.equals(HEALTH_CHECK_URL);

return true;
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.umc.naoman.global.security.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.umc.naoman.global.error.ErrorCode;
import com.umc.naoman.global.error.ErrorResponse;
import jakarta.servlet.ServletException;
import com.umc.naoman.global.error.code.JwtErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
Expand All @@ -18,17 +19,23 @@
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
AuthenticationException authException) throws IOException {
ErrorCode errorCode = (ErrorCode) request.getAttribute("authException");
if (errorCode == null) {
errorCode = UNAUTHORIZED;
}

response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(UNAUTHORIZED.getStatus());
response.setStatus(errorCode.getStatus());
response.setCharacterEncoding(Charset.defaultCharset().name());

ErrorResponse errorResponse = ErrorResponse.builder()
.status(response.getStatus())
.code(UNAUTHORIZED.getMessage())
.message(authException.getMessage())
.code(errorCode.getCode())
.message(errorCode.getMessage())
.data(null)
.build();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,15 @@ private void handleExistingMemberLogin(HttpServletRequest request, HttpServletRe
}

// 로그인 성공 처리를 위해 access token, refresh token 발급
String accessToken = jwtUtils.createJwt(member.getEmail(), role, ACCESS_TOKEN_VALIDITY_IN_SECONDS);
String accessToken = jwtUtils.createJwt(member.getId(), role, ACCESS_TOKEN_VALIDITY_IN_SECONDS);
CookieUtils.addCookie(response, ACCESS_TOKEN_KEY, accessToken, ACCESS_TOKEN_VALIDITY_IN_SECONDS.intValue());

String refreshToken = jwtUtils.createJwt(member.getEmail(), role, REFRESH_TOKEN_VALIDITY_IN_SECONDS);
String refreshToken = jwtUtils.createJwt(member.getId(), role, REFRESH_TOKEN_VALIDITY_IN_SECONDS);
refreshTokenService.saveRefreshToken(member.getId(), refreshToken);
CookieUtils.addCookie(response, REFRESH_TOKEN_KEY, refreshToken, REFRESH_TOKEN_VALIDITY_IN_SECONDS.intValue());

clearAuthenticationAttributes(request, response);
// 프론트엔드 홈 화면으로 리다이렉션
response.sendRedirect(FRONTEND_BASE_URL);
response.sendRedirect(FRONTEND_BASE_URL); // 홈 화면으로 리다이렉션
}

private void handleMemberSignup(HttpServletRequest request, HttpServletResponse response, OAuthAttribute oAuthAttribute)
Expand All @@ -89,8 +88,7 @@ private void handleMemberSignup(HttpServletRequest request, HttpServletResponse
CookieUtils.addCookie(response, TEMP_MEMBER_INFO_KEY, tempMemberInfo, TEMP_MEMBER_INFO_VALIDITY_IN_SECONDS.intValue());

clearAuthenticationAttributes(request, response);
// 약관 동의 화면으로 리다이렉션
response.sendRedirect(FRONTEND_BASE_URL + FRONTEND_AGREEMENT_PATH);
response.sendRedirect(FRONTEND_BASE_URL + FRONTEND_AGREEMENT_PATH); // 약관 동의 화면으로 리다이렉션
}

private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;

/**
*
* @param username 회원을 식별하기 위한 데이터. PK 값인 memberId
* @return
* @throws UsernameNotFoundException
*/
@Override
public MemberDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("해당 이메일을 가진 회원이 존재하지 않습니다."));
Member member = memberRepository.findById(Long.parseLong(username)) // 전달된 memberId를 Long 타입으로 변환
.orElseThrow(() -> new UsernameNotFoundException("해당 memberId를 가진 회원이 존재하지 않습니다."));

return new MemberDetails(member);
}
Expand Down
59 changes: 30 additions & 29 deletions src/main/java/com/umc/naoman/global/security/util/JwtUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,39 +37,13 @@ public class JwtUtils {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SIGNATURE_ALGORITHM);
}

public String getEmail(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get(PAYLOAD_EMAIL_KEY, String.class);
}

public Claims getPayload(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}

public Boolean isExpired(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration().before(new Date());
}

public String createJwt(String email, String role, Long seconds) {
public String createJwt(Long memberId, String role, Long seconds) {
final LocalDateTime now = LocalDateTime.now();
final Date issuedDate = localDateTimeToDate(now);
final Date expiredDate = localDateTimeToDate(now.plusSeconds(seconds));

return Jwts.builder()
.claim(PAYLOAD_EMAIL_KEY, email)
.claim(PAYLOAD_MEMBER_ID_KEY, memberId.toString()) // String 타입으로 세팅
.claim(PAYLOAD_ROLE_KEY, role)
.issuedAt(issuedDate)
.expiration(expiredDate)
Expand All @@ -94,12 +68,39 @@ public String createTempMemberInfoJwt(OAuthAttribute oAuthAttribute, Long second
.compact();
}

public Claims getPayload(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}

// Long 타입이지만 JWT 내부에는 String으로 담겨 있다.
public String getMemberId(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get(PAYLOAD_MEMBER_ID_KEY, String.class);
}

public Boolean isExpired(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration().before(new Date());
}

public Authentication getAuthentication(String token) {
// 나ㅇ만 서비스는 현재 Member 엔티티에게 권한이 존재하지 않으므로 authorities는 빈 리스트 처리
final List<SimpleGrantedAuthority> authorities = Collections.emptyList();

// 사용자 정의로 구현한 MemberDetails 사용
final MemberDetails principal = memberDetailsService.loadUserByUsername(getEmail(token));
final MemberDetails principal = memberDetailsService.loadUserByUsername(getMemberId(token));
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}

Expand Down

0 comments on commit 5d1acbe

Please sign in to comment.