JWT

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

BELATED ARTICLES

more