Node.js 인터뷰 질문 26
질문: Node.js에서 인증(Authentication)과 권한 부여(Authorization)를 어떻게 구현하나요?
답변:
Node.js 애플리케이션에서 인증(Authentication)과 권한 부여(Authorization)는 보안의 핵심 측면입니다. 인증은 사용자가 자신이 주장하는 사람인지 확인하는 과정이고, 권한 부여는 인증된 사용자가 특정 리소스에 접근하거나 작업을 수행할 수 있는 권한이 있는지 확인하는 과정입니다.
인증(Authentication) 방법
1. JWT(JSON Web Token)
JWT는 클라이언트와 서버 간에 정보를 안전하게 전송하기 위한 개방형 표준(RFC 7519)입니다.
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const app = express();
app.use(express.json());
// 사용자 데이터베이스 (예시)
const users = [
{ id: 1, username: "user1", password: "$2b$10$..." }, // 해시된 비밀번호
];
// 로그인 라우트
app.post("/login", async (req, res) => {
const { username, password } = req.body;
// 사용자 찾기
const user = users.find((u) => u.username === username);
if (!user) {
return res
.status(401)
.json({ message: "인증 실패: 사용자를 찾을 수 없습니다" });
}
// 비밀번호 확인
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res
.status(401)
.json({ message: "인증 실패: 비밀번호가 일치하지 않습니다" });
}
// JWT 토큰 생성
const token = jwt.sign(
{ id: user.id, username: user.username },
"your_jwt_secret", // 실제로는 환경 변수로 관리되어야 함
{ expiresIn: "1h" }
);
res.json({ token });
});
// JWT 인증 미들웨어
function authenticateToken(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // "Bearer TOKEN" 형식 파싱
if (!token) {
return res.status(401).json({ message: "인증 토큰이 필요합니다" });
}
jwt.verify(token, "your_jwt_secret", (err, user) => {
if (err) {
return res.status(403).json({ message: "유효하지 않은 토큰입니다" });
}
req.user = user;
next();
});
}
// 보호된 라우트
app.get("/protected", authenticateToken, (req, res) => {
res.json({ message: "보호된 데이터에 접근 성공!", user: req.user });
});
app.listen(3000, () => {
console.log("서버가 포트 3000에서 실행 중입니다");
});
2. 세션 기반 인증
세션은 서버 측에서 사용자 상태를 유지하는 전통적인 방법입니다.
const express = require("express");
const session = require("express-session");
const bcrypt = require("bcrypt");
const app = express();
app.use(express.json());
app.use(
session({
secret: "your_session_secret", // 실제로는 환경 변수로 관리
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production", // HTTPS에서만 쿠키 전송
maxAge: 1000 * 60 * 60, // 1시간
},
})
);
// 로그인 라우트
app.post("/login", async (req, res) => {
const { username, password } = req.body;
// 사용자 찾기 (데이터베이스 쿼리 대신 예시)
const user = await findUserByUsername(username);
if (!user) {
return res.status(401).json({ message: "인증 실패" });
}
// 비밀번호 확인
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: "인증 실패" });
}
// 세션에 사용자 정보 저장
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
};
res.json({ message: "로그인 성공" });
});
// 세션 인증 미들웨어
function isAuthenticated(req, res, next) {
if (req.session.user) {
next();
} else {
res.status(401).json({ message: "로그인이 필요합니다" });
}
}
// 보호된 라우트
app.get("/profile", isAuthenticated, (req, res) => {
res.json({ user: req.session.user });
});
// 로그아웃
app.post("/logout", (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ message: "로그아웃 실패" });
}
res.json({ message: "로그아웃 성공" });
});
});
3. OAuth 2.0 / 소셜 로그인
외부 서비스(Google, Facebook 등)의 인증 시스템을 활용합니다.
const express = require("express");
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const session = require("express-session");
const app = express();
// 세션 설정
app.use(
session({
secret: "your_session_secret",
resave: false,
saveUninitialized: false,
})
);
// Passport 초기화
app.use(passport.initialize());
app.use(passport.session());
// Google OAuth 설정
passport.use(
new GoogleStrategy(
{
clientID: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
callbackURL: "http://localhost:3000/auth/google/callback",
},
function (accessToken, refreshToken, profile, done) {
// 사용자 찾기 또는 생성 (실제 구현에서는 데이터베이스 연동)
const user = {
id: profile.id,
displayName: profile.displayName,
email: profile.emails[0].value,
};
return done(null, user);
}
)
);
// 세션 직렬화/역직렬화
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
// 사용자 ID로 사용자 검색 (실제 구현에서는 데이터베이스 쿼리)
const user = { id: id, displayName: "Test User" }; // 예시
done(null, user);
});
// Google 로그인 라우트
app.get(
"/auth/google",
passport.authenticate("google", { scope: ["profile", "email"] })
);
// Google 로그인 콜백
app.get(
"/auth/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
function (req, res) {
// 성공 시 리디렉션
res.redirect("/profile");
}
);
// 인증 확인 미들웨어
function isLoggedIn(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect("/login");
}
// 보호된 프로필 페이지
app.get("/profile", isLoggedIn, (req, res) => {
res.json({ user: req.user });
});
// 로그아웃
app.get("/logout", (req, res) => {
req.logout();
res.redirect("/");
});
권한 부여(Authorization) 방법
1. 역할 기반 접근 제어(RBAC)
사용자 역할에 따라 권한을 관리합니다.
// 권한 미들웨어
function checkRole(role) {
return (req, res, next) => {
// JWT 또는 세션에서 사용자 정보 확인
if (!req.user) {
return res.status(401).json({ message: "인증이 필요합니다" });
}
if (req.user.role !== role) {
return res.status(403).json({ message: "접근 권한이 없습니다" });
}
next();
};
}
// 관리자만 접근 가능한 라우트
app.get("/admin", authenticateToken, checkRole("admin"), (req, res) => {
res.json({ message: "관리자 대시보드", adminData: "민감한 데이터" });
});
// 일반 사용자 라우트
app.get("/user", authenticateToken, checkRole("user"), (req, res) => {
res.json({ message: "사용자 프로필", userData: "개인 데이터" });
});
2. 속성 기반 접근 제어(ABAC)
세부적인 속성(사용자 속성, 리소스 속성, 환경 조건 등)에 따라 접근을 제어합니다.
// 사용자가 리소스의 소유자인지 확인하는 미들웨어
function isResourceOwner(req, res, next) {
const resourceId = req.params.id;
// 데이터베이스에서 리소스 가져오기 (예시)
getResource(resourceId)
.then((resource) => {
if (!resource) {
return res.status(404).json({ message: "리소스를 찾을 수 없습니다" });
}
if (resource.ownerId !== req.user.id) {
return res
.status(403)
.json({ message: "이 리소스에 대한 권한이 없습니다" });
}
// 리소스를 요청 객체에 추가
req.resource = resource;
next();
})
.catch((err) => {
res.status(500).json({ message: "서버 오류", error: err.message });
});
}
// 문서 수정 라우트
app.put("/documents/:id", authenticateToken, isResourceOwner, (req, res) => {
// 리소스 수정 로직
updateResource(req.params.id, req.body)
.then(() => {
res.json({ message: "문서가 업데이트되었습니다" });
})
.catch((err) => {
res.status(500).json({ message: "서버 오류", error: err.message });
});
});
3. 정책 기반 접근 제어
정책 엔진을 사용하여 복잡한, 동적인 권한 부여 규칙을 관리합니다.
const { AbilityBuilder, Ability } = require("@casl/ability");
// 사용자 역할에 따른 권한 정의
function defineAbilitiesFor(user) {
const { can, cannot, build } = new AbilityBuilder(Ability);
if (user.role === "admin") {
can("manage", "all"); // 모든 리소스에 대한 모든 권한
} else {
can("read", "Post"); // 모든 게시물 읽기 가능
can("create", "Post"); // 게시물 생성 가능
can("update", "Post", { authorId: user.id }); // 자신이 작성한 게시물만 수정 가능
can("delete", "Post", { authorId: user.id }); // 자신이 작성한 게시물만 삭제 가능
can("read", "Comment");
can("create", "Comment");
can("update", "Comment", { authorId: user.id });
can("delete", "Comment", { authorId: user.id });
}
return build();
}
// 권한 미들웨어
function checkAbility(action, subject) {
return (req, res, next) => {
const ability = defineAbilitiesFor(req.user);
const resourceId = req.params.id;
if (resourceId) {
// 리소스 정보 가져오기 (예시)
getResource(subject, resourceId)
.then((resource) => {
if (!resource) {
return res
.status(404)
.json({ message: "리소스를 찾을 수 없습니다" });
}
if (ability.can(action, subject, resource)) {
req.resource = resource;
next();
} else {
res.status(403).json({ message: "접근 권한이 없습니다" });
}
})
.catch(next);
} else {
// 리소스 ID가 없는 경우 (예: 목록 조회)
if (ability.can(action, subject)) {
next();
} else {
res.status(403).json({ message: "접근 권한이 없습니다" });
}
}
};
}
// 라우트 구현
app.get(
"/posts",
authenticateToken,
checkAbility("read", "Post"),
(req, res) => {
// 게시물 목록 조회 로직
}
);
app.put(
"/posts/:id",
authenticateToken,
checkAbility("update", "Post"),
(req, res) => {
// 게시물 수정 로직
// req.resource에는 이미 권한 검사를 통과한 게시물이 포함되어 있음
}
);
보안 모범 사례
1. 비밀번호 해싱
사용자 비밀번호는 항상 해싱하여 저장해야 합니다.
const bcrypt = require("bcrypt");
async function registerUser(username, password) {
try {
// 솔트 생성 및 비밀번호 해싱
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// 사용자 저장 (예시)
const user = {
username,
password: hashedPassword,
// 기타 필드
};
// 데이터베이스에 사용자 저장
return saveUser(user);
} catch (error) {
throw new Error("사용자 등록 실패: " + error.message);
}
}
2. HTTPS 사용
프로덕션 환경에서는 항상 HTTPS를 사용하여 전송 중인 데이터를 암호화해야 합니다.
const express = require("express");
const https = require("https");
const fs = require("fs");
const app = express();
// HTTPS 옵션
const options = {
key: fs.readFileSync("server.key"),
cert: fs.readFileSync("server.cert"),
};
// HTTPS 서버 생성
https.createServer(options, app).listen(443, () => {
console.log("HTTPS 서버가 포트 443에서 실행 중입니다");
});
3. 환경 변수 사용
민감한 정보는 코드에 직접 포함하지 말고 환경 변수로 관리해야 합니다.
// .env 파일
// JWT_SECRET=your_very_strong_secret_key
// DB_PASSWORD=your_database_password
// 애플리케이션 코드
require("dotenv").config();
const jwt = require("jsonwebtoken");
// 환경 변수 사용
const token = jwt.sign({ userId: 123 }, process.env.JWT_SECRET);
4. 토큰 관리
JWT 토큰의 보안을 강화합니다.
// 토큰 생성 시 보안 옵션 설정
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: "15m", // 짧은 만료 시간
audience: "your-app.com",
issuer: "your-api.com",
subject: user.id.toString(),
});
// 토큰 검증 시 같은 옵션 사용
jwt.verify(
token,
process.env.JWT_SECRET,
{
audience: "your-app.com",
issuer: "your-api.com",
subject: user.id.toString(),
},
(err, decoded) => {
if (err) {
// 검증 실패 처리
} else {
// 검증 성공 처리
}
}
);
고급 인증 구현 예: 리프레시 토큰
액세스 토큰과 리프레시 토큰을 사용하여 보안을 강화하는 방법:
const express = require("express");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const app = express();
app.use(express.json());
// 리프레시 토큰 저장소 (실제 구현에서는 데이터베이스 사용)
const refreshTokens = {};
// 로그인 라우트
app.post("/login", async (req, res) => {
// 사용자 인증 (생략)
const user = { id: 123, username: "testuser" };
// 액세스 토큰 생성 (짧은 수명)
const accessToken = jwt.sign(
{ id: user.id, username: user.username },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" }
);
// 리프레시 토큰 생성 (긴 수명)
const refreshToken = crypto.randomBytes(40).toString("hex");
// 리프레시 토큰 저장
refreshTokens[refreshToken] = {
userId: user.id,
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7일
};
res.json({
accessToken,
refreshToken,
});
});
// 토큰 갱신 라우트
app.post("/refresh-token", (req, res) => {
const { refreshToken } = req.body;
// 리프레시 토큰 검증
if (!refreshToken || !refreshTokens[refreshToken]) {
return res.status(401).json({ message: "유효하지 않은 리프레시 토큰" });
}
const tokenData = refreshTokens[refreshToken];
// 만료 확인
if (tokenData.expires < Date.now()) {
delete refreshTokens[refreshToken];
return res.status(401).json({ message: "리프레시 토큰이 만료됨" });
}
// 사용자 정보 가져오기 (실제 구현에서는 데이터베이스 쿼리)
const user = { id: tokenData.userId, username: "testuser" };
// 새 액세스 토큰 생성
const accessToken = jwt.sign(
{ id: user.id, username: user.username },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" }
);
res.json({ accessToken });
});
// 로그아웃 라우트
app.post("/logout", (req, res) => {
const { refreshToken } = req.body;
// 리프레시 토큰 삭제
if (refreshToken && refreshTokens[refreshToken]) {
delete refreshTokens[refreshToken];
}
res.json({ message: "로그아웃 성공" });
});
인증과 권한 부여는 웹 애플리케이션의 보안에 있어 핵심적인 요소입니다. Node.js에서는 다양한 방법과 라이브러리를 활용하여 안전하고 확장 가능한 인증 시스템을 구현할 수 있습니다. 사용 사례와 요구 사항에 맞는 방법을 선택하고, 보안 모범 사례를 따르는 것이 중요합니다.