Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

6차 세미나 실습 과제 (#13) #15

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.sopt.springFirstSeminar.common;

public class Constant {
public static final String AUTHORIZATION = "Authorization";
public static final String BEARER = "Bearer ";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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) 알고리즘 사용
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Comment on lines +17 to +41

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

atk와 rtk의 내용이 다르지 않다면 같은 validate를 쓰는 것도 나쁘지 않을 것 같은데 따로 분리한 이유가 있을까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

에러 메세지가 다르기 때문에 나눠주었습니다!!


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);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
}
Comment on lines +8 to +11
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오... 어노테이션 활용하는 거 처음 봤습니다.
대박 🥹🥹

Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
Comment on lines +31 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아마 여기서 매개변수를 파싱하는 것 같은데,
제가 이해했을 때에는 토큰 파싱보다는 서버에서 기억해둔 사용자 ID를 가져오는 듯한?
코드인 것 같은데 혹시 어떻게 되는 걸까요??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서는 바인딩할 객체를 리턴해주는 코드입니다!!
그래서 여기서 getPrincipal로 userId를 가져옵니다!

}
Loading