이번에는 회원가입/로그인/로그아웃 기능을 구현해볼 것이다.
이 글에서 설명하는 내용은 개인적으로 공부하면서 생각한 것으로 이 방식을 다른 프로젝트에 그대로 도입하면 보안상 문제가 생길 수도 있으니 주의하세요
> 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 - me
Mutation - 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;
`
me
signup
기존에 사용자 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
가 반환되면 성공적으로 로그아웃을 완료한 것이다.