Initial implementation of inventree

A small IT asset inventory system built around a nested folder tree,
matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS,
SQLite storage (WAL, FKs, migrations), embedded templates/static assets,
single compiled binary.

- AJAX-driven inventory tree: lazy-loaded folder expansion with per-user
  persisted expand state, drag-and-drop move for folders/assets, safe
  delete-with-relocate-or-cascade confirmation, search with breadcrumbs.
- Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs,
  CSV import (with optional folder auto-create) and export.
- Admin-defined item types with custom fields (text/long_text/number/date/
  boolean/dropdown/url/ip_address).
- Configurable statuses, session-based auth (bcrypt, secure cookies),
  admin/user roles, user management.
- CLI: serve, version, database migrate, database backup; /healthz;
  graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
Owen
2026-08-17 13:34:40 -05:00
commit 218687a1ee
71 changed files with 8102 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
// Package auth implements session-based authentication with secure cookies.
package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"time"
"inventree/internal/models"
"inventree/internal/users"
)
const (
CookieName = "inventree_session"
SessionTTL = 7 * 24 * time.Hour
contextUserK = ctxKey("current_user")
)
type ctxKey string
var ErrNoSession = errors.New("no valid session")
type Store struct {
db *sql.DB
users *users.Store
}
func NewStore(db *sql.DB, u *users.Store) *Store { return &Store{db: db, users: u} }
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// CreateSession issues a new session for the given user and returns the
// opaque session token to be stored in a cookie.
func (s *Store) CreateSession(ctx context.Context, userID int64) (string, time.Time, error) {
token, err := newToken()
if err != nil {
return "", time.Time{}, err
}
expires := time.Now().Add(SessionTTL)
_, err = s.db.ExecContext(ctx, `INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)`,
token, userID, expires.UTC().Format("2006-01-02T15:04:05.000Z"))
if err != nil {
return "", time.Time{}, err
}
return token, expires, nil
}
func (s *Store) DestroySession(ctx context.Context, token string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, token)
return err
}
// UserForToken resolves a session token to the current, enabled user. Expired
// sessions are treated as invalid (and lazily cleaned up).
func (s *Store) UserForToken(ctx context.Context, token string) (*models.User, error) {
if token == "" {
return nil, ErrNoSession
}
var userID int64
var expiresAt string
err := s.db.QueryRowContext(ctx, `SELECT user_id, expires_at FROM sessions WHERE id = ?`, token).Scan(&userID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoSession
}
if err != nil {
return nil, err
}
exp, _ := time.Parse("2006-01-02T15:04:05.000Z", expiresAt)
if time.Now().After(exp) {
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, token)
return nil, ErrNoSession
}
u, err := s.users.Get(ctx, userID)
if err != nil {
return nil, ErrNoSession
}
if !u.Enabled {
return nil, ErrNoSession
}
return u, nil
}
// CleanExpired removes expired sessions; safe to call periodically.
func (s *Store) CleanExpired(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC().Format("2006-01-02T15:04:05.000Z"))
return err
}
func SetCookie(w http.ResponseWriter, token string, expires time.Time, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func ClearCookie(w http.ResponseWriter, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// WithUser stores the current user in the request context.
func WithUser(ctx context.Context, u *models.User) context.Context {
return context.WithValue(ctx, contextUserK, u)
}
// UserFromContext returns the current authenticated user, if any.
func UserFromContext(ctx context.Context) (*models.User, bool) {
u, ok := ctx.Value(contextUserK).(*models.User)
return u, ok && u != nil
}
+63
View File
@@ -0,0 +1,63 @@
package auth_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"inventree/internal/auth"
"inventree/internal/database"
"inventree/internal/users"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
dir := t.TempDir()
db, err := database.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestSessionLifecycle(t *testing.T) {
ctx := context.Background()
db := testDB(t)
userStore := users.NewStore(db)
authStore := auth.NewStore(db, userStore)
admin, err := userStore.GetByUsername(ctx, "admin")
if err != nil {
t.Fatalf("get admin: %v", err)
}
token, _, err := authStore.CreateSession(ctx, admin.ID)
if err != nil {
t.Fatalf("create session: %v", err)
}
u, err := authStore.UserForToken(ctx, token)
if err != nil {
t.Fatalf("resolve session: %v", err)
}
if u.Username != "admin" {
t.Fatalf("expected admin, got %s", u.Username)
}
if err := authStore.DestroySession(ctx, token); err != nil {
t.Fatalf("destroy session: %v", err)
}
if _, err := authStore.UserForToken(ctx, token); err != auth.ErrNoSession {
t.Fatalf("expected ErrNoSession after destroy, got %v", err)
}
}
func TestUserForTokenRejectsUnknownToken(t *testing.T) {
db := testDB(t)
authStore := auth.NewStore(db, users.NewStore(db))
if _, err := authStore.UserForToken(context.Background(), "does-not-exist"); err != auth.ErrNoSession {
t.Fatalf("expected ErrNoSession, got %v", err)
}
}