지난 포스팅 에서는 SHA-256과 키 스트레칭 및 솔트를 활용하여 비밀번호 안전하게 저장하는 방법에 대한 이론을 알아보았다. 이번 포스팅에서는 이러한 이론들을 직접 활용하여 Node.js, Express, MySQL을 사용하여 실제로 안전한 회원가입 및 로그인 시스템을 구현해보겠다.
보안이 강화된 회원가입 및 로그인 시스템을 구현하기 위해 Node.js, Express, MySQL, dotenv를 설치하고, 환경변수를 활용하여 민감한 정보를 안전하게 관리하는 방법을 설정해보겠다.
우선, 프로젝트를 진행할 폴더로 이동하여 프로젝트에 필요한 패키치들을 설치해준다.
npm install express mysql2
다음으로 서버를 열어줄 파일을 생성하고(예를들어 server.js), 간단한 express 서버를 실행한다.
// server.js
const express = require("express");
require("dotenv").config();
const app = express();
app.get("/", (req, res) => {
res.send("서버가 정상적으로 열렸습니다.");
});
app.listen(3000, () => {
console.log(`server on~`);
});
프로젝트 루트 디렉토리에 .env 파일을 생성하고, MYSQL 접속 정보를 다음 양식에 맞춰 본인의 정보를 입력한다.
DATABASE_USER=user (아이디)
DATABASE_PASSWORD=password (비밀번호)
DATABASE_HOST=localhost
DATABASE_PORT=3306
DATABASE_NAME=database (데이터베이스 이름)
.env 파일을 따로 생성하여 환경변수를 정의하는 이유는 MYSQL 접속 정보 자체가 카카오톡에 로그인 하듯이 나의 데이터베이스에 접속하는 정보이기 때문에 코드에 직접 노출시키는것은 나의 데이터를 누구나 볼 수 있게 오픈하는 것과 같다. 물론, .env 파일은 깃허브나 코드 저장소에 올려서도 안된다.
이제 환경변수를 적용하여 MYSQL 데이터베이스와 연결하는 코드를 데이터를 다룰 자바스크립트 파일(예를들어 data.js)을 만들어 다음과 같이 작성하여 연결상태를 확인하자.
// data.js
const mysql = require("mysql2/promise");
require("dotenv").config();
const connectPool = mysql.createPool({
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
host: process.env.DATABASE_HOST,
port: process.env.DATABASE_PORT,
database: process.env.DATABASE_NAME,
multipleStatements: true
});
// 커넥션 확인
connectPool.getConnection()
.then(() => console.log("MySQL Database Connected"))
.catch(err => console.error("Database Connection Failed:", err));
module.exports = connectPool;
만약 기존에 데이터베이스가 없다면 cmd(터미널)에서 계정에 접속하고 CREATE [데이터베이스 이름]을 입력하면 데이터베이스가 생성될 것이다. 내 데이터 베이스를 확인하려면 SHOW DATABASES!
이제 회원가입하는 유저의 비밀번호롤 안전하게 보호하기 위해 SHA-256 해싱, 솔트(Salt), 키 스트레칭(Key Stretching)을 적용하고, MySQL에 안전하게 저장하는 방법을 구현해보겠다.
보안 관련 로직들(함수들)을 정의할 파일을 생성하여(예를들어 security.js) 다음과 같이 솔트 생성 함수와 해싱 및 키 스트레칭 적용 함수를 작성하여 기능들을 정의해준다.
// security.js
const crypto = require("crypto");
// 솔트 생성 함수
const createSalt = () => { //32바이트의 랜덤 값을 생성하여 예측 불가능한 솔트 값 생성
return new Promise((resolve, reject) => {
crypto.randomBytes(32, (err, salt) => {
if (err) reject(err);
resolve(salt.toString("hex"));
});
});
};
// SHA-256 해싱 + 키 스트레칭 적용
const createHash = (password, salt) => { // pbkdf2Sync를 사용해 SHA-256 해싱 수행
return crypto.pbkdf2Sync(password, salt, 100000, 64, "sha256").toString("hex");
};
module.exports = { createSalt, createHash };
이제 제일 중요한 사용자 정보를 저장할 테이블을 다음과 같이 생성해준다.
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
uid VARCHAR(50) NOT NULL UNIQUE,
upw VARCHAR(128) NOT NULL, // 암호화된 비밀번호 저장
salt VARCHAR(128) NOT NULL // 솔트값 저장 (로그인 시 비밀번호 검증에 필요)
);
이제 로그인 및 회원가입 기능 구현을 실습하기 위한 환경 세팅 및 데이터 세팅도 끝났으니, 비밀번호 해싱 후 저장하는 회원가입 기능과 비밀번호 검증을 수행하는 로그인기능을 간단하게 구현해보자.
회원가입 시 SHA-256 해싱 + 솔트 + 키 스트레칭을 적용하여 비밀번호를 안전하게 저장하고, 로그인 시에는 저장된 해시 값과 비교하여 인증을 수행한다.
회원가입 과정
- 사용자가 id, 비밀번호 입력
- 솔트 생성
- 해싱 + 키 스트레칭을 적용하여 비밀번호 암호화
- 테이블에 암호회된 비밀번호 및 솔트 저장
회원가입 및 로그인 기능을 구현할 자바스크립트 파일(예를들어 controller.js)를 생성하고 다음과 같이 회원가입 로직을 담당하는 함수를 작성 해 준다.
// controller.js
// 앞선 챕터에서 만들어둔 모듈 불러오기
const connectPool = require("./database");
const { createSalt, createHash } = require("./security");
const registerUser = async (uid, password) => {
try {
// 중복 ID 확인
const [[existingUser]] = await connectPool.query("SELECT * FROM users WHERE uid = ?", [uid]);
if (existingUser) {
return { success: false, message: "이미 존재하는 아이디입니다." };
}
// 솔트 생성
const salt = await createSalt();
// 비밀번호 해싱
const hashedPassword = createHash(password, salt);
// 데이터베이스에 저장
const query = "INSERT INTO users (uid, upw, salt) VALUES (?, ?, ?)";
await connectPool.query(query, [uid, hashedPassword, salt]);
console.log("회원가입 성공:", uid);
return { success: true, message: "회원가입 완료" };
} catch (error) {
console.error("회원가입 실패:", error);
return { success: false, message: "회원가입 실패" };
}
};
module.exports = { registerUser };
그다음 앞서 만들어둔 server.js에 회원가입 API를 추가해준다.
// server.js
const express = require("express");
const { registerUser } = require("./controller");
const app = express();
app.use(express.json());
// 회원가입 API
app.post("/signup", async (req, res) => {
const { uid, password } = req.body;
const result = await registerUser(uid, password);
res.json(result);
});
app.listen(3000, () => console.log("server on~"));
로그인 과정
- 사용자가 id, 비밀번호 입력
- 입력한 id가 테이블에 존재하는지 확인
- 저장된 솔트를 불러와 입력한 비밀번호를 해싱
- 저장된 해시값과 비교하여 인증 수행
회원가입 로직을 작성한 것과 마찬가지로 controller.js에 로그인 로직 구현 함수를 작성해준다.
const loginUser = async (uid, password) => {
try {
// 사용자가 입력한 ID가 존재하는지 확인
const [[user]] = await connectPool.query("SELECT * FROM users WHERE uid = ?", [uid]);
if (!user) {
return { success: false, message: "아이디가 존재하지 않습니다." };
}
// 저장된 솔트 값을 가져와 비밀번호 해싱
const hashedInputPassword = createHash(password, user.salt);
// 저장된 해시 값과 비교하여 로그인 인증
if (hashedInputPassword === user.upw) {
console.log("로그인 성공:", uid);
return { success: true, message: "로그인 성공" };
} else {
return { success: false, message: "비밀번호가 틀렸습니다." };
}
} catch (error) {
console.error("로그인 실패:", error);
return { success: false, message: "서버 오류" };
}
};
module.exports = { registerUser, loginUser };
로그인 API를 server.js에 추가해준다.
// server.js
const express = require("express");
const { registerUser, loginUser } = require("./controller");
const app = express();
app.use(express.json());
// 회원가입 API
app.post("/signup", async (req, res) => {
const { uid, password } = req.body;
const result = await registerUser(uid, password);
res.json(result);
});
// 로그인 API
app.post("/login", async (req, res) => {
const { uid, password } = req.body;
const result = await loginUser(uid, password);
res.json(result);
});
app.listen(3000, () => console.log("server on~"));
추가적으로, 실습 코드를 실제로 눈으로 볼 수 있도록
view.ejs코드(프론트엔드 부분)와,server.js코드를 아래에 공유해 두겠다.
http://localhost:3000/에 접속하여 실습을 진행하면 된다.
// server.js
const express = require("express");
const path = require("path");
const { registerUser, loginUser } = require("./controller");
const app = express();
app.use(express.json());
app.set("view engine", "ejs"); // EJS 사용 설정
app.use(express.static(path.join(__dirname, "public"))); // 정적 파일 사용
// 기본 페이지 (회원가입 & 로그인 폼)
app.get("/", (req, res) => {
res.render("view");
});
// 회원가입 요청 처리
app.post("/signup", async (req, res) => {
const { uid, password } = req.body;
const result = await registerUser(uid, password);
res.json(result);
});
// 로그인 요청 처리
app.post("/login", async (req, res) => {
const { uid, password } = req.body;
const result = await loginUser(uid, password);
res.json(result);
});
app.listen(3000, () => console.log("server on~"));
// view.ejs
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회원가입 및 로그인</title>
</head>
<body>
<h2>회원가입</h2>
<form id="signup-form">
<input type="text" id="signup-uid" placeholder="아이디 입력" required><br>
<input type="password" id="signup-password" placeholder="비밀번호 입력" required><br>
<button type="submit">가입하기</button>
</form>
<h2>로그인</h2>
<form id="login-form">
<input type="text" id="login-uid" placeholder="아이디 입력" required><br>
<input type="password" id="login-password" placeholder="비밀번호 입력" required><br>
<button type="submit">로그인</button>
</form>
<script>
// 회원가입 요청
document.getElementById("signup-form").addEventListener("submit", async (e) => {
e.preventDefault();
const uid = document.getElementById("signup-uid").value;
const password = document.getElementById("signup-password").value;
const response = await fetch("/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uid, password })
});
const result = await response.json();
alert(result.message);
});
// 로그인 요청
document.getElementById("login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const uid = document.getElementById("login-uid").value;
const password = document.getElementById("login-password").value;
const response = await fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uid, password })
});
const result = await response.json();
alert(result.message);
});
</script>
</body>
</html>