메가바이트 스쿨 19주차 (4/13) Node.js - Express + TypeORM + JWT

정영찬·2023년 4월 13일
0
post-thumbnail

회원가입/로그인 구현하기

0) 프로젝트 세팅

express 를 활용해서,jwt를 활용한 유저 인증 구현

npm install express typeorm mysql reflect-metadata cors dotenv

npm install -D @types/cors @types/express typescript

package.json

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "nodemon --exec ts-node src/app.ts",
    "build": "tsc"
},

tsconfig.json

{
    "compilerOptions": {
        "lib": ["es5", "es6", "dom"],
        "target": "es6",
        "module": "commonjs",
        "moduleResolution": "node",
        "emitDecoratorMetadata": true,
        "experimentalDecorators": true,
        "esModuleInterop": true,
        "outDir":"./dist",
    },
}

db.ts

import { DataSource } from "typeorm"

export const myDataBase = new DataSource({
    type: "mysql",
    host: "db서버 주소",
    port: 3306,
    username: "유저이름",
    password: "비밀번호",
    database: "mydb", // db 이름
    entities: ["src/entity/*.ts"], // 모델의 경로
    logging: true, // 정확히 어떤 sql 쿼리가 실행됐는지 로그 출력
    synchronize: true,
})

app.ts

import express from "express"
import { myDataBase } from "./db"
import cors from 'cors'

myDataBase
    .initialize()
    .then(() => {
        console.log("DataBase has been initialized!")
    })
    .catch((err) => {
        console.error("Error during DataBase initialization:", err)
    })

const app = express()
app.use(express.json())
app.use(express.urlencoded())
app.use(cors({
    origin: true // 모두 허용
}))

app.listen(3000, () => {
    console.log("Express server has started on port 3000")
})

1)JWT

JWT 구현에 필요한 모듈 설치한다.

npm install jsonwebtoken bcrypt

npm install -D @types/jsonwebtoken @types/bcrypt

2) User 모델 설계

User 모델은 이메일,유저이름, 비밀번호를 입력받도록 작성한다.

src/entity/User.ts

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from "typeorm"

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number

    @Column({ unique: true }) // 이메일은 중복되지 않도록 설계
    email: string

    @Column({ unique: true }) // 유저이름은 중복되지 않도록 설계
    username: string

    @Column()
    password: string

    @CreateDateColumn()
    createdAt: Date

    @UpdateDateColumn()
    updatedAt: Date;
}

3) 토큰 생성을 위한 함수 작성

이제 토큰을 생성하기 위한 함수를 작성한다.
우선은 JWT토큰의 암호화, 복호화 시에 사용할 secret key를 정의해줘야한다.

secret key를 만들기 위해서, 아래 hex값 생성기를 이용한다.

https://www.browserling.com/tools/random-hex

랜덤으로 키를 생성해서 나온 값 2개를 .env로 만들어서 저장한다.

SECRET_ATOKEN="랜덤 키값1"
SECRET_RTOKEN="랜덤 키값2"

백엔드에서 토큰을 발급할 때는, 우리가 정확히 어떤 토큰을 발급했는지를 저장해둬야한다. 백엔드에도 발급 정보가 남아있어야, 정확히 우리가 발급한 토큰이 맞는지를 검증할 수 있기 때문이다.

src/app.ts

// 캐시 형태로 발급된 토큰을 저장하기 위한 객체
// 실제로는 redis 를 활용함
export const tokenList = {};

app.ts 파일에 위처럼 토큰을 저장하기 위한 객체를 하나 추가한다. (실제로는 redis 를 사용해서 캐시 형태로 발급한 토큰들을 저장해둔다.)

유저 생성 시에 활용할 함수들을 미리 선언한다.

src/util/Auth.ts

import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import dotenv from 'dotenv';
import { tokenList } from '../app';
dotenv.config();

// 비밀번호 암호화를 위한 함수
export const generatePassword = async (pw: string) => {
    const salt = await bcrypt.genSalt(10); // 암호화에 사용할 임의의 문자열 (salt) 생성
    const password = await bcrypt.hash(pw, salt); // 해당 salt 를 활용해서 비밀번호 암호화
    return password
};

// 엑세스 토큰 생성을 위한 함수
export const generateAccessToken = (id: number, username: string, email: string) => {
    return jwt.sign(
        { id: id, username: username, email: email },
        process.env.SECRET_ATOKEN,
        {
        expiresIn: '1h',
        }
    ); // jwt.sign(유저 payload, 시크릿 키, 옵션) 형태로 명시 (만료 시간 1시간으로 설정)
}

// 리프레시 토큰 생성을 위한 함수
export const generateRefreshToken = (id: number, username: string, email: string) => {
    return jwt.sign(
        { id: id, username: username, email: email },
        process.env.SECRET_RTOKEN,
        {
        expiresIn: '30d',
        }
    ); // 만료시간 30일로 설정
}

// 우리가 어떤 리프레시 토큰을 발급했는 저장하기 위한 함수
export const registerToken = (refreshToken: string, accessToken: string) => {
    tokenList[refreshToken] = {
        status: 'loggedin',
        accessToken: accessToken,
        refreshToken: refreshToken,
    };
} // 토큰과 해당 토큰의 상태를 저장

// 발급한 리프레시 토큰 삭제를 위한 함수
export const removeToken = (refreshToken: string) => {
    delete tokenList[refreshToken]
}

4) 회원가입 controller 작성

이제 회원가입을 위한 controller 를 작성한다.
회원가입 controller는

  • 요청에 담겨있는 회원정보를 토대로
  • db 에 이미 회원이 있는지 체크하고
  • 없으면 유저를 생성해서 db에 넣고
  • 토큰을 발급해서 응답한다

src/controller/UserController.ts

import { verify } from 'jsonwebtoken'
import bcrypt from 'bcrypt'
import { myDataBase } from '../db'
import { User } from '../entity/User'
import { generateAccessToken, generatePassword, generateRefreshToken, registerToken } from '../util/Auth'
import { Request, Response } from 'express'

export class UserController {
  static register = async (req: Request, res: Response) => {
    const { email, password, username } = req.body

    // 중복 유저 체크
    const existUser = await myDataBase.getRepository(User).findOne({
      where: [
        { email }, // 이메일이 일치하거나
        { username }, // 유저네임이 일치하는 데이터가 있는지 확인
      ],
    })
    if (existUser) {
      // 일치하는 유저가 있다면 400 리턴
      return res.status(400).json({ error: 'Duplicate User' })
    }
    // return 이 안됐으면? 유저가 없다는 뜻!
    // 유저 생성
    const user = new User()
    user.email = email
    user.password = await generatePassword(password) // 암호화된 비밀번호 생성
    user.username = username
    // 익숙해지셨다면 여기서 try catch 로 db 에 접근 못했을 때의 에러 처리도 해주는 것이 좋음!
    // save 의 경우 insert 와 다르게 해당 id 의 데이터가 이미 있다면 업데이트하는 로직을 가지고 있음 (사용 시 주의 필요)
    const newUser = await myDataBase.getRepository(User).save(user) // 유저 생성 완료
    // 만약 회원가입 시 자동 로그인 구현하고 싶다면, 여기서 토큰 발급까지 구현
    // 액세스 토큰 및 리프레시 토큰 발급
    const accessToken = generateAccessToken(newUser.id, newUser.username, newUser.email)
    const refreshToken = generateRefreshToken(newUser.id, newUser.username, newUser.email)
    // 어떤 토큰을 발급했는지 저장해놓기
    registerToken(refreshToken, accessToken)
    // 토큰을 복호화해서, 담겨있는 유저 정보 및 토큰 만료 정보도 함께 넘겨줌
    const decoded = verify(accessToken, process.env.SECRET_ATOKEN)

    res.send({ content: decoded, accessToken, refreshToken })
  }
}

5) 회원가입 route 작성

해당 controller 를 사용하는 router를 생성해서, app.ts에 등록해준다.

회원가입 롲기은 /register 로 post 요청을 보냈을 때 작동하도록 작성한다.

router/auth.ts

import {Router} from "express";
import {UserController} from "../controller/UserController";

const routes = Router();

routes.post('/register', UserController.register);

export default routes;

app.ts

app.use('/auth', AuthRouter)

6) postman 사용해보기

json 형태로 email,username,password를 보내면 토큰 발급이 제대로 오는 것을 확인할수 있다.

7) 로그인 controller 작성

로그인을 위한 controller를 작성해준다.
로그인 controller는

  • 요청에 담겨있는 회원 정보를 토대로
  • db 에 존재하는 회원인지 체크하고
  • 비밀번호를 복호화해서 db 에 있는 회원 비밀번호와 일치하는 체크하고
  • 일치한다면 토큰을 발급해서 응답

하는 순서로 이루어진다.

src/controller/UserController.ts

//내용 추가
import { verify } from 'jsonwebtoken'
import bcrypt from 'bcrypt'
import { myDataBase } from '../db'
import { User } from '../entity/User'
import { generateAccessToken, generatePassword, generateRefreshToken, registerToken } from '../util/Auth'
import { Request, Response } from 'express'

export class UserController {
  static register = async (req: Request, res: Response) => {
    const { email, password, username } = req.body

    // 중복 유저 체크
    const existUser = await myDataBase.getRepository(User).findOne({
      where: [
        { email }, // 이메일이 일치하거나
        { username }, // 유저네임이 일치하는 데이터가 있는지 확인
      ],
    })
    if (existUser) {
      // 일치하는 유저가 있다면 400 리턴
      return res.status(400).json({ error: 'Duplicate User' })
    }
    // return 이 안됐으면? 유저가 없다는 뜻!
    // 유저 생성
    const user = new User()
    user.email = email
    user.password = await generatePassword(password) // 암호화된 비밀번호 생성
    user.username = username
    // 익숙해지셨다면 여기서 try catch 로 db 에 접근 못했을 때의 에러 처리도 해주는 것이 좋음!
    // save 의 경우 insert 와 다르게 해당 id 의 데이터가 이미 있다면 업데이트하는 로직을 가지고 있음 (사용 시 주의 필요)
    const newUser = await myDataBase.getRepository(User).save(user) // 유저 생성 완료
    // 만약 회원가입 시 자동 로그인 구현하고 싶다면, 여기서 토큰 발급까지 구현
    // 액세스 토큰 및 리프레시 토큰 발급
    const accessToken = generateAccessToken(newUser.id, newUser.username, newUser.email)
    const refreshToken = generateRefreshToken(newUser.id, newUser.username, newUser.email)
    // 어떤 토큰을 발급했는지 저장해놓기
    registerToken(refreshToken, accessToken)
    // 토큰을 복호화해서, 담겨있는 유저 정보 및 토큰 만료 정보도 함께 넘겨줌
    const decoded = verify(accessToken, process.env.SECRET_ATOKEN)

    res.send({ content: decoded, accessToken, refreshToken })
  }

  static login = async (req: Request, res: Response) => {
    // 로그인

    const { email, password } = req.body
    //db에 유저가 있는지 확인

    const user = await myDataBase.getRepository(User).findOne({
      where:{email},
    })
    if (!user) {
      return res.status(400).json({error: 'User not found'})
    }

    // 유저가 있네?
    const validPassword = await bcrypt.compare(password, user.password)
    // 근데 비밀번호가 틀리네?
    if (!validPassword) {
      return res.status(400).json({error: "invalid Password"})
    }

    // 액세스 토큰 및 리프레시 토큰 발급
    const accessToken = generateAccessToken(user.id, user.username, user.email)
    const refreshToken = generateRefreshToken(user.id, user.username, user.email)
    // 어떤 토큰을 발급했는지 저장해놓기
    registerToken(refreshToken, accessToken)
    // 노큰을 복호화해서, 담겨있는 유저 정보도 함께 넘겨준다
    const decoded = verify(accessToken, process.env.SECRET_ATOKEN)

    res.send({constent: decoded, accessToken, refreshToken})
  }
}

8) 로그인 route 작성


routes.post('/login', UserController.login)

9) postman 사용하기

이메일과 비밀번호를 사용해서 로그인 해보면 응답이 잘 오는 것을 확인할수 있다.

지금까지는 access token과 refresh token을 모두 응답 데이터에 담아서 보내주는데,
이제 refresh token은 cookie 형태로 내려주도록 작성한다.

백엔드에서 쿠키를 설정할 때는 res.cookie(키, 값, 옵션) 형태로 작성해주면 된다.

src/UserController

// 코드 추가 refister와 login 둘다 작성할것

res.cookie('refreshToken', refreshToken, { path: '/', httpOnly: true, maxAge: 60 * 60 * 24 * 30 * 1000 } )

이제 로그인 요청을 해보면 refresh token은 cookie 형태로 응답이 오는 것을 확인할수 있다

React에서 요청을 보낼 경우, withCredentials를 꼭 true로 해주어야한다는 것을 명심하자.

글 작성자 구현하기

1) Post 모델 설계

post 모델을 작성하는데, 작성자 부분은 user 모델과 연결을 시켜줘야한다.

우선 제목과 내용을 받도록 설꼐하고, author부분은 ManyToOne 관계로 user 모델과 연결시켜보자

entity/Post.ts

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany, ManyToOne } from "typeorm"
import { User } from "./User"

@Entity()
export class Post {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    title: string

    @Column()
    body: string

    @CreateDateColumn()
    createdAt: Date

    @UpdateDateColumn()
    updatedAt: Date;

    @ManyToOne(type => User, user => user.posts) // author를 User의 posts와 연결
    author: User;
}

user 모델 쪽에서도 posts 라는 속성을 만들어서, oneTomany 관계로 post의 작성자 부분과 연결시켜준다. 한명의 유저가 다수의 게시글을 쓸수 있으므로

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany, ManyToOne } from "typeorm"
import { User } from "./User"

@Entity()
export class Post {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    title: string

    @Column()
    body: string

    @CreateDateColumn()
    createdAt: Date

    @UpdateDateColumn()
    updatedAt: Date;

    @ManyToOne(type => User, user => user.posts) // author를 User의 posts와 연결
    author: User;
}

2) 인증을 위한 middleware 구현

이제 token을 검증하는 로직을 구현해놓고, post 와 관련한 요청을 할 때 해당 로직이 먼저 작동한 후에, 토큰이 검증된 경우에만 요청을 처리하도록 코드를 작성한다.

token 을 검증하는 로직은

  • headers 에 포함된 토큰을 가져오고(혹시나 토큰이 없다면 403 응답)
  • 토큰이 있다면 시크릿 키를 토대로 해당 토큰을 검증하고 (혹시나 제대로 검증되지 않는다면 401 응답)
  • 토큰의 payload 부분을 응답(정확히는 request 객체에 추가)하는 방식으로 구현한다.

src/middleware/AuthMiddleware.ts

import { verify } from 'jsonwebtoken';
import dotenv from 'dotenv';
import { NextFunction, Request, Response } from 'express';
dotenv.config();

export interface TokenPayload { // token decode 하면 무엇이 들어가 있는지 작성
    email: string;
    username: string;
    id: number;
}

export interface JwtRequest extends Request { // payload 를 포함한 request 타입을 생성
    decoded?: TokenPayload
}

export class AuthMiddleware {
    static verifyToken = (req: JwtRequest, res: Response, next: NextFunction) => {
        const authHeader = req.headers['authorization'];
        const token = authHeader && authHeader.split(' ')[1];

        if (!token) {
            return res.status(403).send('A token is required for authentication');
        }
        try {
            const decoded = verify(token, process.env.SECRET_ATOKEN) as TokenPayload; // 타입 표명 (보통은 사용하지 않는 것이 좋지만, verify 함수 자체를 수정할 수 없고, 개발자가 타입을 더 정확히 알고 있으므로 사용)
            req.decoded = decoded;
        } catch (err) {
            return res.status(401).send('Invalid Token');
        }
        return next(); // 다음 로직으로 넘어가라는 뜻
    };
}

3) Post controller, router 작성(글쓰기)

이제 post controller 에 글쓰기 로직을 작성한다.

글쓰기 로직은 이전처럼 title, body 를 받아서 db 에 저장하고 끝이 아니라, 중간에 유저를 찾아서 해당 유저를 작성자로 등록하는 과정이 추가된다.

사용자가 글쓰기 요청을 보내면, 아까 작성했던 verify token 로직이 먼저 수행되면서 아래의 순서대로 글쓰기 로직이 동작한다.

  • verify token 로직이 수행되면서, 토큰을 통해 찾아낸 유저 번호를 req 객체에 추가
  • createPost 로직이 수행되면서, req 객체에 있는 유저 번호를 토대로 db 에서 유저 객체를 탐색
  • 해당 유저 객체를 새로운 글의 작성자로 지정
  • 해당 글을 db 에 추가

controller/PostController.ts

import { Request, Response } from "express";
import {Post} from "../entity/Post";
import { myDataBase } from "../db"
import { User } from "../entity/User";
import { JwtRequest } from "../middleware/AuthMiddleware";

export class PostController {
    static createPost = async (req: JwtRequest, res: Response) => {
        const {title, body} = req.body;
        // 토큰 복호화를 통해 찾아낸 유저 번호
        const {id: userId} = req.decoded;
        // 해당 번호를 토대로 db 에서 유저 찾아냄
        // (토큰 검증 시에 예외처리를 하고 있어서 여기서는 따로 안했지만, 필요하다면 여기서 추가적인 예외처리를 해주면 됩니다.)
        const user = await myDataBase.getRepository(User).findOneBy({
            id: userId
        });

        const post = new Post();
        post.title = title;
        post.body = body;
        post.author = user; // 해당 user 를 author 로 등록
        const result = await myDataBase.getRepository(Post).save(post);

        res.status(201).send(result);
    }
}

이제 해당 controller 의 로직과 verify token 로직을 아래처럼 등록한다.
router/posts.ts

import {Router} from "express";
import {PostController} from "../controller/PostController";
import { AuthMiddleware } from "../middleware/AuthMiddleware";

const routes = Router();

routes.post('', AuthMiddleware.verifyToken, PostController.createPost);

export default routes;`

이제 마지막으로 app.ts에 해당 router를 등록해주면 끝!

app.use('/posts', PostsRouter)

4) postman 사용하기

글 작성 기능을 테스트 해보자 여기서 추가해야할 정보는 authorization에서 로그인 하면서 얻은 accesstoken값을 입력해준 뒤에 글을 작성해줘야한다.

5) 필요한 정보만 응답 데이터로 주도록 수정

글 작성시에는 result 객체 대신 성공 여부만 응답으로 내려주도록 변경해보자. (result 객체 자체를 수정해도 무방함)

글을 가져올 때는 ~.find({select:{필요한 정보}} 의 형태로 작성해주시면, 해당 정보만 db에서 가져오게 된다. select 구문을 포함시켜서, 글을 가져올 때 작성자 부분에는 유저 번호, 이름 정도만 포함되도록 작성해준다.

import { Request, Response } from "express";
import {Post} from "../entity/Post";
import { myDataBase } from "../db"
import { User } from "../entity/User";
import { JwtRequest } from "../middleware/AuthMiddleware";

export class PostController {
    static createPost = async (req: JwtRequest, res: Response) => {
        const {title, body} = req.body;
        // 토큰 복호화를 통해 찾아낸 유저 번호
        const {id: userId} = req.decoded;
        // 해당 번호를 토대로 db 에서 유저 찾아냄
        const user = await myDataBase.getRepository(User).findOneBy({
            id: userId
        });

        const post = new Post();
        post.title = title;
        post.body = body;
        post.author = user; // 해당 user 를 author 로 등록
        // !!글 썼을 때는 성공 여부만 보여줄 예정이기 때문에, 그냥 insert 로 변경
        const result = await myDataBase.getRepository(Post).insert(post);

        res.status(201).send({ message: 'success' }); // !!성공 여부만 응답으로 주도록 
    }
    static getPosts = async (req: Request, res: Response) => {
        const results = await myDataBase.getRepository(Post).find({
            select: {
                author: { // !!author 에서 필요한 것만 갖고오도록 (즉, 비밀번호 등은 표시되지 않도록)
                    id: true,
                    username: true,
                    email: true
                }
            },
            relations: { // !!데이터 가져올 때 author 도 표시하도록 설정
                author: true
            }
        });
        res.send(results);
    }
    static getPost = async (req: Request, res: Response) => {
        const results = await myDataBase.getRepository(Post).findOne({
            where: { id: Number(req.params.id) },
            select: {
                author: {
                    id: true,
                    username: true,
                    email: true
                }
            },
            relations: {
                author: true
            }
        });
        res.send(results);
    }
}

라우터 다시 작성

import {Router} from "express";
import {PostController} from "../controller/PostController";
import { AuthMiddleware } from "../middleware/AuthMiddleware";

const routes = Router();

routes.post('', AuthMiddleware.verifyToken, PostController.createPost);
routes.get('', PostController.getPosts);
routes.get('/:id', PostController.getPost);

export default routes;

6) update, delete 요청 구현 (작성자 검증)

update와 delete 요청을 처리할때는, 요청을 보낸 유저가 해당 글의 작성자와 일치하는지 검증해야한다.

  • 토큰 검증 절차를 통해서 요청을 보낸 유저를 식별하고
  • db 상에서 해당 글의 작성자가 누구인지 찾아서
  • 일치여부 확인
    -일치하면 삭제 혹은 수정 요청 처리

controller/PostController.ts

import { Request, Response } from "express";
import {Post} from "../entity/Post";
import { myDataBase } from "../db"
import { User } from "../entity/User";
import { JwtRequest } from "../middleware/AuthMiddleware";

export class PostController {
    static createPost = async (req: JwtRequest, res: Response) => {
        const {title, body} = req.body;
        // 토큰 복호화를 통해 찾아낸 유저 번호
        const {id: userId} = req.decoded;
        // 해당 번호를 토대로 db 에서 유저 찾아냄
        const user = await myDataBase.getRepository(User).findOneBy({
            id: userId
        });

        const post = new Post();
        post.title = title;
        post.body = body;
        post.author = user; // 해당 user 를 author 로 등록
        const result = await myDataBase.getRepository(Post).insert(post);

        res.status(201).send({ message: 'success' }); // 성공 여부만 응답으로 주도록 
    }
    static getPosts = async (req: Request, res: Response) => {
        const results = await myDataBase.getRepository(Post).find({
            select: {
                author: { // author 에서 필요한 것만 갖고오도록 (즉, 비밀번호 등은 표시되지 않도록)
                    id: true,
                    username: true,
                    email: true
                }
            },
            relations: { // 데이터 가져올 때 author 도 표시하도록 설정
                author: true
            }
        });
        res.send(results);
    }
    static getPost = async (req: Request, res: Response) => {
        const results = await myDataBase.getRepository(Post).findOne({
            where: { id: Number(req.params.id) },
            select: {
                author: {
                    id: true,
                    username: true,
                    email: true
                }
            },
            relations: {
                author: true
            }
        });
        res.send(results);
    }
    static updatePost = async (req: JwtRequest, res: Response) => {
        const {id: userId} = req.decoded; 

        const currentPost = await myDataBase.getRepository(Post).findOne({
            where: {id: Number(req.params.id)},
            relations: {
                author: true // 데이터 가져올 때 author 도 표시하도록 설정 / ['author'] 라고 작성해도 됨
            }
        });
        if (userId !== currentPost.author.id) { // 글 작성자와 요청 보낸 사람이 일치하지 않으면
            return res.status(401).send('No Permission') // 거부
        }

        const {title, body} = req.body;
        const newPost = new Post();
        newPost.title = title;
        newPost.body = body;

        const results = await myDataBase.getRepository(Post).update(
            Number(req.params.id),
            newPost
        );
        res.send(results);
    }
    static deletePost = async (req: JwtRequest, res: Response) => {
        const {id: userId} = req.decoded; 

        const currentPost = await myDataBase.getRepository(Post).findOne({
            where: {id: Number(req.params.id)},
            relations: {
                author: true
            }
        });
        if (userId !== currentPost.author.id) { // 글 작성자와 요청 보낸 사람이 일치하지 않으면
            return res.status(401).send('No Permission') // 거부
        }

        const results = await myDataBase.getRepository(Post).delete(Number(req.params.id));
        res.send(results);
    }
}

router/Posts

import {Router} from "express";
import {PostController} from "../controller/PostController";
import { AuthMiddleware } from "../middleware/AuthMiddleware";

const routes = Router();

routes.post('', AuthMiddleware.verifyToken, PostController.createPost);
routes.get('', PostController.getPosts);
routes.get('/:id', PostController.getPost);
routes.put('/:id', AuthMiddleware.verifyToken, PostController.updatePost);
routes.delete('/:id', AuthMiddleware.verifyToken, PostController.deletePost);

export default routes;

7) postman 사용하기

id 가 9인 글을 수정/제거 해보자

수정이 잘 된다. 그럼 제거도 해보자!

오호! 말끔하게 없어졌다!

refresh /verify 구현

이제 토큰 재발급을 위한 refresh, 토큰 검증을 위한 verify API를 작성해보자

요청으로 들어온 쿠키를 백엔드에서 처리할수 있도록 cookie-parser를 설치해보자.

npm instal cookie-parser

install -D @types/cookie-parser

해당 cookie-parser 를 사용한다고 app.ts에 명시

app.use(cookieParser()) // cookie-parser 사용

2) refresh 요청 추가

refresh 요청을 처리하기 위해서는 우선 refresh token을 검증하는 로직을 추가해놓는다.

refresh token의 검증은

  • 쿠키에 리프레시 토큰이 있는지 확인(없으면 403)
  • 해당 토큰이 우리 백엔드에서 발급한 것이 맞는지 확인 (아니면 401)
  • 맞다면 토큰 복호화 (안된다면 401)
  • 복호화한 토큰에 포함된 유저정보를 req객체에 추가

middleware/AuthMiddleware.ts

// 코드 추가

  static verifyRefreshToken = (
        req: JwtRequest,
        res: Response,
        next: NextFunction,
    ) => {
        const cookies = req.cookies
        // 쿠키에 refreshToken 있니?
        if (!cookies.refreshToken) {
            return res.status(403).json({ error: 'No Refresh Token' })
        }
        // 우리꺼 토큰이 맞니?
      if (!(cookies.refreshToken in tokenList)) {
         return res.status(401).json({error:'Invalid Refresh Token'})
      }
      // 우리꺼 맞네?
      try {
        // 기존 리프레시 토큰 복호화
        const decoded = verify(cookies.refreshToken, process.env.SECRET_RTOKEN) as TokenPayload
        req.decoded = decoded
      } catch (err) {
        return res.status(401).send('Invalid Refresh Token')
      }
      return next()
    }

이제 userController 에 refresh 함수를 추가한다

이 refresh 함수는

  • 기존 refresh 토큰을 캐시 (app.ts에 선언했던 tokenList)에서 삭제
  • 앞서 복호화한 유저 정보를 토대로 새롭게 토큰 발급(다시 tokenList에 추가)
  • 토큰 중 access token 은 응답 데이터에, refresh token은 setCookie로 응답하는 로직을 가지고 있다.

controller/UserController.ts

// 코드 추가
 static refresh = async (req: JwtRequest, res: Response) => {
        const { id, username, email } = req.decoded
        // 기존에 발급한 토큰은 메모리에서 삭제
        removeToken(req.cookies.refreshToken)
        // 액세스 토큰 및 리프레시 토큰 새롭게 발급
        const accessToken = generateAccessToken(id, username, email)
        const refreshToken = generateRefreshToken(id, username, email)
        // 새롭게 발급한 토큰 저장
        registerToken(refreshToken, accessToken)
        // 토큰을 복호화해서, 담겨있는 유저 정보 및 토큰 만료 정보도 함께 넘겨줌
        const decoded = verify(accessToken, process.env.SECRET_ATOKEN)

        res.cookie('refreshToken', refreshToken, {
            path: '/',
            httpOnly: true,
            maxAge: 60 * 60 * 24 * 30 * 1000,
        })
        res.send({ content: decoded, accessToken })
    }

refresh 요청은 리프레시 토큰 검증 로직을 거쳐야 하므로, 아래와 같이 작성한다.

router/auth.ts

//코드 추가
routes.get('/refresh', AuthMiddleware.verifyRefreshToken, UserController.refresh);

이제 accessToken 이 만료되어서 헤더에 아무것도 없어도, cookie에 refreshToken 만 있으면 토큰을 재발급해준다!

3) verify 요청 추가

토큰 검증을 위한 api는 특별히 더 추가할 것이 없다.

기존에 만들어놓은 verify token 로직을 거쳐서, 해당 토큰 정보를 리턴하도록 작성해주면 된다.

controller/UserController.ts

//코드 추가
static verify = async (req: JwtRequest, res: Response) => {
        // 필요하다면 verify 할때마다 토큰을 다시 발급해줄수도 있을것
        // 미들웨어를 거치면서 req에 이미 decoded가 들어있음
        res.send({ content: req.decoded })
    } 

router/auth.ts

//코드 추가
routes.get('/verify', AuthMiddleware.verifyToken, UserController.verify)

엑세스 토큰을 헤더에 포함시켜서 verify로 요청을 보내니 현재 요청을 보내는 사람이 누구인지, 현재 토큰의 발행, 만료시간은 어떠한지를 알 수 있다.

profile
개발자 꿈나무

0개의 댓글