From 523af855ba805716b3d6e27cf99f86f3f2aa5a1c Mon Sep 17 00:00:00 2001 From: ShenLin <773933146@qq.com> Date: Tue, 1 Sep 2026 18:32:41 +0800 Subject: [PATCH] fix(auth): secure SSO account binding - Issue and verify short-lived SSO binding state and proof tokens - Bind provider callbacks to an HttpOnly browser session cookie - Reject invalid or already-associated SSO identities during profile updates Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> --- server/handles/auth.go | 23 ++++++- server/handles/ssologin.go | 128 ++++++++++++++++++++++++++++++++++--- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/server/handles/auth.go b/server/handles/auth.go index 780069091..2013a5c2b 100644 --- a/server/handles/auth.go +++ b/server/handles/auth.go @@ -3,14 +3,17 @@ package handles import ( "bytes" "encoding/base64" + "errors" "image/png" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/db" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/gin-gonic/gin" "github.com/pquerna/otp/totp" + "gorm.io/gorm" ) type LoginReq struct { @@ -111,11 +114,29 @@ func UpdateCurrent(c *gin.Context) { common.ErrorStrResp(c, model.GuestCannotUpdateProfile, 403) return } + ssoID := req.SsoID + if req.SsoID != "" && req.SsoID != user.SsoID { + claims, err := parseSSOBindingToken(c, req.SsoID, ssoBindingProofPurpose) + if err != nil { + common.ErrorStrResp(c, "invalid or expired SSO binding proof", 400) + return + } + boundUser, err := db.GetUserBySSOID(claims.SsoID) + if err == nil && boundUser.ID != user.ID { + common.ErrorStrResp(c, "SSO account is already bound to another user", 409) + return + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + common.ErrorResp(c, err, 500) + return + } + ssoID = claims.SsoID + } user.Username = req.Username if req.Password != "" { user.SetPassword(req.Password) } - user.SsoID = req.SsoID + user.SsoID = ssoID if err := op.UpdateUser(user); err != nil { common.ErrorResp(c, err, 500) } else { diff --git a/server/handles/ssologin.go b/server/handles/ssologin.go index 4baabf6c1..c8096fd4f 100644 --- a/server/handles/ssologin.go +++ b/server/handles/ssologin.go @@ -1,6 +1,7 @@ package handles import ( + "crypto/sha256" "encoding/base64" "errors" "fmt" @@ -22,12 +23,25 @@ import ( "github.com/coreos/go-oidc" "github.com/gin-gonic/gin" "github.com/go-resty/resty/v2" + "github.com/golang-jwt/jwt/v4" "golang.org/x/oauth2" "gorm.io/gorm" ) const stateLength = 16 const stateExpire = time.Minute * 5 +const ssoBindingExpire = time.Minute * 5 +const ssoBindingCookie = "openlist_sso_binding" +const ssoBindingStatePurpose = "sso_binding_state" +const ssoBindingProofPurpose = "sso_binding_proof" + +type ssoBindingClaims struct { + Purpose string `json:"purpose"` + Method string `json:"method"` + SessionDigest string `json:"session_digest"` + SsoID string `json:"sso_id,omitempty"` + jwt.RegisteredClaims +} var stateCache = cache.NewMemCache[string](cache.WithShards[string](stateLength)) @@ -46,6 +60,68 @@ func verifyState(clientID, ip, state string) bool { return ok && value == ip } +func ssoBindingSessionDigest(session string) string { + digest := sha256.Sum256([]byte(session)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +func parseSSOBindingToken(c *gin.Context, rawToken, purpose string) (*ssoBindingClaims, error) { + claims := &ssoBindingClaims{} + token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (interface{}, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, errors.New("invalid SSO binding token algorithm") + } + return common.SecretKey, nil + }) + if err != nil || !token.Valid || claims.ExpiresAt == nil || claims.Purpose != purpose || + claims.Method != "get_sso_id" || len(claims.SessionDigest) != 43 { + return nil, errors.New("invalid or expired SSO binding token") + } + if (purpose == ssoBindingStatePurpose && claims.SsoID != "") || + (purpose == ssoBindingProofPurpose && claims.SsoID == "") { + return nil, errors.New("invalid SSO binding token payload") + } + session, err := c.Cookie(ssoBindingCookie) + if err != nil || ssoBindingSessionDigest(session) != claims.SessionDigest { + return nil, errors.New("invalid SSO binding session") + } + return claims, nil +} + +func generateSSOBindingToken(c *gin.Context, purpose, ssoID string) (string, error) { + session, err := c.Cookie(ssoBindingCookie) + expire := ssoBindingExpire + if purpose == ssoBindingStatePurpose { + session = random.String(32) + expire = stateExpire + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie( + ssoBindingCookie, + session, + int((stateExpire+ssoBindingExpire).Seconds()), + path.Join(conf.URL.Path, "/api"), + "", + strings.HasPrefix(common.GetApiUrl(c), "https://"), + true, + ) + } else if err != nil { + return "", errors.New("missing SSO binding session") + } + now := time.Now() + claims := ssoBindingClaims{ + Purpose: purpose, + Method: "get_sso_id", + SessionDigest: ssoBindingSessionDigest(session), + SsoID: ssoID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(expire)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(common.SecretKey) +} + func ssoRedirectUri(c *gin.Context, useCompatibility bool, method string) string { if useCompatibility { return common.GetApiUrl(c) + "/api/auth/" + method @@ -74,6 +150,16 @@ func SSOLoginRedirect(c *gin.Context) { urlValues.Add("response_type", "code") urlValues.Add("redirect_uri", redirectUri) urlValues.Add("client_id", clientId) + bindingState := "" + var err error + if method == "get_sso_id" { + bindingState, err = generateSSOBindingToken(c, ssoBindingStatePurpose, "") + if err != nil { + common.ErrorResp(c, err, 500) + return + } + urlValues.Add("state", bindingState) + } switch platform { case "Github": rUrl = "https://github.com/login/oauth/authorize?" @@ -94,15 +180,19 @@ func SSOLoginRedirect(c *gin.Context) { endpoint := strings.TrimSuffix(setting.GetStr(conf.SSOEndpointName), "/") rUrl = endpoint + "/login/oauth/authorize?" urlValues.Add("scope", "profile") - urlValues.Add("state", endpoint) + if bindingState == "" { + urlValues.Add("state", endpoint) + } case "OIDC": oauth2Config, err := GetOIDCClient(c, useCompatibility, redirectUri, method) if err != nil { common.ErrorStrResp(c, err.Error(), 400) return } - state := generateState(clientId, c.ClientIP()) - c.Redirect(http.StatusFound, oauth2Config.AuthCodeURL(state)) + if bindingState == "" { + bindingState = generateState(clientId, c.ClientIP()) + } + c.Redirect(http.StatusFound, oauth2Config.AuthCodeURL(bindingState)) return default: common.ErrorStrResp(c, "invalid platform", 400) @@ -201,11 +291,15 @@ func OIDCLoginCallback(c *gin.Context) { common.ErrorResp(c, err, 400) return } - if !verifyState(clientId, c.ClientIP(), c.Query("state")) { + if method == "get_sso_id" { + if _, err := parseSSOBindingToken(c, c.Query("state"), ssoBindingStatePurpose); err != nil { + common.ErrorStrResp(c, "incorrect or expired state parameter", 400) + return + } + } else if !verifyState(clientId, c.ClientIP(), c.Query("state")) { common.ErrorStrResp(c, "incorrect or expired state parameter", 400) return } - oauth2Token, err := oauth2Config.Exchange(c, c.Query("code")) if err != nil { common.ErrorResp(c, err, 400) @@ -235,8 +329,13 @@ func OIDCLoginCallback(c *gin.Context) { return } if method == "get_sso_id" { + bindingProof, err := generateSSOBindingToken(c, ssoBindingProofPurpose, userID) + if err != nil { + common.ErrorResp(c, err, 500) + return + } if useCompatibility { - c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+userID) + c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+bindingProof) return } html := fmt.Sprintf(` @@ -246,7 +345,7 @@ func OIDCLoginCallback(c *gin.Context) { window.opener.postMessage({"sso_id": "%s"}, "*") window.close() - `, userID) + `, bindingProof) c.Data(200, "text/html; charset=utf-8", []byte(html)) return } @@ -352,6 +451,12 @@ func SSOLoginCallback(c *gin.Context) { common.ErrorStrResp(c, "No code provided", 400) return } + if argument == "get_sso_id" { + if _, err := parseSSOBindingToken(c, c.Query("state"), ssoBindingStatePurpose); err != nil { + common.ErrorStrResp(c, "incorrect or expired state parameter", 400) + return + } + } var resp *resty.Response var err error if platform == "Dingtalk" { @@ -402,8 +507,13 @@ func SSOLoginCallback(c *gin.Context) { return } if argument == "get_sso_id" { + bindingProof, err := generateSSOBindingToken(c, ssoBindingProofPurpose, userID) + if err != nil { + common.ErrorResp(c, err, 500) + return + } if usecompatibility { - c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+userID) + c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+bindingProof) return } html := fmt.Sprintf(` @@ -413,7 +523,7 @@ func SSOLoginCallback(c *gin.Context) { window.opener.postMessage({"sso_id": "%s"}, "*") window.close() - `, userID) + `, bindingProof) c.Data(200, "text/html; charset=utf-8", []byte(html)) return }