Golang fiber에서 JWT 붙이기

박재훈·2022년 11월 12일
0

GO

목록 보기
6/23
post-thumbnail
post-custom-banner

Fiber 웹프레임워크에서 JWT를 이용해서 유저 관리 기능을 구현하려고 한다.
처음 할 때는 꽤나 복잡해 보였었는데 막상 하고서 보니 생각보다 간단했다.
개발을 진행하면서 문제를 마주하고 해결한 순서로 기술한다.

개요

health-check 엔드포인트를 가진 AppController를 만들었다.

// app.controller.go

type AppController struct {}

func NewAppController(router fiber.Router) *AppController {
	c := &AppController{}
	router.Get("/health", c.HealthCheck)
	return c
}

// @tags App
// @router /api/health [get]
func (c *AppController) HealthCheck(ctx *fiber.Ctx) error {
	return ctx.JSON(fiber.Map{
		"success": true,
	})
}

이 엔드포인트는 GET /api/health 주소를 가진다. 생성자에는 /api 라우터그룹을 넣어줄 것이다.

// main.go

app := fiber.New()
api := app.Group("/api")
app.Get("/swagger/*", swagger.HandlerDefault)

appController := controller.NewAppController(api)
_ = appController

if err := app.Listen(":3001"); err != nil {
	log.Fatal(err)
}

app이라는 fiber 객체를 만든 뒤, /api 경로를 가지는 라우터그룹 api를 만들었다.
그리고 이를 통해 AppController 객체를 생성하고서 서버를 실행시켰다.

swagger를 통해 확인해보니 접속이 잘 된다.
이제 아주 기초적인 서버 구성이 끝났으니 JWT를 붙여보도록 한다.

JWT 붙이기

JWT는 gofiber/jwt 라이브러리를 이용했다.
일단 먼저 필요한 모듈들을 설치해준다.

go get -u github.com/gofiber/jwt/v3
go get -u github.com/golang-jwt/jwt/v4

gofiber/jwtHS256, RS256 두가지 방식을 제공한다. 여기서는 RS256을 이용하기로 했다.

먼저 gofiber/jwt 깃허브 예시에 나온대로 미들웨어를 추가했다.

// main.go

api := app.Group("/api")
app.Get("/swagger/*", swagger.HandlerDefault)

rng := rand.Reader
privateKey, err := rsa.GenerateKey(rng, 2048)
if err != nil {
	log.Fatalf("rsa.GenerateKey: %v", err)
}

// jwt middleware
app.Use(jwtware.New(jwtware.Config{
	SigningMethod: "RS256",
	SigningKey:    privateKey.Public(),
}))

appController := controller.NewAppController(api)
_ = appController

if err := app.Listen(":3001"); err != nil {
	log.Fatal(err)
}

그리고 실행시켜서 접속해봤더니...

아무 생각 없이 접속했다가 이게 뜨는 걸 보고 드는 생각이 '어떻게 인증이 필요한 엔드포인트를 구분하지?' 였다.

인증 필요/불필요 엔드포인트 구분

해결책은 의외로 간단했는데, JWT 미들웨어를 붙이기 전에 엔드포인트를 붙이면 되는 것이었다.
그런데 컨트롤러 하나에 인증이 필요한 엔드포인트와 필요하지 않은 엔드포인트들이 섞여 있는데 그걸 일일이 구분해준 것은 너무 노가다스러워서, 이걸 구분해주기 위한 Controller라는 인터페이스를 추가하였다.

// controller.hook.go

type Controller interface {
	// allow access without jwt
	Accessible()

	// need jwt to access
	Restricted()
}

Accessible() 메소드에는 인증이 필요없는, Restricted() 메소드에는 인증이 필요한 엔드포인트 설정이 들어간다.
이걸 AppController에 적용시켜봤다. 또, 테스트를 위해 인증이 필요한 엔드포인트도 추가해보았다.

// app.controller.go

type AppController struct {
	router fiber.Router
}

func NewAppController(router fiber.Router) *AppController {
	return &AppController{
		router: router,
	}
}

func (c *AppController) Accessible() {
	c.router.Get("/health", c.HealthCheck)
}

func (c *AppController) Restricted() {
	c.router.Get("/tests", c.TestEndpoint)
}

// @tags App
// @router /api/health [get]
func (c *AppController) HealthCheck(ctx *fiber.Ctx) error {
	return ctx.JSON(fiber.Map{
		"success": true,
	})
}

// @tags App
// @router /api/tests [get]
// @security Authorization
func (c *AppController) TestEndpoint(ctx *fiber.Ctx) error {
	return ctx.SendString("good!")
}

AppControllerrouter를 멤버변수로 추가시켰고, 인증이 필요한 GET /api/tests 엔드포인트를 추가시켰다.
그리고 Accessible()Restricted() 메소드를 구현하여 각각에서 인증이 필요한 엔드포인트와 필요하지 않은 엔드포인트들을 추가시켜주었다.

// main.go

controllers := []hook.Controller{
	controller.NewAppController(api),
}

for _, c := range controllers {
	c.Accessible()
}

app.Use(jwtware.New(jwtware.Config{
	SigningMethod: "RS256",
	SigningKey:    privateKey.Public(),
}))

for _, c := range controllers {
	c.Restricted()
}

controllers 배열을 만들고 Accessible()들을 먼저 실행시켜준 뒤 JWT 미들웨어를 부착하고 Restricted()들을 실행시켜주었다. for문을 두 번 돌게 된다지만 어차피 원소가 많지도 않을 거고 시작할 때 한 번만 돌거라서 뭐...


제대로 작동한다. 그럼 이제 키를 발급받아서 진행해보도록 한다.

키 발급받기

키 발급을 위해 AppControllerLogin 메소드를 추가하였다.

// app.controller.go

// @tags App
// @router /api/login [post]
// @param credential body map[string]string true "login data"
func (c *AppController) Login(ctx *fiber.Ctx) error {
	var body map[string]string // { "id": string, "password": string }
	if err := ctx.BodyParser(&body); err != nil {
		return ctx.Status(fiber.StatusBadRequest).SendString("wrong request")
	}

	users := map[string]string{
		"user1": "1234",
		"user2": "abcd",
		"user3": "abc123",
	}

	if users[body["id"]] != body["password"] {
		return ctx.Status(fiber.StatusUnauthorized).SendString("wrong id or password")
	}

	// Create the Claims
	claims := jwt.MapClaims{
		"name": body["id"],
		"exp":  time.Now().Add(time.Hour * 24).Unix(),
	}

	// Create token
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)

	// Generate encoded token and send it as response.
	t, err := token.SignedString(c.privateKey)
	if err != nil {
		log.Printf("token.SignedString: %v", err)
		return ctx.SendStatus(fiber.StatusInternalServerError)
	}

	return ctx.JSON(fiber.Map{
		"token": t,
	})
}

body 파라미터로 {"id": string, "password": string} 형태의 데이터를 받고, 이를 파싱하여 users와 비교한다. users는 테스트를 위해 임의로 만들어놓은 맵이다. 실제로는 bcrypt 같은 라이브러리를 이용해서 엄격하게 검증해야 한다.

일치하는 ID와 비밀번호를 넣었더니 토큰이 잘 나온다. 그럼 이제 이 토큰을 이용하여 인증이 필요한 엔드포인트에 접근해보도록 한다.

토큰 사용하기

공식 예제에서의 사용법은 다음과 같이 나와 있다.

curl localhost:3000/restricted -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NjE5NTcxMzZ9.RB3arc4-OyzASAaUhC2W3ReWaXAt_z2Fd3BN4aWTgEY"

요청의 헤더에 Authorization: Bearer {token}을 추가하면 되는 것이다.
그래서 아까 얻은 토큰을 헤더에 넣고 접근하면,

curl -X 'GET' \
  'http://localhost:3001/api/tests' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNjY4MzM3OTkxLCJuYW1lIjoiSm9obiBEb2UifQ.HbHood0iheYLsWAeD18iojFpzzhJxXe0cf0vZfI4T9rSav2WdTEzU3sw7p4Ti0SrXodX864atqhkm19xqBAdMbgSGGWSnMXcRD-CU91u1HodvMdqGZ8epBMs7wYmL-NKYijHEGt2GIsbB7r8wOBaLi2-yM5MoaVHQgJ78ZAkXeC0SvOHMDtCP0euxBC9WwGoZfyDvLW0k9sbOdgcIxqmj0C4lINzo2HR_-uYCd2bDrMqWye4WLYl8992WX_ReyscLC6n0yFnonFx-7G6TVFCEwy4DdwksqyeMZYfte0KEuwra7kjbPsHPoV2TpjmcYsFhbhqPLbLxaIZLOX8YmXkkA'

훌륭하다!

커스터마이징

JWT 미들웨어를 붙일 때 성공/실패에 따라 원하는 형태의 리턴값을 설정할 수 있다.
gofiber/jwtConfig에는 다양한 설정값이 존재하는데, 여기서는 SuccessHandlerErrorHandler만 이용해볼 것이다.

app.Use(jwtware.New(jwtware.Config{
	SigningMethod: "RS256",
	SigningKey:    privateKey.Public(),
	SuccessHandler: func(c *fiber.Ctx) error {
		user := c.Locals("user").(*jwt.Token)
		claims := user.Claims.(jwt.MapClaims)
		name := claims["name"].(string)
		log.Printf("user '%s' accessing to '%s'", name, c.Request().URI().String())
		return c.Next()
	},
	ErrorHandler: func(c *fiber.Ctx, err error) error {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"status":  fiber.StatusUnauthorized,
			"message": "unauthorized",
		})
	},
}))

JWT 인증을 통과하면 Localsuser라는 키값으로 등록되기 때문에 SuccessHandler에서는 ctx.Locals("user")claim에 접근할 수 있다.

아까 claim{"name": string, "exp": number} 형태로 설정해놨기 때문에 name을 추출하여 로깅하는 코드를 SuccessHandler에 추가해놓았다.

또, ErrorHandler에는 원하는 형태의 미인증 리턴 타입을 정의해놓았다.

2022/11/12 20:47:24 user 'user1' accessing to 'http://localhost:3001/api/tests'

인증 성공 시 위와 같은 로그가 출력된다.

{
  "message": "unauthorized",
  "status": 401
}

실패 시에는 위와 같은 응답이 돌아온다.

결론

fiber 정말 좋습니다..

profile
생각대로 되지 않을 때, 비로소 코딩은 재미있는 법.
post-custom-banner

0개의 댓글