Access Token은 아래글로..
2024.06.25 - [BE/Java] - Springboot3 + Swagger + Jwt (4)
Springboot3 + Swagger + Jwt (4)
DB, SpringSecurity, 회원가입은 아래 글로....2024.06.21 - [BE/Java] - Springboot3 + Swagger + Jwt (3) Springboot3 + Swagger + Jwt (3)Swagger 초기 셋팅2024.06.20 - [BE/Java] - Springboot3 + Swagger + Jwt (2) Springboot3 + Swagger + Jwt (2)프
tistory.slowtuttle.co.kr
진행할 내용
- 공통 응답/에러코드 작성
- Refresh Token 발급 및 저장
- Access Token, Refresh Token 테스트
Refresh Token이란?
Access Token은 로그인 이후 생성되는 토큰으로, 일반적인 API 통신 시 사용하는 인증 토큰으로 많이 사용하며 유효기간이 짧다. (약 60일(ms), 1시간(aws)) 또한 통신 과정에서 탈취당할 위험이 크기 때문에 주기를 짧게 정한다.
이를 보완하기 위하여 Refresh Token을 사용하는데, 짧은 인증기간으로 만료된 Access Token을 재발급 하기 위해서 사용한다.

그렇다면 Refresh Token은 탈취당하지 않으며, 안전하다고 할 수 있는것인가?
엄밀히 말하자면 Refresh Token은 통신의 빈도가 적긴 하지만 아예 탈취당하지 않는다는 보장이 있는 것은 아니다.
이를 예방하기 위해 OAuth 에서는 Refresh Token Rotation 을 제시한다.
Refresh Token Rotation은 Client가 Access Token 재발급 요청을 진행할때 Refresh Token도 새로 발급받는 방식이다.
Refresh Token 발행 및 Access Token 만료 FLOW
| 클라이언트 | 서버 | |
| 인증 | ||
| 1 | 로그인 요청 | |
| 2 | 데이터베이스에서 ID와 비밀번호 대조 후 일치여부 확인 | |
| 3 | 일치 시 암호화된 토큰 생성 | |
| 4 | 응답으로 Access 토큰 / Refresh 토큰을 반환 | |
| 5 | 클라이언트는 토큰을 저장 | |
| 인가 | ||
| 1 | API 요청 시 헤더에 Access Token을 포함시켜 요청 | |
| 2 | 토큰 유효성 검증 | |
| 3 | 유효성 확인 중 Access Token 만료가 되지 않음 | |
| 4 | 응답을 받음 | 검증 완료되었다면 API 로직 처리 후 응답 |
| 재발급 | ||
| 1 | API 요청 시 헤더에 Access Token을 포함시켜 요청 | |
| 2 | 토큰 유효성 검증 | |
| 3 | 유효성 확인 중 Access Token 만료됨 | |
| 4 | 응답으로 Access Token 만료가 되었음을 보냄 | |
| 5 | Refresh Token을 헤더에 포함시켜 Access Token 재발급 요청 | |
| 6 | Refresh Token의 유효성 검증 | |
| 7 | 새로운 Access Token을 응답으로 반환하여 발급 | |
| 8 | 새로운 Access Token 저장 | |
| Refresh Token 만료 | ||
| 1 | 재발급 6번까지 동일 | |
| 2 | 유효성 확인 중 Refresh Token 만료됨 | |
| 3 | 재로그인 요청 응답 | |
나의 경우 갱신 주기를 아래와같이 설정했다.
Access Token (현재 1일)
- 로그인 시
- 만료시간 초과 시
Refresh Token (현재 1주일)
- 다양한 방법이 있지만 기간 만료 시 로그인 하는 경우 신규 발급
공통 에러/응답 추가
Refresh Token 작업을 하기 전에 모든 양식에서 공통 양식으로 응답하기 위하여 해당 class를 추가해놓는다.
CommResponse.java
package org.jjuni.swaggerjwt.common.dto;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CommResponse<T> {
private static final String SUCCESS_STATUS = "success";
private static final String FAIL_STATUS = "fail";
private static final String ERROR_STATUS = "error";
private String status;
private T data;
private String message;
public static <T> CommResponse<T> createSuccess(T data) {
return new CommResponse<>(SUCCESS_STATUS, data, null);
}
// data 없이 단순 성공 결과 응답
public static CommResponse<?> createSuccessWithNoContent() {
return new CommResponse<>(SUCCESS_STATUS, null, null);
}
// Hibernate Validator에 의해 유효하지 않은 데이터로 인해 API 호출이 거부될때 반환
public static CommResponse<?> createFail(BindingResult bindingResult) {
Map<String, String> errors = new HashMap<>();
List<ObjectError> allErrors = bindingResult.getAllErrors();
for (ObjectError error : allErrors) {
if (error instanceof FieldError) {
errors.put(((FieldError) error).getField(), error.getDefaultMessage());
} else {
errors.put( error.getObjectName(), error.getDefaultMessage());
}
}
return new CommResponse<>(FAIL_STATUS, errors, null);
}
// 예외 발생으로 API 호출 실패시 반환
public static CommResponse<?> createError(String message) {
return new CommResponse<>(ERROR_STATUS, null, message);
}
private CommResponse(String status, T data, String message) {
this.status = status;
this.data = data;
this.message = message;
}
}
GlobalControllerExceptionHandler.java
컨트롤러에서 발생하는 전역 에러들을 처리하기 위한 Handler
package org.jjuni.swaggerjwt.common.excepion;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jjuni.swaggerjwt.common.dto.CommResponse;
import org.jjuni.swaggerjwt.common.enums.ResultCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalControllerExceptionHandler extends Exception {
// Handle validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<CommResponse<?>> handleValidationExceptions(MethodArgumentNotValidException ex, WebRequest request) {
log.error(ex.getMessage(), ex);
BindingResult bindingResult = ex.getBindingResult();
CommResponse<?> response = CommResponse.createFail(bindingResult);
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
// Handle JWT Exception Handler
@ExceptionHandler(JwtException.class)
public ResponseEntity<CommResponse<?>> handleJwtExceptions(JwtException ex, WebRequest request) {
log.error(ex.getMessage(), ex);
CommResponse<?> response = CommResponse.createError(ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
// Handle JWT Access Token Expired Handler
@ExceptionHandler(ExpiredJwtException.class)
public ResponseEntity<CommResponse<?>> handleExpiredJwtExceptions(ExpiredJwtException ex, WebRequest request) {
log.error(ex.getMessage(), ex);
CommResponse<?> response;
// Access, Refresh 두 다 ExpiredJwtException 으로 던져서 Refresh Token 에러 발생하는 부분으로 잡아서 판단....
// 내키지 않지만 현재 뚜렷한 방법이 떠오르지 않음...
if (ex.getMessage().contains("Refresh Token Expired")) {
response = CommResponse.createError(ResultCode.JWT_REFRESH_TOKEN_EXPIRED.getResultMessage());
} else {
response = CommResponse.createError(ResultCode.JWT_ACCESS_TOKEN_EXPIRED.getResultMessage());
}
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
// Handle all other exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<CommResponse<?>> handleAllExceptions(Exception ex, WebRequest request) {
log.error(ex.getMessage(), ex);
CommResponse<?> response = CommResponse.createError(ResultCode.INTERNAL_ERROR.getResultMessage());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
ResultCode.java
공통 에러코드 정의
package org.jjuni.swaggerjwt.common.enums;
public enum ResultCode {
SUCCESS(200, ResultMessage.SUCCESS),
UNAUTHORIZED(401, ResultMessage.UNAUTHORIZED),
NO_AUTH(403, ResultMessage.NO_AUTH),
INTERNAL_ERROR(500, ResultMessage.INTERNAL_ERROR),
ACCESS_NO_AUTH(1_000, ResultMessage.ACCESS_NO_AUTH),
ACCESS_TOKEN_EXPIRED(1_001, ResultMessage.ACCESS_TOKEN_EXPIRED),
REFRESH_TOKEN_EXPIRED(1_002, ResultMessage.REFRESH_TOKEN_EXPIRED),
VALID_NOT_PHONE_NUM(1_007, ResultMessage.VALID_NOT_PHONE_NUM),
VALID_NOT_PASSWORD(1_008, ResultMessage.VALID_NOT_PASSWORD),
MEMBER_NOT_EXIST(1_012, ResultMessage.MEMBER_NOT_EXIST),
LOGIN_REQUIRED(1_019, ResultMessage.LOGIN_REQUIRED),
PARAM_NOT_VALID(2_000, ResultMessage.PARAM_NOT_VALID),
// 사용자 미존재
NOT_FOUND_USER(3_000, ResultMessage.NOT_FOUND_USER),
// jwt 에러
JWT_NOT_FIND_TOKEN(4_000, ResultMessage.JWT_NOT_FIND_TOKEN),
JWT_TOKEN_PARSING(4_001, ResultMessage.JWT_TOKEN_PARSING),
JWT_ACCESS_TOKEN_EXPIRED(4_002, ResultMessage.JWT_ACCESS_TOKEN_EXPIRED),
JWT_REFRESH_TOKEN_EXPIRED(4_003, ResultMessage.JWT_REFRESH_TOKEN_EXPIRED),
;
private final int resultCode;
private final String resultMessage;
ResultCode(int resultCode, String resultMessage) {
this.resultCode = resultCode;
this.resultMessage = resultMessage;
}
public int getResultCode() {
return resultCode;
}
public String getResultMessage() {
return resultMessage;
}
public interface ResultMessage {
String SUCCESS = "완료 되었습니다.";
String UNAUTHORIZED = "인증에 실패하였습니다.";
String NO_AUTH = "접근 권한이 없습니다.";
String ACCESS_NO_AUTH = "접근 권한이 없습니다.";
String ACCESS_TOKEN_EXPIRED = "Access Token이 만료 되었습니다.";
String REFRESH_TOKEN_EXPIRED = "Refresh Token이 만료 되었습니다.";
String VALID_NOT_PHONE_NUM = "가입되지 않은 핸드폰 번호 입니다.";
String VALID_NOT_PASSWORD = "잘못된 비밀번호 입니다.";
String MEMBER_NOT_EXIST = "존재하지 않는 사용자 입니다.";
String LOGIN_REQUIRED = "로그인이 필요합니다.";
String PARAM_NOT_VALID = "파라미터 오류입니다.";
String INTERNAL_ERROR = "시스템 오류가 발생하였습니다. 다시 시도해주세요.";
// 사용자 에러
String NOT_FOUND_USER = "사용자 정보가 존재하지 않습니다.";
// JWT
String JWT_NOT_FIND_TOKEN = "토큰 정보가 누락되어있습니다.";
String JWT_TOKEN_PARSING = "토큰 파싱 에러가 발생하였습니다.";
String JWT_ACCESS_TOKEN_EXPIRED = "Access Token 이 만료되었습니다.";
String JWT_REFRESH_TOKEN_EXPIRED = "Refresh Token 이 만료되었습니다. 다시 로그인해주세요.";
}
}
Refresh Token 적용
application.yml
jwt 부분에 refresh token의 만료 시간 설정
##############################################
### jwt
##############################################
jwt:
access-expiration-time: 86400 # 1 일 (expTime 에서 이름 변경)
refresh-expiration-time: 604800 # 7 일
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
RefreshTokenEntity.java
Refresh Token을 발급 시 DB 에 저장하기 위한 Entity
Member 정보와 1:1 매핑
- 즉시로딩 : 데이터를 조회할 때, 연관된 모든 객체의 데이터까지 한번에 조회
- 지연로딩 : 필요한 시점에 연관된 객체의 데이털르 불러온는 것 (여기서는 즉시로딩을 할 필요가 없어서 지연로딩으로 셋팅)
package org.jjuni.swaggerjwt.auth.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.jjuni.swaggerjwt.member.entity.Member;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Comment("사용자별 Refresh Token 테이블")
@Table(name = "tb_user_refresh_token")
public class RefreshTokenEntity {
@Id
@Comment("Refresh token seq")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@MapsId
@Comment("아이디")
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id", unique = true)
private Member member;
@Comment("리프레시 토큰 정보")
@Column(nullable = false)
private String refreshToken;
@Comment("생성일")
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime created;
@Comment("수정일")
@UpdateTimestamp
@Column
private LocalDateTime updated;
public RefreshTokenEntity(Member member, String refreshToken) {
this.member = member;
this.refreshToken = refreshToken;
this.updated = LocalDateTime.now();
}
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
this.updated = LocalDateTime.now();
}
}
JwtUtil.java
Refresh Token 발급 및 검사
토큰 검증 후 Exception 상위로 던지게 처리
package org.jjuni.swaggerjwt.auth.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.jjuni.swaggerjwt.auth.repository.RefreshTokenRepository;
import org.jjuni.swaggerjwt.common.enums.ResultCode;
import org.jjuni.swaggerjwt.member.entity.Member;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.security.Key;
import java.time.ZonedDateTime;
import java.util.Date;
/**
* [JWT 관련 메서드를 제공하는 클래스]
*/
@Slf4j
@Component
public class JwtUtil {
private final Key key;
private final long accessTokenExpTime;
private final long refreshTokenExpTime;
private final RefreshTokenRepository refreshTokenRepository;
public JwtUtil(
@Value("${jwt.secret}") String secretKey,
@Value("${jwt.access-expiration-time}") long accessTokenExpTime,
@Value("${jwt.refresh-expiration-time}") long refreshTokenExpTime,
RefreshTokenRepository refreshTokenRepository
) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenExpTime = accessTokenExpTime;
this.refreshTokenExpTime = refreshTokenExpTime;
this.refreshTokenRepository = refreshTokenRepository;
}
////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////// Access Token ////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////
/**
* Access Token 생성
* @param member
* @return Access Token String
*/
public String createAccessToken(Member member) {
Claims claims = Jwts.claims();
claims.put("id", member.getId()); // 내부적으로 처리되는 ID (Long)
claims.put("userId", member.getUserId()); // 사용자가 사용하는 userId (String)
claims.put("email", member.getEmail());
claims.put("role", member.getRole());
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(accessTokenExpTime);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* Access Token 재발급
* - 이전 Token에서 Calim 정보 추출 후 다시 셋팅
* @param accessToken
* @return Access Token String
*/
public String reIssueAccessToken(String accessToken) {
// Access Tokne에서 Claim 추출
Claims parseClaims = parseClaims(accessToken);
Claims claims = Jwts.claims();
claims.put("id", parseClaims.get("id", Long.class)); // 내부적으로 처리되는 ID (Long)
claims.put("userId", parseClaims.get("userId", String.class)); // 사용자가 사용하는 userId (String)
claims.put("email", parseClaims.get("email", String.class));
claims.put("role", parseClaims.get("role", String.class));
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(accessTokenExpTime);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* Access Token 검증
* @param accessToken
* @return IsValidate
*/
public boolean validateAccessToken(String accessToken) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
throw e;
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
throw e;
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
throw e;
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
throw e;
}
}
////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////// Refresh Token ////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////
/**
* Refresh Token 생성
* - refresh token 은 Access Token 을 초기화 시키는 용도이기때문에 사용자정보를 굳이 담을 필요가 없음
* @return Refresh Token String
*/
public String createRefreshToken() {
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(refreshTokenExpTime);
return Jwts.builder()
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* Refresh Token 검증
* - 토큰 자체 유효성 확인
* - DB에 존재하는 Token 인지 확인
* - Refresh Token 만료 여부 확인
*
* @param refreshToken
* @return IsValidate
*/
@Transactional(readOnly = true)
public void validateRefreshToken(String refreshToken, String expiredAccessToken) throws ExpiredJwtException {
Long userId = 0L;
try {
// Refresh Token 유효성 검증
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(refreshToken);
// Access Token 에서 userId 조회
userId = getId(expiredAccessToken);
} catch (ExpiredJwtException e) {
log.error("Refresh Token Expired : {}", e.getMessage());
throw new ExpiredJwtException(null, null, "Refresh Token Expired");
} catch (Exception e) {
log.error("validate RefreshToken Token Fail : {}", e.getMessage());
throw e;
}
// DB 에서 정보 조회
refreshTokenRepository.findById(userId)
.filter(userRefreshToken -> userRefreshToken.getRefreshToken().equals(refreshToken))
.orElseThrow(() -> new ExpiredJwtException(null, null, ResultCode.JWT_REFRESH_TOKEN_EXPIRED.getResultMessage()));
}
////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////// 기타 정보 추출 ////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////
/**
* Token에서 ID 추출
* @param token
* @return ID (Long)
*/
public Long getId(String token) {
return parseClaims(token).get("id", Long.class);
}
/**
* Token에서 User ID 추출
* @param token
* @return User ID (String)
*/
public String getUserId(String token) {
return parseClaims(token).get("userId", String.class);
}
/**
* JWT Claims 추출
* @param accessToken
* @return JWT Claims
*/
public Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
RefreshTokenRepository.java
Refresh Token 조회 및 저장
package org.jjuni.swaggerjwt.auth.repository;
import org.jjuni.swaggerjwt.auth.entity.RefreshTokenEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
// Member에 있는 id(seq) 로 조회하는 쿼리
Optional<RefreshTokenEntity> findById(Long id);
}
SecurityConfig.java
- Refresh Token 재발급 경로 추가
package org.jjuni.swaggerjwt.config;
import lombok.RequiredArgsConstructor;
import org.jjuni.swaggerjwt.auth.jwt.JwtAuthorizationFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
~~~~~ H2 Driver Class Name 추출 이전과 동일 ~~~~~
// swagger, 로그인 예외 경로 설정
private static final String[] excludePath = {
"/",
"/swagger-ui/**",
"/swagger.html",
"/api-docs/**",
"/swagger-resource/**",
"/api/v1/auth/sign-in",
"/api/v1/auth/sign-up",
"/api/v1/auth/reissue-access-token" // 재발급 요청 API 예외 추가
};
~~~~~ 이전과 동일 ~~~~~
}
JwtAuthorizationFilter.java
Refresh Token 재발급 예외 경로 추가
JWT 공통 인증 에러 처리
Access Token 재발급
공통 에러 처리할때 Swtich 구문으로 쓰고싶었는데 jdk 21부터 지원한다그래서 if, instanceof로 처리..
package org.jjuni.swaggerjwt.auth.jwt;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
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.jjuni.swaggerjwt.common.dto.CommResponse;
import org.jjuni.swaggerjwt.common.enums.ResultCode;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import org.webjars.NotFoundException;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.List;
/**
* [지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증'을 확인]
*
* @author lee
* @fileName JwtAuthorizationFilter
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/swagger-ui/**",
"/swagger-resources/**",
"/swagger.html",
"/api-docs/**",
"/api/v1/auth/sign-in",
"/api/v1/auth/sign-up",
"/api/v1/auth/reissue-access-token", // refresh token 재발급
"/console/**", // H2
"/favicon.ico" // icon 인데 추가 안하니까 jwt에서 계속 에러 발생
);
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* JWT 토큰 검증 필터 수행
*/
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
throws IOException, ServletException {
// 요청 url 추출
String requestURI = request.getRequestURI();
boolean isExcludedPath = EXCLUDE_PATHS.stream()
.anyMatch(excludePath -> pathMatcher.match(excludePath, requestURI));
if (isExcludedPath) {
chain.doFilter(request, response);
return;
}
// OPTIONS 요청일 경우 => 로직 처리 없이 다음 필터로 이동
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
chain.doFilter(request, response);
return;
}
// Header를 확인합니다.
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
try {
// Header 내에 토큰이 존재하는 경우
if (authorizationHeader != null && !authorizationHeader.equalsIgnoreCase("")) {
// Header 내에 토큰을 추출
String accessToken = authorizationHeader.substring(7);
// 추출한 토큰이 유효한지 여부를 체크
if (jwtUtil.validateAccessToken(accessToken)) {
// [STEP4] 토큰을 기반으로 사용자 아이디를 반환 받는 메서드
String userId = jwtUtil.getUserId(accessToken);
logger.debug("[+] user id Check: " + userId);
// [STEP5] 사용자 아이디가 존재하는지 여부 체크
if (userId != null) {
//[STEP6] 사용자 정보 조회 후 security context 등록 (userId로 체크)
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} else {
throw new UsernameNotFoundException("해당하는 사용자가 없습니다.");
}
}
}
// 토큰이 존재하지 않는 경우
else {
throw new NotFoundException("토큰 정보가 누락되어있습니다.");
}
} catch (Exception e) {
log.error(e.getMessage());
// Token 내에 Exception이 발생 하였을 경우 => 클라이언트에 응답값을 반환하고 종료합니다.
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
PrintWriter printWriter = response.getWriter();
String newResponse = jsonResponseWrapper(e);
printWriter.print(newResponse);
printWriter.flush();
printWriter.close();
}
}
/**
* Access Token 만료로 재발급 요청 시 Access Token 재발급
*
* @param request
*/
public String reIssueAccessToken(HttpServletRequest request, String refreshToken) {
// 만료된 Access Token 추출
String expiredAccessToken = request.getHeader(HttpHeaders.AUTHORIZATION).substring(7);
// Refresh Token 검증(사용자 정보, DB 존재 여부, 만료 여부)
jwtUtil.validateRefreshToken(refreshToken, expiredAccessToken);
// Access Token 재발급
String newAccessToken = jwtUtil.reIssueAccessToken(expiredAccessToken);
// 사용자 정보 조회 후 security context 등록 (userId로 체크)
UserDetails userDetails = userDetailsService.loadUserByUsername(jwtUtil.getUserId(newAccessToken));
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
return newAccessToken;
}
/**
* Access Token 만료로 요청 시 Access Token 재발급
* - 미사용
* @param request
* @param response
* @param exception
*/
private void reIssueAccessToken(HttpServletRequest request, HttpServletResponse response, Exception exception) {
try {
// 만료된 Access Token 확인
String expiredAccessToken = request.getHeader(HttpHeaders.AUTHORIZATION).substring(7);
String refreshToken = request.getHeader("Refresh-Token");
// Refresh Token 검증(사용자 정보, DB 존재 여부, 만료 여부)
jwtUtil.validateRefreshToken(refreshToken, expiredAccessToken);
// Access Toen 재발급
String newAccessToken = jwtUtil.reIssueAccessToken(expiredAccessToken);
// 사용자 정보 조회 후 security context 등록 (userId로 체크)
UserDetails userDetails = userDetailsService.loadUserByUsername(jwtUtil.getUserId(newAccessToken));
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
response.setHeader("New-Access-Token", newAccessToken);
} catch (Exception e) {
request.setAttribute("exception", e);
}
}
/**
* 토큰 관련 Exception 발생 시 예외 응답값 구성
*
* @param e Exception
* @return JSONObject
*/
public static String jsonResponseWrapper(Exception e) throws JsonProcessingException {
CommResponse<?> newResponse;
// 토큰 정보가 누락된경우
if (e instanceof NotFoundException) {
newResponse = CommResponse.createError(ResultCode.JWT_NOT_FIND_TOKEN.getResultMessage());
}
// 일치하는 사용자 정보가 없는경우
else if (e instanceof UsernameNotFoundException) {
newResponse = CommResponse.createError(ResultCode.NOT_FOUND_USER.getResultMessage());
}
// JWT 토큰 만료
else if (e instanceof ExpiredJwtException) {
newResponse = CommResponse.createError(ResultCode.JWT_ACCESS_TOKEN_EXPIRED.getResultMessage());
}
// JWT 토큰내에서 오류 발생 시
else if (e instanceof JwtException) {
newResponse = CommResponse.createError(ResultCode.JWT_TOKEN_PARSING.getResultMessage());
}
// JWT 허용된 토큰이 아님
else if (e != null) {
newResponse = CommResponse.createError(ResultCode.UNAUTHORIZED.getResultMessage());
}
// 이외 JTW 토큰내에서 오류 발생
else {
newResponse = CommResponse.createError(e.getMessage());
}
ObjectMapper mapper = new ObjectMapper();
String jsonResponse = mapper.writeValueAsString(newResponse);
log.error(jsonResponse);
return jsonResponse;
}
}
Member.java
Lazy Loading 에러 나서 등록
package org.jjuni.swaggerjwt.member.entity;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.jjuni.swaggerjwt.auth.enums.RoleType;
import java.time.LocalDateTime;
@Entity
@Builder
@Table(name = "tb_user")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) // JPA에서 lazy관련 에러 날 경우 사용
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.AUTO) // jpa 자동생성
private Long id;
@Comment("아이디")
@Column(columnDefinition = "varchar(50)", nullable = false)
private String userId;
@Comment("비밀번호")
@Column(nullable = false)
private String password;
@Comment("이름")
@Column(columnDefinition = "varchar(10)", nullable = false)
private String name;
@Comment("전화번호")
@Column(columnDefinition = "varchar(14)")
private String phoneNum;
@Comment("이메일")
@Column(columnDefinition = "varchar(50)", nullable = false)
private String email;
// JWT 사용자 권한 추가
@Comment("권한")
@Enumerated(EnumType.STRING)
@Column(columnDefinition = "varchar(50)", nullable = false)
private RoleType role;
@Comment("생성일")
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime created;
@Comment("수정일")
@UpdateTimestamp
@Column(updatable = false)
private LocalDateTime updated;
}
Refresh Token 재발급 로직 추가
ApiResponse 공통 응답 모듈 추가
AuthController.java
package org.jjuni.swaggerjwt.auth.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import org.jjuni.swaggerjwt.auth.dto.SignInRequest;
import org.jjuni.swaggerjwt.auth.dto.SignInResponse;
import org.jjuni.swaggerjwt.auth.dto.SignUpRequest;
import org.jjuni.swaggerjwt.auth.dto.SignUpResponse;
import org.jjuni.swaggerjwt.auth.service.AuthService;
import org.jjuni.swaggerjwt.common.dto.CommResponse;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Auth", description = "회원가입, 로그인, 로그아웃 인증처리 API")
@RestController
@RequestMapping("/api/v1/auth")
@AllArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 사용자 회원가입
*
* @param request
* @return SignUpResponse
*/
@Operation(summary = "회원가입", description = "신규 사용자 회원가입")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User Create Success")
})
@ResponseBody
@PostMapping("sign-up")
public CommResponse<SignUpResponse> signUp(@Validated @RequestBody SignUpRequest request) throws Exception {
SignUpResponse signUpResponse = authService.signUp(request);
return CommResponse.createSuccess(signUpResponse);
}
/**
* 사용자 로그인
*
* @param request
* @return
*/
@Operation(summary = "로그인", description = "사용자 로그인 (jwt 토큰 발급)")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User Login Success")
})
@ResponseBody
@PostMapping("sign-in")
public CommResponse<SignInResponse> signIn(@Validated @RequestBody SignInRequest request) {
SignInResponse signInResponse = authService.signIn(request);
return CommResponse.createSuccess(signInResponse);
}
/**
* Access Token 재발급 요청
* - Header 에 있는 Refresh Token 으로 Access Token 재발급
*
* @header refresh-token
* @return
*/
@Operation(summary = "Access Token 재발급", description = "Access Token 만료로 인한 재발급")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Access Token")
})
@ResponseBody
@PostMapping("reissue-access-token")
public CommResponse<String> reIssueAccessToken(HttpServletRequest request, @RequestHeader("Refresh-Token") String refreshToken) throws Exception {
String newAccesToken = authService.reIssueAccessToken(request, refreshToken);
return CommResponse.createSuccess(newAccesToken);
}
}
AuthService.java
Refresh Token 만료 전 기존 Token 응답
Refresh Token 만료 시 새로운 Token 응답
package org.jjuni.swaggerjwt.auth.service;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.transaction.Transactional;
import jakarta.xml.bind.ValidationException;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jjuni.swaggerjwt.auth.dto.SignInRequest;
import org.jjuni.swaggerjwt.auth.dto.SignInResponse;
import org.jjuni.swaggerjwt.auth.dto.SignUpRequest;
import org.jjuni.swaggerjwt.auth.dto.SignUpResponse;
import org.jjuni.swaggerjwt.auth.entity.RefreshTokenEntity;
import org.jjuni.swaggerjwt.auth.jwt.JwtAuthorizationFilter;
import org.jjuni.swaggerjwt.auth.jwt.JwtUtil;
import org.jjuni.swaggerjwt.auth.repository.RefreshTokenRepository;
import org.jjuni.swaggerjwt.member.entity.Member;
import org.jjuni.swaggerjwt.member.repository.MemberRepository;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Slf4j
@Service
@AllArgsConstructor
public class AuthService {
private final MemberRepository memberRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final PasswordEncoder encoder;
private final JwtUtil jwtUtil;
private final JwtAuthorizationFilter jwtAuthorizationFilter;
/**
* 사용자 회원가입
*
* @param req
*/
@Transactional
public SignUpResponse signUp(SignUpRequest req) throws Exception {
// id로 사용자 정보 조회
Optional<Member> findMember = memberRepository.findByUserId(req.getUserId());
if (findMember.isPresent()) {
throw new ValidationException("이미 존재하는 사용자 입니다.");
}
Member newMember = req.toEntity();
try {
newMember.setPassword(encoder.encode(req.getPassword()));
} catch (Exception e) {
throw new Exception(e);
}
// 사용자 등록
try {
memberRepository.save(newMember);
} catch (Exception e) {
throw new Exception(e);
}
return SignUpResponse.toDto(newMember);
}
/**
* 사용자 로그인
* - jwt refresh token이 만료되었을때 에러처리하지 않고 update를 진행하기 위함
* @param req
*/
// @Transactional(dontRollbackOn = ExpiredJwtException.class)
public SignInResponse signIn(SignInRequest req) {
// id로 사용자 정보 조회
Optional<Member> memberInfo = memberRepository.findByUserId(req.getUserId());
if (!memberInfo.isPresent()) { // 사용자가 존재하지 않습니다.
throw new UsernameNotFoundException("존재하지 않는 사용자 입니다.");
}
Member member = memberInfo.get();
// 사용자 비밀번호 비교 (뒤에가 암호화 되지 않은 값이 와야함!!)
if (encoder.matches(member.getPassword(),req.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
// Access Token 발급
String accessToken = jwtUtil.createAccessToken(memberInfo.get());
// Refresh Token 정보 초기화
String refreshToken = null;
// refresh token 존재 여부 확인
// 토큰이 만료된 경우 재발급
refreshToken = refreshTokenRepository.findById(memberInfo.get().getId())
.map(it -> { // refresh token 이 있는 경우
return validateAndRefreshToken(it, accessToken);
})
// refresh token 이 없는 경우
.orElseGet(() -> {
log.info("신규 사용자 토큰 발급");
String newRefreshToken = jwtUtil.createRefreshToken();
refreshTokenRepository.save(new RefreshTokenEntity(member, newRefreshToken));
return newRefreshToken;
});
// access token 발급
return new SignInResponse(memberInfo.get().getName(), memberInfo.get().getRole(), accessToken, refreshToken);
}
/**
* Access Token 재발급
*
* @param req
*/
@Transactional
public String reIssueAccessToken(HttpServletRequest request, String refreshToken) throws Exception {
// id로 사용자 정보 조회
if (refreshToken == null) {
throw new Exception();
}
// access token 발급
return jwtAuthorizationFilter.reIssueAccessToken(request, refreshToken);
}
/**
* 로그인 시 refresh token이 만료되었는지 판단하여 return 해주는 함수
* - 만료 전 : 기존 DB 정보
* - 만료 후 : 신규 발급 및 DB 저장
* @param tokenEntity
* @param accessToken
* @return
*/
public String validateAndRefreshToken(RefreshTokenEntity tokenEntity, String accessToken) {
try {
jwtUtil.validateRefreshToken(tokenEntity.getRefreshToken(), accessToken);
log.info("Refresh Token 만료 전");
return tokenEntity.getRefreshToken();
} catch (ExpiredJwtException e) {
log.info("Refresh Token 만료로 인한 재발급");
String newRefreshToken = jwtUtil.createRefreshToken();
tokenEntity.updateRefreshToken(newRefreshToken);
refreshTokenRepository.save(tokenEntity);
return newRefreshToken;
}
}
}
TestController.java
공통 양식으로 TestContrller 수정
package org.jjuni.swaggerjwt.test;
import org.jjuni.swaggerjwt.common.dto.CommResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/test")
public class TestController {
@GetMapping("test")
@ResponseBody
public CommResponse<?> test() {
return CommResponse.createSuccess("요청 되는지 확인하는 테스트 url");
}
}
테스트
테스트를 진행하기 위해 Access Token, Refresh Token 만료 시간을 줄여서 테스트 진행 할 예정
전체적으로 잘 작동하는지 확인하기 위해 회원가입부터 진행 해볼 예정!!!!
1. 신규 사용자 회원가입
요청

응답

DB 적재 확인

2. 테스트 API 요청
로그인을 하지 않았기 때문에 토큰 정보가 없다고 나오는게 맞음!
요청

3. 로그인
요청

응답

DB 적재 확인

4. 테스트 API 요청
- Swagger 상단에 Authoriz 에 Access Token 입력 후 요청
요청

응답

5. 일정 시간 이후 다시 테스트 API 요청
요청

응답

6. Access Token 재발급
요청

응답

7. 재인증 및 테스트 API 요청
요청

응답

8. Refresh Token 만료
Access Token 재발급 시 Refresh Token이 만료된 경우
요청

응답

9. 재 로그인
- Reresh Token 만료로 재발급
요청

응답

DB

마치며
일전에 빠르게 작업을 해보려고 하였으나, 기본적으로 "json으로 사용자를 인증한다" 말고는 정확한 흐름을 알지 못하였었다. 물론 지금도 나름 최대한 오류들을 잡으려고 진행하였으며, 아직도 미흡한 부분들이 많다. 이 부분들은 추후에 계속 수정해나갈 예정이다.
(혹시나 안되거나 누락된 부분이 있다면 댓글 부탁드립니다.)
refs
Access Token, Refresh Token 차이 : https://velog.io/@chuu1019/Access-Token%EA%B3%BC-Refresh-Token%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C
🧐 Access Token과 Refresh Token이란 무엇이고 왜 필요할까?
JWT 토큰은 유저의 신원이나 권한을 결정하는 정보를 담고 있는 데이터 조각이다. JWT 토큰은 비밀키로 암호화되어 있기에 비교적 안전하다. 그런데 탈취 당했을 때가 문제다!! 어떻게 위험을 최
velog.io
JWT Token 길이는 얼마나 길어질까? : https://upcurvewave.tistory.com/612
JWT 토큰 길이가 과도하게 길어질 때 - 토큰 생성 로직 및 인증 프로세스 최적화 탐구
개요 사내에서 차세대 프로젝트에 참여하여 JWT 토큰 기반 인증 인가 개발을 할 기회가 있었다. 설계 단계에서 살펴 보니 필요한 정보를 JWT에 모두 담을 경우 토큰의 길이가 과도하게 길어질 수
upcurvewave.tistory.com
[기술 면접] JPA 즉시 로딩과 지연 로딩의 차이
JPA에서는 데이터를 조회할 때 즉시 로딩(EAGER)과 지연 로딩(LAZY) 두 가지 방식이 있다. 이 두 가지 방식을 간단하게 설명하면 즉시 로딩은 데이터를 조회할 때 연관된 데이터까지 한 번에 불러오
velog.io
스프링 API 공통 응답 포맷 개발하기
클라이언트 ↔︎ 서버 구조에서클라이언트는 서버에 요청을 보내고 서버는 요청에 대한 결과를 응답합니다.예를 들어 클라이언트가 1번 상품을 요청하는 경우 서버는 1번 상품을 조회해 응답하
velog.io
Spring: 예외 처리 - 쉽게 관심사 나누기 Global Exception Handler(Controller Advice)
스프링에서 예외처리를 할 때, 콜 스택의 연어가 되지 맙시다. 필요한 곳으로 딱 보냅시다.
velog.io
'BE > Java' 카테고리의 다른 글
| String, StringBuffer, StringBuilder 의 차이 (0) | 2024.07.11 |
|---|---|
| Springboot3 + Swagger + Jwt (4) (0) | 2024.06.25 |
| Security 필터 종류? (1) | 2024.06.25 |
| Springboot3 + SpringSecurity + H2 403 (0) | 2024.06.24 |
| Springboot3 + Swagger + Jwt (3) (0) | 2024.06.21 |