[OAuth2와 JWT] JWT를 발급하고 검증하자.

코린이서현이·2024년 6월 20일
0
post-thumbnail

들어가면서

대칭키를 사용한대 ..!!
알바 --(20분)-- 학교 --(20분)-- 집 --(20분)-- 체육관

알바하는 곳부터 체육관까지 일직선으로 지하철역 5정거장 차이난다.
신기한데예

JWT를 발급하고 검증하자.

혹시 cors안했으면 BE목록 가서 해야함

JWT에 대해서

Header.Payload.Signature

  • JWT임을 명시
  • 사용된 암호화 알고리즘

Payload.

  • 정보(실제 정보: body 데이터)
  • 다른 사람들도 쉽게 볼 수 있다.

Signature

  • 암호화알고리즘((BASE64(Header))+(BASE64(Payload)) + 암호화키)
  • 헤더와 페이로드를 서버가 가진 시크릿키를 활용해서 암호화를 진행한다.
  • 내부 정보를 단순 BASE64 방식으로 인코딩하기 때문에 외부에서 쉽게 디코딩
  • 외부에서 열람해도 되는 정보를 담아야한다.

JWT을 사용하는 이유

  • 발급처에 대한 보장을 하기 위해서!
  • 아! 이거 내가 발급한거야!!@ 라고 생각할 수 있도록..!!

JWT 발급키 종류

양방향

  • 대칭키 : ex_ HS256
  • 비대칭키 : 공개키, 비밀키

단방향

  • 이 프로젝트는 양방향 대칭키를 사용하려고 한다.

JWT 발급 구현

암호화 키 저장

  • 암호는 보안을 위해 외부 저장을 한다.
spring: 
  jwt:
    secret: 원하는 키

JWT 검증과 발급을 하는 클래스 구현

  • 저번 글에서 .parseSignedClaims(token)이 검증을 해준다는 걸 다뤘다.
package com.jsh.oauth_jwt_study.jwt;

import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JWTUtil {
    private SecretKey secretKey;

    public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
        //시크릿키 만들기 *두근두근*
    }

    public String getUsername(String token) {

        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get("username", String.class);
    }

    public String getRole(String token) {

        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get("role", String.class);
    }

    public Boolean isExpired(String token) {

        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getExpiration()
                .before(new Date());
    }

    public String createJwt(String username, String role, Long expiredMs) {

        return Jwts.builder()
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }
}

JWT 발급하기

어떻게 발급하는게 좋을지!! 🍪

바로 쿠키를 가지고 만드는게 좋다.

자세한 내용은 아래서 설명하겠다 😅

@Component
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JWTUtil jwtUtil;

    public CustomSuccessHandler(JWTUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        //OAuth2User
        CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();

        //토큰을 만드는 코드
        String username = customUserDetails.getUsername();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        String token = jwtUtil.createJwt(username, role, 60*60*60L);

        //쿠키를 구워요~! 🍪🍪
        response.addCookie(createCookie("Authorization", token));
        response.sendRedirect("http://localhost:3000/");
    }
    
    private Cookie createCookie(String authorization, String token) {
        //클래스 이름이 어떻게 쿠키? 너무 귀여워... 🥲
        Cookie cooKie = new Cookie(authorization,token);

        cooKie.setMaxAge(60*60*60); //쿠키가 적용될 유효 시간을 설정한다. (여기서는 60시간..ㄷㄷ)
        cooKie.setPath("/"); //쿠키가 유효한 경로를 설정한다. "/"는 루트 경로로 설정해 모든 경로에서 쿠키가 유효하도록 설정한다.
        cooKie.setHttpOnly(true); // 쿠키를 HTTP(S)에서만 접근 가능하도록 한다.
      //cooKie.setSecure(true); //쿠키를 HTTPS으로만 전송되도록 설정한다. (그런데 나는 돈없어서 HTTP임 그래서 주석할거임ㅋ)

        return cooKie;

    }
}

JWT를 검증하자.

만약 요청에 토큰이 있는 경우 토큰을 검증하고, 강제로 SecurityContextHolder에 세션을 생성하자.

물론 이 애플리케이션은 STATLESS 상태이기 때문에 소멸된다.

따라서 이렇게 검증을 하고 세션을 생성할 필터를 만들어야한다.

이 클래스가 담당해야하는 일은?

유저가 보낸 쿠키가 제대로 된 토큰이라면 (임시)세션에 권한을 넣어줘서 다양한 리소스에 접근할 수 있도록 하는 것이다.

토큰이 잘못된 경우를 생각해보자.

  • 만료된 경우 등등등...

그 외의 경우도 생각해보자.

  • 혹시 이 요청이 로그인을 위한 요청은 아닌지.
  • 쿠키는 있지만 비어있는 경우 ⭐⭐
    이걸 왜 생각해야하냐면 java는 null을 다루면 바로 예외를 터트리기 때문에^^

오해하지 말자! 권한을 넣어주는 건?

사실 요청에 어떤 값을 추가하거나 어떤 메서드를 호출하는 것은 아니다.
흐름 자체는 잘못된 토큰이거나 올바른 토큰이어도 똑같다!

다만, 강제로 세션에 유저를 넣어줘서 이후 권한이 필요한 리소스에 정상적으로 접근할 수 있도록 하는 것이다.

JWT 검증 필터, JWTFilter 구현

로직이 복잡해서... 좋은 코드는 아니라고 생각한다.
일단 이 코드가 담당하고 있는 역할이 아주 많다 ^🥕^

그 외의 경우 : 로그인을 위한 요청

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestUri = request.getRequestURI();

        if (requestUri.matches("^\\/login(?:\\/.*)?$")) {

            filterChain.doFilter(request, response);
            return;
        }
        if (requestUri.matches("^\\/oauth2(?:\\/.*)?$")) {

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

쿠키는 있지만 비어있는 경우

        String authorization = null;
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            System.out.println("cookie null");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

토큰이 잘못된 경우

for (Cookie cookie : cookies) {
            System.out.println(cookie.getName());
            if (cookie.getName().equals("Authorization")) {

                authorization = cookie.getValue();
            }
        }


        //Authorization 헤더 검증
        if (authorization == null) {

            System.out.println("token null");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

        //토큰
        String token = authorization;

        //토큰 소멸 시간 검증
        if (jwtUtil.isExpired(token)) {

            System.out.println("token expired");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

세션에 유저를 넣어주자.

세션에 들어가는 건? Authentication타입
Authentication타입에 들어가는 건 ~~~ OAuth2User

String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        //userDTO를 생성하여 값 set
        UserDTO userDTO = new UserDTO();
        userDTO.setUsername(username);
        userDTO.setRole(role);

        //UserDetails에 회원 정보 객체 담기
        CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDTO);

        //스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
        //세션에 사용자 등록
        SecurityContextHolder.getContext().
                setAuthentication(authToken);

전체 코드

package com.jsh.oauth_jwt_study.jwt;

import com.jsh.oauth_jwt_study.dto.CustomOAuth2User;
import com.jsh.oauth_jwt_study.dto.UserDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

public class JWTFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;

    public JWTFilter(JWTUtil jwtUtil) {

        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestUri = request.getRequestURI();

        if (requestUri.matches("^\\/login(?:\\/.*)?$")) {

            filterChain.doFilter(request, response);
            return;
        }
        if (requestUri.matches("^\\/oauth2(?:\\/.*)?$")) {

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


        //cookie들을 불러온 뒤 Authorization Key에 담긴 쿠키를 찾음
        String authorization = null;
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            System.out.println("cookie null");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

        for (Cookie cookie : cookies) {
            System.out.println(cookie.getName());
            if (cookie.getName().equals("Authorization")) {

                authorization = cookie.getValue();
            }
        }


        //Authorization 헤더 검증
        if (authorization == null) {

            System.out.println("token null");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

        //토큰
        String token = authorization;

        //토큰 소멸 시간 검증
        if (jwtUtil.isExpired(token)) {

            System.out.println("token expired");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

        //토큰에서 username과 role 획득
        String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        //userDTO를 생성하여 값 set
        UserDTO userDTO = new UserDTO();
        userDTO.setUsername(username);
        userDTO.setRole(role);

        //UserDetails에 회원 정보 객체 담기
        CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDTO);

        //스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
        //세션에 사용자 등록
        SecurityContextHolder.getContext().
                setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}

필터 등록!

package com.jsh.oauth_jwt_study.config;

import com.jsh.oauth_jwt_study.jwt.JWTFilter;
import com.jsh.oauth_jwt_study.jwt.JWTUtil;
import com.jsh.oauth_jwt_study.oauth2.CustomSuccessHandler;
import com.jsh.oauth_jwt_study.service.CustomOAuth2UserService;
import jakarta.servlet.http.HttpServletRequest;
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.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import java.util.Collections;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;
    private final JWTUtil jwtUtil;

    public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomSuccessHandler customSuccessHandler, JWTUtil jwtUtil) {
        this.customOAuth2UserService = customOAuth2UserService;
        this.customSuccessHandler = customSuccessHandler;
        this.jwtUtil = jwtUtil;
    }


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

        //csrf disable
        http
                .csrf((auth) -> auth.disable());

        //From 로그인 방식 disable
        http
                .formLogin((auth) -> auth.disable());

        //HTTP Basic 인증 방식 disable
        http
                .httpBasic((auth) -> auth.disable());

        //JWTFilter 추가
        http
              .addFilterBefore(new JWTFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);

        //oauth2
        http
                .oauth2Login((oauth2) -> oauth2
                        .userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
                                .userService(customOAuth2UserService))
                        .successHandler(customSuccessHandler)
                );

        //경로별 인가 작업
        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/").permitAll()
                        .anyRequest().authenticated());

        //세션 설정 : STATELESS
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }

}

추가 설명

발급 클래스인 CustomSuccessHandler는 무엇일까?

public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
  • SimpleUrlAuthenticationSuccessHandler를 상속받았다.
  • SimpleUrlAuthenticationSuccessHandler는 : 인증이 성공적으로 이루어졌을 때 실행되는 클래스다.

onAuthenticationSuccess 메서드는 무엇일까?

  • 스프링 시큐리티에서 인증이 성공적으로 완료된 후 수행되는 메서드이다.

onAuthenticationSuccess 메서드가 하는 일

1. 리다이렉션 처리
2. 세션관리
3. 쿠키 설정
4. 로그 기록
5. 추가적인 비즈니스 로직 수행

onAuthenticationSuccess의 파라미터 정보

HttpServletRequest request

  • HTTP 요청 객체
  • 요청의 헤더, 파라미터, 세션 정보에 접근할 수 있다.

HttpServletResponse response

  • HTTP 응답 객체, 서버가 클라이언트에게 요청을 보낼 때 사용한다.
  • 응답의 상태 코드 설정, 헤더 설정, 응답 본문 작성 등을 수행할 수 있습니다.
  • 예를 들어, 리다이렉션을 수행하거나 쿠키를 설정할 수 있습니다.

Authentication authentication

  • 스프링 시큐리티의 인증 객체로, 사용자가 인증에 성공하면 시큐리티가 관리하게 되는 객체다.
  • 인증된 사용자의 상세 정보(예: 사용자 이름, 권한, 인증 방식 등)를 접근할 수 있게 한다.
  • 내 프로젝트의 경우 CustomOAuth2UserService의 loadUser 메서드에서 반환되는
    CustomOAuth2User 객체가 Authentication 객체에 담긴다.

쿠키 발급

이 외에도 자주 사용하는 쿠키 클래스 관련 코드를 알아보자.

<쿠키를 조회하는 코드>

Cookie[] cookies = request.getCookies();
            if (cookies != null) {
                for (Cookie cookie : cookies) {
                    if ("name".equals(cookie.getName())) {
                        String value = cookie.getValue();
                        // 쿠키 값 사용
                    }
                }
            }

<쿠키를 삭제하는 방법>

cookie.setMaxAge(0); // 유효 기간을 0으로 설정하여 쿠키 삭제

배운 점 🪄

- 세션리스지만 일시적으로 세션을 넣어서 권한 검사를 통과할 수 있도록 도와준다.
- 자바에서 NULL은 재앙이다.

마무리하면서

오우 힘들어요~! 
profile
24년도까지 프로젝트 두개를 마치고 25년에는 개발 팀장을 할 수 있는 실력이 되자!

0개의 댓글