diff --git a/README.md b/README.md index 5b29e61..6129b71 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,25 @@ However, if it fails to load, you can set these environment variables instead: - `S3_REGION`: S3 Region. - `S3_ENDPOINT_URL`: S3 Endpoint url. +### Authentication + +Enable authentication by setting `AUTH_USER_PASS` environment variable. Generate the username and password hash using the following command: + +```bash +htpasswd -nbBC 10 "YOUR_USERNAME" "YOUR_PASSWORD" +``` + +> If command 'htpasswd' is not found, install `apache2-utils` using your package manager. + +Then update your `docker-compose.yml`: + +```yml +webui: + .... + environment: + AUTH_USER_PASS: "username:$2y$10$DSTi9o..." +``` + ### Running Once your instance of Garage Web UI is started, you can open the web UI at http://your-ip:3909. You can place it behind a reverse proxy to secure it with SSL. diff --git a/backend/go.mod b/backend/go.mod index d6afbe0..0423efc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,8 @@ module khairul169/garage-webui -go 1.22.5 +go 1.23.0 + +toolchain go1.24.0 require ( github.com/aws/aws-sdk-go-v2 v1.30.4 @@ -13,6 +15,7 @@ require ( ) require ( + github.com/alexedwards/scs/v2 v2.8.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect @@ -21,4 +24,5 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect + golang.org/x/crypto v0.35.0 ) diff --git a/backend/go.sum b/backend/go.sum index 8a8a6b7..ece54ae 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= +github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 h1:70PVAiL15/aBMh5LThwgXdSQorVr91L127ttckI9QQU= @@ -42,6 +44,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/main.go b/backend/main.go index da5deef..6ae2332 100644 --- a/backend/main.go +++ b/backend/main.go @@ -12,15 +12,18 @@ import ( ) func main() { + // Initialize app godotenv.Load() utils.InitCacheManager() + sessionMgr := utils.InitSessionManager() if err := utils.Garage.LoadConfig(); err != nil { log.Println("Cannot load garage config!", err) } - http.Handle("/api/", http.StripPrefix("/api", router.HandleApiRouter())) - ui.ServeUI() + mux := http.NewServeMux() + mux.Handle("/api/", http.StripPrefix("/api", router.HandleApiRouter())) + ui.ServeUI(mux) host := utils.GetEnv("HOST", "0.0.0.0") port := utils.GetEnv("PORT", "3909") @@ -28,7 +31,7 @@ func main() { addr := fmt.Sprintf("%s:%s", host, port) log.Printf("Starting server on http://%s", addr) - if err := http.ListenAndServe(addr, nil); err != nil { + if err := http.ListenAndServe(addr, sessionMgr.LoadAndSave(mux)); err != nil { log.Fatal(err) } } diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go new file mode 100644 index 0000000..9c8bbc1 --- /dev/null +++ b/backend/middleware/auth.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "errors" + "khairul169/garage-webui/utils" + "net/http" +) + +func AuthMiddleware(next http.Handler) http.Handler { + authData := utils.GetEnv("AUTH_USER_PASS", "") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := utils.Session.Get(r, "authenticated") + + if authData == "" { + next.ServeHTTP(w, r) + return + } + + if auth == nil || !auth.(bool) { + utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/router/auth.go b/backend/router/auth.go new file mode 100644 index 0000000..9c425ab --- /dev/null +++ b/backend/router/auth.go @@ -0,0 +1,64 @@ +package router + +import ( + "encoding/json" + "errors" + "khairul169/garage-webui/utils" + "net/http" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +type Auth struct{} + +func (c *Auth) Login(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.ResponseError(w, err) + return + } + + userPass := strings.Split(utils.GetEnv("AUTH_USER_PASS", ""), ":") + if len(userPass) < 2 { + utils.ResponseErrorStatus(w, errors.New("AUTH_USER_PASS not set"), 500) + return + } + + if strings.TrimSpace(body.Username) != userPass[0] || bcrypt.CompareHashAndPassword([]byte(userPass[1]), []byte(body.Password)) != nil { + utils.ResponseErrorStatus(w, errors.New("invalid username or password"), 401) + return + } + + utils.Session.Set(r, "authenticated", true) + utils.ResponseSuccess(w, map[string]bool{ + "authenticated": true, + }) +} + +func (c *Auth) Logout(w http.ResponseWriter, r *http.Request) { + utils.Session.Clear(r) + utils.ResponseSuccess(w, true) +} + +func (c *Auth) GetStatus(w http.ResponseWriter, r *http.Request) { + isAuthenticated := true + authSession := utils.Session.Get(r, "authenticated") + enabled := false + + if utils.GetEnv("AUTH_USER_PASS", "") != "" { + enabled = true + } + + if authSession != nil && authSession.(bool) { + isAuthenticated = true + } + + utils.ResponseSuccess(w, map[string]bool{ + "enabled": enabled, + "authenticated": isAuthenticated, + }) +} diff --git a/backend/router/router.go b/backend/router/router.go index e295620..1cf3134 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -1,9 +1,19 @@ package router -import "net/http" +import ( + "khairul169/garage-webui/middleware" + "net/http" +) func HandleApiRouter() *http.ServeMux { + mux := http.NewServeMux() + + auth := &Auth{} + mux.HandleFunc("POST /auth/login", auth.Login) + router := http.NewServeMux() + router.HandleFunc("POST /auth/logout", auth.Logout) + router.HandleFunc("GET /auth/status", auth.GetStatus) config := &Config{} router.HandleFunc("GET /config", config.GetAll) @@ -17,7 +27,9 @@ func HandleApiRouter() *http.ServeMux { router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject) router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject) + // Proxy request to garage api endpoint router.HandleFunc("/", ProxyHandler) - return router + mux.Handle("/", middleware.AuthMiddleware(router)) + return mux } diff --git a/backend/ui/ui.go b/backend/ui/ui.go index ab0f361..6a98d1f 100644 --- a/backend/ui/ui.go +++ b/backend/ui/ui.go @@ -3,4 +3,6 @@ package ui -func ServeUI() {} +import "net/http" + +func ServeUI(mux *http.ServeMux) {} diff --git a/backend/ui/ui_prod.go b/backend/ui/ui_prod.go index 7db5330..d0f0d52 100644 --- a/backend/ui/ui_prod.go +++ b/backend/ui/ui_prod.go @@ -13,11 +13,11 @@ import ( //go:embed dist var embeddedFs embed.FS -func ServeUI() { +func ServeUI(mux *http.ServeMux) { distFs, _ := fs.Sub(embeddedFs, "dist") fileServer := http.FileServer(http.FS(distFs)) - http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _path := path.Clean(r.URL.Path)[1:] // Rewrite non-existing paths to index.html diff --git a/backend/utils/session.go b/backend/utils/session.go new file mode 100644 index 0000000..00d4832 --- /dev/null +++ b/backend/utils/session.go @@ -0,0 +1,33 @@ +package utils + +import ( + "net/http" + "time" + + "github.com/alexedwards/scs/v2" +) + +type SessionManager struct { + mgr *scs.SessionManager +} + +var Session *SessionManager + +func InitSessionManager() *scs.SessionManager { + sessMgr := scs.New() + sessMgr.Lifetime = 24 * time.Hour + Session = &SessionManager{mgr: sessMgr} + return sessMgr +} + +func (s *SessionManager) Get(r *http.Request, key string) interface{} { + return s.mgr.Get(r.Context(), key) +} + +func (s *SessionManager) Set(r *http.Request, key string, value interface{}) { + s.mgr.Put(r.Context(), key, value) +} + +func (s *SessionManager) Clear(r *http.Request) error { + return s.mgr.Clear(r.Context()) +} diff --git a/package.json b/package.json index 64b3de5..8847340 100644 --- a/package.json +++ b/package.json @@ -47,5 +47,11 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.0", "vite": "^5.4.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@swc/core", + "esbuild" + ] } } diff --git a/src/app/router.tsx b/src/app/router.tsx index ba87390..e8b35d9 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,8 +1,9 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom"; -import { lazy } from "react"; +import { lazy, Suspense } from "react"; import AuthLayout from "@/components/layouts/auth-layout"; import MainLayout from "@/components/layouts/main-layout"; +const LoginPage = lazy(() => import("@/pages/auth/login")); const ClusterPage = lazy(() => import("@/pages/cluster/page")); const HomePage = lazy(() => import("@/pages/home/page")); const BucketsPage = lazy(() => import("@/pages/buckets/page")); @@ -13,6 +14,12 @@ const router = createBrowserRouter([ { path: "/auth", Component: AuthLayout, + children: [ + { + path: "login", + Component: LoginPage, + }, + ], }, { path: "/", @@ -42,7 +49,11 @@ const router = createBrowserRouter([ ]); const Router = () => { - return ; + return ( + + + + ); }; export default Router; diff --git a/src/components/containers/sidebar.tsx b/src/components/containers/sidebar.tsx index 7b03f40..b2fc163 100644 --- a/src/components/containers/sidebar.tsx +++ b/src/components/containers/sidebar.tsx @@ -4,6 +4,7 @@ import { HardDrive, KeySquare, LayoutDashboard, + LogOut, Palette, } from "lucide-react"; import { Dropdown, Menu } from "react-daisyui"; @@ -12,6 +13,10 @@ import Button from "../ui/button"; import { themes } from "@/app/themes"; import appStore from "@/stores/app-store"; import garageLogo from "@/assets/garage-logo.svg"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import api from "@/lib/api"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; const pages = [ { icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true }, @@ -22,6 +27,7 @@ const pages = [ const Sidebar = () => { const { pathname } = useLocation(); + const auth = useAuth(); return ( ); }; +const LogoutButton = () => { + const queryClient = useQueryClient(); + + const logout = useMutation({ + mutationFn: () => api.post("/auth/logout"), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + onError: (err) => { + toast.error(err?.message || "Unknown error"); + }, + }); + + return ( + + ); +}; + export default Sidebar; diff --git a/src/components/layouts/auth-layout.tsx b/src/components/layouts/auth-layout.tsx index 12f6398..18547e0 100644 --- a/src/components/layouts/auth-layout.tsx +++ b/src/components/layouts/auth-layout.tsx @@ -1,5 +1,22 @@ +import { useAuth } from "@/hooks/useAuth"; +import { Navigate, Outlet } from "react-router-dom"; + const AuthLayout = () => { - return
AuthLayout
; + const auth = useAuth(); + + if (auth.isLoading) { + return null; + } + + if (auth.isAuthenticated) { + return ; + } + + return ( +
+ +
+ ); }; export default AuthLayout; diff --git a/src/components/layouts/main-layout.tsx b/src/components/layouts/main-layout.tsx index dd2aa2c..5f5c261 100644 --- a/src/components/layouts/main-layout.tsx +++ b/src/components/layouts/main-layout.tsx @@ -1,15 +1,17 @@ import { PageContext } from "@/context/page-context"; import { Suspense, useContext, useEffect } from "react"; -import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom"; import Sidebar from "../containers/sidebar"; import { ArrowLeft, MenuIcon } from "lucide-react"; import Button from "../ui/button"; import { useDisclosure } from "@/hooks/useDisclosure"; import { Drawer } from "react-daisyui"; +import { useAuth } from "@/hooks/useAuth"; const MainLayout = () => { const sidebar = useDisclosure(); const { pathname } = useLocation(); + const auth = useAuth(); useEffect(() => { if (sidebar.isOpen) { @@ -17,6 +19,14 @@ const MainLayout = () => { } }, [pathname]); + if (auth.isLoading) { + return null; + } + + if (!auth.isAuthenticated) { + return ; + } + return ( & { + type?: HTMLButtonElement["type"]; href?: string; target?: "_blank" | "_self" | "_parent" | "_top"; icon?: LucideIcon; diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..5525c77 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,20 @@ +import api from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; + +type AuthResponse = { + enabled: boolean; + authenticated: boolean; +}; + +export const useAuth = () => { + const { data, isLoading } = useQuery({ + queryKey: ["auth"], + queryFn: () => api.get("/auth/status"), + retry: false, + }); + return { + isLoading, + isEnabled: data?.enabled, + isAuthenticated: data?.authenticated, + }; +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index 10fa540..4259944 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -27,6 +27,7 @@ const api = { const res = await fetch(_url, { ...options, + credentials: "include", headers: { ...headers, ...(options?.headers || {}) }, }); diff --git a/src/pages/auth/hooks.ts b/src/pages/auth/hooks.ts new file mode 100644 index 0000000..c6a1de2 --- /dev/null +++ b/src/pages/auth/hooks.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { loginSchema } from "./schema"; +import api from "@/lib/api"; +import { toast } from "sonner"; + +export const useLogin = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body: z.infer) => { + return api.post("/auth/login", { body }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + onError: (err) => { + toast.error(err?.message || "Unknown error"); + }, + }); +}; diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx new file mode 100644 index 0000000..2a84a0f --- /dev/null +++ b/src/pages/auth/login.tsx @@ -0,0 +1,54 @@ +import Button from "@/components/ui/button"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Card } from "react-daisyui"; +import { useForm } from "react-hook-form"; +import { loginSchema } from "./schema"; +import { InputField } from "@/components/ui/input"; +import { useLogin } from "./hooks"; + +export default function LoginPage() { + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { username: "", password: "" }, + }); + const login = useLogin(); + + return ( +
login.mutate(v))}> + + + Login +

+ Enter username and password below to log in to your account +

+ + + + + + + + +
+
+
+ ); +} diff --git a/src/pages/auth/schema.ts b/src/pages/auth/schema.ts new file mode 100644 index 0000000..225705c --- /dev/null +++ b/src/pages/auth/schema.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), +});