From be7c75404377ccddd07e16b57e39ead5609323f7 Mon Sep 17 00:00:00 2001 From: jamesread Date: Wed, 26 Nov 2025 22:33:19 +0000 Subject: [PATCH] feat: Rebuild authentication system to support 3k authentication --- service/cmd/config-tool/main.go | 16 +- service/internal/acl/acl.go | 224 +----------------- service/internal/acl/acl_test.go | 58 +---- service/internal/api/api.go | 64 ++--- service/internal/api/apiActions.go | 3 +- service/internal/auth/authcheck.go | 70 ++++++ .../auth/authpublic/authenticateduser.go | 102 ++++++++ .../auth/authpublic/authenticateduser_test.go | 56 +++++ service/internal/auth/authpublic/context.go | 12 + service/internal/auth/local.go | 49 ++++ .../restapi_auth_jwt.go => auth/otjwt/jwt.go} | 196 +++++++++------ service/internal/auth/otjwt/jwt_test.go | 205 ++++++++++++++++ .../otoauth2}/restapi_auth_oauth2.go | 57 +++-- .../restapi_auth_oauth2_providers.go | 17 +- service/internal/auth/system-users.go | 29 +++ service/internal/auth/trusted-headers.go | 33 +++ service/internal/executor/arguments.go | 3 +- service/internal/executor/executor.go | 12 +- service/internal/executor/executor_test.go | 7 +- .../{singleFrontend.go => frontend.go} | 23 +- service/internal/httpservers/httpServer.go | 16 -- service/internal/httpservers/prometheus.go | 14 +- .../httpservers/restapi_auth_jwt_test.go | 174 -------------- .../httpservers/restapi_auth_local.go | 34 --- service/internal/httpservers/restapi_test.go | 3 - service/internal/httpservers/webuiServer.go | 31 ++- service/internal/oncalendarfile/calendar.go | 9 +- service/internal/oncron/cron.go | 4 +- service/internal/onfileindir/fileindir.go | 4 +- service/internal/onstartup/startup.go | 4 +- service/main.go | 27 ++- 31 files changed, 876 insertions(+), 680 deletions(-) create mode 100644 service/internal/auth/authcheck.go create mode 100644 service/internal/auth/authpublic/authenticateduser.go create mode 100644 service/internal/auth/authpublic/authenticateduser_test.go create mode 100644 service/internal/auth/authpublic/context.go create mode 100644 service/internal/auth/local.go rename service/internal/{httpservers/restapi_auth_jwt.go => auth/otjwt/jwt.go} (55%) create mode 100644 service/internal/auth/otjwt/jwt_test.go rename service/internal/{httpservers => auth/otoauth2}/restapi_auth_oauth2.go (90%) rename service/internal/{httpservers => auth/otoauth2}/restapi_auth_oauth2_providers.go (87%) create mode 100644 service/internal/auth/system-users.go create mode 100644 service/internal/auth/trusted-headers.go rename service/internal/httpservers/{singleFrontend.go => frontend.go} (79%) delete mode 100644 service/internal/httpservers/httpServer.go delete mode 100644 service/internal/httpservers/restapi_auth_jwt_test.go delete mode 100644 service/internal/httpservers/restapi_auth_local.go delete mode 100644 service/internal/httpservers/restapi_test.go diff --git a/service/cmd/config-tool/main.go b/service/cmd/config-tool/main.go index 971f2f7..4a9ebf3 100644 --- a/service/cmd/config-tool/main.go +++ b/service/cmd/config-tool/main.go @@ -125,11 +125,23 @@ func resetAllPasswords(k *koanf.Koanf, cfg *config.Config) { } log.Infof("Reset password for user '%s' (old hash: %s...)", username, oldHashPreview) } - k.Set("authLocalUsers.users", newUsersSlice) + err = k.Set("authLocalUsers.users", newUsersSlice) + + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Fatalf("Error setting users") + } } else { for index, user := range cfg.AuthLocalUsers.Users { key := "authLocalUsers.users." + strconv.Itoa(index) + ".password" - k.Set(key, hashedPassword) + err = k.Set(key, hashedPassword) + + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Fatalf("Error setting user password") + } oldHashPreview := user.Password if len(oldHashPreview) > 20 { diff --git a/service/internal/acl/acl.go b/service/internal/acl/acl.go index 227e537..60a87f2 100644 --- a/service/internal/acl/acl.go +++ b/service/internal/acl/acl.go @@ -1,12 +1,7 @@ package acl import ( - "context" - "net/http" - "strings" - - "connectrpc.com/connect" - "github.com/OliveTin/OliveTin/internal/auth" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" log "github.com/sirupsen/logrus" @@ -26,57 +21,7 @@ func (p PermissionBits) Has(permission PermissionBits) bool { return p&permission != 0 } -// User respresents a person. -type AuthenticatedUser struct { - Username string - UsergroupLine string - - Provider string - SID string - - Acls []string - - EffectivePolicy *config.ConfigurationPolicy -} - -func (u *AuthenticatedUser) IsGuest() bool { - return u.Username == "guest" && u.Provider == "system" -} - -func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string { - ret := []string{} - - if sep != "" { - for _, v := range strings.Split(u.UsergroupLine, sep) { - trimmed := strings.TrimSpace(v) - - if trimmed != "" { - ret = append(ret, trimmed) - } - } - } else { - ret = strings.Fields(u.UsergroupLine) - } - - log.Debugf("parseUsergroupLine: %v, %v, sep:%v", u.UsergroupLine, ret, sep) - - return ret -} - -func (u *AuthenticatedUser) matchesUsergroupAcl(matchUsergroups []string, sep string) bool { - groupList := u.parseUsergroupLine(sep) - - for _, group := range groupList { - if slices.Contains(matchUsergroups, group) { - log.Debugf("Usergroup %v found in %+v (len: %v)", group, groupList, len(groupList)) - return true - } - } - - return false -} - -func logAclNotMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, acl *config.AccessControlList) { +func logAclNotMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, acl *config.AccessControlList) { if cfg.LogDebugOptions.AclNotMatched { log.WithFields(log.Fields{ "User": user.Username, @@ -86,7 +31,7 @@ func logAclNotMatched(cfg *config.Config, aclFunction string, user *Authenticate } } -func logAclMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, acl *config.AccessControlList) { +func logAclMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, acl *config.AccessControlList) { actionTitle := "N/A" if action != nil { @@ -102,7 +47,7 @@ func logAclMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUs } } -func logAclNoneMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, defaultPermission bool) { +func logAclNoneMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, defaultPermission bool) { if cfg.LogDebugOptions.AclNoneMatched { log.WithFields(log.Fields{ "User": user.Username, @@ -136,7 +81,7 @@ func permissionsConfigToBits(permissions config.PermissionsList) PermissionBits return ret } -func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action) bool { +func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action) bool { relevantAcls := getRelevantAcls(cfg, action.Acls, user) if cfg.LogDebugOptions.AclCheckStarted { @@ -167,17 +112,17 @@ func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config. } // IsAllowedLogs checks if a AuthenticatedUser is allowed to view an action's logs -func IsAllowedLogs(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { +func IsAllowedLogs(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool { return aclCheck(Logs, cfg.DefaultPermissions.Logs, cfg, "isAllowedLogs", user, action) } // IsAllowedExec checks if a AuthenticatedUser is allowed to execute an Action -func IsAllowedExec(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { +func IsAllowedExec(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool { return aclCheck(Exec, cfg.DefaultPermissions.Exec, cfg, "isAllowedExec", user, action) } // IsAllowedView checks if a User is allowed to view an Action -func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { +func IsAllowedView(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool { if action.Hidden { return false } @@ -185,129 +130,11 @@ func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.A return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedView", user, action) } -func IsAllowedKill(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { +func IsAllowedKill(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool { return aclCheck(Kill, cfg.DefaultPermissions.Kill, cfg, "isAllowedKill", user, action) } -func getHeaderKeyOrEmpty(headers http.Header, key string) string { - values := headers.Values(key) - if len(values) > 0 { - return values[0] - } - return "" -} - -// UserFromContext tries to find a user from a Connect RPC context -func UserFromContext[T any](ctx context.Context, req *connect.Request[T], cfg *config.Config) *AuthenticatedUser { - user := userFromHeaders(req, cfg) - if user.Username == "" { - user = userFromLocalSession(req, cfg, user) - } - if user.Username == "" { - user = *UserGuest(cfg) - } else { - buildUserAcls(cfg, &user) - } - - path := "" - if req != nil { - path = req.Spec().Procedure - } - - log.WithFields(log.Fields{ - "username": user.Username, - "usergroupLine": user.UsergroupLine, - "provider": user.Provider, - "acls": user.Acls, - "path": path, - }).Debugf("Authenticated API request") - return &user -} - -//gocyclo:ignore -func userFromHeaders[T any](req *connect.Request[T], cfg *config.Config) AuthenticatedUser { - var u AuthenticatedUser - if req == nil { - return u - } - if cfg.AuthHttpHeaderUsername != "" { - u.Username = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUsername) - } - if cfg.AuthHttpHeaderUserGroup != "" { - u.UsergroupLine = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUserGroup) - } - if prov := getHeaderKeyOrEmpty(req.Header(), "provider"); prov != "" { - u.Provider = prov - } - return u -} - -//gocyclo:ignore -func userFromLocalSession[T any](req *connect.Request[T], cfg *config.Config, u AuthenticatedUser) AuthenticatedUser { - if req == nil || u.Username != "" { - return u - } - dummy := &http.Request{Header: req.Header()} - c, err := dummy.Cookie("olivetin-sid-local") - if err != nil || c == nil || c.Value == "" { - return u - } - sess := auth.GetUserSession("local", c.Value) - if sess == nil { - log.WithFields(log.Fields{"sid": c.Value, "provider": "local"}).Warn("UserFromContext: stale local session") - return u - } - if cfgUser := cfg.FindUserByUsername(sess.Username); cfgUser != nil { - u.Username = cfgUser.Username - u.UsergroupLine = cfgUser.Usergroup - u.Provider = "local" - u.SID = c.Value - return u - } - log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config") - return u -} - -func UserGuest(cfg *config.Config) *AuthenticatedUser { - ret := &AuthenticatedUser{} - ret.Username = "guest" - ret.UsergroupLine = "guest" - ret.Provider = "system" - - buildUserAcls(cfg, ret) - - return ret -} - -func UserFromSystem(cfg *config.Config, username string) *AuthenticatedUser { - ret := &AuthenticatedUser{ - Username: username, - UsergroupLine: "system", - Provider: "system", - } - - buildUserAcls(cfg, ret) - - return ret -} - -func buildUserAcls(cfg *config.Config, user *AuthenticatedUser) { - for _, acl := range cfg.AccessControlLists { - if slices.Contains(acl.MatchUsernames, user.Username) { - user.Acls = append(user.Acls, acl.Name) - continue - } - - if user.matchesUsergroupAcl(acl.MatchUsergroups, cfg.AuthHttpHeaderUserGroupSep) { - user.Acls = append(user.Acls, acl.Name) - continue - } - } - - user.EffectivePolicy = getEffectivePolicy(cfg, user) -} - -func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.AccessControlList, user *AuthenticatedUser) bool { +func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.AccessControlList, user *authpublic.AuthenticatedUser) bool { if !slices.Contains(user.Acls, acl.Name) { // If the user does not have this ACL, then it is not relevant @@ -325,7 +152,7 @@ func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config. return false } -func getRelevantAcls(cfg *config.Config, actionAcls []string, user *AuthenticatedUser) []*config.AccessControlList { +func getRelevantAcls(cfg *config.Config, actionAcls []string, user *authpublic.AuthenticatedUser) []*config.AccessControlList { var ret []*config.AccessControlList for _, acl := range cfg.AccessControlLists { @@ -336,32 +163,3 @@ func getRelevantAcls(cfg *config.Config, actionAcls []string, user *Authenticate return ret } - -func getEffectivePolicy(cfg *config.Config, user *AuthenticatedUser) *config.ConfigurationPolicy { - ret := &config.ConfigurationPolicy{ - ShowDiagnostics: cfg.DefaultPolicy.ShowDiagnostics, - ShowLogList: cfg.DefaultPolicy.ShowLogList, - } - - for _, acl := range cfg.AccessControlLists { - if slices.Contains(user.Acls, acl.Name) { - logAclMatched(cfg, "GetEffectivePolicy", user, nil, acl) - - ret = buildConfigurationPolicy(ret, acl.Policy) - } - } - - return ret -} - -func buildConfigurationPolicy(ret *config.ConfigurationPolicy, policy config.ConfigurationPolicy) *config.ConfigurationPolicy { - if policy.ShowDiagnostics { - ret.ShowDiagnostics = policy.ShowDiagnostics - } - - if policy.ShowLogList { - ret.ShowLogList = policy.ShowLogList - } - - return ret -} diff --git a/service/internal/acl/acl_test.go b/service/internal/acl/acl_test.go index 48e4992..1ef14b6 100644 --- a/service/internal/acl/acl_test.go +++ b/service/internal/acl/acl_test.go @@ -1,8 +1,9 @@ package acl import ( - "github.com/stretchr/testify/assert" "testing" + + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" ) func Test_hasGroupsMatch(t *testing.T) { @@ -49,63 +50,14 @@ func Test_hasGroupsMatch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - user := &AuthenticatedUser{ + user := &authpublic.AuthenticatedUser{ Username: "testuser", UsergroupLine: tt.usergroupLine, } - if matches := user.matchesUsergroupAcl(tt.aclMatchUsergroups, tt.sep); matches != tt.matches { - t.Errorf("AuthenticatedUser.matchesUsergroupAcl() = %v, want %v for usergroups %v", matches, tt.matches, tt.aclMatchUsergroups) + if matches := user.MatchesUsergroupAcl(tt.aclMatchUsergroups, tt.sep); matches != tt.matches { + t.Errorf("AuthenticatedUser.MatchesUsergroupAcl() = %v, want %v for usergroups %v", matches, tt.matches, tt.aclMatchUsergroups) } }) } } - -func Test_parseUsergroupLine(t *testing.T) { - tests := []struct { - name string - usergroupLine string - expectedGroups []string - sep string - }{ - { - name: "Default separator (space)", - usergroupLine: "group1 group2", - expectedGroups: []string{"group1", "group2"}, - }, - { - name: "Comma-separated groups", - usergroupLine: "group1 , group2", - expectedGroups: []string{"group1", "group2"}, - sep: ",", - }, - { - name: "Multiple spaces", - usergroupLine: "group1 , group2 , group3", - expectedGroups: []string{"group1", "group2", "group3"}, - sep: ",", - }, - { - name: "Empty usergroup line", - usergroupLine: "", - expectedGroups: []string{}, - }, - { - name: "Empty group names", - usergroupLine: "|group1| | group3|", - expectedGroups: []string{"group1", "group3"}, - sep: "|", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - user := &AuthenticatedUser{ - Username: "testuser", - UsergroupLine: tt.usergroupLine, - } - - assert.Equal(t, tt.expectedGroups, user.parseUsergroupLine(tt.sep)) - }) - } -} diff --git a/service/internal/api/api.go b/service/internal/api/api.go index 4b119a3..d7c3693 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -19,6 +19,7 @@ import ( acl "github.com/OliveTin/OliveTin/internal/acl" auth "github.com/OliveTin/OliveTin/internal/auth" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" entities "github.com/OliveTin/OliveTin/internal/entities" executor "github.com/OliveTin/OliveTin/internal/executor" @@ -51,7 +52,7 @@ func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient { type streamingClient struct { channel chan *apiv1.EventStreamResponse - AuthenticatedUser *acl.AuthenticatedUser + AuthenticatedUser *authpublic.AuthenticatedUser } func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) { @@ -78,14 +79,14 @@ func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.K return connect.NewResponse(ret), nil } - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) api.killActionByTrackingId(user, action, execReqLogEntry, ret) return connect.NewResponse(ret), nil } -func (api *oliveTinAPI) killActionByTrackingId(user *acl.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) { +func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) { if !acl.IsAllowedKill(api.cfg, user, action) { log.Warnf("Killing execution request not possible - user not allowed to kill this action: %v", execReqLogEntry.ExecutionTrackingID) ret.Killed = false @@ -115,7 +116,7 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId)) } - authenticatedUser := acl.UserFromContext(ctx, req, api.cfg) + authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg) execReq := executor.ExecutionRequest{ Binding: pair, @@ -204,7 +205,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request args[arg.Name] = arg.Value } - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) execReq := executor.ExecutionRequest{ Binding: api.executor.FindBindingByID(req.Msg.ActionId), @@ -235,7 +236,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a Binding: api.executor.FindBindingByID(req.Msg.ActionId), TrackingID: uuid.NewString(), Arguments: args, - AuthenticatedUser: acl.UserFromContext(ctx, req, api.cfg), + AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg), Cfg: api.cfg, } @@ -249,7 +250,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) { args := make(map[string]string) - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) execReq := executor.ExecutionRequest{ Binding: api.executor.FindBindingByID(req.Msg.ActionId), @@ -273,7 +274,7 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re } } -func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *acl.AuthenticatedUser) *apiv1.LogEntry { +func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry { pble := &apiv1.LogEntry{ ActionTitle: logEntry.ActionTitle, ActionIcon: logEntry.ActionIcon, @@ -327,7 +328,7 @@ func getMostRecentExecutionStatusById(api *oliveTinAPI, actionId string) *execut func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) { res := &apiv1.ExecutionStatusResponse{} - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return nil, err @@ -352,7 +353,7 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap } func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) log.WithFields(log.Fields{ "username": user.Username, @@ -375,7 +376,7 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou } func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return nil, err @@ -397,7 +398,7 @@ func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[a } func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return nil, err @@ -412,14 +413,14 @@ func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1 return api.buildCustomDashboardResponse(dashboardRenderRequest, req.Msg.Title) } -func (api *oliveTinAPI) checkDashboardAccess(user *acl.AuthenticatedUser) error { +func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser) error { if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin { return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard")) } return nil } -func (api *oliveTinAPI) createDashboardRenderRequest(user *acl.AuthenticatedUser) *DashboardRenderRequest { +func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser) *DashboardRenderRequest { return &DashboardRenderRequest{ AuthenticatedUser: user, cfg: api.cfg, @@ -447,7 +448,7 @@ func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest, } func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return nil, err @@ -471,7 +472,7 @@ func isValidLogEntry(e *executor.InternalLogEntry) bool { } // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user. -func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *acl.AuthenticatedUser) bool { +func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool { return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) } @@ -499,7 +500,7 @@ func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) { } // buildActionLogsResponse builds the response with paginated log entries. -func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *acl.AuthenticatedUser) *apiv1.GetActionLogsResponse { +func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse { startIdx, endIdx := calculateReversedIndices(page, len(filtered)) ret := &apiv1.GetActionLogsResponse{} for _, le := range filtered[startIdx:endIdx] { @@ -513,7 +514,7 @@ func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLog } func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return nil, err @@ -528,20 +529,7 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil } -func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*apiv1.LogEntry { - out := make([]*apiv1.LogEntry, 0, len(entries)) - for _, e := range entries { - if !isValidLogEntry(e) { - continue - } - if api.isLogEntryAllowed(e, user) { - out = append(out, api.internalLogEntryToPb(e, user)) - } - } - return out -} - -func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*executor.InternalLogEntry { +func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser) []*executor.InternalLogEntry { filtered := make([]*executor.InternalLogEntry, 0, len(entries)) for _, e := range entries { if !isValidLogEntry(e) { @@ -596,7 +584,7 @@ func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Reque } func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return nil, err @@ -682,7 +670,7 @@ func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.Ge func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error { log.Debugf("EventStream: %v", req.Msg) - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return err @@ -801,7 +789,7 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api } func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin @@ -834,7 +822,7 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq return connect.NewResponse(res), nil } -func (api *oliveTinAPI) buildRootDashboards(user *acl.AuthenticatedUser, dashboards []*config.DashboardComponent) []string { +func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string { var rootDashboards []string dashboardRenderRequest := api.createDashboardRenderRequest(user) @@ -919,7 +907,7 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string } func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return nil, err @@ -972,7 +960,7 @@ func findEntityInComponents(entityTitle string, parentTitle string, components [ } func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) { - user := acl.UserFromContext(ctx, req, api.cfg) + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { return nil, err diff --git a/service/internal/api/apiActions.go b/service/internal/api/apiActions.go index 3f976fe..473d2f8 100644 --- a/service/internal/api/apiActions.go +++ b/service/internal/api/apiActions.go @@ -3,13 +3,14 @@ package api import ( apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" acl "github.com/OliveTin/OliveTin/internal/acl" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" entities "github.com/OliveTin/OliveTin/internal/entities" executor "github.com/OliveTin/OliveTin/internal/executor" ) type DashboardRenderRequest struct { - AuthenticatedUser *acl.AuthenticatedUser + AuthenticatedUser *authpublic.AuthenticatedUser cfg *config.Config ex *executor.Executor } diff --git a/service/internal/auth/authcheck.go b/service/internal/auth/authcheck.go new file mode 100644 index 0000000..331767b --- /dev/null +++ b/service/internal/auth/authcheck.go @@ -0,0 +1,70 @@ +package auth + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + types "github.com/OliveTin/OliveTin/internal/auth/authpublic" + otjwt "github.com/OliveTin/OliveTin/internal/auth/otjwt" + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{ + checkUserFromHeaders, + checkUserFromLocalSession, + otjwt.CheckUserFromJwtHeader, + otjwt.CheckUserFromJwtCookie, +} + +// Handlers like the OAuth2's handler are "instance methods", so they need to be added to the auth chain after the other handlers. +func AddAuthChainFunction(check func(*types.AuthCheckingContext) *types.AuthenticatedUser) { + authChain = append(authChain, check) +} + +func runAuthChain[T any](req *connect.Request[T], cfg *config.Config) *types.AuthenticatedUser { + var user *types.AuthenticatedUser + + authCtx := &types.AuthCheckingContext{ + Request: &http.Request{Header: req.Header()}, + Config: cfg, + } + + for _, check := range authChain { + user = check(authCtx) + + if user != nil && user.Username != "" { + return user + } + } + + return nil +} + +func UserFromApiCall[T any](ctx context.Context, req *connect.Request[T], cfg *config.Config) *types.AuthenticatedUser { + user := runAuthChain(req, cfg) + + log.Infof("Context: %+v", ctx) + + if user == nil || user.Username == "" { + user = UserGuest(cfg) + } else { + user.BuildUserAcls(cfg) + } + + path := "" + if req != nil { + path = req.Spec().Procedure + } + + log.WithFields(log.Fields{ + "username": user.Username, + "usergroupLine": user.UsergroupLine, + "provider": user.Provider, + "acls": user.Acls, + "path": path, + }).Debugf("Authenticated API request") + + return user +} diff --git a/service/internal/auth/authpublic/authenticateduser.go b/service/internal/auth/authpublic/authenticateduser.go new file mode 100644 index 0000000..e4ab6f0 --- /dev/null +++ b/service/internal/auth/authpublic/authenticateduser.go @@ -0,0 +1,102 @@ +package authpublic + +import ( + "slices" + "strings" + + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +// User represents a person. +type AuthenticatedUser struct { + Username string + UsergroupLine string + + Provider string + SID string + + Acls []string + + EffectivePolicy *config.ConfigurationPolicy +} + +func (u *AuthenticatedUser) IsGuest() bool { + return u.Username == "guest" && u.Provider == "system" +} + +func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string { + ret := []string{} + + if sep != "" { + for _, v := range strings.Split(u.UsergroupLine, sep) { + trimmed := strings.TrimSpace(v) + + if trimmed != "" { + ret = append(ret, trimmed) + } + } + } else { + ret = strings.Fields(u.UsergroupLine) + } + + log.Debugf("parseUsergroupLine: %v, %v, sep:%v", u.UsergroupLine, ret, sep) + + return ret +} + +func (u *AuthenticatedUser) MatchesUsergroupAcl(matchUsergroups []string, sep string) bool { + groupList := u.parseUsergroupLine(sep) + + for _, group := range groupList { + if slices.Contains(matchUsergroups, group) { + log.Debugf("Usergroup %v found in %+v (len: %v)", group, groupList, len(groupList)) + return true + } + } + + return false +} + +func (u *AuthenticatedUser) BuildUserAcls(cfg *config.Config) { + for _, acl := range cfg.AccessControlLists { + if slices.Contains(acl.MatchUsernames, u.Username) { + u.Acls = append(u.Acls, acl.Name) + continue + } + + if u.MatchesUsergroupAcl(acl.MatchUsergroups, cfg.AuthHttpHeaderUserGroupSep) { + u.Acls = append(u.Acls, acl.Name) + continue + } + } + + u.EffectivePolicy = getEffectivePolicy(cfg, u) +} + +func getEffectivePolicy(cfg *config.Config, u *AuthenticatedUser) *config.ConfigurationPolicy { + ret := &config.ConfigurationPolicy{ + ShowDiagnostics: cfg.DefaultPolicy.ShowDiagnostics, + ShowLogList: cfg.DefaultPolicy.ShowLogList, + } + + for _, acl := range cfg.AccessControlLists { + if slices.Contains(u.Acls, acl.Name) { + ret = buildConfigurationPolicy(ret, acl.Policy) + } + } + + return ret +} + +func buildConfigurationPolicy(ret *config.ConfigurationPolicy, policy config.ConfigurationPolicy) *config.ConfigurationPolicy { + if policy.ShowDiagnostics { + ret.ShowDiagnostics = policy.ShowDiagnostics + } + + if policy.ShowLogList { + ret.ShowLogList = policy.ShowLogList + } + + return ret +} diff --git a/service/internal/auth/authpublic/authenticateduser_test.go b/service/internal/auth/authpublic/authenticateduser_test.go new file mode 100644 index 0000000..42a4726 --- /dev/null +++ b/service/internal/auth/authpublic/authenticateduser_test.go @@ -0,0 +1,56 @@ +package authpublic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_parseUsergroupLine(t *testing.T) { + tests := []struct { + name string + usergroupLine string + expectedGroups []string + sep string + }{ + { + name: "Default separator (space)", + usergroupLine: "group1 group2", + expectedGroups: []string{"group1", "group2"}, + }, + { + name: "Comma-separated groups", + usergroupLine: "group1 , group2", + expectedGroups: []string{"group1", "group2"}, + sep: ",", + }, + { + name: "Multiple spaces", + usergroupLine: "group1 , group2 , group3", + expectedGroups: []string{"group1", "group2", "group3"}, + sep: ",", + }, + { + name: "Empty usergroup line", + usergroupLine: "", + expectedGroups: []string{}, + }, + { + name: "Empty group names", + usergroupLine: "|group1| | group3|", + expectedGroups: []string{"group1", "group3"}, + sep: "|", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := &AuthenticatedUser{ + Username: "testuser", + UsergroupLine: tt.usergroupLine, + } + + assert.Equal(t, tt.expectedGroups, user.parseUsergroupLine(tt.sep)) + }) + } +} diff --git a/service/internal/auth/authpublic/context.go b/service/internal/auth/authpublic/context.go new file mode 100644 index 0000000..965446a --- /dev/null +++ b/service/internal/auth/authpublic/context.go @@ -0,0 +1,12 @@ +package authpublic + +import ( + "net/http" + + "github.com/OliveTin/OliveTin/internal/config" +) + +type AuthCheckingContext struct { + Config *config.Config + Request *http.Request +} diff --git a/service/internal/auth/local.go b/service/internal/auth/local.go new file mode 100644 index 0000000..e98e7c9 --- /dev/null +++ b/service/internal/auth/local.go @@ -0,0 +1,49 @@ +package auth + +import ( + "net/http" + + types "github.com/OliveTin/OliveTin/internal/auth/authpublic" + log "github.com/sirupsen/logrus" +) + +func getLocalSessionCookie(r *http.Request) (string, bool) { + c, err := r.Cookie("olivetin-sid-local") + if err != nil { + return "", false + } + if c == nil { + return "", false + } + if c.Value == "" { + return "", false + } + return c.Value, true +} + +func checkUserFromLocalSession(context *types.AuthCheckingContext) *types.AuthenticatedUser { + u := &types.AuthenticatedUser{} + + sid, ok := getLocalSessionCookie(context.Request) + if !ok { + return u + } + + sess := GetUserSession("local", sid) + if sess == nil { + log.WithFields(log.Fields{"sid": sid, "provider": "local"}).Warn("UserFromContext: stale local session") + return u + } + + cfgUser := context.Config.FindUserByUsername(sess.Username) + if cfgUser == nil { + log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config") + return u + } + + u.Username = cfgUser.Username + u.UsergroupLine = cfgUser.Usergroup + u.Provider = "local" + u.SID = sid + return u +} diff --git a/service/internal/httpservers/restapi_auth_jwt.go b/service/internal/auth/otjwt/jwt.go similarity index 55% rename from service/internal/httpservers/restapi_auth_jwt.go rename to service/internal/auth/otjwt/jwt.go index b46098b..998c15e 100644 --- a/service/internal/httpservers/restapi_auth_jwt.go +++ b/service/internal/auth/otjwt/jwt.go @@ -1,73 +1,144 @@ -package httpservers +package otjwt import ( "context" "crypto/rsa" "errors" "fmt" - "github.com/golang-jwt/jwt/v5" - log "github.com/sirupsen/logrus" - "net/http" "os" "strings" - - "github.com/OliveTin/OliveTin/internal/config" - - // "github.com/coreos/go-oidc/v3/oidc" - "github.com/MicahParks/keyfunc/v3" + "sync" "time" + + "github.com/MicahParks/keyfunc/v3" + authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic" + "github.com/OliveTin/OliveTin/internal/config" + "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" ) +func parseJwtToken(cfg *config.Config, jwtString string) (*jwt.Token, error) { + if cfg.AuthJwtCertsURL != "" { + return parseJwtTokenWithRemoteKey(cfg, jwtString) + } + + if cfg.AuthJwtPubKeyPath != "" { + return parseJwtTokenWithLocalKey(cfg, jwtString) + } + + if cfg.AuthJwtHmacSecret == "" { + return nil, errors.New("no JWT authentication method configured") + } + + return parseJwtTokenWithHMAC(cfg, jwtString) +} + +func getClaimsFromJwtToken(cfg *config.Config, jwtString string) (jwt.MapClaims, error) { + token, err := parseJwtToken(cfg, jwtString) + + if err != nil { + log.Errorf("jwt parse failure: %v", err) + return nil, errors.New("jwt parse failure") + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, nil + } else { + return nil, errors.New("jwt token isn't valid") + } +} + +func parseJwtTokenWithRemoteKey(cfg *config.Config, jwtToken string) (*jwt.Token, error) { + err := initJwks(cfg) + + if err != nil { + log.Errorf("jwt init JWKS failure: %v", err) + return nil, err + } + + return jwt.Parse(jwtToken, jwksVerifier.Keyfunc, jwt.WithAudience(cfg.AuthJwtAud)) +} + var ( - pubKeyBytes []byte = nil - pubKey *rsa.PublicKey + pubKeyBytes []byte = nil + pubKey *rsa.PublicKey + loadedKeyPath string jwksVerifier keyfunc.Keyfunc + jwksOnce sync.Once + jwksInitErr error + + localKeyMutex sync.RWMutex + localKeyInitErr error ) -func initJwks(cfg *config.Config) { - if jwksVerifier == nil { - var err error - +func initJwks(cfg *config.Config) error { + jwksOnce.Do(func() { if cfg.AuthJwtCertsURL != "" { - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var err error jwksVerifier, err = keyfunc.NewDefaultCtx(ctx, []string{ cfg.AuthJwtCertsURL, }) if err != nil { log.Errorf("Init JWKS Failure: %v", err) + jwksInitErr = err } - - defer cancel() } - } + }) + return jwksInitErr } -func readLocalPublicKey(cfg *config.Config) error { - if pubKeyBytes != nil { - return nil // Already read. - } - - pubKeyBytes, err := os.ReadFile(cfg.AuthJwtPubKeyPath) +func loadPublicKeyFromFile(keyPath string) error { + keyBytes, err := os.ReadFile(keyPath) if err != nil { - return fmt.Errorf("couldn't read public key from file %s", cfg.AuthJwtPubKeyPath) + return fmt.Errorf("couldn't read public key from file %s", keyPath) } - // Since the token is RSA (which we validated at the start of this function), the return type of this function actually has to be rsa.PublicKey! - pubKey, err = jwt.ParseRSAPublicKeyFromPEM(pubKeyBytes) + parsedKey, err := jwt.ParseRSAPublicKeyFromPEM(keyBytes) if err != nil { - return fmt.Errorf("error parsing public key object (from %s)", cfg.AuthJwtPubKeyPath) + return fmt.Errorf("error parsing public key object (from %s)", keyPath) } + pubKeyBytes = keyBytes + pubKey = parsedKey + loadedKeyPath = keyPath + localKeyInitErr = nil return nil } -func parseJwtTokenWithRemoteKey(cfg *config.Config, jwtToken string) (*jwt.Token, error) { - initJwks(cfg) +func isKeyLoadedForPath(keyPath string) bool { + return pubKeyBytes != nil && loadedKeyPath == keyPath +} - return jwt.Parse(jwtToken, jwksVerifier.Keyfunc, jwt.WithAudience(cfg.AuthJwtAud)) +func readLocalPublicKeyWithLock(keyPath string) error { + localKeyMutex.RLock() + alreadyLoaded := isKeyLoadedForPath(keyPath) + localKeyMutex.RUnlock() + + if alreadyLoaded { + return nil + } + + localKeyMutex.Lock() + defer localKeyMutex.Unlock() + + if isKeyLoadedForPath(keyPath) { + return nil + } + + localKeyInitErr = loadPublicKeyFromFile(keyPath) + return localKeyInitErr +} + +func readLocalPublicKey(cfg *config.Config) error { + if cfg.AuthJwtPubKeyPath == "" { + return errors.New("no JWT public key path configured") + } + return readLocalPublicKeyWithLock(cfg.AuthJwtPubKeyPath) } func parseJwtTokenWithLocalKey(cfg *config.Config, jwtString string) (*jwt.Token, error) { @@ -97,33 +168,6 @@ func parseJwtTokenWithHMAC(cfg *config.Config, jwtString string) (*jwt.Token, er }) } -func parseJwtToken(cfg *config.Config, jwtString string) (*jwt.Token, error) { - if cfg.AuthJwtCertsURL != "" { - return parseJwtTokenWithRemoteKey(cfg, jwtString) - } - - if cfg.AuthJwtPubKeyPath != "" { - return parseJwtTokenWithLocalKey(cfg, jwtString) - } - - return parseJwtTokenWithHMAC(cfg, jwtString) -} - -func getClaimsFromJwtToken(cfg *config.Config, jwtString string) (jwt.MapClaims, error) { - token, err := parseJwtToken(cfg, jwtString) - - if err != nil { - log.Errorf("jwt parse failure: %v", err) - return nil, errors.New("jwt parse failure") - } - - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - return claims, nil - } else { - return nil, errors.New("jwt token isn't valid") - } -} - func lookupClaimValueOrDefault(claims jwt.MapClaims, key string, def string) string { if val, ok := claims[key]; ok { return fmt.Sprintf("%s", val) @@ -132,33 +176,47 @@ func lookupClaimValueOrDefault(claims jwt.MapClaims, key string, def string) str } } -func parseJwtCookie(cfg *config.Config, request *http.Request) (string, string) { - cookie, err := request.Cookie(cfg.AuthJwtCookieName) +func CheckUserFromJwtCookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser { + cookie, err := context.Request.Cookie(context.Config.AuthJwtCookieName) if err != nil { - log.Debugf("jwt cookie check %v name: %v", err, cfg.AuthJwtCookieName) - return "", "" + log.Debugf("jwt cookie check %v name: %v", err, context.Config.AuthJwtCookieName) + return nil } - return parseJwt(cfg, cookie.Value) + return parseJwt(context.Config, cookie.Value) } -func parseJwt(cfg *config.Config, token string) (string, string) { +func CheckUserFromJwtHeader(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser { + header := context.Request.Header.Get(context.Config.AuthJwtHeader) + if header == "" { + return nil + } + + token := strings.TrimPrefix(header, "Bearer ") + token = strings.TrimSpace(token) + + return parseJwt(context.Config, token) +} + +func parseJwt(cfg *config.Config, token string) *authTypes.AuthenticatedUser { claims, err := getClaimsFromJwtToken(cfg, token) if err != nil { log.Warnf("jwt claim error: %+v", err) - return "", "" + return nil } if cfg.InsecureAllowDumpJwtClaims { log.Debugf("JWT Claims %+v", claims) } - username := lookupClaimValueOrDefault(claims, cfg.AuthJwtClaimUsername, "") - usergroup := parseGroupClaim(cfg.AuthJwtClaimUserGroup, claims) + user := &authTypes.AuthenticatedUser{ + Username: lookupClaimValueOrDefault(claims, cfg.AuthJwtClaimUsername, ""), + UsergroupLine: parseGroupClaim(cfg.AuthJwtClaimUserGroup, claims), + } - return username, usergroup + return user } func parseGroupClaim(groupClaim string, claims jwt.MapClaims) string { diff --git a/service/internal/auth/otjwt/jwt_test.go b/service/internal/auth/otjwt/jwt_test.go new file mode 100644 index 0000000..718588b --- /dev/null +++ b/service/internal/auth/otjwt/jwt_test.go @@ -0,0 +1,205 @@ +package otjwt + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "io" + + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/OliveTin/OliveTin/internal/auth/authpublic" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" +) + +func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate RSA key: %v", err) + } + + pubKey := &privateKey.PublicKey + pkixPubKey, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + t.Fatalf("failed to marshal public key: %v", err) + } + + pubPem := pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: pkixPubKey, + }, + ) + + return privateKey, pubPem +} + +func createKeys(t *testing.T) (*rsa.PrivateKey, string) { + tmpFile, err := os.CreateTemp(os.TempDir(), "olivetin-jwt-") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer tmpFile.Close() + + t.Logf("Created File: %s", tmpFile.Name()) + + privateKey, pubPem := generateRSAKeyPair(t) + + if err := os.WriteFile(tmpFile.Name(), pubPem, 0644); err != nil { + t.Fatalf("error when dumping pubKey: %s \n", err) + } + + return privateKey, tmpFile.Name() +} + +func newMux() *http.ServeMux { + mux := http.NewServeMux() + + return mux +} + +func createJWTTokenWithExpiration(t *testing.T, privateKey *rsa.PrivateKey, expire int64) string { + token := jwt.New(jwt.SigningMethodRS256) + claims := token.Claims.(jwt.MapClaims) + claims["nbf"] = time.Now().Unix() - 1000 + claims["exp"] = time.Now().Unix() + expire + claims["sub"] = "test" + claims["olivetinGroup"] = "test" + + tokenStr, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("failed to sign JWT token: %v", err) + } + return tokenStr +} + +func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler { + mux := newMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + context := &authpublic.AuthCheckingContext{ + Request: r, + Config: cfg, + } + user := CheckUserFromJwtHeader(context) + + if user == nil { + w.WriteHeader(403) + return + } + + assert.Equal(t, "test", user.Username) + assert.Equal(t, "test", user.UsergroupLine) + }) + return mux +} + +func verifyJWTResponse(t *testing.T, res *http.Response, expectCode int) { + defer res.Body.Close() + assert.Equal(t, expectCode, res.StatusCode) + body, _ := io.ReadAll(res.Body) + t.Logf("Response body: %s", string(body)) +} + +func testJwkValidation(t *testing.T, expire int64, expectCode int) { + privateKey, publicKeyPath := createKeys(t) + defer os.Remove(publicKeyPath) + + cfg := config.DefaultConfig() + cfg.AuthJwtPubKeyPath = publicKeyPath + cfg.AuthJwtClaimUsername = "sub" + cfg.AuthJwtClaimUserGroup = "olivetinGroup" + cfg.AuthJwtHeader = "Authorization" + + tokenStr := createJWTTokenWithExpiration(t, privateKey, expire) + handler := setupJWTTestHandler(t, cfg) + + srv := httptest.NewServer(handler) + defer srv.Close() + + res := makeJWTRequest(t, srv, tokenStr) + verifyJWTResponse(t, res, expectCode) +} + +func TestJWTSignatureVerificationSucceeds(t *testing.T) { + testJwkValidation(t, 1000, 200) +} + +func TestJWTSignatureVerificationFails(t *testing.T) { + testJwkValidation(t, -500, 403) +} + +func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups interface{}) string { + token := jwt.New(jwt.SigningMethodRS256) + claims := token.Claims.(jwt.MapClaims) + claims["nbf"] = time.Now().Unix() - 1000 + claims["exp"] = time.Now().Unix() + 2000 + claims["sub"] = "test" + claims["olivetinGroup"] = groups + + tokenStr, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("failed to sign JWT token: %v", err) + } + return tokenStr +} + +func makeJWTRequest(t *testing.T, srv *httptest.Server, tokenStr string) *http.Response { + req, err := http.NewRequest("GET", srv.URL, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+tokenStr) + + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Client err: %+v", err) + } + return res +} + +func TestJWTHeader(t *testing.T) { + privateKey, publicKeyPath := createKeys(t) + defer os.Remove(publicKeyPath) + + cfg := config.DefaultConfig() + cfg.AuthJwtPubKeyPath = publicKeyPath + cfg.AuthJwtClaimUsername = "sub" + cfg.AuthJwtClaimUserGroup = "olivetinGroup" + cfg.AuthJwtHeader = "Authorization" + + tokenStr := createJWTTokenWithGroups(t, privateKey, []string{"test", "test2"}) + + mux := newMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + context := &authpublic.AuthCheckingContext{ + Request: r, + Config: cfg, + } + user := CheckUserFromJwtHeader(context) + + if user == nil { + w.WriteHeader(403) + return + } + + assert.Equal(t, "test", user.Username) + assert.Equal(t, "test test2", user.UsergroupLine) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + res := makeJWTRequest(t, srv, tokenStr) + defer res.Body.Close() + + assert.Equal(t, 200, res.StatusCode) + body, _ := io.ReadAll(res.Body) + t.Logf("Response body: %s", string(body)) +} diff --git a/service/internal/httpservers/restapi_auth_oauth2.go b/service/internal/auth/otoauth2/restapi_auth_oauth2.go similarity index 90% rename from service/internal/httpservers/restapi_auth_oauth2.go rename to service/internal/auth/otoauth2/restapi_auth_oauth2.go index 3659aa9..61037b6 100644 --- a/service/internal/httpservers/restapi_auth_oauth2.go +++ b/service/internal/auth/otoauth2/restapi_auth_oauth2.go @@ -1,4 +1,4 @@ -package httpservers +package otoauth2 import ( "context" @@ -13,6 +13,7 @@ import ( "os" "time" + authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" @@ -111,7 +112,7 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re cookie := &http.Cookie{ Name: name, Value: value, - MaxAge: 31556952, // 1 year + MaxAge: 900, // 15 minutes Secure: r.TLS != nil, HttpOnly: true, Path: "/", @@ -120,7 +121,7 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re http.SetCookie(w, cookie) } -func (h *OAuth2Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) { +func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) { state, err := randString(16) if err != nil { @@ -150,30 +151,31 @@ func (h *OAuth2Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusFound) } +func (h *OAuth2Handler) validateStateMatch(queryState, cookieState string) bool { + return queryState == cookieState +} + func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, string, bool) { cookie, err := r.Cookie("olivetin-sid-oauth") - state := cookie.Value - if err != nil { log.Errorf("Failed to get state cookie: %v", err) - http.Error(w, "State not found", http.StatusBadRequest) - return nil, state, false + return nil, "", false } - if r.URL.Query().Get("state") != state { - log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state) + state := cookie.Value + if !h.validateStateMatch(r.URL.Query().Get("state"), state) { + log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state) http.Error(w, "State mismatch", http.StatusBadRequest) return nil, state, false } registeredState, ok := h.registeredStates[state] - if !ok { log.Errorf("State not found in server: %v", state) - http.Error(w, "State not found in server", http.StatusBadRequest) + return nil, state, false } return registeredState, state, true @@ -211,13 +213,13 @@ func getOAuthCertBundle(providerConfig *config.OAuth2Provider) *x509.CertPool { caCertPool := x509.NewCertPool() if ok := caCertPool.AppendCertsFromPEM(caCert); !ok { - log.Errorf("OAuth2 Cert Bundle - failed to append certificates: %v", err) + log.Errorf("OAuth2 Cert Bundle - failed to append certificates from PEM") } return caCertPool } -func (h *OAuth2Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { +func (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { log.Infof("OAuth2 Callback received") registeredState, state, ok := h.checkOAuthCallbackCookie(w, r) @@ -266,17 +268,7 @@ func (h *OAuth2Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Reque h.registeredStates[state].Username = userinfo.Username h.registeredStates[state].Usergroup = userinfo.Usergroup - for k, v := range h.registeredStates { - log.Debugf("states: %+v %+v", k, v) - } - - log.WithFields(log.Fields{ - "state": state, - "username": h.registeredStates[state].Username, - }).Info("OAuth2 login successful") - http.Redirect(w, r, "/", http.StatusFound) - w.Write([]byte("OAuth2 login successful.")) } type UserInfo struct { @@ -352,16 +344,18 @@ func getDataField(data map[string]any, field string) string { return stringVal } -func (h *OAuth2Handler) parseOAuth2Cookie(r *http.Request) (string, string, string) { - cookie, err := r.Cookie("olivetin-sid-oauth") +func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser { + cookie, err := context.Request.Cookie("olivetin-sid-oauth") + + user := &authTypes.AuthenticatedUser{} if err != nil { log.Warnf("Failed to read OAuth2 cookie: %v", err) - return "", "", "" + return nil } if cookie.Value == "" { - return "", "", "" + return nil } serverState, found := h.registeredStates[cookie.Value] @@ -372,10 +366,13 @@ func (h *OAuth2Handler) parseOAuth2Cookie(r *http.Request) (string, string, stri "provider": "oauth2", }).Warnf("Stale session") - return "", "", cookie.Value + return nil } - log.Debugf("Found OAuth2 state: %+v", serverState) + user.Username = serverState.Username + user.UsergroupLine = serverState.Usergroup + user.Provider = "oauth2" + user.SID = cookie.Value - return serverState.Username, serverState.Usergroup, cookie.Value + return user } diff --git a/service/internal/httpservers/restapi_auth_oauth2_providers.go b/service/internal/auth/otoauth2/restapi_auth_oauth2_providers.go similarity index 87% rename from service/internal/httpservers/restapi_auth_oauth2_providers.go rename to service/internal/auth/otoauth2/restapi_auth_oauth2_providers.go index 3da2d56..ec43304 100644 --- a/service/internal/httpservers/restapi_auth_oauth2_providers.go +++ b/service/internal/auth/otoauth2/restapi_auth_oauth2_providers.go @@ -1,4 +1,4 @@ -package httpservers +package otoauth2 import ( config "github.com/OliveTin/OliveTin/internal/config" @@ -13,14 +13,17 @@ var oauth2ProviderDatabase = map[string]config.OAuth2Provider{ WhoamiUrl: "https://api.github.com/user", TokenUrl: endpoints.GitHub.TokenURL, AuthUrl: endpoints.GitHub.AuthURL, - Scopes: []string{"profile", "email"}, + Scopes: []string{"user", "user:email"}, UsernameField: "login", }, "google": { - Icon: "google", - WhoamiUrl: "https://www.googleapis.com/oauth2/v3/userinfo", - TokenUrl: endpoints.Google.TokenURL, - AuthUrl: endpoints.Google.AuthURL, - Scopes: []string{"profile", "email"}, + Title: "Google", + Name: "google", + Icon: "google", + UsernameField: "preferred_username", + WhoamiUrl: "https://www.googleapis.com/oauth2/v3/userinfo", + TokenUrl: endpoints.Google.TokenURL, + AuthUrl: endpoints.Google.AuthURL, + Scopes: []string{"profile", "email"}, }, } diff --git a/service/internal/auth/system-users.go b/service/internal/auth/system-users.go new file mode 100644 index 0000000..5d41954 --- /dev/null +++ b/service/internal/auth/system-users.go @@ -0,0 +1,29 @@ +package auth + +import ( + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" + config "github.com/OliveTin/OliveTin/internal/config" +) + +func UserGuest(cfg *config.Config) *authpublic.AuthenticatedUser { + ret := &authpublic.AuthenticatedUser{} + ret.Username = "guest" + ret.UsergroupLine = "guest" + ret.Provider = "system" + + ret.BuildUserAcls(cfg) + + return ret +} + +func UserFromSystem(cfg *config.Config, username string) *authpublic.AuthenticatedUser { + ret := &authpublic.AuthenticatedUser{ + Username: username, + UsergroupLine: "system", + Provider: "system", + } + + ret.BuildUserAcls(cfg) + + return ret +} diff --git a/service/internal/auth/trusted-headers.go b/service/internal/auth/trusted-headers.go new file mode 100644 index 0000000..23cb46f --- /dev/null +++ b/service/internal/auth/trusted-headers.go @@ -0,0 +1,33 @@ +package auth + +import ( + "net/http" + + types "github.com/OliveTin/OliveTin/internal/auth/authpublic" +) + +//gocyclo:ignore +func checkUserFromHeaders(context *types.AuthCheckingContext) *types.AuthenticatedUser { + u := &types.AuthenticatedUser{} + + if context.Config.AuthHttpHeaderUsername != "" { + u.Username = getHeaderKeyOrEmpty(context.Request.Header, context.Config.AuthHttpHeaderUsername) + } + + if context.Config.AuthHttpHeaderUserGroup != "" { + u.UsergroupLine = getHeaderKeyOrEmpty(context.Request.Header, context.Config.AuthHttpHeaderUserGroup) + } + + if prov := getHeaderKeyOrEmpty(context.Request.Header, "provider"); prov != "" { + u.Provider = prov + } + return u +} + +func getHeaderKeyOrEmpty(headers http.Header, key string) string { + values := headers.Values(key) + if len(values) > 0 { + return values[0] + } + return "" +} diff --git a/service/internal/executor/arguments.go b/service/internal/executor/arguments.go index 960014d..b63c4fb 100644 --- a/service/internal/executor/arguments.go +++ b/service/internal/executor/arguments.go @@ -333,12 +333,13 @@ func mangleCheckboxValues(req *ExecutionRequest, arg *config.ActionArgument) { log.Infof("Checking checkbox values for argument %s in action %s", arg.Name, req.Binding.Action.Title) - for i, _ := range arg.Choices { + for i, v := range arg.Choices { choice := &arg.Choices[i] if req.Arguments[arg.Name] == choice.Title { log.WithFields(log.Fields{ "arg": arg.Name, + "choice": v, "oldValue": req.Arguments[arg.Name], "newValue": choice.Value, "actionTitle": req.Binding.Action.Title, diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index 45862ef..a42e8c7 100644 --- a/service/internal/executor/executor.go +++ b/service/internal/executor/executor.go @@ -2,6 +2,8 @@ package executor import ( acl "github.com/OliveTin/OliveTin/internal/acl" + "github.com/OliveTin/OliveTin/internal/auth" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" "github.com/google/uuid" @@ -69,7 +71,7 @@ type ExecutionRequest struct { TrackingID string Tags []string Cfg *config.Config - AuthenticatedUser *acl.AuthenticatedUser + AuthenticatedUser *authpublic.AuthenticatedUser TriggerDepth int logEntry *InternalLogEntry @@ -230,11 +232,11 @@ func isValidLogEntryForACL(entry *InternalLogEntry) bool { } // isLogEntryAllowedByACL checks if a log entry is allowed to be viewed by the user. -func isLogEntryAllowedByACL(cfg *config.Config, user *acl.AuthenticatedUser, entry *InternalLogEntry) bool { +func isLogEntryAllowedByACL(cfg *config.Config, user *authpublic.AuthenticatedUser, entry *InternalLogEntry) bool { return acl.IsAllowedLogs(cfg, user, entry.Binding.Action) } -func (e *Executor) filterLogsByACL(cfg *config.Config, user *acl.AuthenticatedUser) []*InternalLogEntry { +func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.AuthenticatedUser) []*InternalLogEntry { e.logmutex.RLock() defer e.logmutex.RUnlock() @@ -280,7 +282,7 @@ func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageC // GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and // paginated correctly based on the filtered set. -func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) { +func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *authpublic.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) { filtered := e.filterLogsByACL(cfg, user) return paginateFilteredLogs(filtered, startOffset, pageCount) } @@ -323,7 +325,7 @@ func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) { // ExecRequest processes an ExecutionRequest func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) { if req.AuthenticatedUser == nil { - req.AuthenticatedUser = acl.UserGuest(req.Cfg) + req.AuthenticatedUser = auth.UserGuest(req.Cfg) } req.executor = e diff --git a/service/internal/executor/executor_test.go b/service/internal/executor/executor_test.go index f0a2ab8..89559bb 100644 --- a/service/internal/executor/executor_test.go +++ b/service/internal/executor/executor_test.go @@ -5,7 +5,8 @@ import ( "github.com/stretchr/testify/assert" - acl "github.com/OliveTin/OliveTin/internal/acl" + "github.com/OliveTin/OliveTin/internal/auth" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" ) @@ -35,7 +36,7 @@ func TestCreateExecutorAndExec(t *testing.T) { e, cfg := testingExecutor() req := ExecutionRequest{ - AuthenticatedUser: &acl.AuthenticatedUser{Username: "Mr Tickle"}, + AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "Mr Tickle"}, Cfg: cfg, Arguments: map[string]string{ "person": "yourself", @@ -273,7 +274,7 @@ func TestMangleInvalidArgumentValues(t *testing.T) { req := ExecutionRequest{ // Action: a1, - AuthenticatedUser: acl.UserFromSystem(cfg, "testuser"), + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), Cfg: cfg, Arguments: map[string]string{ "date": "1990-01-10T12:00", // Invalid format, should be without seconds diff --git a/service/internal/httpservers/singleFrontend.go b/service/internal/httpservers/frontend.go similarity index 79% rename from service/internal/httpservers/singleFrontend.go rename to service/internal/httpservers/frontend.go index e42e493..cda9cb5 100644 --- a/service/internal/httpservers/singleFrontend.go +++ b/service/internal/httpservers/frontend.go @@ -15,6 +15,8 @@ import ( "path" "github.com/OliveTin/OliveTin/internal/api" + "github.com/OliveTin/OliveTin/internal/auth" + "github.com/OliveTin/OliveTin/internal/auth/otoauth2" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/executor" log "github.com/sirupsen/logrus" @@ -32,13 +34,13 @@ func logDebugRequest(cfg *config.Config, source string, r *http.Request) { } } -// StartSingleHTTPFrontend will create a reverse proxy that proxies the API -// and webui internally. -func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) { +func StartFrontendMux(cfg *config.Config, ex *executor.Executor) { log.WithFields(log.Fields{ "address": cfg.ListenAddressSingleHTTPFrontend, }).Info("Starting single HTTP frontend") + go StartPrometheus(cfg) + mux := http.NewServeMux() apiPath, apiHandler := api.GetNewHandler(ex) @@ -62,10 +64,11 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) { apiHandler.ServeHTTP(w, r) })) - oauth2handler := NewOAuth2Handler(cfg) + oauth2handler := otoauth2.NewOAuth2Handler(cfg) + auth.AddAuthChainFunction(oauth2handler.CheckUserFromOAuth2Cookie) - mux.HandleFunc("/oauth/login", oauth2handler.handleOAuthLogin) - mux.HandleFunc("/oauth/callback", oauth2handler.handleOAuthCallback) + mux.HandleFunc("/oauth/login", oauth2handler.HandleOAuthLogin) + mux.HandleFunc("/oauth/callback", oauth2handler.HandleOAuthCallback) mux.HandleFunc("/readyz", handleReadyz) @@ -97,5 +100,11 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) { func handleReadyz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) - w.Write([]byte("OK. Single HTTP Frontend is ready.\n")) + _, err := w.Write([]byte("OK. Single HTTP Frontend is ready.\n")) + + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Warnf("Failed to write readyz response") + } } diff --git a/service/internal/httpservers/httpServer.go b/service/internal/httpservers/httpServer.go deleted file mode 100644 index 84fec9e..0000000 --- a/service/internal/httpservers/httpServer.go +++ /dev/null @@ -1,16 +0,0 @@ -package httpservers - -import ( - config "github.com/OliveTin/OliveTin/internal/config" - "github.com/OliveTin/OliveTin/internal/executor" -) - -// StartServers will start 3 HTTP servers. The WebUI, the Rest API, and a proxy -// for both of them. -func StartServers(cfg *config.Config, ex *executor.Executor) { - if cfg.Prometheus.Enabled { - go StartPrometheus(cfg) - } - - StartSingleHTTPFrontend(cfg, ex) -} diff --git a/service/internal/httpservers/prometheus.go b/service/internal/httpservers/prometheus.go index 6425145..868e113 100644 --- a/service/internal/httpservers/prometheus.go +++ b/service/internal/httpservers/prometheus.go @@ -7,13 +7,25 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" + log "github.com/sirupsen/logrus" ) func StartPrometheus(cfg *config.Config) { + if !cfg.Prometheus.Enabled { + return + } + if !cfg.Prometheus.DefaultGoMetrics { prometheus.Unregister(collectors.NewGoCollector()) } http.Handle("/", promhttp.Handler()) - http.ListenAndServe(cfg.ListenAddressPrometheus, nil) + err := http.ListenAndServe(cfg.ListenAddressPrometheus, nil) + + if err != nil { + log.WithFields(log.Fields{ + "address": cfg.ListenAddressPrometheus, + "error": err, + }).Warnf("Failed to start Prometheus server") + } } diff --git a/service/internal/httpservers/restapi_auth_jwt_test.go b/service/internal/httpservers/restapi_auth_jwt_test.go deleted file mode 100644 index 3467a12..0000000 --- a/service/internal/httpservers/restapi_auth_jwt_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package httpservers - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "fmt" - // config "github.com/OliveTin/OliveTin/internal/config" - // "github.com/golang-jwt/jwt/v4" - // "github.com/stretchr/testify/assert" - "net/http" - "os" - "testing" - // "time" -) - -func createKeys(t *testing.T) (*rsa.PrivateKey, string) { - tmpFile, _ := os.CreateTemp(os.TempDir(), "olivetin-jwt-") - - fmt.Println("Created File: " + tmpFile.Name()) - - privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) - pubKey := &privateKey.PublicKey - // https://stackoverflow.com/questions/13555085/save-and-load-crypto-rsa-privatekey-to-and-from-the-disk - pkixPubKey, _ := x509.MarshalPKIXPublicKey(pubKey) - pubPem := pem.EncodeToMemory( - &pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: pkixPubKey, - }, - ) - - if err := os.WriteFile(tmpFile.Name(), pubPem, 0755); err != nil { - t.Fatalf("error when dumping pubKey: %s \n", err) - } - - return privateKey, tmpFile.Name() -} - -func newMux() *http.ServeMux { - mux := http.NewServeMux() - - return mux -} - -func testJwkValidation(t *testing.T, expire int64, expectCode int) { - /* - privateKey, publicKeyPath := createKeys(t) - - defer os.Remove(publicKeyPath) - - cfg := config.DefaultConfig() - cfg.AuthJwtPubKeyPath = publicKeyPath - cfg.AuthJwtClaimUsername = "sub" - cfg.AuthJwtClaimUserGroup = "olivetinGroup" - cfg.AuthJwtCookieName = "authorization_token" - - token := jwt.New(jwt.SigningMethodRS256) - - claims := token.Claims.(jwt.MapClaims) - claims["nbf"] = time.Now().Unix() - 1000 - claims["exp"] = time.Now().Unix() + expire - claims["sub"] = "test" - claims["olivetinGroup"] = "test" - */ - - /* - tokenStr, _ := token.SignedString(privateKey) - - mux := newMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) { - username, usergroup := parseJwtCookie(cfg, r) - - if username == "" { - w.WriteHeader(403) - } - - w.Write([]byte(fmt.Sprintf("username=%v, usergroup=%v", username, usergroup))) - }) - - srv := setupTestingServer(mux, t) - - req, client := newReq("") - req.AddCookie(&http.Cookie{ - Name: "authorization_token", - Value: tokenStr, - MaxAge: 300, - }) - - res, err := client.Do(req) - - if err != nil { - t.Fatalf("Client err: %+v", err) - } else { - defer res.Body.Close() - assert.Equal(t, expectCode, res.StatusCode) - body, _ := io.ReadAll(res.Body) - fmt.Println(string(body)) - } - - err = srv.Shutdown(context.TODO()) - - if err != nil { - t.Fatalf("Server shutdown error: %+v", err) - } - */ -} - -func TestJWTSignatureVerificationSucceeds(t *testing.T) { - testJwkValidation(t, 1000, 200) -} - -func TestJWTSignatureVerificationFails(t *testing.T) { - testJwkValidation(t, -500, 403) -} - -func TestJWTHeader(t *testing.T) { - /* - privateKey, publicKeyPath := createKeys(t) - - defer os.Remove(publicKeyPath) - - cfg := config.DefaultConfig() - cfg.AuthJwtPubKeyPath = publicKeyPath - cfg.AuthJwtClaimUsername = "sub" - cfg.AuthJwtClaimUserGroup = "olivetinGroup" - cfg.AuthJwtHeader = "Authorization" - - token := jwt.New(jwt.SigningMethodRS256) - - claims := token.Claims.(jwt.MapClaims) - claims["nbf"] = time.Now().Unix() - 1000 - claims["exp"] = time.Now().Unix() + 2000 - claims["sub"] = "test" - claims["olivetinGroup"] = []string{"test", "test2"} - */ - - /* - tokenStr, _ := token.SignedString(privateKey) - - mux := newMux() - mux.HandlePath("GET", "/", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) { - username, usergroup := parseJwtHeader(cfg, r) - - if username == "" { - w.WriteHeader(403) - } - - assert.Equal(t, "test", username) - assert.Equal(t, "test test2", usergroup) - - w.Write([]byte(fmt.Sprintf("username=%v, usergroup=%v", username, usergroup))) - }) - - srv := setupTestingServer(mux, t) - - req, client := newReq("") - req.Header.Set("Authorization", "Bearer "+tokenStr) - - res, err := client.Do(req) - - if err != nil { - t.Fatalf("Client err: %+v", err) - } else { - defer res.Body.Close() - assert.Equal(t, 200, res.StatusCode) - body, _ := io.ReadAll(res.Body) - fmt.Println(string(body)) - } - - srv.Shutdown(context.TODO()) - */ -} diff --git a/service/internal/httpservers/restapi_auth_local.go b/service/internal/httpservers/restapi_auth_local.go deleted file mode 100644 index 197b840..0000000 --- a/service/internal/httpservers/restapi_auth_local.go +++ /dev/null @@ -1,34 +0,0 @@ -package httpservers - -import ( - "net/http" - - "github.com/OliveTin/OliveTin/internal/auth" - "github.com/OliveTin/OliveTin/internal/config" - log "github.com/sirupsen/logrus" -) - -func parseLocalUserCookie(cfg *config.Config, req *http.Request) (string, string, string) { - cookie, err := req.Cookie("olivetin-sid-local") - - if err != nil { - return "", "", "" - } - - cookieValue := cookie.Value - - session := auth.GetUserSession("local", cookieValue) - if session == nil { - return "", "", "" - } - - user := cfg.FindUserByUsername(session.Username) - if user == nil { - log.WithFields(log.Fields{ - "username": session.Username, - }).Warnf("User not found in config") - return "", "", "" - } - - return user.Username, user.Usergroup, cookie.Value -} diff --git a/service/internal/httpservers/restapi_test.go b/service/internal/httpservers/restapi_test.go deleted file mode 100644 index 8fb7523..0000000 --- a/service/internal/httpservers/restapi_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package httpservers - -import () diff --git a/service/internal/httpservers/webuiServer.go b/service/internal/httpservers/webuiServer.go index 7a0aa8f..38f8f6b 100644 --- a/service/internal/httpservers/webuiServer.go +++ b/service/internal/httpservers/webuiServer.go @@ -93,22 +93,35 @@ func (s *webUIServer) setupCustomWebuiDir() { } } +func shouldReloadThemeCss() bool { + return !customThemeCssRead +} + +func loadThemeCssFromFile(filename string) []byte { + _, err := os.Stat(filename) + if err == nil { + css, _ := os.ReadFile(filename) + return css + } + log.Debugf("Theme CSS not read: %v", err) + return []byte("/* not found */") +} + func (s *webUIServer) generateThemeCss(w http.ResponseWriter, r *http.Request) { themeCssFilename := path.Join(s.findCustomWebuiDir(), "themes", s.cfg.ThemeName, "theme.css") - if !customThemeCssRead || s.cfg.ThemeCacheDisabled { + if shouldReloadThemeCss() || s.cfg.ThemeCacheDisabled { customThemeCssRead = true - - if _, err := os.Stat(themeCssFilename); err == nil { - customThemeCss, _ = os.ReadFile(themeCssFilename) - } else { - log.Debugf("Theme CSS not read: %v", err) - customThemeCss = []byte("/* not found */") - } + customThemeCss = loadThemeCssFromFile(themeCssFilename) } w.Header().Add("Content-Type", "text/css") - w.Write(customThemeCss) + _, err := w.Write(customThemeCss) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Warnf("Failed to write theme CSS") + } } func (s *webUIServer) handleCustomWebui() http.Handler { diff --git a/service/internal/oncalendarfile/calendar.go b/service/internal/oncalendarfile/calendar.go index f7b837e..266ea01 100644 --- a/service/internal/oncalendarfile/calendar.go +++ b/service/internal/oncalendarfile/calendar.go @@ -2,14 +2,15 @@ package oncalendarfile import ( "context" - "github.com/OliveTin/OliveTin/internal/acl" + "os" + "time" + + "github.com/OliveTin/OliveTin/internal/auth" "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/executor" "github.com/OliveTin/OliveTin/internal/filehelper" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" - "os" - "time" ) func Schedule(cfg *config.Config, ex *executor.Executor) { @@ -105,7 +106,7 @@ func exec(instant time.Time, action *config.Action, cfg *config.Config, ex *exec Binding: ex.FindBindingWithNoEntity(action), Cfg: cfg, Tags: []string{}, - AuthenticatedUser: acl.UserFromSystem(cfg, "calendar"), + AuthenticatedUser: auth.UserFromSystem(cfg, "calendar"), } ex.ExecRequest(req) diff --git a/service/internal/oncron/cron.go b/service/internal/oncron/cron.go index 0839fd1..281c48a 100644 --- a/service/internal/oncron/cron.go +++ b/service/internal/oncron/cron.go @@ -1,7 +1,7 @@ package oncron import ( - "github.com/OliveTin/OliveTin/internal/acl" + "github.com/OliveTin/OliveTin/internal/auth" "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/executor" "github.com/robfig/cron/v3" @@ -37,7 +37,7 @@ func scheduleAction(cfg *config.Config, scheduler *cron.Cron, cronline string, e Binding: ex.FindBindingWithNoEntity(action), Cfg: cfg, Tags: []string{}, - AuthenticatedUser: acl.UserFromSystem(cfg, "cron"), + AuthenticatedUser: auth.UserFromSystem(cfg, "cron"), } ex.ExecRequest(req) diff --git a/service/internal/onfileindir/fileindir.go b/service/internal/onfileindir/fileindir.go index 1297829..40bd19c 100644 --- a/service/internal/onfileindir/fileindir.go +++ b/service/internal/onfileindir/fileindir.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "github.com/OliveTin/OliveTin/internal/acl" + "github.com/OliveTin/OliveTin/internal/auth" "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/executor" "github.com/OliveTin/OliveTin/internal/filehelper" @@ -54,7 +54,7 @@ func scheduleExec(action *config.Action, cfg *config.Config, ex *executor.Execut Cfg: cfg, Tags: []string{}, Arguments: args, - AuthenticatedUser: acl.UserFromSystem(cfg, "fileindir"), + AuthenticatedUser: auth.UserFromSystem(cfg, "fileindir"), } ex.ExecRequest(req) diff --git a/service/internal/onstartup/startup.go b/service/internal/onstartup/startup.go index 297011e..0442ada 100644 --- a/service/internal/onstartup/startup.go +++ b/service/internal/onstartup/startup.go @@ -1,14 +1,14 @@ package onstartup import ( - "github.com/OliveTin/OliveTin/internal/acl" + "github.com/OliveTin/OliveTin/internal/auth" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/executor" log "github.com/sirupsen/logrus" ) func Execute(cfg *config.Config, ex *executor.Executor) { - user := acl.UserFromSystem(cfg, "startup") + user := auth.UserFromSystem(cfg, "startup") for _, action := range cfg.Actions { if action.ExecOnStartup { diff --git a/service/main.go b/service/main.go index b0ed402..dbf4878 100644 --- a/service/main.go +++ b/service/main.go @@ -130,7 +130,13 @@ func getConfigPath(directory string) string { func initConfig(configDir string) { k := koanf.New(".") - k.Load(env.Provider(".", ".", nil), nil) + err := k.Load(env.Provider(".", ".", nil), nil) + + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Fatalf("Error loading environment variables") + } directories := []string{ configDir, @@ -180,13 +186,26 @@ func initConfig(configDir string) { os.Exit(1) } - f.Watch(func(evt interface{}, err error) { + err := f.Watch(func(evt interface{}, err error) { log.Infof("config file changed: %v", evt) - k.Load(f, yaml.Parser()) + errLoad := k.Load(f, yaml.Parser()) + + if errLoad != nil { + log.WithFields(log.Fields{ + "error": errLoad, + }).Fatalf("Error loading config file") + } + config.AppendSource(cfg, k, configPath) }) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Fatalf("Error watching config file") + } + break } @@ -251,5 +270,5 @@ func main() { // Load persistent sessions from disk auth.LoadUserSessions(cfg) - httpservers.StartServers(cfg, executor) + httpservers.StartFrontendMux(cfg, executor) }