회원가입 및 User 정보 구성을 해보겠다!!
1. passport-local
auth를 위해 npm에서 가장 많이 사용하는 passport라는 모듈을 사용하겠다. npm install을 해주자.
$ npm install passport passport-local passport-local-mongoose connect-flash
passport는 google, facebook, twitter 등 다른 아이디를 통해 로그인할 수 있게 해주는 API를 제공하지만 지금 단계에선 local strategy를 이용해보도록 하겠다. flash는 한 번의 req-res cylcle동안 유효한 메세지를 담을 수 있는 모듈이다. 일단 다운받고 사용할 때에 정확히 어떻게 동작하는지 설명하겠다.
일단 auth를 진행할 User 모델을 설정한다.
// models/user.js
const mongoose = require("mongoose");
const { Schema } = mongoose;
const passportLocalMongoose = require("passport-local-mongoose");
const userSchema = new Schema({
username: {
type: String,
required: true,
unique: true,
},
name: {
type: String,
required: true,
},
sex: {
type: String,
enum: ["female", "male", "none"],
required: true,
},
height: {
type: Number,
required: true,
},
weight: {
type: Number,
required: true,
},
birth: {
type: Date,
required: true,
},
activity: {
type: String,
enum: ["none", "light", "moderate", "high"],
required: true,
},
bodyfat: Number,
resolution: String,
});
userSchema.plugin(passportLocalMongoose);
module.exports = mongoose.model("User", userSchema);
마지막에서 두번째 줄이 핵심이다. 만든 userSchema에 plugin(add-on. 추가기능으로 이해하면 됨)을 추가할건데, passportLocalMongoose를 이용한다. mongoose model에서 passport-local을 이용할 수 있게 도와주는 npm module이다. user model에 플러그인을 붙임으로써 passport에서 제공하는 여러가지 auth기능을 사용할 수 있다.
각종 option기능 역시 있는데, 자세한 것은 passport-local-mongoose docs를 참고하도록 하자. 나는 딱히 아무것도 건들지 않았다.
이제 Express app에서 passport를 사용하기 위한 설정을 해야한다. 공식문서와 example을 참고하여 app.js에 다음의 코드를 추가했다.
// app.js
const passport = require("passport");
const LocalStrategy = require("passport-local");
const User = require("./models/user");
app.use(passport.initialize());
app.use(passport.session());
passport.use(new LocalStrategy(User.createStrategy()));
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());
app.use를 이용해서 express와 passport 모듈을 연결해주었다. 참고로 express에서 app.use는 request가 들어올 때마다 use 안에 있는 method를 실행하겠다는 뜻이다. initialize()로 passport와 express를 연결하고, session()으로 express-session모듈을 사용하여 세션에 특정 유저의 로그인 정보를 저장한다. 이걸 위해 저번의 포스트 1에 session configuration을 먼저 한 것이다. 그러니 app.use(passport.session()) 코드는 express session 설정을 완료한 뒤 그 밑에 넣어야 한다.
그 밑 3줄은 passport-local-mongoose의 docs에 나온대로 passport configuration을 진행한 코드다.
serializeUser()은 세션에 저장할 User object 정보를 결정한다. 기본적으로 "username", 즉 passport가 유저의 구분을 위해 사용하는 ID만을 저장하도록 되어있다. deserializeUser()은 serialize를 통해 받은 유저 정보를 토대로 유저를 실제 구분하는 과정이다. 구분된 유저의 object는 req.user에 저장되게 된다.
기본적인 passport configuration을 마치고 실제 auth 과정으로 넘어가기 전 flash셋팅 먼저 하고 가자!
// app.js
const flash = require("connect-flash");
app.use(flash());
앞서 짧게 설명했듯 flash는 한 번의 req~res cycle에서 살아있는 짧은 메세지를 담기 위해 이용하는 모듈이다. req.flash("<fieldname>", "<message>") 포맷으로 flash 메세지를 설정하면 바로 다음 req에 req.flash("<fieldname")으로 등록한 메세지를 확인할 수 있다. 로그인에 실패했을 때 "아이디 또는 비밀번호가 틀립니다!" 등의 메세지를 띄울 때 유용하다. app.use(flash())를 한 뒤 ejs에서 특정 값을 쓰기 좋게! 전역변수로 flash message와 req.user값을 저장하도록 하자.
// app.js
app.use((req, res, next) => {
res.locals.success = req.flash("success");
res.locals.error = req.flash("error");
res.locals.currentUser = req.user;
next();
});
res.locals['fieldname']에 특정 값을 저장하면 해당 middleware의 뒤에 존재하는 모든 middleware에선 해당 fieldname으로 값에 접근할 수 있다. success, error, currentUser은 위에 적어놓은 코드의 밑에 있는 부분에서 전부 쓸 수 있다는 얘기가 된다.
! middleware란? 유저의 require이 들어오고 서버의 response가 나갈 때까지 중간에 거쳐가는 모든 함수들이다.
셋팅 끝이다. 이제 본격적으로 passport를 이용한 auth 과정을 구현해보자.
2. MVC 패턴
Model-View-Controller의 약자로, 모델들이 수행하는 기능으로 이뤄진 하나의 프로젝트에서 Model 자체 / 유저가 보는 페이지 View / 유저와 model, model과 model간의 상호작용을 저장한 Controller 의 기능 코드를 분리하여 프로젝트를 구성하는 방법론이다. 처음에 모델 설계를 할 적에 models 폴더를 생성한 뒤 그 안에 schema를 정의한 것이 이를 위해서이다.
대충.. 완성된 내 프로젝트 폴더는 이렇게 되어있다. controllers에는 모델과 프로젝트의 각종 기능을 위한 실질적 함수들이, models에는 모델의 구성과 instance/static method들이, views에는 유저가 직접 보는 ejs 파일들이, routes에는 controller의 middleware 기능들이 동작하는 순서와 흐름이 들어있다. auth 과정을 통해 각 파일에 어떤 것들이 들어있는지 오늘 포스트로 쫙 정리하겠다.
views 폴더의 register.ejs에 회원가입 폼을 구성하자. form 구성을 위한 여러가지 설정은... html 시간이 아니니 생략하기로 하고.
우리는 유저가 입력하고 POST로 서버에 보낸 데이터를 req.body에 넣어주는 body-parser 모듈을 이용할 것이다. 그것이 express의 urlencoded()이다. 로그인이나 회원가입같이 유저가 문자열로 입력한 데이터는 서버로 보내질 때 주로 application/x-www-form-urlencoded라는 데이터 인코딩 방식을 사용하는데(form의 enctype attribute의 default 값으로 설정됨), 폼에서 name-value로 이뤄진 쌍을 key-value의 object 쌍으로 바꾸는 parser이다. 유저의 Input은 이 미들웨어를 거친 후에 req.body의 key value object가 된다. app.js에 아래 코드를 추가하여 body-parser을 설정하자.
// app.js
app.use(express.urlencoded({ extended: true }));
추가로 이미지나 음성파일 등 문자열 외의 다른 형식의 input이 들어올 때는 multipart/form-data 인코딩 방식을 사용하게 되는데, 그땐 urlencoded방식이 듣지 않기 때문에 multer 등의 다른 parser 모듈을 이용해야한다. 이때는 urlencoded의 extended 값을 true로 해줘야 한다. extended:true는 유저의 입력 value로 string 이외에 다른 object 등을 허용한다는 뜻이다.
이 프로젝트의 경우 나중에 파일을 업로드하는 상황이 필요할지도 모르니까 일단 extended 값을 true로 주었다. 내가 제대로 만드는 첫 프로젝트인 만큼 마구마구 확장시켜보고 싶거든..
3. Routes
코드의 가독성을 높이고 middleware의 흐름을 잘 파악하기 위해 express의 Router method를 사용할 것이다. 프로젝트 루트 디렉토리에 routes 폴더를 추가하고 user.js파일을 아래와 같이 작성한다. router.get의 첫번째 인자는 라우터가 듣는 url 그 자체를, 나머지 인자들은 그 뒤에 실행할 express middleware의 나열을 의미한다.
// routes/user.js
const express = require("express");
const router = express.Router();
const passport = require("passport");
const userController = require("../controllers/user");
router.get("/login", userController.renderLogin);
controllers/user.js는 아래와 같다.
// Ccontrollers/user.js
module.exports.renderLogin = (req, res) => {
res.render("users/login");
};
user controller의 renderLogin 함수가 export되었고, 그걸 user router가 받아 사용하는 형식이다. "/login" url을 받으면 views/users/login.ejs 파일이 렌더되는 식이다.(최초 프로젝트 설정시에 app.js에서 app.set("views")를 __dirname+"/views"로 지정했기 때문에 render dir을 그냥 저렇게 써도 된다)
routes 디렉토리의 내용을 app.js에선 아래 코드로 받는다.
// app.js
const userRoutes = require("./routes/users");
app.use("/user", userRoutes);
/user 라우트 뒤에 오는 것들은 userRoutes에서 핸들링하게 한다는 뜻이다.
3000번 포트에서 express 서버를 열었다고 할 때, "localhost:3000/user/login" url로 들어가면
1. /user을 받은 app이 userRoutes로 req를 전달한다.
2. /login을 받은 userRoutes가 userController.renderLogin middleware으로 req를 전달한다.
3. user controller의 renderLogin middleware은 res에 login form을 render한다.
이 과정을 거치는 것이다. 앞으로 진행하는 모든 model 관련 기능들은 이 MVC + routes 패턴을 따르며 개발할 것이다.
4. Register
쨌든! 이제 req.body에 유저가 회원가입 폼에 입력한 이름, 비밀번호 등의 object가 들어있다. 이제 실질적으로 DB에 회원가입 유저의 정보를 넣는 기능을 controllers에 구현해보자. controllers 폴더에 user.js를 추가한 뒤 코드를 추가한다.
+) ExpressError은 error handling 부분에서 자세히 설명하도록 하겠다.
// controllers/user.js
const User = require("../models/user");
const userSchema = require("../utils/validateSchema");
const ExpressError = require("../utils/expressError");
module.exports.register = async (req, res, next) => {
let { name, email, sex, birth, height, weight, activity, bodyfat } = req.body;
birth = new Date(
birth.slice(0, 4) + "-" + birth.slice(4, 6) + "-" + birth.slice(6)
);
const newUser = await User.register(
new User({
name,
username,
sex,
birth,
height,
weight,
activity,
bodyfat,
}),
req.body.password
);
req.login(newUser, (err) => {
if (err) {
throw ExpressError(err, 500);
} else {
req.flash("success", "부추의 식단관리 사이트 가입을 환영합니다!");
res.redirect("/");
}
});
};
// validate by joi
module.exports.validateUser = (req, res, next) => {
const { error } = userSchema.validate(req.body);
if (error) {
const message = error.details.map((el) => el.message).join(",");
throw new ExpressError(message, 400);
} else {
next();
}
};
birth는 "19960527"같은 8자리 숫자 형식으로 받았다. req.body에서 데이터들을 destructuring한 후 8자리 숫자를 Date로 만들어주는 과정을 거쳤다.
User.register()은 user model에서 passport-local-mongoose 모듈로 plugin 했을 적에 추가된 static method이다. 첫번째 인자로 DB에 넣어줄 user object 자체를, 두번째 인자로 encrypt할 password를 입력한다. 그럼 register 함수가 알아서 password를 encrypt 한 상태로 우리의 mongoDB user collection에 새로운 user object를 insert해줄 것이다. 이 과정은 async하게 이뤄지므로 함수를 async로 두고 await를 사용해주었다. js의 promise와 관련한 지식이 없다면... 공부하고 와라!
간단히 설명하자면 promise는 작동에 시간이 걸리거나, 비동기적으로 처리하고 싶은 작업 처리에 사용되는 객체이다. resolve와 reject 함수를 return하며, .then과 .catch 블락을 이용해서 다음 작업과 error handling이 가능하다.
예전에 블로그에 관련 개념을 정리한 적이 있다!
아무튼. 회원가입 후 로그인 처리가 자동으로 되는게 상식적이므로 req.login을 이용해서 로그인을 실행한다. 첫번째 인자로 로그인시킬 유저의 object를, 두번째 인자로 call back을 전달한다. 이 상황에는 DB에 저장하는 과정에서 생성된 user object가 존재하므로 req.login을 이용했고 실제 login 과정에선 passport.authenticate() method를 이용할 것이다.
user router 파일에서 "/user/register" post request가 들어왔을 때 진행되는 미들웨어를 나열하면 아래 코드와 같다.
// router/user.js
router.post(
"/register",
userController.validateUser,
catchAsync(userController.register)
);
validateUser는 1번 포스트에서 기본 모델설계 했을때 만든 joi validate method다. /user/register로 req가 들어오면, validate 하는 미들웨어가 실행되고, 그 뒤에 user controller에서 실제로 register하는 함수가 실행되는 것이다.
catchAsync 역시 expressError와 마찬가지로 에러 핸들링을 다룰 때 한꺼번에 설명하도록 하겠다.
4. Log in
로그인 기능을 구현해보겠다. views/login.ejs 파일에 username, password를 받는 폼을 추가한다. register form과 같은 방식으로 하면 된다. 로그인 폼은 길지 않아서 ejs코드를 통째로 가져와보았다.
<div class="row justify-content-center">
<div class="col-10 col-md-4">
<div class="d-flex justify-content-center mb-3">
<h1>로그인</h1>
</div>
<form
action="/user/login"
method="post"
class="needs-validation"
novalidate
>
<div class="mb-3">
<label for="email" class="form-label"><h6>Email</h6></label>
<input
type="email"
class="form-control"
id="email"
name="username"
aria-describedby="emailHelp"
required
/>
<div class="invalid-feedback">이메일을 입력해주세요!</div>
</div>
<div class="mb-3">
<label for="password" class="form-label"><h6>Password</h6></label>
<input
type="password"
class="form-control"
name="password"
id="password"
required
/>
<div class="invalid-feedback">비밀번호를 입력해주세요!</div>
</div>
<div class="d-flex justify-content-center my-4">
<button type="submit" class="btn btn-primary">로그인</button>
</div>
</form>
<div class="d-flex justify-content-end">
<div class="text-muted mx-2">회원이 아니신가요?</div>
<div><a href="/user/register">회원가입</a></div>
</div>
</div>
</div>
위의 폼에서 유저가 이메일과 아이디를 입력하면 req.body가 { username, password } 형태의 object형태가 된다. 앞서 언급했듯 로그인 과정에선 passport의 authenticate 미들웨어를 사용할 것이다.
// routes/users.js
router.post(
"/login",
passport.authenticate("local", {
failureRedirect: "/user/login",
failureFlash: "아이디 혹은 비밀번호가 틀렸습니다.",
}),
userController.successLogin
);
Document에 나온대로 우리는 Local strategy를 사용하고 있으므로 첫번째 인자로 "local"을 준다. 두 번째 인자는 configuration이다. 보면 알 수 있듯 로그인이 실패했을 때 redirect할 url, 그리고 connect-flash 모듈을 이용할 경우 flash message를 설정할 수 있다. 설정해주었다. 로그인에 실패할 경우 authentication 미들웨어 안에서 res를 반환하고, 성공할 경우 다음 미들웨어가 실행된다. 로그인에 성공했을 경우 success flash와 success redirct url을 설정한 successLogin 미들웨어는 controller에 작성했다.
// controllers/user.js
module.exports.successLogin = (req, res) => {
req.flash("success", "어서오세요!");
res.redirect("/");
};
5. Log out
passport의 log out 기능은 매우 쉽다. 그냥 req.logout만 시켜주면 된다.
// controllers/user.js
module.exports.logout = async (req, res, next) => {
req.logout((err) => {
if (err) {
throw ExpressError(err, 500);
} else {
req.flash("success", "안녕히가세요!");
res.redirect("/user/login");
}
});
};
// routes/user.js
const { isLoggedIn } = require("../utils/middlewares");
router.get("/logout", isLoggedIn, catchAsync(userController.logout));
routes에 나열된 Logout 미들웨어를 보면 "isLoggedIn"이라는 method 이용한 것을 볼 수 있는데, 이것은 현재 req를 보내고 있는 사용자가 로그인을 한 상태인지 확인하는 것이다. 로그인을 하지 않았다면 로그아웃을 할 필요도, 할 일도 없을 것이기 때문에 확인 과정이 필요하다. utils 디렉토리의 middlewares.js에 확인 과정을 담았다.
// utils/middlewares.js
module.exports.isLoggedIn = (req, res, next) => {
if (!req.isAuthenticated()) {
req.flash("error", "사이트 이용을 위해 로그인이 필요합니다.");
return res.redirect("/user/login");
}
next();
};
이로써 passport local을 이용한 간단한 회원가입, 로그인, 로그아웃 기능을 살펴봤다! 유저가 자기 자신의 정보를 확인하고, 자기 자신의 정보를 수정하는 과정은 포스트에서 진행한 과정과 크게 다르지 않다...! 그러니 굳이 기록하진 않겠다.
다음 포스트는 방금 설명을 생략한 Error handling에 관해서 정리하겠다.