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:
2026-06-19 11:18:42 +02:00
co-authored by Claude Sonnet 4.6
parent 649c9687ac
commit 1b381d9385
32 changed files with 895 additions and 56 deletions
+27
View File
@@ -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)
}
}
}
@@ -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)
}
}