feat: add session-level single sign-out with authentication and configurable scope (#4678)

This commit is contained in:
DacongDA
2025-12-12 23:08:01 +08:00
committed by GitHub
parent f82c90b901
commit 7d130392d9
3 changed files with 314 additions and 38 deletions

View File

@@ -431,7 +431,8 @@ func (c *ApiController) Logout() {
// SsoLogout
// @Title SsoLogout
// @Tag Login API
// @Description logout the current user from all applications
// @Description logout the current user from all applications or current session only
// @Param logoutAll query string false "Whether to logout from all sessions. Accepted values: 'true', '1', or empty (default: true). Any other value means false."
// @Success 200 {object} controllers.Response The Response object
// @router /sso-logout [get,post]
func (c *ApiController) SsoLogout() {
@@ -442,6 +443,11 @@ func (c *ApiController) SsoLogout() {
return
}
// Check if user wants to logout from all sessions or just current session
// Default is true for backward compatibility
logoutAll := c.Input().Get("logoutAll")
logoutAllSessions := logoutAll == "" || logoutAll == "true" || logoutAll == "1"
c.ClearUserSession()
c.ClearTokenSession()
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
@@ -449,37 +455,62 @@ func (c *ApiController) SsoLogout() {
c.ResponseError(err.Error())
return
}
_, err = object.DeleteSessionId(util.GetSessionId(owner, username, object.CasdoorApplication), c.Ctx.Input.CruSession.SessionID())
if err != nil {
c.ResponseError(err.Error())
return
}
_, err = object.ExpireTokenByUser(owner, username)
if err != nil {
c.ResponseError(err.Error())
return
}
sessions, err := object.GetUserSessions(owner, username)
currentSessionId := c.Ctx.Input.CruSession.SessionID()
_, err = object.DeleteSessionId(util.GetSessionId(owner, username, object.CasdoorApplication), currentSessionId)
if err != nil {
c.ResponseError(err.Error())
return
}
var tokens []*object.Token
var sessionIds []string
for _, session := range sessions {
sessionIds = append(sessionIds, session.SessionId...)
}
object.DeleteBeegoSession(sessionIds)
_, err = object.DeleteAllUserSessions(owner, username)
if err != nil {
c.ResponseError(err.Error())
return
if logoutAllSessions {
// Logout from all sessions: expire all tokens and delete all sessions
// Get tokens before expiring them (for session-level logout notification)
tokens, err = object.GetTokensByUser(owner, username)
if err != nil {
c.ResponseError(err.Error())
return
}
_, err = object.ExpireTokenByUser(owner, username)
if err != nil {
c.ResponseError(err.Error())
return
}
sessions, err := object.GetUserSessions(owner, username)
if err != nil {
c.ResponseError(err.Error())
return
}
for _, session := range sessions {
sessionIds = append(sessionIds, session.SessionId...)
}
object.DeleteBeegoSession(sessionIds)
_, err = object.DeleteAllUserSessions(owner, username)
if err != nil {
c.ResponseError(err.Error())
return
}
util.LogInfo(c.Ctx, "API: [%s] logged out from all applications", user)
} else {
// Logout from current session only
sessionIds = []string{currentSessionId}
// Only delete the current session's Beego session
object.DeleteBeegoSession(sessionIds)
util.LogInfo(c.Ctx, "API: [%s] logged out from current session", user)
}
// Send SSO logout notifications to all notification providers in the user's signup application
// Now includes session-level information for targeted logout
userObj, err := object.GetUser(user)
if err != nil {
c.ResponseError(err.Error())
@@ -487,15 +518,13 @@ func (c *ApiController) SsoLogout() {
}
if userObj != nil {
err = object.SendSsoLogoutNotifications(userObj)
err = object.SendSsoLogoutNotifications(userObj, sessionIds, tokens)
if err != nil {
c.ResponseError(err.Error())
return
}
}
util.LogInfo(c.Ctx, "API: [%s] logged out from all applications", user)
c.ResponseOk()
}

View File

@@ -18,8 +18,11 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/casdoor/casdoor/notification"
"github.com/casdoor/casdoor/util"
"github.com/casdoor/notify"
)
@@ -43,9 +46,55 @@ func SendNotification(provider *Provider, content string) error {
return err
}
// SsoLogoutNotification represents the structure of a session-level SSO logout notification
// This includes session information and a signature for authentication
type SsoLogoutNotification struct {
// User information
Owner string `json:"owner"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Phone string `json:"phone"`
Id string `json:"id"`
// Event type
Event string `json:"event"`
// Session-level information for targeted logout
SessionIds []string `json:"sessionIds"` // List of session IDs being logged out
AccessTokenHashes []string `json:"accessTokenHashes"` // Hashes of access tokens being expired
// Authentication fields to prevent malicious logout requests
Nonce string `json:"nonce"` // Random nonce for replay protection
Timestamp int64 `json:"timestamp"` // Unix timestamp of the notification
Signature string `json:"signature"` // HMAC-SHA256 signature for verification
}
// GetTokensByUser retrieves all tokens for a specific user
func GetTokensByUser(owner, username string) ([]*Token, error) {
tokens := []*Token{}
err := ormer.Engine.Where("organization = ? and user = ?", owner, username).Find(&tokens)
if err != nil {
return nil, err
}
return tokens, nil
}
// generateLogoutSignature generates an HMAC-SHA256 signature for the logout notification
// The signature is computed over the critical fields to prevent tampering
func generateLogoutSignature(clientSecret string, owner string, name string, nonce string, timestamp int64, sessionIds []string, accessTokenHashes []string) string {
// Create a deterministic string from all fields that need to be verified
// Use strings.Join to avoid trailing separators and improve performance
sessionIdsStr := strings.Join(sessionIds, ",")
tokenHashesStr := strings.Join(accessTokenHashes, ",")
data := fmt.Sprintf("%s|%s|%s|%d|%s|%s", owner, name, nonce, timestamp, sessionIdsStr, tokenHashesStr)
return util.GetHmacSha256(clientSecret, data)
}
// SendSsoLogoutNotifications sends logout notifications to all notification providers
// configured in the user's signup application
func SendSsoLogoutNotifications(user *User) error {
func SendSsoLogoutNotifications(user *User, sessionIds []string, tokens []*Token) error {
if user == nil {
return nil
}
@@ -60,26 +109,55 @@ func SendSsoLogoutNotifications(user *User) error {
if err != nil {
return fmt.Errorf("failed to get signup application: %w", err)
}
if application == nil {
return fmt.Errorf("signup application not found: %s", user.SignupApplication)
}
// Prepare sanitized user data for notification
// Only include safe, non-sensitive fields
sanitizedData := map[string]interface{}{
"owner": user.Owner,
"name": user.Name,
"displayName": user.DisplayName,
"email": user.Email,
"phone": user.Phone,
"id": user.Id,
"event": "sso-logout",
// Extract access token hashes from tokens
accessTokenHashes := make([]string, 0, len(tokens))
for _, token := range tokens {
if token.AccessTokenHash != "" {
accessTokenHashes = append(accessTokenHashes, token.AccessTokenHash)
}
}
userData, err := json.Marshal(sanitizedData)
// Generate nonce and timestamp for replay protection
nonce := util.GenerateId()
timestamp := time.Now().Unix()
// Generate signature using the application's client secret
signature := generateLogoutSignature(
application.ClientSecret,
user.Owner,
user.Name,
nonce,
timestamp,
sessionIds,
accessTokenHashes,
)
// Prepare the notification data
notificationObj := SsoLogoutNotification{
Owner: user.Owner,
Name: user.Name,
DisplayName: user.DisplayName,
Email: user.Email,
Phone: user.Phone,
Id: user.Id,
Event: "sso-logout",
SessionIds: sessionIds,
AccessTokenHashes: accessTokenHashes,
Nonce: nonce,
Timestamp: timestamp,
Signature: signature,
}
notificationData, err := json.Marshal(notificationObj)
if err != nil {
return fmt.Errorf("failed to marshal user data: %w", err)
}
content := string(userData)
content := string(notificationData)
// Send notifications to all notification providers in the signup application
for _, providerItem := range application.Providers {
@@ -101,3 +179,18 @@ func SendSsoLogoutNotifications(user *User) error {
return nil
}
// VerifySsoLogoutSignature verifies the signature of an SSO logout notification
// This should be called by applications receiving logout notifications
func VerifySsoLogoutSignature(clientSecret string, notification *SsoLogoutNotification) bool {
expectedSignature := generateLogoutSignature(
clientSecret,
notification.Owner,
notification.Name,
notification.Nonce,
notification.Timestamp,
notification.SessionIds,
notification.AccessTokenHashes,
)
return notification.Signature == expectedSignature
}

154
object/notification_test.go Normal file
View File

@@ -0,0 +1,154 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"testing"
)
func TestGenerateLogoutSignature(t *testing.T) {
// Test that the signature generation is deterministic
clientSecret := "test-secret-key"
owner := "test-org"
name := "test-user"
nonce := "test-nonce-123"
timestamp := int64(1699900000)
sessionIds := []string{"session-1", "session-2"}
accessTokenHashes := []string{"hash-1", "hash-2"}
sig1 := generateLogoutSignature(clientSecret, owner, name, nonce, timestamp, sessionIds, accessTokenHashes)
sig2 := generateLogoutSignature(clientSecret, owner, name, nonce, timestamp, sessionIds, accessTokenHashes)
if sig1 != sig2 {
t.Errorf("Signature should be deterministic, got %s and %s", sig1, sig2)
}
// Test that different inputs produce different signatures
sig3 := generateLogoutSignature(clientSecret, owner, "different-user", nonce, timestamp, sessionIds, accessTokenHashes)
if sig1 == sig3 {
t.Error("Different inputs should produce different signatures")
}
// Test with different client secret
sig4 := generateLogoutSignature("different-secret", owner, name, nonce, timestamp, sessionIds, accessTokenHashes)
if sig1 == sig4 {
t.Error("Different client secrets should produce different signatures")
}
}
func TestVerifySsoLogoutSignature(t *testing.T) {
clientSecret := "test-secret-key"
owner := "test-org"
name := "test-user"
nonce := "test-nonce-123"
timestamp := int64(1699900000)
sessionIds := []string{"session-1", "session-2"}
accessTokenHashes := []string{"hash-1", "hash-2"}
// Generate a valid signature
signature := generateLogoutSignature(clientSecret, owner, name, nonce, timestamp, sessionIds, accessTokenHashes)
// Create a notification with the valid signature
notification := &SsoLogoutNotification{
Owner: owner,
Name: name,
Nonce: nonce,
Timestamp: timestamp,
SessionIds: sessionIds,
AccessTokenHashes: accessTokenHashes,
Signature: signature,
}
// Verify with correct secret
if !VerifySsoLogoutSignature(clientSecret, notification) {
t.Error("Valid signature should be verified successfully")
}
// Verify with wrong secret
if VerifySsoLogoutSignature("wrong-secret", notification) {
t.Error("Invalid signature should not be verified")
}
// Verify with tampered data
tamperedNotification := &SsoLogoutNotification{
Owner: owner,
Name: "tampered-user", // Changed
Nonce: nonce,
Timestamp: timestamp,
SessionIds: sessionIds,
AccessTokenHashes: accessTokenHashes,
Signature: signature, // Same signature
}
if VerifySsoLogoutSignature(clientSecret, tamperedNotification) {
t.Error("Tampered notification should not be verified")
}
}
func TestSsoLogoutNotificationStructure(t *testing.T) {
notification := SsoLogoutNotification{
Owner: "test-org",
Name: "test-user",
DisplayName: "Test User",
Email: "test@example.com",
Phone: "+1234567890",
Id: "user-123",
Event: "sso-logout",
SessionIds: []string{"session-1", "session-2"},
AccessTokenHashes: []string{"hash-1", "hash-2"},
Nonce: "nonce-123",
Timestamp: 1699900000,
Signature: "sig-123",
}
// Verify all fields are set correctly
if notification.Owner != "test-org" {
t.Errorf("Owner mismatch, got %s", notification.Owner)
}
if notification.Name != "test-user" {
t.Errorf("Name mismatch, got %s", notification.Name)
}
if notification.Event != "sso-logout" {
t.Errorf("Event mismatch, got %s", notification.Event)
}
if len(notification.SessionIds) != 2 {
t.Errorf("SessionIds count mismatch, got %d", len(notification.SessionIds))
}
if len(notification.AccessTokenHashes) != 2 {
t.Errorf("AccessTokenHashes count mismatch, got %d", len(notification.AccessTokenHashes))
}
}
func TestGenerateLogoutSignatureWithEmptyArrays(t *testing.T) {
clientSecret := "test-secret-key"
owner := "test-org"
name := "test-user"
nonce := "test-nonce-123"
timestamp := int64(1699900000)
// Test with empty session IDs and token hashes
sig1 := generateLogoutSignature(clientSecret, owner, name, nonce, timestamp, []string{}, []string{})
sig2 := generateLogoutSignature(clientSecret, owner, name, nonce, timestamp, nil, nil)
// Empty slice and nil should produce the same signature
if sig1 != sig2 {
t.Errorf("Empty slice and nil should produce the same signature, got %s and %s", sig1, sig2)
}
// Should be different from non-empty arrays
sig3 := generateLogoutSignature(clientSecret, owner, name, nonce, timestamp, []string{"session-1"}, []string{"hash-1"})
if sig1 == sig3 {
t.Error("Empty arrays should produce different signature from non-empty arrays")
}
}