지난 포스팅에서 DB의 접근 및 리졸버의 구현을 알았으니, 이번 포스팅에서는 인증에 대하여 알아볼 예정이다.
회원가입과 로그인의 과정이 이루어지는 기능적인 부분을 알아보도록 하자.
본 시리즈는 HOW TO GRAPHQL 글을 참고하여 작성했습니다.
기존에 참조하던 cadenzah 님의 포스팅이 deprecated되어 새롭게 작성하였습니다.
오타 및 의역이 있을 수 있으니 양해를 부탁 드리며, 수정 사항은 댓글로 알려주세요.
회원가입 및 로그인 기능을 포함하는 인증 기능을 구현하기 위해서 우선 다음과 같이 User 모델을 추가한다.
Uesr 모델은 Link 모델과 관계가 있으며, Link는 User에 의해 작성된다.
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[]
}
위의 코드에서 우리는 User 인스턴스를 참조하는 postedBy 필드를 Link 모델에 추가했다.
마찬가지로 Link의 배열을 필드값으로 가지는 links를 User 모델에 추가했다.
이렇게 참조관계를 성립하게 위해서는, postedBy 필드에 @realation 어노테이션을 추가해야한다.
이러한 방법은 Prisma 스키마를 따르는 모든 참조관계 모델에 해당하며, 참조되는 테이블에 외래키 설정을해야한다.
따라서 위의 경우에, 우리는 Link를 만든 User의 id를 추가적으로 postedById라는 필드 값에 저장하여 Prisma에게 참조관계를 알려준다.
Prisma의 참조관계는 이곳을 참조
데이터 모델의 변경이 일어났으므로, 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
매번 이렇게 귀찮은 과정을 거쳐서 반영해야하는가? 라는 의문이 들 수 있다.
그러나 본 포스팅의 후반부에서자동화과정을 다룰 예정이니 좀만 참길 바란다.
데이터모델을 반영한 데이터베이스가 준비되었으니, 이제 실제 데이터에 접근할 수 있는 API를 구현할 차례이다.
인증의 과정을 반영하기 위해 signup과 login 두가지 뮤테이션을 작성하도록 하자.
뮤테이션 스키마 변경을 위해 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!]!
}
새롭게 추가한 signup과 login 큐테이션은 굉장히 비슷하게 동작한다.
둘다 GraphQL API에서 인증처리를 하는 token을 기반으로 접속한 User의 정보를 반환한다.
이러한 정보가 AuthPayload 타입에 정의된다.
마지막으로, User와 Link간의 양방향 참조관계를 반영하여 schema.graphql 파일의 Link 모델을 다음과 같이 수정한다.
type Link {
id: ID!
description: String!
url: String!
postedBy: User
}
스키마 정의를 새롭게 확장했으니, 추가적으로 리졸버 함수를 수정할 차례이다.
리졸버 함수를 수정하기 전에 기존에 하나의 파일에 존재하는 리졸버들을 별도의 파일로 분리하자.
# ./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,
}
쿼리를 별도의 리졸버 파일로 분리 했으므로, 다음으로는 뮤테이션을 분리한다.
이때 위에서 새롭게 추가된 login과 sginup 리졸버를 함께 추가해준다.
// ./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은 다음의 과정을 통해 이루어진다.
signup 뮤테이션에서 User의 비밀번호를 bcryptjs를 이용하여 암호화한다.PrismaClient 인스턴스를 사용하여 새로운 User를 생성 후 데이터베이스에 저장한다.jwt라이브러리를 설치하고, APP_SECRET을 이용하여 JWT를 생성한다.token과 user를 AuthPayload 객체 모양으로 가공하여 반환한다.login은 다음의 과정을 통해 이루어진다.
User 객체를 생성하는 대신, PrismaClient를 활용하여 login 뮤테이션에서 인자로 받은 email과 일치하는 User를 가져온다.비교하여 다르다면 에러를 반환한다.token과 user를 반환한다.위의 과정을 수행하기 위해 다음과 같은 명령어를 통해 필요한 라이브러리를 설치한다.
# ./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 헤더를 읽고, 요청을 보낸 사용자가 해당 요청에 상응하는 동작을 수행할 수 있는지 여부를 검증할 수 있다.
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가지 다른 방식을 지닌다.
getUserId 함수를 사용하여 User의 ID를 JWT로 부터 받아온다. 그러므로 우리는 어떤 User가 새로운 Link를 작성했는지 알 수 있다.exception을 발생시키고, 해당 함수의 범위는 creatLink 뮤테이션이 실행되기 전에 종료된다.userId를 사용하여, 생성될 Link와 이 Link를 생성한 User를 연결하며, 이 과정은 중첩된 객체 쓰기를 통하여 이루어진다.서버를 구동해서 새로운 기능을 테스트하기 전에 User와 Link 간의 관계가 올바르게 리졸브 되었는지 확인해야 한다.
앞선 포스팅에서 User와 Link 타입에서 스칼라 값의 경우 리졸버를 생략했다.
Link: {
id: parent => parent.id,
url: parent => parent.url,
description: parent => parent.description,
}
하지만 이런 방식으로는 리졸브 할 수 없는 2개의 필드를 새롭게 추가했는데, 다음과 같다.
Link 타입의 postedBy 필드User 타입의 links 필드// ./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,
}
여태 진행하였던 부분을 하나로 모아, 다음의 명령으로 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
}
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 구독에 대하여 알아보도록 한다.