궤도

[Spring] 보안에 대한 고민 : JWT 토큰? 본문

💻 현생/📋 스터디

[Spring] 보안에 대한 고민 : JWT 토큰?

영이오 2021. 7. 18. 18:20

공부용 게시글이라 영양가가 없습니다. 

JWT로 회원가입, 로그인 구현하기

↑링크 추가

 

개요

 

그렇게 옛날은 아니지만 아무것도 모르던 비바 시절...

보안상에 아주 치명적인 코드를 잘만 짜고 다녔었다.

 

https://myunji.tistory.com/160?category=1154148 

 

[백엔드] Node.js + Sequelize + MySQL 상세보기 페이지를 만들어보자

우리의 앱에선 오답노트를 클릭하면 이렇게 노트에 있는 문제들을 하나하나 볼 수 있다. 보다시피 문제 삭제 기능도 있다. 프론트에서는 사용자의 아이디인 stu_id와 선택한 오답노트의 pk인 note_s

myunji.tistory.com

 

내가 주석으로 표시해놓은 주소를 보면 알겠지만 유저의 아이디가 get 방식으로 그대로 노출되어있다.

이럼 큰일난다. 왜? 유저가 저 주소 그대로 치고 뒤에 아이디만 대충 바꾸면 잘못 얻어걸린 어떤 유저의 데이터가 날아갈지도 모르기 때문이다. 

 

뭐 예를 들어

localhost:3001/api/incor-note-content/0?note_sn=1&stu_id=samdol2

이런 주소를 입력했는데 samdol2가 정말 있는 유저였다면? 초등학생도 뚫어버릴 수준의 보안이 되는 것이다.

 

예전에 생활코딩에서 노드에 대해 배웠을 때 이 부분에 대해 지적했었던 것 같은데, 당시의 나는 아는 것도 없고 보안...신경써야 하나...하고 넘긴 것 같았다. 이제 그 업보를 제대로 맞기 전에 공부를 하자.

 

https://webfirewood.tistory.com/115

 

SPRING SECURITY + JWT 회원가입, 로그인 기능 구현

이전에 서블릿 보안과 관련된 포스트(링크)를 작성했던 적이 있습니다. 서블릿 기반의 웹 애플리케이션에서 인증과 인가 과정을 간단하게 설명했습니다. 스프링에서는 마찬가지로 이런 인증과

webfirewood.tistory.com

일단 이런 블로그를 찾았다.

완벽하게 이해하진 못했지만 대충 이해했다.

 

위 블로그를 조금씩 이해하며 코드를 짰는데 저건 access token 구현이고 refresh token이라는게 따로 있다고 한다.

대충 내가 검색한걸 정리하면...

 

1. 보안을 위해 access token의 유효기간은 짧아야 함.

2. 그럼 로그아웃이 잦아서 ux가 떨어짐

3. 유효기간이 긴 refresh token을 사용하면 로그인 유지를 더 오래 할 수 있음

 

인 것 같다. 그럼 refresh token도 구현해봐야겠다.

https://tansfil.tistory.com/59?category=255594 

 

쉽게 알아보는 서버 인증 2편(Access Token + Refresh Token)

안녕하세요! 이전 포스팅에는 크게 세션/쿠키 인증, 토큰 기반 인증(대표적으로 JWT)에 대하여 알아보았습니다. 저희가 앱, 웹 혹은 서버 개발을 하면서 꼭 사용하게 되는 인증(Authorization)은 아주

tansfil.tistory.com

이 블로그의 설명이 좋은 것 같다!

 

https://velog.io/@jwkim/Spring-Boot-Spring-Security-JWT-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

[헤이동동 #06] Spring Security + JWT 사용자 인증 구현

헤이동동 Spring Security + JWT 사용자 인증 구현하기

velog.io

열심히 구글링을 하던 중 좋은 블로그의 글을 발견했다.

 

뭔가...된 것 같다. 지금 정말 기능만 공부하려고 대충 구현했지만 위에가 access, 아래가 refresh token이다.

 

데베에도  refresh token이 저장됐다!

 

재발급도 잘 되는 것 같다!

 

그리고 이 이후에

https://velog.io/@sonaky47/Spring-Security-Jwt-%ED%86%A0%ED%81%B0%EC%A0%95%EB%B3%B4%EB%A1%9C-%ED%95%84%ED%84%B0%EB%A7%81-%EB%90%9C-%EC%9C%A0%EC%A0%80%EC%A0%95%EB%B3%B4%EB%A5%BC-%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC%EB%8B%A8%EC%97%90%EC%84%9C-AuthenticationPricipal-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98%EC%9D%84-%ED%86%B5%ED%95%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EB%8A%94%EB%B2%95

 

[Spring Security] Jwt 토큰정보로 필터링 된 유저정보를 컨트롤러단에서 @AuthenticationPricipal 어노테이

사연 스프링 시큐리티에서 Jwt 토큰정보로 필터링 된 SecurityContext 정보를 컨트롤러 단에서 SecurityContext.getContext() 함수를 통해 복잡하게 가져오지 말고 @AuthenticationPrincipal 어노테이션을 통해 직접

velog.io

이런 글을 봤는데, 난 정말 알 수가 없었다.

도대체 저 @AuthenticationPrincipal이 보내는게 뭔 줄 알고 토큰을 통해 사용자를 찾는단 말인가??

수많은 구글링과 삽질을 통해 아주 어이없는 해답을 알아냈다.

 

그냥 내가 지금까지 한거에서

    @GetMapping("/resource")
    public UserTest resource(@AuthenticationPrincipal UserTest userTest){
        return userTest;
    }

이렇게 하고

 

받아놨던 access token을 보내면 알아서 된다.

내 생각보다 스프링은 더 똑똑하고 난 더 멍청했다.

 

여기까지 하고 나니까 이게 궁금해졌다. access token 만료되면 refresh token이 잘 작동하나..?

 

    private final long ACCESS_TOKEN_VALID_TIME = 1 * 60 * 1000L; //유효시간 1분

유효시간을 1분으로 바꿔버리고

 

access token 받고 기다렸다...1분동안

 

안되는거 보니 만료된걸까?

 

만료됐나보다! 이거 나중엔 exception 뱉는걸로 해야겠다...

 

재발급하면

 

다시 나온다!

 

공부한 내용을 대충 정리한 것. 추후에 따로 게시물로 정리할 예정

더보기
    //JWT access token 생성
    public String createAccessToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); //JWT payload에 저장되는 정보단위
        claims.put("roles", roles); //key - value 쌍으로 저장
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(now) //토큰 발행 시간
                .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) //토큰 만료 시간
                .signWith(SignatureAlgorithm.HS256, secretKey) //사용할 암호화 알고리즘
                .compact();
    }

    //JWT refresh token 생성
    public String createRefreshToken(String value){
        Claims claims = Jwts.claims();
        claims.put("value", value);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(now) //토큰 발행 시간
                .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME)) //토큰 만료 시간
                .signWith(SignatureAlgorithm.HS256, secretKey) //사용할 암호화 알고리즘
                .compact();
    }

이건 access token과 refresh token을 생성하는 메소드이다.

access token에선 setSubject로 pk를 저장하고, 음 둘 다 그렇게 만들어진 토큰에 발행 시간과, 만료 시간, 사용할 암호화 알고리즘 등을 더해서 만든다. 사실 아직 잘 모르겠다.

 

아무튼 알고리즘은 HS256을 사용했고, 비밀키는 application.yml에 잘 보관했다.

 

    //JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    //토큰에서 회원 정보 얻어내기
    private String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    //Request의 Header에서 token 값 가져오기. "X-AUTH-TOKEN" : "TOKEN값"
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    //토큰의 유효성과 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date()); //만료일자
        } catch (Exception e) { //유효성
            return false;
        }
    }

    //refresh token 정보 얻어내기
    public Claims getClaimsFromJwtToken(String jwtToken) throws JwtException {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken).getBody();
    }

이건 토큰을 검증하는 메소드들이다. 이것들을 어디서 사용하냐면

 

package jpa.myunjuk.common;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //헤더에서 JWT 받아오기
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        //유효한 토큰인지 확인
        if (token != null && jwtTokenProvider.validateToken(token)) {
            //토큰이 유효하면 토큰으로부터 유저 정보 반환
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            //SecurityContext에 Authentication 객체를 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

여기서 사용한다.

먼저 resolveToken 메소드로 X-AUTO-TOKEN을 key로 하는 헤더를 받아와 String token에 저장하고

널 체크와 validateToken 메소드로 토큰의 유효성을 확인한다.

 

만약 그렇다면 getAuthentication 메소드로 토큰에 있는 유저 정보를 반환하고, 그 반환한 객체를 SecurityContext에 저장한다.

 

이것들을

package jpa.myunjuk.common;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    //암호화에 필요한 PasswordEncoder를 Bean 등록
    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    //authenticationManager를 Bean 등록
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception{
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.httpBasic().disable() //rest api만 고려하면 해제해도 되는듯?
                    .csrf().disable() //csrf 보안 토큰 diable 처리
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //토큰 기반 인증이라 세션은 사용하지 않음
                    .and()
                    .authorizeRequests() //요청에 대한 권한 체크
                    .antMatchers("/admin/**").hasRole("ADMIN")
                    .antMatchers("/user/**").hasRole("USER")
                    .antMatchers("/**").permitAll() //그외 나머지 요청은 누구나 접근 가능
                    .and()
                    .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                            UsernamePasswordAuthenticationFilter.class); //JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣음
    }
}

여기에 등록해야 쓸 수 있나보다.

 

한편 spring security에서 유저정보를 저장하기 위해선 UserDetails라는 인터페이스를 구현해야 한다.

 

package jpa.myunjuk.domain;

import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserTest implements UserDetails {

    @Id
    @GeneratedValue
    private Long id;

    @Column(length = 100, nullable = false, unique = true)
    private String email;

    @Column(length = 300, nullable = false)
    private String password;

    @Setter
    private String refreshTokenValue;

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

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

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

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

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

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

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

그래서 user domain이 이렇게 되고...

 

package jpa.myunjuk.service;

import jpa.myunjuk.repository.UserTestRepository;
import lombok.RequiredArgsConstructor;
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.Service;

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {

    private final UserTestRepository userTestRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userTestRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
    }
}

UserDetailsService의 메소드를 구현해서 DB에서 사용자를 찾도록 한다.

 

컨트롤러와 서비스 부분은...나중에 보충 게시물에서 따로 쓰도록 하겠다.

 

Comments