회원가입 / 로그인 API 만들기

불꽃남자·2021년 7월 10일
0

서론

1년 전에 CRUD 기능을 가진 게시판 웹의 Front와 Back을 만들어서 배포한 적이 있다.
그 때에는 로그인 기능을 넣지 않았는데, "어짜피 만들 줄 알아!" 라는 생각과 "이 웹에는 로그인 기능이 필요없다." 라는 생각이었다. 근데 이제 와서 냉정히 생각해보면 그냥 만들기 어렵고 귀찮으니 자기합리화를 했던 것 뿐이었다.

요 근래에 SWR이라는 라이브러리도 알게 되어서, 이 기회에 로그인 기능도 있는 CRUD 웹 앱을 제대로 만들고 싶은 생각이 들었다.


그래서 이번 포스트에선 Backend 에서 어떻게 회원가입 / 로그인 API를 구현하는 과정을 써내려갈 생각이다.

개발 환경

나는 아래와 같은 환경에서 Backend 서버를 개발하고 배포하고 있다.

  • OS: Oracle Cloud의 ubuntu VM
  • nginx
  • node.js
  • express
  • MongoDB

전체 코드

index.js

먼저 express 서버를 여는 index.js를 살펴보자.

// index.js

const express = require("express");
const server = express();
const mongoose = require("mongoose");

const authRouter = require("./router/auth.js");

mongoose.connect("mongodb://dbUser:myPassword@SERVER_IP:DB_PORT, {
    useNewUrlParser: true,
    useUnifiedTopology: true,
  })
  .then(() => {
    console.log("MONGO DB CONNECT");
  })

server.use(express.json());

server.use("/auth", authRouter);

server.listen(3001, () => {
    console.log("Express server listen at 3001");
});

먼저 mongoose로 mongodb에 연결한다. 최신 버전의 mongoose(여기선 5.13.2 버전이다)에선 useNewUrlParseruseUnifiedTopoplogy 옵션을 사용할 것을 권장한다. 이 옵션을 사용하지 않으면 mongoose에서 경고를 띄운다.

다음으로 express.json() 미들웨어를 express에 적용한다. 이 미들웨어는 bodyParser 역할을 하는데, express 자체에 내장되어 있어 따로 bodyParser plugin을 설치하지 않아도 되서 좋다.

다음으로 /auth로 요청이 들어오면 authRouter로 라우팅 시켜준다. authRouter의 코드는 바로 아래에 나온다.

이제 3001 port에 서버를 Listening 시킨다. 나는 3000 port에 다른 서버가 Listening 중이라서 3001 port를 선택했다.

auth.js

이제 auth.js 를 살펴보자.

// auth.js

const express = require("express");
const router = express.Router();
const { check, validationResult } = require("express-validator");

const User = require("../models/user.js");
const { encodePassword, decodePassword } = require("../modules/cryptoModule.js");

router.post("/register", [
		check("userId").notEmpty().isLength({ min: 5, max: 12 }).isAlpha().isLowercase(),
		check("password").notEmpty().isLength({ min: 8, max: 16 })
	], async (req, res) => {
	try {
		console.group("detected POST request to /auth/register");
		const errors = validationResult(req);
	
		if(!errors.isEmpty()) {
			throw new Error("Invalid request body");
		}
		
		const { userId, password: plainPassword } = req.body;
		console.log(`userId: ${userId}, password: ${plainPassword}`);
		
		const { hashedPassword, salt } = await encodePassword(plainPassword);
		console.log(`hashedPassword: ${hashedPassword}, salt: ${salt}`);
	
		await User.create({ userId, hashedPassword, salt });
		res.send("Sign up complete");
		
		console.groupEnd();
	} catch (e) {
		console.log(`Error: ${e.message}`);
		console.groupEnd();
		res.status(500).send(e.message);
	}
});

router.get("/login", [
		check("userId").notEmpty().isLength({ min: 5, max: 12 }).isAlpha().isLowercase(),
		check("password").notEmpty().isLength({ min: 8, max: 16 })
	], async (req, res) => {
	try {
		console.group("detected GET request to /auth/login");
		const errors = validationResult(req);
	
		if(!errors.isEmpty()) {
			throw new Error("Invalid request body");
		}
		
		const { userId, password: plainPassword } = req.body;
		const requestUserHashedPassword = await decodePassword(userId, plainPassword);
		
		const { hashedPassword } = await User.findOne({ userId }).exec();
		console.log(hashedPassword);
		console.log(requestUserHashedPassword);
		
		if (!(requestUserHashedPassword === hashedPassword)) {
			throw new Error("Isn't match password");
		}
		
		res.send("Sign in complete");
		console.groupEnd();
	} catch (e) {
		console.log(`Error: ${e.message}`);
		console.groupEnd();
		res.status(500).send(e.message);
	}
	
});

module.exports = router;

뭐가 많으니까 나눠서 찬찬히 뜯어보자.

POST /register router

/auth/register 로 POST 요청이 들어오면 이 Router로 라우팅된다.

const express = require("express");
const router = express.Router();
const { check, validationResult } = require("express-validator");

const User = require("../models/user.js");
const { encodePassword, getUserHashedPassword } = require("../modules/cryptoModule.js");

router.post("/register", [
		check("userId").notEmpty().isLength({ min: 5, max: 12 }).isAlpha().isLowercase(),
		check("password").notEmpty().isLength({ min: 8, max: 16 })
	], async (req, res) => {
	try {
		console.group("detected POST request to /auth/register");
		const errors = validationResult(req);
	
		if(!errors.isEmpty()) {
			throw new Error("Invalid request body");
		}
		
		const { userId, password: plainPassword } = req.body;
		console.log(`userId: ${userId}, password: ${plainPassword}`);
		
		const { hashedPassword, salt } = await encodePassword(plainPassword);
		console.log(`hashedPassword: ${hashedPassword}, salt: ${salt}`);
	
		await User.create({ userId, hashedPassword, salt });
		res.send("Sign up complete");
		
		console.groupEnd();
	} catch (e) {
		console.log(`Error: ${e.message}`);
		console.groupEnd();
		res.status(400).send(e.message);
	}
});

...

Request body 검증

Router의 두 번째 인자로 왠 배열이 들어오는데, 이건 express-validator 에서 참조하는 validation이다.
express-validatorvalidator 라이브러리를 기반으로 만들어졌는데, request의 body값을 검증하는 것을 도와주는 라이브러리이다. 사용자가 일일이 정규표현식으로 request를 검증하는 수고로움을 덜어준다.
validationResult()의 인자로 request를 넘겨주면 errors를 반환하는데, 검증이 통과되었다면 errors는 비어있다. 그러므로 errors가 비어있지 않다면 ealry return을 통해 new Error를 throw해서 catch문으로 바로 넘기고 status 400과 함께 에러 메시지를 response한다.

User password 암호화

DB에 User의 Password를 Plain text상태로 저장하는 것은 얼핏 생각해봐도 보안에 좋지 못 하다. 그래서 개발자들은 여러가지 암호화 방법을 생각해냈는데, 나는 개중에서도 PBKDF2 방식의 암호화를 채택했다.

이 글에서 암호화 방식들에 대해 설명하기엔 그 양이 너무 방대하다. PBKDF2 방식에 대한 정보는 NAVER D2 - 안전한 패스워드 저장 게시물에서 잘 설명되어 있다.

암호화를 시켜주는 encodePassword() 함수를 살펴보자.

// cryptoModule.js

const crypto = require("crypto");
const User = require("../models/user.js");

const createSalt = () => {
		return new Promise((resolve, reject) => {
			crypto.randomBytes(64, (err, buf) => {
				if (err) reject(err);
				resolve(buf.toString("base64"));
			});
		});
}

const encodePassword = (plainPassword) => {
	return new Promise(async (resolve, reject) => {
		const salt = await createSalt();
		crypto.pbkdf2(plainPassword, salt, 121687, 64, "sha512", (err, key) => {
			if (err) reject(err);
			resolve({ hashedPassword: key.toString("base64"), salt });
		});
	});
}

...

createSalt() 함수로 Salt 값을 만든다. crypto.randomBytes() 함수는 말 그대로 임의의 buffer를 반환한다. 이 buffer를 base64로 인코딩하고, 이 값을 Salt 값으로써 Plain text 상태의 Password를 암호화해서 Slat 값과 함께 반환한다. 나는 pdkdf2() 함수의 3번짜 인자인 Key stretching count를 노출시켜놓았는데, 상수이고 보안에 관련된 값인만큼 어디 .env 파일에 저장해놓고 불러오는 게 좋다.

DB에 저장

그 뒤 userId, hashedPassword, salt 를 DB의 User collection에 저장한다.
Salt를 같이 저장하는 것은 login 요청을 받을 때에 사용하기 때문이다.

코드가 잘 작동하는지 Postman으로 확인해보자.



아주 잘 동작한다.
이로써 회원가입 API 구현에 성공했다.

GET /login router

/auth/login으로 GET 요청이 들어오면 이 Router로 라우팅된다.

// auth.js

...

router.get("/login", [
		check("userId").notEmpty().isLength({ min: 5, max: 12 }).isAlpha().isLowercase(),
		check("password").notEmpty().isLength({ min: 8, max: 16 })
	], async (req, res) => {
	try {
		console.group("detected GET request to /auth/login");
		const errors = validationResult(req);
	
		if(!errors.isEmpty()) {
			throw new Error("Invalid request body");
		}
		
		const { userId, password: plainPassword } = req.body;
		const requestUserHashedPassword = await getUserHashedPassword(userId, plainPassword);
		
		const { hashedPassword } = await User.findOne({ userId }).exec();
		console.log(hashedPassword);
		console.log(requestUserHashedPassword);
		
		if (!(requestUserHashedPassword === hashedPassword)) {
			throw new Error("Isn't match password");
		}
		
		res.send("Sign in complete");
		console.groupEnd();
	} catch (e) {
		console.log(`Error: ${e.message}`);
		console.groupEnd();
		res.status(500).send(e.message);
	}
});

...

Request body 검증

이 Router도 /register POST router와 같은 절차로 Request body를 검증한다.

Login password로 hashedPassword 얻기

우선 getUsersHashedPassword() 함수를 살펴보자.

// cryptoModule.js

...

const getUserHashedPassword = (userId, plainPassword) => {
	return new Promise(async (resolve, reject) => {
		const { salt } = await User.findOne({
			userId
		}).exec();
		
		crypto.pbkdf2(plainPassword, salt, 121687, 64, "sha512", (err, key) => {
			if (err) reject(err);
			resolve(key.toString("base64"));
		});
	});
}

...

userId와 Plain text상태의 Password를 매개변수로 받아온다.
그리고 Users collection에서 같은 userId를 가진 Document를 찾아서 해당 User가 회원가입 할 때에 사용한 Salt 값을 얻어온다.
그 후 회원가입 할 때에 사용한 Key stretching count를 같이 인자로 넣어 암호화시키고 반환한다.

DB의 hashedPassword와 대조 후 로그인 여부 결정

이렇게 얻은 HashedPassword가 같은 UserId를 가진 Document의 HashedPassword와 대조해서 일치한다면 회원가입을 했을 때 사용한 비밀번호와 현재 로그인을 할 때에 사용한 비밀번호가 같다는 뜻이 된다. 그럼 성공적으로 로그인에 성공한 것이다.

다시 Postman으로 login 요청을 보내보자.


잘 작동한다.

왜 이렇게 귀찮은 과정을 거치는가?

그것은 PBKDF2 방식이 단방향 암호화 방식의 단점을 보완하기 위해 등장했기 때문이다.

단방향 암호화 방식은 암호화는 가능하지만 복호화는 할 수가 없다. 이런 회원가입 / 로그인 기능을 구현할 때에는 굳이 복호화 할 필요가 없기 때문에(비밀번호를 잊어버렸을 때 기존 비밀번호를 알려주지 않고 새 비밀번호를 만들면 되기 때문) 단방향 암호화 방식을 보편적으로 채택한다.

이 PBKDF2 방식의 등장 배경을 살펴보면, 옛날에는 해쉬 알고리즘(예를 들어 SHA)으로 비밀번호를 해쉬화 한 다음 base64 문자열로 인코딩해서 암호화했는데, 이런 방식으로 암호화를 하면 같은 비밀번호에 대해 항상 같은 문자열을 가지게 된다는 단점을 안게 된다.

해커가 어떤 방법으로든 어느 사용자의 아이디와 비밀번호를 알아내게 되었고, 서버의 DB까지 접근하게 되면 해당 유저와 같은 비밀번호를 사용하는 모든 유저들을 해킹할 수 있는 이야기가 된다.

그래서 암호화를 할 때에 임의로 정해진 Salt 라고 부르는 값을 Plain text 상태의 Password 앞에 붙인 다음, 해쉬 알고리즘을 임의의 숫자만큼 반복해서 적용시킨다. 이 숫자가 위에서 보았던 Key stretching count이다. 숫자가 크면 클 수록, 예측하기 어려우면 어려울 수록 해킹의 위험이 감소한다고 볼 수 있다.

여기서 Salt는 말 그대로 소금이라는 의미로 사용된다. Password에 랜덤한 문자열을 붙여 예측을 어렵게 하는 것을 "Password에 소금을 친다" 라는 은유적인 표현으로 나타내는 듯 하다.

이러면 같은 비밀번호를 암호화 시켜도 다른 문자열이 나오며, 복호화 하는 것 또한 힘들어진다.

🌃

이번 포스팅에선 회원가입 / 로그인 API를 만드는 방법 중 하나에 대해 알아보았다.
가장 기본적이라고도 할 수 있는 기능인데도 쉽게 보고 다가갔다간 큰 코 다치는 기능 중 하나다.
나는 꽤 복잡하다는 것을 알고 있었으나, 막상 구현하고 나니 나의 걱정만큼 복잡하진 않았다. 물론 복잡해지려면 끝도 없이 복잡해질 수 있는 로직이 보안에 관한 로직이지만...

이 다음 단계는 로그인에 성공하면 JWT를 발급해서 로그인이 필요한 API에 접근할 때마다 JWT를 검증하는 것을 구현할 예정이다.

그럼 또 다음 포스팅에서...

profile
프론트엔드 꿈나무, 탐구자.

0개의 댓글