티스토리 뷰

Back-end/Spring

JWT 구현하기

이안_ian 2020. 8. 25. 21:11




반응형

저번에 만들었던 컨트롤러에 자체 회원 가입을 JWT방식으로 구현해 보겠습니다.

이전 링크 : https://smujihoon.tistory.com/240

 

1. 의존성 추가

compile('io.jsonwebtoken:jjwt:0.9.1')

 

2. JwtTokenProvider 라는 jwt관련 메소드를 정의할 클래스 생성

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
  private String secretKey = "webfirewood";

  // 토큰 유효시간 30분
  private long tokenValidTime = 30 * 60 * 1000L;

  private final UserDetailsService userDetailsService;

  // 객체 초기화, secretKey를 Base64로 인코딩한다.
  @PostConstruct
  protected void init() {
    secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
  }

  // JWT 토큰 생성
  public String createToken(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() + tokenValidTime)) // set Expire Time
        .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과
        // signature 에 들어갈 secret값 세팅
        .compact();
  }

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

  // 토큰에서 회원 정보 추출
  public 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) {
    //System.out.println("request : "+request.getHeader("X-AUTH-TOKEN"));
    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;
    }
  }
}

주석만 봐도 알 수 있듯이 토큰에 대한 모든 행위를 기술해 놓은 클래스다.

이렇게 만들어진 클래스는 JwtAuthenticationFilter라는 클래스에서 사용할 것.

 

JwtAuthenticationFilter 클래스 생성

@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);
  }
}

컨트롤러로 가기전 거치는 클래스로써 request에서 token을 추출하여 유효성 검사를 하고 유효하게 되면 Context에 저장하고 컨트롤러는 getContext로 유저의 정보를 볼 수 있다. 로그인을 했을 경우에만 토큰이 있게 되므로 저 토큰이 없다면 로그인이 안된 상태이며 토큰안에 권한도 있기에 로그인한 유저여도 모든 정보를 다 볼 수 있는것은 아님.

 

이제 저번에 만들었던 WebSecurityConfig에 추가하자

 

WebSecurityConfig.Java

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

  private final JwtTokenProvider jwtTokenProvider;
  private final CustomOAuth2UserService customOAuth2UserService;

  // 암호화에 필요한 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 http) throws Exception {
//    http.csrf().disable()
//        .headers().frameOptions().disable()
//        .and().authorizeRequests().antMatchers("/").permitAll()
//        .and().logout().logoutSuccessUrl("/")
//        .and().oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);

    http
        .httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
        .csrf().disable() // csrf 보안 토큰 disable처리.
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
        .and()
        .authorizeRequests() // 요청에 대한 사용권한 체크
        //.antMatchers("/", "/css/**","/images/**","/js/**","/h2-console/**,","/profile").permitAll()
        .antMatchers("/admin/**").hasRole("ADMIN")
        .antMatchers("/user/**").hasRole("USER")
        .anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
        .and()
        .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
            UsernamePasswordAuthenticationFilter.class);
    // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다

    http.authorizeRequests()
        .anyRequest().permitAll()
    .and().oauth2Login();
  }
}

이렇게 설정하면 filter 기능으로 인해 컨트롤러에 도달하기 전 각각 url에 대한 권한등을 지정할 수 있으며 filter 경로 셋팅을 해줘야함.

 

컨트롤러에서 사용할 Dto와 DB에 CRUD할 클래스들을 만들어 보겠습니다.

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

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

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

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

  @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;
  }
}
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<UserImpl, Long> {
  Optional<UserImpl> findByEmail(String email);
}

 

컨트롤러 생성

@RequiredArgsConstructor
@RestController
public class UserController {

  @GetMapping("/hello")
  public String hello(){
    return "Hello World!!";
  }
  private final PasswordEncoder passwordEncoder;
  private final JwtTokenProvider jwtTokenProvider;
  private final UserRepository userRepository;

  // 회원가입
  @PostMapping("/join")
  public Long join(@RequestBody Map<String, String> user) {
    return userRepository.save(UserImpl.builder()
        .email(user.get("email"))
        .password(passwordEncoder.encode(user.get("password")))
        .roles(Collections.singletonList("ROLE_USER")) // 최초 가입시 USER 로 설정, Collections.singletonList로 리턴된 List를 변경하면 UnsupportedOperationException이 발생함
        .build()).getId();
  }

  // 로그인
  @PostMapping("/loginCustom")
  public String login(@RequestBody Map<String, String> user) {
    UserImpl member = userRepository.findByEmail(user.get("email"))
        .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
    if (!passwordEncoder.matches(user.get("password"), member.getPassword())) {
      throw new IllegalArgumentException("잘못된 비밀번호입니다.");
    }

    //근데 브라우저에도 시크릿키가 있어야하는건 뭐였지
    return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
  }

  @GetMapping("/checkJWT")
  public String list(){
    //권한체크
    Authentication user = SecurityContextHolder.getContext().getAuthentication();

    UserImpl user2 = (UserImpl) user.getPrincipal();
    return user.getAuthorities().toString()+" / "+user2.getEmail()+" / "+user2.getPassword();
  }

}

 

index.mustache 일부 수정

...	
		{{^userName}}
          <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
          <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
          <form action="/loginCustom" method="post" name="loginCustom">
            <input type="text" name="email" placeholder="email을 입력하세요">
            <input type="password" name="password" placeholder="비밀번호를 입력하세요">
          </form>
          <button onclick="loginCustom_ajax()">로그인</button>
          <form action="to_ajax();" method="post" name="join">
            <input type="text" name="email" placeholder="email을 입력하세요">
            <input type="password" name="password" placeholder="비밀번호를 입력하세요">
          </form>
          <button onclick="join_ajax()">회원가입</button>
        {{/userName}}
      </div>
    </div>
...

 

실행해보기

1 이라는 회원번호가 리턴되는것을 보니 회원가입이 잘 진행되었음을 알 수 있다.

 

로그인으로 토큰을 발급 받기

로그인을 해서 리턴 값으로 토큰을 잘 받았고 쿠키에 저장까지 잘 된것을 확인 할 수 있다.

브라우저 쿠키에 저장하는 로직은 다음과 같다.

function loginCustom_ajax(){
    var datas = objToJson($("form[name=loginCustom]").serializeArray());

        $.ajax({
            type : 'POST',
            url : 'http://localhost:8082/loginCustom',
            data : JSON.stringify(datas),
            dataType : 'text',  //위와 같이 dataType:json 일 경우 json 형태로 맞춰서 데이터를 받지 못해 오류 발생
            contentType : "application/json",
            error: function(xhr, status, error){
                alert(error);
            },
            success : function(token){
                console.log(token);
                var expireDay = 24 * 60 * 60 * 1000; //1일
                document.cookie = "X-AUTH-TOKEN=" + token + expireDay +"; path=/";

            },
        });
    }

토큰을 브라우저에 저장하는 방법은 2가지가 있다.

 

1. 로컬 스토리지, 세션 스토리지

HTML5부터 브라우저에 5MB의 용량을 사용할 수 있는 공간으로 쿠키보다 보안이 좀더 우수하다.
세션스토리지는 브라우저가 꺼지면 데이터가 삭제 된다는 차이점이 있다. 하지만 단점이 있는데
자바스크립트로 쉽게 저장하고 가져오기 때문에, 이는 XSS라는 js 제어 공격이 가능하다는 점이다.

 

2. 쿠키에 저장

서버측에서 HTTP set-Cookie 헤더를 통해서 토큰을 보낸다. 브라우저는 이를 통해 쿠키를 생성하고 토큰을 저장
이후 해당 API에 요청을 하게 될 때에는 브라우저가 자동으로 이 쿠키를 실어서 보낸다. 간단하다.
쿠키가 스토리지보다 더 좋은점이 있는데 js로 인한 조작을 옵션 설정으로 막을 수 있다.
쿠키 생성시 HttpOnly를 주면 js로 접근이 불가하며 Secure 옵션을 추가하면 쿠키는 HTTPS 통신으로만 전송되어 한층 더 보안수준이 높아진다.


그래도 보통 쿠키에 저장하는 편이 더 안전해 보여서 이번에 쿠키로 진행하였다.

 

이제 제대로 토큰값대로 작동하는지 확인해보자

 

포스트맨으로 헤더에 아까의 토큰 값을 넣고 checkJWT를 호출해보자

 

컨트롤러 list() 메서드에서 SecurityContextHolder.getContext().getAuthentication(); 한 부분의 데이터가 잘 나오는것을 보니 성공했다!! 앞으로도 api를 요청할 때마다 이 토큰이 헤더에 붙어서 요청되므로 서버에 있는 Session에 회원정보들을 담을 필요가 없게 되었다.

 

 

반응형
댓글
반응형
최근에 달린 댓글
글 보관함
Total
Today
Yesterday
최근에 올라온 글
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31