드디어 내가 평소에 궁금했던 회원가입, 로그인 과정을 배우게 되었다.
구현은 기존에 진행한 todo-app에 연장선으로 진행했다.
먼저 회원가입부터 진행을 했는데 회원가입의 경우 어떤 특별한 별개 기능이라기보단 유저의 이름, 메일주소, 패스워드를 POST하는 과정이었다.
기존 todo-app은 task라는 api만 있었는데 신규로 user라는 api를 만들고 라우터 세팅 후 User라는 model 스키마도 설계했다. 스키마에는 이름, 이메일주소, 패스워드를 넣어주었다.
const userSchema = Schema({
name: {
type:String,
required:true
},
email: {
type:String,
required:true
},
password: {
type:String,
required:true
}
}, {timestamps:true});
해당 스키마를 기반으로 컨트롤하는 user컨트롤러 파일을 만들고 케이스에 맞게 짜나갔다.
기존에 가입한 회원인지도 findOne({email})로 확인하고
비번이 그냥 db에 저장되면 안되니까 해쉬화해서 저장하는 작업도 수행했다.
이 비번 해쉬화를 위해 bcrypt라는 npm라이브러리가 사용되었는데 이분이 꽤 흥미로웠다. (npm 인스톨 시 bcryptjs로 받기!)
여기서 saltRounds가 얼마나 더 암호를 섞어?줄거냐 여부인데 권장은 10번이어서 10을 지정했다.
const User = require("../model/User");
const bcrypt = require("bcryptjs");
const saltRounds = 10;
const userController = {};
//회원가입
userController.createUser = async (req, res) => {
try {
const { name, email, password } = req.body;
//이미 있는 유저 처리
const user = await User.findOne({ email: email });
if (user) {
throw new Error("이미 가입이 된 유저입니다.");
}
//패스워드 암호화
const salt = bcrypt.genSaltSync(saltRounds);
const hash = bcrypt.hashSync(password, salt);
console.log("hash", hash);
const newUser = new User({ name, email, password: hash });
await newUser.save();
res.status(200).json({ status: "ok" });
} catch (error) {
res.status(400).json({ status: "fail", error });
}
};
해당 라이브러리를 잘 사용하면 비번을 쉽게 해쉬화해서 저장할 수 있다.
이렇게 회원가입을 위한 유저컨트롤러도 완료.
이어서 로그인 부분도 구축해보았다.
로그인 과정은 자칫 햇갈릴 수도 있는 부분이 프론트쪽에서 POST로 요청한다는 점이었다. 유저의 아이디 비번같은 정보를 url로 담아서 보내는건 안되기 때문에 body에 담아서 보내기 위해서 POST방식을 쓴다고 한다. (GET은 body에 담을 수 없음)
로그인은 이렇게 프론트로부터 받은 이메일, 패스워드 정보를 db의 데이터와 일치하는지 여부를 체크하는 과정이다. 매치가 안되면 에러메시지를 띄워주고 매치가되면 독특한 부분이 'token'이라는걸 발행한다.
참고로 토큰 생성은 재사용성을 고려해서 스키마를 제작하는 곳인 모델/User에서 진행되었다. 스키마에서는 자료구조 뿐만 아닌 이런 자체적인 메소드를 생성할 수 있었다. 토큰생성에는 jsonwebtoken 줄여서 jwt라는 라이브러리가 사용되었다. user의 고유키인 _id를 기반으로 고유의 엄청나게 긴 시크릿 키를 생성해준다.
const jwt = require('jsonwebtoken');
const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY;
//토큰생성 메소드
userSchema.methods.generateToken = function name(params) {
const token = jwt.sign({_id:this._id}, JWT_SECRET_KEY, {
expiresIn:'30d'
});
return token;
}
이렇게 만든 토큰이 바로 인증된 유저라는 증명이며 토큰이 로컬스토리지나 세션스토리지에 저장되어있으면 창을 잠깐 닫더라도 재로그인할 필요없이 토큰이 로그인상태를 유지시켜주며 혹여나 /login 경로로 가더라도 로그인이 되어있다면 콘텐츠가 있는 페이지로 리다이렉션을 시켜준다. 아래 코드는 유저컨트롤러의 loginWithEmail 메소드이며 성공시 프론트엔드에게 'token'을 보내준다.
//로그인
userController.loginWithEmail = async (req, res) => {
try {
//req로부터 유저 정보 가져오기
const { email, password } = req.body;
//이메일주소 일치하는지 대조
const user = await User.findOne({ email: email }, "-createdAt -updatedAt -__v");
if (user) {
//대조해서 찾은 유저의 패스워드와 DB의 패스워드 대조
const isMatch = bcrypt.compareSync(password, user.password);
if (isMatch) {
//맞으면 유저정보와 토큰을 발행시켜서 보내줌
const token = user.generateToken();
return res.status(200).json({ status: "ok", user, token });
}
}
throw new Error("이메일 또는 패스워드가 일치하지 않습니다.");
} catch (error) {
res.status(400).json({ status: "fail", message: error.message });
}
};
프론트엔드는 받은 토큰 정보를 최초 접속 페이지에서 GET api의 헤더(headers) 쪽에 붙여서 다시 백엔드로 보내주면서 인증된 유저의 정보를 요청한다. 아래는 프론트엔드 코드.
window.onload = function() {
const token = sessionStorage.getItem('token');
// 토큰이 없으면 로그인 페이지로 리다이렉션
if (!token) {
window.location.href = 'login.html';
} else {
// 인증된 사용자 정보 가져오기
fetch(`${URI}/user/auth`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${sessionStorage.getItem("token")}` // 토큰을 Bearer 토큰으로 설정
}
})
.then((response) => response.json())
.then((data) => {
console.log("인증된 유저 정보:", data);
// 서버에서 응답 받은 데이터 처리
window.location.href = 'tasks.html';
})
.catch((error) => console.error("Error:", error));
}
};
프론트에서 토큰을 헤더에 붙여보낼 때 "Authorization": `Beaer ~~~`이런식으로 Beaer라는걸 붙여서보내는데 이게 국룰? 같은거라고 한다. 이 최초 접속 페이지에서 토큰이 없다면 로그인페이지로 보내고 토큰이 있다면 본 콘텐츠 페이지로 보낸다.
백엔드에서는 이 토큰 인증을 위해 auth컨트롤러라는 것을 따로 만들어준다. 여기서 프론트엔드 헤더에서 온 토큰정보 인증을 진행하는데 독특한건 이 auth컨트롤러는 인증만을 위해 만들어진 기능이기에 인증 기능만하고 next()를 이용해서 다음 이루어져야 할 과정인 유저정보를 프론트로 넘기는 과정을 패스시킨다. 패스할 때 유저에 대한 단서인 userId 함께 넘겨주는 과정.
const authController = {};
authController.authenticate = (req, res, next) => {
try {
const tokenString = req.headers.authorization;
if (!tokenString) {
throw new Error("invalid token!");
}
const token = tokenString.replace("Bearer ", "");
jwt.verify(token, JWT_SECRET_KEY, (error, payload) => {
if (error) {
throw Error("invalid token!")
}
req.userId = payload._id;
});
//usercontroller가 인증된 유저 아이디를 알 수 있도록 보냄 (미들웨어)
next();
} catch (error) {
res.status(400).json({ status: "fail", message: error.message });
}
};
이런걸 '미들웨어'라고 부르며 함수 파라미터에 req, res 외에 next라는걸 추가로 붙여서 구현한다.
//유저인증을 위해 auth컨트롤러를 미들웨어로 거침
router.get("/auth", authController.authenticate, userController.getUser);
미들웨어를 타게되면 user라우터에서 이런식으로 auth컨트롤러에서는 인증과정만 진행하고 user컨트롤러로 다음 과정을 넘겨버린다.
//로그인 한 유저 정보 가져오기
userController.getUser = async (req, res) => {
try {
//authcontroller에서 인증된 userId 가져옴
const {userId} = req;
const user = await User.findById(userId, "-updatedAt -__v");
if(!user){
throw new Error("can not find user!");
}
res.status(200).json({ status: "ok", user });
} catch (error) {
res.status(400).json({ status: "fail", message: error.message });
}
};
유저컨트롤러는 auth컨트롤러로부터 받은 userId를 통해 인증된 유저의 데이터를 프론트엔드로 보내주면서 로그인 과정이 마무리된다.
꽤 어렵긴 했지만 회원가입이나 로그인과정엔 암호화나 토큰과 같은 독특한 개념들이 있어서 흥미로웠다.