-
Passport를 이용한 로그인 작업Archive/캡스톤디자인 2022. 6. 25. 17:19
Passport.js
Node JS를 이용하여 웹서버를 구성할 때, 로그인 기능을 구현하기 위해 passport라는 라이브러리를 사용할 수 있다.
Passport
Passport is authentication middleware for Node.js. Extermely flexible and modular, Passport can be unobtrusively dropped in to any Express-based web application. A comprehensive set of strategies support authentication using a username and password, Facebook, Twitter, and more.이 라이브러리를 이용하면 유저 이름과 비밀번호를 이용해서 회원가입, 로그인을 구현할 수 있다. 이러한 로그인은 local적으로 로그인을 직접 구현하여 사용할 수도 있고, oauth라는 기능을 이용하여 google, facebook, kakao, naver와 같은 SNS 계정을 통해서 로그인이 진행되도록 설정할 수도 있다.
이번 프로젝트에서는 local방식을 이용하여 내부적인 설정으로 회원가입과 로그인이 되도록 설정하였다.
설치
npm i passport passport-local express-session
passport.js 라이브러리를 npm을 이용해 설치해준다. 이때 해당 라이브러리의 핵심이 되는 passport에, local방식으로 로그인 설정을 하기 위한 passport-local을 추가로 설치한다. 또한 사용자의 로그인 정보를 세션으로 저장하는 express-session 역시 같이 설치하여 총 3개의 라이브러리를 설치한다.
Strategy
passport를 사용하기 위해서는 Strategy라는 것을 사용한다. 이름 그대로 전략을 짠다는 뜻인데, 이는 로그인의 방식이 어떤 식으로 진행되는지를 내부적으로 설계하는 것을 말한다.
이번 프로젝트에서는 local방식으로 로그인 방식을 설계할 것이기 때문에 npm으로 설치해둔 "passport-local"을 사용한다. 이에 대한 코드는 아래와 같다.
const passport = require("passport"); const LocalStrategy = require("passport-local").Strategy; const User = require("./models/userModel"); module.exports = () => { passport.serializeUser((user, done) => { done(null, user); }); passport.deserializeUser((user, done) => { done(null, user); }); passport.use( new LocalStrategy( { usernameField: "id", passwordField: "pw", session: true, passReqToCallback: false, }, (id, password, done) => { User.findOne({ id }, (findError, user) => { if (findError) return done(findError); if (!user) return done(null, false, { message: "The ID does not exist" }); return user.comparePassword( user.salt, password, (passwordError, isMatch) => { if (isMatch) { return done(null, user, { message: "Success" }); } return done(null, false, { message: `${passwordError}` }); } ); }); } ) ); };
우선 serializeUser와 deserializeUser 부분은 세션을 이용해 페이지가 넘어가더라도 로그인 정보를 유지하는 역할을 한다고만 생각하고 넘어간다. 자세한 내용은 아래에서 설명한다.
options
위의 코드에서 주목해야 할 부분은 passport.use 부분에서 new LocalStrategy를 이용하여 새로운 전략 객체를 만드는 부분이다. 해당 생성자에서는 기본적으로 1개 또는 2개의 인수를 받는다. 옵션을 사용할 경우 2개이고, 사용하지 않을 경우 1개이다.
이는 실제 passport-local의 TypeScript파일을 찾아보면 쉽게 확인할 수 있다.
// Type definitions for passport-local 1.0.0 // Project: https://github.com/jaredhanson/passport-local // Definitions by: Maxime LUCE <https://github.com/SomaticIT> // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped // TypeScript Version: 2.3 /// <reference types="passport"/> import { Strategy as PassportStrategy } from "passport-strategy"; import express = require("express"); interface IStrategyOptions { usernameField?: string | undefined; passwordField?: string | undefined; session?: boolean | undefined; passReqToCallback?: false | undefined; } interface IStrategyOptionsWithRequest { usernameField?: string | undefined; passwordField?: string | undefined; session?: boolean | undefined; passReqToCallback: true; } interface IVerifyOptions { message: string; } interface VerifyFunctionWithRequest { ( req: express.Request, username: string, password: string, done: (error: any, user?: any, options?: IVerifyOptions) => void ): void; } interface VerifyFunction { ( username: string, password: string, done: (error: any, user?: any, options?: IVerifyOptions) => void ): void; } declare class Strategy extends PassportStrategy { constructor( options: IStrategyOptionsWithRequest, verify: VerifyFunctionWithRequest ); constructor(options: IStrategyOptions, verify: VerifyFunction); constructor(verify: VerifyFunction); name: string; }
가장 아래의 Strategy 클래스 부분을 확인해보면 생성자가 3개로 나뉘어있다. options의 사용 여부는 자유지만, verify 부분은 필수인 것을 확인할 수 있다.
options의 경우 usernameField, passwordField, session, passReqToCallback로 구성된 객체의 형식을 사용한다.
Parameter Description usernameField body의 어떤 필드로부터 아이디를 전달받을지 정한다. passwordField body의 어떤 필드로부터 비밀번호를 전달받을지 정한다. session session을 사용할지 여부를 정한다. passReqToCallback 콜백함수에서 req 매개변수를 사용할지 여부를 정한다. passReqToCallback 부분이 true라면 VerifyFunctionWithRequest가 verify로 지정되고, 그 외에는 VerifyFunction이 verify로 지정된다.
두 함수는 동일한 형태이지만, VerifyFunctionWithRequest의 경우 첫 번째 인자로 req라는 express.Request 객체를 받는다는 점이 다르다.
따라서 만약 첫 번째 options 부분에서 passReqToCallback을 true로 설정한다면, 다음 인자인 verify부분에서 req인자를 통해 express의 req객체에 접근할 수 있다.
이를 통해 작성한 코드의 options 부분을 다시 살펴보면 다음과 같다.
{ usernameField: "id", passwordField: "pw", session: true, passReqToCallback: false, },
body.id에서 아이디를 받아오고, body.pw에서 비밀번호를 가져오면서 session을 사용한다는 뜻이다.
verify
아래는 위의 코드에서 verify 부분만 따로 작성한 코드이다.
(id, password, done) => { User.findOne({ id }, (findError, user) => { if (findError) return done(findError); if (!user) return done(null, false, { message: "The ID does not exist" }); return user.comparePassword( user.salt, password, (passwordError, isMatch) => { if (isMatch) { return done(null, user, { message: "Success" }); } return done(null, false, { message: `${passwordError}` }); } ); }); };
verify함수는 username, password, done 이렇게 세 개의 parameter를 가진다. username과 password는 순서대로 options에서 usernameField와 passwordField에 해당되는 값이 넘어온다.
따라서 이를 이용해 User 모델을 이용해 해당 id와 같은 아이디를 가지는 유저 정보를 DB에서 찾는다. 이번 프로젝트는 MongoDB와 Mongoose를 사용하므로, 여기서의 User 모델은 Mongoose 객체이다.
Mongoose의 findOne 메서드를 이용해 유저 정보를 찾는데, 에러가 발생하면 findError가 발생하고, 그렇지 않다면 유저 정보를 넘겨준다. 따라서 처음에 if (findError)에서 먼저 에러가 있는지 확인하고, if (!user)를 통해 유저 정보가 있는지를 확인한다.
이 모든 경우에 걸리지 않고 유저 정보를 성공적으로 가져오는 데에 성공했다면, user.comparePassword 메서드가 실행된다.
User 모델의 comparePassword라는 메서드는 모델을 관리하는 파일에서 다음과 같이 선언하였다.
userSchema.methods.comparePassword = async function (salt, inputPW, callback) { if ((await decrypt(salt, inputPW)) === this.key) { callback(null, true); } else { callback("The password is incorrect", false); } };
comparePassword는 salt, password, callback 파라미터를 받아 작동된다.
위의 코드에서 decypt는 암호화에 관련된 내용인데, 아래에서 자세히 설명한다. 우선은 salt라는 값과 비밀번호를 함께 인수로 넣으면 그에 맞는 key를 return 하는 함수라고 생각하면 된다.
이때 DB에서 찾은 user의 key값과 입력값으로 받은 비밀번호를 이용해 새로 만든 key값이 서로 일치하면 비밀번호가 서로 일치한다는 것이다. 이때는 callback 함수에 true를 전달한다.
이때 해당 callback은 done을 불러오는 역할을 한다.
done 함수는 3개의 인자를 가지는데, 순서대로 error, user, options 파라미터이다.
Parameter Description error 에러를 결과로 보내는 경우 에러값 (에러가 없으면 null) user 로그인 성공시 return할 DB에 존재하는 해당 유저의 정보 options 임의로 에러를 표현하고자 할 때 사용 serializeUser & deserializeUser
passport.serializeUser((user, done) => { done(null, user); }); passport.deserializeUser((user, done) => { done(null, user); });
serializeUser는 로그인 성공 시 실행되는 done함수에서 2번째 파라미터인 user 객체를 전달받아 세션에 저장하는 역할을 한다. 세션에 로그인 정보를 저장해두지 않으면, 페이지 이동 시에 로그인 정보가 사라져 계속해서 로그인을 반복해야 하는 불상사가 일어난다.
desrializeUser는 serializeUser에 저장한 세션 정보를 이용해 페이지가 이동되더라도 req.user를 통해 유저 객체 정보를 넘겨주는 역할을 한다.
암호화
로그인 또는 회원가입을 처리할 때, DB에 유저의 비밀번호를 있는 그대로 저장하는 것은 굉장히 위험하다. 물론 데이터베이스는 관리자만 확인할 수 있도록 되어있지만, 한번이라도 해킹당하는 순간 모든 유저의 비밀번호 정보가 해커의 손에 들어가기 때문에 항상 암호화를 진행해야 한다.
이번 프로젝트에서 사용하는 암호화는 단방향 암호화 방식을 사용한다. 단방향 방식은 한번 암호화한 문자열의 경우 다시 원래의 문자열로 복호화 할 수 없는 방식이다. 따라서 사실상 DB를 관리하는 관리자조차도 유저들의 암호를 알 수가 없다.
복호화 할 수 없다면 암호로써 의미가 없다고 생각할 수 있지만, 잘 생각해본다면 굳이 복호화를 진행하지 않아도 비밀번호의 확인이 가능하다.
항상 같은 알고리즘으로 암호화를 진행한다면, 같은 비밀번호를 넣는다면 똑같이 암호화된 문자열이 나올 것이다. 즉, 사용자가 입력한 비밀번호를 다시 한번 암호화 한 다음에 DB에 저장된 암호화된 문자열과 비교하는 방식을 사용하면 된다.
여기에 추가로 salt라는 방식을 사용한다. 만약 해커가 해당 알고리즘으로 굉장히 많은 문자열을 미리 넣어서 이에 대응하는 암호화 문자열을 미리 짝지어 놓았다면, 해당 암호화는 큰 힘을 쓰지 못한다. 이를 레인보우테이블이라 한다.
따라서 값에 약간의 차이만 생겨도 암호화 결과값이 완전히 달라진다는 점을 이용해, 암호화를 진행하기 전에 소금을 뿌리는 방식을 사용한다. 매 순간 암호화를 진행할 때마다 랜덤한 salt값을 함께 사용하여 암호화를 진행하면, 해커는 이에 대응하여 레인보우테이블을 모두 만들어두지 못한다.
물론 암호화를 진행했을 때 사용한 salt값 또한 DB에 저장해둬야 나중에 비밀번호를 서로 비교할 때 같은 salt값으로 암호화를 진행할 수 있다.
이러한 암호화를 사용하기 위해서 crypto 모듈을 사용한다. crypto 모듈은 Node.js에 내장되어 있는 모듈이므로 별도의 설치 없이 바로 사용할 수 있다.
이를 실제로 구현한 코드는 다음과 같다.
const crypto = require("crypto"); const encrypt = (password) => { return new Promise((resolve, reject) => { crypto.randomBytes(64, (err, buf) => { if (err) reject(err); const salt = buf.toString("base64"); crypto.pbkdf2(password, salt, 131567, 64, "sha512", (err, key) => { if (err) reject(err); resolve({ salt, key: key.toString("base64") }); }); }); }); }; const decrypt = (salt, password) => { return new Promise((resolve, reject) => { crypto.pbkdf2(password, salt, 131567, 64, "sha512", (err, key) => { if (err) reject(err); resolve(key.toString("base64")); }); }); }; module.exports = { encrypt, decrypt };
encrypt
encrypt는 비밀번호를 넣으면 암호화를 하는 함수이다. crypto가 동작하는데에 시간이 걸리기 때문에 return값을 Promise값으로 설정한다.
먼저 crypto의 randomBytes라는 메서드를 이용해 64바이트 길이의 랜덤한 salt를 생성한다. 이후 이를 base64 문자열의 형태로 변경하여 salt로 지정한다.
단방향 암호화를 진행하기 위해서는 pbkdf2라는 메서드를 사용한다. 해당 메서드에서는 5개의 파라미터를 요구하는데, 각각 password, salt, 반복 횟수, 비밀번호 길이, 해시 알고리즘이다.
password는 encrypt 함수의 파라미터에서 받아온 값을 사용하고, salt값은 이전에 randomBytes로 생성한 값을 사용한다. 반복횟수는 해시 함수를 반복하는 횟수를 나타내는데, 한번 암호화를 진행하는데 걸리는 시간이 1초 이상은 걸릴정도로 지정한다. 대략 10만번 이상을 반복하면 1초 정도가 소요된다. 이 정도면 해커가 레인보우테이블을 만들기가 불가능에 가까워진다.
암호화 알고리즘은 "sha512"라는 방식을 사용한다. 정확히 어떠한 방식을 사용했는지는 아직 공부하지 않았지만, 간단하게 사용하기에 가장 적합한 방법이라고 알고있다.
이렇게 암호화를 진행한 후 resolve를 통해 사용한 salt와 암호화를 진행한 key값을 return한다.
decrypt
decrypt는 비밀번호와 salt를 같이 넣어서 다시 암호화를 진행하는 함수이다. encrypt와 기능적으로는 다를것이 없다. 랜덤한 salt값을 사용하는 것이 아니라, 미리 정해진 salt값을 사용하는 것밖에는 차이가 없다.
이렇게 DB에 저장된 특정 유저의 salt값과 유저가 직접 입력한 비밀번호를 사용하여 암호화를 진행하고, 이때 나온 결과물 key값을 DB에 저장된 key값과 비교하여 비밀번호가 매칭되는지를 확인한다.
로그인
기본설정
const passportConfig = require("./passport"); app.use( session({ secret: process.env.SESSION_KEY, resave: true, saveUninitialized: false, }) ); app.use(passport.initialize()); app.use(passport.session()); passportConfig();
session을 설정하기 위해 app.use를 사용하여 session을 설정한다.
이후 passport.initialize()와 passport.session()을 이용해 passport 기본 설정을 미리 진행한다.
마지막으로 처음에 설정한 passport Strategy를 사용하기 위해 passportConfig()를 적어준다.
로그인
const passport = require("passport"); app.post("/login", (req, res, next) => { passport.authenticate("local", (err, user, info) => { if (err) { console.error(err); next(err); } if (user) { req.logIn(user, (err) => { if (err) { next(err); } else { if (info.message === "Success") { res.json({ info: info.message, user, }); } else { res.json({ info: info.message, }); } } }); } else { res.send(info.message); } })(req, res, next); });
로그인은 passport의 authenticate 메서드를 이용하여 진행한다. local방식을 사용할 것이기 때문에 첫 번째 인자로 "local"을 사용하고, 이후에는 callback 함수를 두 번째 인자로 사용한다.
해당부분에 대해서는 아직 완벽하게 공부가 되지 않았으므로 이후에 제대로 적겠다.