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)
This commit is contained in:
chanx
2026-03-13 16:53:54 +08:00
committed by GitHub
parent a67fa03584
commit a3e6c2e84a
16 changed files with 850 additions and 66 deletions

View File

@@ -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

View File

@@ -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
}

73
internal/dao/api_token.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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
}

59
internal/dao/task.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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