학생 관리 시스템 #7 - API 인증

프로젝트 코드 : https://github.com/walrus811/Project-A-Server

보안, 언제나 어려운 결정

보안과 사용 편의성은 반비례 관계다. 보안이 단단해지는 만큼 쓰기는 불편해지고 쓰기 편해지면 보안이 약해진다. 게다가 세상에 완벽한 보안이란 게 존재할 수 있을까? 모든 건 선택의 문제다.

초기에 이 앱은 학생 페이지에는 보안을 전혀 넣지 않을 생각이었다. 왜냐하면 학생 페이지에는 단어시험 정보 말고는 API의 쓰기 연산(POST,PUT,DELETE)이 전혀 들어가지 않기 때문이다. 단어시험 정보는 솔직히 말해 그렇게 민감한 정보도 아니다. 그래서 학생별로 고유 URL을 부여해 학생이 스스로 책임지게 만들고 사용 편의를 극대화할 생각이었다.

이 정책을 유지할지 말지는 아직도 고민 중이다. 아무래도 퍼블릭으로 오픈하는 서비스인데 보안 요소가 하나도 없다는 게 걸린다. 또한 요즘에는 브라우저 레벨에서 키체인이 지원되기도 하니 인증 단계가 있어도 그렇게 불편하지 않을 것이다.

걱정되는 건 관리자 페이지다. 관리자 페이지는 시스템 내에서 모든 걸 다 할 수 있으므로 굉장히 강력한 보안을 적용하고 싶다. 아예 네이티브 앱으로 만들면 편하겠지만 너무 많은 시간과 비용이 든다. 편리하면서도 확실한 인증수단이 있으면 좋을 것이다. 물리적인 QR 코드 스티커? 장비 인증? MFA? 다양한 방법을 고를 수 있다. 하지만 앱의 규모와 사용례를 생각할 때 이런 보안까지 적용하는 게 옳은 걸까, 라는 생각이 든다. 이런 추가적인 인증방식은 나중에 릴리즈할 때 배포 인프라의 도움을 받아도 되니 일단 생각은 해두고 더는 고민하지 않기로 했다.

어쨌든, 기본적인 API 인증은 필요하다. 구글링해서 바로 나오는 건 세션과 JWT 방식일 것이다. 세션 방식은 서버에서 인증 정보를 관리한다. 클라이언트는 서버가 공유하는 세션키로 인증 정보에 접근할 수 있다. JWT는 서버에서 발급하는 토큰을 기반으로 인증하며 구현에 따라 다르지만, 기본적으로 서버는 딱히 이 토큰을 관리하지 않는다(관리하기 시작하면 세션 방식과 거의 비슷해진다). 보통 세션은 서버에서 정보를 관리하므로 상대적으로 보안에 유리할 수 있는 요소가 있지만, 수평적인 확장성이 떨어지고 역시 관리에 따른 서버 부하가 존재한다. JWT는 상대적으로 보안이 약하지만, 확장성이 좋고 서버 부하가 없다고 할 수 있다. 물론 JWT 자체가 세션 키 정보보다는 길 수 있기에 트래픽은 조금 많이 잡아먹을 수도 있다.

JWT는 jwt.io에서 발급하는 핸드북에 따르면 jot으로 발음한다고 한다. 한국에서는 삼가는 편이 좋을 거 같다.

node는 논블로킹 싱글 스레드로 동작하기 때문에 실제 병렬성을 주어 서비스를 운용하려면 멀티 프로세스밖에 답이 없다. 즉, 세션을 관리하려면 프로세스간 빠른 공용 저장소를 두어야한다. Redis 같은 걸 많이 쓴다고 한다. 그런데 나는 여기서 시스템에 한 계층을 더 만들어 프로비전에 부담을 지우기 싫다. 그래서 JWT를 고를 것이다. 이번 기회에 한 번 경험해 보고 싶기도 하다.

JWT 인증 추가

JWT에 대한 자세한 사항은 JSON Web Token 소개에 몹시 설명이 잘 되어있으니 참조하기를 바란다. 몹시 간단한 구조이고 해당 문서도 어려움 없이 5분이면 읽는다. 일단 정확히 어떻게 인증체계를 구성할지 결정한 게 아니므로 미들웨어 수준에서 구현할 것이다.

일단 User 모델을 정의해보자. src/resources에 다음과 같이 기본 리소스 관련 소스 구성을 추가해주자.

src/resource/user
    user.controller.ts
    user.middleware.ts
    user.model.ts
    user.router.ts

아직 인증체계가 정확히 잡힌 게 아니므로 모델만 추가한다.

src/resources/users/user.model.ts

import { ObjectId } from 'mongodb';

export interface User
{
  email: string;
  password: string;
  studentId: ObjectId;
}

차후 유연성을 위해 studentuser를 분리한다. 사용자가 꼭 학생만 있진 않을 확률이 높으니까. 다음은 토큰이나 패스워드 생성 시 필요한 정보를 환경설정에 담는다. env_default.env에 복사될 내용이므로 값 외에 키는 같다. 실제 릴리즈할 때는 env_default에 있는 값은 사용해선 안 된다.

.env_default

PORT=3000
MDB_URL=mongodb://root:12345@localhost:27017/
NODE_ENV=development
REQ_BODY_SIZE_LIMIT=2mb
JWT_ACCESS_SECRET=0f26de9e4cc94bd065d40b3ec785f4c2
JWT_ACCESS_TOKEN_LIFE=3600
HASH_SECRET=411a7d0cc1c59731b819990cba17a88b
COOKIE_SECRET=f1a507164b730b56f925c9f299b0f7c1

REQ_BODY_SIZE_LIMIT은 중간에 추가되었는데 express.json 미들웨어의 최대 바디 파싱 용량을 설정한다. 차후 필요해 추가해두었다.

인증에 필요한 정보는 JWT_ACCESS_SECRET, JWT_ACCESS_TOKEN_LIFE, HASH_SECRET, 그리고 COOKIE_SECRET이다. 각각 다음과 같은 역할을 한다.

  • JWT_ACCESS_SECRET : JWT 토큰을 만드는 비밀키
  • JWT_ACCESS_TOKEN_LIFE : JWT 토큰의 만료 시간(초)
  • HASH_SECRET : 패스워드 해쉬 솔트 키
  • COOKIE_SECRET : 서명된 쿠키에 사용될 키

해당 정보를 .env로부터 다루는 코드부터 추가하자.

src/utils/appVars.ts

//...

export function getAccessSecret(req: Request): string
{
  return req.app.get("accessSecret");
}

export function setAccessSecret(app: Application, secret: string)
{
  app.set("accessSecret", secret);
}

export function getAccessTokenLife(req: Request): number
{
  return req.app.get("accessTokenLife");
}

export function setAccessTokenLife(app: Application, lifeSecond: number)
{
  app.set("accessTokenLife", lifeSecond);
}

export function getHashSecret(req: Request): string
{
  return req.app.get("hashSecret");
}

export function setHashSecret(app: Application, secret: string)
{
  app.set("hashSecret", secret);
}
//...

src/server.ts

//...

export async function start()
{
  const url = process.env.MDB_URL;
  try
  {
    //..
    
    if (!process.env.HASH_SECRET)
      return console.error("no specified hash secret!");

    setHashSecret(app, process.env.HASH_SECRET);

    if (!process.env.JWT_ACCESS_SECRET)
      return console.error("no specified jwt secret!");

    setAccessSecret(app, process.env.JWT_ACCESS_SECRET);

    if (!process.env.JWT_ACCESS_TOKEN_LIFE)
      return console.error("no specified token life!");

    const accessTokenLife = parseInt(process.env.JWT_ACCESS_TOKEN_LIFE);

    if (!accessTokenLife)
      return console.error("no specified token life value!");

    setAccessTokenLife(app, accessTokenLife);

    //..
  }
  catch (err)
  {
    //..
  }
}
//...

이제 앱에서 글로벌로 해당 정보를 사용할 수 있다. 글로벌 데이터를 늘리는 건 좋지 못하지만 아직은 감당할 수 있는 수준이다. 토큰 저장에는 쿠키를 사용할 것이다. 다만 쿠키 위조를 방지하기 위해 서명된 쿠키를 사용할 것이며 기본적인 MITM 공격을 막기 위해 secure 옵션을 주고, 스크립트에서 접근 못하게 httpOnly 옵션도 사용할 것이다.

인증 구현을 위한 패키지들을 설치해보자.

$yarn add cookie-parser jsonwebtoken md5
$yarn add -D @types/cookie-parser @types/jsonwebtoken @types/md5

이 패키지들은 다음과 같은 역할을 한다.

  • cookie-parser : 리퀘스트 헤더에 담긴 Cookie를 파싱해 req에 담는 미들웨어. 그냥 쿠키는 req.cookies에, 서명된 쿠키는 req.signedCookies에 담긴다.
  • jsonwebtoken : JWT의 node 구현체다. 여러 구현체가 있지만 이게 제일 잘 나간다. 나머지는 여기서 확인할 수 있다.
  • md5 - md5의 node 구현체다. 패스워드를 해싱하여 저장할 때 쓸 것이다.

토큰 저장소로 서명된 쿠키를 사용할 것이기 때문에 src/server.tscookie-parser 미들웨어를 추가해준다.

src/server.ts

//...
//Middle
app.use(cors());
app.use(express.json({ limit: process.env.REQ_BODY_SIZE_LIMIT || "100kb" }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(morgan("dev"));
//...

서명된 쿠키를 사용할 것이므로 cookieParser에 앞서 .env에 설정한 COOKIE_SECRET를 넣어주면 된다. 당연한 얘기지만 모든 키 정보는 노출되어선 안 된다.

이제 src/utils/mddlewaresauth.ts 파일을 추가해 주자.

src/utils/mddlewares/auth.ts

import { RequestHandler } from "express";
import { User } from "src/resources/users/user.model";
import { getAccessSecret, getAccessTokenLife, getAppName, getHashSecret, getMdb } from "../appVars";
import jwt, { JsonWebTokenError } from 'jsonwebtoken';

export const createToken = (email: string, secret: string, tokenLife: number) =>
{
  return jwt.sign({ user: email }, secret, {
    algorithm: "HS256",
    expiresIn: tokenLife
  });
};

export const signup: RequestHandler = async (req, res, next) =>
{
  try
  {
    if (!req.body.email || !req.body.password ||
      typeof req.body.email !== "string" ||
      typeof req.body.password !== "string"
    )
      return res.status(400).json({ message: `Please use valid email and password` });

    const email = req.body.email;
    const password = req.body.password;
    const passwordHash = md5(password + getHashSecret(req));
    
    const client = getMdb(req);
    const db = client.db(getAppName(req));
    const userCollection = db.collection<Partial<User>>("users");

    const existUser = await userCollection.findOne({ email: req.body.email });
    if (existUser != null)
      return res.status(409).json({ message: `The email already is` });

    const insertBody: Partial<User> = {
      email,
      password: passwordHash,
    };

    const insertResult = await userCollection.insertOne(insertBody);
    if (insertResult.acknowledged)
    {
      const tokenSecet = getAccessSecret(req);
      const tokenLife = getAccessTokenLife(req);
      const token = createToken(email, tokenSecet, tokenLife);

      return res.status(201).cookie("token", token, { signed: true, secure: process.env.NODE_ENV === "production", httpOnly: true }).end();
    }
    else
      next(new Error(`insertOne({${JSON.stringify(insertBody)}}) can't be done with some reasons. please check the DB.`));
  }
  catch (error)
  {
    next(error);
  }
};
//...

일단은 회원가입 코드(signup)이다. 특별한 부분은 없다. 간단히 id(email)와 비밀번호를 체크하고 비밀번호의 해쉬를 만든 다음 DB에 저장한다. 토큰을 발급하고 해당 토큰을 여러 보안 장치(secure, httponly)와 함께 사인된 쿠키에 담아 보낸다. production 모드일 때는 https 서비스를 사용하지 않으므로 잠시 secure는 끄도록 한다.

src/utils/mddlewares/auth.ts

//...
export const signin: RequestHandler = async (req, res, next) =>
{
  try
  {
    if (!req.body.email || !req.body.password ||
      typeof req.body.email !== "string" ||
      typeof req.body.password !== "string"
    )
      return res.status(400).json({ message: `Please use valid email and password` });

    const email = req.body.email;
    const password = req.body.password;
    const passwordHash = md5(password + getHashSecret(req));

    const client = getMdb(req);
    const db = client.db(getAppName(req));
    const userCollection = db.collection<User>("users");

    const existUser = await userCollection.findOne({ email: req.body.email });
    if (existUser == null)
      return res.status(401).json({ message: "You did input wrong email or password" });

    if (existUser.password !== passwordHash)
      return res.status(401).json({ message: "You did input wrong email or password" });

    const tokenSecet = getAccessSecret(req);
    const tokenLife = getAccessTokenLife(req);
    const token = createToken(email, tokenSecet, tokenLife);

    return res.status(200).cookie("token", token, { signed: true, secure: process.env.NODE_ENV === "production", httpOnly: true }).end();
  }
  catch (error)
  {
    next(error);
  }
};
//...

로그인 코드(signin)이다. 이것 또한 특별한 부분은 없다. id와 비밀번호를 체크하고 같은 방식으로 토큰을 발급해준다.

src/utils/mddlewares/auth.ts

//...
export const checkAuthToken: RequestHandler = async (req, res, next) =>
{
  try
  {
    const accessToken = req.signedCookies.token;
    if (!accessToken || typeof accessToken !== "string")
      return res.status(401).end();

    const accessSecret = getAccessSecret(req);
    const jwtPayload = jwt.verify(accessToken, accessSecret);

    if (typeof jwtPayload === "string")
      return res.status(401).end();

    if (!jwtPayload.user)
      return res.status(401).end();

    const client = getMdb(req);
    const db = client.db(getAppName(req));
    const userCollection = db.collection<User>("users");

    const existUser = await userCollection.findOne({ email: jwtPayload.user }, { projection: { password: 0 } });
    if (existUser == null)
      return res.status(401).end();

    res.locals.user = existUser;
    next();
  }
  catch (error)
  {
    if (error instanceof JsonWebTokenError)
      return res.status(401).end();
    next(error);
  }
};

마지막으로 토큰 인증 코드(checkAuthToken)이다. cookie-parser 미들웨어가 알아서 쿠키를 파싱해 req.signedCookies에 넣어주므로 담긴 토큰을 그대로 가져오면 된다. 그리고 jwt 구현체에서 제공하는 verify 함수로 유효성을 검사한다. 이 유효성에는 만료 일자도 포함된다. 만료 일자는 exp 클레임에 포함되는데 이는 곧 살펴볼 것이다. 그리고 jwt의 페이로드에 담긴 사용자 정보를 조회해 이것마저 통과하면 인증이 된 것이다.

다음 미들웨어로 처리를 넘기기전에 res.locals.user에 비밀번호를 제외한 사용자 정보를 담는데 res.locals는 외부에 노출되지 않는 미들웨어간 공용변수에 사용할 수 있는 필드다. 나중에 필요할지도 몰라 추가해두었다. 필요 없다면 꼭 지울 것이다.

잘 동작하는지 확인하기 위해 해당 미들웨어들을 GET - /check, POST - /signin, POST - /signup 라우터에 추가해 보자. 해당 라우터는 모두 임시다.

src/server.ts

//...
app.get('/check', checkAuthToken, (req, res, next) =>
{
  return res.status(200).end();
});
app.post('/signin', signin);
app.post('/signup', signup);
//...

계정을 생성해보자. 여기서는 쿠키값 때문에 Postman을 사용한다.

{
    "email": "[email protected]",
    "password" : "12345"
}

위 정보를 바디에 담아 POST - /signup API를 날렸다. Set-Cookie 리스폰스 헤더에서 보듯이 토큰이 발급되었다. 로그인도 해보자.

같은 정보를 담아 POST - /siginin API를 날렸다. 마찬가지로 발급된 토큰이 쿠키로 날아온다. 이제 이 쿠키를 이용해 인증을 해보자.

인증도 잘 된다. 마지막으로 토큰 내용을 살펴보자.

페이로드도 적절히 들어가 있다. 이 중 iatexp표준에 등록된 클레임들이다. jwt 구현체가 알아서 만들어준다. 추가적인 페이로드를 담고 싶을 때는 표준에 등록된 클레임의 이름과 겹치지 않게 해준다.

jwt는 이렇게 필요한 정보를 페이로드에 투명하게 담지만, 마지막에 서명도 같이 담기 때문에 비밀키를 모르면 위조할 수 없다. 물론 토큰 자체가 외부로 노출 되면 답이 없다. 토큰 자체의 관리는 결국 클라이언트의 부담이 된다. 대신 서버는 편하다. 하지만 이는 세션 방식도 똑같이 지는 부담이다. 그래서 요즘 나오는 앱들을 보면 추가적인 인증 요소(홍채, 지문, 얼굴, 물리키 등)를 인증 과정에 추가하는 MFA(Multi-Factor-Authentication) 방식을 주로 사용한다. 사용자만 가지고 있는 정보를 인증에 담으므로 얼굴을 복사하는 등 SF 영화급 스케일의 일이 벌어지지 않는 한 결코 인증 정보가 새어 나갈 일이 없다. 그런 지원을 받지 못하는 환경에서 개발하기 때문에 여러모로 아쉽다.

마무리

정확히 인증 체계를 추가하진 않았지만 필요할 때 언제든지 JWT 인증을 사용할 수 있게 미들웨어를 추가해두었다. 다음은 관리자 페이지를 만들 생각이다. 그러면서 서버도 필요한 기능을 추가해 나갈 것이다. 혼자 개발하기 때문에 개발하면서 생기는 문제가 피드백된다. 지금까지의 결과물은 with-jwt에서 확인이 가능하다.

$git checkout tags/with-jwt -b main

프로젝트 코드 : https://github.com/walrus811/Project-A-Server

references

좋은 웹페이지 즐겨찾기