프로젝트(개인)
JWT 인증과 Session 인증의 복합 사용!
그zi운아이
2024. 2. 10. 18:40
1. 소개: JWT와 세션 인증의 기본 개념 및 장단점
JWT (JSON Web Token) 인증
- 기본 개념: JSON 객체를 사용하여 사용자의 정보를 안전하게 전송하는 방법입니다. 토큰 자체에 정보를 담고 있어 별도의 인증 저장소가 필요 없습니다.
- 구성 요소:
- Header: 토큰의 유형(JWT)과 해싱 알고리즘 정보(HS256, RS256 등)를 담습니다.
- Payload: 사용자 식별자, 발행자, 유효 기간 등의 클레임(claim) 정보를 담습니다.
- Signature: 서버의 비밀키로 해시하여 생성된 서명으로, 토큰의 무결성과 유효성을 보장합니다.
- 장점:
- Stateless: 서버가 사용자의 상태를 저장하지 않기 때문에 서버 부하를 줄일 수 있습니다.
- 확장성: 다수의 서버나 다양한 도메인 간 통신에 유용합니다.
- 단점:
- 보안 취약성: 토큰이 탈취되면 정보가 노출될 수 있으며, 유효 기간이 길거나 만료가 되지 않으면 리스크가 증가합니다.
세션 인증
- 기본 개념: 서버가 사용자의 상태를 유지하는 방식으로, 세션 ID를 통해 사용자를 관리합니다.
- 장점:
- 보안성: 사용자 정보가 서버에 저장되어 있어 JWT보다 보안성이 높다고 할 수 있습니다.
- 제어 용이성: 서버에서 세션을 관리하기 때문에, 사용자의 로그인 상태를 쉽게 제어할 수 있습니다.
- 단점:
- 서버 부하: 세션 정보를 서버에 저장해야 하므로, 사용자가 많아질수록 서버 부하가 증가합니다.
- 확장성 제한: 서버 환경이 확장될 경우 세션 관리가 복잡해질 수 있습니다.
2. 구현 전략: JWT와 세션 인증 동시 사용
전략 배경
- 프로젝트 초기에는 세션 인증 방식과 JWT 중 어느 것을 사용할지 고민했습니다.
- 서버 부하가 크지 않을 것으로 예상되어 세션 인증 방식을 선택했으나, 추가적인 보안 강화를 위해 JWT 사용을 고려하게 되었습니다.
결정 과정
- 서버 환경과 프로젝트 규모를 고려해 세션 인증 방식이 적합하다고 판단했습니다.
- 하지만, 사용자 정보를 직접 저장하는 대신, JWT를 활용해 토큰을 세션에 저장하는 방식을 도입하기로 결정했습니다.
- 이 접근법은 세션 인증의 안정성과 JWT의 보안성을 결합한 것입니다.
- 사용자 정보 대신 토큰을 세션에 저장함으로써 보안을 강화하고 데이터 노출 위험을 줄일 수 있습니다.
구현 목표
- 두 인증 방식의 장점을 취합하여 보안성이 강화된 사용자 인증 시스템을 구축하는 것이 목표입니다.
- 서버 부하 관리와 사용자 인증의 보안을 동시에 달성할 수 있는 방식을 탐색하고 적용하는 것입니다.
3. 개발과정
1. 구글 인증을 통한 사용자 인증 과정
- 구글 OAuth2 인증: 사용자가 구글 계정으로 인증을 진행하고, 성공적으로 인증이 완료되면 JWT 토큰을 발급받습니다. (참고 포스팅: Google OAuth2 인증)
- 세션에 토큰 저장: 인증 과정에서 발급받은 토큰을 서버의 세션에 저장합니다. 이는 사용자의 인증 정보를 유지하는 데 사용됩니다.
2. 요청 인증 과정과 SecurityContext의 활용
- JWTService에서의 토큰 생성 및 검증 : 유저정보를 이용한 토큰 발급을 하고 검증합니다.
- JwtFilter에서의 토큰 검증: 각 요청이 서버에 도착할 때마다, JwtFilter는 세션에서 JWT 토큰을 꺼내 해당 토큰의 유효성을 검증합니다.
- SecurityContext에 인증 정보 저장: 초기에는 토큰의 유효성 검증 후 별도의 처리를 하지 않았지만, Controller 에서 User정보를 가지고 올떄 Session에서 토큰을 꺼내고 복호화 하는 과정이 중복되는 문제를 해결하기 위해 유효한 토큰의 사용자 정보를 SecurityContext에 저장하여, 중복된 인증 과정을 최소화했습니다.
jwt 생성 및 검증 서비스
@Service
public class
JwtService {
//yml 에 정의된 secret,토큰만료 시간
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.refreshExpiration}")
private Long refreshExpiration;
// 엑세스토큰 발급
public String generateAccessToken(User user) {
return generateToken(user.getId(), expiration);
}
//리프레시 토큰 발급
public String generateRefreshToken(User user) {
return generateToken(user.getId(), refreshExpiration);
}
//토큰 생성
private String generateToken(Long userId, long expirationTime) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expirationTime);
return Jwts.builder()
.setSubject(userId.toString())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
//토큰 검증
public boolean validateAccessToken(String accessToken) {
try {
return isTokenTimeValid(accessToken);
} catch (ExpiredJwtException e) {
return false;
} catch (JwtException e) {
throw new BusinessException(ErrorMessages.Invalid_Token);
}
}
//리프레시 토큰으로 엑세스 토큰 재발급
public String renewAccessTokenUsingRefreshToken(String refreshToken) {
try {
Claims claims = parseToken(refreshToken).getBody();
Long userId = Long.valueOf(claims.getSubject());
return generateToken(userId, expiration);
} catch (JwtException e) {
throw new BusinessException(ErrorMessages.Invalid_Token);
}
}
// payload 에 저장된 유저아이디 가지고 오기
public Long getUserIdByParseToken(String token){
Jws<Claims> claimsJws = parseToken(token);
String subject = claimsJws.getBody().getSubject();
return Long.parseLong(subject);
}
private Jws<Claims> parseToken(String token) throws JwtException {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token);
}
// 시간 검증
private boolean isTokenTimeValid(String token) throws JwtException {
Jws<Claims> claims = parseToken(token);
return !claims.getBody().getExpiration().before(new Date());
}
}
@Component
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
@Autowired
private JwtService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 허용된 경로에 대한 요청인지 확인하고, 맞다면 필터 체인을 계속 진행합니다.
if (isPermitAllPath(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
try {
// 요청으로부터 토큰을 검증하고 인증 컨텍스트를 설정합니다.
processTokenAuthentication(request);
filterChain.doFilter(request, response);
} catch (BusinessException e) {
log.error("Token validation error: {}", e.getMessage(), e);
throw e; // 비즈니스 예외 처리
} catch (Exception e) {
log.error("Unexpected error: {}", e.getMessage(), e);
throw new BusinessException(ErrorMessages.Invalid_Token); // 예상치 못한 예외 처리
}
}
// 요청에서 토큰을 추출하고 인증 과정을 처리합니다.
private void processTokenAuthentication(HttpServletRequest request) {
HttpSession session = request.getSession();
String accessToken = getTokenFromSession(session, "accessToken");
String refreshToken = getTokenFromSession(session, "refreshToken");
// 액세스 토큰이 유효하면 인증 컨텍스트를 설정합니다.
if (accessToken != null && jwtService.validateAccessToken(accessToken)) {
setAuthenticationContext(jwtService.getUserIdByParseToken(accessToken));
} else if (refreshToken != null) {
// 리프레시 토큰이 있다면 액세스 토큰을 갱신하고 인증 컨텍스트를 설정합니다.
String newAccessToken = jwtService.renewAccessTokenUsingRefreshToken(refreshToken);
session.setAttribute("accessToken", newAccessToken);
setAuthenticationContext(jwtService.getUserIdByParseToken(newAccessToken));
}
}
// 세션에서 토큰을 가져옵니다.
private String getTokenFromSession(HttpSession session, String tokenName) {
return (String) session.getAttribute(tokenName);
}
// 인증 정보를 SecurityContext에 설정합니다.
private void setAuthenticationContext(Long userId) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 요청 URI가 허용된 경로에 속하는지 확인합니다.
private boolean isPermitAllPath(String requestURI) {
return requestURI.startsWith("/api/auth/google")
|| requestURI.startsWith("/swagger-ui")
|| requestURI.startsWith("/v3/api-docs") || requestURI.startsWith("/h2-console");
}
}
3. 커스텀 어노테이션을 통한 사용자 정보 접근
- @CurrentUserId 어노테이션: SecurityContext에 저장된 인증 정보를 쉽게 접근할 수 있도록 커스텀 어노테이션을 생성하고, 이를 컨트롤러의 매개변수에 적용하여 사용자 ID를 직접적으로 얻을 수 있습니다.
커스텀 어노테이션(@CurrentUserId) 정의:
// 메소드의 파라미터에 적용될 수 있도록 설정
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME) // 런타임까지 어노테이션 정보를 유지
public @interface CurrentUserId {
}
HandlerMethodArgumentResolver를 구현한 CurrentUserIdArgumentResolver:
public class CurrentUserIdArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
// @CurrentUserId 어노테이션이 적용된, Long 타입의 파라미터만 처리
return parameter.getParameterType().equals(Long.class) && parameter.hasParameterAnnotation(CurrentUserId.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// Authentication 객체에서 사용자 ID 추출하여 반환
return (Long) authentication.getPrincipal();
}
}
커스텀 어노테이션(@CurrentUserId) 사용
4. 재고해야 할 점과 대안
제가 이러한 복합 인증 방식을 선택하며, 주된 목적은 세션의 토큰 탈취에 대한 우려를 줄이는 것이었습니다. 하지만, 실제로는 세션 자체의 토큰 탈취가 발생할 확률은 매우 낮으며, 이에 대한 걱정보다는 인증 단계를 더욱 견고하게 만드는 것이 중요다고 합니다.
- HTTPS의 적극적 사용: 모든 데이터 전송 과정에서 HTTPS를 사용하여, 데이터의 안전한 전송을 보장하고 중간자 공격을 방지합니다.
- 강력한 인증 메커니즘 도입: 다단계 인증(Multi-Factor Authentication, MFA)과 같은 강력한 인증 메커니즘을 도입하여, 인증 과정의 보안을 강화합니다.
- 최신 보안 패치 및 업데이트 적용: 서버와 애플리케이션에 최신 보안 패치와 업데이트를 지속적으로 적용하여, 알려진 취약점으로부터 보호합니다.
- IP 화이트리스트: 특정 IP 대역에서만 서비스에 접근할 수 있도록 함으로써, 무분별한 접근을 차단합니다.
다음 포스팅은 https 적용에 대해 다뤄보도록 하겠습니다! 감사합니다 화이팅!!!!