apollo federation에서는 RemoteGraphQLDataSource 클래스를 이용하여 인증과 인가를 수행할 수 있습니다. RemoteGraphQLDataSource에 정의되어있는 메서드를 재정의하여 gateway와 subgraph 간 통신을 수행하기 전에 로직을 수행할 수 있습니다. RemoteGraphQLDataSource를 살펴보도록 하겠습니다.
RemoteGraphQLDataSource메서드에는 주로 2가지의 메서드를 사용합니다.
1) willSendRequest
해당 메서드는 gateway에서 subgraph로 요청을 보내기 전에 특정 로직을 수행하고 싶을 경우 사용되는 메서드입니다.
2) didReceiveResponse
해당 메서드는 클라이언트에 응답을 보내기 전에 gateway에서 특정 로직을 수행하고 싶을 경우 사용되는 메서드입니다.
1번 메서드를 재정의하여 인증을 수행해보도록 할 예정이고, 흐름도를 한번 살펴 보겠습니다.
1) client에서는 로그인 시 인가받은 토큰 값을 헤더에 실어 gateway로 전송합니다.
2) gateway의 context에서 요청받은 헤더에서 토큰을 추출하고 저장합니다. 그리고 willSendRequest에서 context에 저장되어 있는 토큰 값을 다시 헤더에 실어 subgraph로 전송합니다.
3) subgraph의 context에서 요청받은 헤더에서 토큰을 추출하고 저장합니다. 그리고 service 레이어에서 토큰 값을 사용하기 위해 resolver에서 context 인자를 전달받습니다.
4) service에서 토큰 값에 대한 검증을 시도한 후 로직을 수행합니다.
5) 로직을 수행한 후 반환 값을 gateway로 전송합니다.
6) gateway에서는 반환 값을 client로 전송합니다.
인증을 위한 흐름을 살펴보았으니 위의 흐름대로 코드를 작성해보도록 하겠습니다.
1) client에서는 로그인 시 인가받은 토큰 값을 헤더에 실어 gateway로 전송합니다.
이 부분은 postman을 사용하여 임시로 대체하도록 하겠습니다.
2) gateway의 context에서 요청받은 헤더에서 토큰을 추출하고 저장합니다. 그리고 willSendRequest에서 context에 저장되어 있는 토큰 값을 다시 헤더에 실어 subgraph로 전송합니다.
require('dotenv').config();
import { ApolloServer } from 'apollo-server-express';
import { ApolloGateway } from '@apollo/gateway';
import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core';
import express from 'express';
import cors from 'cors';
import http from 'http';
import { Authentication } from './auth/authentication';
const {
PORT,
AUTH_SERVICE_ENDPOINT
} = process.env;
class Server {
constructor() {}
public async start() {
const app = express();
const httpServer = http.createServer(app);
const server = new ApolloServer({
gateway: new ApolloGateway({
serviceList: [
{
name: "auth-service",
url: AUTH_SERVICE_ENDPOINT
}
],
buildService: ({
name,
url
}) => {
return new Authentication({ url });
}
}),
context: ({ req }) => {
const token = req.headers.authorization;
if(token) {
return { token };
}
},
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })]
});
app.use(cors());
await server.start();
server.applyMiddleware({
app,
path: "/",
cors: false
});
httpServer.listen({ port: PORT });
console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`);
}
}
const server = async () => await new Server().start();
server();
apollo server의 모든 요청은 context에서 우선적으로 수행되므로 이 곳에서 헤더의 토큰 값을 추출합니다. 그리고 #1에서 설명한 willSendRequest를 재정의하여 subgraph로 요청을 보내기 전 인증을 위해 다음의 로직을 수행하도록 하겠습니다.
import { RemoteGraphQLDataSource } from "@apollo/gateway";
const {
AUTH_SERVICE_ENDPOINT
} = process.env;
class Authentication extends RemoteGraphQLDataSource {
willSendRequest({
request,
context
}) {
if(request.http.url.includes(AUTH_SERVICE_ENDPOINT)) {
if(
["modify",
"getUser",
"deleteUser",
"getOtherUser"].includes(request.query
.split('{')[1]
.split('(')[0])
) {
request.http.headers.set('token', context.token);
}
}
}
}
export { Authentication };
우선 auth-service에서 인증이 필요한 메서드는 modify, getUser, deleteUser 총 3가지입니다. 따라서 이 3가지에 대한 쿼리를 파싱할 필요가 있는데 예시로 getUser 요청을 수행한 후 request.query의 값을 확인하면 다음과 같은 결과를 얻을 수 있습니다.
이를 가지고 파싱을 진행해보면 request.query.split('{')으로 나누고, 2번째의 값은 getUser(user_id:$userId)가 됩니다. 그리고 이를 split('(')[0]으로 파싱하여 getUser의 값만 추출하도록 하는 과정입니다.
이런 식으로 auth-service에서 인증이 필요한 메서드 3개를 추출하여 헤더에 토큰을 실어 subgraph로 보낼 수 있게 로직을 재정의하였습니다.
3) subgraph의 context에서 요청받은 헤더에서 토큰을 추출하고 저장합니다. 그리고 service 레이어에서 토큰 값을 사용하기 위해 resolver에서 context 인자를 전달받습니다.
subgraph인 auth-service의 index.js를 다음과 같이 수정하겠습니다.
...
async function startServer(
typeDefs,
resolvers
) {
...
const server = new ApolloServer({
schema: buildSubgraphSchema([{
typeDefs: typeDefs,
resolvers: resolvers
}]),
context: ({ req }) => {
const token = req.headers.token ? req.headers.token : null;
return { token };
},
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
schemaTransforms: [constraintDirective()]
});
...
}
...
context에서 토큰 값을 추출한 후 context에 저장하도록 하는 과정입니다. 그리고 resolver 레이어, service 레이어의 코드를 작성하겠습니다.
import AuthService from '../service/auth.service';
const resolvers = {
Query: {
getUser: async (
_,
args,
context,
info
): Promise<IUser | (IUser & ILicense)> => {
return await service.getUser(context);
},
...
},
Mutation: {
...
modifyUser: async (
_,
args,
context,
info
): Promise<number> => {
return await service.modifyUser(args, context);
},
deleteUser: async (
_,
args,
context,
info
): Promise<number> => {
return await service.deleteUser(args, context);
}
},
};
export { resolvers };
context가 필요한 메서드에 context 인자를 넣어 주도록 합니다.
4) service에서 토큰 값에 대한 검증을 시도한 후 로직을 수행합니다.
service에서 토큰 검증을 위해 검증 메서드를 작성하도록 하겠습니다.
require('dotenv').config();
import jwt from 'jsonwebtoken';
const { SECRET_KEY } = process.env;
class JwtUtils {
constructor() {}
...
public async verify(token: string): Promise<any> {
return jwt.verify(token.split('Bearer ')[1],
SECRET_KEY);
}
}
export { JwtUtils };
import { PasswordUtils } from '../utils/password.util';
import { JwtUtils } from "../utils/jwt.utils";
import { IUser } from '../interface/user.interface';
import { ILicense } from '../interface/license.interface';
import {
DATABASE_ERROR,
EXCEPTION_ERROR,
EXIST_EMAIL,
EXIST_NICKNAME,
FAILURE,
NOT_EXIST_EMAIL,
NOT_EXIST_NICKNAME,
SUCCESS,
UN_AUTHENTICATION,
VALID_ERROR
} from '../constants/result.code';
import {
PASSENGER,
DRIVER
} from '../constants/license.role';
import { v4 } from 'uuid';
import { ApolloError } from "apollo-server-core";
import { AuthRepository } from '../repository/auth.repository';
import { logger } from '../middlewares/logging';
class AuthService {
...
public async getUser(context: any): Promise<IUser | (IUser & ILicense)> {
try {
const decoded: any = await this.jwtUtils.verify(context.token);
if(!decoded) {
logger.error("getUser: ", Object.assign({
message: "다시 로그인해주세요!",
code: "UN_AUTHENTICATION"
}));
throw new ApolloError(
"다시 로그인해주세요!",
"UN_AUTHENTICATION", {
'code_number': UN_AUTHENTICATION
}
);
}
const entity: IUser | (IUser & ILicense) = await this.repository.findUserByUserId(decoded.data);
if(!entity) {
logger.error("getUser: ", Object.assign({
message: "유저를 찾을 수 없습니다!",
code: "DATABASE_ERROR"
}));
throw new ApolloError(
"유저를 찾을 수 없습니다!",
"DATABASE_ERROR", {
'code_number': DATABASE_ERROR
}
);
}
logger.info("getUser: ", Object.assign({
message: "유저를 찾았습니다!",
code: "SUCCESS"
}));
return entity;
} catch(err) {
logger.error("getUser: ", Object.assign({
message: err,
code: "EXCEPTION_ERROR"
}));
throw new ApolloError(
err,
"EXCEPTION_ERROR", {
'code_number': EXCEPTION_ERROR
}
);
}
}
public async getOtherUser(
context: any,
args: any
): Promise<IUser | (IUser & ILicense)> {
try {
const decoded: any = await this.jwtUtils.verify(context.token);
if(!decoded) {
logger.error("getOtherUser: ", Object.assign({
message: "유저를 찾을 수 없습니다!",
code: "DATABASE_ERROR"
}));
throw new ApolloError(
"다시 로그인해주세요!",
"UN_AUTHENTICATION", {
'code_number': UN_AUTHENTICATION
}
);
}
const entity: IUser | (IUser & ILicense) = await this.repository.findUserByUserId(decoded.data);
if(!entity) {
logger.error("getOtherUser: ", Object.assign({
message: "유저를 찾을 수 없습니다!",
code: "DATABASE_ERROR"
}));
throw new ApolloError(
"유저를 찾을 수 없습니다!",
"DATABASE_ERROR", {
'code_number': DATABASE_ERROR
}
);
}
logger.info("getOtherUser: ", Object.assign({
message: "유저를 찾았습니다!",
code: "SUCCESS"
}));
return entity;
} catch(err) {
logger.error("getUser: ", Object.assign({
message: err,
code: "EXCEPTION_ERROR"
}));
throw new ApolloError(
err,
"EXCEPTION_ERROR", {
'code_number': EXCEPTION_ERROR
}
);
}
}
public async modifyUser (
args: any,
context: any
): Promise<number> {
try {
const decoded = await this.jwtUtils.verify(context.token);
if(!decoded) {
logger.error("modifyUser: ", Object.assign({
message: "다시 로그인해주세요",
code: "UN_AUTHENTICATION"
}));
throw new ApolloError(
"다시 로그인해주세요!",
"UN_AUTHENTICATION", {
'code_number': UN_AUTHENTICATION
}
);
}
const dto = args.input;
if(dto.password) {
await this.repository.updatePassword(
decoded.data,
await this.passwordUtils.generator(dto.password).toString()
).then(entity => {
logger.info("modifyUser: ", Object.assign({
message: "비밀번호를 수정하였습니다!",
code: "SUCCESS"
}));
return SUCCESS;
}).catch(error => {
logger.error("modifyUser: ", Object.assign({
message: error,
code: "EXCEPTION_ERROR"
}));
throw new ApolloError(
error,
"EXCEPTION_ERROR", {
'code_number': EXCEPTION_ERROR
}
)
});
} else if(dto.nickname) {
await this.repository.updateNickname(
decoded.data,
dto.nickname
).then(entity => {
logger.info("modifyUser: ", Object.assign({
message: "닉네임을 수정하였습니다!",
code: "SUCCESS"
}));
return SUCCESS;
}).catch(error => {
logger.error("modifyUser: ", Object.assign({
message: error,
code: "EXCEPTION_ERROR"
}));
throw new ApolloError(
error,
"EXCEPTION_ERROR", {
'code_number': EXCEPTION_ERROR
}
)
});
} else if(dto.license) {
await this.repository.updateLicense(
decoded.data,
dto.license,
DRIVER
).then(entity => {
logger.info("modifyUser: ", Object.assign({
message: "운전면허를 등록했습니다!",
code: "SUCCESS"
}));
return SUCCESS;
}).catch(error => {
logger.error("modifyUser: ", Object.assign({
message: error,
code: "EXCEPTION_ERROR"
}));
throw new ApolloError(
error,
"EXCEPTION_ERROR", {
'code_number': EXCEPTION_ERROR
}
)
});
}
return FAILURE;
} catch(err) {
logger.error("modifyUser: ", Object.assign({
message: err,
code: "EXCEPTION_ERROR"
}));
throw new ApolloError(
err,
"EXCEPTION_ERROR", {
'code_number': EXCEPTION_ERROR
}
);
}
}
...
public async deleteUser(
args: any,
context: any
): Promise<number> {
try {
const decoded: any = await this.jwtUtils.verify(context.token);
if(!decoded) {
logger.error("deleteUser: ", Object.assign({
message: "다시 로그인해주세요",
code: "UN_AUTHENTICATION"
}));
throw new ApolloError(
"다시 로그인해주세요!",
"UN_AUTHENTICATION", {
'code_number': UN_AUTHENTICATION
}
);
}
const dto: any = args.input;
await this.repository.deleteUser(decoded.data, dto.is_delete)
.then(entity => {
if(entity.isdelete) {
logger.info("deleteUser: ", Object.assign({
message: "휴면 처리되었습니다!",
code: "SUCCESS"
}));
return SUCCESS;
}
logger.error("deleteUser: ", Object.assign({
message: "데이터베이스 에러!",
code: "DATABASE_ERROR"
}));
return DATABASE_ERROR;
})
.catch(error => {
logger.error("deleteUser: ", Object.assign({
message: error,
code: "EXCEPTION_ERROR"
}));
throw new ApolloError(
error,
"EXCEPTION_ERROR", {
'code_number': EXCEPTION_ERROR
}
);
});
logger.error("deleteUser: ", Object.assign({
message: "서버 에러!",
code: "FAILURE"
}));
return FAILURE;
} catch(err) {
logger.error("deleteUser: ", Object.assign({
message: err,
code: "EXCEPTION_ERROR"
}));
throw new ApolloError(
err,
"EXCEPTION_ERROR", {
'code_number': EXCEPTION_ERROR
}
);
}
}
};
export { AuthService };
service레이어에서는 context에 저장되어있는 토큰을 검증하여 user_id인 decoded.data값을 추출합니다. 따라서 이제 input에 user_id를 body에 넣어 요청을 할 필요가 없죠. input을 수정하도록 하겠습니다.
const { gql } = require('apollo-server-express');
const typeDefs = gql`
...
input ModifyInput {
password: String @constraint(maxLength: 255)
nickname: String @constraint(maxLength: 255)
license: LicenseInput
}
input DeleteInput {
is_delete: Boolean!
}
...
`;
module.exports = typeDefs;
5) 로직을 수행한 후 반환 값을 gateway로 전송합니다.
6) gateway에서는 반환 값을 client로 전송합니다.
실제적으로 작성할 코드는 2) ~ 4)뿐이고, 코드가 전부 작성이 되었으니 테스트를 진행해보겠습니다.
다음의 순서대로 테스트를 진행하겠습니다.
1) 클라이언트에서 로그인을 수행합니다.
2) 로그인 수행 후 반환받은 토큰 값을 헤더에 실어 getUser 요청을 수행하고 정보를 반환받습니다.
테스트 결과 유저의 정보를 잘 받아오는 모습을 볼 수 있습니다. 다음 포스트에서는 front와 back을 연결할 수 있도록 react native에 apollo-client를 적용하도록 하겠습니다.