426 lines
13 KiB
Go
426 lines
13 KiB
Go
/*
|
||
* Copyright 2026 Safronov Grigorii
|
||
*
|
||
* Licensed under the CDDL, Version 1.0 (the "License");
|
||
* you may not use this file except in compliance with the License.
|
||
*
|
||
* You may obtain a copy of the License at
|
||
* https://opensource.org/licenses/CDDL-1.0
|
||
*/
|
||
|
||
// Файл: internal/api/webui_credentials.go
|
||
// Назначение: Управление учётными данными для веб-интерфейса
|
||
// Хранит логин/пароль в скрытом файле .credentials в директории futriis
|
||
// Поддерживает множественных пользователей-администраторов
|
||
|
||
package api
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// UserCredential представляет учётные данные одного пользователя
|
||
type UserCredential struct {
|
||
Password string `json:"password"` // хранится в виде хеша
|
||
Avatar string `json:"avatar,omitempty"` // base64 encoded avatar
|
||
IsAdmin bool `json:"is_admin"` // является ли администратором
|
||
CreatedAt int64 `json:"created_at"` // время создания
|
||
LastLogin int64 `json:"last_login,omitempty"` // время последнего входа
|
||
}
|
||
|
||
// Credentials структура для хранения всех учётных данных
|
||
type Credentials struct {
|
||
Users map[string]*UserCredential `json:"users"`
|
||
Settings map[string]interface{} `json:"settings,omitempty"`
|
||
}
|
||
|
||
// CredentialManager управляет учётными данными веб-интерфейса
|
||
type CredentialManager struct {
|
||
mu sync.RWMutex
|
||
credentials *Credentials
|
||
credFile string
|
||
currentUser string
|
||
}
|
||
|
||
// NewCredentialManager создаёт новый менеджер учётных данных
|
||
func NewCredentialManager() *CredentialManager {
|
||
// Определяем путь к директории futriis
|
||
execPath, err := os.Executable()
|
||
if err != nil {
|
||
execPath = "."
|
||
}
|
||
futriisDir := filepath.Dir(execPath)
|
||
|
||
// Ищем директорию futriis (поднимаемся вверх, если нужно)
|
||
for {
|
||
if _, err := os.Stat(filepath.Join(futriisDir, "futriis")); err == nil {
|
||
futriisDir = filepath.Join(futriisDir, "futriis")
|
||
break
|
||
}
|
||
parent := filepath.Dir(futriisDir)
|
||
if parent == futriisDir {
|
||
// Не нашли директорию futriis, создаём в текущей
|
||
futriisDir = filepath.Join(execPath, "futriis")
|
||
os.MkdirAll(futriisDir, 0700)
|
||
break
|
||
}
|
||
futriisDir = parent
|
||
}
|
||
|
||
credFile := filepath.Join(futriisDir, ".credentials")
|
||
|
||
return &CredentialManager{
|
||
credentials: &Credentials{
|
||
Users: make(map[string]*UserCredential),
|
||
Settings: make(map[string]interface{}),
|
||
},
|
||
credFile: credFile,
|
||
}
|
||
}
|
||
|
||
// hashPassword создаёт хеш пароля
|
||
func (cm *CredentialManager) hashPassword(password string) string {
|
||
hash := sha256.Sum256([]byte(password))
|
||
return base64.StdEncoding.EncodeToString(hash[:])
|
||
}
|
||
|
||
// Load загружает учётные данные из файла
|
||
func (cm *CredentialManager) Load() error {
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
data, err := os.ReadFile(cm.credFile)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return cm.createDefaultFile()
|
||
}
|
||
return err
|
||
}
|
||
|
||
var creds Credentials
|
||
if err := json.Unmarshal(data, &creds); err != nil {
|
||
return err
|
||
}
|
||
|
||
cm.credentials = &creds
|
||
if cm.credentials.Users == nil {
|
||
cm.credentials.Users = make(map[string]*UserCredential)
|
||
}
|
||
if cm.credentials.Settings == nil {
|
||
cm.credentials.Settings = make(map[string]interface{})
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// createDefaultFile создаёт файл с учётными данными по умолчанию
|
||
func (cm *CredentialManager) createDefaultFile() error {
|
||
// Создаём пользователя admin по умолчанию
|
||
cm.credentials.Users["admin"] = &UserCredential{
|
||
Password: cm.hashPassword("admin"),
|
||
IsAdmin: true,
|
||
CreatedAt: time.Now().Unix(),
|
||
}
|
||
cm.credentials.Settings["version"] = "1.0"
|
||
|
||
return cm.save()
|
||
}
|
||
|
||
// CreateDefault создаёт учётные данные по умолчанию (если файл не существует)
|
||
func (cm *CredentialManager) CreateDefault() error {
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
// Проверяем, существует ли файл
|
||
if _, err := os.Stat(cm.credFile); err == nil {
|
||
return nil // файл уже существует
|
||
}
|
||
|
||
cm.credentials.Users["admin"] = &UserCredential{
|
||
Password: cm.hashPassword("admin"),
|
||
IsAdmin: true,
|
||
CreatedAt: time.Now().Unix(),
|
||
}
|
||
cm.credentials.Settings["version"] = "1.0"
|
||
|
||
return cm.save()
|
||
}
|
||
|
||
// save сохраняет учётные данные в файл
|
||
func (cm *CredentialManager) save() error {
|
||
data, err := json.MarshalIndent(cm.credentials, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Устанавливаем права доступа 0600 (только владелец может читать/писать)
|
||
return os.WriteFile(cm.credFile, data, 0600)
|
||
}
|
||
|
||
// Validate проверяет учётные данные
|
||
func (cm *CredentialManager) Validate(username, password string) bool {
|
||
cm.mu.RLock()
|
||
defer cm.mu.RUnlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return false
|
||
}
|
||
|
||
user, exists := cm.credentials.Users[username]
|
||
if !exists {
|
||
return false
|
||
}
|
||
|
||
return user.Password == cm.hashPassword(password)
|
||
}
|
||
|
||
// IsAdmin проверяет, является ли пользователь администратором
|
||
func (cm *CredentialManager) IsAdmin(username string) bool {
|
||
cm.mu.RLock()
|
||
defer cm.mu.RUnlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return false
|
||
}
|
||
|
||
user, exists := cm.credentials.Users[username]
|
||
if !exists {
|
||
return false
|
||
}
|
||
|
||
return user.IsAdmin
|
||
}
|
||
|
||
// CreateUser создаёт нового пользователя (только для администраторов)
|
||
func (cm *CredentialManager) CreateUser(username, password string, isAdmin bool) error {
|
||
if username == "" {
|
||
return fmt.Errorf("username cannot be empty")
|
||
}
|
||
|
||
if len(password) < 4 {
|
||
return fmt.Errorf("password must be at least 4 characters")
|
||
}
|
||
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
if cm.credentials == nil {
|
||
cm.credentials = &Credentials{
|
||
Users: make(map[string]*UserCredential),
|
||
Settings: make(map[string]interface{}),
|
||
}
|
||
}
|
||
|
||
if _, exists := cm.credentials.Users[username]; exists {
|
||
return fmt.Errorf("user %s already exists", username)
|
||
}
|
||
|
||
cm.credentials.Users[username] = &UserCredential{
|
||
Password: cm.hashPassword(password),
|
||
IsAdmin: isAdmin,
|
||
CreatedAt: time.Now().Unix(),
|
||
}
|
||
|
||
return cm.save()
|
||
}
|
||
|
||
// DeleteUser удаляет пользователя (только для администраторов)
|
||
func (cm *CredentialManager) DeleteUser(username string) error {
|
||
if username == "admin" {
|
||
return fmt.Errorf("cannot delete default admin user")
|
||
}
|
||
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return fmt.Errorf("no users found")
|
||
}
|
||
|
||
if _, exists := cm.credentials.Users[username]; !exists {
|
||
return fmt.Errorf("user %s not found", username)
|
||
}
|
||
|
||
delete(cm.credentials.Users, username)
|
||
return cm.save()
|
||
}
|
||
|
||
// ListUsers возвращает список всех пользователей (только для администраторов)
|
||
func (cm *CredentialManager) ListUsers() []map[string]interface{} {
|
||
cm.mu.RLock()
|
||
defer cm.mu.RUnlock()
|
||
|
||
users := make([]map[string]interface{}, 0)
|
||
|
||
for username, user := range cm.credentials.Users {
|
||
users = append(users, map[string]interface{}{
|
||
"username": username,
|
||
"is_admin": user.IsAdmin,
|
||
"created_at": user.CreatedAt,
|
||
"last_login": user.LastLogin,
|
||
"has_avatar": user.Avatar != "",
|
||
})
|
||
}
|
||
|
||
return users
|
||
}
|
||
|
||
// ChangePassword изменяет пароль пользователя
|
||
func (cm *CredentialManager) ChangePassword(username, currentPassword, newPassword string) error {
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return fmt.Errorf("credentials not loaded")
|
||
}
|
||
|
||
user, exists := cm.credentials.Users[username]
|
||
if !exists {
|
||
return fmt.Errorf("user not found")
|
||
}
|
||
|
||
if user.Password != cm.hashPassword(currentPassword) {
|
||
return fmt.Errorf("current password is incorrect")
|
||
}
|
||
|
||
if len(newPassword) < 4 {
|
||
return fmt.Errorf("new password must be at least 4 characters")
|
||
}
|
||
|
||
user.Password = cm.hashPassword(newPassword)
|
||
|
||
return cm.save()
|
||
}
|
||
|
||
// AdminChangePassword изменяет пароль пользователя (без проверки старого, только для админов)
|
||
func (cm *CredentialManager) AdminChangePassword(username, newPassword string) error {
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return fmt.Errorf("credentials not loaded")
|
||
}
|
||
|
||
user, exists := cm.credentials.Users[username]
|
||
if !exists {
|
||
return fmt.Errorf("user not found")
|
||
}
|
||
|
||
if len(newPassword) < 4 {
|
||
return fmt.Errorf("password must be at least 4 characters")
|
||
}
|
||
|
||
user.Password = cm.hashPassword(newPassword)
|
||
|
||
return cm.save()
|
||
}
|
||
|
||
// SetAvatar устанавливает аватар для пользователя
|
||
func (cm *CredentialManager) SetAvatar(username, avatarBase64 string) error {
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return fmt.Errorf("credentials not loaded")
|
||
}
|
||
|
||
user, exists := cm.credentials.Users[username]
|
||
if !exists {
|
||
return fmt.Errorf("user not found")
|
||
}
|
||
|
||
user.Avatar = avatarBase64
|
||
|
||
return cm.save()
|
||
}
|
||
|
||
// GetAvatar возвращает аватар пользователя
|
||
func (cm *CredentialManager) GetAvatar(username string) (string, error) {
|
||
cm.mu.RLock()
|
||
defer cm.mu.RUnlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return "", fmt.Errorf("credentials not loaded")
|
||
}
|
||
|
||
user, exists := cm.credentials.Users[username]
|
||
if !exists {
|
||
return "", fmt.Errorf("user not found")
|
||
}
|
||
|
||
return user.Avatar, nil
|
||
}
|
||
|
||
// DeleteAvatar удаляет аватар пользователя
|
||
func (cm *CredentialManager) DeleteAvatar(username string) error {
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return fmt.Errorf("credentials not loaded")
|
||
}
|
||
|
||
user, exists := cm.credentials.Users[username]
|
||
if !exists {
|
||
return fmt.Errorf("user not found")
|
||
}
|
||
|
||
user.Avatar = ""
|
||
|
||
return cm.save()
|
||
}
|
||
|
||
// UpdateLastLogin обновляет время последнего входа пользователя
|
||
func (cm *CredentialManager) UpdateLastLogin(username string) {
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
|
||
if cm.credentials == nil || cm.credentials.Users == nil {
|
||
return
|
||
}
|
||
|
||
user, exists := cm.credentials.Users[username]
|
||
if !exists {
|
||
return
|
||
}
|
||
|
||
user.LastLogin = time.Now().Unix()
|
||
cm.save() // игнорируем ошибку, т.к. это не критично
|
||
}
|
||
|
||
// GetCurrentUsername возвращает имя текущего пользователя
|
||
func (cm *CredentialManager) GetCurrentUsername() string {
|
||
cm.mu.RLock()
|
||
defer cm.mu.RUnlock()
|
||
|
||
if cm.credentials == nil {
|
||
return ""
|
||
}
|
||
|
||
// Возвращаем последнего вошедшего пользователя или admin по умолчанию
|
||
if cm.currentUser != "" {
|
||
return cm.currentUser
|
||
}
|
||
|
||
return "admin"
|
||
}
|
||
|
||
// SetCurrentUsername устанавливает имя текущего пользователя
|
||
func (cm *CredentialManager) SetCurrentUsername(username string) {
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
cm.currentUser = username
|
||
}
|
||
|
||
// GetCredentialsFile возвращает путь к файлу с учётными данными
|
||
func (cm *CredentialManager) GetCredentialsFile() string {
|
||
return cm.credFile
|
||
}
|