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에서는 다양한 방법과 라이브러리를 활용하여 안전하고 확장 가능한 인증 시스템을 구현할 수 있습니다. 사용 사례와 요구 사항에 맞는 방법을 선택하고, 보안 모범 사례를 따르는 것이 중요합니다.

results matching ""

    No results matching ""