이번에 GoFiber 서버에 세션 기반 사용자 인증을 추가했던 내용을 정리해둔다.
난 원래 JWT(JSON Web Token)기반 사용자 인증을 선호했었다. JWT를 쓰면서 클라이언트 측에 저장되는 토큰값이 항상 불안했기에 자주 "secure storage for JWT" 같은 키워드로 검색을 했었는데, 그러다 아래와 같은 글을 발견했다.
Stateless JWT tokens cannot be invalidated or updated, and will introduce either size issues or security issues depending on where you store them. Stateful JWT tokens are functionally the same as session cookies, but without the battle-tested and well-reviewed implementations or client support.
Unless you work on a Reddit-scale application, there's no reason to be using JWT tokens as a session mechanism. Just use sessions.
해당 글에 상당히 공감을 했기에, 새로 짜는 서비스에서는 세션 기반으로 사용자 인증을 수행하기로 결정했다.
세션 기반 사용자 인증은 사용자의 "세션"을 서버의 데이터베이스에 저장하고, 클라이언트 측에는 해당 세션을 찾을 수 있는 세션키만 저장해두는 방법을 말한다. 클라이언트가 해당 세션키를 갖고 API를 호출하면, 서버는 세션키를 갖고 "세션" 데이터베이스에 접근해서, 해당 세션이 존재하는지, 그리고 이 사용자가 누군지 확인한다. 보통 "세션"에는 사용자 ID 정도가 저장되어 있다.
세션은 서버 쪽에서 저장, 관리하기 때문에 사용자의 로그인/로그아웃을 서버에서 결정할 수 있고, 세션키가 부당하게 유출되거나 했을 때, 즉각적으로 서버에서 비활성화 시킬 수 있는 장점이 있다. 대신 사용자 세션을 관리할 중앙화된 데이터베이스가 필요하고, 수많은 사용자가 동시에 접속하는 서비스의 경우(위의 글에서 예로 든 Reddit), 세션을 관리하는 데이터베이스의 스케일링 문제가 있다는 단점이 있다. 그래서 백엔드 구조가 분산화되어 있는 마이크로서비스 아키텍처 구조 등에서는 사용하기 어렵다.
세션 기반 사용자 인증을 구현하기 위해서는 아래 사항들에 대한 결정이 필요하다.
즉, 모두 저장의 문제인데, 일반적으로 다음과 같은 선택을 한다.
fetch
등의 함수를 이용하여 API를 호출할 때, Origin이 같을 경우 자동으로 포함되어 서버로 전송된다. 쿠키는 매우 작고 가볍지만, "자동으로" 포함된다는 특성 때문에 보안 측면에서 여러 약점이 있고, 수십년동안 다양한 방법으로 보완되어 왔다. 보통 세션키를 저장할 때는 httpOnly
와 secure
플래그를 포함하여 사용한다.세션 기반 사용자 인증 기능 구현에서 사용할 쿠키는 httpOnly
와 secure
옵션이 포함된 쿠키이다. 여기서 httpOnly
는 쿠키가 클라이언트 측의 JavaScript 코드에 의해 접근이 불가능함을 말한다. 예를 들어, 원래 프론트엔드의 JavaScript 코드에서 document.cookie
API를 통해 저장된 쿠키들을 읽고, 쓰고, 변경할 수 있지만, httpOnly
쿠키는 해당 API를 통해 접근이 불가능하다. 오직 HTTP 호출에 대해서만 브라우저가 자동으로 포함시킨다. 그렇기에 JavaScript 삽입을 통한 쿠키 가로채기 공격 등에 대해 안전하다.
두번째로 secure
옵션은 오직 SSL/TLS 호출에 대해서만 해당 쿠키를 포함하는 걸 의미한다. 보안을 위한 가장 기본적인 조치라고 할 수 있다. 또한 secure
옵션은 Cross-Origin 구성(프론트엔드 서버와 백엔드 서버의 호스트가 다른 경우)에서는 필수적으로 사용해야 하기도 하다.
httpOnly
와 secure
옵션으로 Cross-Origin 쿠키를 사용하기 위해서는 CORS 측면에서 몇가지 제약 사항이 있다.
Access-Control-Allow-Credentials
가 true
여야 한다.Access-Control-Allow-Origin
과 Access-Control-Allow-Headers
에 와일드카드(*
) 값을 할당하면 안된다. 어떤 Origin 과 Headers 들이 가능한지 명시적으로 정의되어야 한다.그래서 CORS 관련 GoFiber 앱 코드를 아래와 같이 변경해주자.
app := fiber.New()
...
app.Use(cors.New(cors.Config{
// TODO: production 에서 수정
AllowOrigins: "https://localhost:3000",
AllowMethods: strings.Join([]string{
fiber.MethodGet,
fiber.MethodPost,
fiber.MethodDelete,
fiber.MethodPatch,
}, ","),
AllowCredentials: true,
}))
이제 세션을 저장할 데이터베이스 설정을 해주어야 한다. GoFiber에는 여러 저장소들이 미리 정의되어 있다. 난 이미 MongoDB를 쓰고 있었기 때문에, 일단 MongoDB 에 세션을 추가했다.
import (
...
"github.com/gofiber/fiber/v2/middleware/session"
"github.com/gofiber/storage/mongodb"
...
)
// 전역변수로 선언해서 사용해도 좋다
var store *session.Store
func NewSessionStore() {
storage := mongodb.New(mongodb.Config{
ConnectionURI: "mongodb://...",
})
// Session 저장소 생성
store = session.New(session.Config{
Storage: storage,
...
})
}
여기서 여러 옵션이 가능하지만, 기본적으로 secure
, httpOnly
, same-site
옵션을 조정해준다.
func NewSessionStore() {
...
// Session 저장소 생성
store = session.New(session.Config{
...
CookieSecure: true,
CookieHTTPOnly: true,
CookieSameSite: "None", // For cross-origin
})
}
그러면 이제 App 생성 시 NewSessionStore
함수를 호출해주도록 하자.
func New() *fiber.App {
...
NewSessionStore()
...
app := fiber.New()
...
return app
}
GoFiber 공식 홈페이지에서는 세션 관련 코드들을 Middleware 섹션에서 다루고 있지만, 엄밀히 말해서 미들웨어는 아니다. 그보다는 데이터베이스 설정에 가깝다. 그렇기 때문에, 데이터베이스 설정 코드 근처에서 같이 설정해주면 되겠다.
세션 저장소로 할 일은 쓰고, 읽고, 지우는 것이다. 사용자가 로그인을 하면 쓰고, 사용자가 로그아웃을 하면 지우고, 인증이 필요한 API에서는 읽는 것이다.
var store *session.Store
...
func SetSession(userID string, c *fiber.Ctx) error {
// Fiber Context 에 맞춰 세션 저장소를 불러온다.
sess, err := store.Get(c)
if err != nil {
return err
}
sess.Set("user", userID)
// set-cookie 헤더에 자동으로 세션키를 포함한다
if err := sess.Save(); err != nil {
return err
}
}
var store *session.Store
...
func GetUserIDFromSession(userID string, c *fiber.Ctx) (string, error) {
// Fiber Context 에 맞춰 세션 저장소를 불러온다.
sess, err := store.Get(c)
if err != nil {
return err
}
raw := sess.Get("user")
if raw == nil {
return "", errors.New("user not logged in")
}
userID, ok := raw.(string)
if !ok {
return "", errors.New("malformed session")
}
return userID, nil
}
func RemoveAuthenticatedUserID(c *fiber.Ctx) error {
// Fiber Context 에 맞춰 세션 저장소를 불러온다.
sess, err := store.Get(c)
if err != nil {
return errors.WithStack(err)
}
if err := sess.Destroy(); err != nil {
return errors.WithStack(err)
}
return nil
}
sess.Destroy()
함수는 클라이언트 쿠키까지 제거한다.
프론트엔드 코드에서 중요한 점은 fetch
함수를 호출할 때 credentials: 'include'
값을 추가해주는 것이다.
await fetch('https://...', {
method: 'POST',
...
credentials: 'include'
})
Set-Cookie
헤더를 Response 로 받는 호출, 즉 로그인 API 호출 같은 경우에도 credentials
를 추가해주어야 하니, 주의하자.