From bbf98789632bac53ed7a1e472d6baca8d446ec59 Mon Sep 17 00:00:00 2001 From: Igor Rzegocki Date: Sat, 6 Apr 2024 23:13:45 +0200 Subject: [PATCH] feat(auth): support for forwarded auth provider --- backend/app/api/handlers/v1/controller.go | 59 +++++++++----- backend/app/api/providers/forwardauth.go | 36 +++++++++ backend/app/api/routes.go | 3 + .../internal/core/services/service_user.go | 24 ++++++ backend/internal/sys/config/conf.go | 7 ++ backend/pkgs/ipcheck/ipcheck.go | 37 +++++++++ backend/pkgs/ipcheck/ipcheck_test.go | 54 +++++++++++++ docs/docs/api/openapi-2.0.json | 5 +- docs/docs/quick-start.md | 81 ++++++++++--------- frontend/composables/use-auth-context.ts | 4 +- frontend/lib/api/public.ts | 4 +- frontend/pages/index.vue | 13 +++ 12 files changed, 266 insertions(+), 61 deletions(-) create mode 100644 backend/app/api/providers/forwardauth.go create mode 100644 backend/pkgs/ipcheck/ipcheck.go create mode 100644 backend/pkgs/ipcheck/ipcheck_test.go diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index eb60212..26e3c48 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -10,6 +10,7 @@ import ( "github.com/hay-kot/homebox/backend/internal/core/services" "github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus" "github.com/hay-kot/homebox/backend/internal/data/repo" + "github.com/hay-kot/homebox/backend/pkgs/ipcheck" "github.com/hay-kot/httpkit/errchain" "github.com/hay-kot/httpkit/server" "github.com/rs/zerolog/log" @@ -57,14 +58,28 @@ func WithSecureCookies(secure bool) func(*V1Controller) { } } +func WithForwardAuthHeader(forwardAuthHeader string) func(*V1Controller) { + return func(ctrl *V1Controller) { + ctrl.forwardAuthHeader = forwardAuthHeader + } +} + +func WithForwardAuthAllowedIps(forwardAuthAllowedIps string) func(*V1Controller) { + return func(ctrl *V1Controller) { + ctrl.forwardAuthAllowedIps = forwardAuthAllowedIps + } +} + type V1Controller struct { - cookieSecure bool - repo *repo.AllRepos - svc *services.AllServices - maxUploadSize int64 - isDemo bool - allowRegistration bool - bus *eventbus.EventBus + cookieSecure bool + repo *repo.AllRepos + svc *services.AllServices + maxUploadSize int64 + isDemo bool + allowRegistration bool + bus *eventbus.EventBus + forwardAuthHeader string + forwardAuthAllowedIps string } type ( @@ -77,13 +92,14 @@ type ( } APISummary struct { - Healthy bool `json:"health"` - Versions []string `json:"versions"` - Title string `json:"title"` - Message string `json:"message"` - Build Build `json:"build"` - Demo bool `json:"demo"` - AllowRegistration bool `json:"allowRegistration"` + Healthy bool `json:"health"` + Versions []string `json:"versions"` + Title string `json:"title"` + Message string `json:"message"` + Build Build `json:"build"` + Demo bool `json:"demo"` + AllowRegistration bool `json:"allowRegistration"` + ForwardAuthAvailable bool `json:"forwardAuthAvailable"` } ) @@ -117,13 +133,16 @@ func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, bus *event // @Router /v1/status [GET] func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) errchain.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { + forwardAuthAvailable := r.Header.Get(ctrl.forwardAuthHeader) != "" && ipcheck.ValidateAgainstList(r.RemoteAddr, ctrl.forwardAuthAllowedIps) + return server.JSON(w, http.StatusOK, APISummary{ - Healthy: ready(), - Title: "Homebox", - Message: "Track, Manage, and Organize your Things", - Build: build, - Demo: ctrl.isDemo, - AllowRegistration: ctrl.allowRegistration, + Healthy: ready(), + Title: "Homebox", + Message: "Track, Manage, and Organize your Things", + Build: build, + Demo: ctrl.isDemo, + AllowRegistration: ctrl.allowRegistration, + ForwardAuthAvailable: forwardAuthAvailable, }) } } diff --git a/backend/app/api/providers/forwardauth.go b/backend/app/api/providers/forwardauth.go new file mode 100644 index 0000000..233f494 --- /dev/null +++ b/backend/app/api/providers/forwardauth.go @@ -0,0 +1,36 @@ +package providers + +import ( + "errors" + "net/http" + + "github.com/hay-kot/homebox/backend/internal/core/services" + "github.com/hay-kot/homebox/backend/internal/sys/config" + "github.com/hay-kot/homebox/backend/pkgs/ipcheck" +) + +type ForwardAuthProvider struct { + service *services.UserService + authConfig *config.AuthConfig +} + +func NewForwardAuthProvider(service *services.UserService, authConfig *config.AuthConfig) *ForwardAuthProvider { + return &ForwardAuthProvider{ + service: service, + authConfig: authConfig, + } +} + +func (p *ForwardAuthProvider) Name() string { + return "forwardauth" +} + +func (p *ForwardAuthProvider) Authenticate(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) { + if !ipcheck.ValidateAgainstList(r.RemoteAddr, p.authConfig.ForwardAuthAllowedIps) { + return services.UserAuthTokenDetail{}, errors.New("forward authentication denied, IP address not allowed") + } + + username := r.Header.Get(p.authConfig.ForwardAuthHeader) + + return p.service.PasswordlessLogin(r.Context(), username, p.authConfig.ForwardAuthAutoRegister) +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index de10942..7f61ff8 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -56,6 +56,8 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize), v1.WithRegistration(a.conf.Options.AllowRegistration), v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode + v1.WithForwardAuthHeader(a.conf.Auth.ForwardAuthHeader), + v1.WithForwardAuthAllowedIps(a.conf.Auth.ForwardAuthAllowedIps), ) r.Get(v1Base("/status"), chain.ToHandlerFunc(v1Ctrl.HandleBase(func() bool { return true }, v1.Build{ @@ -67,6 +69,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR r.Get(v1Base("/currencies"), chain.ToHandlerFunc(v1Ctrl.HandleCurrency())) providers := []v1.AuthProvider{ + providers.NewForwardAuthProvider(a.services.User, &a.conf.Auth), providers.NewLocalProvider(a.services.User), } diff --git a/backend/internal/core/services/service_user.go b/backend/internal/core/services/service_user.go index d86c39b..14ff34b 100644 --- a/backend/internal/core/services/service_user.go +++ b/backend/internal/core/services/service_user.go @@ -197,6 +197,30 @@ func (svc *UserService) Login(ctx context.Context, username, password string, ex return svc.createSessionToken(ctx, usr.ID, extendedSession) } +func (svc *UserService) PasswordlessLogin(ctx context.Context, username string, autoRegister bool) (UserAuthTokenDetail, error) { + usr, err := svc.repos.Users.GetOneEmail(ctx, username) + if err == nil { + return svc.createSessionToken(ctx, usr.ID, false) + } + + if !autoRegister { + return UserAuthTokenDetail{}, ErrorInvalidLogin + } + + data := UserRegistration{ + Name: username, + Email: username, + Password: uuid.NewString(), + } + + usr, err = svc.RegisterUser(ctx, data) + if err != nil { + return UserAuthTokenDetail{}, err + } + + return svc.createSessionToken(ctx, usr.ID, false) +} + func (svc *UserService) Logout(ctx context.Context, token string) error { hash := hasher.HashToken(token) err := svc.repos.AuthTokens.DeleteToken(ctx, hash) diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index 8b7b23c..6533d14 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -20,6 +20,7 @@ type Config struct { conf.Version Mode string `yaml:"mode" conf:"default:development"` // development or production Web WebConfig `yaml:"web"` + Auth AuthConfig `yaml:"auth"` Storage Storage `yaml:"storage"` Log LoggerConf `yaml:"logger"` Mailer MailerConf `yaml:"mailer"` @@ -48,6 +49,12 @@ type WebConfig struct { IdleTimeout time.Duration `yaml:"idle_timeout" conf:"default:30s"` } +type AuthConfig struct { + ForwardAuthHeader string `yaml:"forward_auth_header" conf:"default:Remote-Email"` + ForwardAuthAllowedIps string `yaml:"forward_auth_allowed_ips"` + ForwardAuthAutoRegister bool `yaml:"forward_auth_auto_register" conf:"default:false"` +} + // New parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the // file is not read. If the file is not empty, the file is read and the Config struct is returned. func New(buildstr string, description string) (*Config, error) { diff --git a/backend/pkgs/ipcheck/ipcheck.go b/backend/pkgs/ipcheck/ipcheck.go new file mode 100644 index 0000000..e35774b --- /dev/null +++ b/backend/pkgs/ipcheck/ipcheck.go @@ -0,0 +1,37 @@ +// Package ipcheck provides helper functions to validate IP addresses against criteria +package ipcheck + +import ( + "fmt" + "net" + "strings" +) + +func ValidateAgainstList(ip string, comaSeparatedList string) bool { + if comaSeparatedList == "" || ip == "" { + return false + } + + if net.ParseIP(ip) == nil { + ip, _, _ = net.SplitHostPort(ip) + } + + if ip == "" { + return false + } + + cidrs := strings.Split(comaSeparatedList, ",") + testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip)) + if err != nil { + return false + } + + for _, cidr := range cidrs { + _, ipnet, err := net.ParseCIDR(cidr) + if err == nil && ipnet.Contains(testedIP) { + return true + } + } + + return false +} diff --git a/backend/pkgs/ipcheck/ipcheck_test.go b/backend/pkgs/ipcheck/ipcheck_test.go new file mode 100644 index 0000000..05c97a1 --- /dev/null +++ b/backend/pkgs/ipcheck/ipcheck_test.go @@ -0,0 +1,54 @@ +package ipcheck + +import ( + "testing" +) + +func Test_ValidateAgainstList(t *testing.T) { + tests := []struct { + name string + ip string + list string + want bool + }{ + { + name: "IPv4 matching the list", + ip: "192.168.1.1", + list: "192.168.11.0/24,192.168.1.0/24", + want: true, + }, { + name: "IPv4 with exact match", + ip: "192.168.2.2", + list: "192.168.2.2/32,192.168.0.0/24", + want: true, + }, { + name: "IPv4 with no match", + ip: "192.168.3.3", + list: "192.168.0.0/24,192.168.2.0/24", + want: false, + }, { + name: "IPv6 matching the list", + ip: "1111:1111:1111:1111:1111:1111:1111:1111", + list: "1111:1111:1111:1111::/64,2222:2222:2222:2222::/64", + want: true, + }, { + name: "IPv6 with exact match", + ip: "2222:2222:2222:2222:2222:2222:2222:2222", + list: "1111:1111:1111:1111::/64,2222:2222:2222:2222:2222:2222:2222:2222/128", + want: true, + }, { + name: "IPv6 with no match", + ip: "3333:3333:3333:3333:3333:3333:3333:3333", + list: "3333:3333:3333:3333:3333:3333:3333:4444/128,4444::/32", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ValidateAgainstList(tt.ip, tt.list); got != tt.want { + t.Errorf("ValidateAgainstList() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/docs/docs/api/openapi-2.0.json b/docs/docs/api/openapi-2.0.json index b10c93a..132ffbf 100644 --- a/docs/docs/api/openapi-2.0.json +++ b/docs/docs/api/openapi-2.0.json @@ -2847,6 +2847,9 @@ "demo": { "type": "boolean" }, + "forwardAuthAvailable": { + "type": "boolean" + }, "health": { "type": "boolean" }, @@ -2989,4 +2992,4 @@ "in": "header" } } -} \ No newline at end of file +} diff --git a/docs/docs/quick-start.md b/docs/docs/quick-start.md index 2443966..249e06c 100644 --- a/docs/docs/quick-start.md +++ b/docs/docs/quick-start.md @@ -54,29 +54,32 @@ volumes: ## Env Variables & Configuration -| Variable | Default | Description | -| ------------------------------------ | ---------------------- | ---------------------------------------------------------------------------------- | -| HBOX_MODE | production | application mode used for runtime behavior can be one of: development, production | -| HBOX_WEB_PORT | 7745 | port to run the web server on, if you're using docker do not change this | -| HBOX_WEB_HOST | | host to run the web server on, if you're using docker do not change this | -| HBOX_OPTIONS_ALLOW_REGISTRATION | true | allow users to register themselves | -| HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID | true | auto increments the asset_id field for new items | -| HBOX_OPTIONS_CURRENCY_CONFIG | | json configuration file containing additional currencie | -| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB | -| HBOX_WEB_READ_TIMEOUT | 10 | Read timeout of HTTP sever | -| HBOX_WEB_WRITE_TIMEOUT | 10 | Write timeout of HTTP server | -| HBOX_WEB_IDLE_TIMEOUT | 30 | Idle timeout of HTTP server | -| HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker | -| HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, if you're using docker do not change this | -| HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical | -| HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json | -| HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used | -| HBOX_MAILER_PORT | 587 | email port to use | -| HBOX_MAILER_USERNAME | | email user to use | -| HBOX_MAILER_PASSWORD | | email password to use | -| HBOX_MAILER_FROM | | email from address to use | -| HBOX_SWAGGER_HOST | 7745 | swagger host to use, if not set swagger will be disabled | -| HBOX_SWAGGER_SCHEMA | http | swagger schema to use, can be one of: http, https | +| Variable | Default | Description | +| ------------------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------- | +| HBOX_MODE | production | application mode used for runtime behavior can be one of: development, production | +| HBOX_WEB_PORT | 7745 | port to run the web server on, if you're using docker do not change this | +| HBOX_WEB_HOST | | host to run the web server on, if you're using docker do not change this | +| HBOX_OPTIONS_ALLOW_REGISTRATION | true | allow users to register themselves | +| HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID | true | auto increments the asset_id field for new items | +| HBOX_OPTIONS_CURRENCY_CONFIG | | json configuration file containing additional currencies | +| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB | +| HBOX_WEB_READ_TIMEOUT | 10 | Read timeout of HTTP sever | +| HBOX_WEB_WRITE_TIMEOUT | 10 | Write timeout of HTTP server | +| HBOX_WEB_IDLE_TIMEOUT | 30 | Idle timeout of HTTP server | +| HBOX_AUTH_FORWARD_AUTH_HEADER | Remote-Email | Header used to identify user, when forward auth proxy is used | +| HBOX_AUTH_FORWARD_AUTH_ALLOWED_IPS | | Coma separated list of IP CIDRs, allowed to use forward auth - empty list (default), means deny all | +| HBOX_AUTH_FORWARD_AUTH_AUTO_REGISTER | false | If true, non-existing user forwarded by auth proxy will be automatically registered | +| HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker | +| HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, if you're using docker do not change this | +| HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical | +| HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json | +| HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used | +| HBOX_MAILER_PORT | 587 | email port to use | +| HBOX_MAILER_USERNAME | | email user to use | +| HBOX_MAILER_PASSWORD | | email password to use | +| HBOX_MAILER_FROM | | email from address to use | +| HBOX_SWAGGER_HOST | 7745 | swagger host to use, if not set swagger will be disabled | +| HBOX_SWAGGER_SCHEMA | http | swagger schema to use, can be one of: http, https | !!! tip "CLI Arguments" If you're deploying without docker you can use command line arguments to configure the application. Run `homebox --help` for more information. @@ -85,26 +88,32 @@ volumes: Usage: api [options] [arguments] OPTIONS - --mode/$HBOX_MODE (default: development) - --web-port/$HBOX_WEB_PORT (default: 7745) + --mode/$HBOX_MODE (default: development) + --web-port/$HBOX_WEB_PORT (default: 7745) --web-host/$HBOX_WEB_HOST - --web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE (default: 10) - --storage-data/$HBOX_STORAGE_DATA (default: ./.data) - --storage-sqlite-url/$HBOX_STORAGE_SQLITE_URL (default: ./.data/homebox.db?_fk=1) - --log-level/$HBOX_LOG_LEVEL (default: info) - --log-format/$HBOX_LOG_FORMAT (default: text) + --web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE (default: 10) + --web-read-timeout/$HBOX_WEB_READ_TIMEOUT (default: 10s) + --web-write-timeout/$HBOX_WEB_WRITE_TIMEOUT (default: 10s) + --web-idle-timeout/$HBOX_WEB_IDLE_TIMEOUT (default: 30s) + --auth-forward-auth-header/$HBOX_AUTH_FORWARD_AUTH_HEADER (default: Remote-Email) + --auth-forward-auth-allowed-ips/$HBOX_AUTH_FORWARD_AUTH_ALLOWED_IPS + --auth-forward-auth-auto-register/$HBOX_AUTH_FORWARD_AUTH_AUTO_REGISTER (default: false) + --storage-data/$HBOX_STORAGE_DATA (default: ./.data) + --storage-sqlite-url/$HBOX_STORAGE_SQLITE_URL (default: ./.data/homebox.db?_fk=1) + --log-level/$HBOX_LOG_LEVEL (default: info) + --log-format/$HBOX_LOG_FORMAT (default: text) --mailer-host/$HBOX_MAILER_HOST --mailer-port/$HBOX_MAILER_PORT --mailer-username/$HBOX_MAILER_USERNAME --mailer-password/$HBOX_MAILER_PASSWORD --mailer-from/$HBOX_MAILER_FROM - --swagger-host/$HBOX_SWAGGER_HOST (default: localhost:7745) - --swagger-scheme/$HBOX_SWAGGER_SCHEME (default: http) + --swagger-host/$HBOX_SWAGGER_HOST (default: localhost:7745) + --swagger-scheme/$HBOX_SWAGGER_SCHEME (default: http) --demo/$HBOX_DEMO - --debug-enabled/$HBOX_DEBUG_ENABLED (default: false) - --debug-port/$HBOX_DEBUG_PORT (default: 4000) - --options-allow-registration/$HBOX_OPTIONS_ALLOW_REGISTRATION (default: true) - --options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID (default: true) + --debug-enabled/$HBOX_DEBUG_ENABLED (default: false) + --debug-port/$HBOX_DEBUG_PORT (default: 4000) + --options-allow-registration/$HBOX_OPTIONS_ALLOW_REGISTRATION (default: true) + --options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID (default: true) --options-currency-config/$HBOX_OPTIONS_CURRENCY_CONFIG --help/-h display this help message diff --git a/frontend/composables/use-auth-context.ts b/frontend/composables/use-auth-context.ts index 63df8a4..e17c263 100644 --- a/frontend/composables/use-auth-context.ts +++ b/frontend/composables/use-auth-context.ts @@ -84,8 +84,8 @@ class AuthContext implements IAuthContext { console.log("Session invalidated"); } - async login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean) { - const r = await api.login(email, password, stayLoggedIn); + async login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean, provider = "local") { + const r = await api.login(email, password, stayLoggedIn, provider); if (!r.error) { const expiresAt = new Date(r.data.expiresAt); diff --git a/frontend/lib/api/public.ts b/frontend/lib/api/public.ts index 513e492..5c5fed8 100644 --- a/frontend/lib/api/public.ts +++ b/frontend/lib/api/public.ts @@ -13,9 +13,9 @@ export class PublicApi extends BaseAPI { return this.http.get({ url: route("/status") }); } - public login(username: string, password: string, stayLoggedIn = false) { + public login(username: string, password: string, stayLoggedIn = false, provider = "local") { return this.http.post({ - url: route("/users/login"), + url: route("/users/login?provider=" + provider), body: { username, password, diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 2f761e2..db98fff 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -45,6 +45,9 @@ email.value = "demo@example.com"; loginPassword.value = "demo"; } + if (status?.forwardAuthAvailable) { + loginViaForwardAuth() + } }); const route = useRoute(); @@ -120,6 +123,16 @@ loading.value = false; } + async function loginViaForwardAuth() { + const { error } = await ctx.login(api, email.value, loginPassword.value, remember.value, "forwardauth"); + + if (error) { + return; + } + + navigateTo("/home"); + } + const [registerForm, toggleLogin] = useToggle();