forked from casdoor/casdoor
feat: add session-level single sign-out with authentication and configurable scope (#4678)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
154
object/notification_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user