backup: pre-hardening baseline
This commit is contained in:
commit
9e4c612dcb
57 changed files with 10393 additions and 0 deletions
55
backend/internal/database/repositories/session.go
Normal file
55
backend/internal/database/repositories/session.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package repositories
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
)
|
||||
|
||||
type SessionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSessionRepository(db *sql.DB) *SessionRepository {
|
||||
return &SessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Create(session *models.Session) error {
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO sessions (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
session.ID, session.UserID, session.CSRFToken, session.CreatedAt, session.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) GetByID(id string) (*models.Session, error) {
|
||||
session := &models.Session{}
|
||||
err := r.db.QueryRow(
|
||||
`SELECT id, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id = ?`, id,
|
||||
).Scan(&session.ID, &session.UserID, &session.CSRFToken, &session.CreatedAt, &session.ExpiresAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return session, err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Delete(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) DeleteExpired() error {
|
||||
_, err := r.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) RotateCSRFToken(id, newToken string) error {
|
||||
_, err := r.db.Exec(`UPDATE sessions SET csrf_token = ? WHERE id = ?`, newToken, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) ExtendExpiry(id string, newExpiry time.Time) error {
|
||||
_, err := r.db.Exec(`UPDATE sessions SET expires_at = ? WHERE id = ?`, newExpiry, id)
|
||||
return err
|
||||
}
|
||||
56
backend/internal/database/repositories/user.go
Normal file
56
backend/internal/database/repositories/user.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package repositories
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *sql.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(id string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := r.db.QueryRow(
|
||||
`SELECT id, username, email, password_hash, role, created_at, updated_at, last_login
|
||||
FROM users WHERE id = ?`, id,
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.LastLogin)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := r.db.QueryRow(
|
||||
`SELECT id, username, email, password_hash, role, created_at, updated_at, last_login
|
||||
FROM users WHERE username = ?`, username,
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.LastLogin)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateLastLogin(id string) error {
|
||||
_, err := r.db.Exec(
|
||||
`UPDATE users SET last_login = ?, updated_at = ? WHERE id = ?`,
|
||||
time.Now(), time.Now(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(user *models.User) error {
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`,
|
||||
user.ID, user.Username, user.Email, user.PasswordHash, user.Role,
|
||||
)
|
||||
return err
|
||||
}
|
||||
144
backend/internal/database/sqlite.go
Normal file
144
backend/internal/database/sqlite.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func New(path string) (*DB, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
func (d *DB) Migrate() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
CHECK (expires_at > created_at)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS backups (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backups_filename ON backups(filename)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := d.Exec(q); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return d.ensureAdminUser()
|
||||
}
|
||||
|
||||
func (d *DB) ensureAdminUser() error {
|
||||
var count int
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
// Default admin: admin / changeme (bcrypt hash) — development-only
|
||||
hash := "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju"
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`,
|
||||
"admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", hash, "admin",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create admin user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureAdminPasswordViaEnv enforces production password policy.
|
||||
// If envPassword is set, it must be >=12 chars; it will create or update the admin user.
|
||||
// If devMode is false and the default admin/changeme is still in use, it logs a warning.
|
||||
func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error {
|
||||
if envPassword != "" {
|
||||
if len(envPassword) < 12 {
|
||||
return fmt.Errorf("GUI_ADMIN_PASSWORD must be at least 12 characters")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(envPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
// Upsert admin user
|
||||
_, err = d.Exec(`
|
||||
INSERT INTO users (id, username, email, password_hash, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash=excluded.password_hash, updated_at=CURRENT_TIMESTAMP
|
||||
`, "admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", string(hash), "admin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert admin via env: %w", err)
|
||||
}
|
||||
log.Println("Admin password set via GUI_ADMIN_PASSWORD")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !devMode {
|
||||
// Check if default password still in use
|
||||
var hash string
|
||||
err := d.QueryRow(`SELECT password_hash FROM users WHERE username='admin'`).Scan(&hash)
|
||||
if err == nil {
|
||||
// Compare against known dev hash
|
||||
if hash == "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju" {
|
||||
log.Println("WARNING: default admin password 'changeme' is in use — set GUI_ADMIN_PASSWORD (min 12 chars) for production")
|
||||
}
|
||||
// Also try bcrypt check in case hash was regenerated for same password
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("changeme")) == nil {
|
||||
log.Println("WARNING: admin password is still 'changeme' — change it or set GUI_ADMIN_PASSWORD for production")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue