如何在Go中使用JWK验证JWT签名?

29

我一直在寻找一个我能理解的例子,关于如何使用 Go 语言验证JWT的签名。

由于我正在使用 Okta,并且它使用 JWKs,因此这可能特别棘手。

当我收到 JWT 时,我可以轻松解码。但是我卡在了如何验证签名上。

下面我已经包含了一个JWT和JWK细节。有人能提供带有示例的签名验证吗?

您可以在https://oktaproxy.com/oidcgenerator.php获取所有这些信息-该站点将从Okta生成JWT,密钥可在https://companyx.okta.com/oauth2/v1/keys获得。

以下是JWT:

eyJhbGciOiJSUzI1NiIsImtpZCI6Ind5TXdLNEE2Q0w5UXcxMXVvZlZleVExMTlYeVgteHlreW1ra1h5Z1o1T00ifQ.eyJzdWIiOiIwMHUxOGVlaHUzNDlhUzJ5WDFkOCIsIm5hbWUiOiJva3RhcHJveHkgb2t0YXByb3h5IiwidmVyIjoxLCJpc3MiOiJodHRwczovL2NvbXBhbnl4Lm9rdGEuY29tIiwiYXVkIjoidlpWNkNwOHJuNWx4ck45YVo2ODgiLCJpYXQiOjE0ODEzODg0NTMsImV4cCI6MTQ4MTM5MjA1MywianRpIjoiSUQuWm9QdVdIR3IxNkR6a3RUbEdXMFI4b1lRaUhnVWg0aUotTHo3Z3BGcGItUSIsImFtciI6WyJwd2QiXSwiaWRwIjoiMDBveTc0YzBnd0hOWE1SSkJGUkkiLCJub25jZSI6Im4tMFM2X1d6QTJNaiIsInByZWZlcnJlZF91c2VybmFtZSI6Im9rdGFwcm94eUBva3RhLmNvbSIsImF1dGhfdGltZSI6MTQ4MTM4ODQ0MywiYXRfaGFzaCI6Im1YWlQtZjJJczhOQklIcV9CeE1ISFEifQ.OtVyCK0sE6Cuclg9VMD2AwLhqEyq2nv3a1bfxlzeS-bdu9KtYxcPSxJ6vxMcSSbMIIq9eEz9JFMU80zqgDPHBCjlOsC5SIPz7mm1Z3gCwq4zsFJ-2NIzYxA3p161ZRsPv_3bUyg9B_DPFyBoihgwWm6yrvrb4rmHXrDkjxpxCLPp3OeIpc_kb2t8r5HEQ5UBZPrsiScvuoVW13YwWpze59qBl_84n9xdmQ5pS7DklzkAVgqJT_NWBlb5uo6eW26HtJwHzss7xOIdQtcOtC1Gj3O82a55VJSQnsEEBeqG1ESb5Haq_hJgxYQnBssKydPCIxdZiye-0Ll9L8wWwpzwig

这里是钥匙:

{
   "keys":[
      {
         "alg":"RS256",
         "e":"AQAB",
         "n":"ok6rvXu95337IxsDXrKzlIqw_I_zPDG8JyEw2CTOtNMoDi1QzpXQVMGj2snNEmvNYaCTmFf51I-EDgeFLLexr40jzBXlg72quV4aw4yiNuxkigW0gMA92OmaT2jMRIdDZM8mVokoxyPfLub2YnXHFq0XuUUgkX_TlutVhgGbyPN0M12teYZtMYo2AUzIRggONhHvnibHP0CPWDjCwSfp3On1Recn4DPxbn3DuGslF2myalmCtkujNcrhHLhwYPP-yZFb8e0XSNTcQvXaQxAqmnWH6NXcOtaeWMQe43PNTAyNinhndgI8ozG3Hz-1NzHssDH_yk6UYFSszhDbWAzyqw",
         "kid":"wyMwK4A6CL9Qw11uofVeyQ119XyX-xykymkkXygZ5OM",
         "kty":"RSA",
         "use":"sig"
      },
      {
         "alg":"RS256",
         "e":"AQAB",
         "n":"nXv6FSAcMjuanQ2hIIUb8Vkqe94t98kPh2T8-0j6-Jq8pOclgKdtVeIZcBE9F_XiuJvg4b6WVs-uvA-pS8mmMvQ21xU5Q_37Cojv8v_QlHWETHwEJdXXiY2Xq5LgXDSwEhhdDZHSMQYDuvhp_P6nl2LNqqUvJkjoFWcnn2btgSIUQROIaDdxtx7_2h4oUi5u11BGSF2SZZiEpDAKT08Htv3uwXdwDA6ll99fbi8X8RmH5oY_tIZTeIzu50qHxElPewoYO8QrJYsO9oFcCPMHGxYWjXQEa-QZYgo0wS9zRIkeJc5kshc4-9Uhv2DVIjk_-ofGlML9ieggGyillBKptw",
         "kid":"GRF55Lbzgg4sANCmER-sm55eX_qUOpY8UTptDmDG_6U",
         "kty":"RSA",
         "use":"sig"
      }
   ]
}

我回答了一个类似的问题,并且我的答案展示了使用另一个库(https://github.com/square/go-jose/)来进行类似操作(在我看来更好)。 - trung
3个回答

48
下面是JWT解码和验证的示例。它同时使用了jwt-go和jwk包:

以下是JWT解码和验证的示例。它同时使用了jwt-gojwk包:

package main

import (
    "errors"
    "fmt"

    "github.com/dgrijalva/jwt-go"
    "github.com/lestrrat-go/jwx/jwk"
)

const token = `eyJhbGciOiJSUzI1NiIsImtpZCI6Ind5TXdLNEE2Q0w5UXcxMXVvZlZleVExMTlYeVgteHlreW1ra1h5Z1o1T00ifQ.eyJzdWIiOiIwMHUxOGVlaHUzNDlhUzJ5WDFkOCIsIm5hbWUiOiJva3RhcHJveHkgb2t0YXByb3h5IiwidmVyIjoxLCJpc3MiOiJodHRwczovL2NvbXBhbnl4Lm9rdGEuY29tIiwiYXVkIjoidlpWNkNwOHJuNWx4ck45YVo2ODgiLCJpYXQiOjE0ODEzODg0NTMsImV4cCI6MTQ4MTM5MjA1MywianRpIjoiSUQuWm9QdVdIR3IxNkR6a3RUbEdXMFI4b1lRaUhnVWg0aUotTHo3Z3BGcGItUSIsImFtciI6WyJwd2QiXSwiaWRwIjoiMDBveTc0YzBnd0hOWE1SSkJGUkkiLCJub25jZSI6Im4tMFM2X1d6QTJNaiIsInByZWZlcnJlZF91c2VybmFtZSI6Im9rdGFwcm94eUBva3RhLmNvbSIsImF1dGhfdGltZSI6MTQ4MTM4ODQ0MywiYXRfaGFzaCI6Im1YWlQtZjJJczhOQklIcV9CeE1ISFEifQ.OtVyCK0sE6Cuclg9VMD2AwLhqEyq2nv3a1bfxlzeS-bdu9KtYxcPSxJ6vxMcSSbMIIq9eEz9JFMU80zqgDPHBCjlOsC5SIPz7mm1Z3gCwq4zsFJ-2NIzYxA3p161ZRsPv_3bUyg9B_DPFyBoihgwWm6yrvrb4rmHXrDkjxpxCLPp3OeIpc_kb2t8r5HEQ5UBZPrsiScvuoVW13YwWpze59qBl_84n9xdmQ5pS7DklzkAVgqJT_NWBlb5uo6eW26HtJwHzss7xOIdQtcOtC1Gj3O82a55VJSQnsEEBeqG1ESb5Haq_hJgxYQnBssKydPCIxdZiye-0Ll9L8wWwpzwig`

const jwksURL = `https://companyx.okta.com/oauth2/v1/keys`

func getKey(token *jwt.Token) (interface{}, error) {

    // TODO: cache response so we don't have to make a request every time
    // we want to verify a JWT
    set, err := jwk.FetchHTTP(jwksURL)
    if err != nil {
        return nil, err
    }

    keyID, ok := token.Header["kid"].(string)
    if !ok {
        return nil, errors.New("expecting JWT header to have string kid")
    }

    if key := set.LookupKeyID(keyID); len(key) == 1 {
        return key[0].Materialize()
    }

    return nil, fmt.Errorf("unable to find key %q", keyID)
}

func main() {
    token, err := jwt.Parse(token, getKey)
    if err != nil {
        panic(err)
    }
    claims := token.Claims.(jwt.MapClaims)
    for key, value := range claims {
        fmt.Printf("%s\t%v\n", key, value)
    }
}

2
好的!我把代码放到了Go Playground上,这样其他人就可以看到代码了。https://play.golang.org/p/fkag0DMexY - user3888307
3
@JimWharton,Readme.md文件中有一个新的Raw方法的示例,该方法替代了Materialize。 - David
6
这不能与 lestrrat-go 版本的库一起使用。他们破坏了兼容性,Materialize() 方法不再存在。密钥集中的键现在是 *jwk.rsaPublicKey,我们需要 *rsa.PublicKey 才能使用 dgrijalva 的 jwt.Parse()。目前我没有解决方案。 - Ivaylo Novakov
2
@IvayloNovakov:“上面的return key[0].Materialize()已经被这两个调用所取代:var empty interface{}和然后是return nil, key[0].Raw(&empty)。但是,我还有一个问题,Parse方法总是返回一个错误:key的类型无效。有什么想法吗?” - Anton
2
使用 Raw 方法(从 lestrrat-go/jwx v1.x.y 开始)找到了解决方案: var pubkey interface{},然后 err := key[0].Raw(&pubkey) 并且 return pubkey, nil。确保对 err(Raw 方法的返回值)进行错误检查。 - Anton
显示剩余10条评论

6

我最近遇到了一个非常相似的用例,因此我阅读了一些RFC并编写了这个包:github.com/MicahParks/keyfunc

它允许您使用最受欢迎的JWT包,github.com/golang-jwt/jwt/v4(以前是github.com/dgrijalva/jwt-go),来解析令牌。它还可以自动在后台goroutine中重新加载JWKS的内容。

以下是使用您的JWKS和JWT的两个示例。第一个示例将通过HTTPS从远程URL加载JWKS。第二个示例将从静态JSON中加载JWKS。

通过HTTPS托管的JWKS

package main

import (
    "context"
    "log"
    "time"

    "github.com/golang-jwt/jwt/v4"

    "github.com/MicahParks/keyfunc"
)

func main() {
    // Get the JWKS URL.
    //
    // This is a sample JWKS service. Visit https://jwks-service.appspot.com/ and grab a token to test this example.
    jwksURL := "https://jwks-service.appspot.com/.well-known/jwks.json"

    // Create a context that, when cancelled, ends the JWKS background refresh goroutine.
    ctx, cancel := context.WithCancel(context.Background())

    // Create the keyfunc options. Use an error handler that logs. Refresh the JWKS when a JWT signed by an unknown KID
    // is found or at the specified interval. Rate limit these refreshes. Timeout the initial JWKS refresh request after
    // 10 seconds. This timeout is also used to create the initial context.Context for keyfunc.Get.
    options := keyfunc.Options{
        Ctx: ctx,
        RefreshErrorHandler: func(err error) {
            log.Printf("There was an error with the jwt.Keyfunc\nError: %s", err.Error())
        },
        RefreshInterval:   time.Hour,
        RefreshRateLimit:  time.Minute * 5,
        RefreshTimeout:    time.Second * 10,
        RefreshUnknownKID: true,
    }

    // Create the JWKS from the resource at the given URL.
    jwks, err := keyfunc.Get(jwksURL, options)
    if err != nil {
        log.Fatalf("Failed to create JWKS from resource at the given URL.\nError: %s", err.Error())
    }

    // Get a JWT to parse.
    jwtB64 := "eyJraWQiOiJlZThkNjI2ZCIsInR5cCI6IkpXVCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJXZWlkb25nIiwiYXVkIjoiVGFzaHVhbiIsImlzcyI6Imp3a3Mtc2VydmljZS5hcHBzcG90LmNvbSIsImlhdCI6MTYzMTM2OTk1NSwianRpIjoiNDY2M2E5MTAtZWU2MC00NzcwLTgxNjktY2I3NDdiMDljZjU0In0.LwD65d5h6U_2Xco81EClMa_1WIW4xXZl8o4b7WzY_7OgPD2tNlByxvGDzP7bKYA9Gj--1mi4Q4li4CAnKJkaHRYB17baC0H5P9lKMPuA6AnChTzLafY6yf-YadA7DmakCtIl7FNcFQQL2DXmh6gS9J6TluFoCIXj83MqETbDWpL28o3XAD_05UP8VLQzH2XzyqWKi97mOuvz-GsDp9mhBYQUgN3csNXt2v2l-bUPWe19SftNej0cxddyGu06tXUtaS6K0oe0TTbaqc3hmfEiu5G0J8U6ztTUMwXkBvaknE640NPgMQJqBaey0E4u0txYgyvMvvxfwtcOrDRYqYPBnA"

    // Parse the JWT.
    token, err := jwt.Parse(jwtB64, jwks.Keyfunc)
    if err != nil {
        log.Fatalf("Failed to parse the JWT.\nError: %s", err.Error())
    }

    // Check if the token is valid.
    if !token.Valid {
        log.Fatalf("The token is not valid.")
    }
    log.Println("The token is valid.")

    // End the background refresh goroutine when it's no longer needed.
    cancel()

    // This will be ineffectual because the line above this canceled the parent context.Context.
    // This method call is idempotent similar to context.CancelFunc.
    jwks.EndBackground()
}

从JWKS转换为JSON格式

package main

import (
    "encoding/json"
    "log"

    "github.com/golang-jwt/jwt/v4"

    "github.com/MicahParks/keyfunc"
)

func main() {
    // Get the JWKS as JSON.
    jwksJSON := json.RawMessage(`{"keys":[{"kty":"RSA","e":"AQAB","kid":"ee8d626d","n":"gRda5b0pkgTytDuLrRnNSYhvfMIyM0ASq2ZggY4dVe12JV8N7lyXilyqLKleD-2lziivvzE8O8CdIC2vUf0tBD7VuMyldnZruSEZWCuKJPdgKgy9yPpShmD2NyhbwQIAbievGMJIp_JMwz8MkdY5pzhPECGNgCEtUAmsrrctP5V8HuxaxGt9bb-DdPXkYWXW3MPMSlVpGZ5GiIeTABxqYNG2MSoYeQ9x8O3y488jbassTqxExI_4w9MBQBJR9HIXjWrrrenCcDlMY71rzkbdj3mmcn9xMq2vB5OhfHyHTihbUPLSm83aFWSuW9lE7ogMc93XnrB8evIAk6VfsYlS9Q"},{"kty":"EC","crv":"P-256","kid":"711d48d1","x":"tfXCoBU-wXemeQCkME1gMZWK0-UECCHIkedASZR0t-Q","y":"9xzYtnKQdiQJHCtGwpZWF21eP1fy5x4wC822rCilmBw"},{"kty":"EC","crv":"P-384","kid":"d52c9829","x":"tFx6ev6eLs9sNfdyndn4OgbhV6gPFVn7Ul0VD5vwuplJLbIYeFLI6T42tTaE5_Q4","y":"A0gzB8TqxPX7xMzyHH_FXkYG2iROANH_kQxBovSeus6l_QSyqYlipWpBy9BhY9dz"},{"kty":"RSA","e":"AQAB","kid":"ecac72e5","n":"nLbnTvZAUxdmuAbDDUNAfha6mw0fri3UpV2w1PxilflBuSnXJhzo532-YQITogoanMjy_sQ8kHUhZYHVRR6vLZRBBbl-hP8XWiCe4wwioy7Ey3TiIUYfW-SD6I42XbLt5o-47IR0j5YDXxnX2UU7-UgR_kITBeLDfk0rSp4B0GUhPbP5IDItS0MHHDDS3lhvJomxgEfoNrp0K0Fz_s0K33hfOqc2hD1tSkX-3oDTQVRMF4Nxax3NNw8-ahw6HNMlXlwWfXodgRMvj9pcz8xUYa3C5IlPlZkMumeNCFx1qds6K_eYcU0ss91DdbhhE8amRX1FsnBJNMRUkA5i45xkOIx15rQN230zzh0p71jvtx7wYRr5pdMlwxV0T9Ck5PCmx-GzFazA2X6DJ0Xnn1-cXkRoZHFj_8Mba1dUrNz-NWEk83uW5KT-ZEbX7nzGXtayKWmGb873a8aYPqIsp6bQ_-eRBd8TDT2g9HuPyPr5VKa1p33xKaohz4DGy3t1Qpy3UWnbPXUlh5dLWPKz-TcS9FP5gFhWVo-ZhU03Pn6P34OxHmXGWyQao18dQGqzgD4e9vY3rLhfcjVZJYNlWY2InsNwbYS-DnienPf1ws-miLeXxNKG3tFydoQzHwyOxG6Wc-HBfzL_hOvxINKQamvPasaYWl1LWznMps6elKCgKDc"},{"kty":"EC","crv":"P-521","kid":"c570888f","x":"AHNpXq0J7rikNRlwhaMYDD8LGVAVJzNJ-jEPksUIn2LB2LCdNRzfAhgbxdQcWT9ktlc9M1EhmTLccEqfnWdGL9G1","y":"AfHPUW3GYzzqbTczcYR0nYMVMFVrYsUxv4uiuSNV_XRN3Jf8zeYbbOLJv4S3bUytO7qHY8bfZxPxR9nn3BBTf5ol"}]}`)

    // Create the JWKS from the resource at the given URL.
    jwks, err := keyfunc.NewJSON(jwksJSON)
    if err != nil {
        log.Fatalf("Failed to create JWKS from JSON.\nError: %s", err.Error())
    }

    // Get a JWT to parse.
    jwtB64 := "eyJraWQiOiJlZThkNjI2ZCIsInR5cCI6IkpXVCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJXZWlkb25nIiwiYXVkIjoiVGFzaHVhbiIsImlzcyI6Imp3a3Mtc2VydmljZS5hcHBzcG90LmNvbSIsImlhdCI6MTYzMTM2OTk1NSwianRpIjoiNDY2M2E5MTAtZWU2MC00NzcwLTgxNjktY2I3NDdiMDljZjU0In0.LwD65d5h6U_2Xco81EClMa_1WIW4xXZl8o4b7WzY_7OgPD2tNlByxvGDzP7bKYA9Gj--1mi4Q4li4CAnKJkaHRYB17baC0H5P9lKMPuA6AnChTzLafY6yf-YadA7DmakCtIl7FNcFQQL2DXmh6gS9J6TluFoCIXj83MqETbDWpL28o3XAD_05UP8VLQzH2XzyqWKi97mOuvz-GsDp9mhBYQUgN3csNXt2v2l-bUPWe19SftNej0cxddyGu06tXUtaS6K0oe0TTbaqc3hmfEiu5G0J8U6ztTUMwXkBvaknE640NPgMQJqBaey0E4u0txYgyvMvvxfwtcOrDRYqYPBnA"

    // Parse the JWT.
    token, err := jwt.Parse(jwtB64, jwks.Keyfunc)
    if err != nil {
        log.Fatalf("Failed to parse the JWT.\nError: %s", err.Error())
    }

    // Check if the token is valid.
    if !token.Valid {
        log.Fatalf("The token is not valid.")
    }
    log.Println("The token is valid.")
}

1
代码中出现错误:无法将 'jwks.Keyfunc'(类型为 func(token *jwt.Token) (interface{}, error))用作 Keyfunc 类型,可能是我使用了更新的版本,不确定。 - Matteo
1
是的,@Matteo,github.com/dgrijalva/jwt-go 包在一段时间前被宣布为未维护状态。社区的继任者是 github.com/golang-jwt/jwt/v4。这里有一个来自 GitHub 仓库的最新示例,我也会尽快更新这个答案。https://github.com/MicahParks/keyfunc/blob/master/examples/recommended_options/main.go - Micah Parks
1
如果您在未维护的软件包上遇到困难,请访问https://github.com/MicahParks/compatibility-keyfunc - Micah Parks
1
确实,那个方法非常有效。非常感谢。 - Matteo

0

我遇到了一个非常类似的用例,我想验证/验证访问令牌并在解析/解码后提取字段(例如:isssubaudexpiatjti等)。对于我的用例,我使用了jwxjwt-go库。

我尝试了@tim-cooper的示例,但它无法编译/与最新的API版本一起工作,所以这里是适用于我的代码片段。

代码片段

go.mod
module my-go-module

go 1.16

require (
    github.com/dgrijalva/jwt-go v3.2.0+incompatible
    github.com/lestrrat-go/jwx v1.0.4
)
代码
package main

import (
    "errors"
    "fmt"

    "github.com/dgrijalva/jwt-go"
    "github.com/lestrrat-go/jwx/jwa"
    "github.com/lestrrat-go/jwx/jwk"
)

func main() {
    jwksURL := "https://your-tenant.auth0.com/.well-known/jwks.json"

    keySet, _ := jwk.Fetch(jwksURL)
    var accessToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6Ind5TXdLNEE2Q0w5UXcxMXVvZlZleVExMTlYeVgteHlreW1ra1h5Z1o1T00ifQ.eyJzdWIiOiIwMHUxOGVlaHUzNDlhUzJ5WDFkOCIsIm5hbWUiOiJva3RhcHJveHkgb2t0YXByb3h5IiwidmVyIjoxLCJpc3MiOiJodHRwczovL2NvbXBhbnl4Lm9rdGEuY29tIiwiYXVkIjoidlpWNkNwOHJuNWx4ck45YVo2ODgiLCJpYXQiOjE0ODEzODg0NTMsImV4cCI6MTQ4MTM5MjA1MywianRpIjoiSUQuWm9QdVdIR3IxNkR6a3RUbEdXMFI4b1lRaUhnVWg0aUotTHo3Z3BGcGItUSIsImFtciI6WyJwd2QiXSwiaWRwIjoiMDBveTc0YzBnd0hOWE1SSkJGUkkiLCJub25jZSI6Im4tMFM2X1d6QTJNaiIsInByZWZlcnJlZF91c2VybmFtZSI6Im9rdGFwcm94eUBva3RhLmNvbSIsImF1dGhfdGltZSI6MTQ4MTM4ODQ0MywiYXRfaGFzaCI6Im1YWlQtZjJJczhOQklIcV9CeE1ISFEifQ.OtVyCK0sE6Cuclg9VMD2AwLhqEyq2nv3a1bfxlzeS-bdu9KtYxcPSxJ6vxMcSSbMIIq9eEz9JFMU80zqgDPHBCjlOsC5SIPz7mm1Z3gCwq4zsFJ-2NIzYxA3p161ZRsPv_3bUyg9B_DPFyBoihgwWm6yrvrb4rmHXrDkjxpxCLPp3OeIpc_kb2t8r5HEQ5UBZPrsiScvuoVW13YwWpze59qBl_84n9xdmQ5pS7DklzkAVgqJT_NWBlb5uo6eW26HtJwHzss7xOIdQtcOtC1Gj3O82a55VJSQnsEEBeqG1ESb5Haq_hJgxYQnBssKydPCIxdZiye-0Ll9L8wWwpzwig"
    token, err := verify(accessToken, keySet)
    if err != nil {
        fmt.Printf("Gor an error while verifiying access token: %v\n", err)
    }

    // Check if the token is valid.
    if !token.Valid {
        fmt.Println("The token is not valid.")
    }

    // Extract key value from the token and print them on console
    claims := token.Claims.(jwt.MapClaims)
    for key, value := range claims {
        fmt.Printf("%s\t%v\n", key, value)
    }
}

func verify(tokenString string, keySet *jwk.Set) (*jwt.Token, error) {
    tkn, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        if token.Method.Alg() != jwa.RS256.String() { 
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        kid, ok := token.Header["kid"].(string)
        if !ok {
            return nil, errors.New("kid header not found")
        }
        keys := keySet.LookupKeyID(kid)
        if len(keys) == 0 {
            return nil, fmt.Errorf("key %v not found", kid)
        }
        var raw interface{}
        return raw, keys[0].Raw(&raw)
    })
    return tkn, err
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接