출퇴근 시스템 - JWT API 구현

반응형
SMALL

사용자가 로그인 시 JWT를 발급받는 절차를 구현하기 앞서, JWT의 기초 및 웹 인증 전반의 배경 이해가 부족하다면 아래 글을 먼저 읽고 오길 권한다. 실무에서 세션/쿠키와 토큰의 차이, Stateless 환경에서의 식별 문제를 정확히 이해하는 것이 이후 설계 품질을 좌우한다.

2025.09.13 - [cs정리/java] - 쿠키, 세션, 토큰: 웹 인증 방식의 대해

 

쿠키, 세션, 토큰: 웹 인증 방식의 대해

💡웹 애플리케이션에서 가장 중요한 기능 중 하나는 사용자 인증(Authentication)이다. 하지만 HTTP는 본질적으로 Stateless하기 때문에, 한 번 로그인했다고 해서 서버가 이후 요청을 같은 사용자로 인

ha-vlog.tistory.com


Node.js로 API 작성하기

이전에는 단순히 폼 로그인까지만 구현해 두었고, 이번에는 그 폼에서 받은 자격정보를 커스텀 API로 전달하여 인증을 수행하도록 만든다.

API는 Node.js로 작성했다. Node.js는 브라우저 밖에서 자바스크립트를 실행하는 런타임으로, 이벤트 루프 기반 논블로킹 I/O를 제공한다. 덕분에 동시성이 필요한 네트워크 API 서버에 적합하다. npm 생태계를 통해 필요한 모듈을 빠르게 조합할 수 있다는 점도 생산성을 높인다. 단, CPU 연산이 무거운 작업에는 이점이 적다. 이번 프로젝트는 간단한 사용자 인증·조회 중심이므로 Node.js로 구현하였다.


인증 흐름

  1. 클라이언트가 /login 엔드포인트에 사번(num), 비밀번호(password)를 전송
  2. 서버가 Azure AD인증된 앱으로 Access Token 요청
  3. 발급받은 Token으로 Dataverse에서 사용자 조회
  4. 일치하면 서버가 자체 JWT 생성 후 반환
  5. 클라이언트는 이 JWT를 사용해 이후 요청을 인증 (Stateless 구조)

JWT는 서명된 토큰이므로 서버는 세션 저장소 없이도 사용자를 식별할 수 있다. 구조가 단순하고 수평 확장에 유리하다.


구현 코드

환경 변수는 .env 파일로 관리한다.
로깅을 상세히 남겨 배포 환경에서 문제 파악을 쉽게 했다.
코드는 GitHub에 업로드 후 Render를 통해 배포할 예정이다.

const express = require('express');
const axios = require('axios');
const jwt = require('jsonwebtoken');
const cors = require('cors');
require('dotenv').config();

const SECRET = process.env.SECRET;
const app = express();

app.use(express.json());
app.use(cors());

console.log("🔧 서버 시작됨");
console.log("▶ TENANT_ID:", process.env.TENANT_ID);
console.log("▶ CLIENT_ID:", process.env.CLIENT_ID);
console.log("▶ CLIENT_SECRET:", process.env.CLIENT_SECRET ? process.env.CLIENT_SECRET : "[MISSING]");
console.log("▶ RESOURCE:", process.env.RESOURCE);
console.log("▶ SECRET:", SECRET ? "[OK]" : "[MISSING]");

async function getAccessToken() {
  const url = `https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/v2.0/token`;
  const params = new URLSearchParams();
  params.append('client_id', process.env.CLIENT_ID);
  params.append('client_secret', process.env.CLIENT_SECRET);
  params.append('grant_type', 'client_credentials');
  params.append('scope', `${process.env.RESOURCE}/.default`);

  console.log("🔐 Azure AD 토큰 요청 중:", url);

  try {
    const res = await axios.post(url, params.toString(), {
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
    });

    console.log("✅ Access Token 발급 성공");
    return res.data.access_token;
  } catch (err) {
    console.error("❌ Access Token 요청 실패:", err.response?.data || err.message);
    throw new Error('토큰 요청 실패');
  }
}

async function findUser(num, pwd, token) {
  const url = `${process.env.RESOURCE}/api/data/v9.2/cre4e_employees?$filter=cre4e_employee_number eq '${num}' and cre4e_employee_pwd eq '${pwd}'`;

  console.log("📡 Dataverse 사용자 조회 요청:", url);

  try {
    const res = await axios.get(url, {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/json'
      }
    });

    console.log("📦 사용자 조회 응답:", res.data);
    return res.data.value[0];
  } catch (err) {
    console.error("❌ 사용자 조회 실패:", err.response?.data || err.message);
    throw new Error('사용자 조회 오류');
  }
}

app.post('/login', async (req, res) => {
  console.log("🚀 /login API 호출됨");
  console.log("📨 요청 헤더:", req.headers);
  console.log("📨 요청 바디:", req.body);

  const { num, password } = req.body;

  if (!num || !password) {
    console.warn("⚠️ 로그인 정보 누락:", { num, password });
    return res.status(400).json({ error: 'num 또는 비밀번호가 누락되었습니다.' });
  }

  try {
    const accessToken = await getAccessToken();
    console.log("🔑 AccessToken 일부:", accessToken?.slice(0, 30) + "...");

    const user = await findUser(num, password, accessToken);
    console.log("👤 사용자 객체:", user);

    if (!user) {
      console.warn("⚠️ 사용자 없음: 로그인 실패");
      return res.status(401).json({ error: 'ID 또는 비밀번호가 일치하지 않습니다.' });
    }

    const jwtToken = jwt.sign(
      {
        num: user.cre4e_employee_number,
        name: user.cre4e_employee_name,
        role: user.cre4e_employee_department,
        id: user.cre4e_employee_id
      },
      SECRET,
      { expiresIn: '1h' }
    );

    console.log("✅ 로그인 성공, JWT 생성됨");

    res.json({
      token: jwtToken,
      user: {
        num: user.cre4e_employee_number,
        name: user.cre4e_employee_name,
        role: user.cre4e_employee_department,
        id: user.cre4e_employee_id
      }
    });
  } catch (err) {
    console.error("❌ 로그인 실패:", err.message);
    res.status(500).json({ error: '서버 오류 또는 인증 실패' });
  }
});

app.listen(3000, () => {
  console.log('✅ 로그인 API 서버 실행됨: http://localhost:3000/login');
});

 

환경 변수 구성

아래 변수만 준비하면 된다.

  • TENANT_ID, CLIENT_ID, CLIENT_SECRET: Azure AD 앱 등록(App Registration)으로 발급
  • RESOURCE: Dataverse 리소스 URL (예: https://<org>.crm.dynamics.com)
  • SECRET: JWT 서명용 비밀키

👉 배포 환경에서는 절대 로그에 노출되지 않도록 주의한다.


Azure 앱 등록

Azure AD에 앱을 등록해야 토큰 발급이 가능하다. 이유는 다음과 같다:

  1. 애플리케이션 신원 등록 → Azure AD가 해당 서버를 “공식 애플리케이션”으로 식별
  2. 자격 증명 발급 → CLIENT_ID, CLIENT_SECRET을 받아야 토큰 요청 가능
  3. 권한 위임 → Dataverse API 권한을 추가해야 실제 데이터 접근 가능
  4. 보안·신뢰 확보 → 잘못된 앱이나 키를 사용하면 Azure가 거부

앱 등록은 여기서 하면된다.

https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview

 

Microsoft Azure

 

portal.azure.com

추가 버튼을 눌러 앱을 등록해준다. 

  1. 이름 (Name)
    • 이 애플리케이션의 이름을 입력한다.
    • 자유롭게 지정 가능하며, 이후 Azure Portal에서 앱을 식별할 때 사용된다.
    • 예: JwtLoginApi
  2. 지원되는 계정 유형 (Supported account types)
    • 누가 이 앱에 로그인/토큰 요청을 할 수 있는지를 결정한다.
    • 옵션별 의미:
      • 이 조직 디렉터리의 계정만(단일 테넌트) → 현재 조직 계정만 접근 가능. 내부 전용 서비스에 적합
      • 모든 조직 디렉터리 계정(다중 테넌트) → 다른 조직 Azure AD 계정도 접근 가능. SaaS 제공 시 사용
      • 조직 디렉터리 계정 + 개인 Microsoft 계정 → Azure AD 계정 + Outlook/Hotmail 등 개인 계정 허용
      • 개인 Microsoft 계정만 → 기업용 AD 계정 불가, 개인 계정만 허용
    • 👉 현재 시나리오는 우리 조직 Dataverse와 연동하므로 단일 테넌트를 선택한다.
  3. 리디렉션 URI (Redirect URI)
    • 클라이언트 인증 방식에 따라 필수/선택이 달라진다.
    • 현재 구현은 Client Credentials Flow이므로 사용자가 로그인 후 돌아오는 Redirect 과정이 없다. 따라서 이 항목은 비워둔다.
    • 다만, 나중에 Authorization Code Flow(예: PowerApps 포털 사용자 로그인) 같은 사용자 직접 로그인 플로우를 구현한다면 반드시 Redirect URI를 등록해야 한다.
    • 이 URI는 로그인 성공 후 Azure가 인증 코드를 전달할 콜백 주소이므로 단순한 화면 이동이 아니라 토큰 발급을 위한 중요한 엔드포인트이다.

등록을 누르면 이런 화면이 나오는데 여기서,

  • 애플리케이션(클라이언트) ID → CLIENT_ID
  • 디렉터리(테넌트) ID → TENANT_ID
  • 클라이언트 자격 증명(비밀키) 추가 버튼 → CLIENT_SECRET 발급

이 세 가지가 Node.js 서버에서 Azure AD 토큰을 요청할 때 반드시 필요하다.

 

 

 

여기서 추가를 누르면 표에 있는 값을 복사하면 비밀키가 생겨난다. 

SECRET = "MY_SECRET_KEY";
CLIENT_SECRET= 클라이언트 자격 증명 값
TENANT_ID= 디렉터리(테넌트) ID
CLIENT_ID= 애플리케이션(클라이언트) ID
RESOURCE=https://자신환경.crm21.dynamics.com

 

 

이렇게 .ENV파일을 만들어 값을 넣어준다.


Render 배포

Render는 GitHub/GitLab에 Push만 하면 자동으로 빌드·배포해주는 PaaS다.
무료 SSL, 기본 CDN, 자동 CI/CD가 제공되며, 간단한 API 배포에 적합하다.

  1. Render Dashboard → Add New → Web Service
  2. GitHub Repository 연결
  3. 속성 설정
    • Build Command: npm install
    • Start Command: node index.js (또는 npm start)
    • Instance Type: Free (512MB, 1CPU)
  4. Environment 탭에서 .env 값 등록
  5. Deploy 버튼 클릭 → 자동 배포

https://dashboard.render.com/login

 

Cloud Application Hosting for Developers | Render

Render is a unified cloud to build and run all your apps and websites with free SSL, global CDN, private networks and automatic deploys from Git.

dashboard.render.com

 

 

 

이제 속성을 설정해준다.

 

 

 

반응형
LIST