feat: protect write endpoints with JWT auth (story #08)
Add bcrypt user file auth, JWT middleware on all write routes, Pinia authStore with localStorage persistence, login view with redirect support, and v-if guards on all CRUD controls and admin nav link. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user