Next (#1033)
This commit is contained in:
commit
8bf52fbea3
|
|
@ -96,6 +96,7 @@
|
||||||
* xref:security/concepts.adoc[Security]
|
* xref:security/concepts.adoc[Security]
|
||||||
** xref:security/acl.adoc[Access Control Lists]
|
** xref:security/acl.adoc[Access Control Lists]
|
||||||
** xref:security/local.adoc[Local Users Authorization]
|
** xref:security/local.adoc[Local Users Authorization]
|
||||||
|
** xref:security/api_keys.adoc[API Keys]
|
||||||
** xref:security/trusted_header.adoc[Trusted Header Authorization]
|
** xref:security/trusted_header.adoc[Trusted Header Authorization]
|
||||||
** xref:security/jwt.adoc[JWT Authorization]
|
** xref:security/jwt.adoc[JWT Authorization]
|
||||||
*** xref:security/jwt_keys.adoc[JWT with Keys]
|
*** xref:security/jwt_keys.adoc[JWT with Keys]
|
||||||
|
|
|
||||||
|
|
@ -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 <your-api-key>
|
||||||
|
----
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
@ -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.
|
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
|
== Define a user
|
||||||
|
|
||||||
include::partial$config-start.adoc[]
|
include::partial$config-start.adoc[]
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<Section class = "with-header-and-content" v-if="entityDefinitions.length === 0" title="Loading entity definitions...">
|
<Section v-if="!definitionsLoaded" title="Loading entity definitions..." />
|
||||||
<div class = "section-header">
|
<Section
|
||||||
<h2 class="loading-message">
|
v-else-if="totalInstances === 0"
|
||||||
Loading entity definitions...
|
title="There are no entities to show yet."
|
||||||
</h2>
|
>
|
||||||
</div>
|
<p>
|
||||||
|
When OliveTin has registered entity instances (for example from entity files or your setup), they will be listed here.
|
||||||
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
|
<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
|
||||||
<div class = "section-content">
|
|
||||||
<p>{{ def.instances.length }} instances.</p>
|
<p>{{ def.instances.length }} instances.</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -31,21 +32,35 @@
|
||||||
<span v-else>{{ dash }}</span>
|
<span v-else>{{ dash }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
|
|
||||||
|
const definitionsLoaded = ref(false)
|
||||||
const entityDefinitions = ref([])
|
const entityDefinitions = ref([])
|
||||||
|
|
||||||
async function fetchEntities() {
|
const totalInstances = computed(() =>
|
||||||
const ret = await window.client.getEntities()
|
entityDefinitions.value.reduce(
|
||||||
|
(sum, def) => sum + (def.instances?.length ?? 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
entityDefinitions.value = ret.entityDefinitions
|
async function fetchEntities() {
|
||||||
|
try {
|
||||||
|
const ret = await window.client.getEntities()
|
||||||
|
entityDefinitions.value = ret.entityDefinitions ?? []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch entities:', err)
|
||||||
|
window.showBigError('fetch-entities', 'getting entities', err, false)
|
||||||
|
entityDefinitions.value = []
|
||||||
|
} finally {
|
||||||
|
definitionsLoaded.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filteredDashboards(dashboards) {
|
function filteredDashboards(dashboards) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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)
|
match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrArgon2Busy) {
|
if errors.Is(err, ErrArgon2Busy) {
|
||||||
|
|
@ -724,12 +738,46 @@ func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumen
|
||||||
return arg == nil
|
return arg == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *oliveTinAPI) validateArgumentTypeBindingAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
|
||||||
|
if msg == nil || msg.BindingId == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.errUnlessUserMayValidateArgumentTypeForBinding(user, msg.BindingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *authpublic.AuthenticatedUser, bindingID string) error {
|
||||||
|
binding := api.executor.FindBindingByID(bindingID)
|
||||||
|
if binding == nil || binding.Action == nil {
|
||||||
|
return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !api.userCanViewAction(user, binding.Action) {
|
||||||
|
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
|
func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
|
||||||
|
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||||
|
if err := api.checkDashboardAccess(user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := api.validateArgumentTypeBindingAccess(user, req.Msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if api.argumentNotFoundForValidation(req.Msg) {
|
if api.argumentNotFoundForValidation(req.Msg) {
|
||||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
|
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
|
||||||
}
|
}
|
||||||
|
|
||||||
err := api.validateArgumentTypeInternal(req.Msg)
|
return api.validateArgumentTypeConnectResponse(req.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
|
||||||
|
err := api.validateArgumentTypeInternal(msg)
|
||||||
desc := ""
|
desc := ""
|
||||||
if err != nil {
|
if err != nil {
|
||||||
desc = err.Error()
|
desc = err.Error()
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,96 @@ func TestGetActionBindingDeniedWhenNoViewPermission(t *testing.T) {
|
||||||
"user with view:false must get permission denied from GetActionBinding")
|
"user with view:false must get permission denied from GetActionBinding")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidateArgumentTypeDeniesGuestsWhenLoginRequired (GHSA-f637-w7p2-m7fx) asserts that when
|
||||||
|
// guests must log in, ValidateArgumentType does not bypass dashboard access controls.
|
||||||
|
func TestValidateArgumentTypeDeniesGuestsWhenLoginRequired(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.AuthRequireGuestsToLogin = true
|
||||||
|
cfg.Actions = append(cfg.Actions, &config.Action{
|
||||||
|
ID: "a1",
|
||||||
|
Title: "Probe",
|
||||||
|
Shell: "echo",
|
||||||
|
Arguments: []config.ActionArgument{
|
||||||
|
{Name: "x", Type: "ascii"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
ts, client := getNewTestServerAndClient(cfg)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
_, err := client.ValidateArgumentType(context.Background(), connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
|
||||||
|
BindingId: "a1",
|
||||||
|
ArgumentName: "x",
|
||||||
|
Value: "v",
|
||||||
|
Type: "ascii",
|
||||||
|
}))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
|
||||||
|
"guest must not call ValidateArgumentType when AuthRequireGuestsToLogin is true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateArgumentTypeDeniedWithoutViewPermission (GHSA-f637-w7p2-m7fx) asserts ValidateArgumentType
|
||||||
|
// respects the same view ACL as GetActionBinding so the RPC cannot enumerate restricted actions.
|
||||||
|
func TestValidateArgumentTypeDeniedWithoutViewPermission(t *testing.T) {
|
||||||
|
cfg, _, _ := buildViewPermissionTestConfig(t)
|
||||||
|
cfg.AuthHttpHeaderUsername = "X-Ot-User"
|
||||||
|
for i := range cfg.Actions {
|
||||||
|
if cfg.Actions[i].ID == "secret_action" {
|
||||||
|
cfg.Actions[i].Arguments = []config.ActionArgument{{Name: "target", Type: "ascii"}}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
ts, client := getNewTestServerAndClient(cfg)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req := connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
|
||||||
|
BindingId: "secret_action",
|
||||||
|
ArgumentName: "target",
|
||||||
|
Value: "ok",
|
||||||
|
Type: "ascii",
|
||||||
|
})
|
||||||
|
req.Header().Set("X-Ot-User", "low")
|
||||||
|
|
||||||
|
_, err := client.ValidateArgumentType(context.Background(), req)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
|
||||||
|
"user with view:false must get permission denied from ValidateArgumentType")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateArgumentTypeAllowedWithViewPermission (GHSA-f637-w7p2-m7fx) asserts authenticated users
|
||||||
|
// with view access can still use ValidateArgumentType for argument validation.
|
||||||
|
func TestValidateArgumentTypeAllowedWithViewPermission(t *testing.T) {
|
||||||
|
cfg, _, _ := buildViewPermissionTestConfig(t)
|
||||||
|
cfg.AuthHttpHeaderUsername = "X-Ot-User"
|
||||||
|
for i := range cfg.Actions {
|
||||||
|
if cfg.Actions[i].ID == "secret_action" {
|
||||||
|
cfg.Actions[i].Arguments = []config.ActionArgument{{Name: "target", Type: "ascii"}}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
ts, client := getNewTestServerAndClient(cfg)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req := connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
|
||||||
|
BindingId: "secret_action",
|
||||||
|
ArgumentName: "target",
|
||||||
|
Value: "ok",
|
||||||
|
Type: "ascii",
|
||||||
|
})
|
||||||
|
req.Header().Set("X-Ot-User", "admin")
|
||||||
|
|
||||||
|
resp, err := client.ValidateArgumentType(context.Background(), req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotNil(t, resp.Msg)
|
||||||
|
assert.True(t, resp.Msg.Valid, "admin with view:true should get successful validation for a valid ascii value")
|
||||||
|
}
|
||||||
|
|
||||||
// TestViewPermissionAllowedSeesAction (GHSA: view permission) asserts that a user with view: true
|
// TestViewPermissionAllowedSeesAction (GHSA: view permission) asserts that a user with view: true
|
||||||
// still sees the action in the dashboard and can fetch it via GetActionBinding.
|
// still sees the action in the dashboard and can fetch it via GetActionBinding.
|
||||||
func TestViewPermissionAllowedSeesAction(t *testing.T) {
|
func TestViewPermissionAllowedSeesAction(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,15 @@ func comparePasswordAndHash(password, hash string) (bool, error) {
|
||||||
return match, nil
|
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) {
|
func checkUserPassword(cfg *config.Config, username, password string) (bool, error) {
|
||||||
user := cfg.FindUserByUsername(username)
|
user := cfg.FindUserByUsername(username)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{
|
var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{
|
||||||
checkUserFromHeaders,
|
checkUserFromHeaders,
|
||||||
checkUserFromLocalSession,
|
checkUserFromLocalSession,
|
||||||
|
checkUserFromLocalBearerApiKey,
|
||||||
otjwt.CheckUserFromJwtHeader,
|
otjwt.CheckUserFromJwtHeader,
|
||||||
otjwt.CheckUserFromJwtCookie,
|
otjwt.CheckUserFromJwtCookie,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
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 localBearerScheme = "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) {
|
||||||
|
idx := strings.IndexByte(authz, ' ')
|
||||||
|
if idx <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.EqualFold(authz[:idx], localBearerScheme) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(authz[idx+1:])
|
||||||
|
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 localBearerAuthorizationHasEmptyCredential(authz string) bool {
|
||||||
|
idx := strings.IndexByte(authz, ' ')
|
||||||
|
return idx > 0 &&
|
||||||
|
strings.EqualFold(authz[:idx], localBearerScheme) &&
|
||||||
|
strings.TrimSpace(authz[idx+1:]) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func logLocalBearerAPIKeyParseFailure(authz string) {
|
||||||
|
if strings.TrimSpace(authz) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if localBearerAuthorizationHasEmptyCredential(authz) {
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
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_LowercaseBearerScheme(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_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))
|
||||||
|
}
|
||||||
|
|
@ -195,6 +195,7 @@ type LocalUser struct {
|
||||||
Username string `koanf:"username"`
|
Username string `koanf:"username"`
|
||||||
Usergroup string `koanf:"usergroup"`
|
Usergroup string `koanf:"usergroup"`
|
||||||
Password string `koanf:"password"`
|
Password string `koanf:"password"`
|
||||||
|
ApiKey string `koanf:"apiKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuth2Provider struct {
|
type OAuth2Provider struct {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
|
|
@ -15,7 +16,7 @@ func (cfg *Config) Sanitize() {
|
||||||
cfg.sanitizeLogLevel()
|
cfg.sanitizeLogLevel()
|
||||||
cfg.sanitizeAuthRequireGuestsToLogin()
|
cfg.sanitizeAuthRequireGuestsToLogin()
|
||||||
cfg.sanitizeLogHistoryPageSize()
|
cfg.sanitizeLogHistoryPageSize()
|
||||||
cfg.sanitizeLocalUserPasswords()
|
cfg.sanitizeLocalUsers()
|
||||||
cfg.sanitizeSecurityHeaders()
|
cfg.sanitizeSecurityHeaders()
|
||||||
|
|
||||||
// log.Infof("cfg %p", cfg)
|
// 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 {
|
for _, user := range cfg.AuthLocalUsers.Users {
|
||||||
|
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 != "" {
|
if user.Password != "" {
|
||||||
user.Password = parsePasswordTemplate(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() {
|
func (cfg *Config) sanitizeSecurityHeaders() {
|
||||||
|
|
@ -204,16 +248,16 @@ func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() {
|
||||||
cfg.Security.XFrameOptions = "DENY"
|
cfg.Security.XFrameOptions = "DENY"
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePasswordTemplate expands {{ .Env.VAR }} in local user password fields using the process environment.
|
// expandEnvTemplate expands {{ .Env.VAR }} in config strings using the process environment.
|
||||||
func parsePasswordTemplate(source string) string {
|
func expandEnvTemplate(source string) string {
|
||||||
t, err := template.New("password").Option("missingkey=error").Parse(source)
|
t, err := template.New("envTemplate").Option("missingkey=error").Parse(source)
|
||||||
if err != nil {
|
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
|
return source
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if err := t.Execute(&b, map[string]interface{}{"Env": env.BuildEnvMap()}); err != nil {
|
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 source
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSanitizeConfig(t *testing.T) {
|
func TestSanitizeConfig(t *testing.T) {
|
||||||
|
|
@ -89,3 +91,19 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
|
||||||
assert.NotEmpty(t, found.ID, "Inline action should have a generated ID")
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/OliveTin/OliveTin/internal/api"
|
"github.com/OliveTin/OliveTin/internal/api"
|
||||||
"github.com/OliveTin/OliveTin/internal/auth"
|
"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) {
|
func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
|
||||||
if cfg.LogDebugOptions.SingleFrontendRequests {
|
if cfg.LogDebugOptions.SingleFrontendRequests {
|
||||||
log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL)
|
log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL)
|
||||||
|
|
||||||
if cfg.LogDebugOptions.SingleFrontendRequestHeaders {
|
if cfg.LogDebugOptions.SingleFrontendRequestHeaders {
|
||||||
for name, values := range r.Header {
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}))
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,8 @@ func jsonFunc(v any) (string, error) {
|
||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Root template (funcs/options). parseTemplate clones before Parse — text/template
|
||||||
|
// must not receive concurrent Parse calls on the same instance.
|
||||||
var tpl = template.New("tpl").
|
var tpl = template.New("tpl").
|
||||||
Option("missingkey=error").
|
Option("missingkey=error").
|
||||||
Funcs(template.FuncMap{"Json": jsonFunc})
|
Funcs(template.FuncMap{"Json": jsonFunc})
|
||||||
|
|
@ -179,7 +181,12 @@ func checkMissingArgumentError(err error) (bool, string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseTemplate(source string, data any) (string, error) {
|
func parseTemplate(source string, data any) (string, error) {
|
||||||
t, err := tpl.Parse(source)
|
clone, err := tpl.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := clone.Parse(source)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue