Golang中如何在OIDC代码流中持久化和检查状态及nonce
Golang中如何在OIDC代码流中持久化和检查状态及nonce 我正在尝试为 Facebook 实现 OIDC 代码流程。 来源:https://developers.facebook.com/docs/facebook-login/guides/advanced/oidc-token
目前的工作流程如下:

代码
代码看起来像这样(很大程度上借鉴了这篇博客文章:https://chrisguitarguy.com/2022/12/07/oauth-pkce-with-go/)
package controllers
var (
state string
codeVerifier string
nonce string
)
var fbOAuthConfig = &oauth2.Config{
ClientID: "xxxxxxxx",
ClientSecret: "xxxxxxxx",
Scopes: []string{"openid"},
RedirectURL: "", // received by the authEndpointHandler below
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.facebook.com/v17.0/dialog/oauth",
TokenURL: "https://graph.facebook.com/v17.0/oauth/access_token",
},
}
// 1) after the user clicks the Facebook btn on the front, he is redirected to this endpoint
func authEndpointHandler(w http.ResponseWriter, req *http.Request){
// assign the redirect URL to FB OAuth Config
FBOAuthConfig.RedirectURL = req.URL.Query().Get("redirect_url")
// generate authorization URL
...
// save nonce, state and codeVerifier
nonce = authURL.Nonce
state = authURL.State
codeVerifier = authURL.CodeVerifier
// redirect to the authorization URL
http.Redirect(w, req, authURL.URL, http.StatusFound)
}
// 2) once the user give approval, Facebook sends a request to this handler
func responseEndpointHandler(w http.ResponseWriter, req *http.Request){
// get the state
requestState := query.Get("state")
// from the doc: ConstantTimeCompare returns 1 if the two slices, x and y, have equal contents and 0 otherwise.
if subtle.ConstantTimeCompare([]byte(requestState), []byte(state)) == 0 {
textResponse(w, http.StatusBadRequest, "Invalid State")
return
}
// get the code
code := query.Get("code")
if code == "" {
textResponse(w, http.StatusBadRequest, "Missing Code")
return
}
// 3) exchange the code received by Facebook with an id_token
// from the doc: Exchange converts an authorization code into a token.
tokenResp, err := fbOAuthConfig.Exchange(
req.Context(),
code, oauth2.SetAuthURLParam("code_verifier", codeVerifier),
oauth2.SetAuthURLParam("redirect_uri", "https://front.app.com/signin/success)",
)
if err != nil {
textResponse(w, http.StatusInternalServerError, err.Error())
return
}
// extract the id token from the response and validate it
idToken:= tokenResp.Extra("id_token")
Verify(idToken)
...
// redirect to the required URL
http.Redirect(w, req, fbOAuthConfig.RedirectURL, http.StatusFound)
}
问题
当我进行测试时,它是有效的。但我明显遗漏了一些东西……
我的后端 API 可能会同时收到多个请求,而目前,我将 nounce、state 和 code_verifier 存储为包变量,因此如果 authEndpointHandler 在 responseEndpointHandler 完成之前收到请求,那么 nounce、state 和 code_verifier 的值将被传入的请求覆盖。
想法
我考虑创建一个如下所示的映射,其中键是状态。
var requestStates = map[string]struct {
CodeVerifier string
Nonce string
}{}
然后我只需要:
- 确保在短时间内不会生成两次相同的状态。
- 在两个处理程序都完成后删除每个条目,以避免键之间的冲突。
基于这个想法,responseEndpointHandler 函数看起来会像这样:
package controller
// ...
const REDIRECT_URI = "https://localhost:3000/auth/facebook/access-token"
var FBOAuthConfig = &oauth2.Config{
ClientID: "xxxxx",
ClientSecret: "xxxxx",
Scopes: []string{"openid", "email", "public_profile"},
RedirectURL: "", // retrieved as a query string parameter
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.facebook.com/v17.0/dialog/oauth",
TokenURL: "https://graph.facebook.com/v17.0/oauth/access_token",
},
}
var states = map[string]struct {
CodeVerifier string
Nonce string
BrowserRedirectURL string
}{}
// 1) STEP 1: handle GET /auth/facebook/signin?redirect_url=https://localhost/signin/success
func (ctrl *controller) RedirectToAuthURL(w http.ResponseWriter, req *http.Request) {
FBOAuthConfig.RedirectURL = req.URL.Query().Get("redirect_url")
authURL, err := ctrl.getAuthorizationURL(FBOAuthConfig)
if err != nil {
helpers.ErrorJSON(w, http.StatusBadRequest, "bad request")
return
}
// save the nonce and codeVerifier and make it match to the created state
// 1 request = 1 state = 1 nonce + 1 code verifier + 1 BrowserRedirectURL
states[authURL.State] = struct {
CodeVerifier string
Nonce string
BrowserRedirectURL string
}{
authURL.CodeVerifier,
authURL.Nonce,
req.URL.Query().Get("redirect_url"),
}
http.Redirect(w, req, authURL.URL, http.StatusFound)
}
// 2) STEP 2: handle GET /auth/facebook/access-token?code=XXXXXXXXXXXX&state=XXXXXXXXXXXX
func (ctrl *controller) HandleResponse(w http.ResponseWriter, req *http.Request) {
requestState := req.URL.Query().Get("state")
if requestState == "" {
helpers.ErrorJSON(w, http.StatusBadRequest, "request state can't be empty")
return
}
// from doc: ConstantTimeCompare returns 1 if the two slices, x and y, have equal contents and 0 otherwise.
// Verify state and prevent timing attacks on state
// if subtle.ConstantTimeCompare([]byte(requestState), []byte(state)) == 0 {
// textResponse(w, http.StatusBadRequest, "Invalid State")
// return
// }
// check if state exists
state, ok := states[requestState]
if !ok {
helpers.ErrorJSON(w, http.StatusBadRequest, "invalid sequence, state doesn't exist")
return
}
// get the code
code := query.Get("code")
if code == "" {
helpers.ErrorJSON(w, http.StatusBadRequest, "missing code")
return
}
// STEP 3) Exchange converts an authorization code into a token.
tokenResp, err := FBOAuthConfig.Exchange(
req.Context(),
code,
oauth2.SetAuthURLParam("code_verifier", state.CodeVerifier),
oauth2.SetAuthURLParam("redirect_uri", REDIRECT_URI),
)
if err != nil {
helpers.ErrorJSON(w, http.StatusBadRequest, "exchange code error: "+ err)
return
}
// verify ID Token
claims, err := verifyIDToken(tokenResp.Extra("id_token").(string))
if err != nil {
helpers.ErrorJSON(w, http.StatusBadRequest, "invalid id token: "+ err)
return
}
// verify nonce
if claims["nonce"] != state.Nonce{
helpers.ErrorJSON(w, http.StatusBadRequest, "invalid id token: invalid nonce")
}
// set a cookie etc.
// ...
// END) redirect to front end success URL
http.Redirect(w, req, state.RedirectURL, http.StatusFound)
}
func (ctrl *controller) getAuthorizationURL(config *oauth2.Config) (*oauthdom.AuthURL, resterrors.RestErr) {
// create code verifier
codeVerifier, err := randomBytesInHex(32) // 64 character string here
if err != nil {
return nil, restErr
}
// create code challenge
sha2 := sha256.New()
io.WriteString(sha2, codeVerifier)
codeChallenge := base64.RawURLEncoding.EncodeToString(sha2.Sum(nil))
// create state
state, err := randomBytesInHex(24)
if err != nil {
return nil, err
}
// create nonce
nonce, err := randomBytesInHex(12)
if err != nil {
return nil, err
}
// create auth URL
// from doc: oauth2.SetAuthURLParam append the appropriate query string values to the authorization url.
authUrl := config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("redirect_uri", REDIRECT_URI),
oauth2.SetAuthURLParam("scope", "openid, email, public_profile"),
oauth2.SetAuthURLParam("redirect_type", "code"),
oauth2.SetAuthURLParam("code_challenge", codeChallenge), // should be stored in sessions or HTTP only cookies, in memory, etc. WITH THE URL
oauth2.SetAuthURLParam("code_challenge_method", "S256"), // should be stored in sessions or HTTP only cookies, in memory, etc. WITH THE URL
oauth2.SetAuthURLParam("nonce", nonce),
)
return &oauthdom.AuthURL{
URL: authUrl,
State: state,
CodeVerifier: codeVerifier,
Nonce: nonce,
}, nil
}
// Used to Generate a code verifier and a state
func randomBytesInHex(count int) (string, error) {
buf := make([]byte, count)
_, err := io.ReadFull(rand.Reader, buf)
if err != nil {
return "", fmt.Errorf("Could not generate %d random bytes: %v", count, err)
}
return hex.EncodeToString(buf), nil
}
我真的不知道我是否完全搞错了,所以欢迎任何评论/建议 🙂
更多关于Golang中如何在OIDC代码流中持久化和检查状态及nonce的实战教程也可以访问 https://www.itying.com/category-94-b0.html
更多关于Golang中如何在OIDC代码流中持久化和检查状态及nonce的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
在OIDC代码流中持久化和检查状态及nonce的正确方法是使用会话存储或分布式缓存。以下是基于你当前代码的改进方案:
解决方案:使用并发安全的映射和清理机制
package controllers
import (
"sync"
"time"
"crypto/subtle"
"net/http"
"golang.org/x/oauth2"
)
type OAuthState struct {
CodeVerifier string
Nonce string
BrowserRedirectURL string
CreatedAt time.Time
}
type StateManager struct {
mu sync.RWMutex
states map[string]OAuthState
}
func NewStateManager() *StateManager {
return &StateManager{
states: make(map[string]OAuthState),
}
}
var (
stateManager = NewStateManager()
fbOAuthConfig = &oauth2.Config{
ClientID: "xxxxxxxx",
ClientSecret: "xxxxxxxx",
Scopes: []string{"openid"},
RedirectURL: "",
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.facebook.com/v17.0/dialog/oauth",
TokenURL: "https://graph.facebook.com/v17.0/oauth/access_token",
},
}
)
// 清理过期的状态(可以定期执行)
func (sm *StateManager) Cleanup(expiry time.Duration) {
sm.mu.Lock()
defer sm.mu.Unlock()
now := time.Now()
for state, data := range sm.states {
if now.Sub(data.CreatedAt) > expiry {
delete(sm.states, state)
}
}
}
// 存储状态
func (sm *StateManager) StoreState(state string, data OAuthState) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.states[state] = data
}
// 获取并删除状态
func (sm *StateManager) GetAndRemoveState(state string) (OAuthState, bool) {
sm.mu.Lock()
defer sm.mu.Unlock()
data, exists := sm.states[state]
if exists {
delete(sm.states, state)
}
return data, exists
}
// 检查状态是否存在
func (sm *StateManager) StateExists(state string) bool {
sm.mu.RLock()
defer sm.mu.RUnlock()
_, exists := sm.states[state]
return exists
}
// 1) 授权端点处理器
func authEndpointHandler(w http.ResponseWriter, req *http.Request) {
fbOAuthConfig.RedirectURL = req.URL.Query().Get("redirect_url")
authURL, err := getAuthorizationURL(fbOAuthConfig)
if err != nil {
http.Error(w, "Failed to generate auth URL", http.StatusInternalServerError)
return
}
// 存储状态信息
stateData := OAuthState{
CodeVerifier: authURL.CodeVerifier,
Nonce: authURL.Nonce,
BrowserRedirectURL: req.URL.Query().Get("redirect_url"),
CreatedAt: time.Now(),
}
stateManager.StoreState(authURL.State, stateData)
http.Redirect(w, req, authURL.URL, http.StatusFound)
}
// 2) 响应端点处理器
func responseEndpointHandler(w http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
requestState := query.Get("state")
// 使用恒定时间比较防止时序攻击
if !stateManager.StateExists(requestState) {
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
// 获取并删除状态(一次性使用)
stateData, exists := stateManager.GetAndRemoveState(requestState)
if !exists {
http.Error(w, "State already used or expired", http.StatusBadRequest)
return
}
// 验证状态创建时间(防止重放攻击)
if time.Since(stateData.CreatedAt) > 10*time.Minute {
http.Error(w, "State expired", http.StatusBadRequest)
return
}
code := query.Get("code")
if code == "" {
http.Error(w, "Missing code", http.StatusBadRequest)
return
}
// 交换令牌
tokenResp, err := fbOAuthConfig.Exchange(
req.Context(),
code,
oauth2.SetAuthURLParam("code_verifier", stateData.CodeVerifier),
oauth2.SetAuthURLParam("redirect_uri", fbOAuthConfig.RedirectURL),
)
if err != nil {
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
// 验证ID Token和nonce
idToken, ok := tokenResp.Extra("id_token").(string)
if !ok {
http.Error(w, "Invalid token response", http.StatusInternalServerError)
return
}
claims, err := verifyIDToken(idToken)
if err != nil {
http.Error(w, "Invalid ID token", http.StatusBadRequest)
return
}
// 验证nonce
tokenNonce, ok := claims["nonce"].(string)
if !ok || subtle.ConstantTimeCompare([]byte(tokenNonce), []byte(stateData.Nonce)) == 0 {
http.Error(w, "Invalid nonce", http.StatusBadRequest)
return
}
http.Redirect(w, req, stateData.BrowserRedirectURL, http.StatusFound)
}
// 定期清理任务
func init() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
stateManager.Cleanup(10 * time.Minute)
}
}()
}
使用Redis的分布式解决方案(适用于多实例部署)
package controllers
import (
"context"
"time"
"github.com/go-redis/redis/v8"
)
type RedisStateManager struct {
client *redis.Client
ctx context.Context
}
func NewRedisStateManager(addr string) *RedisStateManager {
return &RedisStateManager{
client: redis.NewClient(&redis.Options{
Addr: addr,
}),
ctx: context.Background(),
}
}
func (rsm *RedisStateManager) StoreState(state string, data OAuthState, expiry time.Duration) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
return rsm.client.Set(rsm.ctx, "oauth_state:"+state, jsonData, expiry).Err()
}
func (rsm *RedisStateManager) GetAndRemoveState(state string) (OAuthState, bool, error) {
key := "oauth_state:" + state
// 使用事务确保原子性
tx := rsm.client.TxPipeline()
getCmd := tx.Get(rsm.ctx, key)
delCmd := tx.Del(rsm.ctx, key)
_, err := tx.Exec(rsm.ctx)
if err == redis.Nil {
return OAuthState{}, false, nil
}
if err != nil {
return OAuthState{}, false, err
}
var data OAuthState
err = json.Unmarshal([]byte(getCmd.Val()), &data)
return data, true, err
}
这个方案解决了并发访问问题,提供了状态过期清理机制,并支持分布式部署场景。

