이전 프로젝트에서는 AccessToken, RefreshToken을 사용해 로그인을 구현했는데
그 당시 토큰에 대한 개념과 Spring Security 필터에 대한 지식이 제대로 잡힌 상태가 아니였기에 (지금은..?)
졸업 프로젝트에서 구현한 AccessToken을 구현하면서 내용을 정리해 보고자 한다.
setting
- Gradle 버전: 3.x.x
- java: 17
패키지 구조
노란색 하이라이트인 클래스만 사용해서 JWT필터와 AcessToken 발급 구현
토큰 발급이 주인 내용이므로 memberRepository나 login controller에 대한 내용은 간단하게 설명
Spring Security란?
jwt를 구현하기 위해서는 먼저 Sprint Security 필터에 대한 이해가 어느정도 필요하다.
해당 내용은 나중에 정리해서 올릴 예정..
그럼 바로 본론으로 들어가자
jwt 필터 등록하기
build.gradle에 아래와 같이 의존성을 추가해 준다.
build.gradle
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
jwt는 서버에 secretKey를 가지고 있어서 클라이언트가 토큰을 넘겨주는 이 secretKey와 대조해서 위조된 토큰인지 확인한다.
application.yml에 아래와 같이 jwt관련 설정을 해준다.
application.yml
jwt:
secretKey: base64로 인코딩된 암호 키, HS512를 사용할 것이기 때문에, 512비트(64바이트) 이상이 되어야 합니다. 영숫자 조합으로 아무렇게나 길게 써주자
access:
expiration: 3600000 # 1h
header: Authorization
secretKey 생성은 나같은 경우 git bash에 아래 명령어를 입력해서 무작위 값을 뽑았다.
openssl rand -hex 64
생성한 secretKey는 외부에 노출되지 않도록 조심하자! 이게 털리면 사용자의 개인정보 싹 다...
아래 과정은 내가 구현하지 않고, 팀원이 구현한 코드에 내가 일부만 수정했다.
JwtProvider 구현
- 토큰을 생성해주고,
- 토큰 claim 꺼내기,
- Authorization 헤더에 토큰 셋팅
전체 코드
package smw.capstone.common.provider;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;;
import smw.capstone.common.filter.CustomUserDetailsService;
import smw.capstone.entity.Member;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
@Component
@RequiredArgsConstructor
public class JwtProvider {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.header}")
private String accessHeader;
public String create(String email){
Date expiredDate = Date.from(Instant.now().plus(1, ChronoUnit.HOURS));
String jwt = Jwts.builder()
.signWith(SignatureAlgorithm.HS512, secretKey)
.setSubject(email).setIssuedAt(new Date()) //sum: email
.setExpiration(expiredDate)
.compact();
return jwt;
}
public String validate(String jwt){
Claims claims = null;
try{
claims = Jwts.parser().setSigningKey(secretKey)
.parseClaimsJws(jwt).getBody();
} catch (Exception exception){
exception.printStackTrace();
return null;
}
return claims.getSubject();
}
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus((HttpServletResponse.SC_OK));
response.setHeader(accessHeader, accessToken);
}
}
하나씩 살펴보자
1. 토큰 생성
public String create(String email){
Date expiredDate = Date.from(Instant.now().plus(1, ChronoUnit.HOURS));
String jwt = Jwts.builder()
.signWith(SignatureAlgorithm.HS512, secretKey)
.setSubject(email).setIssuedAt(new Date()) //sub: email
.setExpiration(expiredDate)
.compact();
return jwt;
}
- JwtBuilder 객체를 생성하고 Jwts.builder() 메서드를 이용한다.
- header 파라미터와 claims를 추가하기 위해 JwtBuilder 메서드를 호출한다.
- JWT를 서명하기 위해 SecretKey나 PrivateKey를 지정한다.
- 마지막으로 압축하고 서명하기 위해 compact()를 호출하고, jwt 생성
Header Parameters
JWT Header는 JWT의 claims와 관련된 컨텐츠, 형식, 암호화 작업에 대한 메타데이터를 제공한다.
여기서는 설정하지 않음.
Claims
Claims는 JWT의 body이고 JWT 생성자가 클라이언트에게 제시하는 바라는 정보가 포함된다.
Standard Claims
JWT의 표준 스펙에 대한 메서드 제공
setIssure: iss(Issure) Claim
setSubject: sub(Subject) Claim
setAudience: aud (Audience) Claim
setExpiration: exp (Expiration) Claim
setNotBefore: nbf (Not Before) Claim
setIssuedAt: iat (IssuedAt) Claim
setId: jit (JWT ID) Claim
Custom Claims
기본으로 등록된 클레임에 없는 사용자가 지정하는 클레임을 설정하고 싶을 때는
JwtBuilder claim 메소드를 이용한다.
String jwt = Jwts.builder()
.claim("email", "ex@example.com")
.withClaim("key", "value")
claim(): calim이 호출되면 Claims인스턴스에 key-value 쌍의 데이터가 추가되면서 기존에 있는 key-value는 덮어쓴다.
withClaim(): 다른 기술 블로그를 보면 대부분 withClaim을 사용해서 claim을 추가함.
Signing Key
JwtBuilder의 signWith메소드를 호출하여 sign key를 지정하고, JWT가 지정된 key가 허용된 가장 안전한 알고리즘을 결정하도록 하는 게 좋다.
예를 들어서 256bit(32bytes)길이의 secretKey를 사용하여 signWith를 호출하면 HS384나 HS512에 비해 충분치 않으므로, JWT는 HS256를 이용하여 JWT를 자동 서명할 것이다.
비슷하게 4096bit 길이의 RSA privateKey를 가지고 signWith를 호출하면 JWT는 RS512알고리즘을 사용하려고 하고 alg header에는 RS512를 자동으로 설정한다.
참고: publicKey를 이용한 JWT서명은 항상 불안정하기에 사용할 수 없다.
JWT는 어떤 publicKey를 이용한 서변도 InvalidKeyException을 던지고 거부한다.
yml파일에 HS512알고즘을 사용할 것이라고 가정하고 secretKey를 인코딩 했으므로,
signWith()함수로 HS512 알고리즘을 사용하겠다고 선언했다.
2. 토큰에서 claim 꺼내기 (사용자 정보)
public String validate(String jwt){
Claims claims = null;
try{
claims = Jwts.parser().setSigningKey(secretKey)
.parseClaimsJws(jwt).getBody();
} catch (Exception exception){
exception.printStackTrace();
return null;
}
return claims.getSubject();
}
try 블록에서 secretKey로 jwt 복호화 해주고 claims에서 subject를 꺼낸다 (여기는 sub가 email이다.)
3. Authorization 헤더에 토큰 세팅
일반적으로 Bearer 스키마를 사용하여 Authorization 헤더에 JWT를 보내므로,
토큰은 넘길 때에서 헤더에 넣어서 넘겨준다. 헤더의 내용은 다음과 같아야 한다.
Authorization: Bearer <token>
아래와 같이 헤더에 세팅을 해준다.
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus((HttpServletResponse.SC_OK));
response.setHeader(accessHeader, accessToken);
}
JwtAuthenticationFilter 구현
Spring Security를 잘 모른다면 이 필터가 사용자의 요청을 가로채서 jwt 토큰 유효성 검사를 진행한 후에 컨트롤러 로직이 돌아간다고 생각하자.
package smw.capstone.common.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import smw.capstone.common.exception.BusinessException;
import smw.capstone.common.exception.CustomErrorCode;
import smw.capstone.common.provider.JwtProvider;
import smw.capstone.repository.MemberRepository;
import java.io.IOException;
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private static final String NO_CHECK_URL = "/user"; // "/login"으로 들어오는 요청은 Filter 작동 X
private final MemberRepository memberRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try{
if(request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response);
return;
}
String token = parseBearerToken(request);
String email = jwtProvider.validate(token);
if(email==null){
filterChain.doFilter(request, response);
return;
}
}catch (NullPointerException e){
// e.printStackTrace();
throw new BusinessException(CustomErrorCode.EMPTY_TOKEN);
}
filterChain.doFilter(request, response);
}
private String parseBearerToken(HttpServletRequest request){
String authorization = request.getHeader("Authorization");
if (authorization == null) {
log.error("Authorization 헤더가 비어있습니다.");
throw new NullPointerException();
}
boolean hasAuthorization = StringUtils.hasText("authorization");
if (!hasAuthorization) {
log.error("authorization 헤더가 비어있습니다");
throw new NullPointerException();
}
boolean isBearer = authorization.startsWith("Bearer ");
if (!isBearer) {
log.error("Bearer로 시작하지 않습니다.");
throw new NullPointerException();
}
String token = authorization.substring(7);
return token;
}
}
로그인을 요청한 url은 jwt 필터를 무시하도록 하자
if(request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response);
return; //return 을 안하면 그냥 그대로 필터로직을 수행하게 된다.
}
로그인을 하면 jwt를 발급해줘야하는데 login을 요청한 url은 jwt 검증 필터를 거칠 필요가 없음.
로그인을 요청한 클라이언트에게는 로그인 정보가 맞는지 확인하고 토큰을 발급해주면 된다
회원가입, 로그인을 제외한 다른 요청에 대해서는 헤더에 토큰이 있을 것이므로,
헤더에 있는 토큰을 검증하기 위해 필터를 구현하자.
헤더에 토큰이 있는지 검사하자
private String parseBearerToken(HttpServletRequest request){
String authorization = request.getHeader("Authorization");
if (authorization == null) {
log.error("Authorization 헤더가 비어있습니다.");
throw new NullPointerException();
}
boolean hasAuthorization = StringUtils.hasText("authorization");
if (!hasAuthorization) {
log.error("authorization 헤더가 비어있습니다");
throw new NullPointerException();
}
boolean isBearer = authorization.startsWith("Bearer ");
if (!isBearer) {
log.error("Bearer로 시작하지 않습니다.");
throw new NullPointerException();
}
String token = authorization.substring(7);
return token;
}
토큰은 Authorization 이라는 헤더에 Bearer <token> 형식으로 request가 올것이다.
- requset.getHeader("Autorization")으로 해당 헤더 내용을 가져온다.
- authorization.startWith("Bearer "): Authorization 헤더 내용으 "Bearer " 로 시작하지 않으면 예외를 던진다
- authorization.substring(7): "Bearer "를 제외한 토큰 내용을 뽑아낸다.
JWT필터 등록하기
package smw.capstone.config;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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.configurers.HttpBasicConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import smw.capstone.common.filter.JwtAuthenticationFilter;
import java.io.IOException;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CorsConfig corsConfig;
@Bean
protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception{
httpSecurity
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(new FailedAuthenticationEntryPoint()))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
class FailedAuthenticationEntryPoint implements AuthenticationEntryPoint{
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("{ \"code\": \"NP\", \"message\": \"Do not have permission. \" }");
}
}
}
코드를 살펴보자
httpSecurity.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
사용자의 요청이 오면 제일 먼저 토큰 검사를 해서 유효한 토큰인지 확인해야겠죠?
그럼 Spring Security의 다른 필터보다 먼저 거쳐야하므로 Spring Security에서 요청을 제일 먼저 받는 UsernamePasswordAuthenticationFilter 앞에 JWT 필터를 두면 된다.
httpSecurity.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(new FailedAuthenticationEntryPoint()))
인증, 인가에서 error 발생시 후 처리를 설정해줄 필요가 있는데,
그게 위 코드이다.
Spring Bean에 넣어서 처리하는 방법이다.
인증 예외(status = 401)가 발생하면 등록한 FailAuthenticationEntryPoint가 수행된다.
controller 동작해서 확인해보자
@PostMapping("/user")
public ResponseEntity<String> login(HttpServletResponse response, @RequestBody LoginDTO form){
Member member = new Member();
member = memberService.login(form.getUsername(), form.getPassword());
jwtProvider.sendAccessToken(response, jwtProvider.create(member.getEmail()));
return ResponseEntity.ok().body("access_toekn 헤더 설정 완료");
}
참고자료
'Spring > Spring Security' 카테고리의 다른 글
Spring Security를 사용해서 회원가입, 로그인 구현하기 - 자체회원가입, oauth 활용(0) (0) | 2024.04.29 |
---|---|
Spring Security를 사용해서 로그인 구현하기 - JWT(1) (1) | 2024.04.28 |
Spring Security를 사용해서 로그인 구현하기 - JWT와 세션(0) (0) | 2024.04.27 |