fix: (#718) OAuth Login Buttons not redirecting (#759)

This commit is contained in:
James Read 2025-11-26 23:28:57 +00:00 committed by GitHub
commit ff77e9121c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1097 additions and 721 deletions

View File

@ -1,4 +1,4 @@
// @generated by protoc-gen-es v2.10.0
// @generated by protoc-gen-es v2.10.1
// @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
/* eslint-disable */
@ -1421,15 +1421,15 @@ export declare type OAuth2Provider = Message<"olivetin.api.v1.OAuth2Provider"> &
*/
title: string;
/**
* @generated from field: string url = 2;
*/
url: string;
/**
* @generated from field: string icon = 3;
*/
icon: string;
/**
* @generated from field: string key = 4;
*/
key: string;
};
/**

File diff suppressed because one or more lines are too long

View File

@ -8,10 +8,10 @@
<div v-if="hasOAuth" class="login-oauth2">
<h3>OAuth Login</h3>
<div class="oauth-providers">
<button v-for="provider in oauthProviders" :key="provider.name" class="oauth-button"
<button v-for="provider in oauthProviders" :key="provider.key" class="oauth-button"
@click="loginWithOAuth(provider)">
<span v-if="provider.icon" class="provider-icon" v-html="provider.icon"></span>
<span class="provider-name">Login with {{ provider.name }}</span>
<span class="provider-name">Login with {{ provider.title }}</span>
</button>
</div>
</div>
@ -106,8 +106,13 @@ async function handleLocalLogin() {
}
function loginWithOAuth(provider) {
// Redirect to OAuth provider
window.location.href = provider.authUrl
if (!provider.key) {
console.error('OAuth provider missing key:', provider)
return
}
const providerKey = encodeURIComponent(provider.key)
window.location.href = `/oauth/login?provider=${providerKey}`
}
onMounted(() => {
@ -137,4 +142,12 @@ form {
grid-template-columns: 1fr;
gap: 1em;
}
.provider-icon {
width: 1em;
height: 1em;
margin-right: .4em;
display: inline-flex;
vertical-align: middle;
}
</style>

View File

@ -1,15 +0,0 @@
#
# Integration Test Config: General
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
pageTitle: "My Custom App"
actions:
- title: sleep 2 seconds
shell: sleep 2
icon: "&#x1F971"

View File

@ -0,0 +1,31 @@
#
# Integration Test Config: GitHub OAuth2 Authentication
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
# Enable GitHub OAuth2 authentication
authOAuth2RedirectUrl: "http://localhost:1337/oauth2/callback"
authOAuth2Providers:
github:
name: github
title: "Good old GitHub"
clientId: "test-client-id"
clientSecret: "test-client-secret"
# Require login for guests
authRequireGuestsToLogin: true
# Simple actions for testing
actions:
- title: Ping Google.com
shell: echo "ping google.com"
icon: ping
- title: sleep 2 seconds
shell: sleep 2
icon: "&#x1F971"

View File

@ -0,0 +1,136 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By, until, Condition } from 'selenium-webdriver'
import {
getRootAndWait,
takeScreenshotOnFailure,
} from '../../lib/elements.js'
describe('config: githubOAuth', function () {
this.timeout(30000)
before(async function () {
await runner.start('oauthLoginGithub')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver)
})
it('Server starts successfully with GitHub OAuth enabled', async function () {
await webdriver.get(runner.baseUrl())
// Wait for the page to load
await webdriver.wait(until.titleContains('OliveTin'), 10000)
// Check that the page loaded
const title = await webdriver.getTitle()
expect(title).to.contain('OliveTin')
console.log('Server started successfully with GitHub OAuth enabled')
})
it('Login page is accessible and shows GitHub OAuth button', async function () {
// Navigate to login page
await webdriver.get(runner.baseUrl() + '/login')
// Wait for the page to load
await webdriver.wait(until.titleContains('OliveTin'), 10000)
// Wait for Vue to render
await new Promise(resolve => setTimeout(resolve, 3000))
// Check if OAuth section is present
const oauthSection = await webdriver.findElements(By.css('.login-oauth2'))
expect(oauthSection.length).to.be.greaterThan(0, 'OAuth login section should be present')
// Check for GitHub OAuth button
const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
// Find the GitHub button specifically
// Button may show "Login with GitHub" or "Login with undefined" depending on provider.name vs provider.title
// We'll check for the presence of the button and verify it's in the OAuth section
expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
// The first button should be GitHub since it's the only provider in the config
const githubButton = githubButtons[0]
const buttonText = await githubButton.getText()
// Button should contain "Login with" and the provider should be configured as GitHub
expect(buttonText).to.include('Login with', 'Button should have "Login with" prefix')
console.log('GitHub OAuth button found with text:', buttonText)
})
it('GitHub OAuth button has correct structure and is clickable', async function () {
await webdriver.get(runner.baseUrl() + '/login')
// Wait for the page to load
await webdriver.wait(until.titleContains('OliveTin'), 10000)
await new Promise(resolve => setTimeout(resolve, 3000))
// Find GitHub OAuth button
// Since the test config only has one provider (GitHub), we can use the first button
const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
const githubButton = githubButtons[0]
const buttonText = await githubButton.getText()
console.log('Button text:', buttonText)
// Verify it's the GitHub button (should contain "github" in the text)
expect(buttonText.toLowerCase()).to.include('github', 'Button should be GitHub OAuth button')
// Check for provider icon (if present)
const providerIcons = await githubButton.findElements(By.css('.provider-icon'))
const providerNames = await githubButton.findElements(By.css('.provider-name'))
// Provider name may show "GitHub" (from title) or be undefined (if using name field)
// Just verify the structure is present
if (providerNames.length > 0) {
const providerNameText = await providerNames[0].getText()
expect(providerNameText.toLowerCase()).to.include('github', 'Provider name should include "github"')
expect(providerNameText).to.include('Login with', 'Provider name should have "Login with" prefix')
console.log('Provider name text:', providerNameText)
}
console.log('GitHub OAuth button structure verified')
})
it('Clicking GitHub OAuth button redirects to GitHub OAuth URL', async function () {
await webdriver.get(runner.baseUrl() + '/login')
// Wait for the page to load
await webdriver.wait(until.titleContains('OliveTin'), 10000)
await new Promise(resolve => setTimeout(resolve, 3000))
// Find GitHub OAuth button (should be the first/only one in our test config)
const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
expect(githubButtons.length).to.be.greaterThan(0, 'OAuth button should be present')
const githubButton = githubButtons[0]
// Get the current URL before clicking
const initialUrl = await webdriver.getCurrentUrl()
// Click the button
await githubButton.click()
// Wait for navigation (OAuth redirect happens via window.location.href)
// Since we can't actually complete OAuth flow, we check that the button
// click handler is set up correctly by verifying the button exists and is clickable
// In a real scenario, this would redirect to GitHub's OAuth page
// Give a small delay to allow any navigation to start
await new Promise(resolve => setTimeout(resolve, 1000))
// Note: We can't fully test the OAuth redirect in integration tests without
// a real GitHub OAuth app, but we've verified the button exists and is functional
console.log('GitHub OAuth button click verified (redirect would happen in production)')
})
})

View File

@ -329,8 +329,8 @@ message AdditionalLink {
message OAuth2Provider {
string title = 1;
string url = 2;
string icon = 3;
string key = 4;
}
message GetActionBindingRequest {

View File

@ -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 {

View File

@ -3390,8 +3390,8 @@ func (x *AdditionalLink) GetUrl() string {
type OAuth2Provider struct {
state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"`
Icon string `protobuf:"bytes,3,opt,name=icon,proto3" json:"icon,omitempty"`
Key string `protobuf:"bytes,4,opt,name=key,proto3" json:"key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -3433,16 +3433,16 @@ func (x *OAuth2Provider) GetTitle() string {
return ""
}
func (x *OAuth2Provider) GetUrl() string {
func (x *OAuth2Provider) GetIcon() string {
if x != nil {
return x.Url
return x.Icon
}
return ""
}
func (x *OAuth2Provider) GetIcon() string {
func (x *OAuth2Provider) GetKey() string {
if x != nil {
return x.Icon
return x.Key
}
return ""
}
@ -4003,9 +4003,9 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
"\x03url\x18\x02 \x01(\tR\x03url\"L\n" +
"\x0eOAuth2Provider\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
"\x03url\x18\x02 \x01(\tR\x03url\x12\x12\n" +
"\x04icon\x18\x03 \x01(\tR\x04icon\"8\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" +
"\x04icon\x18\x03 \x01(\tR\x04icon\x12\x10\n" +
"\x03key\x18\x04 \x01(\tR\x03key\"8\n" +
"\x17GetActionBindingRequest\x12\x1d\n" +
"\n" +
"binding_id\x18\x01 \x01(\tR\tbindingId\"K\n" +

View File

@ -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
}

View File

@ -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))
})
}
}

View File

@ -3,6 +3,7 @@ package api
import (
ctx "context"
"encoding/json"
"sort"
"connectrpc.com/connect"
"google.golang.org/protobuf/encoding/protojson"
@ -18,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"
@ -50,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) {
@ -77,17 +79,18 @@ 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
return
}
err := api.executor.Kill(execReqLogEntry)
@ -114,7 +117,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,
@ -203,7 +206,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),
@ -234,7 +237,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,
}
@ -248,7 +251,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),
@ -272,7 +275,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,
@ -326,7 +329,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
@ -351,7 +354,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,
@ -374,7 +377,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
@ -396,7 +399,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
@ -411,14 +414,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,
@ -446,7 +449,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
@ -470,7 +473,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)
}
@ -498,7 +501,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] {
@ -512,7 +515,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
@ -527,20 +530,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) {
@ -595,7 +585,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
@ -681,7 +671,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
@ -800,7 +790,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
@ -833,7 +823,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)
@ -865,14 +855,18 @@ func (api *oliveTinAPI) addCustomDashboards(rootDashboards *[]string, dashboards
func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
var publicProviders []*apiv1.OAuth2Provider
for _, provider := range cfg.AuthOAuth2Providers {
for providerKey, provider := range cfg.AuthOAuth2Providers {
publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
Title: provider.Title,
Url: provider.AuthUrl,
Icon: provider.Icon,
Key: providerKey,
})
}
sort.Slice(publicProviders, func(i, j int) bool {
return publicProviders[i].Key < publicProviders[j].Key
})
return publicProviders
}
@ -914,7 +908,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
@ -967,7 +961,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

View File

@ -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
}

View File

@ -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.Tracef("UserFromApiCall 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
}

View File

@ -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
}

View File

@ -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))
})
}
}

View File

@ -0,0 +1,12 @@
package authpublic
import (
"net/http"
"github.com/OliveTin/OliveTin/internal/config"
)
type AuthCheckingContext struct {
Config *config.Config
Request *http.Request
}

View File

@ -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
}

View File

@ -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,48 @@ 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),
Provider: "jwt",
}
return username, usergroup
return user
}
func parseGroupClaim(groupClaim string, claims jwt.MapClaims) string {

View File

@ -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: "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))
}

View File

@ -1,4 +1,4 @@
package httpservers
package otoauth2
import (
"context"
@ -8,13 +8,15 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
config "github.com/OliveTin/OliveTin/internal/config"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"io"
"net/http"
"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"
)
type OAuth2Handler struct {
@ -110,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: "/",
@ -119,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 {
@ -149,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
@ -210,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)
@ -265,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 {
@ -351,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]
@ -371,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
}

View File

@ -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{"read: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: "email",
WhoamiUrl: "https://www.googleapis.com/oauth2/v3/userinfo",
TokenUrl: endpoints.Google.TokenURL,
AuthUrl: endpoints.Google.AuthURL,
Scopes: []string{"profile", "email"},
},
}

View File

@ -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
}

View File

@ -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 ""
}

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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())
*/
}

View File

@ -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
}

View File

@ -1,3 +0,0 @@
package httpservers
import ()

View File

@ -93,22 +93,42 @@ func (s *webUIServer) setupCustomWebuiDir() {
}
}
func shouldReloadThemeCss() bool {
return !customThemeCssRead
}
func loadThemeCssFromFile(filename string) []byte {
_, err := os.Stat(filename)
if err == nil {
css, err := os.ReadFile(filename)
if err != nil {
log.Tracef("Theme CSS file not read: %s", filename)
return nil
}
return css
}
log.Tracef("Theme CSS file not found: %s", filename)
return nil
}
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 {

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}