mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-08 20:34:48 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
73
internal/dao/api_token.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
59
internal/dao/task.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user