Golang Fiber 세션 미들웨어 구현하기

때로는 클래식하게

https://github.com/ilcm96/dku-aegis-library-system

간단한 JWT 대신 세션을 사용한 이유

요즘 인터넷을 보다 보면 정말 많은 글에서 JWT를 사용자 인증에 활용한다. Stateless 한 HTTP의 특징과 DB에 접근할 필요 없이 인증인가를 진행할 수 있다는 것은 분명한 장점이다. 그러나 MSA나 처리량이 높은 서비스가 아닌 이상 일반적인 모놀리틱 어플리케이션에선 세션을 사용하는 것이 더 좋다고 생각한다.

예를 들면 로그아웃을 구현할 때 세션의 경우 세션 ID를 세션 저장소에서 삭제하면 되지만 JWT는 블랙리스트를 구현해야 하는 등, 결국 요구사항이 늘어나면 늘어날 수록 과연 JWT가 진정으로 Stateless 한건지 의문이 드는 상황에 늘어난다.

- 교내 동아리 도서 관리 프로젝트 회고

위 글에서도 작성했듯이 JWT 기반 인증에 이런 저런 인증을 추가하다 보면 Stateless 하다는 생각이 들지 않았고 결정적으로 요구사항에 로그아웃이 있었는데 이를 JWT로 구현하는 것은 조금 까다로웠다.

세션 기반 인증의 플로우와 JWT와의 차이점

먼저 세션 기반 인증의 플로우를 간단하게 살펴보자.

1번부터 4번까지는 일반적인 과정이고 5번부터 JWT와 같은 토큰 기반 인증과 달라진다.

토큰 기반 인증에서는 서버에서 토큰을 생성하고 반환만 하고 따로 저장하지 않는 반면 세션 기반 인증에서는 세션 정보 별도로 저장하고 세션 ID를 클라이언트에 쿠키에 실어서 반환한다.

이후 클라이언트에서 요청을 보낼 때 요청에 자동으로 담기는 쿠키 특성과 함께 세션 ID를 포함해서 서버에 요청을 날리고 세션의 유효성을 검증하게 된다. Stateless한 JWT와는 다르게 세션 저장소에서 세션 정보를 가져와 유효성을 검증하는, 즉 Stateful한 인증 방법이다.

5 ~ 10번은 기존 글에서 쉽게 찾을 수 있는 Redis를 이용한 CRUD이기 때문에, 본 글에서는 11번 세션 유효성 검증 에서 사용한 미들웨어 대해 다뤄보고자 한다.

세션 미들웨어 이미 내장 되어있는데?

그렇다. 사실 Fiber는 세션 미들웨어를 기본적으로 내장하고 있다. 그러나 프로젝트를 진행하면서 더 많은 기능들이 필요해졌고 요구사항을 만족시키기에는 내장 미들웨어의 기능이 아쉬웠다. 따라서 어짜피 프로젝 트 목적 자체가 학습인 만큼 직접 세션 기반 인증에 필요한 미들웨어를 구현하기로 마음먹었다.

Middleware 함수 구조

미들웨어를 작성하기 전에 먼저 미들웨어의 작동법을 대략적으로 살펴보자.

미들웨어는 위 사진에서 처럼 사용자의 요청이 Handler로 전달되기 전이나, Handler의 응답이 사용자에게 전달되기 전에 작동한다.

func BeforeHandler() fiber.Handler {
    return func(c *fiber.Ctx) error {
        doSomething()
        return c.Next()
    }
}

요청이 핸들러로 전달되기 전에 동작하는 미들웨어는 위와 같이 c *fiber.Ctx 매개변수를 통해 특정 작업을 실행하고 다음 핸들러나 미들웨어로 넘어가기 위해 c.Next() 를 호출한다.

세션을 통한 로그인 구현을 위해 인증, 갱신 미들웨어를 구현했고 모두 위 타입의 미들웨어이다.

func AfterHandler() fiber.Handler {
    return func(c *fiber.Ctx) error {
        c.Next()
        doSomething()
        return someErr
    }
}

반대로 핸들러의 응답이 사용자에게 전달되기 전에 작동하는 미들웨어의 경우 먼저 c.Next() 를 호출해 요청을 처리하고 나서 c.Next() 의 결과값이나 c *fiber.Ctx 값에 따라서 작업을 실행하고 사용자에게 응답을 전달한다.

이러한 타입의 미들웨어는 slog 를 통한 로깅 미들웨어에 사용했다.

인증 미들웨어

가독성을 위해 일부 에러 처리 코드는 생략 처리하였음

func NewSessionAuth(redisClient *redis.Client) fiber.Handler {
    return func(c *fiber.Ctx) error {
        sessId := c.Cookies("session_id")
        data, err := redisClient.Get(context.Background(), sessId).Bytes()
        if errors.Is(err, redis.Nil) {
            c.Cookie(&fiber.Cookie{
                Name:    "session_id",
                Value:   "",
                Expires: time.Now().Add(-time.Hour),
            })
            return redirectToSignInURL(c)
        }

        userId, _ := strconv.Atoi(strings.Split(sessId, ":")[0])

        sess := decodeSessionData(data)
        if err != nil {
            util.LogErrWithReqId(c, err)
            return c.SendStatus(fiber.StatusInternalServerError)
        }

        c.Context().SetUserValue("user-id", userId)
        c.Context().SetUserValue("is-admin", sess.IsAdmin)

        return c.Next()
    }
}
  1. 쿠키에서 session_id 가져오기

  2. Redis에서 session_idkey 로 가지고 있는 value 가져오기

    • 만약 조회되지 않는다면 애초에 발급된 적이 없는 세션이거나, 만료된 세션이므로 쿠키 지우고 로그인 페이지로 리다이렉트
  3. session_iduserId:uuidv4 의 형태이고 userId 는 정수이므로 split 후 형 변환

  4. Redis에서 가져온 valuegob 패키지로 인코딩되어 있으므로 디코딩

  5. 다음 미들웨어나 핸들어에서 사용할 수 있도록 c.Context().SetUserValue() 메서드로 userId와 관리자 여부를 저장

갱신 미들웨어

가독성을 위해 일부 에러 처리 코드는 생략 처리하였음

func NewRenewSession(redisClient *redis.Client) fiber.Handler {
    return func(c *fiber.Ctx) error {
        sessId := c.Cookies("session_id")

        ttl, err := redisClient.TTL(context.Background(), sessId).Result()
        if ttl < 5*time.Minute {
            redisClient.Expire(context.Background(), sessId, 10*time.Minute)

            c.Cookie(&fiber.Cookie{
                Name:     "session_id",
                Value:    sessId,
                Path:     "/",
                Expires:  time.Now().Add(10 * time.Minute),
                HTTPOnly: true,
                SameSite: "Strict",
            })
            return c.Next()
        }

        return c.Next()
    }
}
  1. 쿠키에서 session_id 가져오기

  2. Redis에서 해당 key 의 TTL 조회

    • 만약 5분안에 만료된다면 10분으로 TTL 갱신하고 쿠키의 Expires 도 현재 시각으로부터 10분 후로 다시 설정 후 반환

    • 아직 시간이 남아있다면 바로 c.Next() 호출

미들웨어 사용하기

// PUBLIC ROUTE
app.Get("/signup",viewController.SignUp)
app.Get("/signin", viewController.Signin)
app.Post("/api/signup", userController.SignUp)
app.Post("/api/signin", userController.SignIn)

// RESTRICTED ROUTE
app.Use(middleware.NewSessionAuth(db.RedisClient())) // 인증 미들웨어
app.Use(middleware.NewRenewSession(db.RedisClient())) // 갱신 미들웨어

app.Get("/", viewController.Index)
app.Get("/mypage", viewController.MyPage)
...

미들웨어를 사용하겠다고 선언한 이후에 나오는 핸들러와 미들웨어에 적용되기 때문에 로그인 없이도 접근할 수 있어야하는 로그인과 회원가입 페이지 이후에 적용했다.