diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/Constant.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/Constant.java new file mode 100644 index 0000000..9f22ce4 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/Constant.java @@ -0,0 +1,6 @@ +package org.sopt.springFirstSeminar.common; + +public class Constant { + public static final String AUTHORIZATION = "Authorization"; + public static final String BEARER = "Bearer "; +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/ErrorMessage.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/ErrorMessage.java index 966fae2..7d88d38 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/ErrorMessage.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/ErrorMessage.java @@ -13,7 +13,22 @@ public enum ErrorMessage { BLOG_NOT_MATCH_MEMBER(HttpStatus.NOT_FOUND.value(), "해당 멤버 ID에 해당하는 블로그ID가 아닙니다."), CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "POST ID에 해당하는 글이 존재하지 않습니다"), MAX_BLOG_CONTENT(HttpStatus.BAD_REQUEST.value(), "블로그 글이 최대 글자 수(20)를 초과했습니다"), - JWT_UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED.value(), "사용자의 로그인 검증을 실패했습니다.") + + //jwt + JWT_UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED.value(), "사용자의 로그인 검증을 실패했습니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰의 형식이 올바르지 않습니다. Bearer 타입을 확인해 주세요."), + EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 받아주세요."), + UNSUPPORTED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED.value(), "지원하지 않는 JWT 형식입니다."), + EMPTY_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED.value(), "JWT Claim이 비어있습니다"), + JWT_SIGNATURE_EXCEPTION(HttpStatus.UNAUTHORIZED.value(), "JWT의 기존 서명과 다릅니다."), + + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰의 형식이 올바르지 않습니다."), + INVALID_REFRESH_TOKEN_VALUE(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰의 값이 올바르지 않습니다."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED.value(),"리프레시 토큰이 만료되었습니다. 다시 로그인해 주세요."), + MISMATCH_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 일치하지 않습니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰을 찾을 수 없습니다."), + + ; private final int status; diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/SuccessMessage.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/SuccessMessage.java index 543c1c8..b28f2f9 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/SuccessMessage.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/SuccessMessage.java @@ -10,9 +10,15 @@ @Getter public enum SuccessMessage { + MEMBER_CREATE_SUCCESS(HttpStatus.CREATED.value(), "유저 생성에 성공했습니다."), + MEMBER_FIND_SUCCESS(HttpStatus.OK.value(), "유저 검색에 성공했습니다."), + + BLOG_CREATE_SUCCESS(HttpStatus.CREATED.value(), "블로그 생성이 완료되었습니다."), BLOG_CONTENT_CREATE_SUCCESS(HttpStatus.CREATED.value(), "블로그에 글 작성이 완료되었습니다."), GET_BLOG_CONTENT_SUCCESS(HttpStatus.OK.value(), "블로그 글 가져오기가 완료되었습니다."), + + TOKEN_REISSUE_SUCCESS(HttpStatus.OK.value(), "토큰 재발급에 성공했습니다"), ; private final int status; private final String message; diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenGenerator.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenGenerator.java new file mode 100644 index 0000000..7f8b4ba --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenGenerator.java @@ -0,0 +1,60 @@ +package org.sopt.springFirstSeminar.common.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; + +@Component +public class JwtTokenGenerator { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-expire-time}") //1분 + private long ACCESS_TOKEN_EXPIRE_TIME; + + @Value("${jwt.refresh-token-expire-time}") //1시간 + private long REFRESH_TOKEN_EXPIRE_TIME; + + public String generateToken(final Long userId, boolean isAccessToken) { + final Date presentDate = new Date(); + final Date expireDate = generateExpireDateByToken(isAccessToken, presentDate); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setSubject(String.valueOf(userId)) + .setIssuedAt(presentDate) + .setExpiration(expireDate) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) //여기서 어떤 알고리즘을 사용할 지를 명시적으로 적어주는게 좋음, 안 적어주면 라이브러리 기본 설정에 의존하게됨 + .compact(); + } + + public JwtParser getJwtParser() { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build(); + } + + private Date generateExpireDateByToken(final boolean isAccessToken, Date presentDate) { + return new Date(presentDate.getTime() + setExpireTimeByToken(isAccessToken)); + } + + //토근에 따라 만료시간 다름 + private long setExpireTimeByToken(final boolean isAccessToken) { + if (isAccessToken) { + return ACCESS_TOKEN_EXPIRE_TIME; + } else { + return REFRESH_TOKEN_EXPIRE_TIME; + } + } + + public SecretKey getSigningKey() { + String encodedKey = Base64.getEncoder().encodeToString(secretKey.getBytes()); //SecretKey 통해 서명 생성 + return Keys.hmacShaKeyFor(encodedKey.getBytes()); //일반적으로 HMAC (Hash-based Message Authentication Code) 알고리즘 사용 + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenProvider.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenProvider.java index ed633d4..cad7740 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenProvider.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenProvider.java @@ -1,84 +1,26 @@ package org.sopt.springFirstSeminar.common.jwt; -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.JwtParser; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Component; +import org.sopt.springFirstSeminar.common.jwt.dto.Token; +import org.springframework.stereotype.Service; -import javax.crypto.SecretKey; -import java.util.Base64; -import java.util.Date; - -@Component @RequiredArgsConstructor +@Service public class JwtTokenProvider { - private static final String USER_ID = "userId"; - - private static final Long ACCESS_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 100L * 14; - private static final Long REFRESH_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 1000L * 14; - - - @Value("${jwt.secret}") - private String JWT_SECRET; - - - public String issueAccessToken(final Authentication authentication) { - return generateToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME); - } - -// public String issueRefreshToken(final Authentication authentication) { -// return issueToken(authentication, REFRESH_TOKEN_EXPIRATION_TIME); -// } - - - public String generateToken(Authentication authentication, Long tokenExpirationTime) { - final Date now = new Date(); - final Claims claims = Jwts.claims() - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + tokenExpirationTime)); // 만료 시간 - - claims.put(USER_ID, authentication.getPrincipal()); - - return Jwts.builder() - .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header - .setClaims(claims) // Claim - .signWith(getSigningKey()) // Signature - .compact(); - } - - private SecretKey getSigningKey() { - String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes()); //SecretKey 통해 서명 생성 - return Keys.hmacShaKeyFor(encodedKey.getBytes()); //일반적으로 HMAC (Hash-based Message Authentication Code) 알고리즘 사용 - } - - public JwtValidationType validateToken(String token) { - try { - final Claims claims = getBody(token); - return JwtValidationType.VALID_JWT; - } catch (MalformedJwtException ex) { - return JwtValidationType.INVALID_JWT_TOKEN; - } catch (ExpiredJwtException ex) { - return JwtValidationType.EXPIRED_JWT_TOKEN; - } catch (UnsupportedJwtException ex) { - return JwtValidationType.UNSUPPORTED_JWT_TOKEN; - } catch (IllegalArgumentException ex) { - return JwtValidationType.EMPTY_JWT; - } - } + private final JwtTokenGenerator jwtTokenGenerator; - private Claims getBody(final String token) { - return Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token) - .getBody(); + public Token issueTokens(final Long userId) { + return Token.of( + jwtTokenGenerator.generateToken(userId, true), + jwtTokenGenerator.generateToken(userId, false)); } - public Long getUserFromJwt(String token) { - Claims claims = getBody(token); - return Long.valueOf(claims.get(USER_ID).toString()); + public Long getSubject(String accessToken) { + JwtParser jwtParser = jwtTokenGenerator.getJwtParser(); + return Long.valueOf(jwtParser.parseClaimsJws(accessToken) + .getBody() + .getSubject()); } } diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenValidator.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenValidator.java new file mode 100644 index 0000000..a3b46d7 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenValidator.java @@ -0,0 +1,53 @@ +package org.sopt.springFirstSeminar.common.jwt; + +import io.jsonwebtoken.*; +import lombok.RequiredArgsConstructor; +import org.sopt.springFirstSeminar.common.dto.ErrorMessage; +import org.sopt.springFirstSeminar.exception.UnauthorizedException; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + + +@RequiredArgsConstructor +@Service +public class JwtTokenValidator { + + private final JwtTokenGenerator jwtTokenGenerator; + + public void validateAccessToken(String accessToken) { + try { + parseToken(accessToken); + } catch (ExpiredJwtException e) { + throw new UnauthorizedException(ErrorMessage.EXPIRED_ACCESS_TOKEN); + } catch (MalformedJwtException ex) { + throw new UnauthorizedException(ErrorMessage.INVALID_ACCESS_TOKEN); + } catch (UnsupportedJwtException ex) { + throw new UnauthorizedException(ErrorMessage.UNSUPPORTED_ACCESS_TOKEN); + } catch (IllegalArgumentException ex) { + throw new UnauthorizedException(ErrorMessage.EMPTY_ACCESS_TOKEN); + } catch (SignatureException ex) { + throw new UnauthorizedException(ErrorMessage.JWT_SIGNATURE_EXCEPTION); + } + } + + public void validateRefreshToken(String refreshToken) { + try { + parseToken(refreshToken); + } catch (ExpiredJwtException e) { + throw new UnauthorizedException(ErrorMessage.EXPIRED_REFRESH_TOKEN); + } catch (Exception e) { + throw new UnauthorizedException(ErrorMessage.INVALID_REFRESH_TOKEN_VALUE); + } + } + + public void equalsRefreshToken(String refreshToken, String storedRefreshToken) { + if (!refreshToken.equals(storedRefreshToken)) { + throw new UnauthorizedException(ErrorMessage.MISMATCH_REFRESH_TOKEN); + } + } + + private void parseToken(String token) { + JwtParser jwtParser = jwtTokenGenerator.getJwtParser(); + jwtParser.parseClaimsJws(token); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtValidationType.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtValidationType.java deleted file mode 100644 index e2b98b4..0000000 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtValidationType.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.sopt.springFirstSeminar.common.jwt; - -public enum JwtValidationType { - VALID_JWT, // 유효한 JWT - INVALID_JWT_SIGNATURE, // 유효하지 않은 서명 - INVALID_JWT_TOKEN, // 유효하지 않은 토큰 - EXPIRED_JWT_TOKEN, // 만료된 토큰 - UNSUPPORTED_JWT_TOKEN, // 지원하지 않는 형식의 토큰 - EMPTY_JWT // 빈 JWT -} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/CustomAccessDeniedHandler.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/CustomAccessDeniedHandler.java index 3ae38c9..b4fac77 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/CustomAccessDeniedHandler.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/CustomAccessDeniedHandler.java @@ -11,6 +11,7 @@ @Component public class CustomAccessDeniedHandler implements AccessDeniedHandler { + @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { setResponse(response); diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/MemberId.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/MemberId.java new file mode 100644 index 0000000..2e361a9 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/MemberId.java @@ -0,0 +1,11 @@ +package org.sopt.springFirstSeminar.common.jwt.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface MemberId { +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/RefreshToken.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/RefreshToken.java new file mode 100644 index 0000000..98941e5 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/RefreshToken.java @@ -0,0 +1,30 @@ +package org.sopt.springFirstSeminar.common.jwt.auth; + + +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@RedisHash(value = "", timeToLive = 60 * 60 * 24 * 1000L) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class RefreshToken { + + @Id + private Long id; + + @Indexed + private String refreshToken; + + public static RefreshToken of(final Long memberId, final String refreshToken) { + return RefreshToken.builder() + .id(memberId) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/UserIdArgumentResolver.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/UserIdArgumentResolver.java new file mode 100644 index 0000000..edd7882 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/UserIdArgumentResolver.java @@ -0,0 +1,36 @@ +package org.sopt.springFirstSeminar.common.jwt.auth; + +import org.springframework.core.MethodParameter; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +//Argument Resolver를 사용하면 컨트롤러 메서드의 파라미터 중 특정 조건에 맞는 파라미터가 있다면, +// 요청에 들어온 값을 이용해 원하는 객체를 만들어 바인딩해줄 수 있다. +@Component +public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + + //요청받은 메소드의 파라미터에 @UserId 어노테이션이 붙어있는지 확인 + boolean hasUserIdAnnotation = parameter.hasParameterAnnotation(MemberId.class); + + //타입이 같은지 확인 +// boolean isLongType = Long.class.isAssignableFrom(parameter.getParameterType()); + boolean isLongType = Long.class.equals(parameter.getParameterType()); + + //둘 다 true면 아래 resolveArgument 메서드 실행 + return hasUserIdAnnotation && isLongType; + } + + //바인딩할 객체로 리턴하는 메서드 + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + return SecurityContextHolder.getContext() + .getAuthentication() + .getPrincipal(); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/SecurityConfig.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/config/SecurityConfig.java similarity index 73% rename from springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/SecurityConfig.java rename to springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/config/SecurityConfig.java index b8c4b2b..de2356e 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/SecurityConfig.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/config/SecurityConfig.java @@ -1,7 +1,11 @@ -package org.sopt.springFirstSeminar.common.jwt.auth.filter; +package org.sopt.springFirstSeminar.common.jwt.auth.config; import lombok.RequiredArgsConstructor; +import org.sopt.springFirstSeminar.common.jwt.JwtTokenProvider; +import org.sopt.springFirstSeminar.common.jwt.JwtTokenValidator; import org.sopt.springFirstSeminar.common.jwt.auth.CustomAccessDeniedHandler; +import org.sopt.springFirstSeminar.common.jwt.auth.filter.CustomJwtAuthenticationEntryPoint; +import org.sopt.springFirstSeminar.common.jwt.auth.filter.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -15,12 +19,15 @@ @RequiredArgsConstructor @EnableWebSecurity //web Security를 사용할 수 있게 public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtTokenProvider jwtTokenProvider; + private final JwtTokenValidator jwtTokenValidator; + - private static final String[] AUTH_WHITE_LIST = {"/api/v1/member"}; + private static final String[] AUTH_WHITE_LIST = {"/api/v1/member/signup", "/test", "/api/v1/member/reissue"}; @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -35,10 +42,12 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { }); + //유저 가입이나 로그인 등 인증 전 단계의 api 허용, 그 외는 인증! http.authorizeHttpRequests(auth -> { auth.requestMatchers(AUTH_WHITE_LIST).permitAll(); auth.anyRequest().authenticated(); }) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, jwtTokenValidator), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/config/WebConfig.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/config/WebConfig.java new file mode 100644 index 0000000..60bae8e --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/config/WebConfig.java @@ -0,0 +1,21 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.config; + + +import lombok.RequiredArgsConstructor; +import org.sopt.springFirstSeminar.common.jwt.auth.UserIdArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final UserIdArgumentResolver userIdArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(userIdArgumentResolver); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/JwtAuthenticationFilter.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/JwtAuthenticationFilter.java index 9e50059..7486e69 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/JwtAuthenticationFilter.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/JwtAuthenticationFilter.java @@ -4,13 +4,17 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.NonNull; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.springFirstSeminar.common.Constant; import org.sopt.springFirstSeminar.common.dto.ErrorMessage; import org.sopt.springFirstSeminar.common.jwt.JwtTokenProvider; +import org.sopt.springFirstSeminar.common.jwt.JwtTokenValidator; import org.sopt.springFirstSeminar.common.jwt.UserAuthentication; import org.sopt.springFirstSeminar.exception.UnauthorizedException; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -18,37 +22,50 @@ import java.io.IOException; -import static org.sopt.springFirstSeminar.common.jwt.JwtValidationType.VALID_JWT; +import static org.sopt.springFirstSeminar.common.jwt.UserAuthentication.createUserAuthentication; -@Component @RequiredArgsConstructor +@Component +@Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; + private final JwtTokenValidator jwtTokenValidator; + @Override - protected void doFilterInternal(@NonNull HttpServletRequest request, - @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain) throws ServletException, IOException { + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { - final String token = getJwtFromRequest(request); - if (jwtTokenProvider.validateToken(token) == VALID_JWT) { - Long memberId = jwtTokenProvider.getUserFromJwt(token); - UserAuthentication authentication = UserAuthentication.createUserAuthentication(memberId); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - } catch (Exception exception) { - throw new UnauthorizedException(ErrorMessage.JWT_UNAUTHORIZED_EXCEPTION); + final String accessToken = getAccessToken(request); + jwtTokenValidator.validateAccessToken(accessToken); + doAuthentication(request, jwtTokenProvider.getSubject(accessToken)); + } catch(UnauthorizedException e){ + //여기서 throw를 사용하면 filterChain.doFilter(request, response)가 실행되지 않고, + log.error("JwtAuthentication Authentication Exception Occurs!"); } filterChain.doFilter(request, response); } - private String getJwtFromRequest(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring("Bearer ".length()); + //userId로 UserAuthentication 객체 생성 + private void doAuthentication(HttpServletRequest request, Long userId) { + UserAuthentication authentication = createUserAuthentication(userId); + createAndSetWebAuthenticationDetails(request, authentication); + SecurityContext securityContext = SecurityContextHolder.getContext(); + securityContext.setAuthentication(authentication); + } + + private void createAndSetWebAuthenticationDetails(HttpServletRequest request, UserAuthentication authentication) { + WebAuthenticationDetailsSource webAuthenticationDetailsSource = new WebAuthenticationDetailsSource(); + WebAuthenticationDetails webAuthenticationDetails = webAuthenticationDetailsSource.buildDetails(request); + authentication.setDetails(webAuthenticationDetails); + } + + //토큰 추출 + private String getAccessToken(final HttpServletRequest request) { + String accessToken = request.getHeader(Constant.AUTHORIZATION); + if (StringUtils.hasText(accessToken) && accessToken.startsWith(Constant.BEARER)) { + return accessToken.substring(Constant.BEARER.length()); } - return null; + throw new UnauthorizedException(ErrorMessage.INVALID_ACCESS_TOKEN); } -} \ No newline at end of file +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/Token.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/Token.java deleted file mode 100644 index a97c803..0000000 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/Token.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.sopt.springFirstSeminar.common.jwt.auth.redis; - - -import jakarta.persistence.Id; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import org.springframework.data.redis.core.RedisHash; -import org.springframework.data.redis.core.index.Indexed; - -@RedisHash(value = "", timeToLive = 60 * 60 * 24 * 1000L * 1) -@AllArgsConstructor -@Getter -@Builder -public class Token { - - @Id - private Long id; - - @Indexed - private String refreshToken; - - public static Token of(final Long id, final String refreshToken) { - return Token.builder() - .id(id) - .refreshToken(refreshToken) - .build(); - } -} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/RedisConfig.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/RedisConfig.java new file mode 100644 index 0000000..8952a3a --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/RedisConfig.java @@ -0,0 +1,22 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.redis.repository; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/RefreshTokenRepository.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..ca22190 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/RefreshTokenRepository.java @@ -0,0 +1,9 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.redis.repository; + +import org.sopt.springFirstSeminar.common.jwt.auth.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends CrudRepository { +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/TokenRepository.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/TokenRepository.java deleted file mode 100644 index 9f61d70..0000000 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/TokenRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.sopt.springFirstSeminar.common.jwt.auth.redis.repository; - -import org.sopt.springFirstSeminar.common.jwt.auth.redis.Token; -import org.springframework.data.repository.CrudRepository; - -import java.util.Optional; - -public interface TokenRepository extends CrudRepository { - Optional findByRefreshToken(final String refreshToken); - Optional findById(final Long id); -} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/Token.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/Token.java new file mode 100644 index 0000000..0e9dab8 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/Token.java @@ -0,0 +1,17 @@ +package org.sopt.springFirstSeminar.common.jwt.dto; + +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PRIVATE) +public record Token( + String accessToken, + String refreshToken +) { + public static Token of(String accessToken, String refreshToken) { + return Token.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/TokenAndUserIdResponse.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/TokenAndUserIdResponse.java new file mode 100644 index 0000000..dafbba9 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/TokenAndUserIdResponse.java @@ -0,0 +1,14 @@ +package org.sopt.springFirstSeminar.common.jwt.dto; + +public record TokenAndUserIdResponse( + String accessToken, + String refreshToken, + Long userId +) { + + public static TokenAndUserIdResponse of(Token token, Long memberId) { + return new TokenAndUserIdResponse(token.accessToken(), token.refreshToken(), memberId); + } +} + + diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/UserJoinResponse.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/UserJoinResponse.java deleted file mode 100644 index 812649d..0000000 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/UserJoinResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.sopt.springFirstSeminar.common.jwt.dto; - -public record UserJoinResponse( - String accessToken, - String userId -) { - - public static UserJoinResponse of( - String accessToken, - String userId - ) { - return new UserJoinResponse(accessToken, userId); - } -} - - diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/MemberController.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/MemberController.java index 15b2dbe..e5bf8a1 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/MemberController.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/MemberController.java @@ -2,18 +2,23 @@ import lombok.RequiredArgsConstructor; -import org.sopt.springFirstSeminar.common.jwt.dto.UserJoinResponse; +import org.sopt.springFirstSeminar.common.ApiResponseUtil; +import org.sopt.springFirstSeminar.common.BaseResponse; +import org.sopt.springFirstSeminar.common.dto.SuccessMessage; +import org.sopt.springFirstSeminar.common.jwt.auth.MemberId; +import org.sopt.springFirstSeminar.common.jwt.dto.TokenAndUserIdResponse; import org.sopt.springFirstSeminar.service.MemberService; import org.sopt.springFirstSeminar.service.dto.MemberCreateDTO; import org.sopt.springFirstSeminar.service.dto.MemberFindDTO; import org.sopt.springFirstSeminar.service.dto.MemberDataDTO; -import org.springframework.http.HttpStatus; +import org.sopt.springFirstSeminar.service.dto.ReissueRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.net.URI; import java.util.List; +import static org.sopt.springFirstSeminar.common.Constant.AUTHORIZATION; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/member") @@ -21,21 +26,29 @@ public class MemberController { private final MemberService memberService; - @PostMapping - public ResponseEntity postMember( - @RequestBody MemberCreateDTO memberCreate - ) { - UserJoinResponse userJoinResponse = memberService.createMember(memberCreate); - return ResponseEntity.status(HttpStatus.CREATED) - .header("Location", userJoinResponse.userId()) - .body( - userJoinResponse - ); + @PostMapping("/signup") + public ResponseEntity> postMember(@RequestBody MemberCreateDTO memberCreate) + { + final TokenAndUserIdResponse memberJoinResponse = memberService.createMember(memberCreate); + + return ApiResponseUtil.success(SuccessMessage.MEMBER_CREATE_SUCCESS, memberJoinResponse); } - @GetMapping("/{memberId}") - public ResponseEntity findMemberById(@PathVariable final Long memberId) { - return ResponseEntity.ok(memberService.findMemberById(memberId)); + @GetMapping + public ResponseEntity> findMemberById(@MemberId final Long memberId) { + + final MemberFindDTO memberFindDTO = MemberFindDTO.of(memberService.findMemberById(memberId)); + + return ApiResponseUtil.success(SuccessMessage.MEMBER_FIND_SUCCESS, memberFindDTO); + } + + @PostMapping("/reissue") + public ResponseEntity> reissue(@RequestHeader(AUTHORIZATION) final String refreshToken, + @RequestBody final ReissueRequest reissueRequest) { + + final TokenAndUserIdResponse reissueTokenResponse = memberService.reissue(refreshToken, reissueRequest); + + return ApiResponseUtil.success(SuccessMessage.TOKEN_REISSUE_SUCCESS, reissueTokenResponse); } @DeleteMapping("/{memberId}") @@ -48,5 +61,4 @@ public ResponseEntity deleteMemberById(@PathVariable final Long memberId) { public ResponseEntity> getAllMemberList() { return ResponseEntity.ok(memberService.getAllMemberList()); } - } diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/domain/Member.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/domain/Member.java index a4e3489..73fcc7c 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/domain/Member.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/domain/Member.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.sopt.springFirstSeminar.service.dto.MemberFindDTO; @Entity @Getter @@ -36,4 +37,8 @@ public static Member create(String name, Part part, int age) { .part(part) .build(); } + + public static Member of(Member member) { + return new Member(member.getName(), member.getPart(), member.getAge()); + } } diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/MemberService.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/MemberService.java index f70035a..48466bd 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/MemberService.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/MemberService.java @@ -1,16 +1,22 @@ package org.sopt.springFirstSeminar.service; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.sopt.springFirstSeminar.common.dto.ErrorMessage; import org.sopt.springFirstSeminar.common.jwt.JwtTokenProvider; -import org.sopt.springFirstSeminar.common.jwt.UserAuthentication; -import org.sopt.springFirstSeminar.common.jwt.dto.UserJoinResponse; +import org.sopt.springFirstSeminar.common.jwt.JwtTokenValidator; +import org.sopt.springFirstSeminar.common.jwt.auth.RefreshToken; +import org.sopt.springFirstSeminar.common.jwt.auth.redis.repository.RefreshTokenRepository; +import org.sopt.springFirstSeminar.common.jwt.dto.Token; +import org.sopt.springFirstSeminar.common.jwt.dto.TokenAndUserIdResponse; import org.sopt.springFirstSeminar.domain.Member; import org.sopt.springFirstSeminar.exception.NotFoundException; +import org.sopt.springFirstSeminar.exception.UnauthorizedException; import org.sopt.springFirstSeminar.repository.MemberRepository; import org.sopt.springFirstSeminar.service.dto.MemberCreateDTO; import org.sopt.springFirstSeminar.service.dto.MemberFindDTO; import org.sopt.springFirstSeminar.service.dto.MemberDataDTO; +import org.sopt.springFirstSeminar.service.dto.ReissueRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,34 +25,79 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class MemberService { private final MemberRepository memberRepository; private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenValidator jwtTokenValidator; + //멤버가입 @Transactional - public UserJoinResponse createMember( - MemberCreateDTO memberCreate - ) { - Member member = memberRepository.save( + public TokenAndUserIdResponse createMember(MemberCreateDTO memberCreate) { + + Member createdMember = memberRepository.save( Member.create(memberCreate.name(), memberCreate.part(), memberCreate.age()) ); - Long memberId = member.getId(); - String accessToken = jwtTokenProvider.issueAccessToken( - UserAuthentication.createUserAuthentication(memberId) - ); - return UserJoinResponse.of(accessToken, memberId.toString()); + Long createdMemberId = createdMember.getId(); + Token issuedToken = jwtTokenProvider.issueTokens(createdMemberId); + updateRefreshToken(issuedToken.refreshToken(), createdMemberId); + + return TokenAndUserIdResponse.of(issuedToken, createdMemberId); } - public void findById(final Long memberId) { - findMember(memberId).orElseThrow( - () -> new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND)); + @Transactional + public TokenAndUserIdResponse reissue(final String refreshToken, final ReissueRequest reissueRequest) { + + Long memberId = reissueRequest.memberId(); + validateRefreshToken(refreshToken,memberId); + Member member = findMemberById(memberId); + Token issueedToken = jwtTokenProvider.issueTokens(memberId); + updateRefreshToken(issueedToken.refreshToken(), memberId); + return TokenAndUserIdResponse.of(issueedToken, memberId); } - public MemberFindDTO findMemberById(final Long memberId) { - return MemberFindDTO.of(findMember(memberId).orElseThrow( - () -> new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND))); + private void validateRefreshToken(final String refreshToken, final Long memberId) { + try { + jwtTokenValidator.validateRefreshToken(refreshToken); + String storedRefreshToken = getRefreshToken(memberId); + jwtTokenValidator.equalsRefreshToken(refreshToken, storedRefreshToken); + } catch (UnauthorizedException e) { + signOut(memberId); + throw e; + } + } + + private String getRefreshToken(final Long memberId) { + return getRefreshTokenFromRedis(memberId); + } + + private String getRefreshTokenFromRedis(Long userId) { + RefreshToken storedRefreshToken = refreshTokenRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.REFRESH_TOKEN_NOT_FOUND)); + return storedRefreshToken.getRefreshToken(); + } + + @Transactional + public void updateRefreshToken(String refreshToken, Long memberId) { + refreshTokenRepository.save(RefreshToken.of(memberId, refreshToken)); + } + + public void signOut(final Long memberId) { + Member findMember = findMemberById(memberId); + deleteRefreshToken(findMember); + } + + @Transactional + public void deleteRefreshToken(final Member member) { + refreshTokenRepository.deleteById(member.getId()); + } + + public Member findMemberById(final Long memberId) { + return memberRepository.findById(memberId).orElseThrow( + () -> new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND)); } @Transactional @@ -56,10 +107,6 @@ public void deleteMemberById(final Long memberId) { memberRepository.delete(member); } - public Optional findMember(final Long memberId) { - return memberRepository.findById(memberId); - } - public List getAllMemberList() { // .stream : 메서드가 반환한 컬렉션을 스트림으로 변환함, 스트림을 사용하면 데이터를 순차적으로 처리함 @@ -68,6 +115,4 @@ public List getAllMemberList() { //.toList : 스트림을 리스트로 변환 return memberRepository.findAll().stream().map(member -> MemberDataDTO.of(member)).toList(); } - - } diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/PostService.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/PostService.java index 3a3ecd8..3cf6457 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/PostService.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/PostService.java @@ -33,7 +33,7 @@ public class PostService { @Transactional public String postContent(final Long memberId, final Long blogId, final BlogContentRequestDTO blogContentRequestDTO) { - memberService.findById(memberId); + memberService.findMemberById(memberId); Blog blog = fineBlogById(blogId); diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/dto/ReissueRequest.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/dto/ReissueRequest.java new file mode 100644 index 0000000..f5e3e55 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/dto/ReissueRequest.java @@ -0,0 +1,7 @@ +package org.sopt.springFirstSeminar.service.dto; + +public record ReissueRequest( + Long memberId +) { + +}