Add basic authentication

This commit is contained in:
2026-05-02 14:59:05 +02:00
parent e1c6ea9e51
commit f1fd72520a
15 changed files with 369 additions and 66 deletions
+52
View File
@@ -0,0 +1,52 @@
package handlers
import (
"log"
"quay/app/repository"
"quay/internal/security"
"time"
"github.com/gofiber/fiber/v3"
)
type AuthHandler struct {
Repo repository.UserRepository
}
func NewAuthHandler(repo repository.UserRepository) *AuthHandler {
return &AuthHandler{Repo: repo}
}
type LoginRequest struct {
Name string `json:"name"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
}
func (h *AuthHandler) Login(c fiber.Ctx) error {
var req LoginRequest
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
user, err := h.Repo.GetUserByName(req.Name)
if err != nil || user == nil {
log.Println("login: user lookup failed", err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
if !security.CheckPasswordHash(req.Password, user.HashedPassword) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
token, err := security.GenerateToken(user.ID, user.Role, 24*time.Hour)
if err != nil {
log.Println("login: token generation failed", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
}
return c.JSON(LoginResponse{Token: token})
}
+32
View File
@@ -0,0 +1,32 @@
package middleware
import (
"strings"
"quay/internal/security"
"github.com/gofiber/fiber/v3"
)
func RequireAuth() fiber.Handler {
return func(c fiber.Ctx) error {
auth := c.Get("Authorization")
if auth == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing authorization header"})
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid authorization header"})
}
claims, err := security.ValidateToken(parts[1])
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"})
}
c.Locals("user_id", claims.UserID)
c.Locals("role", claims.Role)
return c.Next()
}
}
+50 -31
View File
@@ -26,50 +26,69 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository)
api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost))
api.Get("/health", handlers.HealthCheck)
api.Post("/deploy", deploySiteHandler.PostDeploy)
public := api.Group("")
public.Get("/health", handlers.HealthCheck)
authHandler := handlers.NewAuthHandler(userRepository)
public.Post("/login", authHandler.Login)
// Protected routes - require auth for everything by default
protected := api.Group("", middleware.RequireAuth())
protected.Post("/deploy", deploySiteHandler.PostDeploy)
// Sites
api.Get("/sites", siteHandler.GetSites)
api.Get("/sites/:id", siteHandler.GetSite)
api.Post("/sites", siteHandler.PostSite)
api.Put("/sites/:id", siteHandler.PutSite)
api.Delete("/sites/:id", siteHandler.DeleteSite)
api.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
protected.Get("/sites", siteHandler.GetSites)
protected.Get("/sites/:id", siteHandler.GetSite)
protected.Post("/sites", siteHandler.PostSite)
protected.Put("/sites/:id", siteHandler.PutSite)
protected.Delete("/sites/:id", siteHandler.DeleteSite)
protected.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
// Forward rules
api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
api.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
api.Get("/sites/:id/forward-rules/:ruleId", siteHandler.GetForwardRule)
api.Put("/sites/:id/forward-rules/:ruleId", siteHandler.PutForwardRule)
api.Delete("/sites/:id/forward-rules/:ruleId", siteHandler.DeleteForwardRule)
protected.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
protected.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
protected.Get("/sites/:id/forward-rules/:ruleId", siteHandler.GetForwardRule)
protected.Put("/sites/:id/forward-rules/:ruleId", siteHandler.PutForwardRule)
protected.Delete("/sites/:id/forward-rules/:ruleId", siteHandler.DeleteForwardRule)
// Custom headers (header rules)
api.Get("/sites/:id/custom-headers", siteHandler.GetSiteCustomHeaders)
api.Post("/sites/:id/custom-headers", siteHandler.PostCustomHeaders)
api.Get("/sites/:id/custom-headers/:customHeaderId", siteHandler.GetCustomHeaders)
api.Put("/sites/:id/custom-headers/:customHeaderId", siteHandler.PutCustomHeaders)
api.Delete("/sites/:id/custom-headers/:customHeaderId", siteHandler.DeleteCustomHeaders)
protected.Get("/sites/:id/custom-headers", siteHandler.GetSiteCustomHeaders)
protected.Post("/sites/:id/custom-headers", siteHandler.PostCustomHeaders)
protected.Get("/sites/:id/custom-headers/:customHeaderId", siteHandler.GetCustomHeaders)
protected.Put("/sites/:id/custom-headers/:customHeaderId", siteHandler.PutCustomHeaders)
protected.Delete("/sites/:id/custom-headers/:customHeaderId", siteHandler.DeleteCustomHeaders)
// Headers
api.Get("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.GetCustomHeaderHeaders)
api.Post("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.PostHeader)
api.Get("/sites/:id/headers/:headerId", siteHandler.GetHeader)
api.Put("/sites/:id/headers/:headerId", siteHandler.PutHeader)
api.Delete("/sites/:id/headers/:headerId", siteHandler.DeleteHeader)
protected.Get("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.GetCustomHeaderHeaders)
protected.Post("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.PostHeader)
protected.Get("/sites/:id/headers/:headerId", siteHandler.GetHeader)
protected.Put("/sites/:id/headers/:headerId", siteHandler.PutHeader)
protected.Delete("/sites/:id/headers/:headerId", siteHandler.DeleteHeader)
// Deployments
api.Get("/deployments/:id", deploymentsHandler.GetDeployment)
api.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
protected.Get("/deployments/:id", deploymentsHandler.GetDeployment)
protected.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
// Users
api.Get("/users", userHandler.GetAllUsers)
api.Get("/users/:id", userHandler.GetUserById)
api.Get("/users/by-name/:name", userHandler.GetUserByName)
api.Post("/users", userHandler.CreateUser)
api.Put("/users/:id", userHandler.UpdateUser)
api.Delete("/users/:id", userHandler.DeleteUser)
protected.Get("/users", userHandler.GetAllUsers)
protected.Get("/users/:id", userHandler.GetUserById)
protected.Get("/users/by-name/:name", userHandler.GetUserByName)
// Allow creating the very first admin user without auth (bootstrap).
// If an admin already exists, require auth to create users.
if exists, err := userRepository.AdminUserExists(); err != nil {
// if we can't determine, be conservative and require auth
protected.Post("/users", userHandler.CreateUser)
} else if !exists {
public.Post("/users", userHandler.CreateUser)
} else {
protected.Post("/users", userHandler.CreateUser)
}
protected.Put("/users/:id", userHandler.UpdateUser)
protected.Delete("/users/:id", userHandler.DeleteUser)
api.Use(func(c fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
+3 -2
View File
@@ -5,9 +5,12 @@ go 1.25.0
require (
github.com/Flussen/swagger-fiber-v3 v1.0.1
github.com/gofiber/fiber/v3 v3.1.0
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.38
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.48.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -28,11 +31,9 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/swaggo/swag v1.16.4 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
+21
View File
@@ -10,7 +10,10 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -27,6 +30,8 @@ github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -38,6 +43,7 @@ github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -49,13 +55,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
@@ -64,11 +76,19 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -84,6 +104,7 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+66
View File
@@ -0,0 +1,66 @@
package security
import (
"errors"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
var ErrMissingSecret = errors.New("JWT secret not set")
type Claims struct {
UserID string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func getSecret() (string, error) {
s := os.Getenv("JWT_SECRET")
if s == "" {
return "", ErrMissingSecret
}
return s, nil
}
func GenerateToken(userID, role string, ttl time.Duration) (string, error) {
secret, err := getSecret()
if err != nil {
return "", err
}
claims := Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(tokenString string) (*Claims, error) {
secret, err := getSecret()
if err != nil {
return nil, err
}
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, 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
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}