diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f5de123..8cc934b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,9 +1,9 @@ ## Taiga - TAIGA_URL: https://taiga.db-extern.de -- TAIGA_PROJECT_SLUG: obstsortendatenbank +- TAIGA_PROJECT_SLUG: obstsortendatenbank-1 ## Stories -Before implementing a story: read its spec file in docs/planning/specs/ first. +Before starting a story: read its spec file in docs/planning/specs/ first. ## Branching Stories developed sequentially; prior story branch may not be merged to main yet. diff --git a/.gitignore b/.gitignore index c3bee36..8d000f5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ frontend/.vite/ # Environment — never commit real credentials .env backend/.env +users.env # Import data — large binary files, not committed 03-data/ diff --git a/README.md b/README.md index 3e9be91..875782c 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,19 @@ A full-stack fruit-variety database (Go + Vue 3). - Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`. - Users can search fruits by name or synonym (case-insensitive, debounced) and filter by type or combined-type alias (e.g. "Birnen- und Quittensorten") from the fruit list. - Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images. +- Maintainers can log in with a username and password to access data-modifying operations; all write API endpoints and admin controls are protected behind JWT authentication. ## Quick Start ```bash -cp .env.example .env # set credentials -make dev-db # start Postgres (waits until healthy) -make migrate-up # apply schema migrations -make run-backend # Echo API on :3000 -make run-frontend # Vite dev server on :5000 +cp .env.example .env # set credentials +cp backend/.env.example backend/.env # set JWT_SECRET and USERS_FILE +cp users.env.example users.env # create user file (see below) +./scripts/create_user.sh admin secret >> users.env # add a user +make dev-db # start Postgres (waits until healthy) +make migrate-up # apply schema migrations +make run-backend # Echo API on :3000 +make run-frontend # Vite dev server on :5000 ``` ## Testing diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..196a9f7 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb +PORT=3000 +JWT_SECRET=change-me-use-a-long-random-string +USERS_FILE=../users.env diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 91496dc..6972da6 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "osdb/internal/auth" "osdb/internal/config" "osdb/internal/database" "osdb/internal/migrate" @@ -52,7 +53,13 @@ func serve(cfg *config.Config) { defer pool.Close() log.Println("database: connected") - e := New(pool) + users, err := auth.LoadUsers(cfg.UsersFile) + if err != nil { + log.Fatalf("auth: load users from %s: %v", cfg.UsersFile, err) + } + log.Printf("auth: loaded %d user(s)", len(users)) + + e := New(pool, users, cfg.JWTSecret) // Start server; signal failures via a channel so the main goroutine can // handle them without calling os.Exit from a goroutine (which would skip diff --git a/backend/cmd/server/router.go b/backend/cmd/server/router.go index 68df4b6..7295b56 100644 --- a/backend/cmd/server/router.go +++ b/backend/cmd/server/router.go @@ -6,17 +6,20 @@ import ( "github.com/labstack/echo/v4/middleware" "osdb/internal/handler" + mw "osdb/internal/middleware" "osdb/internal/repository" ) // New creates and configures the Echo instance with all routes registered. -func New(pool *pgxpool.Pool) *echo.Echo { +func New(pool *pgxpool.Pool, users map[string]string, jwtSecret string) *echo.Echo { e := echo.New() e.HideBanner = true e.Use(middleware.Logger()) e.Use(middleware.Recover()) + jwtAuth := mw.JWTAuth(jwtSecret) + health := handler.NewHealthHandler() // Root health — used by docker healthcheck + ops tooling @@ -26,21 +29,25 @@ func New(pool *pgxpool.Pool) *echo.Echo { api := e.Group("/api/v1") api.GET("/health", health.Health) + // Auth (story #08) + authHandler := handler.NewAuthHandler(users, jwtSecret) + api.POST("/auth/login", authHandler.Login) + // Fruits (story #02) fruitRepo := repository.NewFruitRepo(pool) fruits := handler.NewFruitHandler(fruitRepo) g := api.Group("/fruits") g.GET("", fruits.List) - g.POST("", fruits.Create) + g.POST("", fruits.Create, jwtAuth) g.GET("/:id", fruits.Get) - g.PUT("/:id", fruits.Update) - g.DELETE("/:id", fruits.Delete) + g.PUT("/:id", fruits.Update, jwtAuth) + g.DELETE("/:id", fruits.Delete, jwtAuth) g.GET("/:id/images", fruits.ListImages) - g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M")) + g.POST("/:id/images", fruits.UploadImage, jwtAuth, middleware.BodyLimit("5M")) g.GET("/:id/images/:imageId", fruits.ServeImage) g.GET("/:id/images/:imageId/thumbnail", fruits.ServeThumbnail) - g.DELETE("/:id/images/:imageId", fruits.DeleteImage) + g.DELETE("/:id/images/:imageId", fruits.DeleteImage, jwtAuth) // Publications (story #04) pubRepo := repository.NewPublicationRepo(pool) @@ -52,29 +59,29 @@ func New(pool *pgxpool.Pool) *echo.Echo { p := api.Group("/publications") p.GET("", pubs.List) - p.POST("", pubs.Create) + p.POST("", pubs.Create, jwtAuth) p.GET("/:id", pubs.Get) - p.PUT("/:id", pubs.Update) - p.DELETE("/:id", pubs.Delete) - p.POST("/:id/image", pubs.UploadCoverImage, middleware.BodyLimit("5M")) + p.PUT("/:id", pubs.Update, jwtAuth) + p.DELETE("/:id", pubs.Delete, jwtAuth) + p.POST("/:id/image", pubs.UploadCoverImage, jwtAuth, middleware.BodyLimit("5M")) p.GET("/:id/image", pubs.ServeCoverImage) - p.DELETE("/:id/image", pubs.DeleteCoverImage) + p.DELETE("/:id/image", pubs.DeleteCoverImage, jwtAuth) p.GET("/:id/fruits", pubs.ListFruits) - p.POST("/:id/fruits", pubs.LinkFruit) - p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit) + p.POST("/:id/fruits", pubs.LinkFruit, jwtAuth) + p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit, jwtAuth) p.GET("/:id/descriptions", pubs.ListDescriptions) - p.POST("/:id/descriptions", pubs.UploadDescription, middleware.BodyLimit("5M")) + p.POST("/:id/descriptions", pubs.UploadDescription, jwtAuth, middleware.BodyLimit("5M")) p.GET("/:id/descriptions/:descId", pubs.ServeDescription) - p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription) + p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription, jwtAuth) p.GET("/:id/fruit-images", pubs.ListFruitImages) - p.POST("/:id/fruit-images", pubs.UploadFruitImage, middleware.BodyLimit("5M")) + p.POST("/:id/fruit-images", pubs.UploadFruitImage, jwtAuth, middleware.BodyLimit("5M")) p.GET("/:id/fruit-images/:imgId", pubs.ServeFruitImage) p.GET("/:id/fruit-images/:imgId/thumbnail", pubs.ServeFruitImageThumbnail) - p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage) + p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage, jwtAuth) - // Admin (story #07) + // Admin (story #07) — all admin routes require auth admin := handler.NewAdminHandler(fruitRepo, pubRepo) - adminGroup := api.Group("/admin") + adminGroup := api.Group("/admin", jwtAuth) adminGroup.POST("/backfill-thumbnails", admin.BackfillThumbnails) return e diff --git a/backend/go.mod b/backend/go.mod index 904c7c7..c55d9be 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 3e5802c..52ff0b5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -27,6 +27,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 0000000..d2ea89c --- /dev/null +++ b/backend/internal/auth/auth.go @@ -0,0 +1,83 @@ +package auth + +import ( + "bufio" + "errors" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +// Claims is the JWT payload. +type Claims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +// LoadUsers reads username:bcrypt_hash pairs from path. +// Lines starting with '#' and blank lines are ignored. +func LoadUsers(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + users := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + users[parts[0]] = parts[1] + } + return users, scanner.Err() +} + +// VerifyPassword checks username+password against the bcrypt hash stored for that user. +// The peppered input is "username:password". +func VerifyPassword(users map[string]string, username, password string) bool { + hash, ok := users[username] + if !ok { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(username+":"+password)) == nil +} + +// GenerateJWT creates a signed HS256 token for username, valid for 1 hour. +func GenerateJWT(username, secret string) (string, error) { + claims := Claims{ + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) +} + +// ValidateJWT parses and verifies token, returning its claims. +func ValidateJWT(tokenStr, secret string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go new file mode 100644 index 0000000..0bd12c5 --- /dev/null +++ b/backend/internal/auth/auth_test.go @@ -0,0 +1,125 @@ +package auth_test + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/crypto/bcrypt" + + "osdb/internal/auth" +) + +// hashPepper mirrors the pepper convention: bcrypt("username:password") +func hashPepper(username, password string) string { + h, err := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost) + if err != nil { + panic(err) + } + return string(h) +} + +func writeTempUsersFile(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "users*.env") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(content); err != nil { + t.Fatal(err) + } + f.Close() + return f.Name() +} + +func TestLoadUsers_ParsesValidFile(t *testing.T) { + hash := hashPepper("admin", "secret") + path := writeTempUsersFile(t, "admin:"+hash+"\n") + + users, err := auth.LoadUsers(path) + if err != nil { + t.Fatalf("LoadUsers: %v", err) + } + if len(users) != 1 { + t.Fatalf("expected 1 user, got %d", len(users)) + } + if _, ok := users["admin"]; !ok { + t.Error("expected 'admin' in users map") + } +} + +func TestLoadUsers_SkipsCommentLines(t *testing.T) { + hash := hashPepper("admin", "secret") + content := "# comment\nadmin:" + hash + "\n" + path := writeTempUsersFile(t, content) + + users, err := auth.LoadUsers(path) + if err != nil { + t.Fatalf("LoadUsers: %v", err) + } + if len(users) != 1 { + t.Fatalf("expected 1 user, got %d", len(users)) + } +} + +func TestLoadUsers_MissingFile(t *testing.T) { + _, err := auth.LoadUsers(filepath.Join(t.TempDir(), "nonexistent.env")) + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestVerifyPassword_CorrectPassword(t *testing.T) { + hash := hashPepper("admin", "secret") + users := map[string]string{"admin": hash} + + if !auth.VerifyPassword(users, "admin", "secret") { + t.Error("expected password to match") + } +} + +func TestVerifyPassword_WrongPassword(t *testing.T) { + hash := hashPepper("admin", "secret") + users := map[string]string{"admin": hash} + + if auth.VerifyPassword(users, "admin", "wrong") { + t.Error("expected password to not match") + } +} + +func TestVerifyPassword_UnknownUser(t *testing.T) { + if auth.VerifyPassword(map[string]string{}, "nobody", "secret") { + t.Error("expected false for unknown user") + } +} + +func TestGenerateAndValidateJWT(t *testing.T) { + token, err := auth.GenerateJWT("admin", "testsecret") + if err != nil { + t.Fatalf("GenerateJWT: %v", err) + } + if token == "" { + t.Error("expected non-empty token") + } + + claims, err := auth.ValidateJWT(token, "testsecret") + if err != nil { + t.Fatalf("ValidateJWT: %v", err) + } + if claims.Username != "admin" { + t.Errorf("expected username 'admin', got %q", claims.Username) + } +} + +func TestValidateJWT_WrongSecret(t *testing.T) { + token, _ := auth.GenerateJWT("admin", "correct") + if _, err := auth.ValidateJWT(token, "wrong"); err == nil { + t.Error("expected error for wrong secret") + } +} + +func TestValidateJWT_MalformedToken(t *testing.T) { + if _, err := auth.ValidateJWT("not.a.token", "secret"); err == nil { + t.Error("expected error for malformed token") + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1f104d6..44562e5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,11 +9,15 @@ import ( type Config struct { DatabaseURL string Port string + UsersFile string + JWTSecret string } // Load reads configuration from environment variables. // DATABASE_URL is required (no silent default — fails fast if missing). // PORT defaults to "3000". +// USERS_FILE defaults to "users.env". +// JWT_SECRET is required. func Load() *Config { dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { @@ -21,13 +25,25 @@ func Load() *Config { "Copy .env.example to .env and set the correct value.") } + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Fatal("JWT_SECRET environment variable is required but not set.") + } + port := os.Getenv("PORT") if port == "" { port = "3000" } + usersFile := os.Getenv("USERS_FILE") + if usersFile == "" { + usersFile = "users.env" + } + return &Config{ DatabaseURL: dbURL, Port: port, + UsersFile: usersFile, + JWTSecret: jwtSecret, } } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go new file mode 100644 index 0000000..ed868b6 --- /dev/null +++ b/backend/internal/handler/auth_handler.go @@ -0,0 +1,44 @@ +package handler + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "osdb/internal/auth" +) + +// AuthHandler handles authentication endpoints. +type AuthHandler struct { + users map[string]string + jwtSecret string +} + +// NewAuthHandler creates an AuthHandler with the given user map and JWT secret. +func NewAuthHandler(users map[string]string, jwtSecret string) *AuthHandler { + return &AuthHandler{users: users, jwtSecret: jwtSecret} +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// Login handles POST /api/v1/auth/login. +func (h *AuthHandler) Login(c echo.Context) error { + var req loginRequest + if err := c.Bind(&req); err != nil || req.Username == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + } + + if !auth.VerifyPassword(h.users, req.Username, req.Password) { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + } + + token, err := auth.GenerateJWT(req.Username, h.jwtSecret) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "token generation failed"}) + } + + return c.JSON(http.StatusOK, map[string]string{"token": token}) +} diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go new file mode 100644 index 0000000..e5415d4 --- /dev/null +++ b/backend/internal/handler/auth_handler_test.go @@ -0,0 +1,75 @@ +package handler_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" + + "github.com/labstack/echo/v4" + + "osdb/internal/handler" +) + +func makeUsersMap(username, password string) map[string]string { + h, _ := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost) + return map[string]string{username: string(h)} +} + +func TestAuthHandler_Login_ValidCredentials(t *testing.T) { + users := makeUsersMap("admin", "secret") + h := handler.NewAuthHandler(users, "jwttest") + + body := `{"username":"admin","password":"secret"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := echo.New().NewContext(req, rec) + + if err := h.Login(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d — body: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "token") { + t.Errorf("expected 'token' in response, got: %s", rec.Body.String()) + } +} + +func TestAuthHandler_Login_InvalidCredentials(t *testing.T) { + users := makeUsersMap("admin", "secret") + h := handler.NewAuthHandler(users, "jwttest") + + body := `{"username":"admin","password":"wrong"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := echo.New().NewContext(req, rec) + + if err := h.Login(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } +} + +func TestAuthHandler_Login_MissingBody(t *testing.T) { + users := makeUsersMap("admin", "secret") + h := handler.NewAuthHandler(users, "jwttest") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := echo.New().NewContext(req, rec) + + if err := h.Login(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } +} diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go new file mode 100644 index 0000000..3ae92e6 --- /dev/null +++ b/backend/internal/middleware/jwt_auth.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/labstack/echo/v4" + + "osdb/internal/auth" +) + +// JWTAuth returns an Echo middleware that requires a valid Bearer JWT. +func JWTAuth(secret string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + header := c.Request().Header.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "missing token"}) + } + tokenStr := strings.TrimPrefix(header, "Bearer ") + if _, err := auth.ValidateJWT(tokenStr, secret); err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token"}) + } + return next(c) + } + } +} diff --git a/backend/internal/middleware/jwt_auth_test.go b/backend/internal/middleware/jwt_auth_test.go new file mode 100644 index 0000000..146b7c0 --- /dev/null +++ b/backend/internal/middleware/jwt_auth_test.go @@ -0,0 +1,82 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + + "osdb/internal/auth" + mw "osdb/internal/middleware" +) + +const testSecret = "testsecret" + +func okHandler(c echo.Context) error { + return c.String(http.StatusOK, "ok") +} + +func TestJWTAuth_NoToken_Returns401(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + h := mw.JWTAuth(testSecret)(okHandler) + _ = h(c) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } +} + +func TestJWTAuth_ValidToken_Passes(t *testing.T) { + token, _ := auth.GenerateJWT("admin", testSecret) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + h := mw.JWTAuth(testSecret)(okHandler) + if err := h(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +func TestJWTAuth_InvalidToken_Returns401(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Authorization", "Bearer invalid.token.here") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + h := mw.JWTAuth(testSecret)(okHandler) + _ = h(c) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } +} + +func TestJWTAuth_WrongSecret_Returns401(t *testing.T) { + token, _ := auth.GenerateJWT("admin", "other-secret") + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + h := mw.JWTAuth(testSecret)(okHandler) + _ = h(c) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a78fb6f..02fdee5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,7 +1,9 @@ + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..ce66c56 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,15 @@ +export interface LoginResponse { + token: string +} + +export async function login(username: string, password: string): Promise { + const resp = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + if (!resp.ok) { + throw new Error('Invalid credentials') + } + return resp.json() +} diff --git a/frontend/src/api/authHeader.ts b/frontend/src/api/authHeader.ts new file mode 100644 index 0000000..892aebb --- /dev/null +++ b/frontend/src/api/authHeader.ts @@ -0,0 +1,16 @@ +const TOKEN_KEY = 'auth_token' + +export function bearerHeader(): Record { + try { + const token = localStorage.getItem(TOKEN_KEY) + return token ? { Authorization: `Bearer ${token}` } : {} + } catch { + return {} + } +} + +export function handleUnauthorized(): never { + localStorage.removeItem(TOKEN_KEY) + window.location.href = '/login' + throw new Error('Unauthorized') +} diff --git a/frontend/src/api/fruits.ts b/frontend/src/api/fruits.ts index 2a50629..c43d4bf 100644 --- a/frontend/src/api/fruits.ts +++ b/frontend/src/api/fruits.ts @@ -62,8 +62,11 @@ export function imageUrl(fruitId: number, imageId: number): string { return `/api/v1/fruits/${fruitId}/images/${imageId}` } +import { bearerHeader, handleUnauthorized } from './authHeader' + async function checkOk(res: Response): Promise { if (!res.ok) { + if (res.status === 401) handleUnauthorized() let msg = `${res.status}` try { const body = await res.json() @@ -100,7 +103,7 @@ export async function getFruit(id: number): Promise { export async function createFruit(dto: FruitWriteDTO): Promise { const res = await fetch('/api/v1/fruits', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...bearerHeader() }, body: JSON.stringify(dto), }) return (await checkOk(res)).json() @@ -109,14 +112,14 @@ export async function createFruit(dto: FruitWriteDTO): Promise { export async function updateFruit(id: number, dto: FruitWriteDTO): Promise { const res = await fetch(`/api/v1/fruits/${id}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...bearerHeader() }, body: JSON.stringify(dto), }) return (await checkOk(res)).json() } export async function deleteFruit(id: number): Promise { - const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' }) + const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE', headers: bearerHeader() }) await checkOk(res) } @@ -136,11 +139,11 @@ export async function addImage( form.append('image_type', imageType) if (title) form.append('title', title) // Do NOT set Content-Type — browser sets it with the correct multipart boundary - const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form }) + const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form, headers: bearerHeader() }) return (await checkOk(res)).json() } export async function deleteImage(fruitId: number, imageId: number): Promise { - const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' }) + const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE', headers: bearerHeader() }) await checkOk(res) } diff --git a/frontend/src/api/publications.ts b/frontend/src/api/publications.ts index daab284..9755c38 100644 --- a/frontend/src/api/publications.ts +++ b/frontend/src/api/publications.ts @@ -54,8 +54,11 @@ export function pubFruitImageUrl(pubId: number, imgId: number): string { return `/api/v1/publications/${pubId}/fruit-images/${imgId}` } +import { bearerHeader, handleUnauthorized } from './authHeader' + async function checkOk(res: Response): Promise { if (!res.ok) { + if (res.status === 401) handleUnauthorized() let msg = `${res.status}` try { const body = await res.json() @@ -83,7 +86,7 @@ export async function getPublication(id: number): Promise { export async function createPublication(dto: PublicationWriteDTO): Promise { const res = await fetch('/api/v1/publications', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...bearerHeader() }, body: JSON.stringify(dto), }) return (await checkOk(res)).json() @@ -92,26 +95,26 @@ export async function createPublication(dto: PublicationWriteDTO): Promise { const res = await fetch(`/api/v1/publications/${id}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...bearerHeader() }, body: JSON.stringify(dto), }) return (await checkOk(res)).json() } export async function deletePublication(id: number): Promise { - const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE' }) + const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE', headers: bearerHeader() }) await checkOk(res) } export async function uploadCoverImage(pubId: number, file: File): Promise { const form = new FormData() form.append('image', file) - const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form }) + const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form, headers: bearerHeader() }) await checkOk(res) } export async function deleteCoverImage(pubId: number): Promise { - const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE' }) + const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE', headers: bearerHeader() }) await checkOk(res) } @@ -123,14 +126,14 @@ export async function listPubFruits(pubId: number): Promise export async function linkFruit(pubId: number, fruitId: number): Promise { const res = await fetch(`/api/v1/publications/${pubId}/fruits`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...bearerHeader() }, body: JSON.stringify({ fruit_id: fruitId }), }) await checkOk(res) } export async function unlinkFruit(pubId: number, fruitId: number): Promise { - const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE' }) + const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE', headers: bearerHeader() }) await checkOk(res) } @@ -143,12 +146,12 @@ export async function uploadDescription(pubId: number, fruitId: number, file: Fi const form = new FormData() form.append('pdf', file) form.append('fruit_id', String(fruitId)) - const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form }) + const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form, headers: bearerHeader() }) return (await checkOk(res)).json() } export async function deleteDescription(pubId: number, descId: number): Promise { - const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE' }) + const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE', headers: bearerHeader() }) await checkOk(res) } @@ -161,12 +164,12 @@ export async function uploadPubFruitImage(pubId: number, fruitId: number, file: const form = new FormData() form.append('image', file) form.append('fruit_id', String(fruitId)) - const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form }) + const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form, headers: bearerHeader() }) return (await checkOk(res)).json() } export async function deletePubFruitImage(pubId: number, imgId: number): Promise { - const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE' }) + const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE', headers: bearerHeader() }) await checkOk(res) } diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue new file mode 100644 index 0000000..b93db04 --- /dev/null +++ b/frontend/src/components/NavBar.vue @@ -0,0 +1,36 @@ + + + + + Früchte + Publikationen + Administration + + + Abmelden + + + Anmelden + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 47f7659..9c45d8d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/authStore' import HelloWorld from '../views/HelloWorld.vue' import FruitList from '../views/FruitList.vue' import FruitCreate from '../views/FruitCreate.vue' @@ -6,6 +7,8 @@ import FruitDetail from '../views/FruitDetail.vue' import PublicationList from '../views/PublicationList.vue' import PublicationCreate from '../views/PublicationCreate.vue' import PublicationDetail from '../views/PublicationDetail.vue' +import LoginView from '../views/LoginView.vue' +import AdminView from '../views/AdminView.vue' const router = createRouter({ history: createWebHistory(), @@ -14,14 +17,19 @@ const router = createRouter({ path: '/', component: HelloWorld, }, + { + path: '/login', + component: LoginView, + }, { path: '/fruits', component: FruitList, }, { - // /fruits/new must come before /:id so vue-router v5 doesn't capture "new" as a param + // /fruits/new must come before /:id so vue-router doesn't capture "new" as a param path: '/fruits/new', component: FruitCreate, + meta: { requiresAuth: true }, }, { path: '/fruits/:id', @@ -36,13 +44,28 @@ const router = createRouter({ // /publications/new must come before /:id path: '/publications/new', component: PublicationCreate, + meta: { requiresAuth: true }, }, { path: '/publications/:id', component: PublicationDetail, props: true, }, + { + path: '/admin', + component: AdminView, + meta: { requiresAuth: true }, + }, ], }) +router.beforeEach((to) => { + if (to.meta.requiresAuth) { + const auth = useAuthStore() + if (!auth.isLoggedIn) { + return { path: '/login', query: { redirect: to.fullPath } } + } + } +}) + export default router diff --git a/frontend/src/stores/authStore.test.ts b/frontend/src/stores/authStore.test.ts new file mode 100644 index 0000000..a87b331 --- /dev/null +++ b/frontend/src/stores/authStore.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAuthStore } from './authStore' + +const fetchMock = vi.fn() + +function makeLocalStorageMock() { + const store: Record = {} + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = v }, + removeItem: (k: string) => { delete store[k] }, + } +} + +describe('authStore', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorageMock()) + setActivePinia(createPinia()) + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('starts unauthenticated when localStorage is empty', () => { + const store = useAuthStore() + expect(store.isLoggedIn).toBe(false) + expect(store.token).toBeNull() + }) + + it('restores auth state from localStorage on init', () => { + localStorage.setItem('auth_token', 'my-stored-token') + const store = useAuthStore() + expect(store.isLoggedIn).toBe(true) + expect(store.token).toBe('my-stored-token') + }) + + it('login sets token and isLoggedIn on success', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: 'new-jwt-token' }), + }) + + const store = useAuthStore() + await store.login('admin', 'secret') + + expect(store.isLoggedIn).toBe(true) + expect(store.token).toBe('new-jwt-token') + expect(localStorage.getItem('auth_token')).toBe('new-jwt-token') + }) + + it('login throws on invalid credentials', async () => { + fetchMock.mockResolvedValueOnce({ ok: false }) + + const store = useAuthStore() + await expect(store.login('admin', 'wrong')).rejects.toThrow() + expect(store.isLoggedIn).toBe(false) + }) + + it('logout clears token and isLoggedIn', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: 'tok' }), + }) + const store = useAuthStore() + await store.login('admin', 'secret') + store.logout() + + expect(store.isLoggedIn).toBe(false) + expect(store.token).toBeNull() + expect(localStorage.getItem('auth_token')).toBeNull() + }) +}) diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..9562439 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,23 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { login as apiLogin } from '../api/auth' + +const TOKEN_KEY = 'auth_token' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem(TOKEN_KEY)) + const isLoggedIn = computed(() => token.value !== null) + + async function login(username: string, password: string) { + const resp = await apiLogin(username, password) + token.value = resp.token + localStorage.setItem(TOKEN_KEY, resp.token) + } + + function logout() { + token.value = null + localStorage.removeItem(TOKEN_KEY) + } + + return { token, isLoggedIn, login, logout } +}) diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue new file mode 100644 index 0000000..2a299e7 --- /dev/null +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,48 @@ + + + + + Administration + + + Thumbnails rückfüllen + Erzeugt Thumbnails für alle Bilder, die noch keines haben. + + {{ running ? 'Läuft...' : 'Starten' }} + + {{ error }} + {{ result }} + + + diff --git a/frontend/src/views/FruitDetail.vue b/frontend/src/views/FruitDetail.vue index 438bb16..9a0bd1e 100644 --- a/frontend/src/views/FruitDetail.vue +++ b/frontend/src/views/FruitDetail.vue @@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useFruitStore } from '../stores/fruitStore' +import { useAuthStore } from '../stores/authStore' import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits' import type { Fruit } from '../api/fruits' import { getFruitDescriptions, getFruitPublicationImages } from '../api/publications' @@ -10,6 +11,7 @@ import type { PublicationDescription, PublicationFruitImage } from '../api/publi const props = defineProps<{ id: string }>() const router = useRouter() const store = useFruitStore() +const auth = useAuthStore() const editing = ref(false) const editName = ref('') @@ -134,7 +136,7 @@ async function removeImage(imageId: number) { {{ store.current.name }} - + - + - + Bild hochladen {{ uploadError }} diff --git a/frontend/src/views/FruitList.vue b/frontend/src/views/FruitList.vue index 9414f6a..533b414 100644 --- a/frontend/src/views/FruitList.vue +++ b/frontend/src/views/FruitList.vue @@ -2,6 +2,7 @@ import { onMounted, onUnmounted, computed, ref } from 'vue' import { RouterLink } from 'vue-router' import { useFruitStore } from '../stores/fruitStore' +import { useAuthStore } from '../stores/authStore' import FruitCard from '../components/FruitCard.vue' const SEARCH_TYPES = [ @@ -30,6 +31,7 @@ const SEARCH_TYPES = [ ] const store = useFruitStore() +const auth = useAuthStore() const nameInput = ref(store.searchName) const typeSelect = ref(store.searchType) let debounceTimer: ReturnType | null = null @@ -76,6 +78,7 @@ function onTypeChange() { Früchte diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..5c17a2d --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,73 @@ + + + + + + Anmelden + + + {{ error }} + + + + Benutzername + + + + + Passwort + + + + + {{ loading ? 'Anmelden...' : 'Anmelden' }} + + + + diff --git a/frontend/src/views/PublicationDetail.vue b/frontend/src/views/PublicationDetail.vue index 33f587c..2b8004e 100644 --- a/frontend/src/views/PublicationDetail.vue +++ b/frontend/src/views/PublicationDetail.vue @@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import { usePublicationStore } from '../stores/publicationStore' +import { useAuthStore } from '../stores/authStore' import { uploadCoverImage, deleteCoverImage, @@ -23,6 +24,7 @@ import ImageOverlayDrawer from '../components/ImageOverlayDrawer.vue' const props = defineProps<{ id: string }>() const router = useRouter() const store = usePublicationStore() +const auth = useAuthStore() // Edit state const editing = ref(false) @@ -212,7 +214,7 @@ async function deleteFruitImg(imgId: number) { {{ store.current.osdb_pub_id }} {{ store.current.author }} - + - + {{ serverError }} @@ -266,6 +268,7 @@ async function deleteFruitImg(imgId: number) { @click="overlayOpen = true" /> @@ -274,7 +277,7 @@ async function deleteFruitImg(imgId: number) { Kein Titelbild vorhanden. {{ coverError }} - + {{ f.name }} ({{ f.osdb_number }}) - + Entfernen Keine Früchte verknüpft. - + {{ d.fruit_name || `Frucht #${d.fruit_id}` }} - + Löschen Keine Beschreibungen vorhanden. {{ descError }} - + Frucht #{{ img.fruit_id }} - + Löschen Keine Fruchtbilder vorhanden. {{ fruitImgError }} - + import { onMounted } from 'vue' import { usePublicationStore } from '../stores/publicationStore' +import { useAuthStore } from '../stores/authStore' const store = usePublicationStore() +const auth = useAuthStore() onMounted(async () => { try { @@ -18,6 +20,7 @@ onMounted(async () => { Publikationen diff --git a/scripts/create_user.sh b/scripts/create_user.sh new file mode 100755 index 0000000..5a8cf17 --- /dev/null +++ b/scripts/create_user.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Usage: ./scripts/create_user.sh +# Prints a line suitable for appending to users.env +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +USERNAME="$1" +PASSWORD="$2" + +# bcrypt the peppered input "username:password" +PEPPERED="${USERNAME}:${PASSWORD}" + +# Try Python 3 with bcrypt first, then fall back to htpasswd +if python3 -c "import bcrypt" 2>/dev/null; then + HASH=$(python3 -c " +import bcrypt, sys +pw = sys.argv[1].encode() +print(bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12)).decode()) +" "$PEPPERED") +elif command -v htpasswd &>/dev/null; then + HASH=$(htpasswd -bnBC 12 "" "$PEPPERED" | tr -d ':\n' | sed 's/^\$/\$/') +else + echo "Error: install 'bcrypt' (pip install bcrypt) or 'htpasswd' (apache2-utils)" >&2 + exit 1 +fi + +echo "${USERNAME}:${HASH}" diff --git a/users.env.example b/users.env.example new file mode 100644 index 0000000..918f90f --- /dev/null +++ b/users.env.example @@ -0,0 +1,2 @@ +# username:bcrypt_hash — generate entries with: ./scripts/create_user.sh +# admin:$2a$12$...
Erzeugt Thumbnails für alle Bilder, die noch keines haben.
{{ result }}
{{ store.current.osdb_pub_id }}
{{ store.current.author }}