Golang中如何在OIDC代码流中持久化和检查状态及nonce

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

目前的工作流程如下:

image

代码

代码看起来像这样(很大程度上借鉴了这篇博客文章: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 可能会同时收到多个请求,而目前,我将 nouncestatecode_verifier 存储为包变量,因此如果 authEndpointHandlerresponseEndpointHandler 完成之前收到请求,那么 nouncestatecode_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

1 回复

更多关于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
}

这个方案解决了并发访问问题,提供了状态过期清理机制,并支持分布式部署场景。

回到顶部