# 1. ApollographQL Auth 방법
ApolloServer 에서 context를 이용하여 - 약간의 middleware 형태로 구성 - 개별 resolver에서 이 context를 활용하여, 각 함수 실행 전에 auth나 role 설정 등을 적용할 수 있도록 구성한다.
#2. server 에서 Context 설정
먼저, server 구성의 예이다.
// server side - index.js
const functions = require("firebase-functions");
const express = require("express");
const { ApolloServer, AuthenticationError } = require("apollo-server-express");
const { admin, db } = require("./utils/admin");
// handlers
const {
// customers
users,
enrolls,
// ... 중략
//
} = require("./handlers");
const typeDefs = [
// customers
users.typeDefs,
enrolls.typeDefs,
// ... 중략
];
const resolvers = [
// customers
users.resolvers,
enrolls.resolvers,
// ... 중략
];
// before applyMiddleware, server start in advance
const app = express();
async function startApolloServer(app, typeDefs, resolvers) {
const server = new ApolloServer({
typeDefs,
resolvers,
// [context 설정]
context: async ({ req }) => {
const idToken = req.headers["authorization"] || "";
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
const data = await db.doc(`/users/${decodedToken.uid}`).get();
return { user: data.data() };
} catch (error) {
console.log("로그인이 필요합니다.");
}
},
});
await server.start();
server.applyMiddleware({ app, path: "/", cors: true });
}
startApolloServer(app, typeDefs, resolvers);
exports.api = functions.region("asia-northeast3").https.onRequest(app);
context의 설정 중에, ({req}) 와 같이 값을 받아오는데, 서버의 유형에 따라서 달라진다. express서버의 경우에는 req,res 형태를 사용하며, req를 통해 headers를 세팅하여 받아온다. 자세한 내용은 여기를 참고. https://www.apollographql.com/docs/apollo-server/security/authentication/
Authentication and authorization
Control access to your GraphQL API
www.apollographql.com
# 3. client 에서 header 세팅
이때, client 단에서는 redux를 사용할때는 axios를 사용하였었는데, apollo 에서는 제공하는 createHttpLink를 통해서 header를 세팅해줄 수 있다. 이때, credentials 에 같은 도메인일때 'same-origin' 아닐 경우 'include'로 표기해준다.
// Client side - app.js
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
const httpLink = createHttpLink({
uri: '/',
credentials: 'include'
});
const authLink = setContext((_, { headers }) => {
// get the authentication token from local storage if it exists
const token = localStorage.getItem('token');
// return the headers to the context so httpLink can read them
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
}
}
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache()
});
상세한 내용은 여기를 참고. https://www.apollographql.com/docs/react/networking/authentication/
Authentication
A guide to using the Apollo GraphQL Client with React
www.apollographql.com
# 4. 개별 handler(Server) 에서 resolver 설정
각 resolover의 arguments는 (parent, args, context, info) 의 4가지를 가진다. 이 중 위에 설정한 context를 통해 각 resolver의 사전 context를 middleware형태로 관리해주면 된다. 이 예시에는 context는 {user} 정보를 반환한다.
context를 적용하지 않은 enrolls 같은 경우에는 context와 관계없이 실행되며, enroll 같은 경우에는 user 정보가 있는 경우에만 enroll 정보를 되돌려주는 로직을 가지고 있고, 만일 user정보가 없다면(즉, 적합한 token이 없다면) '로그인이 필요한 서비스입니다' 등의 에러 메시지를 받을 수 있게된다. 매 resolver마다 자유롭게 회원Auth나 Role에 따른 권한 관리 등을 실행해줄 수 있다.
const resolvers = {
Query: {
enrolls: async (_, args) => { // context 미적용
try {
const cols = await db
.collection("enrolls")
.orderBy("displayStudentName")
.get();
return cols.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}));
} catch (error) {
throw new ApolloError(error);
}
},
enroll: async (_, { id }, { user }) => { //context = ({user}) 적용
if (!user) {
throw new AuthenticationError("로그인이 필요합니다.");
} else {
return dba.getDocWithOwnId("enrolls", id);
}
},
}
# 5. apllo studio에서 Headers 세팅하기
서버 테스트를 위해서 apollo studio (playgound)에서 headers에 Token을 저장해두고 테스트를 해볼 수 있는데, 사용하는 localhost 에서 studio를 돌릴 때, 주소가 https://studio.apollographgl.com 로 돌려지기 때문에 몇가지 고려해서 설정해야 하는데,
cors 설정과 변수 설정 방법이다. cors 설정은 간단하게 적용할 수 있는데, 위에 applymiddleware참고하면 된다.
변수설정은 아래와 같이 해주면된다. UI가 자꾸 변경되어서 헷갈릴 수 있는데, 환경변수(Environment Variables)에다 설정해두고, 디폴트헤더(default headers)에다 {{token}} 과 같이 치환하여 사용해주면 가장 편하게 사용할 수 있다. 또는 우하단에 Headers에 설정해서 매 페이지마다 설정해주는 방법도 가능한데, 우측 칸에 value를 입력할 때, "" 은 사용하지 않는다.(참고).

# 6. Users 핸들러 설정 (firebase 예시)
회원가입(createUser)과, 로그인(login)기능을 firebase admin Auth와 firestore를 통해 불러오는 기능을 정의한다.
우선, admin 정보부터 설정한다.
// /utils/admin.js
// admin 설정
const admin = require('firebase-admin');
const serviceAccount = require('../utils/piano-server-firebase-adminsdk-wafyy-xxxxxxx.json');
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
// firestore db 설정
const db = admin.firestore();
// dayjs (한국 timezone)
const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
const timezone = require('dayjs/plugin/timezone');
dayjs.extend(utc);
dayjs.extend(timezone);
module.exports = {
admin,
db,
dayjs,
};
users.js
기본 admin, db 뿐아니라, client module도 서버에 설치해준다. firebase web 서비스는 version 8과 9가 서비스 중이다. 함수가 다르니 주의.이때, 필요한 config 정보는 apiKey, authDomain, projectId, appId... 등이 포함된 정보이다. firebase 기본 설정에서 제공된다.
const { gql, ApolloError, ValidationError } = require('apollo-server-express');
const { db, dayjs, admin } = require('../utils/admin');
const dba = require('../utils/dba');
// client module auth (firebase version 8)
const firebase = require('firebase');
const config = require('../utils/config');
firebase.initializeApp(config);
입력되는 정보의 validator역할을 해주는 함수를 만들고 불러오도록 했다. edit하거나, insert할때, value가 없을 경우 제외해주는 단순한 로직이다.
const { reduceUserDetails } = require('../utils/validators');
// /utils/validators.js
const isEmpty = (string) => {
if (!string) {
return true;
} else if (string.toString().trim() === '') {
return true;
} else {
return false;
}
};
// 빈 값 제외, 불린은 별도 정의
const pickNotEmptyAndBoolean = (object, isBooleans = [], excluded = []) => {
let result = {};
for (let key in object) {
if (isBooleans.includes(key)) {
// boolean일 경우
!isEmpty(object[key]) ? (result[key] = object[key]) : object[key] === false ? (result[key] = false) : null;
} else if (!excluded.includes(key) || key !=='id') {
// 제외되는 컬럼, id 커럼은 당연 불가
if (!isEmpty(object[key])) result[key] = object[key];
}
}
return result;
};
// user
exports.reduceUserDetails = (data) => {
const isBooleans = ['isPiConsent', 'isMktConsent', 'emailVerified'];
const excluded = ['email', 'phone', 'createdAt', 'joinedAt'];
return pickNotEmptyAndBoolean(data, isBooleans, excluded);
};
typeDefs를 설정해주는 부분이다.
const typeDefs = gql`
type User {
id: ID
brandId: ID
roleId: ID
type: String
email: String
tempPW: String
phone: String
nickName: String
name: String
joinSurveyType: String
joinPath: String
appType: String
accountId: String
joinedAt: String
changedAt: String
status: String
hakwonId: String
hName: String
address1: String
address2: String
address3: String
profileImage: String
birthDate: String
gender: String
isPiConsent: Boolean
isMktConsent: Boolean
emailVerified: Boolean
}
type Query {
user(id: ID!): User
userWithNameAndBrand(name: String!, brandId: ID!): [User]
usersLikeName(name: String!): [User]
}
type Mutation {
createUser(
brandId: ID
roleId: ID
type: String
email: String
tempPW: String
phone: String
nickName: String
name: String
joinSurveyType: String
joinPath: String
appType: String
accountId: String
joinedAt: String
changedAt: String
status: String
hakwonId: String
hName: String
address1: String
address2: String
address3: String
profileImage: String
birthDate: String
gender: String
isPiConsent: Boolean
isMktConsent: Boolean
emailVerified: Boolean
): userResponse!
editUser(
id: ID!
brandId: ID
roleId: ID
type: String
email: String
tempPW: String
phone: String
nickName: String
name: String
joinSurveyType: String
joinPath: String
appType: String
accountId: String
joinedAt: String
changedAt: String
status: String
hakwonId: String
hName: String
address1: String
address2: String
address3: String
profileImage: String
birthDate: String
gender: String
isPiConsent: Boolean
isMktConsent: Boolean
emailVerified: Boolean
): userResponse!
deleteUser(id: ID!): simpleResponse! # do not use, admin use only
# Email & Password (client firebase api)
login(email: String!, tempPW: String!): userResponse!
#loginWithToken(token: String!): userResponse!
loginWithToken(token:String): userResponse!
}
# reponse
type userResponse {
success: Boolean!
message: String
token: String
refreshToken: String
user: User
}
type simpleResponse {
success: Boolean!
message: String
}
`;
mutation의 경과는 userResponse (success, message, token, refreshToken, user 등) type과 simpleResponse(success, message) type 두가지로 나누었다. 기본 User 정보와 login, createUser 부분에 대한 설정은 resolver에서 진행하게 된다.
먼저 Query 부분,
const resolvers = {
Query: {
user: async (_, { id }) => {
return dba.getDocWithOwnId('users', id);
},
userWithNameAndBrand: async (_, { name, brandId }) => {
try {
const res = await db
.collection('users')
.where('brandId', '==', brandId)
.where('name', '==', name)
.get();
if (res.empty) {
throw new ApolloError('data not found');
} else {
return res.docs.map((doc) => doc.data());
}
} catch (error) {
throw new ApolloError(error);
}
},
usersLikeName: async (_, { name }) => {
return dba.getSomeLikeThis('users', 'name', name);
},
},
mutation: {
// 후술..
}
};
간단한 Query의 경우에는 dba 라는 별도의 치환함수를 만들어서, firebase 접속이 간단히 구성될 수 있도록 helper function을 만들어서 사용했다.
// /utils/dba.js (help functions)
const { ApolloError, ValidationError } = require('apollo-server-express');
const { db, dayjs } = require('./admin');
const getDocs = async (which) => {
try {
const cols = await db.collection(which).get();
return cols.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}));
} catch (err) {
throw new ApolloError(error);
}
};
const getDocWithOwnId = async (which, id) => {
try {
const doc = await db.doc(`${which}/${id}`).get();
const result =
{
...doc.data(),
id: doc.id,
} || new ValidationError(`${which} ID not found!`);
return result;
} catch (error) {
throw new ApolloError(error);
}
};
const getDocWithOtherId = async (which, otherIdNameText, variable, docNameText = null, allowNull = false) => {
try {
const colls = await db.collection(which).where(otherIdNameText, '==', variable).get();
if (colls.empty) {
return docNameText
? {
success: false,
message: `${which} ID not found!`,
}
: allowNull
? null
: new ValidationError('Not Found!');
} else {
return docNameText
? {
success: true,
message: `get ${which} data successfully!`,
[docNameText]: colls.docs.map((doc) => doc.data())[0],
}
: colls.docs.map((doc) => doc.data())[0];
}
} catch (error) {
throw new ApolloError(error);
}
};
const getIdByOtherId = async (which, otherIdNameText, variable) => {
try {
const colls = await db.collection(which).where(otherIdNameText, '==', variable).get();
if (colls.empty) {
throw new ValidationError(`${which} ID not found!`);
} else {
return colls.docs.map((doc) => doc.id)[0];
}
} catch (error) {
throw new ApolloError(error);
}
};
// 중략
const getSomeLikeThis = async (which, columnText, variable) => {
try {
const data = await db
.collection(which)
.where(columnText, '>=', variable)
.where(columnText, '<=', variable + '\uf8ff')
.get();
if (data.empty) {
throw new ValidationError('no matched data');
} else {
return data.docs.map((doc) => doc.data());
}
} catch (error) {
throw new ApolloError(error);
}
};
module.exports = {
getDocs,
getDocsWithCondition,
getDocWithOwnId,
getDocWithOtherId,
// 중략
isExistsOwnId,
insertDocWithNewCreatedId,
getSomeLikeThis,
};
mutation 부분에서 login, createUser부분을 적용해준다.
const resolvers = {
Query: {
// 중략
},
Mutation: {
// signup (by joinPath)
createUser: async (_, args) => {
try {
const hasEmail = await db
.collection('users')
.where('email', '==', args.email)
.where('brandId', '==', args.brandId)
.get();
if (!hasEmail.empty) {
const bId = hasEmail.docs.map(doc => doc.data().brandId)[0];
return {
success: false,
message: `already signed up with the same email in ${bId}`,
};
} else {
const userRecord = {
email: args.email,
phoneNumber: args.phone,
displayName: args.name,
disabled: args.status,
emailVerified: false,
photoURL: args.profileImage,
};
// firebase auth의 client 함수인 createUserWithEmailAndPassword
const userInfo = await firebase.auth().createUserWithEmailAndPassword(args.email, args.tempPW);
const token = await userInfo.user.getIdToken(true);
const refreshToken = userInfo.user.refreshToken;
const userCredential = {
...args,
id: userInfo.user.uid,
joinedAt: dayjs().tz('Asia/Seoul').format(),
};
// firestore users - add new user
await db.collection('users').doc(userCredential.id).set(userCredential);
return {
success: true,
message: 'create user successfully',
token: token,
refreshToken: refreshToken,
user: userCredential,
};
}
} catch (error) {
return {
success: false,
message: error.code,
};
}
},
editUser: async (_, args) => {
try {
const userDetails = reduceUserDetails(args);
console.log(userDetails);
const ref = db.doc(`/users/${args.id}`);
await ref.update({ ...userDetails, changedAt: dayjs().tz('Asia/Seoul').format() });
const userInfo = await ref.get();
return {
success: true,
message: 'edited successfully',
user: userInfo.data(),
};
} catch (error) {
throw new ApolloError(error);
}
},
deleteUser: async (_, args) => {
return null;
},
// Email & Password (client firebase api)
login: async (_, args) => {
try {
const res = await firebase.auth().signInWithEmailAndPassword(args.email, args.tempPW);
const token = await res.user.getIdToken(true);
const userInfo = await db.doc(`/users/${res.user.uid}`).get();
return {
success: true,
message: 'logged successfully',
token: token,
refreshToken: res.user.refreshToken,
user: userInfo.data(),
};
} catch (error) {
if (error.code === 'auth/user-not-found') {
return {
success: false,
message: '해당 이메일이 없습니다',
};
} else if (error.code === 'auth/wrong-password') {
return {
success: false,
message: '잘못된 패스워드입니다',
};
} else {
return {
success: false,
message: error.code,
};
}
}
},
// Custom login (admin auth api)
loginWithToken: async (_, { token }) => {
try {
const res = await admin.auth().verifyIdToken(token); // not custome token
//const res = await firebase.auth().signInWithCustomToken(token);
const uid = res.uid;
const userRef = await db.doc(`/users/${uid}`).get();
const userInfo = userRef.data();
return {
success: true,
message: 'logged with token successfully',
token: token,
user: userInfo,
};
} catch (error) {
console.log(error);
return {
success: false,
message: error.code,
};
}
},
},
};
module.exports = { typeDefs, resolvers };
createUser의 경우에는 firebase authentification 의 client 함수인, createUserWithEmailAndPassword를 사용하여, getIdToken()를 통해 토큰을 생성하고, 새로운 user.uid를 생성하게 된다. 참고. https://firebase.google.com/docs/auth/web/password-auth
자바스크립트를 사용하여 비밀번호 기반 계정으로 Firebase에 인증하기 | Firebase Documentation
의견 보내기 자바스크립트를 사용하여 비밀번호 기반 계정으로 Firebase에 인증하기 Firebase 인증을 사용하여 사용자가 이메일 주소와 비밀번호로 Firebase에 인증하도록 하고, 앱의 비밀번호 기반
firebase.google.com