Skip to content

Commit 523af85

Browse files
jyxjjjcodex
andcommitted
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>
1 parent bba3516 commit 523af85

2 files changed

Lines changed: 141 additions & 10 deletions

File tree

server/handles/auth.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ package handles
33
import (
44
"bytes"
55
"encoding/base64"
6+
"errors"
67
"image/png"
78

89
"github.com/OpenListTeam/OpenList/v4/internal/conf"
10+
"github.com/OpenListTeam/OpenList/v4/internal/db"
911
"github.com/OpenListTeam/OpenList/v4/internal/model"
1012
"github.com/OpenListTeam/OpenList/v4/internal/op"
1113
"github.com/OpenListTeam/OpenList/v4/server/common"
1214
"github.com/gin-gonic/gin"
1315
"github.com/pquerna/otp/totp"
16+
"gorm.io/gorm"
1417
)
1518

1619
type LoginReq struct {
@@ -111,11 +114,29 @@ func UpdateCurrent(c *gin.Context) {
111114
common.ErrorStrResp(c, model.GuestCannotUpdateProfile, 403)
112115
return
113116
}
117+
ssoID := req.SsoID
118+
if req.SsoID != "" && req.SsoID != user.SsoID {
119+
claims, err := parseSSOBindingToken(c, req.SsoID, ssoBindingProofPurpose)
120+
if err != nil {
121+
common.ErrorStrResp(c, "invalid or expired SSO binding proof", 400)
122+
return
123+
}
124+
boundUser, err := db.GetUserBySSOID(claims.SsoID)
125+
if err == nil && boundUser.ID != user.ID {
126+
common.ErrorStrResp(c, "SSO account is already bound to another user", 409)
127+
return
128+
}
129+
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
130+
common.ErrorResp(c, err, 500)
131+
return
132+
}
133+
ssoID = claims.SsoID
134+
}
114135
user.Username = req.Username
115136
if req.Password != "" {
116137
user.SetPassword(req.Password)
117138
}
118-
user.SsoID = req.SsoID
139+
user.SsoID = ssoID
119140
if err := op.UpdateUser(user); err != nil {
120141
common.ErrorResp(c, err, 500)
121142
} else {

server/handles/ssologin.go

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handles
22

33
import (
4+
"crypto/sha256"
45
"encoding/base64"
56
"errors"
67
"fmt"
@@ -22,12 +23,25 @@ import (
2223
"github.com/coreos/go-oidc"
2324
"github.com/gin-gonic/gin"
2425
"github.com/go-resty/resty/v2"
26+
"github.com/golang-jwt/jwt/v4"
2527
"golang.org/x/oauth2"
2628
"gorm.io/gorm"
2729
)
2830

2931
const stateLength = 16
3032
const stateExpire = time.Minute * 5
33+
const ssoBindingExpire = time.Minute * 5
34+
const ssoBindingCookie = "openlist_sso_binding"
35+
const ssoBindingStatePurpose = "sso_binding_state"
36+
const ssoBindingProofPurpose = "sso_binding_proof"
37+
38+
type ssoBindingClaims struct {
39+
Purpose string `json:"purpose"`
40+
Method string `json:"method"`
41+
SessionDigest string `json:"session_digest"`
42+
SsoID string `json:"sso_id,omitempty"`
43+
jwt.RegisteredClaims
44+
}
3145

3246
var stateCache = cache.NewMemCache[string](cache.WithShards[string](stateLength))
3347

@@ -46,6 +60,68 @@ func verifyState(clientID, ip, state string) bool {
4660
return ok && value == ip
4761
}
4862

63+
func ssoBindingSessionDigest(session string) string {
64+
digest := sha256.Sum256([]byte(session))
65+
return base64.RawURLEncoding.EncodeToString(digest[:])
66+
}
67+
68+
func parseSSOBindingToken(c *gin.Context, rawToken, purpose string) (*ssoBindingClaims, error) {
69+
claims := &ssoBindingClaims{}
70+
token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (interface{}, error) {
71+
if token.Method != jwt.SigningMethodHS256 {
72+
return nil, errors.New("invalid SSO binding token algorithm")
73+
}
74+
return common.SecretKey, nil
75+
})
76+
if err != nil || !token.Valid || claims.ExpiresAt == nil || claims.Purpose != purpose ||
77+
claims.Method != "get_sso_id" || len(claims.SessionDigest) != 43 {
78+
return nil, errors.New("invalid or expired SSO binding token")
79+
}
80+
if (purpose == ssoBindingStatePurpose && claims.SsoID != "") ||
81+
(purpose == ssoBindingProofPurpose && claims.SsoID == "") {
82+
return nil, errors.New("invalid SSO binding token payload")
83+
}
84+
session, err := c.Cookie(ssoBindingCookie)
85+
if err != nil || ssoBindingSessionDigest(session) != claims.SessionDigest {
86+
return nil, errors.New("invalid SSO binding session")
87+
}
88+
return claims, nil
89+
}
90+
91+
func generateSSOBindingToken(c *gin.Context, purpose, ssoID string) (string, error) {
92+
session, err := c.Cookie(ssoBindingCookie)
93+
expire := ssoBindingExpire
94+
if purpose == ssoBindingStatePurpose {
95+
session = random.String(32)
96+
expire = stateExpire
97+
c.SetSameSite(http.SameSiteLaxMode)
98+
c.SetCookie(
99+
ssoBindingCookie,
100+
session,
101+
int((stateExpire+ssoBindingExpire).Seconds()),
102+
path.Join(conf.URL.Path, "/api"),
103+
"",
104+
strings.HasPrefix(common.GetApiUrl(c), "https://"),
105+
true,
106+
)
107+
} else if err != nil {
108+
return "", errors.New("missing SSO binding session")
109+
}
110+
now := time.Now()
111+
claims := ssoBindingClaims{
112+
Purpose: purpose,
113+
Method: "get_sso_id",
114+
SessionDigest: ssoBindingSessionDigest(session),
115+
SsoID: ssoID,
116+
RegisteredClaims: jwt.RegisteredClaims{
117+
ExpiresAt: jwt.NewNumericDate(now.Add(expire)),
118+
IssuedAt: jwt.NewNumericDate(now),
119+
NotBefore: jwt.NewNumericDate(now),
120+
},
121+
}
122+
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(common.SecretKey)
123+
}
124+
49125
func ssoRedirectUri(c *gin.Context, useCompatibility bool, method string) string {
50126
if useCompatibility {
51127
return common.GetApiUrl(c) + "/api/auth/" + method
@@ -74,6 +150,16 @@ func SSOLoginRedirect(c *gin.Context) {
74150
urlValues.Add("response_type", "code")
75151
urlValues.Add("redirect_uri", redirectUri)
76152
urlValues.Add("client_id", clientId)
153+
bindingState := ""
154+
var err error
155+
if method == "get_sso_id" {
156+
bindingState, err = generateSSOBindingToken(c, ssoBindingStatePurpose, "")
157+
if err != nil {
158+
common.ErrorResp(c, err, 500)
159+
return
160+
}
161+
urlValues.Add("state", bindingState)
162+
}
77163
switch platform {
78164
case "Github":
79165
rUrl = "https://github.com/login/oauth/authorize?"
@@ -94,15 +180,19 @@ func SSOLoginRedirect(c *gin.Context) {
94180
endpoint := strings.TrimSuffix(setting.GetStr(conf.SSOEndpointName), "/")
95181
rUrl = endpoint + "/login/oauth/authorize?"
96182
urlValues.Add("scope", "profile")
97-
urlValues.Add("state", endpoint)
183+
if bindingState == "" {
184+
urlValues.Add("state", endpoint)
185+
}
98186
case "OIDC":
99187
oauth2Config, err := GetOIDCClient(c, useCompatibility, redirectUri, method)
100188
if err != nil {
101189
common.ErrorStrResp(c, err.Error(), 400)
102190
return
103191
}
104-
state := generateState(clientId, c.ClientIP())
105-
c.Redirect(http.StatusFound, oauth2Config.AuthCodeURL(state))
192+
if bindingState == "" {
193+
bindingState = generateState(clientId, c.ClientIP())
194+
}
195+
c.Redirect(http.StatusFound, oauth2Config.AuthCodeURL(bindingState))
106196
return
107197
default:
108198
common.ErrorStrResp(c, "invalid platform", 400)
@@ -201,11 +291,15 @@ func OIDCLoginCallback(c *gin.Context) {
201291
common.ErrorResp(c, err, 400)
202292
return
203293
}
204-
if !verifyState(clientId, c.ClientIP(), c.Query("state")) {
294+
if method == "get_sso_id" {
295+
if _, err := parseSSOBindingToken(c, c.Query("state"), ssoBindingStatePurpose); err != nil {
296+
common.ErrorStrResp(c, "incorrect or expired state parameter", 400)
297+
return
298+
}
299+
} else if !verifyState(clientId, c.ClientIP(), c.Query("state")) {
205300
common.ErrorStrResp(c, "incorrect or expired state parameter", 400)
206301
return
207302
}
208-
209303
oauth2Token, err := oauth2Config.Exchange(c, c.Query("code"))
210304
if err != nil {
211305
common.ErrorResp(c, err, 400)
@@ -235,8 +329,13 @@ func OIDCLoginCallback(c *gin.Context) {
235329
return
236330
}
237331
if method == "get_sso_id" {
332+
bindingProof, err := generateSSOBindingToken(c, ssoBindingProofPurpose, userID)
333+
if err != nil {
334+
common.ErrorResp(c, err, 500)
335+
return
336+
}
238337
if useCompatibility {
239-
c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+userID)
338+
c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+bindingProof)
240339
return
241340
}
242341
html := fmt.Sprintf(`<!DOCTYPE html>
@@ -246,7 +345,7 @@ func OIDCLoginCallback(c *gin.Context) {
246345
window.opener.postMessage({"sso_id": "%s"}, "*")
247346
window.close()
248347
</script>
249-
</body>`, userID)
348+
</body>`, bindingProof)
250349
c.Data(200, "text/html; charset=utf-8", []byte(html))
251350
return
252351
}
@@ -352,6 +451,12 @@ func SSOLoginCallback(c *gin.Context) {
352451
common.ErrorStrResp(c, "No code provided", 400)
353452
return
354453
}
454+
if argument == "get_sso_id" {
455+
if _, err := parseSSOBindingToken(c, c.Query("state"), ssoBindingStatePurpose); err != nil {
456+
common.ErrorStrResp(c, "incorrect or expired state parameter", 400)
457+
return
458+
}
459+
}
355460
var resp *resty.Response
356461
var err error
357462
if platform == "Dingtalk" {
@@ -402,8 +507,13 @@ func SSOLoginCallback(c *gin.Context) {
402507
return
403508
}
404509
if argument == "get_sso_id" {
510+
bindingProof, err := generateSSOBindingToken(c, ssoBindingProofPurpose, userID)
511+
if err != nil {
512+
common.ErrorResp(c, err, 500)
513+
return
514+
}
405515
if usecompatibility {
406-
c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+userID)
516+
c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+bindingProof)
407517
return
408518
}
409519
html := fmt.Sprintf(`<!DOCTYPE html>
@@ -413,7 +523,7 @@ func SSOLoginCallback(c *gin.Context) {
413523
window.opener.postMessage({"sso_id": "%s"}, "*")
414524
window.close()
415525
</script>
416-
</body>`, userID)
526+
</body>`, bindingProof)
417527
c.Data(200, "text/html; charset=utf-8", []byte(html))
418528
return
419529
}

0 commit comments

Comments
 (0)