From a3e6c2e84a6b42413c3f6dd8eae7fa9a82991812 Mon Sep 17 00:00:00 2001 From: chanx <1243304602@qq.com> Date: Fri, 13 Mar 2026 16:53:54 +0800 Subject: [PATCH] Fix: Enhanced user management functionality and cascading data deletion. (#13594) ### What problem does this PR solve? Fix: Enhanced user management functionality and cascading data deletion. Added tenant and related data initialization functionality during user creation, including tenants, user-tenant relationships, LLM configuration, and root folder. Added cascading deletion logic for user deletion, ensuring that all associated data is cleaned up simultaneously when a user is deleted. Implemented a Werkzeug-compatible password hash algorithm (scrypt) and verification functionality. Added multiple DAO methods to support batch data operations and cascading deletion. Improved user login processing and added token signing functionality. ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) --- internal/admin/password.go | 134 +++++++---- internal/admin/service.go | 424 ++++++++++++++++++++++++++++++++++- internal/dao/api_token.go | 73 ++++++ internal/dao/chat.go | 15 ++ internal/dao/chat_session.go | 9 + internal/dao/document.go | 24 ++ internal/dao/file.go | 22 ++ internal/dao/kb.go | 15 ++ internal/dao/task.go | 59 +++++ internal/dao/tenant.go | 5 + internal/dao/tenant_llm.go | 14 ++ internal/dao/user.go | 9 +- internal/dao/user_canvas.go | 15 ++ internal/dao/user_tenant.go | 19 ++ internal/handler/user.go | 20 +- internal/service/user.go | 59 +++-- 16 files changed, 850 insertions(+), 66 deletions(-) create mode 100644 internal/dao/api_token.go create mode 100644 internal/dao/task.go diff --git a/internal/admin/password.go b/internal/admin/password.go index d1cd9ec05..fdd0c2b36 100644 --- a/internal/admin/password.go +++ b/internal/admin/password.go @@ -26,40 +26,84 @@ import ( "encoding/pem" "errors" "fmt" - "hash" "os" "strconv" "strings" "golang.org/x/crypto/pbkdf2" + "golang.org/x/crypto/scrypt" ) // CheckWerkzeugPassword verifies a password against a werkzeug password hash -// Format: pbkdf2:sha256:iterations$salt$hash +// Supports both pbkdf2 and scrypt formats func CheckWerkzeugPassword(password, hashStr string) bool { + if strings.HasPrefix(hashStr, "scrypt:") { + return checkScryptPassword(password, hashStr) + } + if strings.HasPrefix(hashStr, "pbkdf2:") { + return checkPBKDF2Password(password, hashStr) + } + return false +} + +// checkScryptPassword verifies password using scrypt format +// Format: scrypt:n:r:p$base64(salt)$hex(hash) +// IMPORTANT: werkzeug uses the base64-encoded salt string as UTF-8 bytes, NOT the decoded bytes +func checkScryptPassword(password, hashStr string) bool { + parts := strings.Split(hashStr, "$") + if len(parts) != 3 { + return false + } + + params := strings.Split(parts[0], ":") + if len(params) != 4 || params[0] != "scrypt" { + return false + } + + n, err := strconv.ParseUint(params[1], 10, 0) + if err != nil { + return false + } + r, err := strconv.ParseUint(params[2], 10, 0) + if err != nil { + return false + } + p, err := strconv.ParseUint(params[3], 10, 0) + if err != nil { + return false + } + + saltB64 := parts[1] + hashHex := parts[2] + + // IMPORTANT: werkzeug uses the base64 string as UTF-8 bytes, NOT decoded bytes + // This is the key difference from standard implementations + salt := []byte(saltB64) + + // Decode hash from hex + expectedHash, err := hex.DecodeString(hashHex) + if err != nil { + return false + } + + computed, err := scrypt.Key([]byte(password), salt, int(n), int(r), int(p), len(expectedHash)) + if err != nil { + return false + } + + return constantTimeCompare(expectedHash, computed) +} + +// checkPBKDF2Password verifies password using PBKDF2 format +// Format: pbkdf2:sha256:iterations$base64(salt)$base64(hash) +func checkPBKDF2Password(password, hashStr string) bool { parts := strings.Split(hashStr, "$") if len(parts) != 3 { return false } - // Parse method (e.g., "pbkdf2:sha256:150000") methodParts := strings.Split(parts[0], ":") - if len(methodParts) != 3 { - return false - } - - if methodParts[0] != "pbkdf2" { - return false - } - - var hashFunc func() hash.Hash - switch methodParts[1] { - case "sha256": - hashFunc = sha256.New - case "sha512": - // sha512 not supported in this implementation - return false - default: + if len(methodParts) != 3 || methodParts[0] != "pbkdf2" { return false } @@ -71,48 +115,58 @@ func CheckWerkzeugPassword(password, hashStr string) bool { salt := parts[1] expectedHash := parts[2] - // Decode salt from base64 saltBytes, err := base64.StdEncoding.DecodeString(salt) if err != nil { - // Try hex encoding saltBytes, err = hex.DecodeString(salt) if err != nil { return false } } - // Generate hash using PBKDF2 - key := pbkdf2.Key([]byte(password), saltBytes, iterations, 32, hashFunc) + key := pbkdf2.Key([]byte(password), saltBytes, iterations, 32, sha256.New) computedHash := base64.StdEncoding.EncodeToString(key) return computedHash == expectedHash } -// IsWerkzeugHash checks if a hash is in werkzeug format -func IsWerkzeugHash(hashStr string) bool { - return strings.HasPrefix(hashStr, "pbkdf2:") +// constantTimeCompare performs constant time comparison +func constantTimeCompare(a, b []byte) bool { + if len(a) != len(b) { + return false + } + var result byte + for i := 0; i < len(a); i++ { + result |= a[i] ^ b[i] + } + return result == 0 } -// GenerateWerkzeugPasswordHash generates a werkzeug-compatible password hash -func GenerateWerkzeugPasswordHash(password string, iterations int) (string, error) { - if iterations == 0 { - iterations = 150000 - } +// IsWerkzeugHash checks if a hash is in werkzeug format +func IsWerkzeugHash(hashStr string) bool { + return strings.HasPrefix(hashStr, "scrypt:") || strings.HasPrefix(hashStr, "pbkdf2:") +} - // Generate random salt - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { +// GenerateWerkzeugPasswordHash generates a werkzeug-compatible password hash using scrypt +// This matches Python werkzeug's default behavior +func GenerateWerkzeugPasswordHash(password string, iterations int) (string, error) { + // Generate random bytes (12 bytes will produce 16-char base64 string) + randomBytes := make([]byte, 12) + if _, err := rand.Read(randomBytes); err != nil { return "", err } - // Generate hash using PBKDF2-SHA256 - key := pbkdf2.Key([]byte(password), salt, iterations, 32, sha256.New) + // Encode to base64 string (this will be 16 characters) + saltB64 := base64.StdEncoding.EncodeToString(randomBytes) - // Format: pbkdf2:sha256:iterations$base64(salt)$base64(hash) - saltB64 := base64.StdEncoding.EncodeToString(salt) - hashB64 := base64.StdEncoding.EncodeToString(key) + // Use scrypt with werkzeug default parameters: N=32768, r=8, p=1, keyLen=64 + // IMPORTANT: werkzeug uses the base64 string as UTF-8 bytes, NOT the decoded bytes + hash, err := scrypt.Key([]byte(password), []byte(saltB64), 32768, 8, 1, 64) + if err != nil { + return "", err + } - return fmt.Sprintf("pbkdf2:sha256:%d$%s$%s", iterations, saltB64, hashB64), nil + // Format: scrypt:n:r:p$base64(salt)$hex(hash) + return fmt.Sprintf("scrypt:32768:8:1$%s$%x", saltB64, hash), nil } // DecryptPassword decrypts the password using RSA private key diff --git a/internal/admin/service.go b/internal/admin/service.go index 4b4e692d1..14abc87a0 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -29,12 +29,15 @@ import ( "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/engine/elasticsearch" + "ragflow/internal/logger" "ragflow/internal/model" "ragflow/internal/server" "ragflow/internal/utility" "regexp" "strconv" "time" + + "go.uber.org/zap" ) // Service errors @@ -50,6 +53,19 @@ type Service struct { licenseDAO *dao.LicenseDAO timeRecordDAO *dao.TimeRecordDAO systemSettingsDAO *dao.SystemSettingsDAO + tenantDAO *dao.TenantDAO + userTenantDAO *dao.UserTenantDAO + tenantLLMDAO *dao.TenantLLMDAO + fileDAO *dao.FileDAO + documentDAO *dao.DocumentDAO + taskDAO *dao.TaskDAO + kbDAO *dao.KnowledgebaseDAO + canvasDAO *dao.UserCanvasDAO + chatDAO *dao.ChatDAO + chatSessionDAO *dao.ChatSessionDAO + apiTokenDAO *dao.APITokenDAO + api4ConvDAO *dao.API4ConversationDAO + llmDAO *dao.LLMDAO } // NewService create admin service @@ -59,6 +75,19 @@ func NewService() *Service { licenseDAO: dao.NewLicenseDAO(), timeRecordDAO: dao.NewTimeRecordDAO(), systemSettingsDAO: dao.NewSystemSettingsDAO(), + tenantDAO: dao.NewTenantDAO(), + userTenantDAO: dao.NewUserTenantDAO(), + tenantLLMDAO: dao.NewTenantLLMDAO(), + fileDAO: dao.NewFileDAO(), + documentDAO: dao.NewDocumentDAO(), + taskDAO: dao.NewTaskDAO(), + kbDAO: dao.NewKnowledgebaseDAO(), + canvasDAO: dao.NewUserCanvasDAO(), + chatDAO: dao.NewChatDAO(), + chatSessionDAO: dao.NewChatSessionDAO(), + apiTokenDAO: dao.NewAPITokenDAO(), + api4ConvDAO: dao.NewAPI4ConversationDAO(), + llmDAO: dao.NewLLMDAO(), } } @@ -176,10 +205,134 @@ func (s *Service) CreateUser(username, password, role string) (map[string]interf }, } - if err := s.userDAO.Create(user); err != nil { + // Start transaction for creating user and related data + tx := dao.DB.Begin() + if tx.Error != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", tx.Error) + } + + // Rollback helper function + rollbackTx := func() { + if rbErr := tx.Rollback(); rbErr.Error != nil { + logger.Error("failed to rollback transaction", rbErr.Error) + } + } + + // 1. Create user + if err := tx.Create(user).Error; err != nil { + rollbackTx() return nil, fmt.Errorf("failed to create user: %w", err) } + // 2. Create tenant (tenant_id = user_id) + // tenant name = nickname + "'s Kingdom" (same as Python) + tenantName := user.Nickname + "'s Kingdom" + + // Get default model IDs from config + cfg := server.GetConfig() + chatMdl := "" + embdMdl := "" + asrMdl := "" + img2txtMdl := "" + rerankMdl := "" + parserIDs := "naive:General,qa:Q&A,resume:Resume,manual:Manual,table:Table,paper:Paper,book:Book,laws:Laws,presentation:Presentation,picture:Picture,one:One,audio:Audio,email:Email,tag:Tag" + + if cfg != nil { + chatMdl = cfg.UserDefaultLLM.DefaultModels.ChatModel.Name + embdMdl = cfg.UserDefaultLLM.DefaultModels.EmbeddingModel.Name + asrMdl = cfg.UserDefaultLLM.DefaultModels.ASRModel.Name + img2txtMdl = cfg.UserDefaultLLM.DefaultModels.Image2TextModel.Name + rerankMdl = cfg.UserDefaultLLM.DefaultModels.RerankModel.Name + } + + tenantStatus := "1" + tenant := &model.Tenant{ + ID: userID, + Name: &tenantName, + LLMID: chatMdl, + EmbdID: embdMdl, + ASRID: asrMdl, + Img2TxtID: img2txtMdl, + RerankID: rerankMdl, + ParserIDs: parserIDs, + Credit: 512, + Status: &tenantStatus, + BaseModel: model.BaseModel{ + CreateTime: &now, + CreateDate: &nowDate, + UpdateTime: &now, + UpdateDate: &nowDate, + }, + } + if err := tx.Create(tenant).Error; err != nil { + rollbackTx() + return nil, fmt.Errorf("failed to create tenant: %w", err) + } + + // 3. Create user-tenant relation + userTenantStatus := "1" + userTenant := &model.UserTenant{ + ID: utility.GenerateToken(), + UserID: userID, + TenantID: userID, + Role: "owner", + InvitedBy: userID, + Status: &userTenantStatus, + BaseModel: model.BaseModel{ + CreateTime: &now, + CreateDate: &nowDate, + UpdateTime: &now, + UpdateDate: &nowDate, + }, + } + if err := tx.Create(userTenant).Error; err != nil { + rollbackTx() + return nil, fmt.Errorf("failed to create user-tenant relation: %w", err) + } + + // 4. Create tenant LLM configurations + tenantLLMs, err := s.getInitTenantLLM(userID) + if err != nil { + logger.Warn("failed to get init tenant LLM configs", zap.Error(err)) + // Continue without LLM configs - not a critical error + } else if len(tenantLLMs) > 0 { + if err := tx.Create(&tenantLLMs).Error; err != nil { + logger.Warn("failed to create tenant LLM configs", zap.Error(err)) + // Continue without LLM configs - not a critical error + } + } + + // 5. Create root file folder + fileID := utility.GenerateToken() + fileLocation := "" + file := &model.File{ + ID: fileID, + ParentID: fileID, + TenantID: userID, + CreatedBy: userID, + Name: "/", + Type: "folder", + Size: 0, + Location: &fileLocation, + BaseModel: model.BaseModel{ + CreateTime: &now, + CreateDate: &nowDate, + UpdateTime: &now, + UpdateDate: &nowDate, + }, + } + if err := tx.Create(file).Error; err != nil { + rollbackTx() + return nil, fmt.Errorf("failed to create root file folder: %w", err) + } + + // Commit transaction + if err := tx.Commit().Error; err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + + logger.Info("Create user success with tenant and related data", zap.String("username", username)) + return map[string]interface{}{ "id": user.ID, "email": user.Email, @@ -190,6 +343,139 @@ func (s *Service) CreateUser(username, password, role string) (map[string]interf }, nil } +// getInitTenantLLM gets initial tenant LLM configurations +// This matches Python's get_init_tenant_llm function +func (s *Service) getInitTenantLLM(userID string) ([]*model.TenantLLM, error) { + cfg := server.GetConfig() + if cfg == nil { + return nil, fmt.Errorf("config not initialized") + } + + var tenantLLMs []*model.TenantLLM + + // Get model configs from configuration + modelConfigs := []server.ModelConfig{ + cfg.UserDefaultLLM.DefaultModels.ChatModel, + cfg.UserDefaultLLM.DefaultModels.EmbeddingModel, + cfg.UserDefaultLLM.DefaultModels.RerankModel, + cfg.UserDefaultLLM.DefaultModels.ASRModel, + cfg.UserDefaultLLM.DefaultModels.Image2TextModel, + } + + // Track seen factories to avoid duplicates + seenFactories := make(map[string]bool) + var uniqueFactories []server.ModelConfig + + for _, mc := range modelConfigs { + if mc.Factory == "" { + continue + } + if !seenFactories[mc.Factory] { + seenFactories[mc.Factory] = true + uniqueFactories = append(uniqueFactories, mc) + } + } + + // Get LLMs for each unique factory + for _, factoryConfig := range uniqueFactories { + llms, err := s.llmDAO.GetByFactory(factoryConfig.Factory) + if err != nil { + logger.Warn("failed to get LLMs for factory", zap.String("factory", factoryConfig.Factory), zap.Error(err)) + continue + } + + for _, llm := range llms { + // Determine API key and base URL based on model type + var apiKey, apiBase string + switch llm.ModelType { + case string(model.ModelTypeChat): + apiKey = factoryConfig.APIKey + apiBase = factoryConfig.BaseURL + case string(model.ModelTypeEmbedding): + apiKey = cfg.UserDefaultLLM.DefaultModels.EmbeddingModel.APIKey + apiBase = cfg.UserDefaultLLM.DefaultModels.EmbeddingModel.BaseURL + if apiKey == "" { + apiKey = factoryConfig.APIKey + } + if apiBase == "" { + apiBase = factoryConfig.BaseURL + } + case string(model.ModelTypeRerank): + apiKey = cfg.UserDefaultLLM.DefaultModels.RerankModel.APIKey + apiBase = cfg.UserDefaultLLM.DefaultModels.RerankModel.BaseURL + if apiKey == "" { + apiKey = factoryConfig.APIKey + } + if apiBase == "" { + apiBase = factoryConfig.BaseURL + } + case string(model.ModelTypeSpeech2Text): + apiKey = cfg.UserDefaultLLM.DefaultModels.ASRModel.APIKey + apiBase = cfg.UserDefaultLLM.DefaultModels.ASRModel.BaseURL + if apiKey == "" { + apiKey = factoryConfig.APIKey + } + if apiBase == "" { + apiBase = factoryConfig.BaseURL + } + case string(model.ModelTypeImage2Text): + apiKey = cfg.UserDefaultLLM.DefaultModels.Image2TextModel.APIKey + apiBase = cfg.UserDefaultLLM.DefaultModels.Image2TextModel.BaseURL + if apiKey == "" { + apiKey = factoryConfig.APIKey + } + if apiBase == "" { + apiBase = factoryConfig.BaseURL + } + default: + apiKey = factoryConfig.APIKey + apiBase = factoryConfig.BaseURL + } + + maxTokens := int64(8192) + if llm.MaxTokens > 0 { + maxTokens = llm.MaxTokens + } + + llmName := llm.LLMName + modelType := llm.ModelType + now := time.Now().Unix() + nowDate := time.Now() + + tenantLLM := &model.TenantLLM{ + TenantID: userID, + LLMFactory: factoryConfig.Factory, + LLMName: &llmName, + ModelType: &modelType, + APIKey: &apiKey, + APIBase: &apiBase, + MaxTokens: maxTokens, + Status: "1", + BaseModel: model.BaseModel{ + CreateTime: &now, + CreateDate: &nowDate, + UpdateTime: &now, + UpdateDate: &nowDate, + }, + } + tenantLLMs = append(tenantLLMs, tenantLLM) + } + } + + // Remove duplicates based on (tenant_id, llm_factory, llm_name) + seen := make(map[string]bool) + var uniqueLLMs []*model.TenantLLM + for _, tllm := range tenantLLMs { + key := fmt.Sprintf("%s|%s|%s", tllm.TenantID, tllm.LLMFactory, *tllm.LLMName) + if !seen[key] { + seen[key] = true + uniqueLLMs = append(uniqueLLMs, tllm) + } + } + + return uniqueLLMs, nil +} + // GetUserDetails get user details func (s *Service) GetUserDetails(username string) (map[string]interface{}, error) { // Query user by email/username @@ -209,7 +495,7 @@ func (s *Service) GetUserDetails(username string) (map[string]interface{}, error }, nil } -// DeleteUser delete user +// DeleteUser delete user with cascade delete of all related data // Parameters: // - username: email address of the user to delete // @@ -237,10 +523,140 @@ func (s *Service) DeleteUser(username string) error { return fmt.Errorf("Cannot delete admin account") } - if err := s.userDAO.DeleteByID(user.ID); err != nil { - return fmt.Errorf("failed to delete user: %w", err) + // Get user-tenant relations + tenants, err := s.userTenantDAO.GetByUserIDAll(user.ID) + if err != nil { + logger.Warn("failed to get user-tenant relations", zap.Error(err)) } + // Find owned tenant (role = "owner") + var ownedTenantID string + for _, t := range tenants { + if t.Role == "owner" { + ownedTenantID = t.TenantID + break + } + } + + // Start transaction for cascade delete + tx := dao.DB.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to begin transaction: %w", tx.Error) + } + + // Rollback helper function + rollbackTx := func() { + if rbErr := tx.Rollback(); rbErr.Error != nil { + logger.Error("failed to rollback transaction", rbErr.Error) + } + } + + // Delete owned tenant data + if ownedTenantID != "" { + // 1. Get knowledge base IDs + kbIDs, err := s.kbDAO.GetKBIDsByTenantIDSimple(ownedTenantID) + if err != nil { + logger.Warn("failed to get knowledge base IDs", zap.Error(err)) + } + + if len(kbIDs) > 0 { + // 2. Get document IDs + docIDs, err := s.documentDAO.GetAllDocIDsByKBIDs(kbIDs) + if err != nil { + logger.Warn("failed to get document IDs", zap.Error(err)) + } + + // 3. Delete tasks by document IDs + if len(docIDs) > 0 { + docIDList := make([]string, len(docIDs)) + for i, d := range docIDs { + docIDList[i] = d["id"] + } + if delErr := tx.Unscoped().Where("doc_id IN ?", docIDList).Delete(&model.Task{}); delErr.Error != nil { + logger.Warn("failed to delete tasks", zap.Error(delErr.Error)) + } + } + + // 4. Delete documents + if delErr := tx.Unscoped().Where("kb_id IN ?", kbIDs).Delete(&model.Document{}); delErr.Error != nil { + logger.Warn("failed to delete documents", zap.Error(delErr.Error)) + } + + // 5. Delete knowledge bases + if delErr := tx.Unscoped().Where("id IN ?", kbIDs).Delete(&model.Knowledgebase{}); delErr.Error != nil { + logger.Warn("failed to delete knowledge bases", zap.Error(delErr.Error)) + } + } + + // 6. Delete files + if delErr := tx.Unscoped().Where("tenant_id = ?", ownedTenantID).Delete(&model.File{}); delErr.Error != nil { + logger.Warn("failed to delete files", zap.Error(delErr.Error)) + } + + // 7. Delete user canvas (agents) + if delErr := tx.Unscoped().Where("user_id = ?", ownedTenantID).Delete(&model.UserCanvas{}); delErr.Error != nil { + logger.Warn("failed to delete user canvas", zap.Error(delErr.Error)) + } + + // 8. Get dialog IDs + var dialogIDs []string + if pluckErr := tx.Model(&model.Chat{}).Where("tenant_id = ?", ownedTenantID).Pluck("id", &dialogIDs); pluckErr.Error != nil { + logger.Warn("failed to get dialog IDs", zap.Error(pluckErr.Error)) + } + + // 9. Delete chat sessions + if len(dialogIDs) > 0 { + if delErr := tx.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&model.ChatSession{}); delErr.Error != nil { + logger.Warn("failed to delete chat sessions", zap.Error(delErr.Error)) + } + } + + // 10. Delete chats/dialogs + if delErr := tx.Unscoped().Where("tenant_id = ?", ownedTenantID).Delete(&model.Chat{}); delErr.Error != nil { + logger.Warn("failed to delete chats", zap.Error(delErr.Error)) + } + + // 11. Delete API tokens + if delErr := tx.Unscoped().Where("tenant_id = ?", ownedTenantID).Delete(&model.APIToken{}); delErr.Error != nil { + logger.Warn("failed to delete API tokens", zap.Error(delErr.Error)) + } + + // 12. Delete API4Conversations + if len(dialogIDs) > 0 { + if delErr := tx.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&model.API4Conversation{}); delErr.Error != nil { + logger.Warn("failed to delete API4Conversations", zap.Error(delErr.Error)) + } + } + + // 13. Delete tenant LLM configurations + if delErr := tx.Unscoped().Where("tenant_id = ?", ownedTenantID).Delete(&model.TenantLLM{}); delErr.Error != nil { + logger.Warn("failed to delete tenant LLM", zap.Error(delErr.Error)) + } + + // 14. Delete tenant + if delErr := tx.Unscoped().Where("id = ?", ownedTenantID).Delete(&model.Tenant{}); delErr.Error != nil { + logger.Warn("failed to delete tenant", zap.Error(delErr.Error)) + } + } + + // 15. Delete user-tenant relations + if delErr := tx.Unscoped().Where("user_id = ?", user.ID).Delete(&model.UserTenant{}); delErr.Error != nil { + logger.Warn("failed to delete user-tenant relations", zap.Error(delErr.Error)) + } + + // 16. Finally, hard delete user + if delErr := tx.Unscoped().Where("id = ?", user.ID).Delete(&model.User{}); delErr.Error != nil { + rollbackTx() + return fmt.Errorf("failed to delete user: %w", delErr.Error) + } + + // Commit transaction + if commitErr := tx.Commit(); commitErr.Error != nil { + return fmt.Errorf("failed to commit transaction: %w", commitErr.Error) + } + + logger.Info("Delete user success with all related data", zap.String("username", username)) + return nil } diff --git a/internal/dao/api_token.go b/internal/dao/api_token.go new file mode 100644 index 000000000..6db2ce76c --- /dev/null +++ b/internal/dao/api_token.go @@ -0,0 +1,73 @@ +// +// Copyright 2026 The InfiniFlow 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 dao + +import ( + "ragflow/internal/model" +) + +// APITokenDAO API token data access object +type APITokenDAO struct{} + +// NewAPITokenDAO create API token DAO +func NewAPITokenDAO() *APITokenDAO { + return &APITokenDAO{} +} + +// Create creates a new API token +func (dao *APITokenDAO) Create(apiToken *model.APIToken) error { + return DB.Create(apiToken).Error +} + +// GetByTenantID gets API tokens by tenant ID +func (dao *APITokenDAO) GetByTenantID(tenantID string) ([]*model.APIToken, error) { + var tokens []*model.APIToken + err := DB.Where("tenant_id = ?", tenantID).Find(&tokens).Error + return tokens, err +} + +// DeleteByTenantID deletes all API tokens by tenant ID (hard delete) +func (dao *APITokenDAO) DeleteByTenantID(tenantID string) (int64, error) { + result := DB.Unscoped().Where("tenant_id = ?", tenantID).Delete(&model.APIToken{}) + return result.RowsAffected, result.Error +} + +// DeleteByDialogIDs deletes API tokens by dialog IDs (hard delete) +func (dao *APITokenDAO) DeleteByDialogIDs(dialogIDs []string) (int64, error) { + if len(dialogIDs) == 0 { + return 0, nil + } + result := DB.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&model.APIToken{}) + return result.RowsAffected, result.Error +} + +// API4ConversationDAO API for conversation data access object +type API4ConversationDAO struct{} + +// NewAPI4ConversationDAO create API4Conversation DAO +func NewAPI4ConversationDAO() *API4ConversationDAO { + return &API4ConversationDAO{} +} + +// DeleteByDialogIDs deletes API4Conversations by dialog IDs (hard delete) +func (dao *API4ConversationDAO) DeleteByDialogIDs(dialogIDs []string) (int64, error) { + if len(dialogIDs) == 0 { + return 0, nil + } + result := DB.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&model.API4Conversation{}) + return result.RowsAffected, result.Error +} diff --git a/internal/dao/chat.go b/internal/dao/chat.go index 91f1b7d1d..3345c447e 100644 --- a/internal/dao/chat.go +++ b/internal/dao/chat.go @@ -215,3 +215,18 @@ func (dao *ChatDAO) UpdateManyByID(updates []map[string]interface{}) error { return tx.Commit().Error } + +// DeleteByTenantID deletes all chats by tenant ID (hard delete) +func (dao *ChatDAO) DeleteByTenantID(tenantID string) (int64, error) { + result := DB.Unscoped().Where("tenant_id = ?", tenantID).Delete(&model.Chat{}) + return result.RowsAffected, result.Error +} + +// GetAllDialogIDsByTenantID gets all dialog IDs by tenant ID +func (dao *ChatDAO) GetAllDialogIDsByTenantID(tenantID string) ([]string, error) { + var dialogIDs []string + err := DB.Model(&model.Chat{}). + Where("tenant_id = ?", tenantID). + Pluck("id", &dialogIDs).Error + return dialogIDs, err +} diff --git a/internal/dao/chat_session.go b/internal/dao/chat_session.go index f728b7ca8..d633df00d 100644 --- a/internal/dao/chat_session.go +++ b/internal/dao/chat_session.go @@ -83,3 +83,12 @@ func (dao *ChatSessionDAO) GetDialogByID(dialogID string) (*model.Chat, error) { } return &dialog, nil } + +// DeleteByDialogIDs deletes chat sessions by dialog IDs (hard delete) +func (dao *ChatSessionDAO) DeleteByDialogIDs(dialogIDs []string) (int64, error) { + if len(dialogIDs) == 0 { + return 0, nil + } + result := DB.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&model.ChatSession{}) + return result.RowsAffected, result.Error +} diff --git a/internal/dao/document.go b/internal/dao/document.go index 49bdb51ed..c275427c2 100644 --- a/internal/dao/document.go +++ b/internal/dao/document.go @@ -79,3 +79,27 @@ func (dao *DocumentDAO) List(offset, limit int) ([]*model.Document, int64, error err := DB.Preload("Author").Offset(offset).Limit(limit).Find(&documents).Error return documents, total, err } + +// DeleteByTenantID deletes all documents by tenant ID (hard delete) +func (dao *DocumentDAO) DeleteByTenantID(tenantID string) (int64, error) { + result := DB.Unscoped().Where("tenant_id = ?", tenantID).Delete(&model.Document{}) + return result.RowsAffected, result.Error +} + +// GetAllDocIDsByKBIDs gets all document IDs by knowledge base IDs +func (dao *DocumentDAO) GetAllDocIDsByKBIDs(kbIDs []string) ([]map[string]string, error) { + var docs []struct { + ID string `gorm:"column:id"` + KbID string `gorm:"column:kb_id"` + } + err := DB.Model(&model.Document{}).Select("id, kb_id").Where("kb_id IN ?", kbIDs).Find(&docs).Error + if err != nil { + return nil, err + } + + result := make([]map[string]string, len(docs)) + for i, doc := range docs { + result[i] = map[string]string{"id": doc.ID, "kb_id": doc.KbID} + } + return result, nil +} diff --git a/internal/dao/file.go b/internal/dao/file.go index c665d1077..e2fba7a7c 100644 --- a/internal/dao/file.go +++ b/internal/dao/file.go @@ -200,6 +200,28 @@ func (dao *FileDAO) Create(file *model.File) error { return DB.Create(file).Error } +// DeleteByTenantID deletes all files by tenant ID (hard delete) +func (dao *FileDAO) DeleteByTenantID(tenantID string) (int64, error) { + result := DB.Unscoped().Where("tenant_id = ?", tenantID).Delete(&model.File{}) + return result.RowsAffected, result.Error +} + +// DeleteByIDs deletes files by IDs (hard delete) +func (dao *FileDAO) DeleteByIDs(ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + result := DB.Unscoped().Where("id IN ?", ids).Delete(&model.File{}) + return result.RowsAffected, result.Error +} + +// GetAllIDsByTenantID gets all file IDs by tenant ID +func (dao *FileDAO) GetAllIDsByTenantID(tenantID string) ([]string, error) { + var ids []string + err := DB.Model(&model.File{}).Where("tenant_id = ?", tenantID).Pluck("id", &ids).Error + return ids, err +} + // generateUUID generates a UUID func generateUUID() string { id := uuid.New().String() diff --git a/internal/dao/kb.go b/internal/dao/kb.go index 6fb2a6a24..6411dcd3e 100644 --- a/internal/dao/kb.go +++ b/internal/dao/kb.go @@ -494,3 +494,18 @@ func mergeConfig(old, new map[string]interface{}) map[string]interface{} { return result } + +// DeleteByTenantID deletes all knowledge bases by tenant ID (hard delete) +func (dao *KnowledgebaseDAO) DeleteByTenantID(tenantID string) (int64, error) { + result := DB.Unscoped().Where("tenant_id = ?", tenantID).Delete(&model.Knowledgebase{}) + return result.RowsAffected, result.Error +} + +// GetKBIDsByTenantID gets all knowledge base IDs by tenant ID +func (dao *KnowledgebaseDAO) GetKBIDsByTenantIDSimple(tenantID string) ([]string, error) { + var kbIDs []string + err := DB.Model(&model.Knowledgebase{}). + Where("tenant_id = ?", tenantID). + Pluck("id", &kbIDs).Error + return kbIDs, err +} diff --git a/internal/dao/task.go b/internal/dao/task.go new file mode 100644 index 000000000..c3dbf06c7 --- /dev/null +++ b/internal/dao/task.go @@ -0,0 +1,59 @@ +// +// Copyright 2026 The InfiniFlow 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 dao + +import ( + "ragflow/internal/model" +) + +// TaskDAO task data access object +type TaskDAO struct{} + +// NewTaskDAO create task DAO +func NewTaskDAO() *TaskDAO { + return &TaskDAO{} +} + +// Create creates a new task +func (dao *TaskDAO) Create(task *model.Task) error { + return DB.Create(task).Error +} + +// GetByID gets task by ID +func (dao *TaskDAO) GetByID(id string) (*model.Task, error) { + var task model.Task + err := DB.Where("id = ?", id).First(&task).Error + if err != nil { + return nil, err + } + return &task, nil +} + +// DeleteByDocIDs deletes tasks by document IDs (hard delete) +func (dao *TaskDAO) DeleteByDocIDs(docIDs []string) (int64, error) { + if len(docIDs) == 0 { + return 0, nil + } + result := DB.Unscoped().Where("doc_id IN ?", docIDs).Delete(&model.Task{}) + return result.RowsAffected, result.Error +} + +// DeleteByTenantID deletes all tasks by tenant ID (hard delete via document join) +func (dao *TaskDAO) DeleteByTenantID(tenantID string) (int64, error) { + result := DB.Unscoped().Where("doc_id IN (SELECT id FROM document WHERE tenant_id = ?)", tenantID).Delete(&model.Task{}) + return result.RowsAffected, result.Error +} diff --git a/internal/dao/tenant.go b/internal/dao/tenant.go index 0585c481a..cdf823334 100644 --- a/internal/dao/tenant.go +++ b/internal/dao/tenant.go @@ -103,3 +103,8 @@ func (dao *TenantDAO) Delete(id string) error { func (dao *TenantDAO) Update(id string, updates map[string]interface{}) error { return DB.Model(&model.Tenant{}).Where("id = ?", id).Updates(updates).Error } + +// HardDelete hard deletes a tenant by ID +func (dao *TenantDAO) HardDelete(id string) error { + return DB.Unscoped().Where("id = ?", id).Delete(&model.Tenant{}).Error +} diff --git a/internal/dao/tenant_llm.go b/internal/dao/tenant_llm.go index e563ad0e8..fdf0bad69 100644 --- a/internal/dao/tenant_llm.go +++ b/internal/dao/tenant_llm.go @@ -127,3 +127,17 @@ func (dao *TenantLLMDAO) ListAllByTenant(tenantID string) ([]*model.TenantLLM, e } return tenantLLMs, nil } + +// InsertMany inserts multiple tenant LLM records +func (dao *TenantLLMDAO) InsertMany(tenantLLMs []*model.TenantLLM) error { + if len(tenantLLMs) == 0 { + return nil + } + return DB.Create(&tenantLLMs).Error +} + +// DeleteByTenantID deletes all tenant LLM records by tenant ID (hard delete) +func (dao *TenantLLMDAO) DeleteByTenantID(tenantID string) (int64, error) { + result := DB.Unscoped().Where("tenant_id = ?", tenantID).Delete(&model.TenantLLM{}) + return result.RowsAffected, result.Error +} diff --git a/internal/dao/user.go b/internal/dao/user.go index abbf79c2f..891e8b5a1 100644 --- a/internal/dao/user.go +++ b/internal/dao/user.go @@ -90,11 +90,11 @@ func (dao *UserDAO) List(offset, limit int) ([]*model.User, int64, error) { var total int64 // Only count users with status != "0" (not deleted) - if err := DB.Model(&model.User{}).Where("status != ? OR status IS NULL", "0").Count(&total).Error; err != nil { + if err := DB.Model(&model.User{}).Count(&total).Error; err != nil { return nil, 0, err } - query := DB.Model(&model.User{}).Where("status != ? OR status IS NULL", "0") + query := DB.Model(&model.User{}) if offset > 0 { query = query.Offset(offset) } @@ -115,6 +115,11 @@ func (dao *UserDAO) DeleteByID(id string) error { return DB.Model(&model.User{}).Where("id = ?", id).Update("status", "0").Error } +// HardDelete hard delete user by string ID +func (dao *UserDAO) HardDelete(id string) error { + return DB.Unscoped().Where("id = ?", id).Delete(&model.User{}).Error +} + // ListByEmail list users by email (only active users with status != "0") // Returns all users matching the given email address func (dao *UserDAO) ListByEmail(email string) ([]*model.User, error) { diff --git a/internal/dao/user_canvas.go b/internal/dao/user_canvas.go index 5d819cdcb..458815137 100644 --- a/internal/dao/user_canvas.go +++ b/internal/dao/user_canvas.go @@ -127,3 +127,18 @@ type CanvasBasicInfo struct { CanvasType *string `gorm:"column:canvas_type" json:"canvas_type,omitempty"` CanvasCategory string `gorm:"column:canvas_category" json:"canvas_category"` } + +// DeleteByUserID deletes all canvases by user ID (hard delete) +func (dao *UserCanvasDAO) DeleteByUserID(userID string) (int64, error) { + result := DB.Unscoped().Where("user_id = ?", userID).Delete(&model.UserCanvas{}) + return result.RowsAffected, result.Error +} + +// GetAllCanvasIDsByUserID gets all canvas IDs by user ID +func (dao *UserCanvasDAO) GetAllCanvasIDsByUserID(userID string) ([]string, error) { + var canvasIDs []string + err := DB.Model(&model.UserCanvas{}). + Where("user_id = ?", userID). + Pluck("id", &canvasIDs).Error + return canvasIDs, err +} diff --git a/internal/dao/user_tenant.go b/internal/dao/user_tenant.go index f6eb2e13b..cfdc55c1c 100644 --- a/internal/dao/user_tenant.go +++ b/internal/dao/user_tenant.go @@ -124,3 +124,22 @@ func (dao *UserTenantDAO) GetTenantsByUserID(userID string) ([]*TenantInfoByUser Scan(&results).Error return results, err } + +// DeleteByUserID delete user tenant relationships by user ID (hard delete) +func (dao *UserTenantDAO) DeleteByUserID(userID string) (int64, error) { + result := DB.Unscoped().Where("user_id = ?", userID).Delete(&model.UserTenant{}) + return result.RowsAffected, result.Error +} + +// DeleteByTenantID delete user tenant relationships by tenant ID (hard delete) +func (dao *UserTenantDAO) DeleteByTenantID(tenantID string) (int64, error) { + result := DB.Unscoped().Where("tenant_id = ?", tenantID).Delete(&model.UserTenant{}) + return result.RowsAffected, result.Error +} + +// GetByUserIDAll get all user tenant relationships by user ID (including deleted) +func (dao *UserTenantDAO) GetByUserIDAll(userID string) ([]*model.UserTenant, error) { + var relations []*model.UserTenant + err := DB.Where("user_id = ?", userID).Find(&relations).Error + return relations, err +} diff --git a/internal/handler/user.go b/internal/handler/user.go index 96f344980..a8507a1b7 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -128,20 +128,32 @@ func (h *UserHandler) Login(c *gin.Context) { return } - // Set Authorization header with access_token - if user.AccessToken != nil { - c.Header("Authorization", *user.AccessToken) + // Sign the access_token using itsdangerous (compatible with Python) + variables := server.GetVariables() + secretKey := variables.SecretKey + authToken, err := utility.DumpAccessToken(*user.AccessToken, secretKey) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeServerError, + "message": "Failed to generate auth token", + "data": false, + }) + return } + + // Set Authorization header with signed token + c.Header("Authorization", authToken) // Set CORS headers c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "*") c.Header("Access-Control-Allow-Headers", "*") c.Header("Access-Control-Expose-Headers", "Authorization") + profile := h.userService.GetUserProfile(user) c.JSON(http.StatusOK, gin.H{ "code": common.CodeSuccess, "message": "Welcome back!", - "data": user, + "data": profile, }) } diff --git a/internal/service/user.go b/internal/service/user.go index bf3aff795..5abcec2fe 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -17,6 +17,7 @@ package service import ( + "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/sha512" @@ -399,16 +400,29 @@ func (s *UserService) ListUsers(page, pageSize int) ([]*UserResponse, int64, com return responses, total, common.CodeSuccess, nil } -// HashPassword generate password hash +// HashPassword generate password hash using scrypt (werkzeug compatible) +// The password should already be base64 encoded (from decrypt process) +// Werkzeug default format: scrypt:32768:8:1$base64(salt)$hex(hash) +// IMPORTANT: werkzeug uses the base64-encoded salt string as UTF-8 bytes, NOT the decoded bytes func (s *UserService) HashPassword(password string) (string, error) { - salt := s.generateSalt() - hash, err := scrypt.Key([]byte(password), salt, 32768, 8, 1, 64) + // Generate random bytes (12 bytes will produce 16-char base64 string) + randomBytes, err := s.generateSalt() if err != nil { - return "", err + return "", fmt.Errorf("failed to generate salt: %w", err) } - // Return werkzeug format: scrypt:n:r:p$salt$hash - return fmt.Sprintf("scrypt:32768:8:1$%s$%x", string(salt), hash), nil + // Encode to base64 string (this will be 16 characters) + saltB64 := base64.StdEncoding.EncodeToString(randomBytes) + + // Use scrypt with werkzeug default parameters: N=32768, r=8, p=1, keyLen=64 + // IMPORTANT: werkzeug uses the base64 string as UTF-8 bytes, NOT the decoded bytes + hash, err := scrypt.Key([]byte(password), []byte(saltB64), 32768, 8, 1, 64) + if err != nil { + return "", fmt.Errorf("failed to compute scrypt hash: %w", err) + } + + // Format: scrypt:n:r:p$base64(salt)$hex(hash) + return fmt.Sprintf("scrypt:32768:8:1$%s$%x", saltB64, hash), nil } // VerifyPassword verify password @@ -481,9 +495,10 @@ func (s *UserService) verifyPBKDF2Password(hashedPassword, password string) bool } // verifyScryptPassword verifies password using scrypt format -// Format: scrypt:n:r:p$salt$hash +// Format: scrypt:n:r:p$base64(salt)$hex(hash) +// IMPORTANT: werkzeug uses the base64-encoded salt string as UTF-8 bytes, NOT the decoded bytes func (s *UserService) verifyScryptPassword(hashedPassword, password string) bool { - // Parse hash format: scrypt:n:r:p$salt$hash + // Parse hash format: scrypt:n:r:p$base64(salt)$hex(hash) parts := strings.Split(hashedPassword, "$") if len(parts) != 3 { return false @@ -507,24 +522,36 @@ func (s *UserService) verifyScryptPassword(hashedPassword, password string) bool return false } - saltStr := parts[1] + saltB64 := parts[1] hashHex := parts[2] - // Compute password hash - computed, err := scrypt.Key([]byte(password), []byte(saltStr), int(n), int(r), int(p), len(hashHex)/2) + // IMPORTANT: werkzeug uses the base64 string as UTF-8 bytes, NOT decoded bytes + // This is the key difference from standard implementations + salt := []byte(saltB64) + + // Decode expected hash from hex + expectedHash, err := hex.DecodeString(hashHex) if err != nil { return false } - decodedHash, err := hex.DecodeString(hashHex) + // Compute password hash + computed, err := scrypt.Key([]byte(password), salt, int(n), int(r), int(p), len(expectedHash)) + if err != nil { + return false + } // Constant time comparison - return s.constantTimeCompare(decodedHash, computed) + return s.constantTimeCompare(expectedHash, computed) } -// generateSalt generate salt -func (s *UserService) generateSalt() []byte { - return []byte("random_salt_for_user") // TODO: use random salt +// generateSalt generates a random 12-byte salt (werkzeug default) +func (s *UserService) generateSalt() ([]byte, error) { + salt := make([]byte, 12) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("failed to generate random salt: %w", err) + } + return salt, nil } // constantTimeCompare constant time comparison