히히심 2024. 1. 29. 17:59
728x90

JSON (JSON Web Token)

1. JWT 

- JSON으로 표현된 사용자 정보를 안전하게 주고받기 위한 Token의 일종.

- JWT 내부에 사용자 확인 인증정보가 담기며, JWT를 받으면 위변조가 되었는지 알아차릴 수 있기 때문에 Token 기반의 인증 시스템에서 많이 활용.

- 상태를 저장하지 않기 때문에 서버의 세션 관리가 불필요!! 

- 토큰 소유가 곧 인증 >> 여러 서버에 걸쳐서 인증이 가능

cf) 쿠키는 요청을 보낸 클라이언트에 종속됨. / 토큰은 쉽게 첨부 가능(주로 header에)

- 로그인 "상태"라는 개념이 사라져서 로그아웃이 불가하다. (기본적으로는.. 로그아웃하려면 프론트에서 토큰 폐기.)

header.payload.signature
header : 이 JWT의 부수적인 정보(어떤 방식으로 암호화 되었는지 등)가 담긴다.
payload : 이 JWT가 실제로 전달하고자 하는 정보가 담긴 부분. 
- claim : 정보
- sub(subject) : 사용자
- iat(issued at) : 발급일자
- exp(expires) : 만료 일자
signature : 이 JWT가 위조되지 않은 JWT인지를 판단하기 위한 부분.
- header, payload의 길이, 사전 공유된 암호키를 기반으로 signature를 계산해 JWT의 위변조를 감지.

 

- JJWT 라이브러리 추가

// build.gradle
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

2. JWT  발급

1) JwtTokenUtils

- 암호키 준비 : application.yaml에 작성

// application.yaml
// 커스텀 설정 (Spring내부 설정 아님)
jwt:
  secret: aaaabbbsdifqbvaesoioegwaaaabbbsdifqbvaesoioegwaaaabbbsdifqbvaes

 

- Constructor에서 암호키 만들기

// JwtTokenUtils class
//JWT 자체와 관련된 기능을 만드는 곳

//@Value 어노테이션은 spring bean factory annotation을 선택(lombok 아님)
import org.springframework.beans.factory.annotation.Value;


@Slf4j
@Component
public class JwtTokenUtils {
    private final Key signingKey;

    //암호키 만들기
    public JwtTokenUtils(
            @Value("${jwt.secret}")
            String jwtSecret
    ) {
        log.info(jwtSecret);
        this.signingKey
                = Keys.hmacShaKeyFor(jwtSecret.getBytes());
    }

}

 

- generateToken() : UserDetails를 받아서 JWT로 발급하는 메서드

  • 일반적인 JWT 외의 정보를 포함하고 싶다면 Map.put() 사용 가능. (Claims 인터페이스는 Map<String, Object>를 상속받기 때문. )
// JwtTokenUtils class
	//UserDetail를 받아서 JWT로 변환하는 메서드
    public String generateToken(UserDetails userDetails) {
        //JWT에 담고싶은 정보를 payload - Claim으로 만든다.
        //sub, iat, exp
        Instant now = Instant.now(); //호출되었을 현재 시각 epoch time
        Claims jwtClaims = Jwts.claims()
                .setSubject(userDetails.getUsername()) //누구인지
                .setIssuedAt(Date.from(now)) //언제 발급 되었는지
                .setExpiration(Date.from(now.plusSeconds(20L))); //언제 만료 예정인지 (20초후 만료되게 설정)

        // 일반적인 JWT 외의 정보를 포함하고 싶다면 Map.put 사용 가능
//        jwtClaims.put("test", "claim");

        //JWT를 최종적으로 발급한다.
        return Jwts.builder().
                setClaims(jwtClaims). //지정된 클레임 설정
                signWith(this.signingKey) //서명 키로 서명
                .compact(); //최종 압축하여 문자열로 만들어줌
    }

 

- JWT를 발급받을 엔드포인트 만들기 (컨트롤러 만들기)

  • JwtTokenUtils : 실제로 JWT를 발급하기 위핸 필요한 Bean
  • UserDetailsManager : 사용자 정보를 확인하기 위한 Bean
  • PasswordEncoder : 사용자가 제공한 비밀번호를 비교하기 위한 암호화 Bean
  • JwtRequestDto와 JwtResponseDto를 만들어 비밀번호를 확인해보고 일치하면 토큰을 발급한다.
//TokenController class

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("token")
public class TokenController {
    // JWT를 발급하기 위한 Bean
    private final JwtTokenUtils jwtTokenUtils;
    // 사용자 정보를 회수하기 위한 Bean
    private final UserDetailsManager manager;
    // 사용자가 제공한 아이디 비밀번호를 비교하기 위한 클래스
    private final PasswordEncoder passwordEncoder;

    @PostMapping("/issue")
    public JwtResponseDto issueJwt(@RequestBody JwtRequestDto dto) {
        if (!manager.userExists(dto.getUsername())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        //사용자가 제공한 username, password가 저장된 사용자인지
        UserDetails userDetails =
                manager.loadUserByUsername(dto.getUsername());

        //비밀번호 대조 (rawPassword, encodedPassword)
        if (!passwordEncoder.matches(dto.getPassword(), userDetails.getPassword())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }

        // 일치하면 토큰 발급
        JwtResponseDto jwtResponseDto = new JwtResponseDto();
        jwtResponseDto.setToken(jwtTokenUtils.generateToken(userDetails));
        return jwtResponseDto;
    }
}


// JwtRequestDto
@Data
public class JwtRequestDto {
    private String username;
    private String password;
}

//JwtResponseDto
@Data
public class JwtResponseDto {
    private String token;
}

 

- WebSecurityConfig : 인증이 필요 없는 상태로 설정.

// WebSecurityConfig class

@Configuration
public class WebSecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(
                        auth -> auth.requestMatchers("/no-auth",
                                        "/users/home",
                                        "/tests",
                                        "/token/issue")
                                .permitAll()
                                .requestMatchers("/users/my-profile")
                                .authenticated()
                                .requestMatchers("/users/register",
                                        "/users/login")
                                .anonymous()
                )
                .formLogin(
                       // ...
                )
                .logout(
                       // ...
                );
        return http.build();
    }
}

 

 

>> 그러면 이렇게 나온다 token이 만들어지고, header.payload.signature 형태로 나온다. 

>> jwt.io에서 얘를 확인해보면 이렇게 바로 decode되는데, 여기서 signature에서 내가 yaml에 설정해놓은 secret key를 넣어보면 Signature Verified로 바뀐다. 이런식으로 인증을 한다는 뜻이다. 즉, signingKey를 꼭 알아야지만 인증이 된다 = 그래서 위조인지 아닌지를 알게된다는 뜻. 

 

3. JWT를 이용한 인증

- 토큰 기반 인증방식에서는 일반적으로 HTTP header에 Authorization Header를 추가해서 보낸다. 

- JWT 비롯한 토큰을 활용해서 진행할 경우 > Bearer {token값} 형태로 추가해 보낸다 (Bearer Token 인증방식)

즉, 받는 요청에 헤더의 Authorization 값이 없다면 인증되지 않은 사용자, 값이 있다면 해당 Token이 유효한지 확인한 후 인증상태 판단.

- 결국 요청을 보내는 시점에 Header에 Authorization 값을 추가해주는 행위를 해야 한다 !! >> 서버(cookie/session)에서가 아닌 Frontend쪽에서 직접 진행해주어야 한다. 

- 서버에서는 들어온 HTTP 요청에 Authorization Header 존재하는지, Bearer로 시작하는 값인지, 그 안의 Token이 유효한지 판단하는 필터를 만들어 확인한다.

 

1) JWT 토큰이 유효한지 확인하는 메서드 : JwtTokenUtils.validate()

- JwtParser : Key정보를 바탕으로 JWT 정보를 해석. >> 필드로 할당해준다. + constructor에 추가(signingKey를 포함해서 jwt를 해석하도록)

- jwtParser.parseClaimJws() : 암호화된 JWT String을 파싱해준다.

 

// JwtTokenUtils class
@Slf4j
@Component
public class JwtTokenUtils {
    private final Key signingKey;
    // Jwt를 해석하는 용도의 객체
    private final JwtParser jwtParser;

    //암호키 만들기
    public JwtTokenUtils(
            @Value("${jwt.secret}")
            String jwtSecret
    ) {
        log.info(jwtSecret);
        this.signingKey
                = Keys.hmacShaKeyFor(jwtSecret.getBytes());
        this.jwtParser = Jwts.parserBuilder()
                .setSigningKey(this.signingKey)
                .build();
    }
    
    // 정상적인 JWT인지를 판단하는 메서드
    public boolean validate(String token) {
        //만약 정상적이지 않은 JWT라면 예외가 발생한다.
        try {
            jwtParser.parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            log.warn("invalid jwt");
        }
        return false;
    }
}

- 혹은 예외에 따라 다르게 로그를 찍어줄 수도 있다.

// JwtTokenUtils class
public boolean validate(String token) {
    try {
        jwtParser.parseClaimsJws(token).getBody();
        return true;
    } catch (SecurityException | MalformedJwtException e){
        log.warn("malformed jwt");
    } catch (ExpiredJwtException e) {
        log.warn("expired jwt presented");
    } catch (UnsupportedJwtException e) {
        log.warn("unsupported jwt");
    } catch (IllegalArgumentException e) {
        log.warn("illegal argument");
    }
    return false;
}

 

- 인증을 위해서 실제 데이터(Payload)를 반환하는 메서드도 만들어준다.

// JwtTokenUtils class
    //실제 데이터(payload)를 반환하는 메서드
    public Claims parseClaims(String token) {
        return jwtParser
                .parseClaimsJws(token) //파싱하면 Jws<Claims> 형태로 반환되어
                .getBody(); // 그 Claim의 Body부분(payload)를 받는다
    }

 

- Controller에서 실제로 인증하는 엔드포인트 만들기

// TokenController class
    @GetMapping("/validate")
    public Claims validateToken(
            @RequestParam("token")
            String token
    ) {
    // 정상적인 jwt가 아니면 unauthorized
        if (!jwtTokenUtils.validate(token)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        // 맞으면 payload부분 반환
        return jwtTokenUtils.parseClaims(token);
    }

 

- WebSecurityConfig에서 해당 url 허용하기

// WebSecurityConfig class

@Configuration
public class WebSecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(
                        auth -> auth.requestMatchers("/no-auth",
                                        "/users/home",
                                        "/tests",
                                        "/token/issue",
                                        "/token/validate")
                                .permitAll()
                                .requestMatchers("/users/my-profile")
                                .authenticated()
                                .requestMatchers("/users/register",
                                        "/users/login")
                                .anonymous()
                )
                .formLogin(
                       // ...
                )
                .logout(
                       // ...
                );
        return http.build();
    }
}

 

 

>> 결과 확인 : token 발급 받고 > 그 토큰을 고대로 parameter로 넣어줬더니 바로 payload부분을 확인 가능하다!~

 

 

2) JWT Filter 만들기

: 사용자가 포함시켜 보낸 JWT가 정당한지 아닌지 판단하는 필터

 

- OncePerRequestFilter를 상속받는 클래스 생성. 

: 비동기 처리 또는 forward/redirect 등의 특정 상황에서 Filter의 기능이 한번만 사용되도록 보장하는 Filter 객체.

이 클래스를 상속받으면 doFilter 대신 doFilterInternal을 구현함.

** JwtTokenFilter를 Bean 객체로 등록할 경우 Spring container가 필터를 검색하고, Spring Security에서 필터를 다시 등록해서 같은 필터가 두번 등록될 수있다. >> 그러므로 여기서는 Bean 객체로 등록 X!!!!

// JwtTokenFilter

@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {

	@Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        // 1. authorization 헤더를 회수
        // 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
        // 3. 존재하면 유효한 토큰인지
        // 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
        // 5. 다음 필터 호출
    }
}

 

- Authorization 헤더를 회수

  •  request.getHeader("직접 Authorization 넣어주기"); 혹은 HttpHeaders에 상수로 설정된 값을 불러와도 된다. (자주 쓰이므로!)
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        // 1. authorization 헤더를 회수
//        String authHeader = request.getHeader("Authorization");
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
        // 3. 존재하면 유효한 토큰인지
        // 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
        // 5. 다음 필터 호출
    }
}

 

- 헤더 존재하는지, Bearer로 시작하는지 확인 (반대 상황을 조건문으로 걸어주었다)

@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        // 1. authorization 헤더를 회수
//        String authHeader = request.getHeader("Authorization");
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            
        }
        // 3. 존재하면 유효한 토큰인지
        // 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
        // 5. 다음 필터 호출
    }
}

- 존재하면 유효한 토큰인지 확인

  • 유효함을 확인하기 위해 JwtTokenUtils 클래스를 필드로 할당.
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    //토큰을 확인하는 메서드 (JwtTokenUtils.validate())를 위해
    private final JwtTokenUtils jwtTokenUtils;
    
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        // 1. authorization 헤더를 회수
//        String authHeader = request.getHeader("Authorization");
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            // 3. 존재하면 유효한 토큰인지
            // 헤더에서 Bearer 뒤의 토큰만 가져오기
            String token = authHeader.split(" ")[1];
            // 토큰을 확인하는 메서드 (JwtTokenUtils.validate()) boolean값 반환
            if (jwtTokenUtils.validate(token)) {
            } else {
                log.warn("jwt validation failed!!");
            }
        }
        // 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
        // 5. 다음 필터 호출
    }
}

 

- 토큰이 유효하다면 사용자 정보 등록

  • 인증에 성공한 사용자를 SecurityContextHolder에 저장하면 >> Spring Security가 사용자를 인증된 사용자로 판단한다~~~~! 이 과정을 진행해주기.
  • SecurityContext 생성
  • 사용자 정보 회수 (JwtTokenUtils.parseClaims())
  • 인증정보 생성 : 추상클래스 AbstractAuthenticationToken을 상속받은 UsernamePasswordAuthenticationToken으로 생성해준다. (인자로는 Object principal, Object Credentials, Collection<? extends GrantedAuthority> authorities 받아서 넘겨줌.)
  • 인증정보 등록 : SecurityContext에 set해주고, 그 context를 SecurityContextHolder에 set해주기. (SecurityContextHolder 내부 메서드는 모두 static이므로 굳이 생성할 필요가 없다)
// JwtTokenFilter class

@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    //토큰을 확인하는 메서드 (JwtTokenUtils.validate())를 위해
    private final JwtTokenUtils jwtTokenUtils;
    private final UserDetailsManager manager;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        // 1. authorization 헤더를 회수
//        String authHeader = request.getHeader("Authorization");
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            // 3. 존재하면 유효한 토큰인지
            // 헤더에서 Bearer 뒤의 토큰만 가져오기
            String token = authHeader.split(" ")[1];
            // 토큰을 확인하는 메서드 (JwtTokenUtils.validate()) boolean값 반환
            if (jwtTokenUtils.validate(token)) {
                // 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
                // contextholder에서 context 만들고,
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                // 토큰에서 사용자 정보 가져오기
                String username = jwtTokenUtils.parseClaims(token).getSubject();
                // 인증 정보 생성
                AbstractAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(
                                //principal : UserDetailsManager에서 실제 사용자 정보 가져오기
                                manager.loadUserByUsername(username),
                                // credentials : token
                                token,
                                // authorities
                                new ArrayList<>()
                        );
                // 인증 정보 등록
                context.setAuthentication(authenticationToken);
                SecurityContextHolder.setContext(context);
                log.info("=== set security context with jwt");
            } else {
                log.warn("jwt validation failed!!");
            }
        }
        // 5. 다음 필터 호출
    }
}

 

- 다음 필터 호출 꼭꼭~ filterChain.doFilter();

@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    //토큰을 확인하는 메서드 (JwtTokenUtils.validate())를 위해
    private final JwtTokenUtils jwtTokenUtils;
    private final UserDetailsManager manager;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        // 1. authorization 헤더를 회수
//        String authHeader = request.getHeader("Authorization");
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            // 3. 존재하면 유효한 토큰인지
            // 헤더에서 Bearer 뒤의 토큰만 가져오기
            String token = authHeader.split(" ")[1];
            // 토큰을 확인하는 메서드 (JwtTokenUtils.validate()) boolean값 반환
            if (jwtTokenUtils.validate(token)) {
                // 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
                // contextholder에서 context 만들고,
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                // 토큰에서 사용자 정보 가져오기
                String username = jwtTokenUtils.parseClaims(token).getSubject();
                // 인증 정보 생성
                AbstractAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(
                                //principal : UserDetailsManager에서 실제 사용자 정보 가져오기
                                manager.loadUserByUsername(username),
                                // credentials : token
                                token,
                                // authorities
                                new ArrayList<>()
                        );
                // 인증 정보 등록
                context.setAuthentication(authenticationToken);
                SecurityContextHolder.setContext(context);
                log.info("=== set security context with jwt");
            } else {
                log.warn("jwt validation failed!!");
            }
        }
        // 5. 다음 필터 호출
        filterChain.doFilter(request, response);
    }
}

 

- 마지막으로 필터를 설정해주기 (WebSecurityConfig)

  • 이제 formLogin을 사용할 필요가 없어졌다. >> .formLogin, .logout 제외하고 JWT 필터를 권한 필터 앞에 넣어주자.
  • 또한, 세션에 저장할 필요가 없어졌기 때문에 보안 관련 세션을 해제해준다. (.sessionManagement)
// WebSecurityConfig class

@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final JwtTokenUtils jwtTokenUtils;
    private final UserDetailsManager manager;
    
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http
                // csrf 보안 해제
                .csrf(AbstractHttpConfigurer::disable)
                // url에 따른 요청 인가
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/no-auth",
                                "/users/home",
                                "/tests",
                                "/token/validate",
                                "/token/issue"
                        )
                        .permitAll()
                        .requestMatchers("/users/my-profile")
                        .authenticated()
                        .requestMatchers("/users/login",
                                "/users/register")
                        .anonymous()
                        .anyRequest().permitAll()
                )
                // JWT를 사용하기 때문에 보안 관련 세션 해제
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                // JWT 필터를 권한 필터 앞에 삽입
                .addFilterBefore(new JwtTokenFilter2(jwtTokenUtils, manager),
                        AuthorizationFilter.class)
        ;
        return http.build();
    }

    // 비밀번호 암호화 클래스
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

 

* * 여기까지 하면 다 된줄 알았는데,, ㅎㅎㅎ 이러한 오류가 난다.

 

what's wrong...? 

뭔가 순환된다는 이야기. 순환참조와 같이 된다 이유는

 

요렇게 두 클래스를 살펴보면 UserDetailsManager 인터페이스를 구현해놓은 CustomUserDetailsManager 클래스에서 생성시에 PasswordEncoder가 필요하다. 그러나, 이는 WebSecurityConfig에서 구현해두었고, 이 WebSecurityConfig를 생성하기 위해서는 UserDetailsManager가 필요하다. 그러므로, 닭이 먼저냐 달걀이 먼저냐...문제가 되면서 서로에게 의존적이기 때문에 application failed to start가 뜬다. 

>> 이를 해결하려면 간단하게 PasswordEncoder 메서드를 그냥 따로 빼버린다. 

 

// PasswordEncoderConfig class

@Configuration
public class PasswordEncoderConfig {

    // 비밀번호 암호화 클래스
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

 

요렇게 빼주고 WebSecurityConfig에서는 삭제해준다. 그렇게 하면 정말로 실행이 된다! 이제 테스트해보면 된다.

토큰을 발행하고, 그 발행한 토큰을 만료 전에 Authorization headers에 넣어주고 실행!

하면 이렇게 잘 나와주고, 토큰 만료가 되고 실행하면 다시 로그인 창이 뜨게 된다. 와아아

 

 

 


그렇게 두려워하던 시큐리티와..... JWT.......드뎌 만났다. 

아직은 직접 사용하기까지 걸리긴 하겠지만 적어둔대로 하나씩 구현해보아야겠다!!!

새로운 오류들을 만나고 해결하는 것이 재미있어진다.

728x90