Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): support for forwarded auth provider #874

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
59 changes: 39 additions & 20 deletions backend/app/api/handlers/v1/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 (
Expand All @@ -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"`
}
)

Expand Down Expand Up @@ -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,
})
}
}
Expand Down
36 changes: 36 additions & 0 deletions backend/app/api/providers/forwardauth.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions backend/app/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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),
}

Expand Down
24 changes: 24 additions & 0 deletions backend/internal/core/services/service_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/sys/config/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions backend/pkgs/ipcheck/ipcheck.go
Original file line number Diff line number Diff line change
@@ -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
}
54 changes: 54 additions & 0 deletions backend/pkgs/ipcheck/ipcheck_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 4 additions & 1 deletion docs/docs/api/openapi-2.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -2847,6 +2847,9 @@
"demo": {
"type": "boolean"
},
"forwardAuthAvailable": {
"type": "boolean"
},
"health": {
"type": "boolean"
},
Expand Down Expand Up @@ -2989,4 +2992,4 @@
"in": "header"
}
}
}
}