이번에는 회원가입/로그인/로그아웃 기능을 구현해볼 것이다.
이 글에서 설명하는 내용은 개인적으로 공부하면서 생각한 것으로 이 방식을 다른 프로젝트에 그대로 도입하면 보안상 문제가 생길 수도 있으니 주의하세요
> yarn add bcrypt crypto-js csprng
bcrypt
crypto-js
csprng
// src/graphql/typeDefs.js
import { gql } from 'apollo-server';
const typeDefs = gql`
...
type User {
id: Int!
name: String
ID: String!
passwordHash: String
role: [String!]!
token: String
}
type Query {
...
me: User!
}
type Mutation {
...
signup(name: String!, ID: String!, password: String!): Boolean!
login(ID: String!, password: String!): User
logout: Boolean!
}
`;
export default typeDefs;
User사용자의 어떤 데이터를 저장할지 정의한다. 임의로 설정해도 된다. name과 ID, password는 사용자 요청으로부터 받아 저장하고, 그외 정보는 리졸버에서 계산해 저장한다.
Query - meMutation - signup이름과 아이디, 비밀번호를 받아 데이터베이스에 등록하는 API이다. 성공적으로 가입하면 true를, 아니면 false를 반환한다.
Mutation - login가입한 아이디와 비밀번호를 통해 로그인을 진행한다. 성공적으로 인증이 완료되면 해당 사용자 정보(User)를, 아니면 null을 반환한다.
Mutation - logout로그인 상태일 때 서버에 등록된 사용자의 로그인 정보를 초기화하는 API이다. 성공적으로 로그아웃했다면 true를, 아니면 false을 반환한다.
// src/graphql/resolvers.js
...
import { AuthenticationError, ForbiddenError } from 'apollo-server';
import bcrypt from 'bcrypt';
import sha256 from 'crypto-js/sha256';
import rand from 'csprng';
const resolver = {
Query: {
...
users: (_, __, { user }) => {
if (!user) throw new AuthenticationError('Not Authenticated');
if (!user.roles.includes('admin'))
throw new ForbiddenError('Not Authorized');
return users;
},
me: (_, __, { user }) => {
if (!user) throw new AuthenticationError('Not Authenticated');
return user;
}
},
Mutation: {
...
signup: (_, { name, ID, password }) => {
if (users.find(user => user.ID === ID)) return false;
bcrypt.hash(password, 10, function(err, passwordHash) {
const newUser = {
id: users.length + 1,
name,
ID,
passwordHash,
role: ['user'],
token: ''
};
users.push(newUser);
});
return true;
},
login: (_, { ID, password }) => {
let user = users.find(user => user.ID === ID);
if (!user) return null; // 해당 ID가 없을 때
if (user.token) return null; // 해당 ID로 이미 로그인되어 있을 때
if (!bcrypt.compareSync(password, user.passwordHash)) return null; // 비밀번호가 일치하지 않을 때
user.token = sha256(rand(160, 36) + ID + password).toString();
return user;
},
logout: (_, __, { user }) => {
if (user?.token) { // 로그인 상태라면(토큰이 존재하면)
user.token = '';
return true;
}
throw new AuthenticationError('Not Authenticated'); // 로그인되어 있지 않거나 로그인 토큰이 없을 때
}
}
};
export default resolvers;
`
mesignup기존에 사용자 ID가 이미 존재하면 false를 반환한다. 그 후 사용자로부터 받은 password를 bcrypt를 이용해 암호화해서 데이터베이스에 저장하고 true를 반환한다. 데이터베이스에 사용자 아이디는 평문으로 저장되지만 비밀번호는 암호화되어 저장된다.
login요청 데이터에 있는 ID로 사용자를 검색한 후, 해당 ID가 없거나, 토큰이 이미 존재하거나, 비밀번호가 일치하지 않으면 null을 반환한다. 그 외 경우 랜덤 토큰을 생성하고 해당 사용자 데이터를 반환한다. 사용자 정보에 토큰이 존재한다는 것은 로그인 상태라는 것을 의미한다.
logout만약 logout API 요청 시 로그인 상태라면, 로그아웃 시 해당 사용자 토큰은 ''로 초기화된다.
// src/graphql/context.js
import users from '../database/users';
const context = ({ req }) => {
const token = req.headers.authorization || '';
// 로그인되어 있지 않거나 로그인 토큰이 없을 때
if (token.length != 64) return { user: null };
const user = users.find(user => user.token === token);
return { user };
};
export default context;
Context는 모든 GraphQL API 요청이 불릴 때마다 항상 실행되는 함수이다. 보통 Context에 사용자 인증 정보를 저장해서 특정 API 실행 권한이 있는지 확인하는 용도로 사용한다.
req.headers에 authorization 항목이 없다면 { user: undefined }을 반환하고, 이는 로그인 전을 의미한다.
HTTP HEADERS의 authorization 항목으로 토큰이 전달됐다면 이는 로그인 후를 의미하고, 이 토큰을 통해 로그인된 사용자를 특정할 수 있고 해당 사용자 정보를 반환한다.
// src/index.js
...
import context from './graphql/context';
const server = new ApolloServer({
typeDefs,
resolvers,
context
});
...
ApolloServer에 context를 추가한다.

localhost:4000으로 접속해서 GraphQL Playground로 테스트한다.
true가 반환되면 성공적으로 회원가입을 완료한 것이다.

사용자 정보가 반환되면 성공적으로 로그인된 것이다. 그리고 토큰값은 매 로그인 시마다 달라진다.

로그인 성공 후 반환된 사용자 토큰을 HTTP HEADER의 Authorization로 전달한다. true가 반환되면 성공적으로 로그아웃을 완료한 것이다.
