diff --git a/Dockerfile b/Dockerfile index 8b4402b..697c4e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ COPY Makefile ./ # Build the frontend COPY frontend frontend -RUN VITE_API_URL=/api npm run --prefix frontend build +RUN VITE_API_URL=/api VITE_OIDC_URL=/oidc npm run --prefix frontend build # Build backend COPY goshort.go ./ diff --git a/Makefile b/Makefile index b503c4e..c7f940b 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ install-backend: install: install-frontend install-backend frontend: - VITE_API_URL=/api npm run --prefix frontend build + VITE_API_URL=/api VITE_OIDC_URL=/oidc npm run --prefix frontend build VERSION=$(shell git describe --tags --abbrev=0) @@ -25,10 +25,10 @@ dev: go run goshort.go dev lint-frontend: - VITE_API_URL=/api npm run --prefix frontend lint + VITE_API_URL=/api VITE_OIDC_URL=/oidc npm run --prefix frontend lint lint-frontend-fix: - VITE_API_URL=/api npm run --prefix frontend lint:fix + VITE_API_URL=/api VITE_OIDC_URL=/oidc npm run --prefix frontend lint:fix lint-backend: golangci-lint run diff --git a/cmd/dev/dev.go b/cmd/dev/dev.go index f316643..fd54009 100644 --- a/cmd/dev/dev.go +++ b/cmd/dev/dev.go @@ -19,6 +19,7 @@ import ( oidcserver "git.maronato.dev/maronato/goshort/internal/server/oidc" shortserver "git.maronato.dev/maronato/goshort/internal/server/short" staticssterver "git.maronato.dev/maronato/goshort/internal/server/static" + configservice "git.maronato.dev/maronato/goshort/internal/service/config" oidcservice "git.maronato.dev/maronato/goshort/internal/service/oidc" shortservice "git.maronato.dev/maronato/goshort/internal/service/short" shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog" @@ -105,17 +106,18 @@ func serveAPI(ctx context.Context, cfg *config.Config) error { tokenService := tokenservice.NewTokenService(storage) shortLogService := shortlogservice.NewShortLogService(storage) oidcService := oidcservice.NewOIDCService(ctx, cfg) + configService := configservice.NewConfigService(cfg) // Start short log worker stopWorker, _ := shortLogService.StartWorker(ctx) defer stopWorker() // Create handlers - apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService, shortLogService) + apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService, shortLogService, configService) shortHandler := shortserver.NewShortHandler(shortService, shortLogService) healthcheckHandler := healthcheckserver.NewHealthcheckHandler(storage) docsHandler := staticssterver.NewStaticHandler(cfg, "/api/docs", docs.Assets()) - oidcHandler := oidcserver.NewOIDCHandler(oidcService, userService) + oidcHandler := oidcserver.NewOIDCHandler(cfg, oidcService, userService) // Create routers apiRouter := apiserver.NewAPIRouter(apiHandler) @@ -146,6 +148,7 @@ func serveAPI(ctx context.Context, cfg *config.Config) error { if oidcService != nil { srv.Mux.Mount("/oidc", oidcRouter) } + srv.Mux.Mount("/healthz", healthcheckRouter) srv.Mux.Mount("/", chainedRouter) diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 3c38f6e..a32ab45 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -17,6 +17,7 @@ import ( oidcserver "git.maronato.dev/maronato/goshort/internal/server/oidc" shortserver "git.maronato.dev/maronato/goshort/internal/server/short" staticssterver "git.maronato.dev/maronato/goshort/internal/server/static" + configservice "git.maronato.dev/maronato/goshort/internal/service/config" oidcservice "git.maronato.dev/maronato/goshort/internal/service/oidc" shortservice "git.maronato.dev/maronato/goshort/internal/service/short" shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog" @@ -78,18 +79,19 @@ func exec(ctx context.Context, cfg *config.Config) error { tokenService := tokenservice.NewTokenService(storage) shortLogService := shortlogservice.NewShortLogService(storage) oidcService := oidcservice.NewOIDCService(ctx, cfg) + configService := configservice.NewConfigService(cfg) // Start short log worker stopWorker, _ := shortLogService.StartWorker(ctx) defer stopWorker() // Create handlers - apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService, shortLogService) + apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService, shortLogService, configService) shortHandler := shortserver.NewShortHandler(shortService, shortLogService) staticHandler := staticssterver.NewStaticHandler(cfg, "/", frontend.Assets()) healthcheckHandler := healthcheckserver.NewHealthcheckHandler(storage) docsHandler := staticssterver.NewStaticHandler(cfg, "/api/docs", docs.Assets()) - oidcHandler := oidcserver.NewOIDCHandler(oidcService, userService) + oidcHandler := oidcserver.NewOIDCHandler(cfg, oidcService, userService) // Create routers apiRouter := apiserver.NewAPIRouter(apiHandler) diff --git a/cmd/shared/shared.go b/cmd/shared/shared.go index 0cf05e8..3f24fed 100644 --- a/cmd/shared/shared.go +++ b/cmd/shared/shared.go @@ -71,6 +71,7 @@ func RegisterServerFlags(fs *flag.FlagSet, cfg *config.Config) { fs.StringVar(&cfg.OIDCClientID, "oidc-client-id", "", "OIDC client ID") fs.StringVar(&cfg.OIDCClientSecret, "oidc-client-secret", "", "OIDC client secret") fs.StringVar(&cfg.OIDCRedirectURL, "oidc-redirect-url", "", "OIDC redirect URL") + fs.StringVar(&cfg.OIDCIssuerName, "oidc-issuer-name", config.DefaultOIDCIssuerName, "OIDC issuer name") } // InitStorage initializes the storage depending on the config. diff --git a/frontend/src/components/UserForm.tsx b/frontend/src/components/UserForm.tsx index e2b1c42..a1e324b 100644 --- a/frontend/src/components/UserForm.tsx +++ b/frontend/src/components/UserForm.tsx @@ -8,6 +8,8 @@ import { useSearchParams, } from "react-router-dom" +import { useServerConfig } from "../hooks/useServerConfig" + import Button from "./Button" const actionOptions = { @@ -102,6 +104,7 @@ const UserForm: FunctionComponent<{ }> = ({ action }) => { const [params] = useSearchParams() const from = params.get("from") || "/" + const serverConfig = useServerConfig() const opts = actionOptions[action] @@ -128,89 +131,99 @@ const UserForm: FunctionComponent<{ const passwordScore = useMemo(() => passwordOMeter(password), [password]) return ( -
+
{opts.title} - {actionData && actionData.error ? ( -

- {actionData.error} -

- ) : null} - -
- - -
-
- - + {serverConfig.disableCredentialLogin ? null : ( + + {actionData && actionData.error ? ( +

+ {actionData.error} +

+ ) : null} + +
+ + +
+
+ + - {action === "login" ? null : ( - <> - -
- Password must be: -
    -
  • At least 8 characters long
  • -
-
- - )} -
- - - {opts.altLabel + " "} - - {opts.altLinkText} - - - + {action === "login" ? null : ( + <> + +
+ Password must be: +
    +
  • At least 8 characters long
  • +
+
+ + )} +
+ + + )} + {serverConfig?.oidcIssuerUrl.length && ( + + + + )} + {serverConfig.disableRegistration && action === "login" ? null : ( + + {opts.altLabel + " "} + + {opts.altLinkText} + + + )} +
) } diff --git a/frontend/src/hooks/useServerConfig.tsx b/frontend/src/hooks/useServerConfig.tsx new file mode 100644 index 0000000..0078384 --- /dev/null +++ b/frontend/src/hooks/useServerConfig.tsx @@ -0,0 +1,50 @@ +import { + createContext, + useState, + useEffect, + useContext, + FC, + ReactNode, +} from "react" + +import fetchAPI from "../util/fetchAPI" + +// These configs mirror their server-side counterparts + +export type ServerConfig = { + disableRegistration: boolean + disableCredentialLogin: boolean + oidcIssuerUrl: string + oidcIssuerName: string +} + +const defaultConfig: ServerConfig = { + disableRegistration: false, + disableCredentialLogin: false, + oidcIssuerUrl: "", + oidcIssuerName: "", +} + +const serverContext = createContext(defaultConfig) + +export const useServerConfig = () => { + return useContext(serverContext) +} + +export const ServerConfigProvider: FC<{ + children?: ReactNode +}> = ({ children }) => { + const [config, setConfig] = useState(defaultConfig) + + useEffect(() => { + fetchAPI("/config").then((result) => { + if (result.ok) { + setConfig(result.data) + } + }) + }, []) + + return ( + {children} + ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 13e2508..e03c1dc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { StrictMode } from "react" import { createRoot } from "react-dom/client" import { RouterProvider } from "react-router-dom" +import { ServerConfigProvider } from "./hooks/useServerConfig.tsx" import "./index.css" import router from "./router.tsx" @@ -12,6 +13,8 @@ if (!rootEl) throw new Error("Root element not found") createRoot(rootEl).render( - + + + ) diff --git a/internal/config/config.go b/internal/config/config.go index 8d0c834..296a427 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,6 +56,8 @@ const ( DefaultQuiet = false // DefaultDisableCredentialsLogin is the default value for diable credential login. DefaultDisableCredentialsLogin = false + // DefaultOIDCIssuerName is the default name of the OIDC issuer. + DefaultOIDCIssuerName = "OpenID" ) const ( @@ -103,7 +105,9 @@ type Config struct { // DisableCredentialsLogin defines whether or not the server should disable credential login. DisableCredentialsLogin bool `json:"disableCredentialLogin"` // OIDCIssuerURL is the URL of the OIDC issuer. - OIDCIssuerURL string + OIDCIssuerURL string `json:"oidcIssuerUrl"` + // OIDCIssueName is the name of the OIDC issuer. + OIDCIssuerName string // OIDCClientID is the client ID for OIDC. OIDCClientID string // OIDCClientSecret is the client secret for OIDC. @@ -126,6 +130,7 @@ func NewConfig() *Config { Verbose: DefaultVerbose, Quiet: DefaultQuiet, DisableCredentialsLogin: DefaultDisableCredentialsLogin, + OIDCIssuerName: DefaultOIDCIssuerName, } } diff --git a/internal/server/api/handler.go b/internal/server/api/handler.go index 41e2a14..151cc22 100644 --- a/internal/server/api/handler.go +++ b/internal/server/api/handler.go @@ -11,6 +11,7 @@ import ( "git.maronato.dev/maronato/goshort/internal/errs" "git.maronato.dev/maronato/goshort/internal/server" authmiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware/auth" + configservice "git.maronato.dev/maronato/goshort/internal/service/config" shortservice "git.maronato.dev/maronato/goshort/internal/service/short" shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog" tokenservice "git.maronato.dev/maronato/goshort/internal/service/token" @@ -27,6 +28,7 @@ type APIHandler struct { users *userservice.UserService tokens *tokenservice.TokenService shortLogs *shortlogservice.ShortLogService + config *configservice.ConfigService } func NewAPIHandler( @@ -34,12 +36,14 @@ func NewAPIHandler( users *userservice.UserService, tokens *tokenservice.TokenService, shortLogs *shortlogservice.ShortLogService, + config *configservice.ConfigService, ) *APIHandler { return &APIHandler{ shorts: shorts, users: users, tokens: tokens, shortLogs: shortLogs, + config: config, } } @@ -171,6 +175,14 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) { l := logging.FromCtx(ctx) + if h.config.GetPublicConfig().DisableCredentialsLogin { + l.Debug("credentials registration is disabled") + + server.RenderForbidden(w, r) + + return + } + // Get the user from the json body type signupForm struct { models.User @@ -578,6 +590,21 @@ func (h *APIHandler) DeleteToken(w http.ResponseWriter, r *http.Request) { render.NoContent(w, r) } +// PublicConfig returns the public configuration of the server. +func (h *APIHandler) PublicConfig(w http.ResponseWriter, r *http.Request) { + _, span := tracing.StartSpan(r.Context(), "api.PublicConfig") + defer span.End() + + // Get public config + config := h.config.GetPublicConfig() + + span.AddEvent("got public config") + + // Render the response + render.Status(r, http.StatusOK) + render.JSON(w, r, config) +} + // findUserOrRespond is a helper function that finds a user in the session, // and returns it. If the user is not found, it returns nil and false. func (h *APIHandler) findUserOrRespond(w http.ResponseWriter, r *http.Request) (user *models.User, ok bool) { diff --git a/internal/server/api/handler_test.go b/internal/server/api/handler_test.go index 2e9cb28..853356d 100644 --- a/internal/server/api/handler_test.go +++ b/internal/server/api/handler_test.go @@ -14,6 +14,7 @@ import ( apiserver "git.maronato.dev/maronato/goshort/internal/server/api" servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware" authmiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware/auth" + configservice "git.maronato.dev/maronato/goshort/internal/service/config" shortservice "git.maronato.dev/maronato/goshort/internal/service/short" shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog" tokenservice "git.maronato.dev/maronato/goshort/internal/service/token" @@ -62,8 +63,9 @@ func setup(ctx context.Context, cfg *config.Config) ( users := userservice.NewUserService(cfg, str) tokens := tokenservice.NewTokenService(str) shortLogs := shortlogservice.NewShortLogService(str) + cfgService := configservice.NewConfigService(cfg) - api := apiserver.NewAPIHandler(shorts, users, tokens, shortLogs) + api := apiserver.NewAPIHandler(shorts, users, tokens, shortLogs, cfgService) return api, shorts, users, tokens, shortLogs } diff --git a/internal/server/api/router.go b/internal/server/api/router.go index 61c3d2e..ef6adc4 100644 --- a/internal/server/api/router.go +++ b/internal/server/api/router.go @@ -15,6 +15,9 @@ func NewAPIRouter(handler *APIHandler) http.Handler { mux.Post("/logout", handler.Logout) mux.Post("/signup", handler.Signup) + // Public routes + mux.Get("/config", handler.PublicConfig) + // Authenticated routes mux.Group(func(r chi.Router) { // UI and API endpoints diff --git a/internal/server/devui/server.go b/internal/server/devui/server.go index 94e8097..cd7f721 100644 --- a/internal/server/devui/server.go +++ b/internal/server/devui/server.go @@ -18,6 +18,8 @@ import ( type Server struct { // apiUrl is API server's URL apiURL string + // oidcURL is OIDC server's URL + oidcURL string // uiPort is the port the UI server will listen on uiPort string // host is the host the UI server will listen on @@ -33,12 +35,18 @@ func NewServer(cfg *config.Config) *Server { Scheme: "http", Path: "/api", } + oidcURL := url.URL{ + Host: net.JoinHostPort(cfg.Host, cfg.Port), + Scheme: "http", + Path: "/oidc", + } return &Server{ - apiURL: apiURL.String(), - uiPort: cfg.UIPort, - host: cfg.Host, - cancel: func() {}, + apiURL: apiURL.String(), + oidcURL: oidcURL.String(), + uiPort: cfg.UIPort, + host: cfg.Host, + cancel: func() {}, } } @@ -86,7 +94,7 @@ func (s *Server) Start(ctx context.Context) error { } // Set the API_URL env var - cmd.Env = append(os.Environ(), "VITE_API_URL="+s.apiURL) + cmd.Env = append(os.Environ(), "VITE_API_URL="+s.apiURL, "VITE_OIDC_URL="+s.oidcURL) // Use the current process's stdout cmd.Stdout = os.Stdout diff --git a/internal/server/oidc/handler.go b/internal/server/oidc/handler.go index 864f34b..6ff65ee 100644 --- a/internal/server/oidc/handler.go +++ b/internal/server/oidc/handler.go @@ -2,8 +2,11 @@ package oidcserver import ( "errors" + "net" "net/http" + "net/url" + "git.maronato.dev/maronato/goshort/internal/config" "git.maronato.dev/maronato/goshort/internal/errs" "git.maronato.dev/maronato/goshort/internal/server" authmiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware/auth" @@ -17,14 +20,18 @@ import ( ) type OIDCHandler struct { - oidc *oidcservice.OIDCService - user *userservice.UserService + oidc *oidcservice.OIDCService + user *userservice.UserService + uiHost string } -func NewOIDCHandler(oidc *oidcservice.OIDCService, user *userservice.UserService) *OIDCHandler { +func NewOIDCHandler(cfg *config.Config, oidc *oidcservice.OIDCService, user *userservice.UserService) *OIDCHandler { + uiHost := net.JoinHostPort(cfg.Host, cfg.UIPort) + return &OIDCHandler{ - oidc: oidc, - user: user, + oidc: oidc, + user: user, + uiHost: uiHost, } } @@ -80,7 +87,6 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { user.SetNoLoginPassword() user, err = h.user.CreateUser(ctx, user) - // Handle errors if err != nil { switch { @@ -115,5 +121,10 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { span.AddEvent("logged in user") // Redirect to the home page - http.Redirect(w, r, "/", http.StatusFound) + uiURL := url.URL{ + Scheme: r.URL.Scheme, + Host: h.uiHost, + Path: "/", + } + http.Redirect(w, r, uiURL.String(), http.StatusFound) } diff --git a/internal/service/config/configservice.go b/internal/service/config/configservice.go new file mode 100644 index 0000000..f815738 --- /dev/null +++ b/internal/service/config/configservice.go @@ -0,0 +1,36 @@ +package configservice + +import "git.maronato.dev/maronato/goshort/internal/config" + +type PublicConfig struct { + // DisableRegistration defines whether or not registration are disabled. + DisableRegistration bool `json:"disableRegistration"` + // DisableCredentialsLogin defines whether or not the server should disable credential login. + DisableCredentialsLogin bool `json:"disableCredentialLogin"` + // OIDCIssuerURL is the URL of the OIDC issuer. + OIDCIssuerURL string `json:"oidcIssuerUrl"` + // OIDCIssuerName is the name of the OIDC issuer. + OIDCIssuerName string `json:"oidcIssuerName"` +} + +// ConfigService is the service that handles the configuration. +type ConfigService struct { + cfg *config.Config +} + +// NewConfigService creates a new configuration service. +func NewConfigService(cfg *config.Config) *ConfigService { + return &ConfigService{ + cfg: cfg, + } +} + +// GetPublicConfigJSON returns the public configuration as a JSON string. +func (s *ConfigService) GetPublicConfig() *PublicConfig { + return &PublicConfig{ + DisableRegistration: s.cfg.DisableRegistration, + DisableCredentialsLogin: s.cfg.DisableCredentialsLogin, + OIDCIssuerURL: s.cfg.OIDCIssuerURL, + OIDCIssuerName: s.cfg.OIDCIssuerName, + } +}