[GraphQL] Authentication

GraphQL + Node.js - 6

Posted by owin2828 on 2020-11-17 17:33 · 21 mins read

들어가기 앞서


지난 포스팅에서 DB의 접근 및 리졸버의 구현을 알았으니, 이번 포스팅에서는 인증에 대하여 알아볼 예정이다.
회원가입로그인의 과정이 이루어지는 기능적인 부분을 알아보도록 하자.

본 시리즈는 HOW TO GRAPHQL 글을 참고하여 작성했습니다.
기존에 참조하던 cadenzah 님의 포스팅이 deprecated되어 새롭게 작성하였습니다.
오타 및 의역이 있을 수 있으니 양해를 부탁 드리며, 수정 사항은 댓글로 알려주세요.

1. User 모델 추가


회원가입 및 로그인 기능을 포함하는 인증 기능을 구현하기 위해서 우선 다음과 같이 User 모델을 추가한다.
Uesr 모델은 Link 모델과 관계가 있으며, LinkUser에 의해 작성된다.

prisma/schema.prisma 파일에 데이터 모델을 명시적으로 표시한다.

model Link {
  id                      Int      @id @default(autoincrement())
  createdAt       DateTime @default(now())
  description     String
  url                     String
  postedBy         User?    @relation(fields: [postedById], references: [id])
  postedById      Int?
}

model User {
  id                     Int      @id @default(autoincrement())
  name              String
  email               String   @unique
  password       String
  links                 Link[]
}


2. 필드값 사이의 관계 이해


위의 코드에서 우리는 User 인스턴스를 참조하는 postedBy 필드를 Link 모델에 추가했다.
마찬가지로 Link의 배열을 필드값으로 가지는 linksUser 모델에 추가했다.

이렇게 참조관계를 성립하게 위해서는, postedBy 필드에 @realation 어노테이션을 추가해야한다.
이러한 방법은 Prisma 스키마를 따르는 모든 참조관계 모델에 해당하며, 참조되는 테이블에 외래키 설정을해야한다.
따라서 위의 경우에, 우리는 Link를 만든 Userid를 추가적으로 postedById라는 필드 값에 저장하여 Prisma에게 참조관계를 알려준다.

Prisma의 참조관계는 이곳을 참조

3. Prisma Client 수정


데이터 모델의 변경이 일어났으므로, migrate 과정을 다시 진행해야 한다.

# ./hackernews-node
npx prisma migrate save --name "add-user-model" --experimental

위의 명령을 통해 prisma/migrations 위치에 2번째 migration이 생성됨을 알 수 있다.
해당 디렉토리 내부의 README.md 파일을 참조하면 자세한 변동내역이 기록이 열람 가능하다.

migration을 저장했으니, 이제 데이터베이스에 변동내역을 반영한다.

# ./hackernews-node
npx prisma migrate up --experimental

이제 우리의 데이터베이스는 우리의 데이터모델를 반영하게 되었다.
마지막으로 다음의 명령을 통해 Prisma Client를 재생성하자.

# ./hackernews-node
npx prisma generate

매번 이렇게 귀찮은 과정을 거쳐서 반영해야하는가? 라는 의문이 들 수 있다.
그러나 본 포스팅의 후반부에서 자동화 과정을 다룰 예정이니 좀만 참길 바란다.

4. GraphQL 스키마의 확장


데이터모델을 반영한 데이터베이스가 준비되었으니, 이제 실제 데이터에 접근할 수 있는 API를 구현할 차례이다.
인증의 과정을 반영하기 위해 signuplogin 두가지 뮤테이션을 작성하도록 하자.

뮤테이션 스키마 변경을 위해 src/schema.graphql 파일을 다음과 같이 수정한다.

type Mutation {
  post(url: String!, description: String!): Link!
  signup(email: String!, password: String!, name: String!): AuthPayload
  login(email: String!, password: String!): AuthPayload
}

AuthPayload 타입을 사용하기 위해 새롭게 src/schema.graphql 파일에 추가한다.

type AuthPayload {
  token: String
  user: User
}

type User {
  id: ID!
  name: String!
  email: String!
  links: [Link!]!
}

새롭게 추가한 signuplogin 큐테이션은 굉장히 비슷하게 동작한다.
둘다 GraphQL API에서 인증처리를 하는 token을 기반으로 접속한 User의 정보를 반환한다.
이러한 정보가 AuthPayload 타입에 정의된다.

마지막으로, UserLink간의 양방향 참조관계를 반영하여 schema.graphql 파일의 Link 모델을 다음과 같이 수정한다.

type Link {
  id: ID!
  description: String!
  url: String!
  postedBy: User
}


5. Resolver 함수 수정


스키마 정의를 새롭게 확장했으니, 추가적으로 리졸버 함수를 수정할 차례이다.
리졸버 함수를 수정하기 전에 기존에 하나의 파일에 존재하는 리졸버들을 별도의 파일로 분리하자.

# ./hackernews-node

mkdir src/resolvers
touch src/resolvers/Query.js
touch src/resolvers/Mutation.js
touch src/resolvers/User.js
touch src/resolvers/Link.js

분리가 되었다면, 기존에 구현된 feed 리졸버를 Query.js 파일로 옮긴다.

// ./hackernews-node/src/resolvers/Query.js

function feed(parent, args, context, info) {
  return context.prisma.link.findMany()
}

module.exports = {
  feed,
}


6. 인증 관련 Resolver 추가


쿼리를 별도의 리졸버 파일로 분리 했으므로, 다음으로는 뮤테이션을 분리한다.
이때 위에서 새롭게 추가된 loginsginup 리졸버를 함께 추가해준다.

// ./hackernews-node/src/resolvers/Mutation.js

async function signup(parent, args, context, info) {
  // 1
  const password = await bcrypt.hash(args.password, 10)
  
  // 2
  const user = await context.prisma.user.create({ data: { ...args, password } })

  // 3
  const token = jwt.sign({ userId: user.id }, APP_SECRET)

  // 4
  return {
    token,
    user,
  }
}

async function login(parent, args, context, info) {
  // 1
  const user = await context.prisma.user.findOne({ where: { email: args.email } })
  if (!user) {
    throw new Error('No such user found')
  }

  // 2
  const valid = await bcrypt.compare(args.password, user.password)
  if (!valid) {
    throw new Error('Invalid password')
  }

  const token = jwt.sign({ userId: user.id }, APP_SECRET)

  // 3
  return {
    token,
    user,
  }
}

module.exports = {
  signup,
  login,
  post,
}

signup은 다음의 과정을 통해 이루어진다.

  1. signup 뮤테이션에서 User의 비밀번호를 bcryptjs를 이용하여 암호화한다.
  2. PrismaClient 인스턴스를 사용하여 새로운 User를 생성 후 데이터베이스에 저장한다.
  3. jwt라이브러리를 설치하고, APP_SECRET을 이용하여 JWT를 생성한다.
  4. 생성된 tokenuserAuthPayload 객체 모양으로 가공하여 반환한다.

login은 다음의 과정을 통해 이루어진다.

  1. 새로운 User 객체를 생성하는 대신, PrismaClient를 활용하여 login 뮤테이션에서 인자로 받은 email과 일치하는 User를 가져온다.
    만약 존재하지 않는다면 에러를 반환한다.
  2. 전달받은 비밀번호와 데이터베이스에 저장된 비밀번호를 비교하여 다르다면 에러를 반환한다.
  3. 모든 과정이 성공한다면, tokenuser를 반환한다.

위의 과정을 수행하기 위해 다음과 같은 명령어를 통해 필요한 라이브러리를 설치한다.

# ./hackernews-node
npm install jsonwebtoken bcryptjs

또한 사용할 라이브러리를 위한 새로운 파일을 src/utils.js로 생성후, 내용을 추가한다.

# ./hackernews-node
touch src/utils.js
// ./hackernews-node/src/utils.js

const jwt = require('jsonwebtoken')
const APP_SECRET = 'GraphQL-is-aw3some'

function getUserId(context) {
  const Authorization = context.request.get('Authorization')
  if (Authorization) {
    const token = Authorization.replace('Bearer ', '')
    const { userId } = jwt.verify(token, APP_SECRET)
    return userId
  }

  throw new Error('Not authenticated')
}

module.exports = {
  APP_SECRET,
  getUserId,
}

APP_SECRET은 사용자들에게 발급해줄 JWT를 서명하는 데에 사용된다.

getUserId는 헬퍼함수로써, 인증이 필요한 함수에서 사용할 수 있는 함수이다.
이 함수는 context로 부터 User의 JWT를 포함한 Authorization Header를 받는다.
그 후 JWT를 검증하고, User의 ID를 추출한다.
만약 정상적이지 않다면, exception을 발생시키는데, 이는 인증 과정에서 리졸버들을 보호하는 역할을 한다.

모든 과정을 수행하기 위해 Mutation.js에 위의 함수들을 포함시켜준다.

// ./hackernews-node/src/resolvers/Mutation.js

const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const { APP_SECRET, getUserId } = require('../utils')

여기까지 왔다면, 마지막 작은 문제가 남게 된다. 우리는 context 객체 내의 request 객체에 접근하고 있다.
하지만 context를 처음 초기화할 때, 우리는 prisma 클라이언트 인스턴스만을 추가하고, 아직 request 객체가 추가되지 않았다.
이를 위해 index.js에 다음과 같이 초기화 코드를 추가한다.

// ./hackernews-node/src/index.js

const server = new GraphQLServer({
  typeDefs: './src/schema.graphql',
  resolvers,
  context: request => {
    return {
      ...request,
      prisma,
    }
  },
})

이번에는 request를 context 객체에 직접 추가하지 않고, context 객체를 반환하는 함수로 그 형태를 바꿨다.
이러한 접근 방식의 장점은 바로 GraphQL 쿼리(또는 뮤테이션)를 전달하는 HTTP 요청을 context에 직접 추가할 수 있다는 점이다.
이렇게 되면 리졸버에서 Authorization 헤더를 읽고, 요청을 보낸 사용자가 해당 요청에 상응하는 동작을 수행할 수 있는지 여부를 검증할 수 있다.

7. post mutation에 대해 인증 요구하기


Mutation.js 파일을 열고, 다음의 코드를 post 리졸버에 추가한다.

// ./hackernews-node/src/resolvers/Mutation.js

function post(parent, args, context, info) {
  const userId = getUserId(context)

  return context.prisma.link.create({
    data: {
      url: args.url,
      description: args.description,
      postedBy: { connect: { id: userId } },
    }
  })
}

이는 기존의 방식과는 다음과 같은 2가지 다른 방식을 지닌다.

  1. getUserId 함수를 사용하여 User의 ID를 JWT로 부터 받아온다. 그러므로 우리는 어떤 User가 새로운 Link를 작성했는지 알 수 있다.
    잘못된 UserId의 조회는 exception을 발생시키고, 해당 함수의 범위는 creatLink 뮤테이션이 실행되기 전에 종료된다.
    이 경우, GraphQL 반환값에는 단지 해당 사용자가 인증되지 않았다고 하는 오류가 포함될 것이다.
  2. 다음으로 userId를 사용하여, 생성될 Link와 이 Link를 생성한 User를 연결하며, 이 과정은 중첩된 객체 쓰기를 통하여 이루어진다.

8. 참조관계 Resolve하기


서버를 구동해서 새로운 기능을 테스트하기 전에 UserLink 간의 관계가 올바르게 리졸브 되었는지 확인해야 한다.
앞선 포스팅에서 UserLink 타입에서 스칼라 값의 경우 리졸버를 생략했다.

Link: {
  id: parent => parent.id,
  url: parent => parent.url,
  description: parent => parent.description,
}

하지만 이런 방식으로는 리졸브 할 수 없는 2개의 필드를 새롭게 추가했는데, 다음과 같다.

  1. Link 타입의 postedBy 필드
  2. User 타입의 links 필드
    GraphQL은 참조관계에 있는 위의 필드들을 어디서 가져와야하는지 알 수 없으므로, 명시적으로 구현해야 한다.
// ./hackernews-node/src/resolvers/Link.js

function postedBy(parent, args, context) {
  return context.prisma.link.findOne({ where: { id: parent.id } }).postedBy()
}

module.exports = {
  postedBy,
}

postedBy 리졸버에서는 우선 prisma 클라이언트를 사용하여 Link를 불러오고, 다음으로 해당 Link에 대하여 postedBy 메서드를 호출한다.
이 리졸버는 schema.graphql에 정의된 Link 타입이 가지는 postedBy 필드를 리졸브해야 하므로, postedBy 라는 이름을 가져야 한다.

links 관계 또한 비슷한 방식으로 이루어진다.

// ./hackernews-node/src/resolvers/User.js

function links(parent, args, context) {
  return context.prisma.user.findOne({ where: { id: parent.id } }).links()
}

module.exports = {
  links,
}


9. 하나로 모으기


여태 진행하였던 부분을 하나로 모아, 다음의 명령으로 index.js 파일에 합치도록 하자.

// ./hackernews-node/src/index.js

const Query = require('./resolvers/Query')
const Mutation = require('./resolvers/Mutation')
const User = require('./resolvers/User')
const Link = require('./resolvers/Link')

...

const resolvers = {
  Query,
  Mutation,
  User,
  Link
}


10. 인증 테스트하기


signup 뮤테이션을 통해 새로운 User를 데이터베이스에 저장하는 인증 과정을 테스트 해보자.

mutation {
  signup(
    name: "Alice"
    email: "alice@prisma.io"
    password: "graphql"
  ) {
    token
    user {
      id
    }
  }
}

서버 측 응답에서 인증 token을 복사하고, 새로운 탭을 열어 Playground에 접속한다.
새 탭의 왼쪽 하단에 위치한 HTTP HEADERS 패널을 열고 Authorization 헤더를 설정한다.
아래의 코드에서 TOKEN 으로 표시해놓은 부분의 값을 복사한 토큰값으로 대체하면 된다.

{
  "Authorization": "Bearer __TOKEN__"
}

이제 해당 탭에서 쿼리 혹은 뮤테이션을 전송할 때마다, 인증 토큰이 포함되어 전송된다.
마지막으로, Authorization 헤더를 포함한 상태로, GraphQL 서버에 다음 뮤테이션을 전송해보자.

mutation {
  post(
    url: "www.graphqlconf.org"
    description: "An awesome GraphQL conference"
  ) {
    id
  }
}

서버가 이 뮤테이션을 받으면, post 리졸버를 호출하여 제공된 JWT를 검증한다.
또한, 새로 생성되는 Link는 이전에 signup 뮤테이션을 통하여 전달된 User에 연결된다.

모든 과정을 테스트하기 위해 login 뮤테이션 테스트를 진행하자.

mutation {
  login(
    email: "alice@prisma.io"
    password: "graphql"
  ) {
    token
    user {
      email
      links {
        url
        description
      }
    }
  }
}

성공적으로 응답한다면 다음과 비슷한 응답이 돌아올 것이다.

{
  "data": {
    "login": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjanBzaHVsazJoM3lqMDk0NzZzd2JrOHVnIiwiaWF0IjoxNTQ1MDYyNTQyfQ.KjGZTxr1jyJH7HcT_0glRInBef37OKCTDl0tZzogekw",
      "user": {
        "email": "alice@prisma.io",
        "links": [
          {
            "url": "www.graphqlconf.org",
            "description": "An awesome GraphQL conference"
          }
        ]
      }
    }
  }
}


끝마치며


GraphQL 및 jwt를 활용한 인증 과정에 대하여 알아보았다.
다음 포스팅에서는 실시간 GraphQL 구독에 대하여 알아보도록 한다.