diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index 135b1446b..16310615b 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -51,6 +51,12 @@ func WithRegistration(allowRegistration bool) func(*V1Controller) { } } +func WithHeaderAuthEnabled(headerAuthEnabled bool) func(*V1Controller) { + return func(ctrl *V1Controller) { + ctrl.headerAuthEnabled = headerAuthEnabled + } +} + func WithSecureCookies(secure bool) func(*V1Controller) { return func(ctrl *V1Controller) { ctrl.cookieSecure = secure @@ -70,6 +76,7 @@ type V1Controller struct { maxUploadSize int64 isDemo bool allowRegistration bool + headerAuthEnabled bool bus *eventbus.EventBus url string } @@ -91,6 +98,7 @@ type ( Build Build `json:"build"` Demo bool `json:"demo"` AllowRegistration bool `json:"allowRegistration"` + HeaderAuthEnabled bool `json:"headerAuthEnabled"` } ) @@ -125,6 +133,7 @@ func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) errchain.Hand Build: build, Demo: ctrl.isDemo, AllowRegistration: ctrl.allowRegistration, + HeaderAuthEnabled: ctrl.headerAuthEnabled, }) } } diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 6247fe6ad..e705dc4e5 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -39,6 +39,7 @@ var ( func build() string { short := commit + //goland:noinspection GoBoolExpressions if len(short) > 7 { short = short[:7] } @@ -72,6 +73,13 @@ func run(cfg *config.Config) error { app := new(cfg) app.setupLogger() + if (cfg.Proxy.HeaderExternalUserName != "" || cfg.Proxy.HeaderExternalUserId != "") && len(cfg.Proxy.TrustedHosts) == 0 { + panic("To use External User login, Trusted Hosts must be set") + } + if cfg.Proxy.HeaderExternalUserName != "" && cfg.Proxy.HeaderExternalUserId == "" { + panic("External User Name header is set but External User ID Header is not set") + } + // ========================================================================= // Initialize Database & Repos @@ -173,14 +181,22 @@ func run(cfg *config.Config) error { logger := log.With().Caller().Logger() router := chi.NewMux() - router.Use( + var middlewares []func(handler http.Handler) http.Handler + if len(app.conf.Proxy.TrustedHosts) > 0 { + middlewares = append(middlewares, + mid.TrustedIps(logger, app.conf.Proxy.TrustedHosts), + middleware.RealIP, + ) + } + middlewares = append(middlewares, middleware.RequestID, - middleware.RealIP, mid.Logger(logger), middleware.Recoverer, middleware.StripSlashes, ) + router.Use(middlewares...) + chain := errchain.New(mid.Errors(logger)) app.mountRoutes(router, chain, app.repos) diff --git a/backend/app/api/middleware.go b/backend/app/api/middleware.go index 45495933f..aad6761c6 100644 --- a/backend/app/api/middleware.go +++ b/backend/app/api/middleware.go @@ -96,6 +96,21 @@ func getQuery(r *http.Request) (string, error) { return token, nil } +func getHeader(external_user_id_header string, external_user_name_header string, user_service *services.UserService) func(r *http.Request) (string, error) { + return func(r *http.Request) (string, error) { + externalId := r.Header.Get(external_user_id_header) + if externalId == "" { + return "", errors.New("external user id header is required") + } + userToken, err := user_service.LoginWithExternalIDHeader(r.Context(), externalId) + if err != nil { + //todo create user + return "", errors.New("no user with provided external user id") + } + return userToken.Raw, nil + } +} + // mwAuthToken is a middleware that will check the database for a stateful token // and attach it's user to the request context, or return an appropriate error. // Authorization support is by token via Headers or Query Parameter @@ -116,10 +131,13 @@ func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler { } if requestToken == "" { - keyFuncs := [...]KeyFunc{ + keyFuncs := []KeyFunc{ getBearer, getQuery, } + if len(a.conf.Proxy.TrustedHosts) > 0 { + keyFuncs = append(keyFuncs, getHeader(a.conf.Proxy.HeaderExternalUserId, a.conf.Proxy.HeaderExternalUserName, a.services.User)) + } for _, keyFunc := range keyFuncs { token, err := keyFunc(r) diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 386675e07..4fa4702a9 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -54,6 +54,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR a.bus, v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize), v1.WithRegistration(a.conf.Options.AllowRegistration), + v1.WithHeaderAuthEnabled(a.conf.Proxy.HeaderExternalUserId != ""), v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode v1.WithURL(fmt.Sprintf("%s:%s", a.conf.Web.Host, a.conf.Web.Port)), ) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 7fef44b3e..94ce1b6c2 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -2944,6 +2944,9 @@ const docTemplate = `{ "demo": { "type": "boolean" }, + "headerAuthEnabled": { + "type": "boolean" + }, "health": { "type": "boolean" }, diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 6c642d8d7..ab71af01f 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -2937,6 +2937,9 @@ "demo": { "type": "boolean" }, + "headerAuthEnabled": { + "type": "boolean" + }, "health": { "type": "boolean" }, diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index d62757083..5709dde50 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -671,6 +671,8 @@ definitions: $ref: '#/definitions/v1.Build' demo: type: boolean + headerAuthEnabled: + type: boolean health: type: boolean message: diff --git a/backend/internal/core/services/service_user.go b/backend/internal/core/services/service_user.go index 0b67cb310..cef09a367 100644 --- a/backend/internal/core/services/service_user.go +++ b/backend/internal/core/services/service_user.go @@ -197,6 +197,15 @@ func (svc *UserService) Login(ctx context.Context, username, password string, ex return svc.createSessionToken(ctx, usr.ID, extendedSession) } +// LoginWithExternalIDHeader returns the user matching the external id provided +func (svc *UserService) LoginWithExternalIDHeader(ctx context.Context, id string) (UserAuthTokenDetail, error) { + usr, err := svc.repos.Users.GetOneExternalID(ctx, id) + if err != nil { + return UserAuthTokenDetail{}, ErrorInvalidLogin + } + 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/data/ent/migrate/schema.go b/backend/internal/data/ent/migrate/schema.go index 2b5883808..33d995936 100644 --- a/backend/internal/data/ent/migrate/schema.go +++ b/backend/internal/data/ent/migrate/schema.go @@ -410,6 +410,7 @@ var ( {Name: "superuser", Type: field.TypeBool, Default: false}, {Name: "role", Type: field.TypeEnum, Enums: []string{"user", "owner"}, Default: "user"}, {Name: "activated_on", Type: field.TypeTime, Nullable: true}, + {Name: "external_user_id", Type: field.TypeString, Nullable: true}, {Name: "group_users", Type: field.TypeUUID}, } // UsersTable holds the schema information for the "users" table. @@ -420,7 +421,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "users_groups_users", - Columns: []*schema.Column{UsersColumns[10]}, + Columns: []*schema.Column{UsersColumns[11]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.Cascade, }, diff --git a/backend/internal/data/ent/mutation.go b/backend/internal/data/ent/mutation.go index 228de48ee..9ee368273 100644 --- a/backend/internal/data/ent/mutation.go +++ b/backend/internal/data/ent/mutation.go @@ -10684,6 +10684,7 @@ type UserMutation struct { superuser *bool role *user.Role activated_on *time.Time + external_user_id *string clearedFields map[string]struct{} group *uuid.UUID clearedgroup bool @@ -11139,6 +11140,55 @@ func (m *UserMutation) ResetActivatedOn() { delete(m.clearedFields, user.FieldActivatedOn) } +// SetExternalUserID sets the "external_user_id" field. +func (m *UserMutation) SetExternalUserID(s string) { + m.external_user_id = &s +} + +// ExternalUserID returns the value of the "external_user_id" field in the mutation. +func (m *UserMutation) ExternalUserID() (r string, exists bool) { + v := m.external_user_id + if v == nil { + return + } + return *v, true +} + +// OldExternalUserID returns the old "external_user_id" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldExternalUserID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldExternalUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldExternalUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldExternalUserID: %w", err) + } + return oldValue.ExternalUserID, nil +} + +// ClearExternalUserID clears the value of the "external_user_id" field. +func (m *UserMutation) ClearExternalUserID() { + m.external_user_id = nil + m.clearedFields[user.FieldExternalUserID] = struct{}{} +} + +// ExternalUserIDCleared returns if the "external_user_id" field was cleared in this mutation. +func (m *UserMutation) ExternalUserIDCleared() bool { + _, ok := m.clearedFields[user.FieldExternalUserID] + return ok +} + +// ResetExternalUserID resets all changes to the "external_user_id" field. +func (m *UserMutation) ResetExternalUserID() { + m.external_user_id = nil + delete(m.clearedFields, user.FieldExternalUserID) +} + // SetGroupID sets the "group" edge to the Group entity by id. func (m *UserMutation) SetGroupID(id uuid.UUID) { m.group = &id @@ -11320,7 +11370,7 @@ func (m *UserMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserMutation) Fields() []string { - fields := make([]string, 0, 9) + fields := make([]string, 0, 10) if m.created_at != nil { fields = append(fields, user.FieldCreatedAt) } @@ -11348,6 +11398,9 @@ func (m *UserMutation) Fields() []string { if m.activated_on != nil { fields = append(fields, user.FieldActivatedOn) } + if m.external_user_id != nil { + fields = append(fields, user.FieldExternalUserID) + } return fields } @@ -11374,6 +11427,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { return m.Role() case user.FieldActivatedOn: return m.ActivatedOn() + case user.FieldExternalUserID: + return m.ExternalUserID() } return nil, false } @@ -11401,6 +11456,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldRole(ctx) case user.FieldActivatedOn: return m.OldActivatedOn(ctx) + case user.FieldExternalUserID: + return m.OldExternalUserID(ctx) } return nil, fmt.Errorf("unknown User field %s", name) } @@ -11473,6 +11530,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { } m.SetActivatedOn(v) return nil + case user.FieldExternalUserID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetExternalUserID(v) + return nil } return fmt.Errorf("unknown User field %s", name) } @@ -11506,6 +11570,9 @@ func (m *UserMutation) ClearedFields() []string { if m.FieldCleared(user.FieldActivatedOn) { fields = append(fields, user.FieldActivatedOn) } + if m.FieldCleared(user.FieldExternalUserID) { + fields = append(fields, user.FieldExternalUserID) + } return fields } @@ -11523,6 +11590,9 @@ func (m *UserMutation) ClearField(name string) error { case user.FieldActivatedOn: m.ClearActivatedOn() return nil + case user.FieldExternalUserID: + m.ClearExternalUserID() + return nil } return fmt.Errorf("unknown User nullable field %s", name) } @@ -11558,6 +11628,9 @@ func (m *UserMutation) ResetField(name string) error { case user.FieldActivatedOn: m.ResetActivatedOn() return nil + case user.FieldExternalUserID: + m.ResetExternalUserID() + return nil } return fmt.Errorf("unknown User field %s", name) } diff --git a/backend/internal/data/ent/schema/user.go b/backend/internal/data/ent/schema/user.go index bd747aead..bae313417 100644 --- a/backend/internal/data/ent/schema/user.go +++ b/backend/internal/data/ent/schema/user.go @@ -45,6 +45,9 @@ func (User) Fields() []ent.Field { Values("user", "owner"), field.Time("activated_on"). Optional(), + field.String("external_user_id"). + Optional(). + Sensitive(), } } diff --git a/backend/internal/data/ent/user.go b/backend/internal/data/ent/user.go index b00f838b0..e10d2c1fa 100644 --- a/backend/internal/data/ent/user.go +++ b/backend/internal/data/ent/user.go @@ -37,6 +37,8 @@ type User struct { Role user.Role `json:"role,omitempty"` // ActivatedOn holds the value of the "activated_on" field. ActivatedOn time.Time `json:"activated_on,omitempty"` + // ExternalUserID holds the value of the "external_user_id" field. + ExternalUserID string `json:"-"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the UserQuery when eager-loading is set. Edges UserEdges `json:"edges"` @@ -95,7 +97,7 @@ func (*User) scanValues(columns []string) ([]any, error) { switch columns[i] { case user.FieldIsSuperuser, user.FieldSuperuser: values[i] = new(sql.NullBool) - case user.FieldName, user.FieldEmail, user.FieldPassword, user.FieldRole: + case user.FieldName, user.FieldEmail, user.FieldPassword, user.FieldRole, user.FieldExternalUserID: values[i] = new(sql.NullString) case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldActivatedOn: values[i] = new(sql.NullTime) @@ -178,6 +180,12 @@ func (u *User) assignValues(columns []string, values []any) error { } else if value.Valid { u.ActivatedOn = value.Time } + case user.FieldExternalUserID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field external_user_id", values[i]) + } else if value.Valid { + u.ExternalUserID = value.String + } case user.ForeignKeys[0]: if value, ok := values[i].(*sql.NullScanner); !ok { return fmt.Errorf("unexpected type %T for field group_users", values[i]) @@ -261,6 +269,8 @@ func (u *User) String() string { builder.WriteString(", ") builder.WriteString("activated_on=") builder.WriteString(u.ActivatedOn.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("external_user_id=") builder.WriteByte(')') return builder.String() } diff --git a/backend/internal/data/ent/user/user.go b/backend/internal/data/ent/user/user.go index 33b657bd7..f856d07c0 100644 --- a/backend/internal/data/ent/user/user.go +++ b/backend/internal/data/ent/user/user.go @@ -34,6 +34,8 @@ const ( FieldRole = "role" // FieldActivatedOn holds the string denoting the activated_on field in the database. FieldActivatedOn = "activated_on" + // FieldExternalUserID holds the string denoting the external_user_id field in the database. + FieldExternalUserID = "external_user_id" // EdgeGroup holds the string denoting the group edge name in mutations. EdgeGroup = "group" // EdgeAuthTokens holds the string denoting the auth_tokens edge name in mutations. @@ -77,6 +79,7 @@ var Columns = []string{ FieldSuperuser, FieldRole, FieldActivatedOn, + FieldExternalUserID, } // ForeignKeys holds the SQL foreign-keys that are owned by the "users" @@ -200,6 +203,11 @@ func ByActivatedOn(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldActivatedOn, opts...).ToFunc() } +// ByExternalUserID orders the results by the external_user_id field. +func ByExternalUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldExternalUserID, opts...).ToFunc() +} + // ByGroupField orders the results by group field. func ByGroupField(field string, opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/backend/internal/data/ent/user/where.go b/backend/internal/data/ent/user/where.go index f70676bcf..6b7b0db5f 100644 --- a/backend/internal/data/ent/user/where.go +++ b/backend/internal/data/ent/user/where.go @@ -96,6 +96,11 @@ func ActivatedOn(v time.Time) predicate.User { return predicate.User(sql.FieldEQ(FieldActivatedOn, v)) } +// ExternalUserID applies equality check predicate on the "external_user_id" field. It's identical to ExternalUserIDEQ. +func ExternalUserID(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldExternalUserID, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.User { return predicate.User(sql.FieldEQ(FieldCreatedAt, v)) @@ -461,6 +466,81 @@ func ActivatedOnNotNil() predicate.User { return predicate.User(sql.FieldNotNull(FieldActivatedOn)) } +// ExternalUserIDEQ applies the EQ predicate on the "external_user_id" field. +func ExternalUserIDEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldExternalUserID, v)) +} + +// ExternalUserIDNEQ applies the NEQ predicate on the "external_user_id" field. +func ExternalUserIDNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldExternalUserID, v)) +} + +// ExternalUserIDIn applies the In predicate on the "external_user_id" field. +func ExternalUserIDIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldExternalUserID, vs...)) +} + +// ExternalUserIDNotIn applies the NotIn predicate on the "external_user_id" field. +func ExternalUserIDNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldExternalUserID, vs...)) +} + +// ExternalUserIDGT applies the GT predicate on the "external_user_id" field. +func ExternalUserIDGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldExternalUserID, v)) +} + +// ExternalUserIDGTE applies the GTE predicate on the "external_user_id" field. +func ExternalUserIDGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldExternalUserID, v)) +} + +// ExternalUserIDLT applies the LT predicate on the "external_user_id" field. +func ExternalUserIDLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldExternalUserID, v)) +} + +// ExternalUserIDLTE applies the LTE predicate on the "external_user_id" field. +func ExternalUserIDLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldExternalUserID, v)) +} + +// ExternalUserIDContains applies the Contains predicate on the "external_user_id" field. +func ExternalUserIDContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldExternalUserID, v)) +} + +// ExternalUserIDHasPrefix applies the HasPrefix predicate on the "external_user_id" field. +func ExternalUserIDHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldExternalUserID, v)) +} + +// ExternalUserIDHasSuffix applies the HasSuffix predicate on the "external_user_id" field. +func ExternalUserIDHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldExternalUserID, v)) +} + +// ExternalUserIDIsNil applies the IsNil predicate on the "external_user_id" field. +func ExternalUserIDIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldExternalUserID)) +} + +// ExternalUserIDNotNil applies the NotNil predicate on the "external_user_id" field. +func ExternalUserIDNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldExternalUserID)) +} + +// ExternalUserIDEqualFold applies the EqualFold predicate on the "external_user_id" field. +func ExternalUserIDEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldExternalUserID, v)) +} + +// ExternalUserIDContainsFold applies the ContainsFold predicate on the "external_user_id" field. +func ExternalUserIDContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldExternalUserID, v)) +} + // HasGroup applies the HasEdge predicate on the "group" edge. func HasGroup() predicate.User { return predicate.User(func(s *sql.Selector) { diff --git a/backend/internal/data/ent/user_create.go b/backend/internal/data/ent/user_create.go index 7367ddb76..26fab7cdf 100644 --- a/backend/internal/data/ent/user_create.go +++ b/backend/internal/data/ent/user_create.go @@ -126,6 +126,20 @@ func (uc *UserCreate) SetNillableActivatedOn(t *time.Time) *UserCreate { return uc } +// SetExternalUserID sets the "external_user_id" field. +func (uc *UserCreate) SetExternalUserID(s string) *UserCreate { + uc.mutation.SetExternalUserID(s) + return uc +} + +// SetNillableExternalUserID sets the "external_user_id" field if the given value is not nil. +func (uc *UserCreate) SetNillableExternalUserID(s *string) *UserCreate { + if s != nil { + uc.SetExternalUserID(*s) + } + return uc +} + // SetID sets the "id" field. func (uc *UserCreate) SetID(u uuid.UUID) *UserCreate { uc.mutation.SetID(u) @@ -362,6 +376,10 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { _spec.SetField(user.FieldActivatedOn, field.TypeTime, value) _node.ActivatedOn = value } + if value, ok := uc.mutation.ExternalUserID(); ok { + _spec.SetField(user.FieldExternalUserID, field.TypeString, value) + _node.ExternalUserID = value + } if nodes := uc.mutation.GroupIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, diff --git a/backend/internal/data/ent/user_update.go b/backend/internal/data/ent/user_update.go index 3277713d3..d38c4b14b 100644 --- a/backend/internal/data/ent/user_update.go +++ b/backend/internal/data/ent/user_update.go @@ -142,6 +142,26 @@ func (uu *UserUpdate) ClearActivatedOn() *UserUpdate { return uu } +// SetExternalUserID sets the "external_user_id" field. +func (uu *UserUpdate) SetExternalUserID(s string) *UserUpdate { + uu.mutation.SetExternalUserID(s) + return uu +} + +// SetNillableExternalUserID sets the "external_user_id" field if the given value is not nil. +func (uu *UserUpdate) SetNillableExternalUserID(s *string) *UserUpdate { + if s != nil { + uu.SetExternalUserID(*s) + } + return uu +} + +// ClearExternalUserID clears the value of the "external_user_id" field. +func (uu *UserUpdate) ClearExternalUserID() *UserUpdate { + uu.mutation.ClearExternalUserID() + return uu +} + // SetGroupID sets the "group" edge to the Group entity by ID. func (uu *UserUpdate) SetGroupID(id uuid.UUID) *UserUpdate { uu.mutation.SetGroupID(id) @@ -339,6 +359,12 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) { if uu.mutation.ActivatedOnCleared() { _spec.ClearField(user.FieldActivatedOn, field.TypeTime) } + if value, ok := uu.mutation.ExternalUserID(); ok { + _spec.SetField(user.FieldExternalUserID, field.TypeString, value) + } + if uu.mutation.ExternalUserIDCleared() { + _spec.ClearField(user.FieldExternalUserID, field.TypeString) + } if uu.mutation.GroupCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -588,6 +614,26 @@ func (uuo *UserUpdateOne) ClearActivatedOn() *UserUpdateOne { return uuo } +// SetExternalUserID sets the "external_user_id" field. +func (uuo *UserUpdateOne) SetExternalUserID(s string) *UserUpdateOne { + uuo.mutation.SetExternalUserID(s) + return uuo +} + +// SetNillableExternalUserID sets the "external_user_id" field if the given value is not nil. +func (uuo *UserUpdateOne) SetNillableExternalUserID(s *string) *UserUpdateOne { + if s != nil { + uuo.SetExternalUserID(*s) + } + return uuo +} + +// ClearExternalUserID clears the value of the "external_user_id" field. +func (uuo *UserUpdateOne) ClearExternalUserID() *UserUpdateOne { + uuo.mutation.ClearExternalUserID() + return uuo +} + // SetGroupID sets the "group" edge to the Group entity by ID. func (uuo *UserUpdateOne) SetGroupID(id uuid.UUID) *UserUpdateOne { uuo.mutation.SetGroupID(id) @@ -815,6 +861,12 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) if uuo.mutation.ActivatedOnCleared() { _spec.ClearField(user.FieldActivatedOn, field.TypeTime) } + if value, ok := uuo.mutation.ExternalUserID(); ok { + _spec.SetField(user.FieldExternalUserID, field.TypeString, value) + } + if uuo.mutation.ExternalUserIDCleared() { + _spec.ClearField(user.FieldExternalUserID, field.TypeString) + } if uuo.mutation.GroupCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, diff --git a/backend/internal/data/migrations/migrations/20240925103037_add_external_user_id.sql b/backend/internal/data/migrations/migrations/20240925103037_add_external_user_id.sql new file mode 100644 index 000000000..345bf1c52 --- /dev/null +++ b/backend/internal/data/migrations/migrations/20240925103037_add_external_user_id.sql @@ -0,0 +1,2 @@ +-- Add column "external_user_id" to table: "users" +ALTER TABLE `users` ADD COLUMN `external_user_id` text NULL; diff --git a/backend/internal/data/migrations/migrations/atlas.sum b/backend/internal/data/migrations/migrations/atlas.sum index e8d99a617..1d975939e 100644 --- a/backend/internal/data/migrations/migrations/atlas.sum +++ b/backend/internal/data/migrations/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:sjJCTAqc9FG8BKBIzh5ZynYD/Ilz6vnLqM4XX83WQ4M= +h1:lmkp47KpO+JAh0zsA/P4ReYYzmYPgtFwqHYuTwvfpCo= 20220929052825_init.sql h1:ZlCqm1wzjDmofeAcSX3jE4h4VcdTNGpRg2eabztDy9Q= 20221001210956_group_invitations.sql h1:YQKJFtE39wFOcRNbZQ/d+ZlHwrcfcsZlcv/pLEYdpjw= 20221009173029_add_user_roles.sql h1:vWmzAfgEWQeGk0Vn70zfVPCcfEZth3E0JcvyKTjpYyU= @@ -13,3 +13,4 @@ h1:sjJCTAqc9FG8BKBIzh5ZynYD/Ilz6vnLqM4XX83WQ4M= 20230305065819_add_notifier_types.sql h1:r5xrgCKYQ2o9byBqYeAX1zdp94BLdaxf4vq9OmGHNl0= 20230305071524_add_group_id_to_notifiers.sql h1:xDShqbyClcFhvJbwclOHdczgXbdffkxXNWjV61hL/t4= 20231006213457_add_primary_attachment_flag.sql h1:J4tMSJQFa7vaj0jpnh8YKTssdyIjRyq6RXDXZIzDDu4= +20240925103037_add_external_user_id.sql h1:gXYDh2uLMVEXpYoUOD4DSBAn9jfj25bxNlzzk9r+TOI= diff --git a/backend/internal/data/repo/repo_users.go b/backend/internal/data/repo/repo_users.go index 8007fbc04..ae998d74f 100644 --- a/backend/internal/data/repo/repo_users.go +++ b/backend/internal/data/repo/repo_users.go @@ -67,6 +67,12 @@ func (r *UserRepository) GetOneID(ctx context.Context, id uuid.UUID) (UserOut, e Only(ctx)) } +func (r *UserRepository) GetOneExternalID(ctx context.Context, externalId string) (UserOut, error) { + return mapUserOutErr(r.db.User.Query(). + Where(user.ExternalUserID(externalId)). + Only(ctx)) +} + func (r *UserRepository) GetOneEmail(ctx context.Context, email string) (UserOut, error) { return mapUserOutErr(r.db.User.Query(). Where(user.EmailEqualFold(email)). diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index 8b7b23c3c..86fe3106b 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -26,6 +26,13 @@ type Config struct { Demo bool `yaml:"demo"` Debug DebugConf `yaml:"debug"` Options Options `yaml:"options"` + Proxy Proxy `yaml:"proxy"` +} + +type Proxy struct { + TrustedHosts []string `yaml:"trusted_hosts"` + HeaderExternalUserId string `yaml:"header_external_user_id"` + HeaderExternalUserName string `yaml:"header_external_user_name"` } type Options struct { diff --git a/backend/internal/web/mid/trustedips.go b/backend/internal/web/mid/trustedips.go new file mode 100644 index 000000000..eeb8568fe --- /dev/null +++ b/backend/internal/web/mid/trustedips.go @@ -0,0 +1,51 @@ +package mid + +import ( + "net" + "net/http" + "slices" + "strings" + + "github.com/rs/zerolog" +) + + +func TrustedIps(logger zerolog.Logger, trustedHosts []string) func(http.Handler) http.Handler { + var trustedIps []string + var trusting_all = false + if slices.Contains(trustedHosts, "0.0.0.0") { + trusting_all = true + } + if !trusting_all { + for _, host := range trustedHosts { + addrs, err := net.LookupIP(host) + if err != nil { + logger.Err(err) + continue + } + for _, addr := range addrs { + trustedIps = append(trustedIps, addr.String()) + } + } + logger.Info().Msgf("Trusted ips: %q", trustedIps) + } else { + logger.Info().Msgf("Trusted ips: ALL (0.0.0.0)") + } + return func(handler http.Handler) http.Handler { + return http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + port_colon_idx := strings.LastIndex(request.RemoteAddr, ":") + addr := request.RemoteAddr[:port_colon_idx] + if trusting_all || slices.Contains(trustedIps, addr) { + handler.ServeHTTP(writer, request) + } else { + writer.WriteHeader(http.StatusUnauthorized) + _, err := writer.Write([]byte{}) + if err != nil { + logger.Err(err) + } + } + }, + ) + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 6b577608d..30f751c0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,5 +7,7 @@ services: args: - COMMIT=head - BUILD_TIME=0001-01-01T00:00:00Z + extra_hosts: + - "host.docker.internal:host-gateway" ports: - 3100:7745 diff --git a/docs/docs/api/openapi-2.0.json b/docs/docs/api/openapi-2.0.json index 6c642d8d7..ab71af01f 100644 --- a/docs/docs/api/openapi-2.0.json +++ b/docs/docs/api/openapi-2.0.json @@ -2937,6 +2937,9 @@ "demo": { "type": "boolean" }, + "headerAuthEnabled": { + "type": "boolean" + }, "health": { "type": "boolean" }, diff --git a/docs/en/configure-homebox.md b/docs/en/configure-homebox.md index 138193e83..e28f001cf 100644 --- a/docs/en/configure-homebox.md +++ b/docs/en/configure-homebox.md @@ -2,30 +2,32 @@ ## 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 | 10s | Read timeout of HTTP sever | -| HBOX_WEB_WRITE_TIMEOUT | 10s | Write timeout of HTTP server | -| HBOX_WEB_IDLE_TIMEOUT | 30s | 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 currencie | +| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB | +| HBOX_WEB_READ_TIMEOUT | 10s | Read timeout of HTTP sever | +| HBOX_WEB_WRITE_TIMEOUT | 10s | Write timeout of HTTP server | +| HBOX_WEB_IDLE_TIMEOUT | 30s | 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` | +| HBOX_PROXY_TRUSTED_HOSTS | | Reverse Proxies IPs (or hostnames) to trust | +| HBOX_PROXY_HEADER_EXTERNAL_USER_ID | | HTTP Header containing the ID of the external user to automatically log into Homebox, must use HBOX_PROXY_TRUSTED_HOSTS | +| HBOX_PROXY_HEADER_EXTERNAL_USER_NAME | | Username of the external user, only used when creating user from external source | ::: 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. diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 12727f0f6..d2cc6d8b2 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -403,6 +403,7 @@ export interface APISummary { allowRegistration: boolean; build: Build; demo: boolean; + headerAuthEnabled: boolean; health: boolean; message: string; title: string;