diff --git a/backend/app/handlers/auth.go b/backend/app/handlers/auth.go new file mode 100644 index 0000000..a5d19da --- /dev/null +++ b/backend/app/handlers/auth.go @@ -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}) +} diff --git a/backend/app/middleware/auth.go b/backend/app/middleware/auth.go new file mode 100644 index 0000000..80a6128 --- /dev/null +++ b/backend/app/middleware/auth.go @@ -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() + } +} diff --git a/backend/app/routes/routes.go b/backend/app/routes/routes.go index 2b2dbfd..51a91ab 100644 --- a/backend/app/routes/routes.go +++ b/backend/app/routes/routes.go @@ -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{ diff --git a/backend/go.mod b/backend/go.mod index 6c793e4..0b755c0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 16c0604..386262f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/security/jwt.go b/backend/internal/security/jwt.go new file mode 100644 index 0000000..2d19515 --- /dev/null +++ b/backend/internal/security/jwt.go @@ -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") +} diff --git a/frontend/src/api/auth.api.ts b/frontend/src/api/auth.api.ts new file mode 100644 index 0000000..c4d9fc7 --- /dev/null +++ b/frontend/src/api/auth.api.ts @@ -0,0 +1,18 @@ +import { makeApiUrl } from '.'; + +export const login = async (name: string, password: string): Promise => { + const response = await fetch(makeApiUrl('/login'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, password }), + }); + + if (!response.ok) { + throw new Error('Failed to login'); + } + + const data = await response.json(); + return data.token; +}; diff --git a/frontend/src/api/customHeaders.api.ts b/frontend/src/api/customHeaders.api.ts index 24c70ec..37ffdf7 100644 --- a/frontend/src/api/customHeaders.api.ts +++ b/frontend/src/api/customHeaders.api.ts @@ -1,4 +1,4 @@ -import { makeApiUrl } from '.'; +import { fetchWithAuth } from '.'; import type { CreateCustomHeadersRequest, CreateHeaderRequest, @@ -7,7 +7,7 @@ import type { } from './types/site'; export const getCustomHeaders = async (siteId: string): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers`), { + const response = await fetchWithAuth(`/sites/${siteId}/custom-headers`, { method: 'GET', }); if (!response.ok) { @@ -20,7 +20,7 @@ export const createCustomHeaders = async ( siteId: string, data: CreateCustomHeadersRequest ): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers`), { + const response = await fetchWithAuth(`/sites/${siteId}/custom-headers`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -38,7 +38,7 @@ export const updateCustomHeaders = async ( groupId: string, data: CreateCustomHeadersRequest ): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}`), { + const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -52,7 +52,7 @@ export const updateCustomHeaders = async ( }; export const deleteCustomHeaders = async (siteId: string, groupId: string): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}`), { + const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}`, { method: 'DELETE', }); if (!response.ok) { @@ -65,7 +65,7 @@ export const createHeader = async ( groupId: string, data: CreateHeaderRequest ): Promise
=> { - const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}/headers`), { + const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}/headers`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -83,7 +83,7 @@ export const updateHeader = async ( headerId: string, data: CreateHeaderRequest ): Promise
=> { - const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), { + const response = await fetchWithAuth(`/sites/${siteId}/headers/${headerId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -97,7 +97,7 @@ export const updateHeader = async ( }; export const deleteHeader = async (siteId: string, headerId: string): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), { + const response = await fetchWithAuth(`/sites/${siteId}/headers/${headerId}`, { method: 'DELETE', }); if (!response.ok) { diff --git a/frontend/src/api/deployments.api.ts b/frontend/src/api/deployments.api.ts index 3a398f7..752cba2 100644 --- a/frontend/src/api/deployments.api.ts +++ b/frontend/src/api/deployments.api.ts @@ -1,11 +1,11 @@ -import { makeApiUrl } from '.'; +import { fetchWithAuth } from '.'; import type { Deployment } from './types/deployments'; export const getDeploymentsForSite = async ( siteId: string, limit: number = 100 ): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/deployments?limit=${limit}`), { + const response = await fetchWithAuth(`/sites/${siteId}/deployments?limit=${limit}`, { method: 'GET', }); diff --git a/frontend/src/api/forwardrules.api.ts b/frontend/src/api/forwardrules.api.ts index f48580c..2680ea1 100644 --- a/frontend/src/api/forwardrules.api.ts +++ b/frontend/src/api/forwardrules.api.ts @@ -1,8 +1,8 @@ -import { makeApiUrl } from '.'; +import { fetchWithAuth } from '.'; import type { CreateForwardRuleRequest, ForwardRule } from './types/site'; export const getForwardRules = async (siteId: string): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules`), { + const response = await fetchWithAuth(`/sites/${siteId}/forward-rules`, { method: 'GET', }); if (!response.ok) { @@ -15,7 +15,7 @@ export const createForwardRule = async ( siteId: string, data: CreateForwardRuleRequest ): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules`), { + const response = await fetchWithAuth(`/sites/${siteId}/forward-rules`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -33,7 +33,7 @@ export const updateForwardRule = async ( ruleId: string, data: CreateForwardRuleRequest ): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), { + const response = await fetchWithAuth(`/sites/${siteId}/forward-rules/${ruleId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -47,7 +47,7 @@ export const updateForwardRule = async ( }; export const deleteForwardRule = async (siteId: string, ruleId: string): Promise => { - const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), { + const response = await fetchWithAuth(`/sites/${siteId}/forward-rules/${ruleId}`, { method: 'DELETE', }); if (!response.ok) { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7a81129..0548bb1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -5,3 +5,20 @@ export const makeApiUrl = (endpoint: string) => { const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; return `${base}${path}`; }; + +export const authHeaders = () => { + try { + const token = localStorage.getItem('token'); + return token ? { Authorization: `Bearer ${token}` } : {}; + } catch (e) { + return {}; + } +}; + +export const fetchWithAuth = (endpoint: string, options: RequestInit = {}) => { + const headers = { + ...(options.headers || {}), + ...authHeaders(), + } as Record; + return fetch(makeApiUrl(endpoint), { ...options, headers }); +}; diff --git a/frontend/src/api/sites.api.ts b/frontend/src/api/sites.api.ts index e3489e5..e80198a 100644 --- a/frontend/src/api/sites.api.ts +++ b/frontend/src/api/sites.api.ts @@ -1,4 +1,4 @@ -import { makeApiUrl } from '.'; +import { fetchWithAuth } from '.'; import type { CreateSiteRequest, CreateSiteResponse, @@ -8,7 +8,7 @@ import type { } from './types/site'; export const getSites = async (): Promise => { - const response = await fetch(makeApiUrl('/sites'), { + const response = await fetchWithAuth('/sites', { method: 'GET', }); if (response.status === 404) { @@ -21,7 +21,7 @@ export const getSites = async (): Promise => { }; export const getSite = async (id: string): Promise => { - const response = await fetch(makeApiUrl(`/sites/${id}`), { + const response = await fetchWithAuth(`/sites/${id}`, { method: 'GET', }); if (!response.ok) { @@ -31,7 +31,7 @@ export const getSite = async (id: string): Promise => { }; export const createSite = async (data: CreateSiteRequest): Promise => { - const response = await fetch(makeApiUrl('/sites'), { + const response = await fetchWithAuth('/sites', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -45,7 +45,7 @@ export const createSite = async (data: CreateSiteRequest): Promise): Promise => { - const response = await fetch(makeApiUrl(`/sites/${id}`), { + const response = await fetchWithAuth(`/sites/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -59,7 +59,7 @@ export const updateSite = async (id: string, data: Partial): }; export const toggleSiteEnabled = async (id: string): Promise => { - const response = await fetch(makeApiUrl(`/sites/${id}/enabled`), { + const response = await fetchWithAuth(`/sites/${id}/enabled`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -72,7 +72,7 @@ export const toggleSiteEnabled = async (id: string): Promise => { - const response = await fetch(makeApiUrl(`/sites/${id}`), { + const response = await fetchWithAuth(`/sites/${id}`, { method: 'DELETE', }); if (!response.ok) { diff --git a/frontend/src/api/users.api.ts b/frontend/src/api/users.api.ts index 3f3de98..7164e5a 100644 --- a/frontend/src/api/users.api.ts +++ b/frontend/src/api/users.api.ts @@ -1,8 +1,8 @@ -import { makeApiUrl } from '.'; +import { fetchWithAuth } from '.'; import type { CreateUserRequest, UpdateUserRequest, User } from './types/user'; export const getUsers = async (): Promise => { - const response = await fetch(makeApiUrl('/users'), { + const response = await fetchWithAuth('/users', { method: 'GET', }); if (response.status === 404) { @@ -15,7 +15,7 @@ export const getUsers = async (): Promise => { }; export const getUserById = async (id: string): Promise => { - const response = await fetch(makeApiUrl(`/users/${id}`), { + const response = await fetchWithAuth(`/users/${id}`, { method: 'GET', }); if (!response.ok) { @@ -25,7 +25,7 @@ export const getUserById = async (id: string): Promise => { }; export const createUser = async (data: CreateUserRequest): Promise => { - const response = await fetch(makeApiUrl('/users'), { + const response = await fetchWithAuth('/users', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -39,7 +39,7 @@ export const createUser = async (data: CreateUserRequest): Promise => { }; export const updateUser = async (id: string, data: Partial): Promise => { - const response = await fetch(makeApiUrl(`/users/${id}`), { + const response = await fetchWithAuth(`/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -53,7 +53,7 @@ export const updateUser = async (id: string, data: Partial): }; export const deleteUser = async (id: string): Promise => { - const response = await fetch(makeApiUrl(`/users/${id}`), { + const response = await fetchWithAuth(`/users/${id}`, { method: 'DELETE', }); if (!response.ok) { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index eea82e3..c0aa9d3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Route, Routes } from 'react-router'; +import { BrowserRouter, Route, Routes, Navigate } from 'react-router'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MainPage from './pages/Main/Main'; @@ -8,6 +8,7 @@ import './index.css'; import NewSite from './pages/NewSite/NewSite'; import SiteOverview from './pages/SiteOverview/SiteOverview'; import Users from './pages/Users/Users'; +import Login from './pages/Login/Login'; const queryClient = new QueryClient(); @@ -16,10 +17,35 @@ createRoot(document.getElementById('root')!).render( - } /> - } /> - } /> - } /> + } /> + : + } + /> + : + } + /> + + ) : ( + + ) + } + /> + : + } + /> diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx new file mode 100644 index 0000000..ebfd477 --- /dev/null +++ b/frontend/src/pages/Login/Login.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { login } from '../../api/auth.api'; +import { Input } from '../../components/ui/input'; +import { Button } from '../../components/ui/button'; + +const Login = () => { + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + try { + const token = await login(name, password); + localStorage.setItem('token', token); + navigate('/'); + } catch (err) { + setError('Invalid credentials'); + } + }; + + return ( +
+

Sign in

+
+
+ + setName(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} +
+ +
+
+
+ ); +}; + +export default Login;