Compare commits

..

2 Commits

Author SHA1 Message Date
Stavros f0c38b023c fix: use contents write in sponsors list action 2026-04-28 15:48:02 +03:00
Stavros 11e5fab6fe fix: use pinned step versions and set workflow permissions 2026-04-28 15:25:17 +03:00
42 changed files with 807 additions and 1610 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Connect to server",
"type": "go",
"request": "attach",
"mode": "remote",
"remotePath": "/tinyauth",
"port": 4000,
"host": "127.0.0.1",
"debugAdapter": "legacy"
}
]
}
+1 -1
View File
@@ -17,7 +17,7 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
# Deps
deps:
bun install --frozen-lockfile --cwd frontend
bun install --cwd frontend
go mod download
# Clean data
-1
View File
@@ -13,7 +13,6 @@
<a href="https://scorecard.dev/viewer/?uri=github.com/tinyauthapp/tinyauth" target="_blank" title="OpenSSF Scorecard">
<img src="https://api.scorecard.dev/projects/github.com/tinyauthapp/tinyauth/badge">
</a>
<a href="https://www.bestpractices.dev/projects/12681" target="_blank" title="OSSF Best Practices"><img src="https://www.bestpractices.dev/projects/12681/baseline"></a>
</div>
<br />
+3 -3
View File
@@ -73,7 +73,7 @@ func generateTotpCmd() *cli.Command {
docker = true
}
if user.TOTPSecret != "" {
if user.TotpSecret != "" {
return fmt.Errorf("user already has a TOTP secret")
}
@@ -102,14 +102,14 @@ func generateTotpCmd() *cli.Command {
qrterminal.GenerateWithConfig(key.URL(), config)
user.TOTPSecret = secret
user.TotpSecret = secret
// If using docker escape re-escape it
if docker {
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
}
tlog.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
tlog.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
return nil
},
+4 -4
View File
@@ -5,7 +5,7 @@ import (
"charm.land/huh/v2"
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/utils/loaders"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
@@ -14,7 +14,7 @@ import (
)
func main() {
tConfig := model.NewDefaultConfiguration()
tConfig := config.NewDefaultConfiguration()
loaders := []cli.ResourceLoader{
&loaders.FileLoader{},
@@ -108,11 +108,11 @@ func main() {
}
}
func runCmd(cfg model.Config) error {
func runCmd(cfg config.Config) error {
logger := tlog.NewLogger(cfg.Log)
logger.Init()
tlog.App.Info().Str("version", model.Version).Msg("Starting tinyauth")
tlog.App.Info().Str("version", config.Version).Msg("Starting tinyauth")
app := bootstrap.NewBootstrapApp(cfg)
+2 -2
View File
@@ -95,7 +95,7 @@ func verifyUserCmd() *cli.Command {
return fmt.Errorf("password is incorrect: %w", err)
}
if user.TOTPSecret == "" {
if user.TotpSecret == "" {
if tCfg.Totp != "" {
tlog.App.Warn().Msg("User does not have TOTP secret")
}
@@ -103,7 +103,7 @@ func verifyUserCmd() *cli.Command {
return nil
}
ok := totp.Validate(tCfg.Totp, user.TOTPSecret)
ok := totp.Validate(tCfg.Totp, user.TotpSecret)
if !ok {
return fmt.Errorf("TOTP code incorrect")
+5 -4
View File
@@ -3,8 +3,9 @@ package main
import (
"fmt"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/model"
)
func versionCmd() *cli.Command {
@@ -14,9 +15,9 @@ func versionCmd() *cli.Command {
Configuration: nil,
Resources: nil,
Run: func(_ []string) error {
fmt.Printf("Version: %s\n", model.Version)
fmt.Printf("Commit Hash: %s\n", model.CommitHash)
fmt.Printf("Build Timestamp: %s\n", model.BuildTimestamp)
fmt.Printf("Version: %s\n", config.Version)
fmt.Printf("Commit Hash: %s\n", config.CommitHash)
fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
return nil
},
}
+2 -2
View File
@@ -5,7 +5,7 @@ WORKDIR /frontend
COPY ./frontend/package.json ./
COPY ./frontend/bun.lock ./
RUN bun install --frozen-lockfile
RUN bun install
COPY ./frontend/public ./public
COPY ./frontend/src ./src
@@ -19,4 +19,4 @@ COPY ./frontend/vite.config.ts ./
EXPOSE 5173
ENTRYPOINT ["bun", "run", "dev"]
ENTRYPOINT ["bun", "run", "dev"]
+1 -15
View File
@@ -19,10 +19,9 @@ require (
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
github.com/weppos/publicsuffix-go v0.50.3
golang.org/x/crypto v0.50.0
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
golang.org/x/oauth2 v0.36.0
gotest.tools/v3 v3.5.2
k8s.io/apimachinery v0.32.2
k8s.io/client-go v0.32.2
modernc.org/sqlite v1.49.1
)
@@ -64,7 +63,6 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
@@ -75,9 +73,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -96,7 +92,6 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
@@ -111,7 +106,6 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@@ -123,23 +117,15 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
-80
View File
@@ -97,14 +97,10 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -122,12 +118,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -140,23 +130,14 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -181,12 +162,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -199,8 +176,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -234,8 +209,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -269,8 +242,6 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -290,12 +261,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE=
github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -322,54 +289,29 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
@@ -382,27 +324,11 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/api v0.32.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw=
k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y=
k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ=
k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA=
k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
@@ -433,9 +359,3 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA=
sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
+16 -16
View File
@@ -12,15 +12,15 @@ import (
"strings"
"time"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
)
type BootstrapApp struct {
config model.Config
config config.Config
context struct {
appUrl string
uuid string
@@ -29,15 +29,15 @@ type BootstrapApp struct {
csrfCookieName string
redirectCookieName string
oauthSessionCookieName string
localUsers []model.LocalUser
oauthProviders map[string]model.OAuthServiceConfig
users []config.User
oauthProviders map[string]config.OAuthServiceConfig
configuredProviders []controller.Provider
oidcClients []model.OIDCClientConfig
oidcClients []config.OIDCClientConfig
}
services Services
}
func NewBootstrapApp(config model.Config) *BootstrapApp {
func NewBootstrapApp(config config.Config) *BootstrapApp {
return &BootstrapApp{
config: config,
}
@@ -69,7 +69,7 @@ func (app *BootstrapApp) Setup() error {
return err
}
app.context.localUsers = *users
app.context.users = users
// Setup OAuth providers
app.context.oauthProviders = app.config.OAuth.Providers
@@ -88,7 +88,7 @@ func (app *BootstrapApp) Setup() error {
for id, provider := range app.context.oauthProviders {
if provider.Name == "" {
if name, ok := model.OverrideProviders[id]; ok {
if name, ok := config.OverrideProviders[id]; ok {
provider.Name = name
} else {
provider.Name = utils.Capitalize(id)
@@ -115,14 +115,14 @@ func (app *BootstrapApp) Setup() error {
// Cookie names
app.context.uuid = utils.GenerateUUID(appUrl.Hostname())
cookieId := strings.Split(app.context.uuid, "-")[0]
app.context.sessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
app.context.csrfCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
app.context.redirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
app.context.oauthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
app.context.sessionCookieName = fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId)
app.context.csrfCookieName = fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
app.context.oauthSessionCookieName = fmt.Sprintf("%s-%s", config.OAuthSessionCookieName, cookieId)
// Dumps
tlog.App.Trace().Interface("config", app.config).Msg("Config dump")
tlog.App.Trace().Interface("users", app.context.localUsers).Msg("Users dump")
tlog.App.Trace().Interface("users", app.context.users).Msg("Users dump")
tlog.App.Trace().Interface("oauthProviders", app.context.oauthProviders).Msg("OAuth providers dump")
tlog.App.Trace().Str("cookieDomain", app.context.cookieDomain).Msg("Cookie domain")
tlog.App.Trace().Str("sessionCookieName", app.context.sessionCookieName).Msg("Session cookie name")
@@ -171,7 +171,7 @@ func (app *BootstrapApp) Setup() error {
})
}
if services.authService.LDAPAuthConfigured() {
if services.authService.LdapAuthConfigured() {
configuredProviders = append(configuredProviders, controller.Provider{
Name: "LDAP",
ID: "ldap",
@@ -244,7 +244,7 @@ func (app *BootstrapApp) heartbeatRoutine() {
var body heartbeat
body.UUID = app.context.uuid
body.Version = model.Version
body.Version = config.Version
bodyJson, err := json.Marshal(body)
@@ -257,7 +257,7 @@ func (app *BootstrapApp) heartbeatRoutine() {
Timeout: 30 * time.Second, // The server should never take more than 30 seconds to respond
}
heartbeatURL := model.APIServer + "/v1/instances/heartbeat"
heartbeatURL := config.ApiServer + "/v1/instances/heartbeat"
for range ticker.C {
tlog.App.Debug().Msg("Sending heartbeat")
+4 -6
View File
@@ -4,9 +4,9 @@ import (
"fmt"
"slices"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/middleware"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/gin-gonic/gin"
)
@@ -14,7 +14,7 @@ import (
var DEV_MODES = []string{"main", "test", "development"}
func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
if !slices.Contains(DEV_MODES, model.Version) {
if !slices.Contains(DEV_MODES, config.Version) {
gin.SetMode(gin.ReleaseMode)
}
@@ -30,8 +30,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
}
contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{
CookieDomain: app.context.cookieDomain,
SessionCookieName: app.context.sessionCookieName,
CookieDomain: app.context.cookieDomain,
}, app.services.authService, app.services.oauthBrokerService)
err := contextMiddleware.Init()
@@ -99,8 +98,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
proxyController.SetupRoutes()
userController := controller.NewUserController(controller.UserControllerConfig{
CookieDomain: app.context.cookieDomain,
SessionCookieName: app.context.sessionCookieName,
CookieDomain: app.context.cookieDomain,
}, apiRouter, app.services.authService)
userController.SetupRoutes()
+18 -38
View File
@@ -1,8 +1,6 @@
package bootstrap
import (
"os"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
@@ -12,7 +10,6 @@ type Services struct {
accessControlService *service.AccessControlsService
authService *service.AuthService
dockerService *service.DockerService
kubernetesService *service.KubernetesService
ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService
@@ -22,14 +19,14 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
services := Services{}
ldapService := service.NewLdapService(service.LdapServiceConfig{
Address: app.config.LDAP.Address,
BindDN: app.config.LDAP.BindDN,
BindPassword: app.config.LDAP.BindPassword,
BaseDN: app.config.LDAP.BaseDN,
Insecure: app.config.LDAP.Insecure,
SearchFilter: app.config.LDAP.SearchFilter,
AuthCert: app.config.LDAP.AuthCert,
AuthKey: app.config.LDAP.AuthKey,
Address: app.config.Ldap.Address,
BindDN: app.config.Ldap.BindDN,
BindPassword: app.config.Ldap.BindPassword,
BaseDN: app.config.Ldap.BaseDN,
Insecure: app.config.Ldap.Insecure,
SearchFilter: app.config.Ldap.SearchFilter,
AuthCert: app.config.Ldap.AuthCert,
AuthKey: app.config.Ldap.AuthKey,
})
err := ldapService.Init()
@@ -41,34 +38,17 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
services.ldapService = ldapService
var labelProvider service.LabelProvider
var dockerService *service.DockerService
var kubernetesService *service.KubernetesService
dockerService := service.NewDockerService()
useKubernetes := app.config.LabelProvider == "kubernetes" ||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
err = dockerService.Init()
if useKubernetes {
tlog.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService = service.NewKubernetesService()
err = kubernetesService.Init()
if err != nil {
return Services{}, err
}
services.kubernetesService = kubernetesService
labelProvider = kubernetesService
} else {
tlog.App.Debug().Msg("Using Docker label provider")
dockerService = service.NewDockerService()
err = dockerService.Init()
if err != nil {
return Services{}, err
}
services.dockerService = dockerService
labelProvider = dockerService
if err != nil {
return Services{}, err
}
accessControlsService := service.NewAccessControlsService(labelProvider, app.config.Apps)
services.dockerService = dockerService
accessControlsService := service.NewAccessControlsService(dockerService, app.config.Apps)
err = accessControlsService.Init()
@@ -89,7 +69,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
services.oauthBrokerService = oauthBrokerService
authService := service.NewAuthService(service.AuthServiceConfig{
LocalUsers: app.context.localUsers,
Users: app.context.users,
OauthWhitelist: app.config.OAuth.Whitelist,
SessionExpiry: app.config.Auth.SessionExpiry,
SessionMaxLifetime: app.config.Auth.SessionMaxLifetime,
@@ -99,8 +79,8 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
LoginMaxRetries: app.config.Auth.LoginMaxRetries,
SessionCookieName: app.context.sessionCookieName,
IP: app.config.Auth.IP,
LDAPGroupsCacheTTL: app.config.LDAP.GroupCacheTTL,
}, services.ldapService, queries, services.oauthBrokerService)
LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL,
}, dockerService, services.ldapService, queries, services.oauthBrokerService)
err = authService.Init()
@@ -1,4 +1,4 @@
package model
package config
// Default configuration
func NewDefaultConfiguration() *Config {
@@ -29,7 +29,7 @@ func NewDefaultConfiguration() *Config {
BackgroundImage: "/background.jpg",
WarningsEnabled: true,
},
LDAP: LDAPConfig{
Ldap: LdapConfig{
Insecure: false,
SearchFilter: "(uid=%s)",
GroupCacheTTL: 900, // 15 minutes
@@ -59,25 +59,38 @@ func NewDefaultConfiguration() *Config {
Experimental: ExperimentalConfig{
ConfigFile: "",
},
LabelProvider: "auto",
}
}
// Version information, set at build time
var Version = "development"
var CommitHash = "development"
var BuildTimestamp = "0000-00-00T00:00:00Z"
// Cookie name templates
var SessionCookieName = "tinyauth-session"
var CSRFCookieName = "tinyauth-csrf"
var RedirectCookieName = "tinyauth-redirect"
var OAuthSessionCookieName = "tinyauth-oauth"
// Main app config
type Config struct {
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
Database DatabaseConfig `description:"Database configuration." yaml:"database"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"`
Server ServerConfig `description:"Server configuration." yaml:"server"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
UI UIConfig `description:"UI customization." yaml:"ui"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, or kubernetes). auto detects the environment." yaml:"labelProvider"`
Log LogConfig `description:"Logging configuration." yaml:"log"`
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
Database DatabaseConfig `description:"Database configuration." yaml:"database"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"`
Server ServerConfig `description:"Server configuration." yaml:"server"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
UI UIConfig `description:"UI customization." yaml:"ui"`
Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
Log LogConfig `description:"Logging configuration." yaml:"log"`
}
type DatabaseConfig struct {
@@ -163,7 +176,7 @@ type UIConfig struct {
WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled"`
}
type LDAPConfig struct {
type LdapConfig struct {
Address string `description:"LDAP server address." yaml:"address"`
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
@@ -196,6 +209,20 @@ type ExperimentalConfig struct {
ConfigFile string `description:"Path to config file." yaml:"-"`
}
// Config loader options
const DefaultNamePrefix = "TINYAUTH_"
// OAuth/OIDC config
type Claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Groups any `json:"groups"`
}
type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID." yaml:"clientId"`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"`
@@ -218,6 +245,60 @@ type OIDCClientConfig struct {
Name string `description:"Client name in UI." yaml:"name"`
}
var OverrideProviders = map[string]string{
"google": "Google",
"github": "GitHub",
}
// User/session related stuff
type User struct {
Username string
Password string
TotpSecret string
Attributes UserAttributes
}
type LdapUser struct {
DN string
Groups []string
}
type UserSearch struct {
Username string
Type string // local, ldap or unknown
}
type UserContext struct {
Username string
Name string
Email string
IsLoggedIn bool
IsBasicAuth bool
OAuth bool
Provider string
TotpPending bool
OAuthGroups string
TotpEnabled bool
OAuthName string
OAuthSub string
LdapGroups string
Attributes UserAttributes
}
// API responses and queries
type UnauthorizedQuery struct {
Username string `url:"username"`
Resource string `url:"resource"`
GroupErr bool `url:"groupErr"`
IP string `url:"ip"`
}
type RedirectQuery struct {
RedirectURI string `url:"redirect_uri"`
}
// ACLs
type Apps struct {
@@ -273,3 +354,7 @@ type AppPath struct {
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"`
Block string `description:"Comma-separated list of blocked paths." yaml:"block"`
}
// API server
var ApiServer = "https://api.tinyauth.app"
+20 -21
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"net/url"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin"
@@ -19,7 +19,7 @@ type UserContextResponse struct {
Email string `json:"email"`
Provider string `json:"provider"`
OAuth bool `json:"oauth"`
TOTPPending bool `json:"totpPending"`
TotpPending bool `json:"totpPending"`
OAuthName string `json:"oauthName"`
}
@@ -76,29 +76,28 @@ func (controller *ContextController) SetupRoutes() {
}
func (controller *ContextController) userContextHandler(c *gin.Context) {
context, err := new(model.UserContext).NewFromGin(c)
if err != nil {
tlog.App.Debug().Err(err).Msg("No user context found in request")
c.JSON(200, UserContextResponse{
Status: 401,
Message: "Unauthorized",
IsLoggedIn: false,
})
return
}
context, err := utils.GetContext(c)
userContext := UserContextResponse{
Status: 200,
Message: "Success",
IsLoggedIn: context.Authenticated,
Username: context.GetUsername(),
Name: context.GetName(),
Email: context.GetEmail(),
Provider: context.ProviderName(),
OAuth: context.IsOAuth(),
TOTPPending: context.TOTPPending(),
OAuthName: context.OAuthName(),
IsLoggedIn: context.IsLoggedIn,
Username: context.Username,
Name: context.Name,
Email: context.Email,
Provider: context.Provider,
OAuth: context.OAuth,
TotpPending: context.TotpPending,
OAuthName: context.OAuthName,
}
if err != nil {
tlog.App.Debug().Err(err).Msg("No user context found in request")
userContext.Status = 401
userContext.Message = "Unauthorized"
userContext.IsLoggedIn = false
c.JSON(200, userContext)
return
}
c.JSON(200, userContext)
-12
View File
@@ -1,12 +0,0 @@
package controller
type UnauthorizedQuery struct {
Username string `url:"username"`
Resource string `url:"resource"`
GroupErr bool `url:"groupErr"`
IP string `url:"ip"`
}
type RedirectQuery struct {
RedirectURI string `url:"redirect_uri"`
}
+4 -5
View File
@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
@@ -175,7 +176,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
tlog.App.Warn().Str("email", user.Email).Msg("Email not whitelisted")
tlog.AuditLoginFailure(c, user.Email, req.Provider, "email not whitelisted")
queries, err := query.Values(UnauthorizedQuery{
queries, err := query.Values(config.UnauthorizedQuery{
Username: user.Email,
})
@@ -235,7 +236,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
cookie, err := controller.auth.CreateSession(c, sessionCookie)
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
@@ -243,8 +244,6 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return
}
http.SetCookie(c.Writer, cookie)
tlog.AuditLoginSuccess(c, sessionCookie.Username, sessionCookie.Provider)
if controller.isOidcRequest(oauthPendingSession.CallbackParams) {
@@ -260,7 +259,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
}
if oauthPendingSession.CallbackParams.RedirectURI != "" {
queries, err := query.Values(RedirectQuery{
queries, err := query.Values(config.RedirectQuery{
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
})
+5 -6
View File
@@ -10,7 +10,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
@@ -112,14 +111,14 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
userContext, err := utils.GetContext(c)
if err != nil {
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
return
}
if !userContext.Authenticated {
if !userContext.IsLoggedIn {
controller.authorizeError(c, errors.New("err user not logged in"), "User not logged in", "The user is not logged in", "", "", "")
return
}
@@ -152,7 +151,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
}
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.GetUsername(), client.ID))
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.Username, client.ID))
code := utils.GenerateString(32)
// Before storing the code, delete old session
@@ -171,7 +170,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
// We also need a snapshot of the user that authorized this (skip if no openid scope)
if slices.Contains(strings.Fields(req.Scope), "openid") {
err = controller.oidc.StoreUserinfo(c, sub, *userContext, req)
err = controller.oidc.StoreUserinfo(c, sub, userContext, req)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to insert user info into database")
@@ -430,7 +429,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
if err != nil {
if errors.Is(err, service.ErrTokenNotFound) {
if err == service.ErrTokenNotFound {
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token")
c.JSON(401, gin.H{
"error": "invalid_grant",
+42 -43
View File
@@ -8,7 +8,7 @@ import (
"regexp"
"strings"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
@@ -99,16 +99,12 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if acls == nil {
acls = &model.App{}
}
tlog.App.Trace().Interface("acls", acls).Msg("ACLs for resource")
clientIP := c.ClientIP()
if controller.auth.IsBypassedIP(&acls.IP, clientIP) {
controller.setHeaders(c, *acls)
if controller.auth.IsBypassedIP(acls.IP, clientIP) {
controller.setHeaders(c, acls)
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
@@ -116,7 +112,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, &acls.Path)
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls.Path)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource")
@@ -126,7 +122,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
if !authEnabled {
tlog.App.Debug().Msg("Authentication disabled for resource, allowing access")
controller.setHeaders(c, *acls)
controller.setHeaders(c, acls)
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
@@ -134,8 +130,8 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if !controller.auth.CheckIP(&acls.IP, clientIP) {
queries, err := query.Values(UnauthorizedQuery{
if !controller.auth.CheckIP(acls.IP, clientIP) {
queries, err := query.Values(config.UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0],
IP: clientIP,
})
@@ -161,24 +157,28 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
var userContext config.UserContext
context, err := utils.GetContext(c)
if err != nil {
tlog.App.Debug().Err(err).Msg("No user context found in request, treating as unauthenticated")
userContext = &model.UserContext{
Authenticated: false,
tlog.App.Debug().Msg("No user context found in request, treating as not logged in")
userContext = config.UserContext{
IsLoggedIn: false,
}
} else {
userContext = context
}
tlog.App.Trace().Interface("context", userContext).Msg("User context from request")
if userContext.Authenticated {
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
if userContext.IsLoggedIn {
userAllowed := controller.auth.IsUserAllowed(c, userContext, acls)
if !userAllowed {
tlog.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource")
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource")
queries, err := query.Values(UnauthorizedQuery{
queries, err := query.Values(config.UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0],
})
@@ -188,10 +188,10 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if userContext.IsOAuth() {
queries.Set("username", userContext.GetEmail())
if userContext.OAuth {
queries.Set("username", userContext.Email)
} else {
queries.Set("username", userContext.GetUsername())
queries.Set("username", userContext.Username)
}
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
@@ -209,19 +209,19 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if userContext.IsOAuth() || userContext.IsLDAP() {
if userContext.OAuth || userContext.Provider == "ldap" {
var groupOK bool
if userContext.IsOAuth() {
groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls.OAuth.Groups)
if userContext.OAuth {
groupOK = controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups)
} else {
groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls.LDAP.Groups)
groupOK = controller.auth.IsInLdapGroup(c, userContext, acls.LDAP.Groups)
}
if !groupOK {
tlog.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements")
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements")
queries, err := query.Values(UnauthorizedQuery{
queries, err := query.Values(config.UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0],
GroupErr: true,
})
@@ -232,10 +232,10 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if userContext.IsOAuth() {
queries.Set("username", userContext.GetEmail())
if userContext.OAuth {
queries.Set("username", userContext.Email)
} else {
queries.Set("username", userContext.GetUsername())
queries.Set("username", userContext.Username)
}
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
@@ -254,20 +254,19 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
}
}
c.Header("Remote-User", utils.SanitizeHeader(userContext.GetUsername()))
c.Header("Remote-Name", utils.SanitizeHeader(userContext.GetName()))
c.Header("Remote-Email", utils.SanitizeHeader(userContext.GetEmail()))
c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
if userContext.IsLDAP() {
c.Header("Remote-Groups", utils.SanitizeHeader(strings.Join(userContext.LDAP.Groups, ",")))
if userContext.Provider == "ldap" {
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.LdapGroups))
} else if userContext.Provider != "local" {
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
}
if userContext.IsOAuth() {
c.Header("Remote-Groups", utils.SanitizeHeader(strings.Join(userContext.OAuth.Groups, ",")))
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuth.Sub))
}
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub))
controller.setHeaders(c, *acls)
controller.setHeaders(c, acls)
c.JSON(200, gin.H{
"status": 200,
@@ -276,7 +275,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
queries, err := query.Values(RedirectQuery{
queries, err := query.Values(config.RedirectQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
})
@@ -300,7 +299,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func (controller *ProxyController) setHeaders(c *gin.Context, acls model.App) {
func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) {
c.Header("Authorization", c.Request.Header.Get("Authorization"))
headers := utils.ParseHeaders(acls.Response.Headers)
+1 -1
View File
@@ -412,7 +412,7 @@ func TestProxyController(t *testing.T) {
err = broker.Init()
require.NoError(t, err)
authService := service.NewAuthService(authServiceCfg, ldap, queries, broker)
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
err = authService.Init()
require.NoError(t, err)
+46 -97
View File
@@ -1,12 +1,10 @@
package controller
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
@@ -26,8 +24,7 @@ type TotpRequest struct {
}
type UserControllerConfig struct {
CookieDomain string
SessionCookieName string
CookieDomain string
}
type UserController struct {
@@ -80,28 +77,20 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return
}
search, err := controller.auth.SearchUser(req.Username)
userSearch := controller.auth.SearchUser(req.Username)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
tlog.App.Warn().Str("username", req.Username).Msg("User not found")
controller.auth.RecordLoginAttempt(req.Username, false)
tlog.AuditLoginFailure(c, req.Username, "username", "user not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
tlog.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
if userSearch.Type == "unknown" {
tlog.App.Warn().Str("username", req.Username).Msg("User not found")
controller.auth.RecordLoginAttempt(req.Username, false)
tlog.AuditLoginFailure(c, req.Username, "username", "user not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
if !controller.auth.VerifyUser(userSearch, req.Password) {
tlog.App.Warn().Str("username", req.Username).Msg("Invalid password")
controller.auth.RecordLoginAttempt(req.Username, false)
tlog.AuditLoginFailure(c, req.Username, "username", "invalid password")
@@ -117,26 +106,30 @@ func (controller *UserController) loginHandler(c *gin.Context) {
controller.auth.RecordLoginAttempt(req.Username, true)
var localUser *model.LocalUser
var localUser *config.User
if userSearch.Type == "local" {
user := controller.auth.GetLocalUser(userSearch.Username)
localUser = &user
}
if search.Type == model.UserLocal {
localUser = controller.auth.GetLocalUser(req.Username)
if userSearch.Type == "local" && localUser != nil {
user := *localUser
if localUser.TOTPSecret != "" {
if user.TotpSecret != "" {
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
name := localUser.Attributes.Name
name := user.Attributes.Name
if name == "" {
name = utils.Capitalize(localUser.Username)
name = utils.Capitalize(user.Username)
}
email := localUser.Attributes.Email
email := user.Attributes.Email
if email == "" {
email = utils.CompileUserEmail(localUser.Username, controller.config.CookieDomain)
email = utils.CompileUserEmail(user.Username, controller.config.CookieDomain)
}
cookie, err := controller.auth.CreateSession(c, repository.Session{
Username: localUser.Username,
err := controller.auth.CreateSessionCookie(c, &repository.Session{
Username: user.Username,
Name: name,
Email: email,
Provider: "local",
@@ -152,8 +145,6 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return
}
http.SetCookie(c.Writer, cookie)
c.JSON(200, gin.H{
"status": 200,
"message": "TOTP required",
@@ -170,7 +161,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
Provider: "local",
}
if search.Type == model.UserLocal {
if userSearch.Type == "local" && localUser != nil {
if localUser.Attributes.Name != "" {
sessionCookie.Name = localUser.Attributes.Name
}
@@ -179,13 +170,13 @@ func (controller *UserController) loginHandler(c *gin.Context) {
}
}
if search.Type == model.UserLDAP {
if userSearch.Type == "ldap" {
sessionCookie.Provider = "ldap"
}
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
cookie, err := controller.auth.CreateSession(c, sessionCookie)
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
@@ -196,8 +187,6 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return
}
http.SetCookie(c.Writer, cookie)
c.JSON(200, gin.H{
"status": 200,
"message": "Login successful",
@@ -207,51 +196,13 @@ func (controller *UserController) loginHandler(c *gin.Context) {
func (controller *UserController) logoutHandler(c *gin.Context) {
tlog.App.Debug().Msg("Logout request received")
uuid, err := c.Cookie(controller.config.SessionCookieName)
controller.auth.DeleteSessionCookie(c)
if err != nil {
if errors.Is(err, http.ErrNoCookie) {
tlog.App.Warn().Msg("No session cookie found on logout request")
c.JSON(200, gin.H{
"status": 200,
"message": "Logout successful",
})
return
}
tlog.App.Error().Err(err).Msg("Error retrieving session cookie on logout")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
context, err := utils.GetContext(c)
if err == nil && context.IsLoggedIn {
tlog.AuditLogout(c, context.Username, context.Provider)
}
context, err := new(model.UserContext).NewFromGin(c)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to get user context on logout")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
cookie, err := controller.auth.DeleteSession(c, uuid)
if err != nil {
tlog.App.Error().Err(err).Msg("Error deleting session on logout")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
tlog.AuditLogout(c, context.GetUsername(), context.ProviderName())
http.SetCookie(c.Writer, cookie)
c.JSON(200, gin.H{
"status": 200,
"message": "Logout successful",
@@ -271,7 +222,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return
}
context, err := new(model.UserContext).NewFromGin(c)
context, err := utils.GetContext(c)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to get user context")
@@ -282,7 +233,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return
}
if !context.TOTPPending() {
if !context.TotpPending {
tlog.App.Warn().Msg("TOTP attempt without a pending TOTP session")
c.JSON(401, gin.H{
"status": 401,
@@ -291,12 +242,12 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return
}
tlog.App.Debug().Str("username", context.GetUsername()).Msg("TOTP verification attempt")
tlog.App.Debug().Str("username", context.Username).Msg("TOTP verification attempt")
isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername())
isLocked, remaining := controller.auth.IsAccountLocked(context.Username)
if isLocked {
tlog.App.Warn().Str("username", context.GetUsername()).Msg("Account is locked due to too many failed TOTP attempts")
tlog.App.Warn().Str("username", context.Username).Msg("Account is locked due to too many failed TOTP attempts")
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{
@@ -306,14 +257,14 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return
}
user := controller.auth.GetLocalUser(context.GetUsername())
user := controller.auth.GetLocalUser(context.Username)
ok := totp.Validate(req.Code, user.TOTPSecret)
ok := totp.Validate(req.Code, user.TotpSecret)
if !ok {
tlog.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code")
controller.auth.RecordLoginAttempt(context.GetUsername(), false)
tlog.AuditLoginFailure(c, context.GetUsername(), "totp", "invalid totp code")
tlog.App.Warn().Str("username", context.Username).Msg("Invalid TOTP code")
controller.auth.RecordLoginAttempt(context.Username, false)
tlog.AuditLoginFailure(c, context.Username, "totp", "invalid totp code")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -321,10 +272,10 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return
}
tlog.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful")
tlog.AuditLoginSuccess(c, context.GetUsername(), "totp")
tlog.App.Info().Str("username", context.Username).Msg("TOTP verification successful")
tlog.AuditLoginSuccess(c, context.Username, "totp")
controller.auth.RecordLoginAttempt(context.GetUsername(), true)
controller.auth.RecordLoginAttempt(context.Username, true)
sessionCookie := repository.Session{
Username: user.Username,
@@ -342,7 +293,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
cookie, err := controller.auth.CreateSession(c, sessionCookie)
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
@@ -353,8 +304,6 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return
}
http.SetCookie(c.Writer, cookie)
c.JSON(200, gin.H{
"status": 200,
"message": "Login successful",
+1 -1
View File
@@ -370,7 +370,7 @@ func TestUserController(t *testing.T) {
err = broker.Init()
require.NoError(t, err)
authService := service.NewAuthService(authServiceCfg, ldap, queries, broker)
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
err = authService.Init()
require.NoError(t, err)
+176 -173
View File
@@ -1,13 +1,10 @@
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
@@ -36,8 +33,7 @@ var (
)
type ContextMiddlewareConfig struct {
CookieDomain string
SessionCookieName string
CookieDomain string
}
type ContextMiddleware struct {
@@ -65,43 +61,194 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
return
}
uuid, err := c.Cookie(m.config.SessionCookieName)
cookie, err := m.auth.GetSessionCookie(c)
if err == nil {
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid)
if err != nil {
tlog.App.Debug().Err(err).Msg("No valid session cookie found")
goto basic
}
if err != nil {
tlog.App.Error().Msgf("Error authenticating session cookie: %v", err)
c.Next()
return
}
if cookie != nil {
http.SetCookie(c.Writer, cookie)
}
tlog.App.Trace().Msgf("Authenticated user from session cookie: %s", userContext.GetUsername())
c.Set("context", userContext)
if cookie.TotpPending {
c.Set("context", &config.UserContext{
Username: cookie.Username,
Name: cookie.Name,
Email: cookie.Email,
Provider: "local",
TotpPending: true,
TotpEnabled: true,
})
c.Next()
return
}
basic, err := m.auth.GetBasicAuth(c.Request)
switch cookie.Provider {
case "local", "ldap":
userSearch := m.auth.SearchUser(cookie.Username)
if err == nil {
userContext, headers, err := m.basicAuth(c.Request.Context(), basic)
if userSearch.Type == "unknown" {
tlog.App.Debug().Msg("User from session cookie not found")
m.auth.DeleteSessionCookie(c)
goto basic
}
if err != nil {
tlog.App.Error().Msgf("Error authenticating basic auth: %v", err)
if userSearch.Type != cookie.Provider {
tlog.App.Warn().Msg("User type from session cookie does not match user search type")
m.auth.DeleteSessionCookie(c)
c.Next()
return
}
for k, v := range headers {
c.Header(k, v)
var ldapGroups []string
var localAttributes config.UserAttributes
if cookie.Provider == "ldap" {
ldapUser, err := m.auth.GetLdapUser(userSearch.Username)
if err != nil {
tlog.App.Error().Err(err).Msg("Error retrieving LDAP user details")
c.Next()
return
}
ldapGroups = ldapUser.Groups
}
c.Set("context", userContext)
if cookie.Provider == "local" {
localUser := m.auth.GetLocalUser(cookie.Username)
localAttributes = localUser.Attributes
}
m.auth.RefreshSessionCookie(c)
c.Set("context", &config.UserContext{
Username: cookie.Username,
Name: cookie.Name,
Email: cookie.Email,
Provider: cookie.Provider,
IsLoggedIn: true,
LdapGroups: strings.Join(ldapGroups, ","),
Attributes: localAttributes,
})
c.Next()
return
default:
_, exists := m.broker.GetService(cookie.Provider)
if !exists {
tlog.App.Debug().Msg("OAuth provider from session cookie not found")
m.auth.DeleteSessionCookie(c)
goto basic
}
if !m.auth.IsEmailWhitelisted(cookie.Email) {
tlog.App.Debug().Msg("Email from session cookie not whitelisted")
m.auth.DeleteSessionCookie(c)
goto basic
}
m.auth.RefreshSessionCookie(c)
c.Set("context", &config.UserContext{
Username: cookie.Username,
Name: cookie.Name,
Email: cookie.Email,
Provider: cookie.Provider,
OAuthGroups: cookie.OAuthGroups,
OAuthName: cookie.OAuthName,
OAuthSub: cookie.OAuthSub,
IsLoggedIn: true,
OAuth: true,
})
c.Next()
return
}
basic:
basic := m.auth.GetBasicAuth(c)
if basic == nil {
tlog.App.Debug().Msg("No basic auth provided")
c.Next()
return
}
locked, remaining := m.auth.IsAccountLocked(basic.Username)
if locked {
tlog.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", basic.Username, remaining)
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.Next()
return
}
userSearch := m.auth.SearchUser(basic.Username)
if userSearch.Type == "unknown" || userSearch.Type == "error" {
m.auth.RecordLoginAttempt(basic.Username, false)
tlog.App.Debug().Msg("User from basic auth not found")
c.Next()
return
}
if !m.auth.VerifyUser(userSearch, basic.Password) {
m.auth.RecordLoginAttempt(basic.Username, false)
tlog.App.Debug().Msg("Invalid password for basic auth user")
c.Next()
return
}
m.auth.RecordLoginAttempt(basic.Username, true)
switch userSearch.Type {
case "local":
tlog.App.Debug().Msg("Basic auth user is local")
user := m.auth.GetLocalUser(basic.Username)
if user.TotpSecret != "" {
tlog.App.Debug().Msg("User with TOTP not allowed to login via basic auth")
return
}
name := utils.Capitalize(user.Username)
if user.Attributes.Name != "" {
name = user.Attributes.Name
}
email := utils.CompileUserEmail(user.Username, m.config.CookieDomain)
if user.Attributes.Email != "" {
email = user.Attributes.Email
}
c.Set("context", &config.UserContext{
Username: user.Username,
Name: name,
Email: email,
Provider: "local",
IsLoggedIn: true,
IsBasicAuth: true,
Attributes: user.Attributes,
})
c.Next()
return
case "ldap":
tlog.App.Debug().Msg("Basic auth user is LDAP")
ldapUser, err := m.auth.GetLdapUser(basic.Username)
if err != nil {
tlog.App.Debug().Err(err).Msg("Error retrieving LDAP user details")
c.Next()
return
}
c.Set("context", &config.UserContext{
Username: basic.Username,
Name: utils.Capitalize(basic.Username),
Email: utils.CompileUserEmail(basic.Username, m.config.CookieDomain),
Provider: "ldap",
IsLoggedIn: true,
LdapGroups: strings.Join(ldapUser.Groups, ","),
IsBasicAuth: true,
})
c.Next()
return
}
@@ -110,150 +257,6 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
}
}
func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model.UserContext, *http.Cookie, error) {
session, err := m.auth.GetSession(ctx, uuid)
if err != nil {
return nil, nil, fmt.Errorf("error retrieving session: %w", err)
}
userContext, err := new(model.UserContext).NewFromSession(session)
if err != nil {
return nil, nil, fmt.Errorf("error creating user context from session: %w", err)
}
if userContext.Provider == model.ProviderLocal &&
userContext.Local.TOTPPending {
userContext.Local.TOTPEnabled = true
return userContext, nil, nil
}
switch userContext.Provider {
case model.ProviderLocal:
user := m.auth.GetLocalUser(userContext.Local.Username)
if user == nil {
return nil, nil, fmt.Errorf("local user not found")
}
userContext.Local.Attributes = user.Attributes
if userContext.Local.Attributes.Name == "" {
userContext.Local.Attributes.Name = utils.Capitalize(user.Username)
}
if userContext.Local.Attributes.Email == "" {
userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.config.CookieDomain)
}
case model.ProviderLDAP:
search, err := m.auth.SearchUser(userContext.LDAP.Username)
if err != nil {
return nil, nil, fmt.Errorf("error searching for ldap user: %w", err)
}
if search.Type != model.UserLDAP {
return nil, nil, fmt.Errorf("user from session cookie is not ldap")
}
user, err := m.auth.GetLDAPUser(search.Username)
if err != nil {
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
}
userContext.LDAP.Groups = user.Groups
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.config.CookieDomain)
case model.ProviderOAuth:
_, exists := m.broker.GetService(userContext.OAuth.ID)
if !exists {
return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID)
}
if !m.auth.IsEmailWhitelisted(userContext.OAuth.Email) {
m.auth.DeleteSession(ctx, uuid)
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
}
}
cookie, err := m.auth.RefreshSession(ctx, uuid)
if err != nil {
return nil, nil, fmt.Errorf("error refreshing session: %w", err)
}
return userContext, cookie, nil
}
func (m *ContextMiddleware) basicAuth(ctx context.Context, basic *model.LocalUser) (*model.UserContext, map[string]string, error) {
headers := make(map[string]string)
userContext := new(model.UserContext)
locked, remaining := m.auth.IsAccountLocked(basic.Username)
if locked {
tlog.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", basic.Username, remaining)
headers["x-tinyauth-lock-locked"] = "true"
headers["x-tinyauth-lock-reset"] = time.Now().Add(time.Duration(remaining) * time.Second).Format(time.RFC3339)
return nil, headers, nil
}
search, err := m.auth.SearchUser(basic.Username)
if err != nil {
return nil, nil, fmt.Errorf("error searching for user: %w", err)
}
err = m.auth.CheckUserPassword(*search, basic.Password)
if err != nil {
m.auth.RecordLoginAttempt(basic.Username, false)
return nil, nil, fmt.Errorf("invalid password for basic auth user: %w", err)
}
m.auth.RecordLoginAttempt(basic.Username, true)
switch search.Type {
case model.UserLocal:
user := m.auth.GetLocalUser(basic.Username)
if user.TOTPSecret != "" {
return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", basic.Username)
}
userContext.Local = &model.LocalContext{
BaseContext: model.BaseContext{
Username: user.Username,
Name: utils.Capitalize(user.Username),
Email: utils.CompileUserEmail(user.Username, m.config.CookieDomain),
},
Attributes: user.Attributes,
}
userContext.Provider = model.ProviderLocal
case model.UserLDAP:
user, err := m.auth.GetLDAPUser(basic.Username)
if err != nil {
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
}
userContext.LDAP = &model.LDAPContext{
BaseContext: model.BaseContext{
Username: basic.Username,
Name: utils.Capitalize(basic.Username),
Email: utils.CompileUserEmail(basic.Username, m.config.CookieDomain),
},
Groups: user.Groups,
}
userContext.Provider = model.ProviderLDAP
}
userContext.Authenticated = true
return userContext, nil, nil
}
func (m *ContextMiddleware) isIgnorePath(path string) bool {
for _, prefix := range contextSkipPathsPrefix {
if strings.HasPrefix(path, prefix) {
-23
View File
@@ -1,23 +0,0 @@
package model
const DefaultNamePrefix = "TINYAUTH_"
const APIServer = "https://api.tinyauth.app"
type Claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Groups any `json:"groups"`
}
var OverrideProviders = map[string]string{
"google": "Google",
"github": "GitHub",
}
const SessionCookieName = "tinyauth-session"
const CSRFCookieName = "tinyauth-csrf"
const RedirectCookieName = "tinyauth-redirect"
const OAuthSessionCookieName = "tinyauth-oauth"
-206
View File
@@ -1,206 +0,0 @@
package model
import (
"errors"
"strings"
"github.com/gin-gonic/gin"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
type ProviderType int
const (
ProviderLocal ProviderType = iota
ProviderBasicAuth
ProviderOAuth
ProviderLDAP
)
type UserContext struct {
Authenticated bool
Provider ProviderType
Local *LocalContext
OAuth *OAuthContext
LDAP *LDAPContext
}
type BaseContext struct {
Username string
Name string
Email string
}
type LocalContext struct {
BaseContext
TOTPPending bool
TOTPEnabled bool
Attributes UserAttributes
}
type OAuthContext struct {
BaseContext
Groups []string
Sub string
DisplayName string
ID string
}
type LDAPContext struct {
BaseContext
Groups []string
}
func (c *UserContext) IsAuthenticated() bool {
return c.Authenticated
}
func (c *UserContext) IsLocal() bool {
return c.Provider == ProviderLocal
}
func (c *UserContext) IsOAuth() bool {
return c.Provider == ProviderOAuth
}
func (c *UserContext) IsLDAP() bool {
return c.Provider == ProviderLDAP
}
func (c *UserContext) IsBasicAuth() bool {
return c.Provider == ProviderBasicAuth
}
func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
userContextValue, exists := ginctx.Get("context")
if !exists {
return nil, errors.New("failed to get user context")
}
userContext, ok := userContextValue.(*UserContext)
if !ok {
return nil, errors.New("invalid user context type")
}
*c = *userContext
return c, nil
}
// Compatability layer until we get an excuse to drop in database migrations
func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) {
switch session.Provider {
case "local":
c.Provider = ProviderLocal
c.Local = &LocalContext{
BaseContext: BaseContext{
Username: session.Username,
Name: session.Name,
Email: session.Email,
},
TOTPPending: session.TotpPending,
}
case "ldap":
c.Provider = ProviderLDAP
c.LDAP = &LDAPContext{
BaseContext: BaseContext{
Username: session.Username,
Name: session.Name,
Email: session.Email,
},
}
// By default we assume an unkown name which is oauth
default:
c.Provider = ProviderOAuth
c.OAuth = &OAuthContext{
BaseContext: BaseContext{
Username: session.Username,
Name: session.Name,
Email: session.Email,
},
Groups: strings.Split(session.OAuthGroups, ","),
Sub: session.OAuthSub,
DisplayName: session.OAuthName,
ID: session.Provider,
}
}
if !session.TotpPending {
c.Authenticated = true
}
return c, nil
}
func (c *UserContext) GetUsername() string {
switch c.Provider {
case ProviderLocal:
return c.Local.Username
case ProviderLDAP:
return c.LDAP.Username
case ProviderBasicAuth:
return c.Local.Username
case ProviderOAuth:
return c.OAuth.Username
default:
return ""
}
}
func (c *UserContext) GetEmail() string {
switch c.Provider {
case ProviderLocal:
return c.Local.Email
case ProviderLDAP:
return c.LDAP.Email
case ProviderBasicAuth:
return c.Local.Email
case ProviderOAuth:
return c.OAuth.Email
default:
return ""
}
}
func (c *UserContext) GetName() string {
switch c.Provider {
case ProviderLocal:
return c.Local.Name
case ProviderLDAP:
return c.LDAP.Name
case ProviderBasicAuth:
return c.Local.Name
case ProviderOAuth:
return c.OAuth.Name
default:
return ""
}
}
func (c *UserContext) ProviderName() string {
switch c.Provider {
case ProviderBasicAuth, ProviderLocal:
return "local"
case ProviderLDAP:
return "ldap"
case ProviderOAuth:
return c.OAuth.DisplayName // compatability
default:
return "unknown"
}
}
func (c *UserContext) TOTPPending() bool {
if c.Provider == ProviderLocal {
return c.Local.TOTPPending
}
return false
}
func (c *UserContext) OAuthName() string {
if c.Provider == ProviderOAuth {
return c.OAuth.DisplayName
}
return ""
}
-25
View File
@@ -1,25 +0,0 @@
package model
type UserSearchType int
const (
UserLocal UserSearchType = iota
UserLDAP
)
type LDAPUser struct {
DN string
Groups []string
}
type LocalUser struct {
Username string
Password string
TOTPSecret string
Attributes UserAttributes
}
type UserSearch struct {
Username string
Type UserSearchType
}
-5
View File
@@ -1,5 +0,0 @@
package model
var Version = "development"
var CommitHash = "development"
var BuildTimestamp = "0000-00-00T00:00:00Z"
+14 -18
View File
@@ -4,23 +4,19 @@ import (
"errors"
"strings"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
)
type LabelProvider interface {
GetLabels(appDomain string) (*model.App, error)
}
type AccessControlsService struct {
labelProvider LabelProvider
static map[string]model.App
docker *DockerService
static map[string]config.App
}
func NewAccessControlsService(labelProvider LabelProvider, static map[string]model.App) *AccessControlsService {
func NewAccessControlsService(docker *DockerService, static map[string]config.App) *AccessControlsService {
return &AccessControlsService{
labelProvider: labelProvider,
static: static,
docker: docker,
static: static,
}
}
@@ -28,22 +24,22 @@ func (acls *AccessControlsService) Init() error {
return nil // No initialization needed
}
func (acls *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {
for app, config := range acls.static {
if config.Config.Domain == domain {
tlog.App.Debug().Str("name", app).Msg("Found matching container by domain")
return &config, nil
return config, nil
}
if strings.SplitN(domain, ".", 2)[0] == app {
tlog.App.Debug().Str("name", app).Msg("Found matching container by app name")
return &config, nil
return config, nil
}
}
return nil, errors.New("no results")
return config.App{}, errors.New("no results")
}
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
func (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {
// First check in the static config
app, err := acls.lookupStaticACLs(domain)
@@ -52,7 +48,7 @@ func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App,
return app, nil
}
// Fallback to label provider
tlog.App.Debug().Msg("Falling back to label provider for ACLs")
return acls.labelProvider.GetLabels(domain)
// Fallback to Docker labels
tlog.App.Debug().Msg("Falling back to Docker labels for ACLs")
return acls.docker.GetLabels(domain)
}
+140 -151
View File
@@ -5,22 +5,20 @@ import (
"database/sql"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"slices"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"golang.org/x/exp/slices"
"golang.org/x/oauth2"
)
@@ -30,10 +28,6 @@ const MaxOAuthPendingSessions = 256
const OAuthCleanupCount = 16
const MaxLoginAttemptRecords = 256
var (
ErrUserNotFound = errors.New("user not found")
)
// slightly modified version of the AuthorizeRequest from the OIDC service to basically accept all
// parameters and pass them to the authorize page if needed
type OAuthURLParams struct {
@@ -73,7 +67,7 @@ type Lockdown struct {
}
type AuthServiceConfig struct {
LocalUsers []model.LocalUser
Users []config.User
OauthWhitelist []string
SessionExpiry int
SessionMaxLifetime int
@@ -82,12 +76,13 @@ type AuthServiceConfig struct {
LoginTimeout int
LoginMaxRetries int
SessionCookieName string
IP model.IPConfig
IP config.IPConfig
LDAPGroupsCacheTTL int
}
type AuthService struct {
config AuthServiceConfig
docker *DockerService
loginAttempts map[string]*LoginAttempt
ldapGroupsCache map[string]*LdapGroupsCache
oauthPendingSessions map[string]*OAuthPendingSession
@@ -102,9 +97,10 @@ type AuthService struct {
lockdownCancelFunc context.CancelFunc
}
func NewAuthService(config AuthServiceConfig, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService {
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService {
return &AuthService{
config: config,
docker: docker,
loginAttempts: make(map[string]*LoginAttempt),
ldapGroupsCache: make(map[string]*LdapGroupsCache),
oauthPendingSessions: make(map[string]*OAuthPendingSession),
@@ -119,67 +115,79 @@ func (auth *AuthService) Init() error {
return nil
}
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
func (auth *AuthService) SearchUser(username string) config.UserSearch {
if auth.GetLocalUser(username).Username != "" {
return &model.UserSearch{
return config.UserSearch{
Username: username,
Type: model.UserLocal,
}, nil
Type: "local",
}
}
if auth.ldap.IsConfigured() {
userDN, err := auth.ldap.GetUserDN(username)
if err != nil {
return nil, fmt.Errorf("failed to get ldap user: %w", err)
tlog.App.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP")
return config.UserSearch{
Type: "unknown",
}
}
return &model.UserSearch{
return config.UserSearch{
Username: userDN,
Type: model.UserLDAP,
}, nil
Type: "ldap",
}
}
return nil, ErrUserNotFound
return config.UserSearch{
Type: "unknown",
}
}
func (auth *AuthService) CheckUserPassword(search model.UserSearch, password string) error {
func (auth *AuthService) VerifyUser(search config.UserSearch, password string) bool {
switch search.Type {
case model.UserLocal:
case "local":
user := auth.GetLocalUser(search.Username)
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
case model.UserLDAP:
return auth.CheckPassword(user, password)
case "ldap":
if auth.ldap.IsConfigured() {
err := auth.ldap.Bind(search.Username, password)
if err != nil {
return fmt.Errorf("failed to bind to ldap user: %w", err)
tlog.App.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
return false
}
err = auth.ldap.BindService(true)
if err != nil {
return fmt.Errorf("failed to bind to ldap service account: %w", err)
tlog.App.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
return false
}
return nil
return true
}
default:
return errors.New("unknown user search type")
tlog.App.Debug().Str("type", search.Type).Msg("Unknown user type for authentication")
return false
}
return errors.New("user authentication failed")
tlog.App.Warn().Str("username", search.Username).Msg("User authentication failed")
return false
}
func (auth *AuthService) GetLocalUser(username string) *model.LocalUser {
for _, user := range auth.config.LocalUsers {
func (auth *AuthService) GetLocalUser(username string) config.User {
for _, user := range auth.config.Users {
if user.Username == username {
return &user
return user
}
}
return nil
tlog.App.Warn().Str("username", username).Msg("Local user not found")
return config.User{}
}
func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
func (auth *AuthService) GetLdapUser(userDN string) (config.LdapUser, error) {
if !auth.ldap.IsConfigured() {
return nil, errors.New("ldap service not configured")
return config.LdapUser{}, errors.New("LDAP service not initialized")
}
auth.ldapGroupsMutex.RLock()
@@ -187,7 +195,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
auth.ldapGroupsMutex.RUnlock()
if exists && time.Now().Before(entry.Expires) {
return &model.LDAPUser{
return config.LdapUser{
DN: userDN,
Groups: entry.Groups,
}, nil
@@ -196,7 +204,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
groups, err := auth.ldap.GetUserGroups(userDN)
if err != nil {
return nil, fmt.Errorf("failed to get ldap groups: %w", err)
return config.LdapUser{}, err
}
auth.ldapGroupsMutex.Lock()
@@ -206,12 +214,16 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
}
auth.ldapGroupsMutex.Unlock()
return &model.LDAPUser{
return config.LdapUser{
DN: userDN,
Groups: groups,
}, nil
}
func (auth *AuthService) CheckPassword(user config.User, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
}
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
auth.loginMutex.RLock()
defer auth.loginMutex.RUnlock()
@@ -280,11 +292,11 @@ func (auth *AuthService) IsEmailWhitelisted(email string) bool {
return utils.CheckFilter(strings.Join(auth.config.OauthWhitelist, ","), email)
}
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *repository.Session) error {
uuid, err := uuid.NewRandom()
if err != nil {
return nil, fmt.Errorf("failed to generate session uuid: %w", err)
return err
}
var expiry int
@@ -309,30 +321,28 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
OAuthSub: data.OAuthSub,
}
_, err = auth.queries.CreateSession(ctx, session)
_, err = auth.queries.CreateSession(c, session)
if err != nil {
return nil, fmt.Errorf("failed to create session entry: %w", err)
return err
}
return &http.Cookie{
Name: auth.config.SessionCookieName,
Value: session.UUID,
Path: "/",
Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
Expires: time.Now().Add(time.Duration(expiry) * time.Second),
MaxAge: expiry,
Secure: auth.config.SecureCookie,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}, nil
c.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
return nil
}
func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http.Cookie, error) {
session, err := auth.queries.GetSession(ctx, uuid)
func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
cookie, err := c.Cookie(auth.config.SessionCookieName)
if err != nil {
return nil, fmt.Errorf("failed to retrieve session: %w", err)
return err
}
session, err := auth.queries.GetSession(c, cookie)
if err != nil {
return err
}
currentTime := time.Now().Unix()
@@ -346,12 +356,12 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
}
if session.Expiry-currentTime > refreshThreshold {
return nil, nil
return nil
}
newExpiry := session.Expiry + refreshThreshold
_, err = auth.queries.UpdateSession(ctx, repository.UpdateSessionParams{
_, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{
Username: session.Username,
Email: session.Email,
Name: session.Name,
@@ -365,121 +375,120 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
})
if err != nil {
return nil, fmt.Errorf("failed to update session expiry: %w", err)
return err
}
return &http.Cookie{
Name: auth.config.SessionCookieName,
Value: session.UUID,
Path: "/",
Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
MaxAge: auth.config.SessionExpiry,
Secure: auth.config.SecureCookie,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}, nil
c.SetCookie(auth.config.SessionCookieName, cookie, int(newExpiry-currentTime), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
tlog.App.Trace().Str("username", session.Username).Msg("Session cookie refreshed")
return nil
}
func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.Cookie, error) {
err := auth.queries.DeleteSession(ctx, uuid)
func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
cookie, err := c.Cookie(auth.config.SessionCookieName)
if err != nil {
tlog.App.Warn().Err(err).Msg("Failed to delete session from database, proceeding to clear cookie anyway")
return err
}
return &http.Cookie{
Name: auth.config.SessionCookieName,
Value: "",
Path: "/",
Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
Expires: time.Now(),
MaxAge: -1,
Secure: auth.config.SecureCookie,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}, nil
err = auth.queries.DeleteSession(c, cookie)
if err != nil {
return err
}
c.SetCookie(auth.config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
return nil
}
func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*repository.Session, error) {
session, err := auth.queries.GetSession(ctx, uuid)
func (auth *AuthService) GetSessionCookie(c *gin.Context) (repository.Session, error) {
cookie, err := c.Cookie(auth.config.SessionCookieName)
if err != nil {
return repository.Session{}, err
}
session, err := auth.queries.GetSession(c, cookie)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("session not found")
return repository.Session{}, fmt.Errorf("session not found")
}
return nil, err
return repository.Session{}, err
}
currentTime := time.Now().Unix()
if auth.config.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
if currentTime-session.CreatedAt > int64(auth.config.SessionMaxLifetime) {
err = auth.queries.DeleteSession(ctx, uuid)
err = auth.queries.DeleteSession(c, cookie)
if err != nil {
return nil, fmt.Errorf("failed to delete expired session: %w", err)
tlog.App.Error().Err(err).Msg("Failed to delete session exceeding max lifetime")
}
return nil, fmt.Errorf("session max lifetime exceeded")
return repository.Session{}, fmt.Errorf("session expired due to max lifetime exceeded")
}
}
if currentTime > session.Expiry {
err = auth.queries.DeleteSession(ctx, uuid)
err = auth.queries.DeleteSession(c, cookie)
if err != nil {
return nil, fmt.Errorf("failed to delete expired session: %w", err)
tlog.App.Error().Err(err).Msg("Failed to delete expired session")
}
return nil, fmt.Errorf("session expired")
return repository.Session{}, fmt.Errorf("session expired")
}
return &session, nil
return repository.Session{
UUID: session.UUID,
Username: session.Username,
Email: session.Email,
Name: session.Name,
Provider: session.Provider,
TotpPending: session.TotpPending,
OAuthGroups: session.OAuthGroups,
OAuthName: session.OAuthName,
OAuthSub: session.OAuthSub,
}, nil
}
func (auth *AuthService) LocalAuthConfigured() bool {
return len(auth.config.LocalUsers) > 0
return len(auth.config.Users) > 0
}
func (auth *AuthService) LDAPAuthConfigured() bool {
func (auth *AuthService) LdapAuthConfigured() bool {
return auth.ldap.IsConfigured()
}
func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if context.Provider == model.ProviderOAuth {
func (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {
if context.OAuth {
tlog.App.Debug().Msg("Checking OAuth whitelist")
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
return utils.CheckFilter(acls.OAuth.Whitelist, context.Email)
}
if acls.Users.Block != "" {
tlog.App.Debug().Msg("Checking blocked users")
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
if utils.CheckFilter(acls.Users.Block, context.Username) {
return false
}
}
tlog.App.Debug().Msg("Checking users")
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
return utils.CheckFilter(acls.Users.Allow, context.Username)
}
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, requiredGroups string) bool {
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {
if requiredGroups == "" {
return true
}
if !context.IsOAuth() {
tlog.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return false
for id := range config.OverrideProviders {
if context.Provider == id {
tlog.App.Info().Str("provider", id).Msg("OAuth groups not supported for this provider")
return true
}
}
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
tlog.App.Debug().Msg("Provider override for OAuth groups enabled, skipping group check")
return true
}
for _, userGroup := range context.OAuth.Groups {
for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") {
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
return true
@@ -490,17 +499,12 @@ func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContex
return false
}
func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext, requiredGroups string) bool {
func (auth *AuthService) IsInLdapGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {
if requiredGroups == "" {
return true
}
if !context.IsLDAP() {
tlog.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
return false
}
for _, userGroup := range context.LDAP.Groups {
for userGroup := range strings.SplitSeq(context.LdapGroups, ",") {
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
return true
@@ -511,11 +515,7 @@ func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext
return false
}
func (auth *AuthService) IsAuthEnabled(uri string, path *model.AppPath) (bool, error) {
if path == nil {
return true, nil
}
func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) {
// Check for block list
if path.Block != "" {
regex, err := regexp.Compile(path.Block)
@@ -545,26 +545,19 @@ func (auth *AuthService) IsAuthEnabled(uri string, path *model.AppPath) (bool, e
return true, nil
}
// local user is used only as a medium to pass the basic auth credentials, user can be ldap too
func (auth *AuthService) GetBasicAuth(req *http.Request) (*model.LocalUser, error) {
if req == nil {
return nil, errors.New("request is nil")
}
username, password, ok := req.BasicAuth()
func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User {
username, password, ok := c.Request.BasicAuth()
if !ok {
return nil, errors.New("no basic auth credentials provided")
tlog.App.Debug().Msg("No basic auth provided")
return nil
}
return &model.LocalUser{
return &config.User{
Username: username,
Password: password,
}, nil
}
}
func (auth *AuthService) CheckIP(acls *model.AppIP, ip string) bool {
if acls == nil {
acls = &model.AppIP{}
}
func (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool {
// Merge the global and app IP filter
blockedIps := append(auth.config.IP.Block, acls.Block...)
allowedIPs := append(auth.config.IP.Allow, acls.Allow...)
@@ -602,11 +595,7 @@ func (auth *AuthService) CheckIP(acls *model.AppIP, ip string) bool {
return true
}
func (auth *AuthService) IsBypassedIP(acls *model.AppIP, ip string) bool {
if acls == nil {
return false
}
func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool {
for _, bypassed := range acls.Bypass {
res, err := utils.FilterIP(bypassed, ip)
if err != nil {
@@ -686,21 +675,21 @@ func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.T
return token, nil
}
func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, error) {
func (auth *AuthService) GetOAuthUserinfo(sessionId string) (config.Claims, error) {
session, err := auth.GetOAuthPendingSession(sessionId)
if err != nil {
return nil, err
return config.Claims{}, err
}
if session.Token == nil {
return nil, fmt.Errorf("oauth token not found for session: %s", sessionId)
return config.Claims{}, fmt.Errorf("oauth token not found for session: %s", sessionId)
}
userinfo, err := (*session.Service).GetUserinfo(session.Token)
if err != nil {
return nil, fmt.Errorf("failed to get userinfo: %w", err)
return config.Claims{}, fmt.Errorf("failed to get userinfo: %w", err)
}
return userinfo, nil
+10 -10
View File
@@ -4,7 +4,7 @@ import (
"context"
"strings"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
@@ -66,41 +66,41 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
return inspect, nil
}
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
if !docker.isConnected {
tlog.App.Debug().Msg("Docker not connected, returning empty labels")
return nil, nil
return config.App{}, nil
}
containers, err := docker.getContainers()
if err != nil {
return nil, err
return config.App{}, err
}
for _, ctr := range containers {
inspect, err := docker.inspectContainer(ctr.ID)
if err != nil {
return nil, err
return config.App{}, err
}
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
labels, err := decoders.DecodeLabels[config.Apps](inspect.Config.Labels, "apps")
if err != nil {
return nil, err
return config.App{}, err
}
for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == appDomain {
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
return &appLabels, nil
return appLabels, nil
}
if strings.SplitN(appDomain, ".", 2)[0] == appName {
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
return &appLabels, nil
return appLabels, nil
}
}
}
tlog.App.Debug().Msg("No matching container found, returning empty labels")
return nil, nil
return config.App{}, nil
}
-302
View File
@@ -1,302 +0,0 @@
package service
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
)
type ingressKey struct {
namespace string
name string
}
type ingressAppKey struct {
ingressKey
appName string
}
type ingressApp struct {
domain string
appName string
app model.App
}
type KubernetesService struct {
client dynamic.Interface
ctx context.Context
cancel context.CancelFunc
started bool
mu sync.RWMutex
ingressApps map[ingressKey][]ingressApp
domainIndex map[string]ingressAppKey
appNameIndex map[string]ingressAppKey
}
func NewKubernetesService() *KubernetesService {
return &KubernetesService{
ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
}
}
func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) {
k.mu.Lock()
defer k.mu.Unlock()
key := ingressKey{namespace, name}
// Remove existing entries for this ingress
if existing, ok := k.ingressApps[key]; ok {
for _, app := range existing {
delete(k.domainIndex, app.domain)
delete(k.appNameIndex, app.appName)
}
}
// Add new entries
k.ingressApps[key] = apps
for _, app := range apps {
appKey := ingressAppKey{key, app.appName}
k.domainIndex[app.domain] = appKey
k.appNameIndex[app.appName] = appKey
}
}
func (k *KubernetesService) removeIngress(namespace, name string) {
k.mu.Lock()
defer k.mu.Unlock()
key := ingressKey{namespace, name}
if apps, ok := k.ingressApps[key]; ok {
for _, app := range apps {
delete(k.domainIndex, app.domain)
delete(k.appNameIndex, app.appName)
}
delete(k.ingressApps, key)
}
}
func (k *KubernetesService) getByDomain(domain string) (*model.App, bool) {
k.mu.RLock()
defer k.mu.RUnlock()
if appKey, ok := k.domainIndex[domain]; ok {
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
for _, app := range apps {
if app.domain == domain && app.appName == appKey.appName {
return &app.app, true
}
}
}
}
return nil, false
}
func (k *KubernetesService) getByAppName(appName string) (*model.App, bool) {
k.mu.RLock()
defer k.mu.RUnlock()
if appKey, ok := k.appNameIndex[appName]; ok {
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
for _, app := range apps {
if app.appName == appName {
return &app.app, true
}
}
}
}
return nil, false
}
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
namespace := item.GetNamespace()
name := item.GetName()
annotations := item.GetAnnotations()
if annotations == nil {
k.removeIngress(namespace, name)
return
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil {
tlog.App.Debug().Err(err).Msg("Failed to decode labels from annotations")
k.removeIngress(namespace, name)
return
}
var apps []ingressApp
for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == "" {
continue
}
apps = append(apps, ingressApp{
domain: appLabels.Config.Domain,
appName: appName,
app: appLabels,
})
}
if len(apps) == 0 {
k.removeIngress(namespace, name)
} else {
k.addIngressApps(namespace, name, apps)
}
}
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
ctx, cancel := context.WithTimeout(k.ctx, 30*time.Second)
defer cancel()
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
if err != nil {
tlog.App.Debug().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list ingresses during resync")
return err
}
for i := range list.Items {
k.updateFromItem(&list.Items[i])
}
tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resynced ingress cache")
return nil
}
// runWatcher drains events from an active watcher until it closes or the context is done.
// Returns true if the caller should restart the watcher, false if it should exit.
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker) bool {
for {
select {
case <-k.ctx.Done():
w.Stop()
return false
case event, ok := <-w.ResultChan():
if !ok {
tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting in 5 seconds")
w.Stop()
time.Sleep(5 * time.Second)
return true
}
item, ok := event.Object.(*unstructured.Unstructured)
if !ok {
tlog.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Failed to cast watched object")
continue
}
switch event.Type {
case watch.Added, watch.Modified:
k.updateFromItem(item)
case watch.Deleted:
k.removeIngress(item.GetNamespace(), item.GetName())
}
case <-resyncTicker.C:
if err := k.resyncGVR(gvr); err != nil {
tlog.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed")
}
}
}
}
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
resyncTicker := time.NewTicker(5 * time.Minute)
defer resyncTicker.Stop()
if err := k.resyncGVR(gvr); err != nil {
tlog.App.Error().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, retrying in 30 seconds")
time.Sleep(30 * time.Second)
}
for {
select {
case <-k.ctx.Done():
tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Stopping watcher")
return
case <-resyncTicker.C:
if err := k.resyncGVR(gvr); err != nil {
tlog.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed")
}
default:
ctx, cancel := context.WithCancel(k.ctx)
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
if err != nil {
tlog.App.Error().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher")
cancel()
time.Sleep(10 * time.Second)
continue
}
tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started")
if !k.runWatcher(gvr, watcher, resyncTicker) {
cancel()
return
}
cancel()
}
}
}
func (k *KubernetesService) Init() error {
var cfg *rest.Config
var err error
cfg, err = rest.InClusterConfig()
if err != nil {
return fmt.Errorf("failed to get in-cluster Kubernetes config: %w", err)
}
client, err := dynamic.NewForConfig(cfg)
if err != nil {
return fmt.Errorf("failed to create Kubernetes client: %w", err)
}
k.client = client
k.ctx, k.cancel = context.WithCancel(context.Background())
gvr := schema.GroupVersionResource{
Group: "networking.k8s.io",
Version: "v1",
Resource: "ingresses",
}
accessCtx, accessCancel := context.WithTimeout(k.ctx, 5*time.Second)
defer accessCancel()
_, err = k.client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
if err != nil {
tlog.App.Warn().Err(err).Msg("Insufficient permissions for networking.k8s.io/v1 Ingress, Kubernetes label provider will not work")
k.started = false
return nil
}
tlog.App.Debug().Msg("networking.k8s.io/v1 Ingress API accessible")
go k.watchGVR(gvr)
k.started = true
tlog.App.Info().Msg("Kubernetes label provider initialized")
return nil
}
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
if !k.started {
tlog.App.Debug().Msg("Kubernetes not connected, returning empty labels")
return nil, nil
}
// First check cache
if app, found := k.getByDomain(appDomain); found {
tlog.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
return app, nil
}
appName := strings.SplitN(appDomain, ".", 2)[0]
if app, found := k.getByAppName(appName); found {
tlog.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
return app, nil
}
tlog.App.Debug().Str("domain", appDomain).Msg("Cache miss, no matching ingress found")
return nil, nil
}
-186
View File
@@ -1,186 +0,0 @@
package service
import (
"testing"
"github.com/tinyauthapp/tinyauth/internal/config"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestKubernetesService(t *testing.T) {
type testCase struct {
description string
run func(t *testing.T, svc *KubernetesService)
}
tests := []testCase{
{
description: "Cache by domain returns app and misses unknown domain",
run: func(t *testing.T, svc *KubernetesService) {
app := config.App{Config: config.AppConfig{Domain: "foo.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "foo.example.com", appName: "foo", app: app},
})
got, ok := svc.getByDomain("foo.example.com")
require.True(t, ok)
assert.Equal(t, "foo.example.com", got.Config.Domain)
_, ok = svc.getByDomain("notfound.example.com")
assert.False(t, ok)
},
},
{
description: "Cache by app name returns app and misses unknown name",
run: func(t *testing.T, svc *KubernetesService) {
app := config.App{Config: config.AppConfig{Domain: "bar.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "bar.example.com", appName: "bar", app: app},
})
got, ok := svc.getByAppName("bar")
require.True(t, ok)
assert.Equal(t, "bar.example.com", got.Config.Domain)
_, ok = svc.getByAppName("notfound")
assert.False(t, ok)
},
},
{
description: "RemoveIngress clears domain and app name entries",
run: func(t *testing.T, svc *KubernetesService) {
app := config.App{Config: config.AppConfig{Domain: "baz.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "baz.example.com", appName: "baz", app: app},
})
svc.removeIngress("default", "my-ingress")
_, ok := svc.getByDomain("baz.example.com")
assert.False(t, ok)
_, ok = svc.getByAppName("baz")
assert.False(t, ok)
},
},
{
description: "AddIngressApps replaces stale entries for the same ingress",
run: func(t *testing.T, svc *KubernetesService) {
old := config.App{Config: config.AppConfig{Domain: "old.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "old.example.com", appName: "old", app: old},
})
updated := config.App{Config: config.AppConfig{Domain: "new.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "new.example.com", appName: "new", app: updated},
})
_, ok := svc.getByDomain("old.example.com")
assert.False(t, ok)
got, ok := svc.getByDomain("new.example.com")
require.True(t, ok)
assert.Equal(t, "new.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns app from cache when started",
run: func(t *testing.T, svc *KubernetesService) {
svc.started = true
app := config.App{Config: config.AppConfig{Domain: "hit.example.com"}}
svc.addIngressApps("default", "ing", []ingressApp{
{domain: "hit.example.com", appName: "hit", app: app},
})
got, err := svc.GetLabels("hit.example.com")
require.NoError(t, err)
assert.Equal(t, "hit.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns empty app on cache miss when started",
run: func(t *testing.T, svc *KubernetesService) {
svc.started = true
got, err := svc.GetLabels("notfound.example.com")
require.NoError(t, err)
assert.Equal(t, config.App{}, got)
},
},
{
description: "GetLabels resolves app by app name",
run: func(t *testing.T, svc *KubernetesService) {
svc.started = true
app := config.App{Config: config.AppConfig{Domain: "myapp.internal.example.com"}}
svc.addIngressApps("default", "ing", []ingressApp{
{domain: "myapp.internal.example.com", appName: "myapp", app: app},
})
got, err := svc.GetLabels("myapp.internal.example.com")
require.NoError(t, err)
assert.Equal(t, "myapp.internal.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns empty app when service not yet started",
run: func(t *testing.T, svc *KubernetesService) {
got, err := svc.GetLabels("anything.example.com")
require.NoError(t, err)
assert.Equal(t, config.App{}, got)
},
},
{
description: "UpdateFromItem parses annotations and populates cache",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.allow": "alice",
})
svc.updateFromItem(&item)
got, ok := svc.getByDomain("myapp.example.com")
require.True(t, ok)
assert.Equal(t, "myapp.example.com", got.Config.Domain)
assert.Equal(t, "alice", got.Users.Allow)
},
},
{
description: "UpdateFromItem with no annotations removes existing cache entries",
run: func(t *testing.T, svc *KubernetesService) {
app := config.App{Config: config.AppConfig{Domain: "todelete.example.com"}}
svc.addIngressApps("default", "test-ingress", []ingressApp{
{domain: "todelete.example.com", appName: "todelete", app: app},
})
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
svc.updateFromItem(&item)
_, ok := svc.getByDomain("todelete.example.com")
assert.False(t, ok)
},
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
svc := &KubernetesService{
ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
}
test.run(t, svc)
})
}
}
+6 -7
View File
@@ -1,11 +1,10 @@
package service
import (
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"slices"
"golang.org/x/exp/slices"
"golang.org/x/oauth2"
)
@@ -15,20 +14,20 @@ type OAuthServiceImpl interface {
NewRandom() string
GetAuthURL(state string, verifier string) string
GetToken(code string, verifier string) (*oauth2.Token, error)
GetUserinfo(token *oauth2.Token) (*model.Claims, error)
GetUserinfo(token *oauth2.Token) (config.Claims, error)
}
type OAuthBrokerService struct {
services map[string]OAuthServiceImpl
configs map[string]model.OAuthServiceConfig
configs map[string]config.OAuthServiceConfig
}
var presets = map[string]func(config model.OAuthServiceConfig) *OAuthService{
var presets = map[string]func(config config.OAuthServiceConfig) *OAuthService{
"github": newGitHubOAuthService,
"google": newGoogleOAuthService,
}
func NewOAuthBrokerService(configs map[string]model.OAuthServiceConfig) *OAuthBrokerService {
func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService {
return &OAuthBrokerService{
services: make(map[string]OAuthServiceImpl),
configs: configs,
+19 -19
View File
@@ -8,7 +8,7 @@ import (
"net/http"
"strconv"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
)
type GithubEmailResponse []struct {
@@ -22,32 +22,32 @@ type GithubUserInfoResponse struct {
ID int `json:"id"`
}
func defaultExtractor(client *http.Client, url string) (*model.Claims, error) {
return simpleReq[model.Claims](client, url, nil)
func defaultExtractor(client *http.Client, url string) (config.Claims, error) {
return simpleReq[config.Claims](client, url, nil)
}
func githubExtractor(client *http.Client, url string) (*model.Claims, error) {
var user model.Claims
func githubExtractor(client *http.Client, url string) (config.Claims, error) {
var user config.Claims
userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{
"accept": "application/vnd.github+json",
})
if err != nil {
return nil, err
return config.Claims{}, err
}
userEmails, err := simpleReq[GithubEmailResponse](client, "https://api.github.com/user/emails", map[string]string{
"accept": "application/vnd.github+json",
})
if err != nil {
return nil, err
return config.Claims{}, err
}
if len(*userEmails) == 0 {
return nil, errors.New("no emails found")
if len(userEmails) == 0 {
return user, errors.New("no emails found")
}
for _, email := range *userEmails {
for _, email := range userEmails {
if email.Primary {
user.Email = email.Email
break
@@ -56,22 +56,22 @@ func githubExtractor(client *http.Client, url string) (*model.Claims, error) {
// Use first available email if no primary email was found
if user.Email == "" {
user.Email = (*userEmails)[0].Email
user.Email = userEmails[0].Email
}
user.PreferredUsername = userInfo.Login
user.Name = userInfo.Name
user.Sub = strconv.Itoa(userInfo.ID)
return &user, nil
return user, nil
}
func simpleReq[T any](client *http.Client, url string, headers map[string]string) (*T, error) {
func simpleReq[T any](client *http.Client, url string, headers map[string]string) (T, error) {
var decodedRes T
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
return decodedRes, err
}
for key, value := range headers {
@@ -80,23 +80,23 @@ func simpleReq[T any](client *http.Client, url string, headers map[string]string
res, err := client.Do(req)
if err != nil {
return nil, err
return decodedRes, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("request failed with status: %s", res.Status)
return decodedRes, fmt.Errorf("request failed with status: %s", res.Status)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
return decodedRes, err
}
err = json.Unmarshal(body, &decodedRes)
if err != nil {
return nil, err
return decodedRes, err
}
return &decodedRes, nil
return decodedRes, nil
}
+3 -3
View File
@@ -1,11 +1,11 @@
package service
import (
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"golang.org/x/oauth2/endpoints"
)
func newGoogleOAuthService(config model.OAuthServiceConfig) *OAuthService {
func newGoogleOAuthService(config config.OAuthServiceConfig) *OAuthService {
scopes := []string{"openid", "email", "profile"}
config.Scopes = scopes
config.AuthURL = endpoints.Google.AuthURL
@@ -14,7 +14,7 @@ func newGoogleOAuthService(config model.OAuthServiceConfig) *OAuthService {
return NewOAuthService(config, "google")
}
func newGitHubOAuthService(config model.OAuthServiceConfig) *OAuthService {
func newGitHubOAuthService(config config.OAuthServiceConfig) *OAuthService {
scopes := []string{"read:user", "user:email"}
config.Scopes = scopes
config.AuthURL = endpoints.GitHub.AuthURL
+5 -5
View File
@@ -6,21 +6,21 @@ import (
"net/http"
"time"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"golang.org/x/oauth2"
)
type UserinfoExtractor func(client *http.Client, url string) (*model.Claims, error)
type UserinfoExtractor func(client *http.Client, url string) (config.Claims, error)
type OAuthService struct {
serviceCfg model.OAuthServiceConfig
serviceCfg config.OAuthServiceConfig
config *oauth2.Config
ctx context.Context
userinfoExtractor UserinfoExtractor
id string
}
func NewOAuthService(config model.OAuthServiceConfig, id string) *OAuthService {
func NewOAuthService(config config.OAuthServiceConfig, id string) *OAuthService {
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
@@ -78,7 +78,7 @@ func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, er
return s.config.Exchange(s.ctx, code, oauth2.VerifierOption(verifier))
}
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) {
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (config.Claims, error) {
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
}
+66 -69
View File
@@ -18,14 +18,13 @@ import (
"strings"
"time"
"slices"
"github.com/gin-gonic/gin"
"github.com/go-jose/go-jose/v4"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"golang.org/x/exp/slices"
)
var (
@@ -68,27 +67,27 @@ type ClaimSet struct {
}
type UserinfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
MiddleName string `json:"middle_name,omitempty"`
Nickname string `json:"nickname,omitempty"`
Profile string `json:"profile,omitempty"`
Picture string `json:"picture,omitempty"`
Website string `json:"website,omitempty"`
Gender string `json:"gender,omitempty"`
Birthdate string `json:"birthdate,omitempty"`
Zoneinfo string `json:"zoneinfo,omitempty"`
Locale string `json:"locale,omitempty"`
Email string `json:"email,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Groups []string `json:"groups,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"`
Address *model.AddressClaim `json:"address,omitempty"`
UpdatedAt int64 `json:"updated_at"`
Sub string `json:"sub"`
Name string `json:"name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
MiddleName string `json:"middle_name,omitempty"`
Nickname string `json:"nickname,omitempty"`
Profile string `json:"profile,omitempty"`
Picture string `json:"picture,omitempty"`
Website string `json:"website,omitempty"`
Gender string `json:"gender,omitempty"`
Birthdate string `json:"birthdate,omitempty"`
Zoneinfo string `json:"zoneinfo,omitempty"`
Locale string `json:"locale,omitempty"`
Email string `json:"email,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Groups []string `json:"groups,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"`
Address *config.AddressClaim `json:"address,omitempty"`
UpdatedAt int64 `json:"updated_at"`
}
type TokenResponse struct {
@@ -112,7 +111,7 @@ type AuthorizeRequest struct {
}
type OIDCServiceConfig struct {
Clients map[string]model.OIDCClientConfig
Clients map[string]config.OIDCClientConfig
PrivateKeyPath string
PublicKeyPath string
Issuer string
@@ -122,7 +121,7 @@ type OIDCServiceConfig struct {
type OIDCService struct {
config OIDCServiceConfig
queries *repository.Queries
clients map[string]model.OIDCClientConfig
clients map[string]config.OIDCClientConfig
privateKey *rsa.PrivateKey
publicKey crypto.PublicKey
issuer string
@@ -255,7 +254,7 @@ func (service *OIDCService) Init() error {
}
// We will reorganize the client into a map with the client ID as the key
service.clients = make(map[string]model.OIDCClientConfig)
service.clients = make(map[string]config.OIDCClientConfig)
for id, client := range service.config.Clients {
client.ID = id
@@ -283,7 +282,7 @@ func (service *OIDCService) GetIssuer() string {
return service.issuer
}
func (service *OIDCService) GetClient(id string) (model.OIDCClientConfig, bool) {
func (service *OIDCService) GetClient(id string) (config.OIDCClientConfig, bool) {
client, ok := service.clients[id]
return client, ok
}
@@ -367,45 +366,43 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
return err
}
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext model.UserContext, req AuthorizeRequest) error {
userInfoParams := repository.CreateOidcUserInfoParams{
Sub: sub,
Name: userContext.GetName(),
Email: userContext.GetEmail(),
PreferredUsername: userContext.GetUsername(),
UpdatedAt: time.Now().Unix(),
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {
addressJSON, err := json.Marshal(userContext.Attributes.Address)
if err != nil {
return err
}
if userContext.IsLocal() {
addressJSON, err := json.Marshal(userContext.Local.Attributes.Address)
if err != nil {
return err
}
userInfoParams.GivenName = userContext.Local.Attributes.GivenName
userInfoParams.FamilyName = userContext.Local.Attributes.FamilyName
userInfoParams.MiddleName = userContext.Local.Attributes.MiddleName
userInfoParams.Nickname = userContext.Local.Attributes.Nickname
userInfoParams.Profile = userContext.Local.Attributes.Profile
userInfoParams.Picture = userContext.Local.Attributes.Picture
userInfoParams.Website = userContext.Local.Attributes.Website
userInfoParams.Gender = userContext.Local.Attributes.Gender
userInfoParams.Birthdate = userContext.Local.Attributes.Birthdate
userInfoParams.Zoneinfo = userContext.Local.Attributes.Zoneinfo
userInfoParams.Locale = userContext.Local.Attributes.Locale
userInfoParams.PhoneNumber = userContext.Local.Attributes.PhoneNumber
userInfoParams.Address = string(addressJSON)
userInfoParams := repository.CreateOidcUserInfoParams{
Sub: sub,
Name: userContext.Name,
Email: userContext.Email,
PreferredUsername: userContext.Username,
UpdatedAt: time.Now().Unix(),
GivenName: userContext.Attributes.GivenName,
FamilyName: userContext.Attributes.FamilyName,
MiddleName: userContext.Attributes.MiddleName,
Nickname: userContext.Attributes.Nickname,
Profile: userContext.Attributes.Profile,
Picture: userContext.Attributes.Picture,
Website: userContext.Attributes.Website,
Gender: userContext.Attributes.Gender,
Birthdate: userContext.Attributes.Birthdate,
Zoneinfo: userContext.Attributes.Zoneinfo,
Locale: userContext.Attributes.Locale,
PhoneNumber: userContext.Attributes.PhoneNumber,
Address: string(addressJSON),
}
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
if userContext.IsLDAP() {
userInfoParams.Groups = strings.Join(userContext.LDAP.Groups, ",")
if userContext.Provider == "ldap" {
userInfoParams.Groups = userContext.LdapGroups
}
if userContext.IsOAuth() {
userInfoParams.Groups = strings.Join(userContext.OAuth.Groups, ",")
if userContext.OAuth && len(userContext.OAuthGroups) > 0 {
userInfoParams.Groups = userContext.OAuthGroups
}
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
_, err = service.queries.CreateOidcUserInfo(c, userInfoParams)
return err
}
@@ -447,7 +444,7 @@ func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, client
return oidcCode, nil
}
func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {
func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {
createdAt := time.Now().Unix()
expiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
@@ -513,7 +510,7 @@ func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user
return token, nil
}
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OIDCClientConfig, codeEntry repository.OidcCode) (TokenResponse, error) {
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OIDCClientConfig, codeEntry repository.OidcCode) (TokenResponse, error) {
user, err := service.GetUserinfo(c, codeEntry.Sub)
if err != nil {
@@ -532,7 +529,7 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OID
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
// Refresh token lives double the time of an access token but can't be used to access userinfo
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
tokenResponse := TokenResponse{
AccessToken: accessToken,
@@ -550,7 +547,7 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OID
ClientID: client.ClientID,
Scope: codeEntry.Scope,
TokenExpiresAt: tokenExpiresAt,
RefreshTokenExpiresAt: refreshTokenExpiresAt,
RefreshTokenExpiresAt: refrshTokenExpiresAt,
Nonce: codeEntry.Nonce,
CodeHash: codeEntry.CodeHash,
})
@@ -566,7 +563,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
entry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if err == sql.ErrNoRows {
return TokenResponse{}, ErrTokenNotFound
}
return TokenResponse{}, err
@@ -587,7 +584,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
return TokenResponse{}, err
}
idToken, err := service.generateIDToken(model.OIDCClientConfig{
idToken, err := service.generateIDToken(config.OIDCClientConfig{
ClientID: entry.ClientID,
}, user, entry.Scope, entry.Nonce)
@@ -599,7 +596,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
newRefreshToken := utils.GenerateString(32)
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
tokenResponse := TokenResponse{
AccessToken: accessToken,
@@ -614,7 +611,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
AccessTokenHash: service.Hash(accessToken),
RefreshTokenHash: service.Hash(newRefreshToken),
TokenExpiresAt: tokenExpiresAt,
RefreshTokenExpiresAt: refreshTokenExpiresAt,
RefreshTokenExpiresAt: refrshTokenExpiresAt,
RefreshTokenHash_2: service.Hash(refreshToken), // that's the selector, it's not stored in the db
})
@@ -645,7 +642,7 @@ func (service *OIDCService) GetAccessToken(c *gin.Context, tokenHash string) (re
entry, err := service.queries.GetOidcToken(c, tokenHash)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if err == sql.ErrNoRows {
return repository.OidcToken{}, ErrTokenNotFound
}
return repository.OidcToken{}, err
@@ -716,7 +713,7 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
}
if slices.Contains(scopes, "address") {
var addr model.AddressClaim
var addr config.AddressClaim
if err := json.Unmarshal([]byte(user.Address), &addr); err == nil {
userInfo.Address = &addr
}
@@ -786,7 +783,7 @@ func (service *OIDCService) Cleanup() {
token, err := service.queries.GetOidcTokenBySub(ctx, expiredCode.Sub)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if err == sql.ErrNoRows {
continue
}
tlog.App.Warn().Err(err).Msg("Failed to get OIDC token by sub")
+18
View File
@@ -7,8 +7,10 @@ import (
"net/url"
"strings"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin"
"github.com/weppos/publicsuffix-go/publicsuffix"
)
@@ -71,6 +73,22 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) {
return res
}
func GetContext(c *gin.Context) (config.UserContext, error) {
userContextValue, exists := c.Get("context")
if !exists {
return config.UserContext{}, errors.New("no user context in request")
}
userContext, ok := userContextValue.(*config.UserContext)
if !ok {
return config.UserContext{}, errors.New("invalid user context in request")
}
return *userContext, nil
}
func IsRedirectSafe(redirectURL string, domain string) bool {
if redirectURL == "" {
return false
+24
View File
@@ -3,8 +3,10 @@ package utils_test
import (
"testing"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"gotest.tools/v3/assert"
)
@@ -127,6 +129,28 @@ func TestFilter(t *testing.T) {
assert.DeepEqual(t, expectedStr, resultStr)
}
func TestGetContext(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(nil)
// Normal case
c.Set("context", &config.UserContext{Username: "testuser"})
result, err := utils.GetContext(c)
assert.NilError(t, err)
assert.Equal(t, "testuser", result.Username)
// Case with no context
c.Set("context", nil)
_, err = utils.GetContext(c)
assert.Error(t, err, "invalid user context in request")
// Case with invalid context type
c.Set("context", "invalid type")
_, err = utils.GetContext(c)
assert.Error(t, err, "invalid user context in request")
}
func TestIsRedirectSafe(t *testing.T) {
// Setup
domain := "example.com"
+4 -3
View File
@@ -4,20 +4,21 @@ import (
"fmt"
"os"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/paerser/env"
"github.com/tinyauthapp/tinyauth/internal/model"
)
type EnvLoader struct{}
func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
vars := env.FindPrefixedEnvVars(os.Environ(), model.DefaultNamePrefix, cmd.Configuration)
vars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration)
if len(vars) == 0 {
return false, nil
}
if err := env.Decode(vars, model.DefaultNamePrefix, cmd.Configuration); err != nil {
if err := env.Decode(vars, config.DefaultNamePrefix, cmd.Configuration); err != nil {
return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err)
}
+13 -13
View File
@@ -7,7 +7,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
)
type Logger struct {
@@ -22,7 +22,7 @@ var (
App zerolog.Logger
)
func NewLogger(cfg model.LogConfig) *Logger {
func NewLogger(cfg config.LogConfig) *Logger {
baseLogger := log.With().
Timestamp().
Caller().
@@ -44,24 +44,24 @@ func NewLogger(cfg model.LogConfig) *Logger {
}
func NewSimpleLogger() *Logger {
return NewLogger(model.LogConfig{
return NewLogger(config.LogConfig{
Level: "info",
Json: false,
Streams: model.LogStreams{
HTTP: model.LogStreamConfig{Enabled: true},
App: model.LogStreamConfig{Enabled: true},
Audit: model.LogStreamConfig{Enabled: false},
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true},
App: config.LogStreamConfig{Enabled: true},
Audit: config.LogStreamConfig{Enabled: false},
},
})
}
func NewTestLogger() *Logger {
return NewLogger(model.LogConfig{
return NewLogger(config.LogConfig{
Level: "trace",
Streams: model.LogStreams{
HTTP: model.LogStreamConfig{Enabled: true},
App: model.LogStreamConfig{Enabled: true},
Audit: model.LogStreamConfig{Enabled: true},
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true},
App: config.LogStreamConfig{Enabled: true},
Audit: config.LogStreamConfig{Enabled: true},
},
})
}
@@ -72,7 +72,7 @@ func (l *Logger) Init() {
App = l.App
}
func createLogger(component string, streamCfg model.LogStreamConfig, baseLogger zerolog.Logger) zerolog.Logger {
func createLogger(component string, streamCfg config.LogStreamConfig, baseLogger zerolog.Logger) zerolog.Logger {
if !streamCfg.Enabled {
return zerolog.Nop()
}
+16 -16
View File
@@ -6,14 +6,14 @@ import (
"net/mail"
"strings"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/config"
)
func ParseUsers(usersStr []string, userAttributes map[string]model.UserAttributes) (*[]model.LocalUser, error) {
var users []model.LocalUser
func ParseUsers(usersStr []string, userAttributes map[string]config.UserAttributes) ([]config.User, error) {
var users []config.User
if len(usersStr) == 0 {
return &users, nil
return []config.User{}, nil
}
for _, user := range usersStr {
@@ -22,22 +22,22 @@ func ParseUsers(usersStr []string, userAttributes map[string]model.UserAttribute
}
parsed, err := ParseUser(strings.TrimSpace(user))
if err != nil {
return nil, err
return []config.User{}, err
}
if attrs, ok := userAttributes[parsed.Username]; ok {
parsed.Attributes = attrs
}
users = append(users, *parsed)
users = append(users, parsed)
}
return &users, nil
return users, nil
}
func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]model.UserAttributes) (*[]model.LocalUser, error) {
func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]config.UserAttributes) ([]config.User, error) {
var usersStr []string
if len(usersCfg) == 0 && usersPath == "" {
return &[]model.LocalUser{}, nil
return []config.User{}, nil
}
if len(usersCfg) > 0 {
@@ -48,7 +48,7 @@ func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]mod
contents, err := ReadFile(usersPath)
if err != nil {
return nil, err
return []config.User{}, err
}
lines := strings.SplitSeq(contents, "\n")
@@ -65,7 +65,7 @@ func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]mod
return ParseUsers(usersStr, userAttributes)
}
func ParseUser(userStr string) (*model.LocalUser, error) {
func ParseUser(userStr string) (config.User, error) {
if strings.Contains(userStr, "$$") {
userStr = strings.ReplaceAll(userStr, "$$", "$")
}
@@ -73,27 +73,27 @@ func ParseUser(userStr string) (*model.LocalUser, error) {
parts := strings.SplitN(userStr, ":", 4)
if len(parts) < 2 || len(parts) > 3 {
return nil, errors.New("invalid user format")
return config.User{}, errors.New("invalid user format")
}
for i, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, errors.New("invalid user format")
return config.User{}, errors.New("invalid user format")
}
parts[i] = trimmed
}
user := model.LocalUser{
user := config.User{
Username: parts[0],
Password: parts[1],
}
if len(parts) == 3 {
user.TOTPSecret = parts[2]
user.TotpSecret = parts[2]
}
return &user, nil
return user, nil
}
func CompileUserEmail(username string, domain string) string {