Skip to content

Commit

Permalink
Merge pull request #107 from Na-o-man/fix/#106/fix-member-identification
Browse files Browse the repository at this point in the history
[FIX] jwt 토큰 처리 관련 예외 핸들링 구현, 회원 식별 방식 변경
  • Loading branch information
bflykky authored Aug 13, 2024
2 parents 4606e16 + 4b3f155 commit e331cb2
Show file tree
Hide file tree
Showing 10 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 e331cb2

Please sign in to comment.