Logo

GraphQL 서버의 사용자 인증/인가 (Apollo Server Authentication/Authorization)

서버 애플리케이션을 개발할 때 사용자 사용자 인증(authentication)과 인가(Authorization)는 데이터 보안을 위해서 매우 핵심적인 기능입니다. 따라서 GraphQL API를 설계하거나, GraphQL 서버를 개발할 때도 사용자 인증/인가 부분에 대해서 여러 가지 고려가 필요합니다. 이번 포스팅에서는 Apllo Server를 이용하여 GraphQL 서버의 사용자 인증과 인가를 구현해보도록 하겠습니다.

HTTP 인증 방식

HTTP 인증 방법에는 여러 가지가 있는데, GraphQL 스팩에서는 어떤 특별한 인증 방법을 쓰라고 별도로 가이드를 하고 있지는 않고 있습니다. 본 포스팅에서는 최대한 간결한 예제를 위해서 단순한 API 인증에 많이 쓰이는 Bearer 인증 방식을 사용하도록 하겠습니다.

Bearer 인증 방식은 클라이언트에서 서버로 요청을 보낼 때 마다 HTTP Authorization 헤더를 Bearer <인증 토큰>으로 설정합니다. 그러면 서버에서는 클라이언트에서 보낸 인증 토큰이 유효한지, 어떤 사용자의 토큰인지를 파악해서 사용자 인증 처리를 해줍니다.

실습 서버 프로젝트

실습 용으로 Apollo Server를 이용해서 간단한 GraphQL 서버를 작성해보도록 하겠습니다.

Apollo Server를 이용해서 GraphQL 서버를 개발해보지 않으신 분들은 아래 포스팅를 먼저 보시고 기본 프로젝트 세팅을 하시고 돌아오시기 바랍니다.

먼저 Apollo Server의 내장 에러 클래스인 AuthenticationErrorForbiddenError를 임포트해서 각각 인증 실패와 인가 실패 시 사용하도록 하겠습니다.

const {
  ApolloServer,
  gql,
  AuthenticationError,
  ForbiddenError,
} = require("apollo-server");

GraphQL 서버의 오류 처리에 대한 자세한 내용은 아래 관련 포스팅를 참고 바라겠습니다.

그리고 아래와 같이 아무런 인증/인가 처리를 하지 않는 매우 간단한 서버 코드에서 부터 시작을 하겠습니다.

const typeDefs = gql`
  type Query {
    ping: String
  }
`;

const resolvers = {
  Query: {
    ping: () => "pong",
  },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

server.listen().then(({ url }) => {
  console.log(`Listening at ${url}`);
});

컨텍스트 레벨 인증

GraphQL 서버에서 구현할 수 있는 가장 단순하지만 가장 안전한 인증부터 적용해보도록 하겠습니다. 클라이언트에서 인증 토큰이 넘어오지 않거나, 넘어온 토큰이 유효하지 않는 경우에는 요청을 무조건 차단하는 것입니다.

컨텍스트 레벨 인증을 할 때는 ApolloServer 생성자의 context 옵션에 함수를 할당해줘야 하는데요. context 옵션에 할당된 함수는 모든 요청에 대해 호출이 되고 요청 정보를 인자로 받기 때문에 인증 토큰을 검증하는 장소로 적합합니다.

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => {
    if (!req.headers.authorization)
      throw new AuthenticationError("mssing token");

    const token = req.headers.authorization.substr(7);
    const user = users.find((user) => user.token === token);
    if (!user) throw new AuthenticationError("invalid token");
    return { user };
  },
});

Bearer 인증 방식에서는 Authorization HTTP 헤더로 인증 토큰이 넘어오기 때문에 먼저 req.headers.authorization를 통해 토큰 존재 여부를 체크합니다. Authorization HTTP 헤더값이 없는 경우에는 인증 실패 처리를 위해서 AuthenticationError 에러를 던짐니다. Authorization HTTP 헤더값이 있다면 맨 앞의 Bearer 부분을 제외한 뒷 부분만을 인증 토큰 문자열로 취합니다.

다음으로 이 인증 토큰에 매칭되는 사용자가 있는지 users 배열을 검색합니다. 사용자가 없다면 역시 인증 실패 상황이므로 AuthenticationError 에러를 던짐니다. 사용자가 있다면 해당 사용자 정보를 컨텍스트에 세팅하여, 추후 리졸버(resover)에서 접근할 수 있도록 해줍니다.

인증 토큰에 매칭되는 사용자를 검색하는 부분은 아래와 같이 users 변수에 임의의 사용자 데이터를 배열에 저장하였습니다. 실제 프로젝트였다면 데이터베이스나 회원 관리 시스템을 이용되었을 것입니다.

const users = [
  {
    token: "a1b2c3",
    email: "user@email.com",
    username: "user",
    password: "123",
  },
  {
    token: "e4f5g6",
    email: "admin@email.com",
    username: "admin",
    password: "456",
  },
];

테스트

GraphQL Playground에서 Authorization 헤더없이 ping 쿼리를 요청해보면 인증 토큰이 없다는 오류 메시지가 응답됩니다.

  • 요청 쿼리
query {
  ping
}
  • 응답
{
  "errors": [
    {
      "message": "Context creation failed: empty token",
      "extensions": {
        "code": "UNAUTHENTICATED"
      }
    }
  ]
}

반면에 유효한 인증 토큰 a1b2c3Authorization 헤더에 세팅하여 ping 쿼리를 요청하면 정상적인 데이터가 응답됩니다.

  • 요청 쿼리
query {
  me {
    username
    email
  }
}
  • 요청 헤더
{
  "Authorization": "Bearer a1b2c3"
}
  • 응답
{
  "data": {
    "ping": "pong"
  }
}

이번에는 유효하지 않은 토큰인 xyz789Authorization 헤더에 세팅하여 ping 쿼리를 요청하면 토큰이 유효하지 않다는 오류 메시지가 응답됩니다.

  • 요청 쿼리
query {
  me {
    username
    email
  }
}
  • 요청 헤더
{
  "Authorization": "Bearer xyz789"
}
  • 응답
{
  "errors": [
    {
      "message": "Context creation failed: invalid token",
      "extensions": {
        "code": "UNAUTHENTICATED"
      }
    }
  ]
}

리졸버 레벨 인증

컨텍스트 레벨 인증이 보안적으로 매우 강력하기는 하지만 현실 속의 실제 GraphQL API는 이렇게 단순하지 않을 수 있는데요. GraphQL API가 제공하는 쿼리(query) 중 일부가 인증없이도 실행이 가능하다면 위와 같이 일괄적으로 차단을 한다면 곤란할 것입니다.

좋은 예로, 클라이언트가 최초에 인증 토큰을 얻기 위해서 호출하는 쿼리를 들 수 있습니다. 클라이언트가 아직 인증 토큰을 가지고 있지도 않는데 인증 토큰을 요구한다면 모순적인 상황이 될 것입니다. 따라서 이렇게 인증 이전에 호출이 필요한 쿼리들은 인증 처리에서 예외가 되야 합니다.

이를 시뮬레이션 하기 위해서 실습 프로젝트의 GraphQL 스키마(typeDefs)에 사용자와 관련된 쿼리 몇 개와 타입 한 개를 정의해보겠습니다. authenticate 쿼리는 usernamepassword를 입력받아 인증 토큰을 응답합니다. me 쿼리는 현재 인증된 사용자의 정보를 응답하고, users 쿼리는 전체 사용자 데이터를 응답합니다.

const typeDefs = gql`
  type Query {
    authenticate(username: String, password: String): String
    me: User
    users: [User]
  }

  type User {
    username: String!
    email: String!
  }
`;

다음으로 위에서 정의한 쿼리를 구현하는 리졸버(resolvers)를 추가합니다. me 쿼리와 users 쿼리는 인증이 필요하므로 리졸버 레벨에서 컨텍스트에 검증 로직을 넣어, 컨텍스트 내에 사용자 정보가 없다면 에러를 던지게 합니다.

const resolvers = {
  Query: {
    authenticate: (parent, { username, password }) => {
      const found = users.find(
        (user) => user.username === username && user.password === password
      );
      console.log(found);
      return found && found.token;
    },
    me: (parent, args, { user }) => {
      if (!user) throw new AuthenticationError("not authenticated");
      return user;
    },
    users: (parent, args, { user }) => {
      if (!user) throw new AuthenticationError("not authenticated");
      return users;
    },
  },
};

컨텍스트 레벨 인증에서 리졸버 레벨 인증으로 변경하려면 컨텍스트 함수에도 약간의 변경이 필요합니다. 기존처럼 인증 토큰이 넘어오지 않았을 때 무조건 에러를 던지는 대신에, 사용자 정보를 undefined로 세팅을 해줍니다.

이렇게 설정을 해주면 인증 토큰이 넘어오지 않았거나, 넘어온 인증 토큰이 유효하지 않더라도, 해당 쿼리에 대한 리졸버 함수까지는 호출이 될 것입니다. 따라서 각 리졸버 함수는 인자로 넘어온 컨텍스트 내의 사용자 정보를 읽어서 차단을 할지 허용을 할지 직접 결정을 할 수 있습니다.

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => {
    // if (!req.headers.authorization)
    //   throw new AuthenticationError("empty token");
    if (!req.headers.authorization) return { user: undefined };

    const token = req.headers.authorization.substr(7);
    const user = users.find((user) => user.token === token);
    // if (!user) throw new AuthenticationError('invalid token');
    return { user };
  },
});

테스트

먼저 GraphQL Playground에서 authenticate 쿼리에 usernamepassword 변수를 던져 인증 토큰을 얻습니다.

  • 요청 쿼리
query ($username: String, $password: String) {
	authenticate(username: $username, password: $password)
}
  • 요청 변수
{
  "username": "user",
  "password": "123"
}
  • 응답
{
  "data": {
    "authenticate": "a1b2c3"
  }
}

위의 쿼리 결과로 얻은 인증 토큰 a1b2c3Authorization 헤더로 넘겨서 인증된 사용자의 정보를 얻습니다.

  • 요청 쿼리
query {
  me {
    username
    email
  }
}
  • 요청 헤더
{
  "Authorization": "Bearer a1b2c3"
}
  • 응답
{
  "data": {
    "me": {
      "username": "user",
      "email": "user@email.com"
    }
  }
}

리졸버 레벨 인가

지금까지 사용자를 인증하는 방법에 대해서 살펴봤으니, 지금부터 인증된 사용자가 어떤 데이터에 접근할 또는 어떤 작업을 수행할 권한이 있는지를 체크하는 인가(authorization)에 대해서 알아보겠습니다. 모든 사용자의 데이터를 제공하는 users 쿼리는 관리자 권한이 없는 사용자에 의해서 열람이 될 경우 큰 보안 이슈가 발생할 수 있습니다. 따라서 인증된 사용자가 관리자 권한이 있는지 체크하고, 관리자 권한이 없다면 데이터의 접근을 차단해야 합니다.

먼저 users 배열에 저장되어 있는 사용자 데이터에 roles 속성을 추가하고, 각 사용자가 가지고 있는 권한 정보를 담고 있는 임의의 배열을 할당합니다.

const users = [
  {
    token: "a1b2c3",
    email: "user@email.com",
    username: "user",
    password: "123",
    roles: ["user"],
  },
  {
    token: "e4f5g6",
    email: "admin@email.com",
    username: "admin",
    password: "456",
    roles: ["user", "admin"],
  },
];

다음으로 users 쿼리에 대한 리졸버 함수를 수정합니다. 컨텍스트에 세팅되어 있는 사용자의 정보가 없으면 인증 오류를 발생시킬 뿐만 아니라, 사용자 정보가 있더라도 관리자 권한을 가지고 있지 않다면 인가 오류를 발생시킵니다.

const resolvers = {
  Query: {
    // 생략
    users: (parent, args, { user }) => {
      if (!user) throw new AuthenticationError("not authenticated");
      if (!user.roles.includes("admin"))
        throw new ForbiddenError("not authorized");
      return users;
    },
  },
};

테스트

GraphQL Playground에서 관리자 계정의 토큰인 e4f5g6Authorization 헤더에 넘겨 users 쿼리를 요청하면 정상적으로 모든 사용자 데이터가 응답됩니다.

  • 요청 쿼리
query {
  users {
    email
  }
}
  • 요청 헤더
{
  "Authorization": "Bearer e4f5g6"
}
  • 응답
{
  "data": {
    "users": [
      {
        "email": "user@email.com"
      },
      {
        "email": "admin@email.com"
      }
    ]
  }
}

하지만 일반 사용자 계정의 토큰인 a1b2c3Authorization 헤더에 넘겨 users 쿼리를 요청하면 인가 실패 오류가 응답됩니다.

  • 요청 쿼리
query {
  users {
    email
  }
}
  • 요청 헤더
{
  "Authorization": "Bearer a1b2c3"
}
  • 응답
{
  "data": null,
  "errors": [
    {
      "message": "not authorized",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": ["users"],
      "extensions": {
        "code": "FORBIDDEN"
      }
    }
  ]
}

전체 코드

관련 포스팅