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는 gofiber/jwt 라이브러리를 이용했다.
일단 먼저 필요한 모듈들을 설치해준다.
go get -u github.com/gofiber/jwt/v3
go get -u github.com/golang-jwt/jwt/v4
gofiber/jwt
는 HS256
, 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!")
}
AppController
에 router
를 멤버변수로 추가시켰고, 인증이 필요한 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문을 두 번 돌게 된다지만 어차피 원소가 많지도 않을 거고 시작할 때 한 번만 돌거라서 뭐...
제대로 작동한다. 그럼 이제 키를 발급받아서 진행해보도록 한다.
키 발급을 위해 AppController
에 Login
메소드를 추가하였다.
// 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/jwt
의 Config
에는 다양한 설정값이 존재하는데, 여기서는 SuccessHandler
와 ErrorHandler
만 이용해볼 것이다.
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 인증을 통과하면 Locals
에 user
라는 키값으로 등록되기 때문에 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 정말 좋습니다..