From b1513560451dff20deb191e82463070231b49668 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 17 Jul 2026 09:42:12 +0800 Subject: [PATCH] fix(auth): handle saml logout and xml encryption padding --- frontend/src/api/auth.ts | 15 ++++++- frontend/src/composables/useAuth.ts | 6 ++- server/.env.example | 1 + server/internal/config/config.go | 2 + server/internal/handler/saml.go | 22 ++++++++- server/internal/sso/login.go | 9 +--- server/internal/sso/login_test.go | 69 +++++++++++++++++++++++++++++ 7 files changed, 112 insertions(+), 12 deletions(-) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 2b9cf17..a9d70a1 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -24,6 +24,10 @@ export interface AuthConfig { sso_enabled: boolean } +export interface LogoutResponse { + logout_url?: string +} + export const authApi = { async getConfig(): Promise> { const response = await fetch('/auth/api/v1/config', { @@ -64,7 +68,7 @@ export const authApi = { } }, - async logout(): Promise> { + async logout(): Promise> { const token = getToken() const response = await fetch('/auth/api/v1/logout', { method: 'POST', @@ -78,7 +82,14 @@ export const authApi = { const data = await response.json().catch(() => ({})) throw new Error(data.error || `HTTP ${response.status}`) } - return { code: 0, message: 'success', data: null } + const data = await response.json().catch(() => ({})) + return { + code: 0, + message: 'success', + data: { + logout_url: data.logout_url || '', + }, + } }, async getUserInfo(): Promise> { diff --git a/frontend/src/composables/useAuth.ts b/frontend/src/composables/useAuth.ts index bc134de..93342d5 100644 --- a/frontend/src/composables/useAuth.ts +++ b/frontend/src/composables/useAuth.ts @@ -13,11 +13,13 @@ export function useAuth() { } const logout = async () => { + let logoutUrl = '' try { - await authApi.logout() + const response = await authApi.logout() + logoutUrl = response.data.logout_url || '' } finally { authStore.clearAuth() - window.location.assign('/login?logged_out=1') + window.location.assign(logoutUrl || '/login?logged_out=1') } } diff --git a/server/.env.example b/server/.env.example index 9092794..97eae34 100644 --- a/server/.env.example +++ b/server/.env.example @@ -18,5 +18,6 @@ JWT_TTL_MINUTES=120 SAML_ENTITY_ID=http://localhost:8080/api/v1/saml/metadata SAML_ACS_URL=http://localhost:8080/api/v1/saml/acs SAML_IDP_METADATA_URL=http://sso-internal.dev.qiniu.io/saml2/meta +SAML_LOGOUT_URL=http://sso-internal.dev.qiniu.io/signout SAML_SP_CERT_FILE=certs/sp.crt SAML_SP_KEY_FILE=certs/sp.key diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 5a81eab..38b1e3b 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { SAMLSPCert string SAMLSPKey string SAMLIDPMetaURL string + SAMLLogoutURL string WayenLoginURL string WayenTargetURL string WayenUsernameKey string @@ -78,6 +79,7 @@ func Load() Config { SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"), SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"), SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"), + SAMLLogoutURL: trimURL(env("SAML_LOGOUT_URL", "")), WayenLoginURL: env("WAYEN_LOGIN_URL", ""), WayenTargetURL: env("WAYEN_TARGET_URL", ""), WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"), diff --git a/server/internal/handler/saml.go b/server/internal/handler/saml.go index bfcac2d..d1350af 100644 --- a/server/internal/handler/saml.go +++ b/server/internal/handler/saml.go @@ -75,7 +75,10 @@ func (h *SAMLHandler) Logout(c *gin.Context) { Expires: expired, }) } - c.JSON(http.StatusOK, gin.H{"ok": true}) + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "logout_url": h.logoutURL(), + }) } func (h *SAMLHandler) ACS(c *gin.Context) { @@ -125,3 +128,20 @@ func ssoRedirectURL(relayState, token string) string { parsed.RawQuery = values.Encode() return parsed.String() } + +func (h *SAMLHandler) logoutURL() string { + logoutURL := strings.TrimSpace(h.cfg.SAMLLogoutURL) + if logoutURL == "" { + return "" + } + parsed, err := url.Parse(logoutURL) + if err != nil { + return logoutURL + } + values := parsed.Query() + if values.Get("redirect") == "" { + values.Set("redirect", strings.TrimRight(h.cfg.PublicBaseURL, "/")+"/") + parsed.RawQuery = values.Encode() + } + return parsed.String() +} diff --git a/server/internal/sso/login.go b/server/internal/sso/login.go index 14e6f77..8390850 100644 --- a/server/internal/sso/login.go +++ b/server/internal/sso/login.go @@ -246,7 +246,7 @@ func decryptAESCBC(value, key []byte) ([]byte, error) { cipherText := value[block.BlockSize():] plain := make([]byte, len(cipherText)) cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, cipherText) - plain, err = pkcs7Unpad(plain, block.BlockSize()) + plain, err = xmlEncCBCUnpad(plain, block.BlockSize()) if err != nil { return nil, err } @@ -274,7 +274,7 @@ func decryptAESGCM(value, key []byte) ([]byte, error) { return plain, nil } -func pkcs7Unpad(value []byte, blockSize int) ([]byte, error) { +func xmlEncCBCUnpad(value []byte, blockSize int) ([]byte, error) { if len(value) == 0 || len(value)%blockSize != 0 { return nil, errors.New("invalid saml assertion padding length") } @@ -282,11 +282,6 @@ func pkcs7Unpad(value []byte, blockSize int) ([]byte, error) { if padding == 0 || padding > blockSize || padding > len(value) { return nil, errors.New("invalid saml assertion padding") } - for _, b := range value[len(value)-padding:] { - if int(b) != padding { - return nil, errors.New("invalid saml assertion padding bytes") - } - } return value[:len(value)-padding], nil } diff --git a/server/internal/sso/login_test.go b/server/internal/sso/login_test.go index d61350f..0e6ce56 100644 --- a/server/internal/sso/login_test.go +++ b/server/internal/sso/login_test.go @@ -143,6 +143,47 @@ func TestDecodeSAMLResponseDecryptsEncryptedAssertion(t *testing.T) { } } +func TestDecodeSAMLResponseDecryptsEncryptedAssertionWithXMLCBCPadding(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + keyFile := writeTestPrivateKey(t, key) + + assertion := ` + carol@example.com +` + sessionKey := []byte("0123456789abcdef") + encryptedAssertion := encryptTestAssertionXMLCBCPadding(t, []byte(assertion), sessionKey) + encryptedKey, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, &key.PublicKey, sessionKey, nil) + if err != nil { + t.Fatalf("encrypt key: %v", err) + } + response := ` + + + + + + ` + base64.StdEncoding.EncodeToString(encryptedKey) + ` + + + ` + base64.StdEncoding.EncodeToString(encryptedAssertion) + ` + + +` + + info, err := DecodeSAMLResponse(base64.StdEncoding.EncodeToString([]byte(response)), keyFile) + if err != nil { + t.Fatalf("DecodeSAMLResponse returned error: %v", err) + } + if info.NameID != "carol@example.com" { + t.Fatalf("unexpected name id: %q", info.NameID) + } +} + func TestDecodeSAMLResponseDecryptsGCMEncryptedAssertion(t *testing.T) { t.Parallel() @@ -221,6 +262,20 @@ func encryptTestAssertion(t *testing.T, plain, key []byte) []byte { return out } +func encryptTestAssertionXMLCBCPadding(t *testing.T, plain, key []byte) []byte { + t.Helper() + block, err := aes.NewCipher(key) + if err != nil { + t.Fatalf("init cipher: %v", err) + } + plain = xmlEncCBCPad(plain, block.BlockSize()) + iv := bytes.Repeat([]byte{3}, block.BlockSize()) + out := make([]byte, len(iv)+len(plain)) + copy(out, iv) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out[len(iv):], plain) + return out +} + func encryptTestAssertionGCM(t *testing.T, plain, key []byte) []byte { t.Helper() block, err := aes.NewCipher(key) @@ -244,6 +299,20 @@ func pkcs7Pad(value []byte, blockSize int) []byte { return append(value, bytes.Repeat([]byte{byte(padding)}, padding)...) } +func xmlEncCBCPad(value []byte, blockSize int) []byte { + padding := blockSize - len(value)%blockSize + if padding == 0 { + padding = blockSize + } + padded := make([]byte, len(value)+padding) + copy(padded, value) + for i := len(value); i < len(padded)-1; i++ { + padded[i] = byte(i % 251) + } + padded[len(padded)-1] = byte(padding) + return padded +} + func testIDPMetadataXML(entityID, redirectURL, postURL string) string { return `