diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 3ec2fa3..8438f60 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -96,6 +96,7 @@ * xref:security/concepts.adoc[Security] ** xref:security/acl.adoc[Access Control Lists] ** xref:security/local.adoc[Local Users Authorization] +** xref:security/api_keys.adoc[API Keys] ** xref:security/trusted_header.adoc[Trusted Header Authorization] ** xref:security/jwt.adoc[JWT Authorization] *** xref:security/jwt_keys.adoc[JWT with Keys] diff --git a/docs/modules/ROOT/pages/security/api_keys.adoc b/docs/modules/ROOT/pages/security/api_keys.adoc new file mode 100644 index 0000000..b19b211 --- /dev/null +++ b/docs/modules/ROOT/pages/security/api_keys.adoc @@ -0,0 +1,66 @@ +[#api-keys] += API Keys + +This page is for **developers** who want to call OliveTin's HTTP API (Connect RPC under `/api/`) using a **Bearer token**, without using the interactive web login. + +API keys are configured on xref:security/local.adoc[local users] as an optional `apiKey` field. When present, clients can authenticate by sending: + +---- +Authorization: Bearer +---- + +The prefix `Bearer ` (including the trailing space after `Bearer`) must match exactly. + +== Configuration + +include::partial$config-start.adoc[] +---- +authLocalUsers: + enabled: true + users: + - username: automation + usergroup: bots + apiKey: "{{ .Env.OLIVETIN_AUTOMATION_KEY }}" + + - username: alice + usergroup: admins + password: $argon2id$v=19$m=65536,t=4,p=6$... + apiKey: "{{ .Env.OLIVETIN_ALICE_API_KEY }}" +---- + +* Use a **long, random** API key (similar to any other bearer secret). +* Prefer loading the key from the environment with `{{ .Env.VAR }}` instead of committing the raw value to disk. +* **TLS**: send bearer tokens only over HTTPS in real deployments. +* **Interactive login**: if a user has **no** `password` configured, they **cannot** use the `/login` page; they can only authenticate with an API key (or another auth mechanism you configure separately). + +Two local users **must not** share the same `apiKey` value. OliveTin will refuse to start if duplicate keys are detected. + +== Authorization (permissions) + +API key authentication uses the same **username** and **usergroup** as the matching local user. xref:security/acl.adoc[Access Control Lists] and `defaultPermissions` apply in the same way as for users who sign in via the web UI. + +== Example: curl and Init + +The OliveTin API is **Connect RPC**. Unary calls accept JSON bodies. The following example calls `Init` with an empty request object: + +[source,bash] +---- +curl -sS -X POST \ + -H "Authorization: Bearer YOUR_API_KEY_HERE" \ + -H "Content-Type: application/json" \ + "https://olivetin.example.com:1337/api/olivetin.api.v1.OliveTinApiService/Init" \ + --data '{}' +---- + +Replace the host, port, and path prefix if your installation differs. Other RPCs use the same URL pattern with a different final segment (method name). + +== Operational security notes + +* **Reverse proxies**: if you use xref:security/trusted_header.adoc[Trusted Header Authorization], remember it is evaluated **before** bearer API keys. Do not expose OliveTin in a way that allows clients to spoof trusted identity headers. +* **Debug logging**: avoid enabling `logDebugOptions.singleFrontendRequestHeaders` in production. OliveTin redacts common sensitive headers (including `Authorization`) in debug output, but minimizing debug surface area is still recommended. +* **Brute force**: OliveTin does not ship per-IP rate limiting for failed bearer attempts. Consider rate limiting or WAF rules on `/api/` at your reverse proxy. + +== See also + +* xref:security/local.adoc[Local Users Authorization] (password hashing and local user basics) +* xref:security/acl.adoc[Access Control Lists] diff --git a/docs/modules/ROOT/pages/security/local.adoc b/docs/modules/ROOT/pages/security/local.adoc index 6014eda..a6634e9 100644 --- a/docs/modules/ROOT/pages/security/local.adoc +++ b/docs/modules/ROOT/pages/security/local.adoc @@ -3,6 +3,8 @@ OliveTin supports just basic users defined with a username and password in the config.yaml file. This can be used when you do not want to use a full authentication system like LDAP, OAuth2 or a Reverse Proxy. +For programmatic access (scripts, integrations) using per-user bearer API keys, see xref:security/api_keys.adoc[API Keys]. + == Define a user include::partial$config-start.adoc[] diff --git a/service/internal/api/api.go b/service/internal/api/api.go index d1af9ad..0fc102f 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -204,10 +204,24 @@ func (api *oliveTinAPI) applyLocalLoginResult(req *apiv1.LocalUserLoginRequest, } } -func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) { +func (api *oliveTinAPI) localUserLoginEarlyReject(req *connect.Request[apiv1.LocalUserLoginRequest]) *connect.Response[apiv1.LocalUserLoginResponse] { if !api.cfg.AuthLocalUsers.Enabled { - return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false}), nil + return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false}) } + + if isLocalInteractiveLoginDisabledForUser(api.cfg, req.Msg.Username) { + log.WithFields(log.Fields{"username": req.Msg.Username}).Debug("LocalUserLogin: interactive login disabled (no password configured)") + return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false}) + } + + return nil +} + +func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) { + if early := api.localUserLoginEarlyReject(req); early != nil { + return early, nil + } + match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password) if err != nil { if errors.Is(err, ErrArgon2Busy) { diff --git a/service/internal/api/local_user_login.go b/service/internal/api/local_user_login.go index 11485f9..64159c2 100644 --- a/service/internal/api/local_user_login.go +++ b/service/internal/api/local_user_login.go @@ -61,6 +61,15 @@ func comparePasswordAndHash(password, hash string) (bool, error) { return match, nil } +func isLocalInteractiveLoginDisabledForUser(cfg *config.Config, username string) bool { + user := cfg.FindUserByUsername(username) + if user == nil { + return false + } + + return user.Password == "" +} + func checkUserPassword(cfg *config.Config, username, password string) (bool, error) { user := cfg.FindUserByUsername(username) if user == nil { diff --git a/service/internal/api/local_user_login_test.go b/service/internal/api/local_user_login_test.go new file mode 100644 index 0000000..8d27168 --- /dev/null +++ b/service/internal/api/local_user_login_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + config "github.com/OliveTin/OliveTin/internal/config" +) + +func TestLocalUserLoginRejectsUserWithNoPassword(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "onlykey", + ApiKey: "k", + Password: "", + }} + + ts, client := getNewTestServerAndClient(cfg) + defer ts.Close() + + resp, err := client.LocalUserLogin(context.Background(), connect.NewRequest(&apiv1.LocalUserLoginRequest{ + Username: "onlykey", + Password: "anything", + })) + require.NoError(t, err) + assert.False(t, resp.Msg.GetSuccess()) +} diff --git a/service/internal/auth/authcheck.go b/service/internal/auth/authcheck.go index 3b612b9..674906a 100644 --- a/service/internal/auth/authcheck.go +++ b/service/internal/auth/authcheck.go @@ -14,6 +14,7 @@ import ( var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{ checkUserFromHeaders, checkUserFromLocalSession, + checkUserFromLocalBearerApiKey, otjwt.CheckUserFromJwtHeader, otjwt.CheckUserFromJwtCookie, } diff --git a/service/internal/auth/local_bearer.go b/service/internal/auth/local_bearer.go new file mode 100644 index 0000000..d9b752f --- /dev/null +++ b/service/internal/auth/local_bearer.go @@ -0,0 +1,97 @@ +package auth + +import ( + "crypto/subtle" + "strings" + + types "github.com/OliveTin/OliveTin/internal/auth/authpublic" + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +const localBearerPrefix = "Bearer " + +func constantTimeEqualString(a, b string) bool { + if len(a) != len(b) { + return false + } + + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +func bearerTokenFromAuthorizationHeader(authz string) (string, bool) { + if !strings.HasPrefix(authz, localBearerPrefix) { + return "", false + } + + token := strings.TrimSpace(strings.TrimPrefix(authz, localBearerPrefix)) + if token == "" { + return "", false + } + + return token, true +} + +func localUserHasAPIKey(user *config.LocalUser) bool { + return user != nil && user.ApiKey != "" +} + +func findLocalUserByAPIKey(cfg *config.Config, token string) *config.LocalUser { + for _, user := range cfg.AuthLocalUsers.Users { + if !localUserHasAPIKey(user) { + continue + } + + if constantTimeEqualString(token, user.ApiKey) { + return user + } + } + + return nil +} + +func logLocalBearerAPIKeyParseFailure(authz string) { + if strings.TrimSpace(authz) == "" { + return + } + + if strings.HasPrefix(authz, localBearerPrefix) { + log.Debugf("Local bearer API key: rejected (empty credential after Bearer prefix)") + return + } + + log.Tracef("Local bearer API key: skipped (Authorization is not a Bearer token)") +} + +func checkUserFromLocalBearerApiKey(context *types.AuthCheckingContext) *types.AuthenticatedUser { + if !context.Config.AuthLocalUsers.Enabled { + log.Tracef("Local bearer API key: skipped (authLocalUsers disabled)") + return nil + } + + authz := context.Request.Header.Get("Authorization") + token, ok := bearerTokenFromAuthorizationHeader(authz) + if !ok { + logLocalBearerAPIKeyParseFailure(authz) + return nil + } + + log.Debugf("Local bearer API key: checking configured local user API keys") + + user := findLocalUserByAPIKey(context.Config, token) + if user == nil { + log.Debugf("Local bearer API key: rejected (no matching local user)") + return nil + } + + log.WithFields(log.Fields{ + "username": user.Username, + "usergroup": user.Usergroup, + }).Debugf("Local bearer API key: authenticated") + + return &types.AuthenticatedUser{ + Username: user.Username, + UsergroupLine: user.Usergroup, + Provider: "local", + } +} diff --git a/service/internal/auth/local_bearer_test.go b/service/internal/auth/local_bearer_test.go new file mode 100644 index 0000000..1f6fba6 --- /dev/null +++ b/service/internal/auth/local_bearer_test.go @@ -0,0 +1,84 @@ +package auth + +import ( + "net/http/httptest" + "testing" + + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckUserFromLocalBearerApiKey_Match(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + Usergroup: "bots", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer secret-api-key") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + user := checkUserFromLocalBearerApiKey(ctx) + require.NotNil(t, user) + assert.Equal(t, "bot", user.Username) + assert.Equal(t, "bots", user.UsergroupLine) + assert.Equal(t, "local", user.Provider) +} + +func TestCheckUserFromLocalBearerApiKey_WrongKey(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer wrong") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + assert.Nil(t, checkUserFromLocalBearerApiKey(ctx)) +} + +func TestCheckUserFromLocalBearerApiKey_DisabledLocalUsers(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = false + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer secret-api-key") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + assert.Nil(t, checkUserFromLocalBearerApiKey(ctx)) +} + +func TestCheckUserFromLocalBearerApiKey_NoBearerPrefix(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "secret-api-key") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + assert.Nil(t, checkUserFromLocalBearerApiKey(ctx)) +} diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 03edd4a..913e0b1 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -195,6 +195,7 @@ type LocalUser struct { Username string `koanf:"username"` Usergroup string `koanf:"usergroup"` Password string `koanf:"password"` + ApiKey string `koanf:"apiKey"` } type OAuth2Provider struct { diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index 1fee4a7..674fba5 100644 --- a/service/internal/config/sanitize.go +++ b/service/internal/config/sanitize.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "strings" "text/template" @@ -15,7 +16,7 @@ func (cfg *Config) Sanitize() { cfg.sanitizeLogLevel() cfg.sanitizeAuthRequireGuestsToLogin() cfg.sanitizeLogHistoryPageSize() - cfg.sanitizeLocalUserPasswords() + cfg.sanitizeLocalUsers() cfg.sanitizeSecurityHeaders() // log.Infof("cfg %p", cfg) @@ -177,12 +178,55 @@ func (cfg *Config) sanitizeLogHistoryPageSize() { } } -func (cfg *Config) sanitizeLocalUserPasswords() { +func (cfg *Config) sanitizeLocalUsers() { for _, user := range cfg.AuthLocalUsers.Users { - if user.Password != "" { - user.Password = parsePasswordTemplate(user.Password) + expandLocalUserEnvTemplates(user) + } + + if err := validateUniqueLocalUserAPIKeys(cfg.AuthLocalUsers.Users); err != nil { + log.Fatalf("%v", err) + } +} + +func expandLocalUserEnvTemplates(user *LocalUser) { + if user == nil { + return + } + + if user.Password != "" { + user.Password = expandEnvTemplate(user.Password) + } + + if user.ApiKey != "" { + user.ApiKey = expandEnvTemplate(user.ApiKey) + } +} + +// validateUniqueLocalUserAPIKeys returns an error when two local users share the same non-empty apiKey. +func validateUniqueLocalUserAPIKeys(users []*LocalUser) error { + seen := make(map[string]string) + + for _, user := range users { + if err := recordUniqueLocalUserAPIKey(seen, user); err != nil { + return err } } + + return nil +} + +func recordUniqueLocalUserAPIKey(seen map[string]string, user *LocalUser) error { + if user == nil || user.ApiKey == "" { + return nil + } + + if prior, ok := seen[user.ApiKey]; ok { + return fmt.Errorf("duplicate authLocalUsers apiKey for users %q and %q", prior, user.Username) + } + + seen[user.ApiKey] = user.Username + + return nil } func (cfg *Config) sanitizeSecurityHeaders() { @@ -204,16 +248,16 @@ func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() { cfg.Security.XFrameOptions = "DENY" } -// parsePasswordTemplate expands {{ .Env.VAR }} in local user password fields using the process environment. -func parsePasswordTemplate(source string) string { - t, err := template.New("password").Option("missingkey=error").Parse(source) +// expandEnvTemplate expands {{ .Env.VAR }} in config strings using the process environment. +func expandEnvTemplate(source string) string { + t, err := template.New("envTemplate").Option("missingkey=error").Parse(source) if err != nil { - log.WithFields(log.Fields{"error": err}).Debug("Password template parse failed, using literal") + log.WithFields(log.Fields{"error": err}).Debug("Env template parse failed, using literal") return source } var b strings.Builder if err := t.Execute(&b, map[string]interface{}{"Env": env.BuildEnvMap()}); err != nil { - log.WithFields(log.Fields{"error": err}).Debug("Password template execute failed, using literal") + log.WithFields(log.Fields{"error": err}).Debug("Env template execute failed, using literal") return source } return b.String() diff --git a/service/internal/config/sanitize_test.go b/service/internal/config/sanitize_test.go index 992d5d6..225e708 100644 --- a/service/internal/config/sanitize_test.go +++ b/service/internal/config/sanitize_test.go @@ -1,8 +1,10 @@ package config import ( - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSanitizeConfig(t *testing.T) { @@ -42,9 +44,9 @@ func TestSanitizePopupOnStartHistory(t *testing.T) { c.DefaultPopupOnStart = "nothing" c.Actions = append(c.Actions, &Action{ - Title: "With history", - PopupOnStart: "history", - Shell: "true", + Title: "With history", + PopupOnStart: "history", + Shell: "true", }) c.Sanitize() @@ -89,3 +91,19 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) { assert.NotEmpty(t, found.ID, "Inline action should have a generated ID") } } + +func TestValidateUniqueLocalUserAPIKeys(t *testing.T) { + t.Parallel() + + err := validateUniqueLocalUserAPIKeys([]*LocalUser{ + {Username: "a", ApiKey: "same"}, + {Username: "b", ApiKey: "same"}, + }) + require.Error(t, err) + + err = validateUniqueLocalUserAPIKeys([]*LocalUser{ + {Username: "a", ApiKey: "one"}, + {Username: "b", ApiKey: "two"}, + }) + require.NoError(t, err) +} diff --git a/service/internal/httpservers/frontend.go b/service/internal/httpservers/frontend.go index 399c6fd..e317b18 100644 --- a/service/internal/httpservers/frontend.go +++ b/service/internal/httpservers/frontend.go @@ -13,6 +13,7 @@ import ( "net/http/httputil" "net/url" "path" + "strings" "github.com/OliveTin/OliveTin/internal/api" "github.com/OliveTin/OliveTin/internal/auth" @@ -57,13 +58,35 @@ func securityHeadersMiddleware(cfg *config.Config, next http.Handler) http.Handl }) } +func isSensitiveLogHeaderName(name string) bool { + switch strings.ToLower(name) { + case "authorization", "cookie", "x-forwarded-access-token": + return true + default: + return false + } +} + +func redactHeaderValuesForLog(name string, values []string) []string { + if !isSensitiveLogHeaderName(name) { + return values + } + + out := make([]string, len(values)) + for i := range values { + out[i] = "[redacted]" + } + + return out +} + func logDebugRequest(cfg *config.Config, source string, r *http.Request) { if cfg.LogDebugOptions.SingleFrontendRequests { log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL) if cfg.LogDebugOptions.SingleFrontendRequestHeaders { for name, values := range r.Header { - log.Debugf("SingleFrontend HTTP Req Hdr: %v = %v", name, values) + log.Debugf("SingleFrontend HTTP Req Hdr: %v = %v", name, redactHeaderValuesForLog(name, values)) } } } diff --git a/service/internal/httpservers/frontend_test.go b/service/internal/httpservers/frontend_test.go new file mode 100644 index 0000000..f44c2c5 --- /dev/null +++ b/service/internal/httpservers/frontend_test.go @@ -0,0 +1,17 @@ +package httpservers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRedactHeaderValuesForLog(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("Authorization", []string{"Bearer secret"})) + assert.Equal(t, []string{"[redacted]", "[redacted]"}, redactHeaderValuesForLog("Cookie", []string{"a=1", "b=2"})) + assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("authorization", []string{"x"})) + assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("X-Forwarded-Access-Token", []string{"jwt"})) + assert.Equal(t, []string{"https"}, redactHeaderValuesForLog("X-Forwarded-Proto", []string{"https"})) +}