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
+83
View File
@@ -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
}
+125
View File
@@ -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")
}
}
+16
View File
@@ -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,
}
}
+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)
}
}
+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)
}
}