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
+44
View File
@@ -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})
}
@@ -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)
}
}