[SOPT 세미나] 서버 파트 5차 세미나 회고, Middleware를 배웠다! API 명세서도 써봤다!

SSO·2022년 5월 26일
0

SOPT 30기

목록 보기
6/9

👩‍💻 5차 세미나 회고

우와 벌써 5차 세미나다-! 시간이 너무 슝슝 가버리는 기분 😵 이번 세미나에서는 미들웨어, 유저 인증, API 명세서 쓰는 법을 배웠는데 막 엄청 수월하게 따라간 건 아니었지만 5차 세미나는 끝까지 따라가는데 성공해서 뿌듯했다 :) 내용도 알찼다 ㅎㅎㅎㅎ 인증에 관한 내용이 이렇게 많을 줄은 몰랐다 @@


💜 Middleware (미들웨어)

요청과 응답의 중간
요청과 응답을 조작하여 기능을 추가하기도 하고, 나쁜 요청은 걸러내기도 함

Node.js 서버에서는 에러 핸들러, 라우터, 유저 인증 등을 사용


💜 Authentication (인증)

사용자가 자신임을 주장하는 것으로, 사용자가 맞는지 확인하는 절차

아무런 주체가 막 보내는 API 요청을 모두 허용해도 될까? 아니다. 허용한 주체에게만 요청을 허용해줘야 보안적으로 문제가 발생하지 않을 것이다. 이렇게 미들웨어에서는 요청의 옳고 그름을 판단하기 위해서 인증을 사용한다.

Stateless Protocol (무상태 프로토콜)
모든 요청이 상호 독립적
서버가 request, response 간에 어떠한 데이터도 보존하지 않음
중간에 요청이 다른 서버로 들어가도 전혀 문제 없음

미들웨어는 위와 같은 무상태 프로토콜을 통해 인증을 진행해야 한다. 때문에 로그인 같이 인증 과정을 거쳤더라도 그 다음 요청과는 독립적이다.

Authorization (인가)
사용자가 특정 자원에 대해 접근 권한이 있는지 확인하는 절차

개인 정보, 로그인 시 열람 가능한 정보 등

로그인 후에도 특정한 자원에 접근하려고 할 때 인가를 통해 접근 권한이 있는지 판단 후 자원에 접근할 수 있도록 한다.


그럼 인증은 어떤 방식으로 진행할 수 있을까? 다양한 방식들이 있는데 그 중 Cookie, Session, Token, JWT에 대해 배웠다.


클라이언트 로컬에 저장되는 키(key)와 값(value)이 쌍으로 들어있는 작은 데이터 파일

쿠키는 다음과 같은 특징들이 있다.

  • 단순한 Key-Value 쌍을 가지며, 클라이언트 로컬에 저장된다.
  • 일정 시간동안 저장할 수 있다.
  • 서버로부터 쿠키가 들어오면 웹 브라우저는 쿠키를 저장해두었다가 요청 시 브라우저가 자동으로 쿠키를 같이 보낸다. (ex. 로그인 기록)
  • 쿠키는 요청과 응답의 헤더(header)에 저장된다.
  • 모바일 앱에서는 거의 사용이 불가능하다.

💜 Session (세션)

일정 시간 동안 같은 브라우저로부터 들어오는 일련의 요구를 하나의 상태로 보고, 그 상태를 유지하는 기술

세션은 다음과 같은 특징들이 있다.

  • 웹 브라우저를 통해 웹 서버에 접속한 이후로 브라우저를 종료할 때까지 유지되는 상태이다.
  • 클라이언트는 발급 받은 세션 ID를 서버 메모리에 저장한다.
  • 서버가 재시작되면 세션 데이터가 사라진다. (메모리 리셋)
  • 하나의 사용자가 여러 로그인 정보를 가지는 경우에 유용하다.
  • 세션은 서버의 자원을 사용하기 때문에, 무분별하게 만들다보면 메모리가 감당할 수 없어질 수 있고 속도도 느려질 수 있다.

💜 Token (토큰)

클라이언트가 인가 인증에 승인받으면 접근할 수 있는 특정 토큰을 발급 받을 수 있다. 발급 받은 토큰을 서버로 넘기면 접근 가능한 토큰인지 확인하고 보호받는 자원을 넘겨준다.

토큰은 다음과 같은 특징들을 가진다.

보안성: 정보가 담긴 데이터(Json)을 암호화
Self-Contained: JWT가 자체적으로 모든 정보를 포함
확장성: 인증 저장소가 불필요


💜 JWT (Json Web Token)

두 개체 사이에서 JSON 객체를 사용하여 정보를 안전하게 전달한다.

https://jwt.io/
🖕 위 사이트에서 암호화(Encoding)된 JWT를 반환받을 수 있다.

Header: JWT 토큰 유형 해시 알고리즘
Payload: 클라이언트 정보
Signature: 서명 정보

위와 같은 방식으로 작성한 후 인코딩 된 JWT를 받으면 된다.

일반적으로 토큰은 다음과 같은 형식으로 헤더의 Authorization(인가) 필드에 담긴다.

Authorization : <_type><_credentials>

type: 인증 타입. 전송 받은 인증 타입에 따라 토큰을 다르게 처리한다.

대표적인 인증 타입은 다음과 같다.

Basic: 사용자의 아이디와 암호를 Base64로 인코딩한 값을 토큰으로 사용
Bearer: JWT 또는 OAuth 인증
Digest: 서버에서 난수 데이터 문자열을 클라이언트로 전송. 클라이언트는 사용자 정보와 nonce를 포함하는 해시 값을 사용하여 응답함
HOBA: 전자 서명

이렇게 인증 타입이 많은 지 몰랐다 (와웅) 보통 Basic이나 Bearer 타입을 많이들 쓰는 것 같다 !


💜 API 명세서

클라이언트에게 API 명세서를 제공해야 만든 API를 사용할 수 있다. 때문에,

누가 봐도 이해할 수 있도록 명확하고 직관적이어야 한다.
내부적으로 어떻게 구현되어 있는지 몰라도 사용 가능해야 한다.

위와 같은 특징을 지녀야 한다!

또한 명세서를 작성할 수 있는 도구는 swagger, postman, notion, github wiki 등 다양하다.

주로 notion을 많이 사용하는데, swagger에 익숙해지면 제일 편하다고 들었다. 시간이 될 때 swagger도 연습해봐야겠다 😊

구성 요소는 다음과 같이 이루어진다.

  • API 이름
  • HTTP Method (POST, GET, PUT, DELETE)
  • Content-Type
  • Request Header, Body, Params, Query
  • Response Body (Success, Fail)

💜 API 실습 : 인가 인증(auth) 추가

yarn add jsonwebtoken
yarn add -D @types/jsonwebtoken

실습하기 전에, JWT를 사용하기 위해 위와 같이 설치해주자.

> src/interfaces/common/JwtPayloadInfo.ts


import mongoose from "mongoose"

export interface JwtPayloadInfo {
    user : {
        id: mongoose.Schema.Types.ObjectId
    }
}

JWT 토큰 객체 안에 담아서 보내줄 정보를 JwtPayloadInfo 인터페이스 파일로 다음과 같이 작성해주자. (위에서는 유저의 id 정보를 보내준다.)

> .env


JWT_SECRET=<JWT에서 발급받은 인코딩된 JWT >
JWT_ALGO='RS256'

JWT_SECRET : 암호화 할 때 사용할 키
JWT_ALGO : 암호화 알고리즘

.env 파일에 JWT 토큰 정보를 추가해준다.

> src/config/index.ts


 // jwt Secret
  jwtSecret: process.env.JWT_SECRET as string,

  // jwt Algorithm
  jwtAlgo: process.env.JWT_ALGO as string,

.env 파일의 JWT 토큰을 사용할 수 있게 index.ts에 다음과 같이 추가해주자.

> src/modules/jwtHandler.ts


import jwt from "jsonwebtoken";
import mongoose from "mongoose";
import { JwtPayloadInfo } from "../interfaces/common/JwtPayloadInfo";
import config from "../config";

const getToken = (userId: mongoose.Schema.Types.ObjectId): string => {
	/**
    JWT Payload
    */
    const payload: JwtPayloadInfo = {
        user: {
            id: userId
        }
    };

	/**
    jwt.sign(): 암호화
    expiresln: 유효기간 (2시간)
    */
    const accesssToken: string = jwt.sign(
        payload,
        config.jwtSecret,
        { expiresIn: '2h' }
    );

    return accesssToken;
};

export default getToken;

jwtHandler 파일에서 payload의 id를 불러와 JWT 토큰을 암호화 해준다.

> src/middleware/auth.ts


import express, { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import message from "../modules/responseMessage";
import statusCode from "../modules/statusCode";
import util from "../modules/util";
import config from "../config";

export default (req: Request, res: Response, next: NextFunction) => {
    // request-header에서 토큰 받아오기 : Bearer token 파싱해서 토큰만 가져오기
    const token = req.headers["authorization"]?.split(' ').reverse()[0];

    // 토큰이 없는 경우 401 에러 반환 : 접근 금지
    if(!token) {
        return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, message.NULL_VALUE_TOKEN));
    }

    try {
        // jwt token 해독
        const decoded = jwt.verify(token, config.jwtSecret);

        // payload 꺼내오기 : decoded 타입 단언 필요
        req.body.user = (decoded as any).user;

        // middleware 끝나면 다음으로 넘기기
        next();
    } catch (error: any) {
        console.log(error)

        // TokenExpiredError 발생 시 401(접근 금지) 반환
        if (error.name === 'TokenExpiredError') {
            return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, message.INVALID_TOKEN));
        }

        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));

    }
}

헤더에서 토큰을 받아와 검증하는 작업이다. middleware에서 접근 가능한 토큰을 검증하고, 검증되면 요청한 api로 넘어가도록 한다.

middleware의 작업은 라우터에서 명시해준다.

> src/routes/ReviewRouter.ts


router.get('/movies/:movieId', auth, ReviewController.getReviews);

다음과 같이 리뷰 관련 API로 넘기기 전 auth를 추가하여 middleware에서 인가 인증 작업을 진행하도록 한다.

유저 인증을 가능하도록 해보자. 로그인에서 비밀번호가 필요하다-!

> src/models/User.ts


import mongoose from "mongoose";
import { UserInfo } from "../interfaces/user/UserInfo";

// 타입은 몽구스 홈페이지에서 참고해서 정확하게 !!
const UserSchema = new mongoose.Schema({
    name: {
        type: String,
        required: true
    }, 
    phone: {
        type: String,
        required: true
    },
    email: {
        type: String,
        required: true,
        unique: true
    },
    password: {
        type: String,
        required: true
    },
    age: {
        type: Number
    },
    school: {
        name: { type: String },
        major: { type: String }
    }
});

export default mongoose.model<UserInfo & mongoose.Document>("User", UserSchema);

그럼 다음과 같이 Use Collection에 password 필드를 추가하여 유저의 비밀번호를 받을 수 있게 하자.

> src/interfaces/user/UserCreateDto.ts


import { SchoolInfo } from "../school/SchoolInfo";

export interface UserCreateDto {
    name: string;
    phone: string;
    email: string;
    password: string;
    age?: number;
    school?: SchoolInfo;
}

UserCreateDto에도 입력값으로 받아올 수 있게 password를 추가해주자.


여기서!! 유저의 비밀번호는 유출되면 안된다. 즉, 비밀번호를 문자 그대로 DB에 저장하면 정보가 유출될 수 있으므로 보안상으로 문제가 발생한다.

이러한 유출 위험을 피하기 위해서 비밀번호는 암호화하여 저장해야 한다. Bcrypt.js 라이브러리로 비밀번호를 암호화하고 대조 할 수 있다.

yarn add bcryptjs
yarn add -D @types/bcryptjs

위와 같이 Bcrypt.js 라이브러리르 설치해주자.

> src/routes/UserRouter.ts


router.post('/', [
    body('password').isLength({min:6}),
    body('password').notEmpty(),
    body('name').notEmpty(),
    body('phone').notEmpty(),
    body('email').isEmail()
],UserController.createUser);

express-validator를 사용하여 req.body를 검증한다.
.isLength({min: <_number>}) : 최소 number 길이의 비밀번호를 지정해줘야 한다.

> src/controllers/UserController.ts


const createUser = async (req: Request, res: Response) => {
    // 유효성 검사
    const error = validationResult(req);
    if (!error.isEmpty()){
        console.log(error)
        return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, message.BAD_REQUEST));
    }
    ..
    ..

위와 같이 controller에서 유효성 검사를 진행할 수 있다. 실패하면 BAD_REQUEST 오류 메세지를 반환한다.

> src/services/UserService.ts


const createUser = async (userCreateDto: UserCreateDto): Promise<PostBaseResponseDto|null> => {
    try {

        // user email이 이미 존재하는지 검사 : 409 duplicated
        const existUser = await User.findOne({
            email: userCreateDto.email
        });
        if (existUser) return null;
        
        ..
        ..
        
        // 아주 작은 임의의 랜덤한 텍스트
        const salt = await bcrypt.genSalt(10); 

        // plain text + salt >> hashing >> hashed text
        user.password = await bcrypt.hash(userCreateDto.password, salt);
        
        await user.save();
        
        ..
        ..

service 파일에서는 유저의 email이 중복되는 것을 방지한다. findOne()을 통해 해당 email이 존재하면 409번의 duplicated 오류 메세지를 반환한다.
유효성 검사에 통과하면, salt를 이용하여 유저의 비밀번호를 암호화하여 저장한다.

> src/controllers/UserController.ts

..
..

    try {
        const result = await UserService.createUser(userCreateDto);
        if(!result) return res.status(statusCode.CONFLICT).send(util.fail(statusCode.CONFLICT, message.PASSWWORD_DUPLICATED));

        // 아까 만든 jwtHandler.ts 내 getToken을 통해 access token (JWT) 받아와 전달
        const accesssToken = getToken(result._id);

        const data = {
            _id: result._id,
            accesssToken
        };
        ..
        ..

이전에 UserController의 createUser 메소드에 위와 같이 jwt 토큰을 받아오는 로직을 추가한다.
Servic를 통과하면 클라이언트에게 접근 가능한 토큰을 반환한다.


유저 생성 API 테스트에 성공하면 "accessToken" 키 값으로 접근 가능한 토큰을 얻을 수 있다.

이제 발급 받은 토큰을 Bearer에 넣어서 영화 리뷰 조회 API를 요청(테스트)하면 성공할 수 있다. (위에서 ReviewRouter에 auth를 추가하여 인가 인증 로직을 추가했기 때문에 토큰을 검증하는 작업이 추가되었기 때문이다.)


비밀번호 필드도 추가했으니까 로그인에 성공하면 토큰을 발급받는 로직도 만들어 볼 있었다 -!

> src/controllers/UserController.ts


const signInUser = async (req: Request, res: Response) => {
    const error = validationResult(req);
    if(!error.isEmpty()) {
        return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, message.BAD_REQUEST));
    }

    const userSignInDto: UserSignInDto = req.body;
    ..
    ..

유효성 검사 후 UserSignDto로 로그인 입력값을 받아오자.

> src/interfaces/user/UserSignInDto.ts


export interface UserSignInDto {
    email: string,
    password: string
}

UserSignDto는 위와 같다.

> src/controllers/UserController.ts


..
..
    try {
        const result = await UserService.signInUser(userSignInDto);

        // 로그인 후 accessToken 반환
        if(!result) return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, message.NOT_FOUND));
        else if (result == 401) return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, message.INVALID_PASSWORD));

        const accessToken = getToken((result as PostBaseResponseDto)._id);

        const data = {
            _id: (result as PostBaseResponseDto)._id,
            accessToken
        }
        ..
        ..

이제 UserController에서 입력값으로 받아와서, 유효성 검사에 성공하면 토큰을 반환한다. (클라이언트는 로그인 후 accessToken을 반환 받을 수 있다.)

> src/services/UserService.ts


const signInUser = async (userSignInDto: UserSignInDto) : Promise<PostBaseResponseDto | null | number> => {
    try {
        const user = await User.findOne({
            email: userSignInDto.email
        });
        if (!user) return null; // user email 존재하지 않는 경우 처리

        // bcrypt가 원래 password와 현재 보낸 password eowh : match 되지 않으면 401 반환
        const isMatch = await bcrypt.compare(userSignInDto.password, user.password);
        if (!isMatch) return 401;

        const data = {
            _id: user._id
        };

        return data;
    } catch (error) {
        console.log(error);
        throw error;
    }
}

bcrypt.compare()을 통해 클라이언트가 입력한 password와 원래 password 값을 대조해서 유효성을 체크 한다. (매치되지 않으면 401 접근 금지 반환)

> src/routes/UserRouter.ts


router.post('/signin', [
    body('email').notEmpty(),
    body('email').isEmail(),
    body('password').isLength({min: 6}),
    body('password').notEmpty()
], UserController.signInUser);

signin 라우터에 express-validator까지 추가해주자.

이제 로그인 API를 테스트해서 올바른 email과 password를 입력하면 로그인 성공 후 접근 가능한 토큰을 발급 받을 수 있다.



👩‍💻 Concluding

인가 인증에 대해 처음 제대로 배워볼 수 있었다. 캡스톤 때 구글링 해서 만든 로그인 로직은 아주 잘못된 로직인 것도 깨달을 수 있었다 😂 (비밀번호 암호화도 안하고 토큰 발급도 안함) 매주 알차게 배우고 과제와 회고를 통해 복습할 수 있어서 뿌듯하다 :)

profile
쏘's 코딩·개발 일기장

0개의 댓글