blegram(2) - 로그인 구현 ( JWT, Middleware )
포스트
취소

blegram(2) - 로그인 구현 ( JWT, Middleware )

해당 프로젝트는 Next.js + TypeScript를 기반으로 만드는 인스타그램 클론 개인 프로젝트입니다.

📤 FE

프론트 영역의 컴포넌트 부분에서는 특별하게 설명할 부분이 없어서 넘어가겠습니다.

react-query를 이용해서 데이터를 송/수신하고 react-tostify를 토스트 메시지를 보여주는 부분에 대해 작성하겠습니다.

0️⃣ useUser()

react-query를 사용해서 유저의 정보를 패칭하는 커스텀 훅을 만든 부분에 대해서 작성하겠습니다.

  • apiServiceAuth.apiFetchMe(): axios를 이용해서 api/user/meGET을 보내는 메서드
  • ApiFetchMeResponse: 응답 받을 유저 타입

react-query를 이용했기 때문에 어떤 컴포넌트에서 사용해도 네트워크 요청을 계속 보내지 않고 캐싱한 데이터를 사용할 수 있습니다.

아직은 react-query가 익숙하지 않아서 내용이 별로 없습니다.
앞으로 기능을 추가하면서 계속 사용하면서 다음 포스트들에서 더 잘 활용한 예시를 작성하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useQuery } from "react-query";

// api
import { apiServiceAuth } from "@src/apis";

// type
import type { ApiFetchMeResponse } from "@src/types/api";

/** 2023/03/29 - 유저 정보를 얻는 훅 - by 1-blue */
const useUser = () => {
  const { data } = useQuery<ApiFetchMeResponse>("me", apiServiceAuth.apiFetchMe);

  return { user: data?.user };
};

export default useUser;

또한 react-tostify라는 라이브러리를 이용해서 react-query로 데이터를 응답받을 때마다 혹은 에러가 발생했을 경우 토스트 메시지를 띄워주도록 만들었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { AxiosError } from "axios";
import { QueryClientProvider, QueryClient, QueryCache } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import { toast } from "react-toastify";

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError(error, query) {
      let message = "알 수 없는 오류가 발생했습니다.";

      if (error instanceof AxiosError) {
        message = error.response?.data.message;
      }
      if (error instanceof Error) {
        message = error.message;
      }

      toast.warning(message);
    },
    onSuccess(data, query) {
      if (
        typeof data === "object" &&
        data &&
        "message" in data &&
        typeof data.message === "string"
      ) {
        toast.success(data.message);
      }
    },
  }),
});

/** 2023/03/26 - "react-query" Provider 적용 - by 1-blue */
const MyReactQueryProvider: React.FC<React.PropsWithChildren> = ({
  children,
}) => (
  <QueryClientProvider client={queryClient}>
    <ReactQueryDevtools initialIsOpen position="top-left" />
    {children}
  </QueryClientProvider>
);

export default MyReactQueryProvider;

1️⃣ middleware

Next.js에서는 자체적으로 middleware를 제공해줍니다.

미들웨어란 어떤 작업을 수행하기전에 선행으로 먼저 수행하고 이후에 원래 수행할 작업을 수행하도록 하는 선행 실행 함수같은 동작을 합니다.
정해진 위치에 정해진 이름으로 파일을 생성하면 미들웨어로 동작합니다.

일단 크게 3 가지 페이지로 나눕니다.

  1. 로그인 여부에 상관 없이 접근 가능한 페이지
  2. 로그인 시 접근 가능한 페이지
  3. 비 로그인 시 접근 가능한 페이지

해당 미들웨어를 통해서 AccessTokenRefreshToken을 담고 있는 쿠키의 유무에 따라서 로그인 여부를 확인합니다.
이렇게 검사하면 확실하게 검증할 수 없지만 일차적으로 쿠키 소지 여부에 따라 판단하고 해당 페이지에 들어가서는 서버에 요청을 보내서 로그인 여부를 판단하도록 구현했습니다.
여기서는 미들웨어를 통해서 페이지 접근전에 로그인 여부를 판단하는 로직에 대해 설명하겠습니다.

authURLs, nonAuthURLs에 각각의 path를 넣어주고 잘못 접근하는 경우 강제로 페이지를 이동시켜 애초에 접근조차 못하도록 만들었습니다.
그리고 공식문서를 참고해서 configmatcher를 작성해서 프론트 URL을 제외하고는 미들웨어를 거치지 않도록 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

/** 2023/03/27 - 로그인 후 접근 가능한 URL - by 1-blue */
const authURLs = ["/dm"];
/** 2023/03/27 - 로그인 후 접근 불가능한 URL - by 1-blue */
const nonAuthURLs = ["/login", "/signup"];

/**
 * 2023/03/27 -특정 페이지에 접근하기전에 수행할 미들웨어 - by 1-blue
 * 현재는 쿠키의 유무로만 로그인된 상태인지 확인 후 접근 제한
 * ( 만약 쿠키가 있는데 유효하지 않은 경우라면 페이지에 들어갔다가 다시 되돌아오도록 구현 )
 * */
export const middleware = (req: NextRequest) => {
  // 리프래쉬 토큰이 없다면
  if (!req.cookies.has("brt")) {
    // 비로그인 시 접근 불가능한 페이지라면 => 로그인 페이지로 리다이렉트
    if (authURLs.some((url) => req.url.includes(url))) {
      // FIXME: 배포 시 제거
      console.log("비로그인 시 접근 불가능");

      const url = req.nextUrl.clone();
      url.pathname = "/login";

      return NextResponse.redirect(url);
    }
  }
  // 리프래쉬 토큰이 있다면
  else {
    // 로그인 시 접근 불가능한 페이지라면 => 메인 페이지로 리다이렉트
    if (nonAuthURLs.some((url) => req.url.includes(url))) {
      // FIXME: 배포 시 제거
      console.log("로그인 시 접근 불가능");

      const url = req.nextUrl.clone();
      url.pathname = "/";

      return NextResponse.redirect(url);
    }
  }

  return NextResponse.next();
};

/**
 * 아래 네 가지 경우는 미들웨어를 거치지 않음
 * 1. /api
 * 2. /_next/static
 * 3. /_next/image
 * 4. /favicon.ico
 */
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

📥 BE

크게는 아래 두 가지 라이브러리를 이용해서 로그인 기능을 구현했습니다.

  1. bcrypt: 비밀번호 해싱
  2. jsonwebtoken: AccessTokenRefreshToken 생성에 사용

아래의 영역의 함수를 이용해서 토큰을 만들고 쿠키를 이용해서 주고 받고 로그인을 검증하고 로그인된 유저 정보를 얻는 로직을 구현했습니다.
( 더 자세한 흐름은 로그인 로직의 흐름에서 작성하겠습니다. )

0️⃣ 토큰 생성과 검증 함수

인증/리프래쉬 토큰을 생성하는 함수들와 그 토큰들을 검증하는데 사용하는 함수들입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import { JsonWebTokenError, sign, verify, type JwtPayload } from "jsonwebtoken";

interface Payload {
  idx: number;
}
interface generateTokenHandler {
  (payload: Payload): string;
}
/**
 * 2023/03/26 - 인증 토큰 발행 ( 유효 기간 1시간 ) - by 1-blue
 * @param payload 토큰의 페이로드
 * @returns 인증 토큰
 */
export const generateAccessToken: generateTokenHandler = (payload) =>
  sign(payload, process.env.ACCESS_SECRET, { expiresIn: "1h" });

/**
 * 2023/03/26 - 리프레쉬 토큰 발행 ( 유효 기간 7일 ) - by 1-blue
 * @param payload 토큰의 페이로드
 * @returns 인증 토큰
 */
export const generateRefreshToken: generateTokenHandler = (payload) =>
  sign(payload, process.env.REFRESH_SECRET, { expiresIn: "7d" });

interface VerifyTokenHandler {
  (type: "access" | "refresh", token: string):
    | {
        payload: JwtPayload;
        status: "SUCCESS";
      }
    | {
        payload: null;
        status: "EXPIRED" | "INVALID";
      };
}
/**
 * 2023/03/26 - 토큰 검증 - by 1-blue
 * @param type 토큰 타입
 * @param token 검증할 토큰
 * @returns 검증 결과
 */
export const verifyToken: VerifyTokenHandler = (type, token) => {
  let secretKey = "";

  switch (type) {
    case "access":
      secretKey = process.env.ACCESS_SECRET;
      break;
    case "refresh":
      secretKey = process.env.REFRESH_SECRET;
      break;
  }

  try {
    const payload = verify(token, secretKey) as JwtPayload;

    return { payload, status: "SUCCESS" };
  } catch (error) {
    // 토큰 관련 에러
    if (error instanceof JsonWebTokenError) {
      // 인증 토큰 만료 ( 인증 토큰 재발급 )
      if (error.message === "jwt expired") {
        return { payload: null, status: "EXPIRED" };
      }
    }

    return { payload: null, status: "INVALID" };
  }
};

1️⃣ 쿠키 헤더 생성 함수

Node.jsexpress처럼 res.cookie 같은 기능을 찾지 못해서 직접 HeaderSet-Cookie를 등록하기 위해 사용하는 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface generateCookieHandler {
  (name: string, value: string, maxAge: number): string;
}

/**
 * 2023/03/26 - "Set-Cookie" 헤더 생성 - by 1-blue
 * @param name 쿠키 이름
 * @param value 쿠키 값
 * @param maxAge 쿠키 지속 시간
 * @returns 쿠키 헤더 값
 */
export const generateCookie: generateCookieHandler = (name, value, maxAge) => {
  const domain = process.env.NODE_ENV === "development" ? "localhost" : "배포할 경로로 수정";
  const path = "/";
  const sameSite = "Strict";

  return `${name}=${value}; Domain=${domain}; Path=${path}; SameSite=${sameSite}; Max-Age=${maxAge}; HttpOnly; Secure;`;
};

2️⃣ 미들웨어로 사용하는 HOC

인증만으로 사용하는 것은 아니고 여러 가지 목적으로 사용하는 Higher-Order-Function입니다.

  • 사용 목적
    1. 정해진 HTTP Method에 대한 응답만 처리
    2. 인증 및 서버에 유저 정보 등록
    3. 인증 토큰 만료 시 재발급
    4. 에러 처리
  • 문제점
    1. 반복되는 로직이 존재
    2. 가독성이 안 좋음
    3. 매번 요청 때마다 토큰에 일치하는 유저 정보를 DB에서 검색

일단은 마땅한 방법이 떠오르지 않아서 남겨두고 넘어가려고 합니다.
나중에 좋은 방법이 생각나면 수정하고 그에 대한 내용을 작성하겠습니다.
( 해당 함수는 여러 역할을 하기 때문에 개발하면서 계속 변경될 것 같습니다. )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import { prisma } from "@src/prisma";

// lib
import { generateAccessToken, verifyToken } from "@src/lib/auth";

// util
import { clearCookie, generateCookie } from "@src/utils/cookie";

// type
import type { NextApiHandler } from "next";

/** 2023/03/26 - HTTP Methods 타입 - by 1-blue */
type Methods = "GET" | "POST" | "DELETE" | "PATCH";

interface AuthMiddlewareConfig {
  methods: Methods[];
  handler: NextApiHandler;
  isAuth?: boolean;
}
interface WithAuthMiddleware {
  (config: AuthMiddlewareConfig): NextApiHandler;
}

// 2023/03/26 - method에 따른 라우팅을 쉽게 처리해주는 HOF + 접근 권한 확인 - by 1-blue
const withAuthMiddleware: WithAuthMiddleware =
  ({ methods, handler, isAuth = true }) =>
  async (req, res) => {
    // 정해진 메서드를 사용하지 않았다면
    if (!methods.includes(req.method as Methods)) return res.status(405).end();

    // 로그인이 필요한 접근이라면 ( 토큰 검사 후 "req.user"에 로그인한 데이터 넣어주는 로직 )
    if (isAuth) {
      // 쿠키에서 인증/리프래쉬 토큰 갖고오기
      const { bat, brt } = req.cookies;

      // 리프레쉬 토큰이 없는 경우
      if (!brt) {
        return res.status(401).json({
          message: "접근할 권한이 없습니다.\n로그인 후에 접근해주세요.",
        });
      }

      // 리프래쉬 토큰 유효성 검사
      const verifyRefreshToken = verifyToken("refresh", brt);

      // 유효하지 않은 토큰
      if (verifyRefreshToken.status === "INVALID") {
        // 유효하지 않은 토큰 지우기
        res.setHeader("Set-Cookie", [
          clearCookie("brt", brt),
          clearCookie("bat", bat),
        ]);

        return res.status(401).json({
          message: "유효하지 않은 토큰입니다.\n다시 로그인해주세요.",
        });
      }
      // 리프래쉬 토큰 유효기간 만료
      if (verifyRefreshToken.status === "EXPIRED") {
        // 만료된 토큰 지우기
        res.setHeader("Set-Cookie", [
          clearCookie("brt", brt),
          clearCookie("bat", bat),
        ]);

        return res.status(401).json({
          message: "만료된 토큰입니다.\n다시 로그인해주세요.",
        });
      }
      // 안전한 타입을 위해 검사
      if (verifyRefreshToken.status !== "SUCCESS") {
        return res.status(500).json({
          message: "서버측 문제입니다.\n잠시후에 다시 시도해주세요!",
        });
      }

      // 인증 토큰이 없는 경우
      if (!bat) {
        // 인증 토큰 생성
        const accessToken = generateAccessToken({
          idx: verifyRefreshToken.payload.idx,
        });

        // 인증 토큰 쿠키로 등록
        res.setHeader(
          "Set-Cookie",
          generateCookie("bat", accessToken, 1000 * 60 * 60)
        );

        // 리다이렉트
        return res.status(302).redirect(req.url || "/");
      }

      // 인증 토큰이 있는 경우
      // 인증 토큰 유효성 검사
      const verifyAccessToken = verifyToken("access", bat);

      // 유효하지 않은 인증 토큰
      if (verifyAccessToken.status === "INVALID") {
        // 유효하지 않은 토큰 지우기
        res.setHeader("Set-Cookie", clearCookie("bat", bat));

        return res.status(401).json({
          message: "유효하지 않은 토큰입니다.\n다시 로그인해주세요.",
        });
      }
      // 인증 토큰 유효기간 만료 ( 인증 토큰 재발급 )
      if (verifyAccessToken.status === "EXPIRED") {
        // 인증 토큰 생성
        const accessToken = generateAccessToken({
          idx: verifyRefreshToken.payload.idx,
        });

        // 인증 토큰 쿠키로 등록
        res.setHeader(
          "Set-Cookie",
          generateCookie("bat", accessToken, 1000 * 60 * 60)
        );

        // 리다이렉트
        return res.status(302).redirect(req.url || "/");
      }
      // 안전한 타입을 위해 검사
      if (verifyRefreshToken.status !== "SUCCESS") {
        return res.status(500).json({
          message: "서버측 문제입니다.\n잠시후에 다시 시도해주세요!",
        });
      }

      try {
        // 토큰에 일치하는 유저 검색
        const exUser = await prisma.user.findUnique({
          where: { idx: verifyRefreshToken.payload.idx },
        });

        // 존재하지 않는 유저 ( 인증 토큰을 갖고 회원 탈퇴한 경우 )
        if (!exUser)
          return res.status(404).json({
            message: "존재하지 않는 유저입니다.\n다시 로그인해주세요.",
          });

        // 비밀번호 제외하기
        const { password, ...user } = exUser;

        // 응답 객체에 유저 정보 등록
        Object.defineProperty(req, "user", {
          value: user,
          writable: false,
          configurable: false,
        });
      } catch (error) {
        // 알 수 없는 서버측의 에러
        console.error("middleware token error >> ", error);
        return res
          .status(500)
          .json({ message: "서버측 문제입니다.\n잠시후에 다시 시도해주세요!" });
      }
    }

    try {
      await handler(req, res);
    } catch (error) {
      console.error("middleware server error >> ", error);

      return res
        .status(500)
        .json({ message: "서버측 문제입니다.\n잠시후에 다시 시도해주세요!" });
    }
  };

export default withAuthMiddleware;

🎞️ 로그인 로직의 흐름

일단 전체적인 흐름에 대해 간략하게 설명하고 이후에 코드를 보면서 더 자세하게 설명하겠습니다.

  1. 브라우저에서 idpassword를 서버로 전달
  2. 서버에서 idpassword에 해당하는 유저 있는지 확인 ( prisma, bcrypt )
  3. 유저가 존재한다면 JWT로 만들어진 AccessTokenRefreshToken 생성 후 쿠키에 동봉
  4. 유저 정보를 요청하는 엔드포인트로 리다이렉트
  5. 엑세스 토큰을 이용해서 해당 유저 정보를 클라이언트로 응답
  6. 이후에 접근할 때마다 미들웨어처럼 동작하는 래퍼 함수를 이용해서 유저 데이터 찾기
  7. 엑세스 토큰 만료 시 재생성 / 리프래쉬 토큰 만료 시 모든 토큰 제거

0️⃣ 정보 확인과 토큰 생성 및 리다이렉트

prismabcrypt를 사용해서 클라이언트에게 받은 정보에 해당하는 유저가 있는지 먼저 확인합니다.
해당 유저가 없다면 403으로 응답하고 끝냅니다.

만약 유저가 존재한다면 해당 유저에게 부여할 AccessTokenRefreshToken을 생성하고 쿠키에 넣습니다.
쿠키를 동봉할 때는 HeaderSet-Cookie에 배열 형태로 넣어주면 두 개의 쿠키가 동시에 들어가게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
interface LogInBody {
  id: string;
  password: string;
}

const handler: NextApiHandler = async (req, res) => {
  try {
    // 로그인
    if (req.method === "POST") {
      const { id, password } = req.body as LogInBody;

      // 아이디가 일치하는 유저 있는지 검색
      const exUser = await prisma.user.findUnique({
        where: { id },
        select: { idx: true, password: true },
      });

      // 아이디에 해당하는 유저가 존재하지 않음
      if (!exUser)
        return res.status(403).json({ message: "존재하는 유저가 없습니다." });

      // 비밀번호 일치 여부 확인
      const isValidated = await bcrypt.compare(password, exUser.password);
      // 비밀번호 불일치
      if (!isValidated) {
        return res
          .status(403)
          .json({ message: "비밀번호가 일치하지 않습니다." });
      }

      // 인증/리프레쉬 토큰 생성
      const accessToken = generateAccessToken({ idx: exUser.idx });
      const refreshToken = generateRefreshToken({ idx: exUser.idx });

      // 인증/리프래쉬 토큰 쿠키로 등록
      res.setHeader("Set-Cookie", [
        generateCookie("bat", accessToken, 1000 * 60 * 60),
        generateCookie("brt", refreshToken, 1000 * 60 * 60 * 24 * 7),
      ]);

      return res
        .status(200)
        .json({ message: "로그인에 성공했습니다.\n메인 페이지로 이동됩니다." });
    }
  } catch (error) {
    console.error("/api/login error >> ", error);

    return res
      .status(500)
      .json({ message: "서버측 문제입니다.\n잠시후에 다시 시도해주세요!" });
  }
};

1️⃣ 로그인한 유저 정보 응답

/api/me라는 엔드 포인트를 이용해서 로그인한 유저의 정보를 얻을 수 있습니다.

여기서 중요한 점은 위에서 작성한 withAuthMiddleware()를 통해서 로그인된 유저인지, 검증된 토큰인지 확인하는 로직을 처리한다는 점입니다.
아래처럼 엔드 포인트의 라우터를 실행하기 전에 withAuthMiddleware()를 먼저 실행합니다.
그리고 isAuth를 통해서 인증된 유저만 접근이 가능한 부분인지 확인합니다.

작성된 로직을 통해서 인증 토큰이 만료되었다면 재발급하고, 리프래쉬 토큰이 만료되었다면 토큰을 제거합니다.
그리고 토큰이 유효하다면 req.user에 현재 로그인한 유저의 데이터를 넣어줍니다.
따라서 다른 라우터에서는 안전하게 로그인한 유저의 정보를 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// lib
import withAuthMiddleware from "@src/lib/middleware";

// type
import type { NextApiHandler } from "next";

const handler: NextApiHandler = async (req, res) => {
  console.log(req.user); // "password"를 제외한 로그인한 유저의 데이터

  // ... 나머지 생략

  return res.json({ message: "일단 OK" });
};

export default withAuthMiddleware({
  methods: ["GET"],
  handler,
  isAuth: true,
});

2️⃣ 타입 재정의

단, 위처럼 사용하는 경우 req.user라는 속성을 TypeScript에서 알지 못하기 때문에 타입을 재정의해줘야합니다.

(1)처럼 사용하고 싶은데 적용이 안돼서 NextApiRequest의 부모인 IncomingMessage에 적용할 타입을 명시했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import type { User } from "@prisma/client";

/** "NextApiRequest" 재정의 ( TODO: 정확히는 모르겠으나 "IncomingMessage"를 상속받음 ) */
declare module "http" {
  interface IncomingMessage {
    user?: Omit<User, "password">;
  }
}

// (1) FIXME: 아래처럼 사용하고 싶은데 안 되는 이유를 모르겠음
// interface NextApiRequest {
//   user: Omit<User, "password">;
// }

📮 레퍼런스

  1. Next.js middleware matcher

  2. 1-blue - JWT
  3. 1-blue - Cookie
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.