[Node.js] express-graphql, graphql-passport로 로그인 기능 구현

Yeon Jeffrey Seo·2022년 7월 28일

GraphQL

목록 보기
3/4

graphql-passport 사용 준비

passport.config.js

다른 passport 인증/인가 전략과 마찬가지로, graphql-passport도 별도의 전략을 만들어주어야 한다. 아래는 passport.config.js 모듈. 중간에 다른 전략들에 대한 선언도 있지만 생략하였다.

import passport from 'passport';
import LocalStrategy from 'passport-local';
import { GraphQLLocalStrategy } from 'graphql-passport';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import { findUserByAccount, findUserByPk } from '../queries/users.queries.js';
import config from './general.config.js';
import { comparePlainHash } from '../utils/bcrypt.js';

...
...
...

const graphqlOption = { passReqToCallback: true };

const graphqlCallback = async (req, account, password, done) => {
  // find user by account
  const user = await findUserByAccount(account);
  if (!user) {
    console.log('사용자 없음');
    return done(null, false, { message: '존재하는 회원이 없습니다.' });
  }
  // check if password matches
  const isMatched = await comparePlainHash(password, user.password);
  if (!isMatched) {
    console.log('비밀번호 일치하지 않음');
    return done(null, true, { message: '비밀번호가 일치하지 않습니다.' });
  }
  done(null, user.dataValues);
};

export const graphqlLocalStrategy = () =>
  passport.use('graphql-local', new GraphQLLocalStrategy(graphqlOption, graphqlCallback));

export const jwtStrategy = () => passport.use('jwt', new JwtStrategy(jwtOptions, jwtCallback));

export const localStrategy = () => passport.use('local', new LocalStrategy(localOptions, localCallback));

app.js

기존에 만들어두었던 GraphQLHTTP를 그대로 사용한다. 여기서 설정을 추가로 해줘야 한다. graphql-passport 의 passport.authenticate 또는 passport.authorize 메서드는 resolver의 context에서 호출을 해야 한다. 이를 위해서는 buildContext라는 메서드를 실행시켜야 한다. 아래는 buildContext에 대한 설명.

Instead of creating the context manually we could also use graphql-passport. It provides a buildContext function useful fields from the request to the context and allows us to access Passport functionality for authentication from within the resolvers.

또한 위에서 설정한 graphqlLocalStrategy를 호출하여 graphql local 전략을 등록한다.

import express from 'express';
import cors from 'cors';
import passport from 'passport';
import helmet from 'helmet';
import config from './config/general.config.js';
import entrypoint from './routes/index.js';
import { localStrategy, jwtStrategy, graphqlLocalStrategy } from './config/passport.config.js';
import errorHandler from './utils/errorHandler.js';
import { graphqlHTTP } from 'express-graphql';
import { graphQLSchema } from './graphql/index.js';
import { buildContext } from 'graphql-passport';

const PORT = config.PORT;
const app = express();

localStrategy();
jwtStrategy();
graphqlLocalStrategy();

app.use(passport.initialize());

// app.use(helmet());
app.use(helmet({ contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false }));
app.use('*', cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use('/api', entrypoint);
app.use(
  '/graphql',
  graphqlHTTP((req, res) => ({
    schema: graphQLSchema,
    graphiql: true,
    context: buildContext({ req, res }),
  }))
);

// 에러 핸들러
app.use(errorHandler);

app.listen(PORT, () => console.log(`Express WAS is listening to port ${PORT}!! 👂`));

Mutation schema, resolver 작성

구글링을 해 본 결과, graphql 기반 로그인 기능은 대부분 mutation으로 구현하더라. 짐작컨데, graphql 역시 http 프로토콜 기반으로 작동하고, query는 get에 해당하기 때문에 보안 이슈 때문에 그런게 아닐까 싶다.
나는 buildSchema가 아닌 GraphQLSchema를 사용해서 스키마를 작성했고, 뼈저리게 후회를 했다. 소스가 너무 없고, 이해도가 부족해서인지 작성이 너무 까다롭다고 느껴졌다.

graphQLSchema.js

import { GraphQLSchema } from 'graphql';
import MutationType from './mutation/mutation.index.js';
import QueryType from './query/query.index.js';

export const graphQLSchema = new GraphQLSchema({
  query: QueryType,
  mutation: MutationType,
});

mutation.index.js

현재 login 필드는 모듈화가 되어있지 않은 상태. 구현이 급급했기에 index에서 바로 구현을 했다.

import { GraphQLNonNull, GraphQLObjectType, GraphQLString, GraphQLUnionType } from 'graphql';
import { publishToken } from '../../services/authenticate.service.js';
import { SignupUserTypeConfig } from './users/users.mutation.types.js';

const MutationType = new GraphQLObjectType({
  name: 'Mutation',
  fields: {
    signupUser: SignupUserTypeConfig,
    login: {
      name: 'login',
      description: 'login with graphql mutation',
      type: GraphQLString,
      args: {
        account: { type: new GraphQLNonNull(GraphQLString) },
        password: { type: new GraphQLNonNull(GraphQLString) },
      },
      resolve: async (root, args, context) => {
        const { account, password } = args;

        const { user, info } = await context.authenticate('graphql-local', {
          account,
          password,
        });

        if (user === false || user === true) return info.message;
        const tokens = await publishToken(user);

        return JSON.stringify(tokens);
      },
    },
  },
});
export default MutationType;

resolver를 살펴보면, passport의 authenticate 메서드를 context에서 호출하는 것을 볼 수 있다. 위에서 context binding (binding이라는 용어가 맞는지 모르겠음) 작업을 해주었기 때문에 사용 가능하다.

현재 GraphQLSchema 사용 시, output type을 string | object 로 만들어주는 타입의 구현 방법을 알지 못해, 토큰 객체를 직렬화해서 output으로 반환하도록 설정했다.

주의할 점은 graphql-passport의 경우, username argument로 email 또는 usernam만 사용 가능하다. local strategy와는 다르게 { usernameField, passwordField } 설정 옵션이 없다. 다른 이름으로 인자를 받고 싶다면, node_modules를 직접 만져야 한다.

graphql-local 전략 사용 시 원하는 key 이름을 변경 할 때

default로 mutation parameter는 email 혹은 username 만 받아올 수 있게 되어 있다. graphql-passport 패키지를 직접 수정해 주면 내가 원하는 parameter를 받아 올 수 있다.

// node_modules/graphql-passport/lib/GraphQLLocalStrategy.js

  authenticate(req, options) {
    const {
      account,
      username,
      email,
      password
    } = options;

    const done = (err, user, info) => {
      if (err) {
        return this.error(err);
      }

      if (!user) {
        return this.fail(info, 401);
      }

      return this.success(user, info);
    };

    if (this.passReqToCallback) {
      // @ts-ignore - not sure how tow handle this nicely in TS
      this.verify(req, username || email || account, password, done);
    } else {
      // @ts-ignore - not sure how tow handle this nicely in TS
      this.verify(username || email || account, password, done);
    }
  }

코드 상단 options 구조분해할당에서, username, email, password 만 옵션으로 받아오는 걸 확인 할 수 있다. 내가 원하는 parameter를 넣어주고, this.verify 에서 or 연산에 내가 넣은 parameter를 추가해준다. 나는 account 라는 변수를 추가함.

node_modules를 직접 만져보는 작업은 처음이라, 불안한 마음에 타입스크립트 파일도 바꾸어주었다.

// node_modules/graphql-passport/lib/GraphQLLocalStrategy.d.js

declare class GraphQLLocalStrategy<U extends {}, Request extends ExpressRequest = ExpressRequest> extends PassportStrategy {
    constructor(verify: VerifyFn);
    constructor(options: GraphQLLocalStrategyOptionsWithoutRequest, verify: VerifyFn);
    constructor(options: GraphQLLocalStrategyOptionsWithRequest, verify: VerifyFnWRequest);
    verify: VerifyFn | VerifyFnWRequest;
    passReqToCallback: boolean | undefined;
    name: string;
    authenticate(req: Request, options: {
        account?: string;
        username?: string;
        email?: string;
        password: string;
    }): void;
}

authenticate 메서드의 options 타입에 account?: string 도 하나 끼워 넣었다.

참고 자료

https://jkettmann.com/authentication-and-authorization-with-graphql-and-passport
https://howardlee.cloud/blog/tutorial-of-basic-graphql-setup/
https://www.npmjs.com/package/graphql-passport

profile
The best time to plant a tree was twenty years ago. The second best time is now.

0개의 댓글