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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user