본문 바로가기

카테고리 없음

스프링 시큐리티 + JWT

 

첫 프로젝트를 진행할 때 security와 JWT를 사용해서 인증 인가를 구현하려고 했는데, 

온갖 클론 포스팅에 원하는 정보를 얻지 못해 지쳐 내가 직접 쓰는 글.

스프링에서 시큐리티와 함께 JWT를 사용하려는 사람들에게 도움이 됐으면 좋겠다.


1. JWT란 ? 

JWT는 Json Web Token의 약자로, 웹에서 클라이언트와 서버가 통신할 때 사용되는 JSON 형식의 토큰에 대한 표준 규격이며 인증에 필요한 정보들을 암호화시켜서 가지고 있다.

사용자는 로그인으로 인증과정을 거친 후 받은 JWT를 헤더에 실어 보내고, 서버가 클라이언트를 식별하는 인가 과정에서 사용된다.

 

JWT의 구조는 3가지로 나누어진다.

XXXXXX.YYYYYY.ZZZZZZ

헤더.내용.서명

  • Header : JWT에서 사용할 타입과 해시 알고리즘의 종류가 담겨 있다.
{
	"alg": "HS256",
	"typ": "JWT"
}
  • alg : 서명 암호화 알고리즘
  • typ : 토큰 유형

 

  • Payload : 토큰에서 사용할 정보의 조각들인 Claim이 담겨있다. (실제로 사용될 정보에 대한 내용)
{
	"sub": "123467890",
	"name": "Seon Ho",
	"iat": 1516239022
}

정해진 타입은 없지만, 대표적으로 Registered claims, Public claims, Private Claims 이렇게 세 가지로 나뉜다.

  • Registered claims
    • iss(issuer) : 발행자
    • exp(expireation time) : 만료 시간
    • sub(subject) : 제목
    • iat(issued At) : 발행 시간
    • jti(JWI ID)
  • Public claims : 사용자가 정의할 수 있는 클레임 공개용 정보 전달을 위해 사용
  • Private claims : 해당하는 당사자들 간에 정보를 공유하기 위해 만들어진 사용자 지정 클레임. 외부에 공개되도 상관없지만 해당 유저를 특정할 수 있는 정보들을 담는다.

 

  • Signature : Header, Payload를 Base64 URL-safe Encode를 한 이후 Header에 명시된 해시 함수를 적용. 개인키로 서명한 전자 서명이 담겨있다.
    • Header와 Payload는 단순하게 인코딩된 갑시기 때문에 복호화 및 조작할 수 있지만, Signature는 서버에서 관리하는 비밀키가 유출되지 않는 이상 복호화할 수 없다. 따라서 Signature는 토큰의 위변조 여부를 확인하는데 사용된다.

2. JWT 인증 과정

  1. 사용자가 아이디, 비밀번호를 입력하여 로그인 요청을 한다.
  2. 서버에서는 데이터베이스에 저장된 사용자를 확인하고, Access Token과 Refresh Token을 발급하여 각각 헤더와 HttpOnly 쿠키에 실어서 응답해준다.
  3. 클라이언트는 서버로부터 받은 JWT를 저장해둔다. (로컬 스토리지, 쿠키 등)
  4. 사용자가 어떤 요청을 할 때 헤더에 Access Token을 함께 실어서 보낸다.
  5. 서버는 헤더에 담긴 Access Token을 검증하여 유효한 토큰이라면 정상적으로 응답해준다.
  6. 사용자가 어떤 요청과 함께 헤더에 Access Token을 실어서 보냈는데, 확인해보니 Access Token이 만료되었다면 클라이언트에 401 UNAUTHORIZED 응답으로 Access Token을 재발급하라는 응답을 보낸다. 
  7. 클라이언트는 Refresh Token과 함께 Access Token을 재발급하는 요청을 한다.
  8. 서버는 Refresh Token을 검증 후 유효하다면 응답으로 Access Token을 재발급해준다.
  9. Refresh Token도 만료되거나 유효하지 않다면, 다시 한 번 401 UNAUTHORIZED으로 응답해주고, 클라이언트는 다시 로그인하라는 메세지와 함께 로그인 페이지로 리다이렉트 한다.

3. 토큰이 신뢰성을 가지는 이유

유저 U의 JWT : A (Header) + B (Payload) + C(Signature) 라면,

  • 다른 유저 H가 B를 임의로 수정 => 유저 H의 JWT : A + B' + C
  • 수정한 토큰을 서버에 요청을 보내면, 서버는 토큰의 유효성을 검사
    • 서버에서 검증한 유저 H의 JWT : A + B' + C' => signature 불일치 (signature는 서버에서의 암호키 + 헤더 + 페이로드를 헤더에서 정의한 알고리즘으로 암호화하기 때문)
  • 대조 결과가 일치하지 않으므로 유저의 정보가 임의로 조작되었음을 알 수 있음.

 

서버는 토큰 안의 정보가 무엇인지 아는 게 중요한 것이 아니라, 해당 토큰이 유효한 토큰인지 확인하는 것이 중요하다. 

클라이언트로부터 받은 JWT의 헤더, 페이로드를 서버의 암호키값을 이용해 시그니처를 다시 만들고, 이를 비교하여 일치하는 경우에 인증을 통과시킨다. 

 


4. JWT의 장단점

쿠키 & 세션과 비교한다.

 

장점

  • 쿠키 & 세션을 사용한다면 세션 저장소 (ex. redis)를 따로 사용해야 하고, 서버가 클라이언트로부터 받은 세션 ID를 세션 저장소에서 확인하는 과정에서 서버의 부하가 심해질 수 있지만, JWT를 사용한다면 세션 저장소나 DB 등을 거치지 않고 토큰 검증만을 통해서 사용자를 식별할 수 있음 (서버의 부하가 덜어짐)
  • 쿠키 & 세션을 사용한다면 scale out 시에 세션 불일치 같은 문제가 없어서 확장성 측면에서 좀 더 편리하다.
  • 인증 처리 과정의 속도가 더 빠르다.

단점

  • 토큰의 길이가 길어질 수록 네트워크의 부하가 증가한다.
  • Payload 자체는 암호화가 되지 않기 때문에 중요한 정보는 담을 수 없다.
  • 토큰이 탈취당했을 때의 대처가 어렵다. (토큰을 강제로 만료시키기가 어려움)

 

Access Token과 Refresh Token

JWT의 단점이 토큰이 탈취당했을 때의 대처가 어렵다는 것이다. 쿠키 & 세션을 사용하는 경우 세션을 강제로 만료시켜버릴 수가 있지만, JWT의 경우에는 그것이 어렵다.

내가 JWT를 처음 사용할 때는 토큰값 또한 DB에 저장해두고 검증하는 방식으로 구현한다면 강제로 만료시킬 수 있지 않을까? 라고 생각했었는데, 이렇게 한다면 결국 쿠키 & 세션과 마찬가지로 인증 인가 과정에서 DB를 거쳐야 하기 때문에 서버의 부하가 생기고, 속도가 느려지고,구현에 따라 확장성 측면에서의 장점이 없어질  수도 있다는 것이다.

(아주 초보적인 생각)

 

Access Token은 탈취당했을 때 해커가 그 토큰으로 서버에 요청을 한다면, 원래 토큰의 주인 행세를 할 수 있다. 이 때 서버에서는 토큰을 만료시킨다던가 또는 어떤 조취를 취할 수 있는게 없으므로 Access Token의 만료 시간을 보통 30분 ~ 1시간 정도로 짧게 설정한다.

그렇다면 토큰이 만료되면 ?

=> 이때 사용하는 것이 Refresh Token이다. 

  • Refresh Token은 새로운 Access Token을 발급해주기 위해서만 사용하는 토큰이다. Refresh Token 없이 Access Token만 사용하면서 만료시간을 짧게 설정하면 그만큼 사용자는 로그인을 자주 다시 해야할 것이다.
    • 그래서 Access Token에는 실제 사용자의 정보를 담고 있고 만료 시간이 짧지만, Refresh Token의 경우에는 사용자의 정보를 담지 않고 데이터베이스에 유저 정보와 함께 기록해둔다.

정리하자면 Refresh Token은 Access Token을 재발급해주기 위한 토큰이며, JWT를 위한 JWT라고 말할 수 있다.

 


5. 시큐리티 + JWT 구현 

내가 구현한 방식 : 

로그인 시에 access token과 refresh token을 발급해주는데,

access token에는 email과 user roles를 저장

refresh token에는 random UUID 값을 value로 담고 DB의 유저 정보에 random UUID 값을 저장한다.

 => refresh token의 value는 로그인 시마다 바뀌고 발급된다.

refresh token 또한 만료되면 401 UNAUTHORIZED를 또 응답해주는데, 이 때는 클라이언트가 다시 로그인 하라는 메세지와 함께 로그인 페이지로 리다이렉트할 예정.

  • build.gradle 
dependencies {
	...
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
	...
}

 

  • jwt 시크릿키가 노출되면 안 되므로 application-secret.properties 생성 후 application.properties에 추가
spring.profiles.include=secret

 

  • application-secret.properties에 시크릿 키 추가
    • 시크릿 키의 경우 최소 512 bits(64글자) 이상의 값을 설정하는 것을 권장함. 문자의 길이가 짧고 쉬울수록 브루트포스 어택에 취약하다. 
jwt.secret=asdfuaigaeliruhlsiUAVHlsAIUvheatfdAFF~~

 

  • SecurityConfig 추가
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf((csrf) -> csrf.disable())
                .cors((cors) -> cors.configurationSource(corsConfigurationSource()))
                .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authR -> {
                	authR....~~

                })
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) // 중요
                .addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class); // 중요

        return http.build();

    }



    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOrigin("*");
        configuration.addAllowedMethod("*");
        configuration.addAllowedHeader("*");
        configuration.addExposedHeader("*"); // CORS 문제, 포스트맨에는 보이지만 클라이언트에서 안 보이는거 해결
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


}

 

  • JwtAuthenticationFilter 추가
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = jwtProvider.resolveToken(request);

        if (token != null && jwtProvider.isTokenValid(token)) {

            Authentication authentication = jwtProvider.getAuthentication(token);

            SecurityContextHolder.getContext().setAuthentication(authentication);

        }
        filterChain.doFilter(request, response);
    }
}

 

  • JwtExceptionFilter 추가 (매우 중요)
public class JwtExceptionFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (JwtException e) { {
            log.error(e.getMessage());
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }}
    }

}

 

처음엔 토큰이 만료되었거나, 유효하지 않은 값이 들어왔을 때 JwtAuthenticationFilter에서 ExceptionHandler를 사용해서 예외를 던지려고 했는데 401 UNAUTHORIZED를 응답해주려고 했는데, ExceptionHandler는 SecurityFilter에서 발생한 예외를 핸들링 해주지 못해서 403 FORBIDDEN이 응답되었다. 그러면 클라이언트는 토큰이 만료된거나 유효하지 않은 건지, 아니면 다른 예외가 있는 건지 알 수가 없다.

ExceptionHandler가 예외를 처리할 수 없는 이유는 filter는 dispatcher servlet보다 앞에 존재하고, handler intercepter는 뒤에 존재하기 때문이다.
따라서, 새로운 Filter를 정의해서 FilterChain에 추가해주어야 함.

 

그래서 SecurityConfig의 SecurityFilterChain에 이 코드가 추가된다.

.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class);

이렇게 하면 filter에서 발생한 JWT 예외를 JwtExceptionFilter가 처리하게 되는 것이고, 401 UNAUTHORIZED를 응답할 수 있다.

 

  • JwtProvider 추가. JWT 발급과 유효성을 검사함.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtProvider {


    @Value("${jwt.secret}")
    private String secretKey;
    private final long ACCESS_TOKEN_VALID_TIME = 2 * 30 * 60 * 1000L;   // 60분
    private final long REFRESH_TOKEN_VALID_TIME = 60 * 60 * 24 * 14 * 1000L;   // 2주


    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public String createAccessToken(String email, List<String> roles) { // access token 발급.
        Claims claims = Jwts.claims().setSubject(email); // payload에 이메일 저장, 나는 email이 pk임.
        claims.put("roles", roles); // 유저의 권한 저장.
        Date now = new Date();
        Date expiration = new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME); // 만료시간 설정

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public String createRefreshToken(String value) { // refresh token 발급.
        Claims claims = Jwts.claims();
        claims.put("value", value); // 사용자의 정보가 아닌 UUID 랜덤값이 저장.
        Date now = new Date();
        Date expiration = new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public String resolveToken(HttpServletRequest request) { // access token을 받을 때, header에서 Authorization이름의 값을 가져옴.
        if (request.getHeader("Authorization") != null)
            return request.getHeader("Authorization").substring(7); // 관습적으로 토큰 값 앞에 Bearer를 붙이기 때문.

        return null;
    }


    public Authentication getAuthentication(String token) {
        User user = new User();
        String email = getEmail(token);
        user.setEmail(email);
        user.setRoles(getRoles(token));
        return new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities());
    } 


    public String getEmail(String token) {
        return getClaimsFromToken(token).getBody().getSubject();
    }

    public List<String> getRoles(String token) {
        return (ArrayList<String>) getClaimsFromToken(token).getBody().get("roles");
    }

    public boolean isTokenValid(String token) { 
        try {
            Jws<Claims> claims = getClaimsFromToken(token);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (ExpiredJwtException e) {
            throw new JwtException("[expired jwt] token : " + token);
        } catch (SecurityException e) {
            throw new JwtException("[wrong signature] token : " + token);
        } catch (MalformedJwtException e) {
            throw new JwtException("[invalid jwt] token : " + token);
        } catch (UnsupportedJwtException e) {
            throw new JwtException("[unsupported JWT] token : " + token);
        }
    }

    public Jws<Claims> getClaimsFromToken(String token) throws JwtException {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
    }


    public String getRefreshTokenValue(String token) {
        Jws<Claims> claims;

        try {
            claims = getClaimsFromToken(token);
        } catch (ExpiredJwtException e) {
            throw new InvalidRefreshTokenException("[expired jwt] token : " + token);
        } catch (SecurityException e) {
            throw new InvalidRefreshTokenException("[wrong signature] token : " + token);
        } catch (MalformedJwtException e) {
            throw new InvalidRefreshTokenException("[invalid refresh token] : " + token);
        } catch (UnsupportedJwtException e) {
            throw new InvalidRefreshTokenException("[unsupported JWT] token : " + token);
        }

        return (String) claims.getBody().get("value");

    }

}

 

나는 Authentication의 principal로 user를 사용했다. 

그러면 어떤 요청이 왔을 때 나의 정보 Primary Key인 email만 필요하다면, 

Access Token 안에 있는 email과 roles로 user 엔티티를 만들어서 DB를 조회하지 않고 바로 토큰 안의 email을 사용할 수 있다.

ex ) 내가 쓴 게시글 조회

=> 토큰 안에 담긴 email로 select * from board where board.user_email = email 가능

 

  • 그렇게 하기 위해서 User 테이블에  UserDetails를 구현해준다. (사실 getAuthorities() 함수를 사용하기 위해서인데, UserDetails를 구현하지 않고, getAuthentication() 메소드 안에서 처리해줘도 되지 않을까?)
@Entity
@Table(name = "USER")
@Getter
@Setter
@DynamicInsert
@DynamicUpdate
public class User extends BaseEntity implements UserDetails {

    @Id
    private String email;

	....

    @Column(name = "refreshtoken_value", unique = true) // UUID를 사용 => 중복될 확률 매ㅐㅐ우 희박. 유니크키로 설정해서 검색 속도 올림.
    private String refreshtokenValue;

    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Board> boards = new ArrayList<>();

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private Signature signature;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}

 

  • UserController
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    @PostMapping("/login")
    public ResponseEntity<HttpHeaders> login(@RequestBody LoginRequestDto requestDto) {

        requestDto.validateFieldsNotNull();

        ResponseEntity response = userService.login(requestDto);

        return response;
    }

    @GetMapping("/reissue-token") // refresh token 재발급 api
    public ResponseEntity<HttpHeaders> reissueToken(@RequestHeader("Cookie") String cookie) {

        if(cookie.isEmpty() || cookie.isBlank())
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);

        HttpHeaders headers = userService.reissueToken(cookie);

        return new ResponseEntity<>(headers, HttpStatus.OK);
    }

}

refresh token을 재발급받는 api는 JwtExceptionFilter가 아닌 ExceptionHandler에서 처리하도록 했다.

 

  • UserService
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final JwtProvider jwtProvider;

    public ResponseEntity<HttpHeaders> login(LoginRequestDto requestDto) {
    
    	//아이디 비밀번호 확인 후 유저가 인증되면 헤더에 access token, refresh token 추가
        
        HttpHeaders headers = createHeaders(email, randomUuid, roles);
        
        return new ResponseEntity(headers, HttpStatus.OK);


    }

    public HttpHeaders createHeaders(String email, String refreshtokenValue, List<String> roles) {
        HttpHeaders headers = new HttpHeaders();

        headers.add("Authorization", "Bearer " + jwtProvider.createAccessToken(email, roles)); // access token

        ResponseCookie cookie = ResponseCookie.from("refreshToken", jwtProvider.createRefreshToken(refreshtokenValue))
                .maxAge(14 * 24 * 60 * 60)
                .path("/")
                .secure(true)
                .sameSite("None")
                .httpOnly(true)
                .build();
        headers.add(HttpHeaders.COOKIE, cookie.toString()); // refresh token

        return headers;
    }




    public HttpHeaders reissueToken(String cookie) {

        String token = cookie.substring(13, cookie.indexOf(";"));

        String value = jwtProvider.getRefreshTokenValue(token); // 토큰 유효성 검사까지 함.

        //value로 유저 찾고, 그 유저 이메일로 엑세스 토큰 재발급.

        User user = userRepository.findByRefreshtokenValue(value).orElseThrow(() -> new InvalidRefreshTokenException("[not exists value] token : " + token));

        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + jwtProvider.createAccessToken(user.getEmail(), user.getRoles())); // access token

        return headers;

    }

 

  • 그 후에 나의 게시글 조회 같은 요청이 왔을 때는 
@GetMapping("/boards/my-boards")
ResponseEntity<?> getMyBoards(@AuthenticationPrincipal User user) {
	
    GetMyBoardsResponse response = boardService.getMyBoards(user);
    
    return new ResponseEntity(response, HttpStatus.OK);
}

@AuthenticationPrincipal 을 사용해서 토큰 안에 있는 정보를 사용할 수가 있다.

 

 

 

내가 처음에 시큐리티와 JWT를 사용해서 프로젝트를 진행하려고 했는데,  모두 클론 포스팅으로 내가 원하는 정보인 401 UNAUTHORIZED 응답, 토큰 재발급, 토큰에 저장된 값 사용을 설명하는 글이 없었다. 

나도 스프링 시큐리티를 따로 공부한 적이 없어서 주먹구구식으로 구현했지만,

시큐리티와 JWT를 처음 사용하는 사람들에게 조금이라도 도움이 됐으면 한다,,,

다음엔 시큐리티를 제대로 공부해서 다시 글을 써보도록 하겠다.