Goa(v2)でjwt認証

GoaでJWT認証できるAPIを作ろうとこちらのリンクを参考にしたのですがそれでもハマりポイントがあったのでメモ

goaで作ったAPIサーバにJWT認証を追加する | Fusic Tech Blog

jwtキーを発行するURIBasic認証をかけ、認証が成功するとキーを発行するようにしています。

design.go

package design

import (
    . "github.com/goadesign/goa/design"
    . "github.com/goadesign/goa/design/apidsl"
)

var _ = API("Secure", func() {
    Title("Secure API")
    Description("Secure API use JWT")
    BasePath("/api")
    Scheme("http")
    Host("localhost:8080")
})

var BasicAuth = BasicAuthSecurity("BasicAuth", func() {
    Description("Use client ID and client secret to authenticate")
})

var JWT = JWTSecurity("jwt", func() {
    Header("Authorization")
    Scope("api:access", "API access")
})

var _ = Resource("jwt", func() { // Resources group related API endpoints
    DefaultMedia(SuccessMedia) // services.
    Security(JWT, func() {
        Scope("api:access")
    })

    Action("signin", func() { // Actions define a single API endpoint together  // ←ここでBasic認証。成功するとJWTトークンが取れる
        Description("Get JWT Token") // with its path, parameters (both path
        Security(BasicAuth)
        Routing(GET("/jwt/signin")) // parameters and querystring values) and payload
        Response(NoContent, func() {
            Headers(func() {
                Header("Authorizatiton", String, "Generated JWT")
            })
        })
        Response(Unauthorized) // of HTTP responses.
    })

    Action("secure", func() {  // ←取得したJWTが必要なAPI
        Routing(GET("/jwt"))
        Response(OK)
        Response(Unauthorized)
    })
})

var SuccessMedia = MediaType("application/vnd.goa.jwt.test.success", func() {
    Description("A station of mine")
    Attributes(func() { // Attributes define the media type shape.
        Attribute("ok", Boolean, "Always true")
        Required("ok")
    })
    View("default", func() { // View defines a rendering of the media type.
        Attribute("ok") // Media types may have multiple views and must
    })
})

コード生成

goagen bootstrap -d github.com/hryktrd/jwtTest/design

実装

  • main.goにmiddleware追加
//go:generate goagen bootstrap -d github.com/hryktrd/jwtTest/design

package main

import (
    "context"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"

    jwtgo "github.com/dgrijalva/jwt-go"
    "github.com/goadesign/goa"
    "github.com/goadesign/goa/middleware"
    "github.com/goadesign/goa/middleware/security/jwt"
    "github.com/hryktrd/jwtTest/app"
)

func main() {
    // Create service
    service := goa.New("Secure")

    // Mount middleware
    service.Use(middleware.RequestID())
    service.Use(middleware.LogRequest(true))
    service.Use(middleware.ErrorHandler(service, true))
    service.Use(middleware.Recover())

        // ここから追加
    // JWTキー用追加コード
    pem, err := ioutil.ReadFile("./jwtkey/jwt.key.pub.pkcs8")
    if err != nil {
        fmt.Printf("%s", err)
        os.Exit(1)
    }
    key, err := jwtgo.ParseRSAPublicKeyFromPEM([]byte(pem))
    if err != nil {
        fmt.Printf("%s", err)
        os.Exit(1)
    }
    jwtHandler := func(h goa.Handler) goa.Handler {
        return func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error {
            return h(ctx, rw, req)
        }
    }
    app.UseJWTMiddleware(service, jwt.New(jwt.NewSimpleResolver([]jwt.Key{key}), jwtHandler, app.NewJWTSecurity()))

    basicAuthHandler := func(h goa.Handler) goa.Handler {
        return func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error {
            user, pass, ok := req.BasicAuth()
            if !ok || user != "foo" || pass != "bar" {
                errUnauthorized := goa.NewErrorClass("unauthorized", 401)
                return errUnauthorized("missing auth")
            }
            return h(ctx, rw, req)
        }
    }
    app.UseBasicAuthMiddleware(service, basicAuthHandler)
        //  追加ここまで

    // Mount "jwt" controller
    c := NewJWTController(service)
    app.MountJWTController(service, c)

    // Start service
    if err := service.ListenAndServe(":8080"); err != nil {
        service.LogError("startup", "err", err)
    }

}
  • JWTコントローラ実装 jwt.goにサインイン後JWTを返すところとJWTを読み込む機能を実装
package main

import (
    "fmt"
    "io/ioutil"
    "time"

    "github.com/hryktrd/jwtTest/app"

    jwtgo "github.com/dgrijalva/jwt-go"
    "github.com/goadesign/goa"
    "github.com/goadesign/goa/middleware/security/jwt"
    "github.com/gofrs/uuid"
)

// JWTController implements the jwt resource.
type JWTController struct {
    *goa.Controller
}

// NewJWTController creates a jwt controller.
func NewJWTController(service *goa.Service) *JWTController {
    return &JWTController{Controller: service.NewController("JWTController")}
}

// JWTを読み込む実装
// Secure runs the secure action.
func (c *JWTController) Secure(ctx *app.SecureJWTContext) error {
    // JWTController_Secure: start_implement

    // Retrieve the token claims
    token := jwt.ContextJWT(ctx)
    if token == nil {
        return fmt.Errorf("JWT token is missing from context") // internal error
    }
    claims := token.Claims.(jwtgo.MapClaims)

    // Use the claims to authorize
    subject := claims["sub"]
    if subject != "subject" {
        // A real app would probably use an "Unauthorized" response here
        res := &app.GoaJWTTestSuccess{OK: false}
        return ctx.OK(res)
    }

    res := &app.GoaJWTTestSuccess{OK: true}
    return ctx.OK(res)

    // JWTController_Secure: end_implement
}

// サインインしてJWTを返す実装
// Signin runs the signin action.
func (c *JWTController) Signin(ctx *app.SigninJWTContext) error {
    // JWTController_Signin: start_implement

    b, err := ioutil.ReadFile("./jwtkey/jwt.key")
    if err != nil {
        return fmt.Errorf("read private key file: %s", err) // internal error
    }
    privKey, err := jwtgo.ParseRSAPrivateKeyFromPEM(b)
    if err != nil {
        return fmt.Errorf("failed to parse RSA private key: %s", err) // internal error
    }
    token := jwtgo.New(jwtgo.SigningMethodRS512)
    in3m := time.Now().Add(time.Duration(3) * time.Minute).Unix()
    token.Claims = jwtgo.MapClaims{
        "iss":    "Issuer",                         // who creates the token and signs it
        "aud":    "Audience",                       // to whom the token is intended to be sent
        "exp":    in3m,                             // time when the token will expire (10 minutes from now)
        "jti":    uuid.Must(uuid.NewV4()).String(), // a unique identifier for the token
        "iat":    time.Now().Unix(),                // when the token was issued/created (now)
        "nbf":    2,                                // time before which the token is not yet valid (2 minutes ago)
        "sub":    "subject",                        // the subject/principal is whom the token is about
        "scopes": "api:access",                     // token scope - not a standard claim
    }
    signedToken, err := token.SignedString(privKey)
    if err != nil {
        return fmt.Errorf("failed to sign token: %s", err) // internal error
    }
    ctx.ResponseData.Header().Set("Authorization", "Bearer "+signedToken)
    return ctx.NoContent()

    return nil
    // JWTController_Signin: end_implement
}

JWT生成用鍵準備

$ mkdir jwtkey
$ ssh-keygen -t rsa -b 4096 -f jwtkey/jwt.key

公開鍵はPKCS8化しないといけないので変換

$ ssh-keygen -f .\jwtkey\jwt.key -e -m pkcs8 > .\jwtkey\jwt.key.pkcs8

↑このファイルがWindowsでやるとUTF16 LEになってしまったのでVSCodeでUTF8にして保存しなおしました。

実行

$ go run .\main.go .\jwt.go

localhost:8080/jwt/siginin にfoo/bar のBasic認証でアクセスするとBearerトークンが返され、そのトークンで localhost:8080/jwt にアクセスすると200が返ってきます。