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>
This commit is contained in:
ShenLin
2026-09-01 18:32:41 +08:00
committed by jyxjjj
parent bba3516693
commit 523af855ba
2 changed files with 141 additions and 10 deletions
+22 -1
View File
@@ -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 {
+119 -9
View File
@@ -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(`<!DOCTYPE html>
@@ -246,7 +345,7 @@ func OIDCLoginCallback(c *gin.Context) {
window.opener.postMessage({"sso_id": "%s"}, "*")
window.close()
</script>
</body>`, userID)
</body>`, 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(`<!DOCTYPE html>
@@ -413,7 +523,7 @@ func SSOLoginCallback(c *gin.Context) {
window.opener.postMessage({"sso_id": "%s"}, "*")
window.close()
</script>
</body>`, userID)
</body>`, bindingProof)
c.Data(200, "text/html; charset=utf-8", []byte(html))
return
}