[Toy Project - Omuk] 10. 사용자 인증(Redis, Passport, Express)
Passport
세션을 내부적으로 처리하여 로그인 기능을 대신 해줍니다. 그냥 로그인도 가능하지만, 카카오·구글·페이스북·애플 등 SNS 계정을 통해 로그인 하고자 할 때, 유용하게 사용할 수 있습니다.
먼저 passport 모듈과 서버 자체적으로 로그인할 때 사용하기 위해 로컬 전략을 사용해야 하므로 passport-local 을 설치해줍니다.
npm i passport passport-local
이제 passport를 사용하기 위해 루트 디렉터리에서 passport 폴더를 생성합니다. 그리고 메인 파일인 index.js 파일과 로컬 전략을 작성하기 위해 localStrategy.js 를 생성합니다.
먼저 로컬 전략을 세우기 위해 localStrategy.js 파일에 내용을 작성하겠습니다. 이때의 전략은 간단하게 어떻게 로그인을 구현할지입니다.
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import User from "../models/user.js";
passport와 passport-local 모듈을 불러옵니다. 그리고 유저 모델도 불러옵니다. 이제, 패스포트를 통해 로그인 과정을 구현합니다.
export default () => {
passport.use(
new LocalStrategy(
{
// input 태그의 name
usernameField: "email",
passwordField: "password",
},
로컬 전략 인스턴스를 새로 생성해주고, 첫 번째 매개변수에 usernameField와 passowrdField의 값을 객체로 넣어주어야 합니다. 이때 각각 username과 password로 쓰일 input 태그의 name 값에 맞춰서 값을 넣어줍니다.
async (email, password, done) => {
if (email || password) {
try {
const exUser = await User.findOne({ where: { email } });
if (exUser) {
if (password === exUser.password) {
done(null, exUser);
} else {
done(null, false, { message: "비밀번호가 일치하지 않습니다." });
}
} else {
done(null, false, { message: "가입하지 않은 회원입니다." });
}
} catch (err) {
console.error(err);
done(err);
}
}
}
위의 과정은 로그인 과정을 작성한 것입니다. 여기서는 password를 그대로 썼는데, 암호화를 통해서 둘을 비교하는 것이 더 보안적으로 좋기 때문에 향후 bcrypt 모듈을 사용하여 패스워드를 암호화하고 복호화하여 로그인의 보안의 조금 더 강화하겠습니다.
이제 index.js 파일을 작성하겠습니다.
import passport from "passport";
import local from "./localStrategy.js";
import User from "../models/user.js";
앞서 작성한 로컬 전략 파일을 임포트해서 가져옵니다.
export default () => {
/** 사용자 세션 작성 */
passport.serializeUser((user, done) => {
done(null, user.id);
});
/** 사용자 세션 해석 */
passport.deserializeUser((id, done) => {
User.findOne({ where: { id } })
.then((user) => done(null, user))
.catch((err) => done(err));
});
local();
};
사용자 세션을 작성할 수 있도록 serializeUser 메서드를 사용해서 사용자의 세션을 작성하도록 합니다. 그리고 사용자 세션을 읽어주기 위해서 deserializeUser 메서드를 사용합니다.
이제 메인 app.js 파일에서 불러오고 실행합니다.
// 패스포트 설정
import passportConfig from "./passport/index.js";
const app = express();
passportConfig();
...
// 7. 패드포트
app.use(passport.initialize());
app.use(passport.session());
그리고, 세션이 작성되어 Redis 저장소에 저장될 수 있도록 redis와 연결시켜줍니다.
Redis
Redis (Remote Dictionary Server). 키와 밸류를 사용하는 저장소로 데이터베이스나 캐시 저장소 등으로 사용되며 빠른 속도가 장점인 원격 저장소입니다.
https://redis.io/docs/getting-started/installation/
저는 Windows OS를 사용하기 때문에 WSL2를 통해 설치하겠습니다.
우분투 환경에서
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
sudo apt-get update
sudo apt-get install redis
를 입력한 후에 서버를 실행합니다.
sudo service redis-server start
이제 다시 VSCode로 돌아가서 레디스 환경을 설정해줍니다.
필요한 패키지는 redis, connect-redis 입니다.
npm i redis connect-redis
redis는 클라이언트를 생성하기 위해서, connect-redis는 세션을 레디스에 저장하기 위해서 설치합니다.
// 레디스 설정
import { createClient } from "redis";
import rs from "connect-redis";
const redisClient = createClient({ legacyMode: true });
const RedisStore = rs(expressSession);
redisClient.connect().catch(console.error);
레디스 패키지에서 createClient를 가져오고 connect-redis로 익스프레스 세션을 넣어줍니다.
이때, redisClient에서 옵션을 legacyMode로 설정한 이유는, redis와 connect-redis 패키지가 버전이 업데이트 되면서 서로 잘 맞지 않아서 계속 에러가 발생하기 때문입니다.
이제 세션의 옵션에서 저장소를 레디스 저장소로 설정하면 됩니다.
app.use(
expressSession({
resave: false,
secret: process.env.COOKIE_SECRET,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: false,
},
store: new RedisStore({
client: redisClient,
host: "127.0.0.1",
port: 6379,
}),
})
);
우분투에서 레디스 서버를 실행하며, 해당 포트는 6379입니다. 우분투에서 sudo service redis-server start 한 후에 redis-cli 명령을 입력하면 포트가 보입니다. 혹은 netstat으로 확인해도 됩니다.
이제 회원가입 후에 로그인 하면 세션 쿠키가 주어지면서 세션 데이터를 자유롭게 사용할 수 있습니다.
그런데 Passport의 문법이 처음 보면 너무나도 이해하기가 난해합니다. 특히 done에서 각 인자가 어떻게 전달이 되고 어떻게 사용되는지, 어떻게 세션이 부여되는지 헷갈립니다.
실제로 로그인을 다시 구현하면서 활용방법을 살펴보겠습니다.
로그인
/** 로그인 시도 함수 */
const postSignIn = (req, res, next) => {
passport.authenticate("local", (authError, user, info) => {
if (authError) {
console.error(authError);
return next(authError);
}
if (!user) {
return res.redirect(`/?loginError=${info.message}`);
}
return req.login(user, (loginError) => {
if (loginError) {
console.error(loginError);
return next(loginError);
}
console.log("successed");
return res.redirect("/");
});
})(req, res, next);
};
먼저 passport.authenticate("local", ···)
를 실행하여 localStrategy.js의 passport.use()를 불러옵니다.
done(null, exUser);
...
done(null, false, { message: "password incorrected." });
...
done(null, false, { message: "no user" });
그 과정에서 3개의 done이 있습니다. 첫 번째 done(null, exUser)
는 정상적으로 처리되었을 때의 done입니다. 이 done은 passport.authenricate의 두 번째 매개변수 함수로 넘어가 매칭됩니다.
authError, user, info 이렇게 세 개의 매개변수가 존재하는데, done의 인수가 그대로 전해집니다. 앞선 사례에서는 null이 authError로, exUser가 user로, info는 아무 것도 전해지지 않습니다.
그 상황에서 로그인 API로 다시 넘어가보면, authError가 null이기 때문에
if (authError) {
console.error(authError);
return next(authError);
}
이 부분은 해당되지 않아 다행히 넘어갑니다.
if(!user) {
return res.redirect(`/?loginError=${info.message}`);
}
이 부분도 user가 존재하기 때문에 넘어갑니다. 그 후로 최종적으로
return req.login(user, loginError) => {
if (loginError) {
console.error(loginError);
return next(loginError);
}
return res.redirect("/");
});
이 부분을 거쳐서 로그인하게 됩니다. 여기까지 과정을 다시 살펴보면
- 로그인을 시도하면서
passport.authenticate("local", ... )
미들웨어를 거칩니다. - authenticate가 호출되며
passport.use(new LocalStrategy())
로 넘어가 실행됩니다. passport.use()
를 통해 done을 통해 인자가 다시passport.authenticate("local", (authError, user, info) => {})
의 두 번째 매개변수로 전달됩니다- 각 조건에 따라 처리되며,
req.login()
이 호출됩니다.
그 다음 req.login으로 인해서 passport.serializeUser()로 넘어가 실행됩니다.
/** 사용자 세션 작성 */
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.serializeUser((user, done) => {})에서 user에 로그인 사용자의 정보가 전달된 후에 done(null, user.id); 를 거쳐서 req.session 에 user.id를 저장합니다. 그 후에 바로 passport.deserializeUser()로 넘어갑니다.
/** 사용자 세션 해석 */
passport.deserializeUser((id, done) => {
User.findOne({ where: { id } })
.then((user) => done(null, user))
.catch((err) => done(err));
});
이때, user.id 값이 passport.deserializeUser()의 콜백 함수의 매개변수 id 값으로 전달이 됩니다. 그 후에 쿼리를 거쳐 원하는 값을 찾아내고, 그 찾아낸 값이 done(null, user)를 통해서 req.user에 값이 저장됩니다. 만약 찾지 못한다면 done(err)에서 err 값이 req.login()의 콜백함수 매개변수로 전달되어 로그인 에러를 일으킵니다.
req.user에 값이 전달되는 것은, done(null, user)에서
return req.login(user, (loginError) => {
if (loginError) {
console.error(loginError);
return next(loginError);
}
console.log("successed");
return res.redirect("/");
});
loginError가 null이기 때문에 루트 경로로 리다이렉트되며 req.user에 세션 쿠키가 전달되는 것입니다. 정리하자면,
- serializeUser를 통해서 세션에 값을 저장합니다.
- deserializeUser에서 쿼리를 실행해 값이 있다면 req.user에 값이 저장되고 리다이렉트 됩니다.
- 쿼리를 실행했는데 값이 없으면 loginError 조건문을 거쳐서 에러를 일으킵니다.
세션 쿠키 유지
세션 쿠키가 계속해서 사이트에서 유지되는 것은, 패스포트 미들웨어를 설정했기 때문입니다. 위에서 passport.session()을 미들웨어로 넣은 것 기억하시나요?
app.use(passport.session());
이 passport.session() 함수가 passport.deserializeUser()를 호출합니다. 이 호출에서 id 값으로 사용자를 조회하고 일치하는 사용자 값이 있다면 요청 객체(req.user)에 값이 부여됩니다.
패스포트는 참 편리한데 조금 어려운 것 같습니다. 다음에는 카카오나 구글 등으로 로그인 하는 방법도 구현해보겠습니다.