React Apollo: JWT 및 새로 고침 토큰

요즘에는 많은 서비스들이 JWT(JSON Web Token)를 인증으로 선택합니다. JWT를 구현할 때 액세스 토큰과 새로 고침 토큰을 발급합니다.


AccessToken 및 RefreshToken


  • AccessToken은 만료 시간이 짧고(예: 10~15분) API에 액세스할 수 있는 권한을 나타냅니다.
  • RefreshToken은 새 액세스 토큰을 발급하는 데 사용되며 액세스 토큰보다 만료 시간이 더 깁니다.

  • Refresh Token 덕분에 더욱 안전한 Access Token을 관리할 수 있습니다.
    '갱신 토큰이 유출되면 어떡하지?'라고 물을 수 있습니다. 우리를 더 안전하게 만드는 많은 전략이 있습니다. RTR(Refresh Token Rotation)처럼요.
    간단히 말해서 refresh API는 액세스 토큰과 새로 고침 토큰을 발급하고 새로 고침 토큰을 만료시킵니다. 새로 고침 토큰이 두 번 이상 사용되면 토큰이 유출되었을 것이라고 가정합니다.

    이 설명서auth0-refresh-token-rotation를 읽는 것이 좋습니다.

    이 게시물에서는 더 이상 JWT에 대해 이야기하지 않고 계속 진행하겠습니다.


    새로 고침 토큰 구현


    NestJS를 사용하여 테스트 서버를 만들었습니다. 3명의 리졸버와 2명의 가드가 있습니다.

    근위 연대


  • JwtAuthGuard: Authorization 헤더에서 액세스 토큰이 유효한지 인증합니다.
  • JwtRefreshAuthGuard: 새로 고침 토큰이 Authorization 헤더에서 유효한지 인증합니다.

  • 두 토큰 모두 각 요청의 Authorization 헤더에 전달되고 localStorage에 저장됩니다.
    더 나은 보안을 위해 httpOnly 속성 및 SameSite 속성과 함께 cookie 를 사용할 수 있습니다.

    아피스


  • createToken: 액세스 토큰과 새로 고침 토큰을 발급합니다.
  • ping: 액세스 토큰이 확인되면 true를 반환하고 그렇지 않으면 401 error를 반환합니다.
  • refreshToken: 새로 고침 토큰이 확인된 경우 액세스 토큰을 반환하고 그렇지 않은 경우 반환401 error

  • DTO




    import { ObjectType, Field } from '@nestjs/graphql';
    
    @ObjectType()
    export class CreateTokenResponse {
      @Field()
      accessToken: string;
    
      @Field()
      refreshToken: string;
    }
    
    @ObjectType()
    export class RefreshTokenResponse {
      @Field()
      accessToken: string;
    }
    
    


    리졸버




    @Resolver()
    export class AuthResolver {
      constructor(private readonly authService: AuthService) {}
    
      @Mutation(() => CreateTokenResponse)
      async createToken(): Promise<CreateTokenResponse> {
        return this.authService.createToken();
      }
    
      @UseGuards(JwtAuthGuard)
      @Query(() => Boolean)
      async ping() {
        return true;
      }
    
      @UseGuards(JwtRefreshAuthGuard)
      @Mutation(() => RefreshTokenResponse)
      async refreshToken(): Promise<RefreshTokenResponse> {
        return this.authService.refreshToken();
      }
    }
    


    대본





    이 시나리오에는 6단계가 있습니다.
  • createToken을 요청하고 서버에서 액세스 토큰 및 새로 고침 토큰을 가져옵니다
  • .
  • 액세스 토큰이 만료된 요청 통과 시 401 오류 발생
  • refreshToken 요청
  • 새 액세스 토큰 받기
  • 실패한 요청 재시도
  • 성공!

  • 시나리오에서는 액세스 토큰의 만료 시간을 5초로 설정했습니다.


    아폴로 클라이언트 반응



    유형 및 쿼리




    
    /**
     * Types
     */
    interface Tokens {
      accessToken: string;
      refreshToken: string;
    }
    
    interface AccessToken {
      accessToken: string;
    }
    
    /**
     * Queries
     */
    const CREATE_TOKEN = gql`
      mutation createToken {
        createToken {
          accessToken
          refreshToken
        }
      }
    `;
    
    const REFRESH_TOKEN = gql`
      mutation refreshToken {
        refreshToken {
          accessToken
        }
      }
    `;
    
    const PING = gql`
      query ping {
        ping
      }
    `;
    


    페이지




    
    /**
     * React Components
     */
    
    function App() {
      const [createToken, { data: createTokenData }] = useMutation<{
        createToken: Tokens;
      }>(CREATE_TOKEN);
      const [ping] = useLazyQuery(PING, {
        fetchPolicy: 'network-only',
      });
    
      const requestToken = () => {
        createToken();
      };
    
      const sendPing = () => {
        ping();
      };
    
      useEffect(() => {
        if (!createTokenData) return;
    
        const { accessToken, refreshToken } = createTokenData.createToken;
    
        // Save tokens in localStorage
        localStorage.setItem('accessToken', accessToken);
        localStorage.setItem('refreshToken', refreshToken);
      }, [createTokenData]);
    
      return (
        <Container>
          <button type="button" onClick={requestToken}>
            login
          </button>
          <button type="button" onClick={sendPing}>
            ping
          </button>
        </Container>
      );
    }
    
    function ApolloWrapper() {
      return (
        <ApolloProvider client={client}>
          <App />
        </ApolloProvider>
      );
    }
    
    /**
     * Styles
     */
    
    const Container = styled.div`
      display: flex;
      flex-direction: column;
      row-gap: 12px;
      padding: 24px;
    
      > button {
        width: 200px;
        height: 24px;
      }
    `;
    
    export default ApolloWrapper;
    


    두 개의 버튼이 있습니다. 하나는 createToken용이고 다른 하나는 pass용입니다.

    refreshToken 요청 및 실패한 요청 재시도




    
    /**
     * Apollo Setup
     */
    
    function isRefreshRequest(operation: GraphQLRequest) {
      return operation.operationName === 'refreshToken';
    }
    
    // Returns accesstoken if opoeration is not a refresh token request
    function returnTokenDependingOnOperation(operation: GraphQLRequest) {
      if (isRefreshRequest(operation))
        return localStorage.getItem('refreshToken') || '';
      else return localStorage.getItem('accessToken') || '';
    }
    
    const httpLink = createHttpLink({
      uri: 'http://localhost:3000/graphql',
    });
    
    const authLink = setContext((operation, { headers }) => {
      let token = returnTokenDependingOnOperation(operation);
    
      return {
        headers: {
          ...headers,
          authorization: token ? `Bearer ${token}` : '',
        },
      };
    });
    
    const errorLink = onError(
      ({ graphQLErrors, networkError, operation, forward }) => {
        if (graphQLErrors) {
          for (let err of graphQLErrors) {
            switch (err.extensions.code) {
              case 'UNAUTHENTICATED':
                // ignore 401 error for a refresh request
                if (operation.operationName === 'refreshToken') return;
    
                const observable = new Observable<FetchResult<Record<string, any>>>(
                  (observer) => {
                    // used an annonymous function for using an async function
                    (async () => {
                      try {
                        const accessToken = await refreshToken();
    
                        if (!accessToken) {
                          throw new GraphQLError('Empty AccessToken');
                        }
    
                        // Retry the failed request
                        const subscriber = {
                          next: observer.next.bind(observer),
                          error: observer.error.bind(observer),
                          complete: observer.complete.bind(observer),
                        };
    
                        forward(operation).subscribe(subscriber);
                      } catch (err) {
                        observer.error(err);
                      }
                    })();
                  }
                );
    
                return observable;
            }
          }
        }
    
        if (networkError) console.log(`[Network error]: ${networkError}`);
      }
    );
    
    const client = new ApolloClient({
      link: ApolloLink.from([errorLink, authLink, httpLink]),
      cache: new InMemoryCache(),
    });
    
    // Request a refresh token to then stores and returns the accessToken.
    const refreshToken = async () => {
      try {
        const refreshResolverResponse = await client.mutate<{
          refreshToken: AccessToken;
        }>({
          mutation: REFRESH_TOKEN,
        });
    
        const accessToken = refreshResolverResponse.data?.refreshToken.accessToken;
        localStorage.setItem('accessToken', accessToken || '');
        return accessToken;
      } catch (err) {
        localStorage.clear();
        throw err;
      }
    };
    


    요청이 refreshToken에 대한 것인지 아닌지를 operation.operationName를 통해 구분합니다.
    요점은 onError를 사용하여 Observable에서 재시도 요청 논리를 구현할 수 있다는 것입니다.Observable에서 onError 개체를 반환한 다음 함수에서 새 액세스 토큰을 가져오고 forward을 사용하여 요청을 다시 시도합니다.
    링크 순서가 원하는 대로 올바른지 확인하십시오.


    this repository에서 결과를 gif 이미지와 코드로 볼 수 있습니다.

    그게 다야, 누군가에게 도움이 되길 바랍니다.

    행복한 코딩!

    좋은 웹페이지 즐겨찾기