13 Commits

Author SHA1 Message Date
H1K0 12d4dbcbb2 test(backend): regression tests for the security fixes
Cover the refresh-token flow (works, not usable as an access token, and
revokes the rotated-away access token), non-owner denial on object ACLs /
file tags / import, and immediate session revocation on user block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:19:41 +03:00
H1K0 aff270fa44 fix(backend): rate-limit login and refresh endpoints
/auth/login and /auth/refresh had no throttling, allowing unbounded
password brute-force attempts. Add a process-local fixed-window limiter
(10 requests/minute per client IP) in front of both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:14:51 +03:00
H1K0 40c91cec55 fix(backend): add baseline security response headers
Set X-Content-Type-Options: nosniff (so served file bytes are not MIME
sniffed), X-Frame-Options: DENY, and Referrer-Policy: no-referrer on all
responses via middleware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:13:56 +03:00
H1K0 591b3d2fe3 fix(backend): set HTTP server timeouts to mitigate Slowloris
gin's Run uses a default http.Server with no timeouts, so a client could
hold connections open by trickling request headers. Serve via an explicit
http.Server with a 10s ReadHeaderTimeout and 120s IdleTimeout. Body
read/write remain unbounded so large uploads and downloads still stream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:13:22 +03:00
H1K0 f4545ff107 fix(backend): invalidate thumbnail cache on replace and permanent delete
Replacing a file's content left the old {id}_thumb.jpg / {id}_preview.jpg
in the cache, and the cache-hit fast path kept serving the stale image
forever; permanent deletion left those files orphaned. FileStorage gains
InvalidateCache, which Replace and PermanentDelete now call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:12:38 +03:00
H1K0 3b79f12ec0 fix(backend): bound image decode and ffmpeg during thumbnailing
Thumbnail/preview generation decoded untrusted images with no size limit
(a decompression bomb could exhaust memory) and ran ffmpeg with no
timeout (a malformed video could hang the request). Image dimensions are
now checked via image.DecodeConfig before the raster is allocated and
rejected above 64 Mpx, and ffmpeg runs under a 30s timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:11:31 +03:00
H1K0 4645107ea1 fix(backend): make access tokens revocable via session validation
The auth middleware trusted any unexpired, well-signed access token, so
logout, session termination and admin blocks had no effect until the
15-minute token expired. The middleware now validates that the token's
session is still active on every request (SessionRepo.GetByID), and
blocking a user deactivates all of their sessions, immediately revoking
their outstanding access tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:09:25 +03:00
H1K0 fa2acca858 fix(backend): cap upload size to prevent memory exhaustion
Upload and Replace buffered the entire request body into memory with no
size limit, so a few large uploads could OOM the server. The file
handler now wraps the request body in http.MaxBytesReader and rejects any
file larger than MAX_UPLOAD_BYTES (default 500 MiB) before it is buffered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:07:34 +03:00
H1K0 f069fccd96 fix(backend): harden JWT handling and login
Three related auth weaknesses:

- Access and refresh tokens were structurally identical, so a 30-day
  refresh token was accepted as a bearer access token. Tokens now carry a
  "typ" claim; the access path rejects refresh tokens and /refresh rejects
  access tokens.

- Login stored the hash of a throwaway refresh token (sid=0) but returned
  a re-issued one, so the stored hash never matched and /refresh always
  401'd. Tokens are no longer re-issued: the refresh token is located by
  hash and carries no session id, while the access token embeds the real
  session id. A random jti keeps tokens unique within the same second.

- Login skipped bcrypt for unknown users (a timing oracle) and returned
  403 for blocked accounts before checking the password (leaking account
  existence). It now always runs a bcrypt comparison and verifies the
  password before disclosing blocked state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:04:33 +03:00
H1K0 9ea939ccf6 fix(backend): bootstrap admin from env instead of seeding admin/admin
007_seed_data.sql shipped a fixed admin account whose bcrypt hash decodes
to the password "admin", giving every deployment the same known
credentials. The seed row is removed; UserService.EnsureAdmin now creates
the administrator on startup from ADMIN_USERNAME / ADMIN_PASSWORD. It is
idempotent and never overwrites an existing password, so an operator who
rotates the admin password keeps it across restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:01:48 +03:00
H1K0 945df7ef8a fix(backend): enforce file ACL on file-tag and import endpoints
Two broken-access-control holes:

- PUT/DELETE /files/:id/tags(/:tag_id) and GET /files/:id/tags went
  straight to TagService with no ACL check, letting any authenticated
  user read or rewrite tags on anyone's private files. The handlers now
  require view (list) or edit (mutate) on the target file via new
  FileService.AuthorizeView/AuthorizeEdit helpers.

- POST /files/import accepted an arbitrary host path from any user,
  turning it into an arbitrary server-side file read. It is now
  admin-only and the supplied path is confined to IMPORT_PATH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:59:33 +03:00
H1K0 a6680b1c05 fix(backend): require owner/admin to read or modify object ACLs
GET/PUT /acl/:object_type/:object_id performed no authorization check, so
any authenticated user could read the permission list of, or grant
themselves view/edit on, any file/tag/category/pool. ACLService now
resolves the object's owner and rejects callers who are neither the owner
nor an admin. SetPermissions also wraps its delete+insert replace in a
single transaction so a partial failure can no longer wipe permissions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:59:10 +03:00
H1K0 eb2eb00d96 fix(frontend): use $appSettings.tagRuleApplyToExisting on creating a new tag rule also 2026-04-07 11:59:09 +03:00
21 changed files with 724 additions and 116 deletions
+8
View File
@@ -11,6 +11,11 @@ JWT_SECRET=change-me-to-a-random-32-byte-secret
JWT_ACCESS_TTL=15m JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h JWT_REFRESH_TTL=720h
# Initial administrator, created on first startup if it does not yet exist.
# Changing the password later (via the API) is preserved across restarts.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-before-first-run
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Database # Database
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -22,6 +27,9 @@ DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disabl
FILES_PATH=/data/files FILES_PATH=/data/files
THUMBS_CACHE_PATH=/data/thumbs THUMBS_CACHE_PATH=/data/thumbs
# Maximum accepted upload size in bytes (default 500 MiB).
MAX_UPLOAD_BYTES=524288000
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Thumbnails # Thumbnails
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+21 -4
View File
@@ -3,7 +3,9 @@ package main
import ( import (
"context" "context"
"log/slog" "log/slog"
"net/http"
"os" "os"
"time"
"github.com/jackc/pgx/v5/stdlib" "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
@@ -77,7 +79,7 @@ func main() {
cfg.JWTAccessTTL, cfg.JWTAccessTTL,
cfg.JWTRefreshTTL, cfg.JWTRefreshTTL,
) )
aclSvc := service.NewACLService(aclRepo) aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo) auditSvc := service.NewAuditService(auditRepo)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc) categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
@@ -92,12 +94,18 @@ func main() {
transactor, transactor,
cfg.ImportPath, cfg.ImportPath,
) )
userSvc := service.NewUserService(userRepo, auditSvc) userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
// Bootstrap the initial administrator (idempotent).
if err := userSvc.EnsureAdmin(context.Background(), cfg.AdminUsername, cfg.AdminPassword); err != nil {
slog.Error("failed to bootstrap admin user", "err", err)
os.Exit(1)
}
// Handlers // Handlers
authMiddleware := handler.NewAuthMiddleware(authSvc) authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc) authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc) fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc) tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc) categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc) poolHandler := handler.NewPoolHandler(poolSvc)
@@ -111,8 +119,17 @@ func main() {
userHandler, aclHandler, auditHandler, userHandler, aclHandler, auditHandler,
) )
// ReadHeaderTimeout bounds slow-header (Slowloris) attacks; body read/write
// are left unbounded so large file uploads and downloads can stream.
srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
slog.Info("starting server", "addr", cfg.ListenAddr) slog.Info("starting server", "addr", cfg.ListenAddr)
if err := r.Run(cfg.ListenAddr); err != nil { if err := srv.ListenAndServe(); err != nil {
slog.Error("server error", "err", err) slog.Error("server error", "err", err)
os.Exit(1) os.Exit(1)
} }
+22
View File
@@ -18,12 +18,17 @@ type Config struct {
JWTAccessTTL time.Duration JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration JWTRefreshTTL time.Duration
// Initial admin bootstrap (applied on startup if the user does not exist)
AdminUsername string
AdminPassword string
// Database // Database
DatabaseURL string DatabaseURL string
// Storage // Storage
FilesPath string FilesPath string
ThumbsCachePath string ThumbsCachePath string
MaxUploadBytes int64 // reject uploads larger than this (bytes)
// Thumbnails // Thumbnails
ThumbWidth int ThumbWidth int
@@ -81,16 +86,33 @@ func Load() (*Config, error) {
return n return n
} }
parseInt64 := func(key string, def int64) int64 {
raw := os.Getenv(key)
if raw == "" {
return def
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
return def
}
return n
}
cfg := &Config{ cfg := &Config{
ListenAddr: defaultStr("LISTEN_ADDR", ":8080"), ListenAddr: defaultStr("LISTEN_ADDR", ":8080"),
JWTSecret: requireStr("JWT_SECRET"), JWTSecret: requireStr("JWT_SECRET"),
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"), JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"), JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
AdminPassword: requireStr("ADMIN_PASSWORD"),
DatabaseURL: requireStr("DATABASE_URL"), DatabaseURL: requireStr("DATABASE_URL"),
FilesPath: requireStr("FILES_PATH"), FilesPath: requireStr("FILES_PATH"),
ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"), ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"),
MaxUploadBytes: parseInt64("MAX_UPLOAD_BYTES", 500<<20), // 500 MiB
ThumbWidth: parseInt("THUMB_WIDTH", 160), ThumbWidth: parseInt("THUMB_WIDTH", 160),
ThumbHeight: parseInt("THUMB_HEIGHT", 160), ThumbHeight: parseInt("THUMB_HEIGHT", 160),
@@ -74,6 +74,28 @@ func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) (*domain.Se
return &created, nil return &created, nil
} }
func (r *SessionRepo) GetByID(ctx context.Context, id int) (*domain.Session, error) {
const sql = `
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
FROM activity.sessions
WHERE id = $1 AND is_active = true`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, id)
if err != nil {
return nil, fmt.Errorf("SessionRepo.GetByID: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("SessionRepo.GetByID scan: %w", err)
}
s := toSession(row)
return &s, nil
}
func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) { func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) {
const sql = ` const sql = `
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
+5 -3
View File
@@ -80,7 +80,8 @@ func (h *ACLHandler) GetPermissions(c *gin.Context) {
return return
} }
perms, err := h.aclSvc.GetPermissions(c.Request.Context(), objectTypeID, objectID) userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
perms, err := h.aclSvc.GetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -124,13 +125,14 @@ func (h *ACLHandler) SetPermissions(c *gin.Context) {
} }
} }
if err := h.aclSvc.SetPermissions(c.Request.Context(), objectTypeID, objectID, perms); err != nil { userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
if err := h.aclSvc.SetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID, perms); err != nil {
respondError(c, err) respondError(c, err)
return return
} }
// Re-read to return the stored permissions (with UserName denormalized). // Re-read to return the stored permissions (with UserName denormalized).
stored, err := h.aclSvc.GetPermissions(c.Request.Context(), objectTypeID, objectID) stored, err := h.aclSvc.GetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
+36 -9
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -21,11 +22,33 @@ import (
type FileHandler struct { type FileHandler struct {
fileSvc *service.FileService fileSvc *service.FileService
tagSvc *service.TagService tagSvc *service.TagService
maxUploadBytes int64
} }
// NewFileHandler creates a FileHandler. // NewFileHandler creates a FileHandler. maxUploadBytes caps the size of an
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService) *FileHandler { // uploaded or replacement file.
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc} func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, maxUploadBytes int64) *FileHandler {
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, maxUploadBytes: maxUploadBytes}
}
// formFileLimited reads the "file" multipart field while bounding how many bytes
// are read from the request body, then rejects files larger than the configured
// cap. The body limit guards against a dishonest Content-Length; the Size check
// gives a clear rejection for an honestly-declared oversized file.
func (h *FileHandler) formFileLimited(c *gin.Context) (*multipart.FileHeader, bool) {
// Allow a little slack above the file cap for multipart framing overhead.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes+(1<<20))
fh, err := c.FormFile("file")
if err != nil {
respondError(c, domain.ErrValidation)
return nil, false
}
if fh.Size > h.maxUploadBytes {
respondError(c, domain.ErrValidation)
return nil, false
}
return fh, true
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -186,9 +209,8 @@ func (h *FileHandler) List(c *gin.Context) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func (h *FileHandler) Upload(c *gin.Context) { func (h *FileHandler) Upload(c *gin.Context) {
fh, err := c.FormFile("file") fh, ok := h.formFileLimited(c)
if err != nil { if !ok {
respondError(c, domain.ErrValidation)
return return
} }
@@ -378,9 +400,8 @@ func (h *FileHandler) ReplaceContent(c *gin.Context) {
return return
} }
fh, err := c.FormFile("file") fh, ok := h.formFileLimited(c)
if err != nil { if !ok {
respondError(c, domain.ErrValidation)
return return
} }
@@ -613,6 +634,12 @@ func (h *FileHandler) CommonTags(c *gin.Context) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func (h *FileHandler) Import(c *gin.Context) { func (h *FileHandler) Import(c *gin.Context) {
// Server-side directory import reads arbitrary paths on the host; restrict
// it to administrators.
if !requireAdmin(c) {
return
}
var body struct { var body struct {
Path string `json:"path"` Path string `json:"path"`
} }
+1 -1
View File
@@ -35,7 +35,7 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
} }
token := strings.TrimPrefix(raw, "Bearer ") token := strings.TrimPrefix(raw, "Bearer ")
claims, err := m.authSvc.ParseAccessToken(token) claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, errorBody{ c.JSON(http.StatusUnauthorized, errorBody{
Code: domain.ErrUnauthorized.Code(), Code: domain.ErrUnauthorized.Code(),
+77
View File
@@ -0,0 +1,77 @@
package handler
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// rateLimiter is a process-local, fixed-window per-key request limiter used to
// throttle unauthenticated endpoints (login, refresh) against brute force. It
// is best-effort: counts live in memory and reset on restart.
type rateLimiter struct {
mu sync.Mutex
counts map[string]*rateWindow
limit int
window time.Duration
}
type rateWindow struct {
count int
reset time.Time
}
// newRateLimiter allows up to limit requests per key within each window.
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{
counts: make(map[string]*rateWindow),
limit: limit,
window: window,
}
}
// allow records a request for key and reports whether it is within the limit.
func (rl *rateLimiter) allow(key string) bool {
now := time.Now()
rl.mu.Lock()
defer rl.mu.Unlock()
// Opportunistically prune expired entries so the map cannot grow without
// bound under a flood of distinct client IPs.
if len(rl.counts) > 10000 {
for k, w := range rl.counts {
if now.After(w.reset) {
delete(rl.counts, k)
}
}
}
w, ok := rl.counts[key]
if !ok || now.After(w.reset) {
rl.counts[key] = &rateWindow{count: 1, reset: now.Add(rl.window)}
return true
}
if w.count >= rl.limit {
return false
}
w.count++
return true
}
// Middleware throttles requests by client IP, returning 429 when over the limit.
func (rl *rateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !rl.allow(c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, errorBody{
Code: "rate_limited",
Message: "too many requests, please try again later",
})
c.Abort()
return
}
c.Next()
}
}
+19 -3
View File
@@ -2,10 +2,24 @@ package handler
import ( import (
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// securityHeaders sets conservative response headers on every response: prevent
// MIME sniffing of served file content, forbid framing, and suppress the
// Referer header on outbound navigations.
func securityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.Writer.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
c.Next()
}
}
// NewRouter builds and returns a configured Gin engine. // NewRouter builds and returns a configured Gin engine.
func NewRouter( func NewRouter(
auth *AuthMiddleware, auth *AuthMiddleware,
@@ -19,7 +33,7 @@ func NewRouter(
auditHandler *AuditHandler, auditHandler *AuditHandler,
) *gin.Engine { ) *gin.Engine {
r := gin.New() r := gin.New()
r.Use(gin.Logger(), gin.Recovery()) r.Use(gin.Logger(), gin.Recovery(), securityHeaders())
// Health check — no auth required. // Health check — no auth required.
r.GET("/health", func(c *gin.Context) { r.GET("/health", func(c *gin.Context) {
@@ -33,8 +47,10 @@ func NewRouter(
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
authGroup := v1.Group("/auth") authGroup := v1.Group("/auth")
{ {
authGroup.POST("/login", authHandler.Login) // Throttle credential endpoints per client IP to slow brute force.
authGroup.POST("/refresh", authHandler.Refresh) authLimiter := newRateLimiter(10, time.Minute).Middleware()
authGroup.POST("/login", authLimiter, authHandler.Login)
authGroup.POST("/refresh", authLimiter, authHandler.Refresh)
protected := authGroup.Group("", auth.Handle()) protected := authGroup.Group("", auth.Handle())
{ {
+20
View File
@@ -430,6 +430,11 @@ func (h *TagHandler) FileListTags(c *gin.Context) {
return return
} }
if err := h.fileSvc.AuthorizeView(c.Request.Context(), fileID); err != nil {
respondError(c, err)
return
}
tags, err := h.tagSvc.ListFileTags(c.Request.Context(), fileID) tags, err := h.tagSvc.ListFileTags(c.Request.Context(), fileID)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
@@ -465,6 +470,11 @@ func (h *TagHandler) FileSetTags(c *gin.Context) {
return return
} }
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
respondError(c, err)
return
}
tags, err := h.tagSvc.SetFileTags(c.Request.Context(), fileID, tagIDs) tags, err := h.tagSvc.SetFileTags(c.Request.Context(), fileID, tagIDs)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
@@ -491,6 +501,11 @@ func (h *TagHandler) FileAddTag(c *gin.Context) {
return return
} }
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
respondError(c, err)
return
}
tags, err := h.tagSvc.AddFileTag(c.Request.Context(), fileID, tagID) tags, err := h.tagSvc.AddFileTag(c.Request.Context(), fileID, tagID)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
@@ -517,6 +532,11 @@ func (h *TagHandler) FileRemoveTag(c *gin.Context) {
return return
} }
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
respondError(c, err)
return
}
if err := h.tagSvc.RemoveFileTag(c.Request.Context(), fileID, tagID); err != nil { if err := h.tagSvc.RemoveFileTag(c.Request.Context(), fileID, tagID); err != nil {
respondError(c, err) respondError(c, err)
return return
+146 -3
View File
@@ -125,18 +125,22 @@ func setupSuite(t *testing.T) *harness {
// --- Services ------------------------------------------------------------ // --- Services ------------------------------------------------------------
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour) authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour)
aclSvc := service.NewACLService(aclRepo) aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo) auditSvc := service.NewAuditService(auditRepo)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc) categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc) poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir) fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir)
userSvc := service.NewUserService(userRepo, auditSvc) userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
// Bootstrap the admin account the suite logs in with (replaces the old
// hardcoded seed credentials).
require.NoError(t, userSvc.EnsureAdmin(ctx, "admin", "admin"))
// --- Handlers ------------------------------------------------------------ // --- Handlers ------------------------------------------------------------
authMiddleware := handler.NewAuthMiddleware(authSvc) authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc) authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc) fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc) tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc) categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc) poolHandler := handler.NewPoolHandler(poolSvc)
@@ -685,6 +689,145 @@ func TestTagAutoRule(t *testing.T) {
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names) assert.ElementsMatch(t, []string{"outdoor", "nature"}, names)
} }
// ---------------------------------------------------------------------------
// Security regression tests
// ---------------------------------------------------------------------------
// loginPair logs in and returns the full access/refresh token pair.
func (h *harness) loginPair(name, password string) (access, refresh string) {
h.t.Helper()
resp := h.doJSON("POST", "/auth/login", map[string]string{"name": name, "password": password}, "")
require.Equal(h.t, http.StatusOK, resp.StatusCode, "login failed: %s", resp)
var out struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
resp.decode(h.t, &out)
require.NotEmpty(h.t, out.AccessToken)
require.NotEmpty(h.t, out.RefreshToken)
return out.AccessToken, out.RefreshToken
}
// TestRefreshTokenFlow verifies that refresh tokens work (regression for the
// stored-hash mismatch that made /refresh always 401), that a refresh token is
// rejected as a bearer access token, and that rotating a session revokes the
// pre-rotation access token.
func TestRefreshTokenFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
access, refresh := h.loginPair("admin", "admin")
// A refresh token must not be accepted as a bearer access token.
resp := h.doJSON("GET", "/users/me", nil, refresh)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
// Refreshing yields a working new pair.
resp = h.doJSON("POST", "/auth/refresh", map[string]string{"refresh_token": refresh}, "")
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var pair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
resp.decode(t, &pair)
require.NotEmpty(t, pair.AccessToken)
resp = h.doJSON("GET", "/users/me", nil, pair.AccessToken)
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// The pre-rotation access token is now revoked (its session was rotated away).
resp = h.doJSON("GET", "/users/me", nil, access)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
}
// TestNonOwnerAccessControl verifies that a non-owner, non-admin user cannot
// read or change another user's object ACL, cannot view or tag another user's
// private file, and cannot trigger a server-side import.
func TestNonOwnerAccessControl(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
mkUser := func(name, pass string) {
resp := h.doJSON("POST", "/users", map[string]any{
"name": name, "password": pass, "can_create": true,
}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
}
mkUser("alice", "alicepass")
mkUser("bob", "bobpass")
aliceToken := h.login("alice", "alicepass")
bobToken := h.login("bob", "bobpass")
// Alice uploads a private file.
file := h.uploadJPEG(aliceToken, "secret.jpg")
fileID := file["id"].(string)
// Bob cannot read the file's ACL...
resp := h.doJSON("GET", "/acl/file/"+fileID, nil, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// ...nor grant himself access.
resp = h.doJSON("PUT", "/acl/file/"+fileID, map[string]any{
"permissions": []map[string]any{{"user_id": 2, "can_view": true, "can_edit": true}},
}, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// ...and still cannot view it.
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// Bob cannot list or modify tags on Alice's private file.
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
"tag_ids": []string{"11111111-1111-1111-1111-111111111111"},
}, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// A non-admin cannot trigger a server-side import.
resp = h.doJSON("POST", "/files/import", map[string]any{}, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// The owner can still manage her own file's ACL.
resp = h.doJSON("GET", "/acl/file/"+fileID, nil, aliceToken)
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// TestBlockRevokesActiveSessions verifies that blocking a user immediately
// invalidates their outstanding access tokens.
func TestBlockRevokesActiveSessions(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/users", map[string]any{"name": "dave", "password": "davepass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var dave map[string]any
resp.decode(t, &dave)
daveID := dave["id"].(float64)
daveToken := h.login("dave", "davepass")
resp = h.doJSON("GET", "/users/me", nil, daveToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// Block dave.
resp = h.doJSON("PATCH", fmt.Sprintf("/users/%.0f", daveID), map[string]any{"is_blocked": true}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// Dave's previously-issued access token is now rejected.
resp = h.doJSON("GET", "/users/me", nil, daveToken)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test helpers // Test helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+3
View File
@@ -132,6 +132,9 @@ type UserRepo interface {
type SessionRepo interface { type SessionRepo interface {
// ListByUser returns all active sessions for a user. // ListByUser returns all active sessions for a user.
ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error) ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error)
// GetByID returns an active session by its ID, or ErrNotFound if it does not
// exist or has been deactivated.
GetByID(ctx context.Context, id int) (*domain.Session, error)
// GetByTokenHash looks up a session by the hashed refresh token. // GetByTokenHash looks up a session by the hashed refresh token.
GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error)
Create(ctx context.Context, s *domain.Session) (*domain.Session, error) Create(ctx context.Context, s *domain.Session) (*domain.Session, error)
+4
View File
@@ -21,6 +21,10 @@ type FileStorage interface {
// Delete removes the file content from storage. // Delete removes the file content from storage.
Delete(ctx context.Context, id uuid.UUID) error Delete(ctx context.Context, id uuid.UUID) error
// InvalidateCache removes any cached thumbnail/preview for the file so they
// are regenerated from the current content on next request.
InvalidateCache(ctx context.Context, id uuid.UUID) error
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound // Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
// if the thumbnail has not been generated yet. // if the thumbnail has not been generated yet.
Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
+100 -5
View File
@@ -13,10 +13,31 @@ import (
// ACLService handles access control checks and permission management. // ACLService handles access control checks and permission management.
type ACLService struct { type ACLService struct {
aclRepo port.ACLRepo aclRepo port.ACLRepo
files port.FileRepo
tags port.TagRepo
categories port.CategoryRepo
pools port.PoolRepo
tx port.Transactor
} }
func NewACLService(aclRepo port.ACLRepo) *ACLService { // NewACLService creates an ACLService. The object repositories are used to
return &ACLService{aclRepo: aclRepo} // resolve an object's owner when authorizing permission management.
func NewACLService(
aclRepo port.ACLRepo,
files port.FileRepo,
tags port.TagRepo,
categories port.CategoryRepo,
pools port.PoolRepo,
tx port.Transactor,
) *ACLService {
return &ACLService{
aclRepo: aclRepo,
files: files,
tags: tags,
categories: categories,
pools: pools,
tx: tx,
}
} }
// CanView returns true if the user may view the object. // CanView returns true if the user may view the object.
@@ -70,12 +91,86 @@ func (s *ACLService) CanEdit(
return perm.CanEdit, nil return perm.CanEdit, nil
} }
// GetPermissions returns all explicit ACL entries for an object. // GetPermissions returns all explicit ACL entries for an object. Only the
func (s *ACLService) GetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) { // object's owner or an admin may inspect its permission list.
func (s *ACLService) GetPermissions(
ctx context.Context,
userID int16, isAdmin bool,
objectTypeID int16, objectID uuid.UUID,
) ([]domain.Permission, error) {
if err := s.authorizeManage(ctx, userID, isAdmin, objectTypeID, objectID); err != nil {
return nil, err
}
return s.aclRepo.List(ctx, objectTypeID, objectID) return s.aclRepo.List(ctx, objectTypeID, objectID)
} }
// SetPermissions replaces all ACL entries for an object (full replace semantics). // SetPermissions replaces all ACL entries for an object (full replace semantics).
func (s *ACLService) SetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error { // Only the object's owner or an admin may change its permissions. The replace is
// performed atomically inside a single transaction.
func (s *ACLService) SetPermissions(
ctx context.Context,
userID int16, isAdmin bool,
objectTypeID int16, objectID uuid.UUID,
perms []domain.Permission,
) error {
if err := s.authorizeManage(ctx, userID, isAdmin, objectTypeID, objectID); err != nil {
return err
}
return s.tx.WithTx(ctx, func(ctx context.Context) error {
return s.aclRepo.Set(ctx, objectTypeID, objectID, perms) return s.aclRepo.Set(ctx, objectTypeID, objectID, perms)
})
}
// authorizeManage returns nil if the user may manage the object's ACL
// (admin or owner), ErrForbidden otherwise, or a propagated lookup error
// (including ErrNotFound when the object does not exist).
func (s *ACLService) authorizeManage(
ctx context.Context,
userID int16, isAdmin bool,
objectTypeID int16, objectID uuid.UUID,
) error {
if isAdmin {
return nil
}
owner, err := s.objectOwner(ctx, objectTypeID, objectID)
if err != nil {
return err
}
if owner != userID {
return domain.ErrForbidden
}
return nil
}
// objectOwner resolves the creator ID of the object identified by
// (objectTypeID, objectID). Returns ErrNotFound if the object does not exist.
func (s *ACLService) objectOwner(ctx context.Context, objectTypeID int16, objectID uuid.UUID) (int16, error) {
switch objectTypeID {
case fileObjectTypeID:
obj, err := s.files.GetByID(ctx, objectID)
if err != nil {
return 0, err
}
return obj.CreatorID, nil
case tagObjectTypeID:
obj, err := s.tags.GetByID(ctx, objectID)
if err != nil {
return 0, err
}
return obj.CreatorID, nil
case categoryObjectTypeID:
obj, err := s.categories.GetByID(ctx, objectID)
if err != nil {
return 0, err
}
return obj.CreatorID, nil
case poolObjectTypeID:
obj, err := s.pools.GetByID(ctx, objectID)
if err != nil {
return 0, err
}
return obj.CreatorID, nil
default:
return 0, domain.ErrValidation
}
} }
+72 -59
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@@ -14,12 +15,25 @@ import (
"tanabata/backend/internal/port" "tanabata/backend/internal/port"
) )
// Token types distinguish short-lived access tokens from long-lived refresh
// tokens so the two cannot be substituted for one another.
const (
tokenTypeAccess = "access"
tokenTypeRefresh = "refresh"
)
// dummyPasswordHash is a valid bcrypt hash used to equalise the cost of a login
// attempt against a non-existent user, preventing username enumeration via
// response timing. It is the hash of a random string no one knows.
const dummyPasswordHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// Claims is the JWT payload for both access and refresh tokens. // Claims is the JWT payload for both access and refresh tokens.
type Claims struct { type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
UserID int16 `json:"uid"` UserID int16 `json:"uid"`
IsAdmin bool `json:"adm"` IsAdmin bool `json:"adm"`
SessionID int `json:"sid"` SessionID int `json:"sid"`
TokenType string `json:"typ"`
} }
// TokenPair holds an issued access/refresh token pair with the access TTL. // TokenPair holds an issued access/refresh token pair with the access TTL.
@@ -59,8 +73,16 @@ func NewAuthService(
func (s *AuthService) Login(ctx context.Context, name, password, userAgent string) (*TokenPair, error) { func (s *AuthService) Login(ctx context.Context, name, password, userAgent string) (*TokenPair, error) {
user, err := s.users.GetByName(ctx, name) user, err := s.users.GetByName(ctx, name)
if err != nil { if err != nil {
// Return ErrUnauthorized regardless of whether the user exists, // Compare against a dummy hash so a missing user costs the same as a
// to avoid username enumeration. // wrong password, and return ErrUnauthorized either way to avoid
// username enumeration.
_ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(password))
return nil, domain.ErrUnauthorized
}
// Verify the password before disclosing anything about account state, so a
// caller cannot distinguish "blocked" from "wrong password".
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, domain.ErrUnauthorized return nil, domain.ErrUnauthorized
} }
@@ -68,48 +90,7 @@ func (s *AuthService) Login(ctx context.Context, name, password, userAgent strin
return nil, domain.ErrForbidden return nil, domain.ErrForbidden
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { return s.issuePair(ctx, user, userAgent)
return nil, domain.ErrUnauthorized
}
var expiresAt *time.Time
if s.refreshTTL > 0 {
t := time.Now().Add(s.refreshTTL)
expiresAt = &t
}
// Issue the refresh token first so we can store its hash.
refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
if err != nil {
return nil, fmt.Errorf("issue refresh token: %w", err)
}
session, err := s.sessions.Create(ctx, &domain.Session{
TokenHash: hashToken(refreshToken),
UserID: user.ID,
UserAgent: userAgent,
ExpiresAt: expiresAt,
})
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL)
if err != nil {
return nil, fmt.Errorf("issue access token: %w", err)
}
// Re-issue the refresh token with the real session ID now that we have it.
refreshToken, err = s.issueToken(user.ID, user.IsAdmin, session.ID, s.refreshTTL)
if err != nil {
return nil, fmt.Errorf("issue refresh token: %w", err)
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(s.accessTTL.Seconds()),
}, nil
} }
// Logout deactivates the session identified by sessionID. // Logout deactivates the session identified by sessionID.
@@ -124,7 +105,7 @@ func (s *AuthService) Logout(ctx context.Context, sessionID int) error {
// the old session. // the old session.
func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent string) (*TokenPair, error) { func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent string) (*TokenPair, error) {
claims, err := s.parseToken(refreshToken) claims, err := s.parseToken(refreshToken)
if err != nil { if err != nil || claims.TokenType != tokenTypeRefresh {
return nil, domain.ErrUnauthorized return nil, domain.ErrUnauthorized
} }
@@ -152,19 +133,30 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent strin
return nil, domain.ErrForbidden return nil, domain.ErrForbidden
} }
return s.issuePair(ctx, user, userAgent)
}
// issuePair creates a session and the access/refresh token pair for user.
//
// The refresh token is issued first and its hash is stored as the session's
// identity; the refresh token is located on /refresh purely by that hash, so it
// carries no session ID. The access token then embeds the real session ID so it
// can be revoked on logout. Because the stored hash is the hash of the token
// actually returned, /refresh works (unlike the previous re-issue approach).
func (s *AuthService) issuePair(ctx context.Context, user *domain.User, userAgent string) (*TokenPair, error) {
var expiresAt *time.Time var expiresAt *time.Time
if s.refreshTTL > 0 { if s.refreshTTL > 0 {
t := time.Now().Add(s.refreshTTL) t := time.Now().Add(s.refreshTTL)
expiresAt = &t expiresAt = &t
} }
newRefresh, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL) refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL, tokenTypeRefresh)
if err != nil { if err != nil {
return nil, fmt.Errorf("issue refresh token: %w", err) return nil, fmt.Errorf("issue refresh token: %w", err)
} }
newSession, err := s.sessions.Create(ctx, &domain.Session{ session, err := s.sessions.Create(ctx, &domain.Session{
TokenHash: hashToken(newRefresh), TokenHash: hashToken(refreshToken),
UserID: user.ID, UserID: user.ID,
UserAgent: userAgent, UserAgent: userAgent,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
@@ -173,19 +165,14 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent strin
return nil, fmt.Errorf("create session: %w", err) return nil, fmt.Errorf("create session: %w", err)
} }
accessToken, err := s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.accessTTL) accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL, tokenTypeAccess)
if err != nil { if err != nil {
return nil, fmt.Errorf("issue access token: %w", err) return nil, fmt.Errorf("issue access token: %w", err)
} }
newRefresh, err = s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.refreshTTL)
if err != nil {
return nil, fmt.Errorf("issue refresh token: %w", err)
}
return &TokenPair{ return &TokenPair{
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: newRefresh, RefreshToken: refreshToken,
ExpiresIn: int(s.accessTTL.Seconds()), ExpiresIn: int(s.accessTTL.Seconds()),
}, nil }, nil
} }
@@ -227,26 +214,43 @@ func (s *AuthService) TerminateSession(ctx context.Context, callerID int16, isAd
return nil return nil
} }
// ParseAccessToken parses and validates an access token, returning its claims. // ValidateAccessToken parses and validates an access token, returning its
func (s *AuthService) ParseAccessToken(tokenStr string) (*Claims, error) { // claims. A refresh token is rejected (wrong type), and the token's session
// must still be active — so logout, session termination, an admin block, or a
// refresh rotation revoke any outstanding access tokens immediately rather than
// only at expiry.
func (s *AuthService) ValidateAccessToken(ctx context.Context, tokenStr string) (*Claims, error) {
claims, err := s.parseToken(tokenStr) claims, err := s.parseToken(tokenStr)
if err != nil { if err != nil {
return nil, domain.ErrUnauthorized return nil, domain.ErrUnauthorized
} }
if claims.TokenType != tokenTypeAccess {
return nil, domain.ErrUnauthorized
}
if _, err := s.sessions.GetByID(ctx, claims.SessionID); err != nil {
return nil, domain.ErrUnauthorized
}
return claims, nil return claims, nil
} }
// issueToken signs a JWT with the given parameters. // issueToken signs a JWT with the given parameters. A random JWT ID guarantees
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration) (string, error) { // uniqueness even for tokens minted within the same second.
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration, tokenType string) (string, error) {
jti, err := randomJTI()
if err != nil {
return "", err
}
now := time.Now() now := time.Now()
claims := Claims{ claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ID: jti,
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
}, },
UserID: userID, UserID: userID,
IsAdmin: isAdmin, IsAdmin: isAdmin,
SessionID: sessionID, SessionID: sessionID,
TokenType: tokenType,
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(s.secret) signed, err := token.SignedString(s.secret)
@@ -280,3 +284,12 @@ func hashToken(token string) string {
sum := sha256.Sum256([]byte(token)) sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
// randomJTI returns a 128-bit random hex string for use as a JWT ID.
func randomJTI() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate jti: %w", err)
}
return hex.EncodeToString(b), nil
}
+64 -5
View File
@@ -8,6 +8,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
@@ -344,6 +345,7 @@ func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
return err return err
} }
_ = s.storage.Delete(ctx, id) _ = s.storage.Delete(ctx, id)
_ = s.storage.InvalidateCache(ctx, id)
objType := fileObjectType objType := fileObjectType
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil) _ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
@@ -382,6 +384,8 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil { if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err) return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
} }
// Drop stale thumbnail/preview so they regenerate from the new content.
_ = s.storage.InvalidateCache(ctx, id)
patch := &domain.File{ patch := &domain.File{
MIMEType: mime.Name, MIMEType: mime.Name,
@@ -407,6 +411,32 @@ func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*
return s.files.List(ctx, params) return s.files.List(ctx, params)
} }
// AuthorizeView ensures the caller may view the file. Returns ErrNotFound if the
// file does not exist or ErrForbidden if the caller lacks view access.
func (s *FileService) AuthorizeView(ctx context.Context, id uuid.UUID) error {
_, err := s.Get(ctx, id)
return err
}
// AuthorizeEdit ensures the caller may edit the file. Returns ErrNotFound if the
// file does not exist or ErrForbidden if the caller lacks edit access.
func (s *FileService) AuthorizeEdit(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
return nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Content / thumbnail / preview streaming // Content / thumbnail / preview streaming
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -468,14 +498,21 @@ func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error
// Import scans a server-side directory and uploads all supported files. // Import scans a server-side directory and uploads all supported files.
// If path is empty, the configured default import path is used. // If path is empty, the configured default import path is used.
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) { func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
dir := path if s.importPath == "" {
if dir == "" {
dir = s.importPath
}
if dir == "" {
return nil, domain.ErrValidation return nil, domain.ErrValidation
} }
dir := s.importPath
if path != "" {
// Confine caller-supplied paths to the configured import directory so a
// directory-traversal value cannot read arbitrary host files.
confined, err := confineToBase(s.importPath, path)
if err != nil {
return nil, err
}
dir = confined
}
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err) return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
@@ -550,6 +587,28 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
// Internal helpers // Internal helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// confineToBase resolves target and verifies it does not escape base (after
// cleaning and resolving "..") so a caller cannot read files outside the
// configured import directory. Returns the cleaned absolute path on success.
func confineToBase(base, target string) (string, error) {
absBase, err := filepath.Abs(base)
if err != nil {
return "", domain.ErrValidation
}
absTarget, err := filepath.Abs(target)
if err != nil {
return "", domain.ErrValidation
}
rel, err := filepath.Rel(absBase, absTarget)
if err != nil {
return "", domain.ErrValidation
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", domain.ErrForbidden
}
return absTarget, nil
}
// extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON // extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON
// representation and the DateTimeOriginal (if present). Both may be nil. // representation and the DateTimeOriginal (if present). Both may be nil.
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) { func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
+39 -3
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -13,12 +14,43 @@ import (
// UserService handles user CRUD and profile management. // UserService handles user CRUD and profile management.
type UserService struct { type UserService struct {
users port.UserRepo users port.UserRepo
sessions port.SessionRepo
audit *AuditService audit *AuditService
} }
// NewUserService creates a UserService. // NewUserService creates a UserService.
func NewUserService(users port.UserRepo, audit *AuditService) *UserService { func NewUserService(users port.UserRepo, sessions port.SessionRepo, audit *AuditService) *UserService {
return &UserService{users: users, audit: audit} return &UserService{users: users, sessions: sessions, audit: audit}
}
// EnsureAdmin creates the initial administrator account if it does not already
// exist. It is idempotent and never overwrites an existing user's password, so
// an operator who has changed the admin password keeps it across restarts.
func (s *UserService) EnsureAdmin(ctx context.Context, username, password string) error {
if username == "" || password == "" {
return fmt.Errorf("EnsureAdmin: username and password must be set")
}
if _, err := s.users.GetByName(ctx, username); err == nil {
return nil // already exists
} else if !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("EnsureAdmin: lookup: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("EnsureAdmin: hash: %w", err)
}
_, err = s.users.Create(ctx, &domain.User{
Name: username,
Password: string(hash),
IsAdmin: true,
CanCreate: true,
})
if err != nil {
return fmt.Errorf("EnsureAdmin: create: %w", err)
}
return nil
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -135,11 +167,15 @@ func (s *UserService) UpdateAdmin(ctx context.Context, id int16, p UpdateAdminPa
return nil, err return nil, err
} }
// Log block/unblock specifically. // Log block/unblock specifically, and revoke all sessions on block so the
// user's outstanding access tokens stop working immediately.
if p.IsBlocked != nil { if p.IsBlocked != nil {
action := "user_unblock" action := "user_unblock"
if *p.IsBlocked { if *p.IsBlocked {
action = "user_block" action = "user_block"
if err := s.sessions.DeleteByUserID(ctx, id); err != nil {
return nil, fmt.Errorf("UserService.UpdateAdmin revoke sessions: %w", err)
}
} }
_ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id}) _ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id})
} }
+47 -3
View File
@@ -15,6 +15,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"time"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/google/uuid" "github.com/google/uuid"
@@ -108,6 +109,17 @@ func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
return nil return nil
} }
// InvalidateCache removes the cached thumbnail and preview for id, if present,
// so they are regenerated from the current file content on the next request.
func (s *DiskStorage) InvalidateCache(_ context.Context, id uuid.UUID) error {
for _, p := range []string{s.thumbCachePath(id), s.previewCachePath(id)} {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("storage.InvalidateCache remove %q: %w", p, err)
}
}
return nil
}
// Thumbnail returns a JPEG that fits within the configured max width×height // Thumbnail returns a JPEG that fits within the configured max width×height
// (never upscaled, never cropped). Generated on first call and cached. // (never upscaled, never cropped). Generated on first call and cached.
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder. // Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
@@ -149,11 +161,11 @@ func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePat
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err) return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
} }
// 1. Try still-image decode (JPEG/PNG/GIF). // 1. Try still-image decode (JPEG/PNG/GIF), rejecting decompression bombs.
// 2. Try video frame extraction via ffmpeg. // 2. Try video frame extraction via ffmpeg.
// 3. Fall back to placeholder. // 3. Fall back to placeholder.
var img image.Image var img image.Image
if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil { if decoded, err := decodeImageLimited(srcPath); err == nil {
img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos) img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos)
} else if frame, err := extractVideoFrame(ctx, srcPath); err == nil { } else if frame, err := extractVideoFrame(ctx, srcPath); err == nil {
img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos) img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos)
@@ -206,12 +218,44 @@ func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) {
return f, nil return f, nil
} }
// maxDecodePixels caps the pixel count of an image we are willing to decode
// into memory, bounding the cost of a decompression bomb (a tiny file that
// expands to an enormous raster). 64 Mpx is ~ an 8192×8192 image.
const maxDecodePixels = 64 << 20
// decodeImageLimited decodes the image at path after first inspecting its header
// dimensions via image.DecodeConfig (which does not allocate the raster), and
// refuses images whose pixel count exceeds maxDecodePixels.
func decodeImageLimited(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return nil, err
}
if int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
return nil, fmt.Errorf("image too large to decode: %dx%d", cfg.Width, cfg.Height)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return imaging.Decode(f, imaging.AutoOrientation(true))
}
// extractVideoFrame uses ffmpeg to extract a single frame from a video file. // extractVideoFrame uses ffmpeg to extract a single frame from a video file.
// It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out // It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out
// as PNG. If the video is shorter than 1 s the seek is silently ignored by // as PNG. If the video is shorter than 1 s the seek is silently ignored by
// ffmpeg and the first available frame is returned instead. // ffmpeg and the first available frame is returned instead.
// Returns an error if ffmpeg is not installed or produces no output. // Returns an error if ffmpeg is not installed or produces no output. The run is
// bounded by a timeout so a malformed file cannot hang the request indefinitely.
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) { func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var out bytes.Buffer var out bytes.Buffer
cmd := exec.CommandContext(ctx, "ffmpeg", cmd := exec.CommandContext(ctx, "ffmpeg",
"-ss", "1", // fast input seek; ignored gracefully on short files "-ss", "1", // fast input seek; ignored gracefully on short files
+3 -3
View File
@@ -38,12 +38,12 @@ INSERT INTO activity.action_types (name) VALUES
-- Sessions -- Sessions
('session_terminate'); ('session_terminate');
INSERT INTO core.users (name, password, is_admin, can_create) VALUES -- The initial administrator is created at application startup from the
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true); -- ADMIN_USERNAME / ADMIN_PASSWORD environment variables (see UserService.
-- EnsureAdmin), so no default credentials are seeded here.
-- +goose Down -- +goose Down
DELETE FROM core.users WHERE name = 'admin';
DELETE FROM activity.action_types; DELETE FROM activity.action_types;
DELETE FROM core.object_types; DELETE FROM core.object_types;
DELETE FROM core.mime_types; DELETE FROM core.mime_types;
@@ -47,7 +47,7 @@
const rule = await api.post<TagRule>(`/tags/${tagId}/rules`, { const rule = await api.post<TagRule>(`/tags/${tagId}/rules`, {
then_tag_id: thenTagId, then_tag_id: thenTagId,
is_active: true, is_active: true,
apply_to_existing: false, apply_to_existing: $appSettings.tagRuleApplyToExisting,
}); });
onRulesChange([...rules, rule]); onRulesChange([...rules, rule]);
search = ''; search = '';
+2 -2
View File
@@ -262,8 +262,8 @@
<div class="toggle-row"> <div class="toggle-row">
<div> <div>
<span class="toggle-label">Apply activated tag rules to existing files</span> <span class="toggle-label">Apply new tag rules to existing files</span>
<p class="hint-text">When a tag rule is activated, automatically add the implied tag to all files that already have the source tag.</p> <p class="hint-text">When a tag rule is created or activated, automatically add the implied tag to all files that already have the source tag.</p>
</div> </div>
<button <button
class="toggle" class="toggle"