added frontend oidc support
Some checks failed
Check / checks (push) Failing after 4m11s

This commit is contained in:
Gustavo Maronato 2024-03-09 05:42:36 -05:00
parent 25a37ba90d
commit 4fef573447
No known key found for this signature in database
15 changed files with 268 additions and 104 deletions

View File

@ -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 ./

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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.

View File

@ -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 (
<Form
method="post"
replace
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8 max-w-min">
<div className="flex flex-col mx-auto rounded-lg shadow-md p-8 max-w-md gap-y-6">
<span className="text-3xl font-bold text-center mb-4">{opts.title}</span>
{actionData && actionData.error ? (
<p className="text-red-500 text-center font-medium">
{actionData.error}
</p>
) : null}
<input type="hidden" name="redirectTo" value={from} />
<section className="flex flex-col">
<label
htmlFor="email"
className="font-medium text-slate-600 -mb-2 z-10 text-lg">
Email
</label>
<input
autoFocus
id="email"
name="username"
autoComplete="username"
type="email"
required
placeholder=" "
minLength={4}
maxLength={128}
className="p-1 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</section>
<section className="flex flex-col">
<label
htmlFor="password"
className="font-medium text-slate-600 -mb-2 z-10 text-lg">
Password
</label>
<input
id={opts.passAutoComplete}
name="password"
autoComplete={opts.passAutoComplete}
aria-describedby={
action === "login" ? undefined : "password-constraints"
}
type="password"
placeholder=" "
onChange={onPasswordChange}
minLength={8}
maxLength={128}
required
className="mb-1 p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
{serverConfig.disableCredentialLogin ? null : (
<Form method="post" replace className="flex flex-col gap-y-2">
{actionData && actionData.error ? (
<p className="text-red-500 text-center font-medium">
{actionData.error}
</p>
) : null}
<input type="hidden" name="redirectTo" value={from} />
<section className="flex flex-col">
<label
htmlFor="email"
className="font-medium text-slate-600 -mb-2 z-10 text-lg">
Email
</label>
<input
autoFocus
id="email"
name="username"
autoComplete="username"
type="email"
required
placeholder=" "
minLength={4}
maxLength={128}
className="p-1 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</section>
<section className="flex flex-col">
<label
htmlFor="password"
className="font-medium text-slate-600 -mb-2 z-10 text-lg">
Password
</label>
<input
id={opts.passAutoComplete}
name="password"
autoComplete={opts.passAutoComplete}
aria-describedby={
action === "login" ? undefined : "password-constraints"
}
type="password"
placeholder=" "
onChange={onPasswordChange}
minLength={8}
maxLength={128}
required
className="mb-1 p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
{action === "login" ? null : (
<>
<PassScore score={passwordScore} />
<div
id="password-constraints"
className="mt-2 text-sm text-slate-500">
Password must be:
<ul className="list-disc list-inside">
<li className="list-item">At least 8 characters long</li>
</ul>
</div>
</>
)}
</section>
<Button
id={opts.buttonID}
type="submit"
color="blue"
disabled={isLoading || !canSubmit}
className="mt-6 px-8 py-3 max-w-fit mx-auto">
{isLoading ? opts.buttonLoadingText : opts.buttonText}
</Button>
<span className="text-slate-500 font-light text-center text-sm">
{opts.altLabel + " "}
<Link
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
to={altLink}>
{opts.altLinkText}
</Link>
</span>
</Form>
{action === "login" ? null : (
<>
<PassScore score={passwordScore} />
<div
id="password-constraints"
className="mt-2 text-sm text-slate-500">
Password must be:
<ul className="list-disc list-inside">
<li className="list-item">At least 8 characters long</li>
</ul>
</div>
</>
)}
</section>
<Button
id={opts.buttonID}
type="submit"
color="blue"
disabled={isLoading || !canSubmit}
className="mt-6 px-8 py-3 max-w-fit mx-auto">
{isLoading ? opts.buttonLoadingText : opts.buttonText}
</Button>
</Form>
)}
{serverConfig?.oidcIssuerUrl.length && (
<a href={`${import.meta.env.VITE_OIDC_URL}/redirect`}>
<Button color="green" className="px-8 py-3 max-w-fit mx-auto">
{`${opts.buttonText} with ${serverConfig.oidcIssuerName}`}
</Button>
</a>
)}
{serverConfig.disableRegistration && action === "login" ? null : (
<span className="text-slate-500 font-light text-center text-sm">
{opts.altLabel + " "}
<Link
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
to={altLink}>
{opts.altLinkText}
</Link>
</span>
)}
</div>
)
}

View File

@ -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<ServerConfig>(defaultConfig)
export const useServerConfig = () => {
return useContext(serverContext)
}
export const ServerConfigProvider: FC<{
children?: ReactNode
}> = ({ children }) => {
const [config, setConfig] = useState<ServerConfig>(defaultConfig)
useEffect(() => {
fetchAPI<ServerConfig>("/config").then((result) => {
if (result.ok) {
setConfig(result.data)
}
})
}, [])
return (
<serverContext.Provider value={config}>{children}</serverContext.Provider>
)
}

View File

@ -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(
<StrictMode>
<RouterProvider router={router} />
<ServerConfigProvider>
<RouterProvider router={router} />
</ServerConfigProvider>
</StrictMode>
)

View File

@ -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,
}
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -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,
}
}