mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-10-31 06:05:43 +00:00 
			
		
		
		
	Compare commits
	
		
			8 Commits
		
	
	
		
			729f0d5783
			...
			feat/domai
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 95f8a95fd3 | ||
|   | c80c37ba69 | ||
|   | 0c18a64466 | ||
|   | 57dd8a6d81 | ||
|   | 4ef5eef167 | ||
|   | 3873bb279c | ||
|   | d9fda75d41 | ||
|   | 4093a91e12 | 
| @@ -112,7 +112,6 @@ func init() { | |||||||
| 		{"ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup."}, | 		{"ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup."}, | ||||||
| 		{"resources-dir", "/data/resources", "Path to a directory containing custom resources (e.g. background image)."}, | 		{"resources-dir", "/data/resources", "Path to a directory containing custom resources (e.g. background image)."}, | ||||||
| 		{"database-path", "/data/tinyauth.db", "Path to the Sqlite database file."}, | 		{"database-path", "/data/tinyauth.db", "Path to the Sqlite database file."}, | ||||||
| 		{"trusted-proxies", "", "Comma separated list of trusted proxies (IP addresses) for correct client IP detection and for header ACLs."}, |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for _, opt := range configOptions { | 	for _, opt := range configOptions { | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ export const appContextSchema = z.object({ | |||||||
|   title: z.string(), |   title: z.string(), | ||||||
|   genericName: z.string(), |   genericName: z.string(), | ||||||
|   appUrl: z.string(), |   appUrl: z.string(), | ||||||
|  |   rootDomain: z.string(), | ||||||
|   forgotPasswordMessage: z.string(), |   forgotPasswordMessage: z.string(), | ||||||
|   oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]), |   oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]), | ||||||
|   backgroundImage: z.string(), |   backgroundImage: z.string(), | ||||||
|   | |||||||
							
								
								
									
										67
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								go.mod
									
									
									
									
									
								
							| @@ -1,27 +1,23 @@ | |||||||
| module tinyauth | module tinyauth | ||||||
|  |  | ||||||
| go 1.24.0 | go 1.23.2 | ||||||
|  |  | ||||||
| toolchain go1.24.3 |  | ||||||
|  |  | ||||||
| require ( | require ( | ||||||
| 	github.com/cenkalti/backoff/v5 v5.0.3 | 	github.com/cenkalti/backoff/v5 v5.0.3 | ||||||
| 	github.com/gin-gonic/gin v1.10.1 | 	github.com/gin-gonic/gin v1.10.1 | ||||||
| 	github.com/glebarez/sqlite v1.11.0 |  | ||||||
| 	github.com/go-playground/validator/v10 v10.27.0 | 	github.com/go-playground/validator/v10 v10.27.0 | ||||||
| 	github.com/golang-migrate/migrate/v4 v4.19.0 | 	github.com/golang-migrate/migrate/v4 v4.18.3 | ||||||
| 	github.com/google/go-querystring v1.1.0 | 	github.com/google/go-querystring v1.1.0 | ||||||
| 	github.com/google/uuid v1.6.0 | 	github.com/google/uuid v1.6.0 | ||||||
| 	github.com/mdp/qrterminal/v3 v3.2.1 | 	github.com/mdp/qrterminal/v3 v3.2.1 | ||||||
| 	github.com/rs/zerolog v1.34.0 | 	github.com/rs/zerolog v1.34.0 | ||||||
| 	github.com/spf13/cobra v1.10.1 | 	github.com/spf13/cobra v1.9.1 | ||||||
| 	github.com/spf13/viper v1.21.0 | 	github.com/spf13/viper v1.20.1 | ||||||
| 	github.com/traefik/paerser v0.2.2 | 	github.com/traefik/paerser v0.2.2 | ||||||
| 	github.com/weppos/publicsuffix-go v0.50.0 | 	golang.org/x/crypto v0.41.0 | ||||||
| 	golang.org/x/crypto v0.42.0 | 	gorm.io/driver/sqlite v1.6.0 | ||||||
| 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b | 	gorm.io/gorm v1.30.1 | ||||||
| 	gorm.io/gorm v1.30.5 | 	modernc.org/sqlite v1.38.2 | ||||||
| 	gotest.tools/v3 v3.5.2 |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| require ( | require ( | ||||||
| @@ -32,9 +28,9 @@ require ( | |||||||
| 	github.com/containerd/errdefs/pkg v0.3.0 // indirect | 	github.com/containerd/errdefs/pkg v0.3.0 // indirect | ||||||
| 	github.com/containerd/log v0.1.0 // indirect | 	github.com/containerd/log v0.1.0 // indirect | ||||||
| 	github.com/glebarez/go-sqlite v1.21.2 // indirect | 	github.com/glebarez/go-sqlite v1.21.2 // indirect | ||||||
|  | 	github.com/glebarez/sqlite v1.11.0 // indirect | ||||||
| 	github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect | 	github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect | ||||||
| 	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | 	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | ||||||
| 	github.com/google/go-cmp v0.7.0 // indirect |  | ||||||
| 	github.com/hashicorp/errwrap v1.1.0 // indirect | 	github.com/hashicorp/errwrap v1.1.0 // indirect | ||||||
| 	github.com/hashicorp/go-multierror v1.1.1 // indirect | 	github.com/hashicorp/go-multierror v1.1.1 // indirect | ||||||
| 	github.com/jinzhu/inflection v1.0.0 // indirect | 	github.com/jinzhu/inflection v1.0.0 // indirect | ||||||
| @@ -48,12 +44,12 @@ require ( | |||||||
| 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect | 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect | ||||||
| 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect | 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect | ||||||
| 	go.opentelemetry.io/otel/sdk v1.34.0 // indirect | 	go.opentelemetry.io/otel/sdk v1.34.0 // indirect | ||||||
| 	go.yaml.in/yaml/v3 v3.0.4 // indirect | 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect | ||||||
| 	golang.org/x/term v0.35.0 // indirect | 	golang.org/x/term v0.34.0 // indirect | ||||||
|  | 	gotest.tools/v3 v3.5.2 // indirect | ||||||
| 	modernc.org/libc v1.66.3 // indirect | 	modernc.org/libc v1.66.3 // indirect | ||||||
| 	modernc.org/mathutil v1.7.1 // indirect | 	modernc.org/mathutil v1.7.1 // indirect | ||||||
| 	modernc.org/memory v1.11.0 // indirect | 	modernc.org/memory v1.11.0 // indirect | ||||||
| 	modernc.org/sqlite v1.38.2 // indirect |  | ||||||
| 	rsc.io/qr v0.2.0 // indirect | 	rsc.io/qr v0.2.0 // indirect | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -74,21 +70,24 @@ require ( | |||||||
| 	github.com/charmbracelet/x/term v0.2.1 // indirect | 	github.com/charmbracelet/x/term v0.2.1 // indirect | ||||||
| 	github.com/cloudwego/base64x v0.1.4 // indirect | 	github.com/cloudwego/base64x v0.1.4 // indirect | ||||||
| 	github.com/distribution/reference v0.6.0 // indirect | 	github.com/distribution/reference v0.6.0 // indirect | ||||||
| 	github.com/docker/docker v28.4.0+incompatible | 	github.com/docker/docker v28.3.3+incompatible | ||||||
| 	github.com/docker/go-connections v0.5.0 // indirect | 	github.com/docker/go-connections v0.5.0 // indirect | ||||||
| 	github.com/docker/go-units v0.5.0 // indirect | 	github.com/docker/go-units v0.5.0 // indirect | ||||||
| 	github.com/dustin/go-humanize v1.0.1 // indirect | 	github.com/dustin/go-humanize v1.0.1 // indirect | ||||||
| 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||||||
| 	github.com/felixge/httpsnoop v1.0.4 // indirect | 	github.com/felixge/httpsnoop v1.0.4 // indirect | ||||||
| 	github.com/fsnotify/fsnotify v1.9.0 // indirect | 	github.com/fsnotify/fsnotify v1.8.0 // indirect | ||||||
| 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect | 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect | ||||||
| 	github.com/gin-contrib/sse v1.0.0 // indirect | 	github.com/gin-contrib/sse v1.0.0 // indirect | ||||||
| 	github.com/go-ldap/ldap/v3 v3.4.11 | 	github.com/go-ldap/ldap/v3 v3.4.11 | ||||||
| 	github.com/go-logr/logr v1.4.3 // indirect | 	github.com/go-logr/logr v1.4.2 // indirect | ||||||
| 	github.com/go-logr/stdr v1.2.2 // indirect | 	github.com/go-logr/stdr v1.2.2 // indirect | ||||||
| 	github.com/go-playground/locales v0.14.1 // indirect | 	github.com/go-playground/locales v0.14.1 // indirect | ||||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect | 	github.com/go-playground/universal-translator v0.18.1 // indirect | ||||||
| 	github.com/goccy/go-json v0.10.4 // indirect | 	github.com/goccy/go-json v0.10.4 // indirect | ||||||
|  | 	github.com/gogo/protobuf v1.3.2 // indirect | ||||||
|  | 	github.com/gorilla/securecookie v1.1.2 // indirect | ||||||
|  | 	github.com/gorilla/sessions v1.4.0 | ||||||
| 	github.com/inconshreveable/mousetrap v1.1.0 // indirect | 	github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||||||
| 	github.com/json-iterator/go v1.1.12 // indirect | 	github.com/json-iterator/go v1.1.12 // indirect | ||||||
| 	github.com/klauspost/cpuid/v2 v2.2.9 // indirect | 	github.com/klauspost/cpuid/v2 v2.2.9 // indirect | ||||||
| @@ -107,28 +106,30 @@ require ( | |||||||
| 	github.com/muesli/termenv v0.16.0 // indirect | 	github.com/muesli/termenv v0.16.0 // indirect | ||||||
| 	github.com/opencontainers/go-digest v1.0.0 // indirect | 	github.com/opencontainers/go-digest v1.0.0 // indirect | ||||||
| 	github.com/opencontainers/image-spec v1.1.0 // indirect | 	github.com/opencontainers/image-spec v1.1.0 // indirect | ||||||
| 	github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect | ||||||
| 	github.com/pkg/errors v0.9.1 // indirect | 	github.com/pkg/errors v0.9.1 // indirect | ||||||
| 	github.com/pquerna/otp v1.5.0 | 	github.com/pquerna/otp v1.5.0 | ||||||
| 	github.com/rivo/uniseg v0.4.7 // indirect | 	github.com/rivo/uniseg v0.4.7 // indirect | ||||||
| 	github.com/sagikazarmark/locafero v0.11.0 // indirect | 	github.com/sagikazarmark/locafero v0.7.0 // indirect | ||||||
| 	github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | 	github.com/sourcegraph/conc v0.3.0 // indirect | ||||||
| 	github.com/spf13/afero v1.15.0 // indirect | 	github.com/spf13/afero v1.12.0 // indirect | ||||||
| 	github.com/spf13/cast v1.10.0 // indirect | 	github.com/spf13/cast v1.7.1 // indirect | ||||||
| 	github.com/spf13/pflag v1.0.10 // indirect | 	github.com/spf13/pflag v1.0.6 // indirect | ||||||
| 	github.com/subosito/gotenv v1.6.0 // indirect | 	github.com/subosito/gotenv v1.6.0 // indirect | ||||||
| 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | ||||||
| 	github.com/ugorji/go/codec v1.2.12 // indirect | 	github.com/ugorji/go/codec v1.2.12 // indirect | ||||||
| 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect | 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect | ||||||
| 	go.opentelemetry.io/otel v1.37.0 // indirect | 	go.opentelemetry.io/otel v1.34.0 // indirect | ||||||
| 	go.opentelemetry.io/otel/metric v1.37.0 // indirect | 	go.opentelemetry.io/otel/metric v1.34.0 // indirect | ||||||
| 	go.opentelemetry.io/otel/trace v1.37.0 // indirect | 	go.opentelemetry.io/otel/trace v1.34.0 // indirect | ||||||
|  | 	go.uber.org/atomic v1.9.0 // indirect | ||||||
|  | 	go.uber.org/multierr v1.9.0 // indirect | ||||||
| 	golang.org/x/arch v0.13.0 // indirect | 	golang.org/x/arch v0.13.0 // indirect | ||||||
| 	golang.org/x/net v0.44.0 // indirect | 	golang.org/x/net v0.42.0 // indirect | ||||||
| 	golang.org/x/oauth2 v0.31.0 | 	golang.org/x/oauth2 v0.30.0 | ||||||
| 	golang.org/x/sync v0.17.0 // indirect | 	golang.org/x/sync v0.16.0 // indirect | ||||||
| 	golang.org/x/sys v0.36.0 // indirect | 	golang.org/x/sys v0.35.0 // indirect | ||||||
| 	golang.org/x/text v0.29.0 // indirect | 	golang.org/x/text v0.28.0 // indirect | ||||||
| 	google.golang.org/protobuf v1.36.3 // indirect | 	google.golang.org/protobuf v1.36.3 // indirect | ||||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||||
| ) | ) | ||||||
|   | |||||||
							
								
								
									
										160
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										160
									
								
								go.sum
									
									
									
									
									
								
							| @@ -74,8 +74,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c | |||||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||||
| github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | ||||||
| github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | ||||||
| github.com/docker/docker v28.4.0+incompatible h1:KVC7bz5zJY/4AZe/78BIvCnPsLaC9T/zh72xnlrTTOk= | github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= | ||||||
| github.com/docker/docker v28.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= | github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= | ||||||
| github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= | ||||||
| github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= | ||||||
| github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= | ||||||
| @@ -88,8 +88,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 | |||||||
| github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | 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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | ||||||
| github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | ||||||
| github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= | ||||||
| github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= | ||||||
| github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= | ||||||
| github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= | ||||||
| github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= | github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= | ||||||
| @@ -105,8 +105,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:h | |||||||
| github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= | github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= | ||||||
| github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= | github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= | ||||||
| github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | ||||||
| github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= | ||||||
| github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||||||
| github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= | 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-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= | ||||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||||
| @@ -122,18 +122,26 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd | |||||||
| github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= | github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= | ||||||
| github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= | github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= | ||||||
| github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | ||||||
| github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= | ||||||
| github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= | ||||||
|  | github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= | ||||||
|  | github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= | ||||||
| github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||||
| github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | ||||||
| github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||||||
| github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= | ||||||
| github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= | ||||||
| github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= | 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= | ||||||
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= | 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= | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||||||
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||||
|  | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= | ||||||
|  | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= | ||||||
|  | github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= | ||||||
|  | github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= | ||||||
| github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= | ||||||
| github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= | ||||||
| github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= | ||||||
| @@ -163,6 +171,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= | |||||||
| github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= | ||||||
| github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | 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/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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= | ||||||
| github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= | github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= | ||||||
| github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= | github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= | ||||||
| @@ -221,8 +231,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 | |||||||
| github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= | ||||||
| github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= | ||||||
| github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= | ||||||
| github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= | ||||||
| github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= | ||||||
| github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | ||||||
| github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||||
| @@ -240,23 +250,22 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= | |||||||
| github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= | ||||||
| github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= | ||||||
| github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||||||
| github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= | ||||||
| github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= | ||||||
| github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= | ||||||
| github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= | ||||||
| github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= | ||||||
| github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= | ||||||
| github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= | ||||||
| github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= | ||||||
| github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= | ||||||
| github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= | ||||||
| github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= | ||||||
| github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= | ||||||
| github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= | ||||||
| github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||||||
| github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= | ||||||
| github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= | ||||||
| github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= |  | ||||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | 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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||||||
| github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||||||
| @@ -267,9 +276,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ | |||||||
| github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | ||||||
| github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||||||
| github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | ||||||
|  | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= | ||||||
| github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||||||
| github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= |  | ||||||
| github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= |  | ||||||
| github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | ||||||
| github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | ||||||
| github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ= | github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ= | ||||||
| @@ -278,60 +286,86 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS | |||||||
| github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||||||
| github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | ||||||
| github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||||
| github.com/weppos/publicsuffix-go v0.50.0 h1:M178k6l8cnh9T1c1cStkhytVxdk5zPd6gGZf8ySIuVo= |  | ||||||
| github.com/weppos/publicsuffix-go v0.50.0/go.mod h1:VXhClBYMlDrUsome4pOTpe68Ui0p6iQRAbyHQD1yKoU= |  | ||||||
| github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= | 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/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.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= | ||||||
| go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= | ||||||
| go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= | ||||||
| go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= | ||||||
| go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= | ||||||
| go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= | ||||||
| go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= | ||||||
| go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= | ||||||
| go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs= | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs= | ||||||
| go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4= | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4= | ||||||
| go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= | ||||||
| go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= | ||||||
| go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= | go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= | ||||||
| go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= | go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= | ||||||
| go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= | ||||||
| go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= | ||||||
| go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= | go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= | ||||||
| go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= | go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= | ||||||
| go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= | ||||||
| go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= | ||||||
|  | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= | ||||||
|  | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= | ||||||
| golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA= | golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA= | ||||||
| golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= | golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= | ||||||
| golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||||
| golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= | 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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= | ||||||
|  | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= | ||||||
| golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= | ||||||
| golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= | ||||||
| golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||||
| golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||||
| golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= | golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= | ||||||
| golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= | golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= | ||||||
| golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||||||
| golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||||||
| golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||||||
| golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= | ||||||
|  | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= | ||||||
|  | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= | ||||||
|  | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= | ||||||
|  | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= | ||||||
|  | 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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= | ||||||
|  | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= | ||||||
|  | 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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= | ||||||
| golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | ||||||
| golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= | golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= | ||||||
| golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= | golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= | ||||||
| golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||||
| golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||||
| golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= | ||||||
| golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= | ||||||
| golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= | golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= | ||||||
| golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= | golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= | ||||||
|  | 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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= | ||||||
|  | golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= | ||||||
|  | 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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||||
| google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||||
|  | google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= | ||||||
| google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA= | google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA= | ||||||
| google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= | google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= | ||||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= | ||||||
| @@ -346,8 +380,10 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV | |||||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||||
| gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s= | gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= | ||||||
| gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= | gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= | ||||||
|  | gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= | ||||||
|  | gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= | ||||||
| gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | ||||||
| gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= | ||||||
| modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= | modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= | ||||||
|   | |||||||
| @@ -45,8 +45,8 @@ func (app *BootstrapApp) Setup() error { | |||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Get cookie domain | 	// Get root domain | ||||||
| 	cookieDomain, err := utils.GetCookieDomain(app.Config.AppURL) | 	rootDomain, err := utils.GetRootDomain(app.Config.AppURL) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| @@ -65,7 +65,7 @@ func (app *BootstrapApp) Setup() error { | |||||||
| 		OauthWhitelist:    app.Config.OAuthWhitelist, | 		OauthWhitelist:    app.Config.OAuthWhitelist, | ||||||
| 		SessionExpiry:     app.Config.SessionExpiry, | 		SessionExpiry:     app.Config.SessionExpiry, | ||||||
| 		SecureCookie:      app.Config.SecureCookie, | 		SecureCookie:      app.Config.SecureCookie, | ||||||
| 		CookieDomain:      cookieDomain, | 		RootDomain:        rootDomain, | ||||||
| 		LoginTimeout:      app.Config.LoginTimeout, | 		LoginTimeout:      app.Config.LoginTimeout, | ||||||
| 		LoginMaxRetries:   app.Config.LoginMaxRetries, | 		LoginMaxRetries:   app.Config.LoginMaxRetries, | ||||||
| 		SessionCookieName: sessionCookieName, | 		SessionCookieName: sessionCookieName, | ||||||
| @@ -146,7 +146,6 @@ func (app *BootstrapApp) Setup() error { | |||||||
|  |  | ||||||
| 	// Create engine | 	// Create engine | ||||||
| 	engine := gin.New() | 	engine := gin.New() | ||||||
| 	engine.SetTrustedProxies(strings.Split(app.Config.TrustedProxies, ",")) |  | ||||||
|  |  | ||||||
| 	if config.Version != "development" { | 	if config.Version != "development" { | ||||||
| 		gin.SetMode(gin.ReleaseMode) | 		gin.SetMode(gin.ReleaseMode) | ||||||
| @@ -156,7 +155,7 @@ func (app *BootstrapApp) Setup() error { | |||||||
| 	var middlewares []Middleware | 	var middlewares []Middleware | ||||||
|  |  | ||||||
| 	contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{ | 	contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{ | ||||||
| 		CookieDomain: cookieDomain, | 		RootDomain: rootDomain, | ||||||
| 	}, authService, oauthBrokerService) | 	}, authService, oauthBrokerService) | ||||||
|  |  | ||||||
| 	uiMiddleware := middleware.NewUIMiddleware() | 	uiMiddleware := middleware.NewUIMiddleware() | ||||||
| @@ -183,6 +182,7 @@ func (app *BootstrapApp) Setup() error { | |||||||
| 		Title:                 app.Config.Title, | 		Title:                 app.Config.Title, | ||||||
| 		GenericName:           app.Config.GenericName, | 		GenericName:           app.Config.GenericName, | ||||||
| 		AppURL:                app.Config.AppURL, | 		AppURL:                app.Config.AppURL, | ||||||
|  | 		RootDomain:            rootDomain, | ||||||
| 		ForgotPasswordMessage: app.Config.ForgotPasswordMessage, | 		ForgotPasswordMessage: app.Config.ForgotPasswordMessage, | ||||||
| 		BackgroundImage:       app.Config.BackgroundImage, | 		BackgroundImage:       app.Config.BackgroundImage, | ||||||
| 		OAuthAutoRedirect:     app.Config.OAuthAutoRedirect, | 		OAuthAutoRedirect:     app.Config.OAuthAutoRedirect, | ||||||
| @@ -193,7 +193,7 @@ func (app *BootstrapApp) Setup() error { | |||||||
| 		SecureCookie:       app.Config.SecureCookie, | 		SecureCookie:       app.Config.SecureCookie, | ||||||
| 		CSRFCookieName:     csrfCookieName, | 		CSRFCookieName:     csrfCookieName, | ||||||
| 		RedirectCookieName: redirectCookieName, | 		RedirectCookieName: redirectCookieName, | ||||||
| 		CookieDomain:       cookieDomain, | 		RootDomain:         rootDomain, | ||||||
| 	}, apiRouter, authService, oauthBrokerService) | 	}, apiRouter, authService, oauthBrokerService) | ||||||
|  |  | ||||||
| 	proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ | 	proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ | ||||||
| @@ -201,7 +201,7 @@ func (app *BootstrapApp) Setup() error { | |||||||
| 	}, apiRouter, dockerService, authService) | 	}, apiRouter, dockerService, authService) | ||||||
|  |  | ||||||
| 	userController := controller.NewUserController(controller.UserControllerConfig{ | 	userController := controller.NewUserController(controller.UserControllerConfig{ | ||||||
| 		CookieDomain: cookieDomain, | 		RootDomain: rootDomain, | ||||||
| 	}, apiRouter, authService) | 	}, apiRouter, authService) | ||||||
|  |  | ||||||
| 	resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{ | 	resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{ | ||||||
|   | |||||||
| @@ -53,7 +53,6 @@ type Config struct { | |||||||
| 	LdapSearchFilter        string `mapstructure:"ldap-search-filter"` | 	LdapSearchFilter        string `mapstructure:"ldap-search-filter"` | ||||||
| 	ResourcesDir            string `mapstructure:"resources-dir"` | 	ResourcesDir            string `mapstructure:"resources-dir"` | ||||||
| 	DatabasePath            string `mapstructure:"database-path" validate:"required"` | 	DatabasePath            string `mapstructure:"database-path" validate:"required"` | ||||||
| 	TrustedProxies          string `mapstructure:"trusted-proxies"` |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // OAuth/OIDC config | // OAuth/OIDC config | ||||||
| @@ -126,51 +125,51 @@ type RedirectQuery struct { | |||||||
|  |  | ||||||
| // Labels | // Labels | ||||||
|  |  | ||||||
| type Apps struct { | type Labels struct { | ||||||
| 	Apps map[string]App | 	Apps map[string]AppLabels | ||||||
| } | } | ||||||
|  |  | ||||||
| type App struct { | type AppLabels struct { | ||||||
| 	Config   AppConfig | 	Config   ConfigLabels | ||||||
| 	Users    AppUsers | 	Users    UsersLabels | ||||||
| 	OAuth    AppOAuth | 	OAuth    OAuthLabels | ||||||
| 	IP       AppIP | 	IP       IPLabels | ||||||
| 	Response AppResponse | 	Response ResponseLabels | ||||||
| 	Path     AppPath | 	Path     PathLabels | ||||||
| } | } | ||||||
|  |  | ||||||
| type AppConfig struct { | type ConfigLabels struct { | ||||||
| 	Domain string | 	Domain string | ||||||
| } | } | ||||||
|  |  | ||||||
| type AppUsers struct { | type UsersLabels struct { | ||||||
| 	Allow string | 	Allow string | ||||||
| 	Block string | 	Block string | ||||||
| } | } | ||||||
|  |  | ||||||
| type AppOAuth struct { | type OAuthLabels struct { | ||||||
| 	Whitelist string | 	Whitelist string | ||||||
| 	Groups    string | 	Groups    string | ||||||
| } | } | ||||||
|  |  | ||||||
| type AppIP struct { | type IPLabels struct { | ||||||
| 	Allow  []string | 	Allow  []string | ||||||
| 	Block  []string | 	Block  []string | ||||||
| 	Bypass []string | 	Bypass []string | ||||||
| } | } | ||||||
|  |  | ||||||
| type AppResponse struct { | type ResponseLabels struct { | ||||||
| 	Headers   []string | 	Headers   []string | ||||||
| 	BasicAuth AppBasicAuth | 	BasicAuth BasicAuthLabels | ||||||
| } | } | ||||||
|  |  | ||||||
| type AppBasicAuth struct { | type BasicAuthLabels struct { | ||||||
| 	Username     string | 	Username     string | ||||||
| 	Password     string | 	Password     string | ||||||
| 	PasswordFile string | 	PasswordFile string | ||||||
| } | } | ||||||
|  |  | ||||||
| type AppPath struct { | type PathLabels struct { | ||||||
| 	Allow string | 	Allow string | ||||||
| 	Block string | 	Block string | ||||||
| } | } | ||||||
|   | |||||||
| @@ -28,6 +28,7 @@ type AppContextResponse struct { | |||||||
| 	Title                 string   `json:"title"` | 	Title                 string   `json:"title"` | ||||||
| 	GenericName           string   `json:"genericName"` | 	GenericName           string   `json:"genericName"` | ||||||
| 	AppURL                string   `json:"appUrl"` | 	AppURL                string   `json:"appUrl"` | ||||||
|  | 	RootDomain            string   `json:"rootDomain"` | ||||||
| 	ForgotPasswordMessage string   `json:"forgotPasswordMessage"` | 	ForgotPasswordMessage string   `json:"forgotPasswordMessage"` | ||||||
| 	BackgroundImage       string   `json:"backgroundImage"` | 	BackgroundImage       string   `json:"backgroundImage"` | ||||||
| 	OAuthAutoRedirect     string   `json:"oauthAutoRedirect"` | 	OAuthAutoRedirect     string   `json:"oauthAutoRedirect"` | ||||||
| @@ -38,25 +39,26 @@ type ContextControllerConfig struct { | |||||||
| 	Title                 string | 	Title                 string | ||||||
| 	GenericName           string | 	GenericName           string | ||||||
| 	AppURL                string | 	AppURL                string | ||||||
|  | 	RootDomain            string | ||||||
| 	ForgotPasswordMessage string | 	ForgotPasswordMessage string | ||||||
| 	BackgroundImage       string | 	BackgroundImage       string | ||||||
| 	OAuthAutoRedirect     string | 	OAuthAutoRedirect     string | ||||||
| } | } | ||||||
|  |  | ||||||
| type ContextController struct { | type ContextController struct { | ||||||
| 	config ContextControllerConfig | 	Config ContextControllerConfig | ||||||
| 	router *gin.RouterGroup | 	Router *gin.RouterGroup | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController { | func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController { | ||||||
| 	return &ContextController{ | 	return &ContextController{ | ||||||
| 		config: config, | 		Config: config, | ||||||
| 		router: router, | 		Router: router, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *ContextController) SetupRoutes() { | func (controller *ContextController) SetupRoutes() { | ||||||
| 	contextGroup := controller.router.Group("/context") | 	contextGroup := controller.Router.Group("/context") | ||||||
| 	contextGroup.GET("/user", controller.userContextHandler) | 	contextGroup.GET("/user", controller.userContextHandler) | ||||||
| 	contextGroup.GET("/app", controller.appContextHandler) | 	contextGroup.GET("/app", controller.appContextHandler) | ||||||
| } | } | ||||||
| @@ -89,17 +91,18 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *ContextController) appContextHandler(c *gin.Context) { | func (controller *ContextController) appContextHandler(c *gin.Context) { | ||||||
| 	appUrl, _ := url.Parse(controller.config.AppURL) // no need to check error, validated on startup | 	appUrl, _ := url.Parse(controller.Config.AppURL) // no need to check error, validated on startup | ||||||
|  |  | ||||||
| 	c.JSON(200, AppContextResponse{ | 	c.JSON(200, AppContextResponse{ | ||||||
| 		Status:                200, | 		Status:                200, | ||||||
| 		Message:               "Success", | 		Message:               "Success", | ||||||
| 		ConfiguredProviders:   controller.config.ConfiguredProviders, | 		ConfiguredProviders:   controller.Config.ConfiguredProviders, | ||||||
| 		Title:                 controller.config.Title, | 		Title:                 controller.Config.Title, | ||||||
| 		GenericName:           controller.config.GenericName, | 		GenericName:           controller.Config.GenericName, | ||||||
| 		AppURL:                fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host), | 		AppURL:                fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host), | ||||||
| 		ForgotPasswordMessage: controller.config.ForgotPasswordMessage, | 		RootDomain:            controller.Config.RootDomain, | ||||||
| 		BackgroundImage:       controller.config.BackgroundImage, | 		ForgotPasswordMessage: controller.Config.ForgotPasswordMessage, | ||||||
| 		OAuthAutoRedirect:     controller.config.OAuthAutoRedirect, | 		BackgroundImage:       controller.Config.BackgroundImage, | ||||||
|  | 		OAuthAutoRedirect:     controller.Config.OAuthAutoRedirect, | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,133 +0,0 @@ | |||||||
| package controller_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"net/http/httptest" |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/config" |  | ||||||
| 	"tinyauth/internal/controller" |  | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| var controllerCfg = controller.ContextControllerConfig{ |  | ||||||
| 	ConfiguredProviders:   []string{"github", "google", "generic"}, |  | ||||||
| 	Title:                 "Test App", |  | ||||||
| 	GenericName:           "Generic", |  | ||||||
| 	AppURL:                "http://localhost:8080", |  | ||||||
| 	ForgotPasswordMessage: "Contact admin to reset your password.", |  | ||||||
| 	BackgroundImage:       "/assets/bg.jpg", |  | ||||||
| 	OAuthAutoRedirect:     "google", |  | ||||||
| } |  | ||||||
|  |  | ||||||
| var userContext = config.UserContext{ |  | ||||||
| 	Username:    "testuser", |  | ||||||
| 	Name:        "testuser", |  | ||||||
| 	Email:       "test@example.com", |  | ||||||
| 	IsLoggedIn:  true, |  | ||||||
| 	OAuth:       false, |  | ||||||
| 	Provider:    "username", |  | ||||||
| 	TotpPending: false, |  | ||||||
| 	OAuthGroups: "", |  | ||||||
| 	TotpEnabled: false, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { |  | ||||||
| 	// Setup |  | ||||||
| 	gin.SetMode(gin.TestMode) |  | ||||||
| 	router := gin.Default() |  | ||||||
| 	recorder := httptest.NewRecorder() |  | ||||||
|  |  | ||||||
| 	if middlewares != nil { |  | ||||||
| 		for _, m := range *middlewares { |  | ||||||
| 			router.Use(m) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	group := router.Group("/api") |  | ||||||
|  |  | ||||||
| 	ctrl := controller.NewContextController(controllerCfg, group) |  | ||||||
| 	ctrl.SetupRoutes() |  | ||||||
|  |  | ||||||
| 	return router, recorder |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestAppContextHandler(t *testing.T) { |  | ||||||
| 	expectedRes := controller.AppContextResponse{ |  | ||||||
| 		Status:                200, |  | ||||||
| 		Message:               "Success", |  | ||||||
| 		ConfiguredProviders:   controllerCfg.ConfiguredProviders, |  | ||||||
| 		Title:                 controllerCfg.Title, |  | ||||||
| 		GenericName:           controllerCfg.GenericName, |  | ||||||
| 		AppURL:                controllerCfg.AppURL, |  | ||||||
| 		ForgotPasswordMessage: controllerCfg.ForgotPasswordMessage, |  | ||||||
| 		BackgroundImage:       controllerCfg.BackgroundImage, |  | ||||||
| 		OAuthAutoRedirect:     controllerCfg.OAuthAutoRedirect, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	router, recorder := setupContextController(nil) |  | ||||||
| 	req := httptest.NewRequest("GET", "/api/context/app", nil) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
|  |  | ||||||
| 	var ctrlRes controller.AppContextResponse |  | ||||||
|  |  | ||||||
| 	err := json.Unmarshal(recorder.Body.Bytes(), &ctrlRes) |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.DeepEqual(t, expectedRes, ctrlRes) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestUserContextHandler(t *testing.T) { |  | ||||||
| 	expectedRes := controller.UserContextResponse{ |  | ||||||
| 		Status:      200, |  | ||||||
| 		Message:     "Success", |  | ||||||
| 		IsLoggedIn:  userContext.IsLoggedIn, |  | ||||||
| 		Username:    userContext.Username, |  | ||||||
| 		Name:        userContext.Name, |  | ||||||
| 		Email:       userContext.Email, |  | ||||||
| 		Provider:    userContext.Provider, |  | ||||||
| 		OAuth:       userContext.OAuth, |  | ||||||
| 		TotpPending: userContext.TotpPending, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Test with context |  | ||||||
| 	router, recorder := setupContextController(&[]gin.HandlerFunc{ |  | ||||||
| 		func(c *gin.Context) { |  | ||||||
| 			c.Set("context", &userContext) |  | ||||||
| 			c.Next() |  | ||||||
| 		}, |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	req := httptest.NewRequest("GET", "/api/context/user", nil) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
|  |  | ||||||
| 	var ctrlRes controller.UserContextResponse |  | ||||||
|  |  | ||||||
| 	err := json.Unmarshal(recorder.Body.Bytes(), &ctrlRes) |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.DeepEqual(t, expectedRes, ctrlRes) |  | ||||||
|  |  | ||||||
| 	// Test no context |  | ||||||
| 	expectedRes = controller.UserContextResponse{ |  | ||||||
| 		Status:     401, |  | ||||||
| 		Message:    "Unauthorized", |  | ||||||
| 		IsLoggedIn: false, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	router, recorder = setupContextController(nil) |  | ||||||
| 	req = httptest.NewRequest("GET", "/api/context/user", nil) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
|  |  | ||||||
| 	err = json.Unmarshal(recorder.Body.Bytes(), &ctrlRes) |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.DeepEqual(t, expectedRes, ctrlRes) |  | ||||||
| } |  | ||||||
| @@ -3,18 +3,18 @@ package controller | |||||||
| import "github.com/gin-gonic/gin" | import "github.com/gin-gonic/gin" | ||||||
|  |  | ||||||
| type HealthController struct { | type HealthController struct { | ||||||
| 	router *gin.RouterGroup | 	Router *gin.RouterGroup | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewHealthController(router *gin.RouterGroup) *HealthController { | func NewHealthController(router *gin.RouterGroup) *HealthController { | ||||||
| 	return &HealthController{ | 	return &HealthController{ | ||||||
| 		router: router, | 		Router: router, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *HealthController) SetupRoutes() { | func (controller *HealthController) SetupRoutes() { | ||||||
| 	controller.router.GET("/health", controller.healthHandler) | 	controller.Router.GET("/health", controller.healthHandler) | ||||||
| 	controller.router.HEAD("/health", controller.healthHandler) | 	controller.Router.HEAD("/health", controller.healthHandler) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *HealthController) healthHandler(c *gin.Context) { | func (controller *HealthController) healthHandler(c *gin.Context) { | ||||||
|   | |||||||
| @@ -23,27 +23,27 @@ type OAuthControllerConfig struct { | |||||||
| 	RedirectCookieName string | 	RedirectCookieName string | ||||||
| 	SecureCookie       bool | 	SecureCookie       bool | ||||||
| 	AppURL             string | 	AppURL             string | ||||||
| 	CookieDomain       string | 	RootDomain         string | ||||||
| } | } | ||||||
|  |  | ||||||
| type OAuthController struct { | type OAuthController struct { | ||||||
| 	config OAuthControllerConfig | 	Config OAuthControllerConfig | ||||||
| 	router *gin.RouterGroup | 	Router *gin.RouterGroup | ||||||
| 	auth   *service.AuthService | 	Auth   *service.AuthService | ||||||
| 	broker *service.OAuthBrokerService | 	Broker *service.OAuthBrokerService | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService, broker *service.OAuthBrokerService) *OAuthController { | func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService, broker *service.OAuthBrokerService) *OAuthController { | ||||||
| 	return &OAuthController{ | 	return &OAuthController{ | ||||||
| 		config: config, | 		Config: config, | ||||||
| 		router: router, | 		Router: router, | ||||||
| 		auth:   auth, | 		Auth:   auth, | ||||||
| 		broker: broker, | 		Broker: broker, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *OAuthController) SetupRoutes() { | func (controller *OAuthController) SetupRoutes() { | ||||||
| 	oauthGroup := controller.router.Group("/oauth") | 	oauthGroup := controller.Router.Group("/oauth") | ||||||
| 	oauthGroup.GET("/url/:provider", controller.oauthURLHandler) | 	oauthGroup.GET("/url/:provider", controller.oauthURLHandler) | ||||||
| 	oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler) | 	oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler) | ||||||
| } | } | ||||||
| @@ -61,7 +61,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	service, exists := controller.broker.GetService(req.Provider) | 	service, exists := controller.Broker.GetService(req.Provider) | ||||||
|  |  | ||||||
| 	if !exists { | 	if !exists { | ||||||
| 		log.Warn().Msgf("OAuth provider not found: %s", req.Provider) | 		log.Warn().Msgf("OAuth provider not found: %s", req.Provider) | ||||||
| @@ -74,13 +74,13 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 	state := service.GenerateState() | 	state := service.GenerateState() | ||||||
| 	authURL := service.GetAuthURL(state) | 	authURL := service.GetAuthURL(state) | ||||||
| 	c.SetCookie(controller.config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) | 	c.SetCookie(controller.Config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) | ||||||
|  |  | ||||||
| 	redirectURI := c.Query("redirect_uri") | 	redirectURI := c.Query("redirect_uri") | ||||||
|  |  | ||||||
| 	if redirectURI != "" && utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) { | 	if redirectURI != "" && utils.IsRedirectSafe(redirectURI, controller.Config.RootDomain) { | ||||||
| 		log.Debug().Msg("Setting redirect URI cookie") | 		log.Debug().Msg("Setting redirect URI cookie") | ||||||
| 		c.SetCookie(controller.config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) | 		c.SetCookie(controller.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.JSON(200, gin.H{ | 	c.JSON(200, gin.H{ | ||||||
| @@ -104,59 +104,58 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	state := c.Query("state") | 	state := c.Query("state") | ||||||
| 	csrfCookie, err := c.Cookie(controller.config.CSRFCookieName) | 	csrfCookie, err := c.Cookie(controller.Config.CSRFCookieName) | ||||||
|  |  | ||||||
| 	if err != nil || state != csrfCookie { | 	if err != nil || state != csrfCookie { | ||||||
| 		log.Warn().Err(err).Msg("CSRF token mismatch or cookie missing") | 		log.Warn().Err(err).Msg("CSRF token mismatch or cookie missing") | ||||||
| 		c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) |  | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) | 	c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) | ||||||
|  |  | ||||||
| 	code := c.Query("code") | 	code := c.Query("code") | ||||||
| 	service, exists := controller.broker.GetService(req.Provider) | 	service, exists := controller.Broker.GetService(req.Provider) | ||||||
|  |  | ||||||
| 	if !exists { | 	if !exists { | ||||||
| 		log.Warn().Msgf("OAuth provider not found: %s", req.Provider) | 		log.Warn().Msgf("OAuth provider not found: %s", req.Provider) | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	err = service.VerifyCode(code) | 	err = service.VerifyCode(code) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error().Err(err).Msg("Failed to verify OAuth code") | 		log.Error().Err(err).Msg("Failed to verify OAuth code") | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	user, err := controller.broker.GetUser(req.Provider) | 	user, err := controller.Broker.GetUser(req.Provider) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error().Err(err).Msg("Failed to get user from OAuth provider") | 		log.Error().Err(err).Msg("Failed to get user from OAuth provider") | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if user.Email == "" { | 	if user.Email == "" { | ||||||
| 		log.Error().Msg("OAuth provider did not return an email") | 		log.Error().Msg("OAuth provider did not return an email") | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if !controller.auth.IsEmailWhitelisted(user.Email) { | 	if !controller.Auth.IsEmailWhitelisted(user.Email) { | ||||||
| 		queries, err := query.Values(config.UnauthorizedQuery{ | 		queries, err := query.Values(config.UnauthorizedQuery{ | ||||||
| 			Username: user.Email, | 			Username: user.Email, | ||||||
| 		}) | 		}) | ||||||
|  |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Error().Err(err).Msg("Failed to encode unauthorized query") | 			log.Error().Err(err).Msg("Failed to encode unauthorized query") | ||||||
| 			c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) | 			c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.Config.AppURL, queries.Encode())) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -170,35 +169,29 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { | |||||||
| 		name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1]) | 		name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1]) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var username string | 	var usename string | ||||||
|  |  | ||||||
| 	if user.PreferredUsername != "" { | 	if user.PreferredUsername != "" { | ||||||
| 		log.Debug().Msg("Using preferred username from OAuth provider") | 		log.Debug().Msg("Using preferred username from OAuth provider") | ||||||
| 		username = user.PreferredUsername | 		usename = user.PreferredUsername | ||||||
| 	} else { | 	} else { | ||||||
| 		log.Debug().Msg("No preferred username from OAuth provider, using pseudo username") | 		log.Debug().Msg("No preferred username from OAuth provider, using pseudo username") | ||||||
| 		username = strings.Replace(user.Email, "@", "_", -1) | 		usename = strings.Replace(user.Email, "@", "_", -1) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	err = controller.auth.CreateSessionCookie(c, &config.SessionCookie{ | 	controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ | ||||||
| 		Username:    username, | 		Username:    usename, | ||||||
| 		Name:        name, | 		Name:        name, | ||||||
| 		Email:       user.Email, | 		Email:       user.Email, | ||||||
| 		Provider:    req.Provider, | 		Provider:    req.Provider, | ||||||
| 		OAuthGroups: utils.CoalesceToString(user.Groups), | 		OAuthGroups: utils.CoalesceToString(user.Groups), | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	if err != nil { | 	redirectURI, err := c.Cookie(controller.Config.RedirectCookieName) | ||||||
| 		log.Error().Err(err).Msg("Failed to create session cookie") |  | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	redirectURI, err := c.Cookie(controller.config.RedirectCookieName) | 	if err != nil || !utils.IsRedirectSafe(redirectURI, controller.Config.RootDomain) { | ||||||
|  |  | ||||||
| 	if err != nil || !utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) { |  | ||||||
| 		log.Debug().Msg("No redirect URI cookie found, redirecting to app root") | 		log.Debug().Msg("No redirect URI cookie found, redirecting to app root") | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, controller.config.AppURL) | 		c.Redirect(http.StatusTemporaryRedirect, controller.Config.AppURL) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -208,10 +201,10 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error().Err(err).Msg("Failed to encode redirect URI query") | 		log.Error().Err(err).Msg("Failed to encode redirect URI query") | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.SetCookie(controller.config.RedirectCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) | 	c.SetCookie(controller.Config.RedirectCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) | ||||||
| 	c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", controller.config.AppURL, queries.Encode())) | 	c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", controller.Config.AppURL, queries.Encode())) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -22,23 +22,23 @@ type ProxyControllerConfig struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| type ProxyController struct { | type ProxyController struct { | ||||||
| 	config ProxyControllerConfig | 	Config ProxyControllerConfig | ||||||
| 	router *gin.RouterGroup | 	Router *gin.RouterGroup | ||||||
| 	docker *service.DockerService | 	Docker *service.DockerService | ||||||
| 	auth   *service.AuthService | 	Auth   *service.AuthService | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, docker *service.DockerService, auth *service.AuthService) *ProxyController { | func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, docker *service.DockerService, auth *service.AuthService) *ProxyController { | ||||||
| 	return &ProxyController{ | 	return &ProxyController{ | ||||||
| 		config: config, | 		Config: config, | ||||||
| 		router: router, | 		Router: router, | ||||||
| 		docker: docker, | 		Docker: docker, | ||||||
| 		auth:   auth, | 		Auth:   auth, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *ProxyController) SetupRoutes() { | func (controller *ProxyController) SetupRoutes() { | ||||||
| 	proxyGroup := controller.router.Group("/auth") | 	proxyGroup := controller.Router.Group("/auth") | ||||||
| 	proxyGroup.GET("/:proxy", controller.proxyHandler) | 	proxyGroup.GET("/:proxy", controller.proxyHandler) | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -55,15 +55,6 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if req.Proxy != "nginx" && req.Proxy != "traefik" && req.Proxy != "caddy" { |  | ||||||
| 		log.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy") |  | ||||||
| 		c.JSON(400, gin.H{ |  | ||||||
| 			"status":  400, |  | ||||||
| 			"message": "Bad Request", |  | ||||||
| 		}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html") | 	isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html") | ||||||
|  |  | ||||||
| 	if isBrowser { | 	if isBrowser { | ||||||
| @@ -76,18 +67,44 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 	proto := c.Request.Header.Get("X-Forwarded-Proto") | 	proto := c.Request.Header.Get("X-Forwarded-Proto") | ||||||
| 	host := c.Request.Header.Get("X-Forwarded-Host") | 	host := c.Request.Header.Get("X-Forwarded-Host") | ||||||
|  |  | ||||||
| 	labels, err := controller.docker.GetLabels(host) | 	hostWithoutPort := strings.Split(host, ":")[0] | ||||||
|  | 	id := strings.Split(hostWithoutPort, ".")[0] | ||||||
|  |  | ||||||
|  | 	labels, err := controller.Docker.GetLabels(id, hostWithoutPort) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error().Err(err).Msg("Failed to get labels from Docker") | 		log.Error().Err(err).Msg("Failed to get labels from Docker") | ||||||
| 		controller.handleError(c, req, isBrowser) |  | ||||||
|  | 		if req.Proxy == "nginx" || !isBrowser { | ||||||
|  | 			c.JSON(500, gin.H{ | ||||||
|  | 				"status":  500, | ||||||
|  | 				"message": "Internal Server Error", | ||||||
|  | 			}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	clientIP := c.ClientIP() | 	clientIP := c.ClientIP() | ||||||
|  |  | ||||||
| 	if controller.auth.IsBypassedIP(labels.IP, clientIP) { | 	if controller.Auth.IsBypassedIP(labels.IP, clientIP) { | ||||||
| 		controller.setHeaders(c, labels) | 		c.Header("Authorization", c.Request.Header.Get("Authorization")) | ||||||
|  |  | ||||||
|  | 		headers := utils.ParseHeaders(labels.Response.Headers) | ||||||
|  |  | ||||||
|  | 		for key, value := range headers { | ||||||
|  | 			log.Debug().Str("header", key).Msg("Setting header") | ||||||
|  | 			c.Header(key, value) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		basicPassword := utils.GetSecret(labels.Response.BasicAuth.Password, labels.Response.BasicAuth.PasswordFile) | ||||||
|  | 		if labels.Response.BasicAuth.Username != "" && basicPassword != "" { | ||||||
|  | 			log.Debug().Str("username", labels.Response.BasicAuth.Username).Msg("Setting basic auth header") | ||||||
|  | 			c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Response.BasicAuth.Username, basicPassword))) | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status":  200, | 			"status":  200, | ||||||
| 			"message": "Authenticated", | 			"message": "Authenticated", | ||||||
| @@ -95,17 +112,41 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	authEnabled, err := controller.auth.IsAuthEnabled(uri, labels.Path) | 	authEnabled, err := controller.Auth.IsAuthEnabled(uri, labels.Path) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error().Err(err).Msg("Failed to check if auth is enabled for resource") | 		log.Error().Err(err).Msg("Failed to check if auth is enabled for resource") | ||||||
| 		controller.handleError(c, req, isBrowser) |  | ||||||
|  | 		if req.Proxy == "nginx" || !isBrowser { | ||||||
|  | 			c.JSON(500, gin.H{ | ||||||
|  | 				"status":  500, | ||||||
|  | 				"message": "Internal Server Error", | ||||||
|  | 			}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if !authEnabled { | 	if !authEnabled { | ||||||
| 		log.Debug().Msg("Authentication disabled for resource, allowing access") | 		log.Debug().Msg("Authentication disabled for resource, allowing access") | ||||||
| 		controller.setHeaders(c, labels) |  | ||||||
|  | 		c.Header("Authorization", c.Request.Header.Get("Authorization")) | ||||||
|  |  | ||||||
|  | 		headers := utils.ParseHeaders(labels.Response.Headers) | ||||||
|  |  | ||||||
|  | 		for key, value := range headers { | ||||||
|  | 			log.Debug().Str("header", key).Msg("Setting header") | ||||||
|  | 			c.Header(key, value) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		basicPassword := utils.GetSecret(labels.Response.BasicAuth.Password, labels.Response.BasicAuth.PasswordFile) | ||||||
|  | 		if labels.Response.BasicAuth.Username != "" && basicPassword != "" { | ||||||
|  | 			log.Debug().Str("username", labels.Response.BasicAuth.Username).Msg("Setting basic auth header") | ||||||
|  | 			c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Response.BasicAuth.Username, basicPassword))) | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status":  200, | 			"status":  200, | ||||||
| 			"message": "Authenticated", | 			"message": "Authenticated", | ||||||
| @@ -113,7 +154,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if !controller.auth.CheckIP(labels.IP, clientIP) { | 	if !controller.Auth.CheckIP(labels.IP, clientIP) { | ||||||
| 		if req.Proxy == "nginx" || !isBrowser { | 		if req.Proxy == "nginx" || !isBrowser { | ||||||
| 			c.JSON(401, gin.H{ | 			c.JSON(401, gin.H{ | ||||||
| 				"status":  401, | 				"status":  401, | ||||||
| @@ -129,11 +170,11 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Error().Err(err).Msg("Failed to encode unauthorized query") | 			log.Error().Err(err).Msg("Failed to encode unauthorized query") | ||||||
| 			c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) | 			c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.Config.AppURL, queries.Encode())) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -156,7 +197,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if userContext.IsLoggedIn { | 	if userContext.IsLoggedIn { | ||||||
| 		appAllowed := controller.auth.IsResourceAllowed(c, userContext, labels) | 		appAllowed := controller.Auth.IsResourceAllowed(c, userContext, labels) | ||||||
|  |  | ||||||
| 		if !appAllowed { | 		if !appAllowed { | ||||||
| 			log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") | 			log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") | ||||||
| @@ -173,24 +214,24 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 				Resource: strings.Split(host, ".")[0], | 				Resource: strings.Split(host, ".")[0], | ||||||
| 			}) | 			}) | ||||||
|  |  | ||||||
| 			if err != nil { |  | ||||||
| 				log.Error().Err(err).Msg("Failed to encode unauthorized query") |  | ||||||
| 				c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) |  | ||||||
| 				return |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			if userContext.OAuth { | 			if userContext.OAuth { | ||||||
| 				queries.Set("username", userContext.Email) | 				queries.Set("username", userContext.Email) | ||||||
| 			} else { | 			} else { | ||||||
| 				queries.Set("username", userContext.Username) | 				queries.Set("username", userContext.Username) | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())) | 			if err != nil { | ||||||
|  | 				log.Error().Err(err).Msg("Failed to encode unauthorized query") | ||||||
|  | 				c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.Config.AppURL, queries.Encode())) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if userContext.OAuth { | 		if userContext.OAuth { | ||||||
| 			groupOK := controller.auth.IsInOAuthGroup(c, userContext, labels.OAuth.Groups) | 			groupOK := controller.Auth.IsInOAuthGroup(c, userContext, labels.OAuth.Groups) | ||||||
|  |  | ||||||
| 			if !groupOK { | 			if !groupOK { | ||||||
| 				log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements") | 				log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements") | ||||||
| @@ -208,29 +249,41 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 					GroupErr: true, | 					GroupErr: true, | ||||||
| 				}) | 				}) | ||||||
|  |  | ||||||
| 				if err != nil { |  | ||||||
| 					log.Error().Err(err).Msg("Failed to encode unauthorized query") |  | ||||||
| 					c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) |  | ||||||
| 					return |  | ||||||
| 				} |  | ||||||
|  |  | ||||||
| 				if userContext.OAuth { | 				if userContext.OAuth { | ||||||
| 					queries.Set("username", userContext.Email) | 					queries.Set("username", userContext.Email) | ||||||
| 				} else { | 				} else { | ||||||
| 					queries.Set("username", userContext.Username) | 					queries.Set("username", userContext.Username) | ||||||
| 				} | 				} | ||||||
|  |  | ||||||
| 				c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())) | 				if err != nil { | ||||||
|  | 					log.Error().Err(err).Msg("Failed to encode unauthorized query") | ||||||
|  | 					c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
|  | 					return | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.Config.AppURL, queries.Encode())) | ||||||
| 				return | 				return | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		c.Header("Authorization", c.Request.Header.Get("Authorization")) | ||||||
| 		c.Header("Remote-User", utils.SanitizeHeader(userContext.Username)) | 		c.Header("Remote-User", utils.SanitizeHeader(userContext.Username)) | ||||||
| 		c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name)) | 		c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name)) | ||||||
| 		c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) | 		c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) | ||||||
| 		c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) | 		c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) | ||||||
|  |  | ||||||
| 		controller.setHeaders(c, labels) | 		headers := utils.ParseHeaders(labels.Response.Headers) | ||||||
|  |  | ||||||
|  | 		for key, value := range headers { | ||||||
|  | 			log.Debug().Str("header", key).Msg("Setting header") | ||||||
|  | 			c.Header(key, value) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		basicPassword := utils.GetSecret(labels.Response.BasicAuth.Password, labels.Response.BasicAuth.PasswordFile) | ||||||
|  | 		if labels.Response.BasicAuth.Username != "" && basicPassword != "" { | ||||||
|  | 			log.Debug().Str("username", labels.Response.BasicAuth.Username).Msg("Setting basic auth header") | ||||||
|  | 			c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Response.BasicAuth.Username, basicPassword))) | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status":  200, | 			"status":  200, | ||||||
| @@ -253,39 +306,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error().Err(err).Msg("Failed to encode redirect URI query") | 		log.Error().Err(err).Msg("Failed to encode redirect URI query") | ||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())) | 	c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", controller.Config.AppURL, queries.Encode())) | ||||||
| } |  | ||||||
|  |  | ||||||
| func (controller *ProxyController) setHeaders(c *gin.Context, labels config.App) { |  | ||||||
| 	c.Header("Authorization", c.Request.Header.Get("Authorization")) |  | ||||||
|  |  | ||||||
| 	headers := utils.ParseHeaders(labels.Response.Headers) |  | ||||||
|  |  | ||||||
| 	for key, value := range headers { |  | ||||||
| 		log.Debug().Str("header", key).Msg("Setting header") |  | ||||||
| 		c.Header(key, value) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	basicPassword := utils.GetSecret(labels.Response.BasicAuth.Password, labels.Response.BasicAuth.PasswordFile) |  | ||||||
|  |  | ||||||
| 	if labels.Response.BasicAuth.Username != "" && basicPassword != "" { |  | ||||||
| 		log.Debug().Str("username", labels.Response.BasicAuth.Username).Msg("Setting basic auth header") |  | ||||||
| 		c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Response.BasicAuth.Username, basicPassword))) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (controller *ProxyController) handleError(c *gin.Context, req Proxy, isBrowser bool) { |  | ||||||
| 	if req.Proxy == "nginx" || !isBrowser { |  | ||||||
| 		c.JSON(500, gin.H{ |  | ||||||
| 			"status":  500, |  | ||||||
| 			"message": "Internal Server Error", |  | ||||||
| 		}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,164 +0,0 @@ | |||||||
| package controller_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"net/http/httptest" |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/config" |  | ||||||
| 	"tinyauth/internal/controller" |  | ||||||
| 	"tinyauth/internal/service" |  | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder, *service.AuthService) { |  | ||||||
| 	// Setup |  | ||||||
| 	gin.SetMode(gin.TestMode) |  | ||||||
| 	router := gin.Default() |  | ||||||
|  |  | ||||||
| 	if middlewares != nil { |  | ||||||
| 		for _, m := range *middlewares { |  | ||||||
| 			router.Use(m) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	group := router.Group("/api") |  | ||||||
| 	recorder := httptest.NewRecorder() |  | ||||||
|  |  | ||||||
| 	// Database |  | ||||||
| 	databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{ |  | ||||||
| 		DatabasePath: "/tmp/tinyauth_test.db", |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, databaseService.Init()) |  | ||||||
|  |  | ||||||
| 	database := databaseService.GetDatabase() |  | ||||||
|  |  | ||||||
| 	// Docker |  | ||||||
| 	dockerService := service.NewDockerService() |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, dockerService.Init()) |  | ||||||
|  |  | ||||||
| 	// Auth service |  | ||||||
| 	authService := service.NewAuthService(service.AuthServiceConfig{ |  | ||||||
| 		Users: []config.User{ |  | ||||||
| 			{ |  | ||||||
| 				Username: "testuser", |  | ||||||
| 				Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 		OauthWhitelist:    "", |  | ||||||
| 		SessionExpiry:     3600, |  | ||||||
| 		SecureCookie:      false, |  | ||||||
| 		CookieDomain:      "localhost", |  | ||||||
| 		LoginTimeout:      300, |  | ||||||
| 		LoginMaxRetries:   3, |  | ||||||
| 		SessionCookieName: "tinyauth-session", |  | ||||||
| 	}, dockerService, nil, database) |  | ||||||
|  |  | ||||||
| 	// Controller |  | ||||||
| 	ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ |  | ||||||
| 		AppURL: "http://localhost:8080", |  | ||||||
| 	}, group, dockerService, authService) |  | ||||||
| 	ctrl.SetupRoutes() |  | ||||||
|  |  | ||||||
| 	return router, recorder, authService |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestProxyHandler(t *testing.T) { |  | ||||||
| 	// Setup |  | ||||||
| 	router, recorder, authService := setupProxyController(t, nil) |  | ||||||
|  |  | ||||||
| 	// Test invalid proxy |  | ||||||
| 	req := httptest.NewRequest("GET", "/api/auth/invalidproxy", nil) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 400, recorder.Code) |  | ||||||
|  |  | ||||||
| 	// Test logged out user (traefik/caddy) |  | ||||||
| 	recorder = httptest.NewRecorder() |  | ||||||
| 	req = httptest.NewRequest("GET", "/api/auth/traefik", nil) |  | ||||||
| 	req.Header.Set("X-Forwarded-Proto", "https") |  | ||||||
| 	req.Header.Set("X-Forwarded-Host", "example.com") |  | ||||||
| 	req.Header.Set("X-Forwarded-Uri", "/somepath") |  | ||||||
| 	req.Header.Set("Accept", "text/html") |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 307, recorder.Code) |  | ||||||
| 	assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location")) |  | ||||||
|  |  | ||||||
| 	// Test logged out user (nginx) |  | ||||||
| 	recorder = httptest.NewRecorder() |  | ||||||
| 	req = httptest.NewRequest("GET", "/api/auth/nginx", nil) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 401, recorder.Code) |  | ||||||
|  |  | ||||||
| 	// Test logged in user |  | ||||||
| 	c := gin.CreateTestContextOnly(recorder, router) |  | ||||||
|  |  | ||||||
| 	err := authService.CreateSessionCookie(c, &config.SessionCookie{ |  | ||||||
| 		Username:    "testuser", |  | ||||||
| 		Name:        "testuser", |  | ||||||
| 		Email:       "testuser@example.com", |  | ||||||
| 		Provider:    "username", |  | ||||||
| 		TotpPending: false, |  | ||||||
| 		OAuthGroups: "", |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	cookie := c.Writer.Header().Get("Set-Cookie") |  | ||||||
|  |  | ||||||
| 	router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ |  | ||||||
| 		func(c *gin.Context) { |  | ||||||
| 			c.Set("context", &config.UserContext{ |  | ||||||
| 				Username:    "testuser", |  | ||||||
| 				Name:        "testuser", |  | ||||||
| 				Email:       "testuser@example.com", |  | ||||||
| 				IsLoggedIn:  true, |  | ||||||
| 				OAuth:       false, |  | ||||||
| 				Provider:    "username", |  | ||||||
| 				TotpPending: false, |  | ||||||
| 				OAuthGroups: "", |  | ||||||
| 				TotpEnabled: false, |  | ||||||
| 			}) |  | ||||||
| 			c.Next() |  | ||||||
| 		}, |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	req = httptest.NewRequest("GET", "/api/auth/traefik", nil) |  | ||||||
| 	req.Header.Set("Cookie", cookie) |  | ||||||
| 	req.Header.Set("Accept", "text/html") |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "testuser", recorder.Header().Get("Remote-User")) |  | ||||||
| 	assert.Equal(t, "testuser", recorder.Header().Get("Remote-Name")) |  | ||||||
| 	assert.Equal(t, "testuser@example.com", recorder.Header().Get("Remote-Email")) |  | ||||||
|  |  | ||||||
| 	// Ensure basic auth is disabled for TOTP enabled users |  | ||||||
| 	router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ |  | ||||||
| 		func(c *gin.Context) { |  | ||||||
| 			c.Set("context", &config.UserContext{ |  | ||||||
| 				Username:    "testuser", |  | ||||||
| 				Name:        "testuser", |  | ||||||
| 				Email:       "testuser@example.com", |  | ||||||
| 				IsLoggedIn:  true, |  | ||||||
| 				OAuth:       false, |  | ||||||
| 				Provider:    "basic", |  | ||||||
| 				TotpPending: false, |  | ||||||
| 				OAuthGroups: "", |  | ||||||
| 				TotpEnabled: true, |  | ||||||
| 			}) |  | ||||||
| 			c.Next() |  | ||||||
| 		}, |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	req = httptest.NewRequest("GET", "/api/auth/traefik", nil) |  | ||||||
| 	req.SetBasicAuth("testuser", "test") |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 401, recorder.Code) |  | ||||||
| } |  | ||||||
| @@ -11,32 +11,32 @@ type ResourcesControllerConfig struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| type ResourcesController struct { | type ResourcesController struct { | ||||||
| 	config     ResourcesControllerConfig | 	Config     ResourcesControllerConfig | ||||||
| 	router     *gin.RouterGroup | 	Router     *gin.RouterGroup | ||||||
| 	fileServer http.Handler | 	FileServer http.Handler | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController { | func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController { | ||||||
| 	fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.ResourcesDir))) | 	fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.ResourcesDir))) | ||||||
|  |  | ||||||
| 	return &ResourcesController{ | 	return &ResourcesController{ | ||||||
| 		config:     config, | 		Config:     config, | ||||||
| 		router:     router, | 		Router:     router, | ||||||
| 		fileServer: fileServer, | 		FileServer: fileServer, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *ResourcesController) SetupRoutes() { | func (controller *ResourcesController) SetupRoutes() { | ||||||
| 	controller.router.GET("/resources/*resource", controller.resourcesHandler) | 	controller.Router.GET("/resources/*resource", controller.resourcesHandler) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *ResourcesController) resourcesHandler(c *gin.Context) { | func (controller *ResourcesController) resourcesHandler(c *gin.Context) { | ||||||
| 	if controller.config.ResourcesDir == "" { | 	if controller.Config.ResourcesDir == "" { | ||||||
| 		c.JSON(404, gin.H{ | 		c.JSON(404, gin.H{ | ||||||
| 			"status":  404, | 			"status":  404, | ||||||
| 			"message": "Resources not found", | 			"message": "Resources not found", | ||||||
| 		}) | 		}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	controller.fileServer.ServeHTTP(c.Writer, c.Request) | 	controller.FileServer.ServeHTTP(c.Writer, c.Request) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,57 +0,0 @@ | |||||||
| package controller_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"net/http/httptest" |  | ||||||
| 	"os" |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/controller" |  | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestResourcesHandler(t *testing.T) { |  | ||||||
| 	// Setup |  | ||||||
| 	gin.SetMode(gin.TestMode) |  | ||||||
| 	router := gin.New() |  | ||||||
| 	group := router.Group("/") |  | ||||||
|  |  | ||||||
| 	ctrl := controller.NewResourcesController(controller.ResourcesControllerConfig{ |  | ||||||
| 		ResourcesDir: "/tmp/tinyauth", |  | ||||||
| 	}, group) |  | ||||||
| 	ctrl.SetupRoutes() |  | ||||||
|  |  | ||||||
| 	// Create test data |  | ||||||
| 	err := os.Mkdir("/tmp/tinyauth", 0755) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	defer os.RemoveAll("/tmp/tinyauth") |  | ||||||
|  |  | ||||||
| 	file, err := os.Create("/tmp/tinyauth/test.txt") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	_, err = file.WriteString("This is a test file.") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	file.Close() |  | ||||||
|  |  | ||||||
| 	// Test existing file |  | ||||||
| 	req := httptest.NewRequest("GET", "/resources/test.txt", nil) |  | ||||||
| 	recorder := httptest.NewRecorder() |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
| 	assert.Equal(t, "This is a test file.", recorder.Body.String()) |  | ||||||
|  |  | ||||||
| 	// Test non-existing file |  | ||||||
| 	req = httptest.NewRequest("GET", "/resources/nonexistent.txt", nil) |  | ||||||
| 	recorder = httptest.NewRecorder() |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 404, recorder.Code) |  | ||||||
|  |  | ||||||
| 	// Test directory traversal attack |  | ||||||
| 	req = httptest.NewRequest("GET", "/resources/../etc/passwd", nil) |  | ||||||
| 	recorder = httptest.NewRecorder() |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 404, recorder.Code) |  | ||||||
| } |  | ||||||
| @@ -22,25 +22,25 @@ type TotpRequest struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| type UserControllerConfig struct { | type UserControllerConfig struct { | ||||||
| 	CookieDomain string | 	RootDomain string | ||||||
| } | } | ||||||
|  |  | ||||||
| type UserController struct { | type UserController struct { | ||||||
| 	config UserControllerConfig | 	Config UserControllerConfig | ||||||
| 	router *gin.RouterGroup | 	Router *gin.RouterGroup | ||||||
| 	auth   *service.AuthService | 	Auth   *service.AuthService | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewUserController(config UserControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *UserController { | func NewUserController(config UserControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *UserController { | ||||||
| 	return &UserController{ | 	return &UserController{ | ||||||
| 		config: config, | 		Config: config, | ||||||
| 		router: router, | 		Router: router, | ||||||
| 		auth:   auth, | 		Auth:   auth, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *UserController) SetupRoutes() { | func (controller *UserController) SetupRoutes() { | ||||||
| 	userGroup := controller.router.Group("/user") | 	userGroup := controller.Router.Group("/user") | ||||||
| 	userGroup.POST("/login", controller.loginHandler) | 	userGroup.POST("/login", controller.loginHandler) | ||||||
| 	userGroup.POST("/logout", controller.logoutHandler) | 	userGroup.POST("/logout", controller.logoutHandler) | ||||||
| 	userGroup.POST("/totp", controller.totpHandler) | 	userGroup.POST("/totp", controller.totpHandler) | ||||||
| @@ -69,7 +69,7 @@ func (controller *UserController) loginHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 	log.Debug().Str("username", req.Username).Str("ip", clientIP).Msg("Login attempt") | 	log.Debug().Str("username", req.Username).Str("ip", clientIP).Msg("Login attempt") | ||||||
|  |  | ||||||
| 	isLocked, remainingTime := controller.auth.IsAccountLocked(rateIdentifier) | 	isLocked, remainingTime := controller.Auth.IsAccountLocked(rateIdentifier) | ||||||
|  |  | ||||||
| 	if isLocked { | 	if isLocked { | ||||||
| 		log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed login attempts") | 		log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed login attempts") | ||||||
| @@ -80,11 +80,11 @@ func (controller *UserController) loginHandler(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	userSearch := controller.auth.SearchUser(req.Username) | 	userSearch := controller.Auth.SearchUser(req.Username) | ||||||
|  |  | ||||||
| 	if userSearch.Type == "unknown" { | 	if userSearch.Type == "" { | ||||||
| 		log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("User not found") | 		log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("User not found") | ||||||
| 		controller.auth.RecordLoginAttempt(rateIdentifier, false) | 		controller.Auth.RecordLoginAttempt(rateIdentifier, false) | ||||||
| 		c.JSON(401, gin.H{ | 		c.JSON(401, gin.H{ | ||||||
| 			"status":  401, | 			"status":  401, | ||||||
| 			"message": "Unauthorized", | 			"message": "Unauthorized", | ||||||
| @@ -92,9 +92,9 @@ func (controller *UserController) loginHandler(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if !controller.auth.VerifyUser(userSearch, req.Password) { | 	if !controller.Auth.VerifyUser(userSearch, req.Password) { | ||||||
| 		log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Invalid password") | 		log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Invalid password") | ||||||
| 		controller.auth.RecordLoginAttempt(rateIdentifier, false) | 		controller.Auth.RecordLoginAttempt(rateIdentifier, false) | ||||||
| 		c.JSON(401, gin.H{ | 		c.JSON(401, gin.H{ | ||||||
| 			"status":  401, | 			"status":  401, | ||||||
| 			"message": "Unauthorized", | 			"message": "Unauthorized", | ||||||
| @@ -104,18 +104,18 @@ func (controller *UserController) loginHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 	log.Info().Str("username", req.Username).Str("ip", clientIP).Msg("Login successful") | 	log.Info().Str("username", req.Username).Str("ip", clientIP).Msg("Login successful") | ||||||
|  |  | ||||||
| 	controller.auth.RecordLoginAttempt(rateIdentifier, true) | 	controller.Auth.RecordLoginAttempt(rateIdentifier, true) | ||||||
|  |  | ||||||
| 	if userSearch.Type == "local" { | 	if userSearch.Type == "local" { | ||||||
| 		user := controller.auth.GetLocalUser(userSearch.Username) | 		user := controller.Auth.GetLocalUser(userSearch.Username) | ||||||
|  |  | ||||||
| 		if user.TotpSecret != "" { | 		if user.TotpSecret != "" { | ||||||
| 			log.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification") | 			log.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification") | ||||||
|  |  | ||||||
| 			err := controller.auth.CreateSessionCookie(c, &config.SessionCookie{ | 			err := controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ | ||||||
| 				Username:    user.Username, | 				Username:    user.Username, | ||||||
| 				Name:        utils.Capitalize(req.Username), | 				Name:        utils.Capitalize(req.Username), | ||||||
| 				Email:       fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain), | 				Email:       fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.RootDomain), | ||||||
| 				Provider:    "username", | 				Provider:    "username", | ||||||
| 				TotpPending: true, | 				TotpPending: true, | ||||||
| 			}) | 			}) | ||||||
| @@ -138,10 +138,10 @@ func (controller *UserController) loginHandler(c *gin.Context) { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	err = controller.auth.CreateSessionCookie(c, &config.SessionCookie{ | 	err = controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ | ||||||
| 		Username: req.Username, | 		Username: req.Username, | ||||||
| 		Name:     utils.Capitalize(req.Username), | 		Name:     utils.Capitalize(req.Username), | ||||||
| 		Email:    fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain), | 		Email:    fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.RootDomain), | ||||||
| 		Provider: "username", | 		Provider: "username", | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| @@ -163,7 +163,7 @@ func (controller *UserController) loginHandler(c *gin.Context) { | |||||||
| func (controller *UserController) logoutHandler(c *gin.Context) { | func (controller *UserController) logoutHandler(c *gin.Context) { | ||||||
| 	log.Debug().Msg("Logout request received") | 	log.Debug().Msg("Logout request received") | ||||||
|  |  | ||||||
| 	controller.auth.DeleteSessionCookie(c) | 	controller.Auth.DeleteSessionCookie(c) | ||||||
|  |  | ||||||
| 	c.JSON(200, gin.H{ | 	c.JSON(200, gin.H{ | ||||||
| 		"status":  200, | 		"status":  200, | ||||||
| @@ -214,24 +214,24 @@ func (controller *UserController) totpHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 	log.Debug().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification attempt") | 	log.Debug().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification attempt") | ||||||
|  |  | ||||||
| 	isLocked, remainingTime := controller.auth.IsAccountLocked(rateIdentifier) | 	isLocked, remainingTime := controller.Auth.IsAccountLocked(rateIdentifier) | ||||||
|  |  | ||||||
| 	if isLocked { | 	if isLocked { | ||||||
| 		log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed TOTP attempts") | 		log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed TOTP attempts") | ||||||
| 		c.JSON(429, gin.H{ | 		c.JSON(429, gin.H{ | ||||||
| 			"status":  429, | 			"status":  429, | ||||||
| 			"message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remainingTime), | 			"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime), | ||||||
| 		}) | 		}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	user := controller.auth.GetLocalUser(context.Username) | 	user := controller.Auth.GetLocalUser(context.Username) | ||||||
|  |  | ||||||
| 	ok := totp.Validate(req.Code, user.TotpSecret) | 	ok := totp.Validate(req.Code, user.TotpSecret) | ||||||
|  |  | ||||||
| 	if !ok { | 	if !ok { | ||||||
| 		log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Invalid TOTP code") | 		log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Invalid TOTP code") | ||||||
| 		controller.auth.RecordLoginAttempt(rateIdentifier, false) | 		controller.Auth.RecordLoginAttempt(rateIdentifier, false) | ||||||
| 		c.JSON(401, gin.H{ | 		c.JSON(401, gin.H{ | ||||||
| 			"status":  401, | 			"status":  401, | ||||||
| 			"message": "Unauthorized", | 			"message": "Unauthorized", | ||||||
| @@ -241,12 +241,12 @@ func (controller *UserController) totpHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 	log.Info().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification successful") | 	log.Info().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification successful") | ||||||
|  |  | ||||||
| 	controller.auth.RecordLoginAttempt(rateIdentifier, true) | 	controller.Auth.RecordLoginAttempt(rateIdentifier, true) | ||||||
|  |  | ||||||
| 	err = controller.auth.CreateSessionCookie(c, &config.SessionCookie{ | 	err = controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ | ||||||
| 		Username: user.Username, | 		Username: user.Username, | ||||||
| 		Name:     utils.Capitalize(user.Username), | 		Name:     utils.Capitalize(user.Username), | ||||||
| 		Email:    fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.config.CookieDomain), | 		Email:    fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.Config.RootDomain), | ||||||
| 		Provider: "username", | 		Provider: "username", | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,297 +0,0 @@ | |||||||
| package controller_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"net/http" |  | ||||||
| 	"net/http/httptest" |  | ||||||
| 	"strings" |  | ||||||
| 	"testing" |  | ||||||
| 	"time" |  | ||||||
| 	"tinyauth/internal/config" |  | ||||||
| 	"tinyauth/internal/controller" |  | ||||||
| 	"tinyauth/internal/service" |  | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"github.com/pquerna/otp/totp" |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| var cookieValue string |  | ||||||
| var totpSecret = "6WFZXPEZRK5MZHHYAFW4DAOUYQMCASBJ" |  | ||||||
|  |  | ||||||
| func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { |  | ||||||
| 	// Setup |  | ||||||
| 	gin.SetMode(gin.TestMode) |  | ||||||
| 	router := gin.Default() |  | ||||||
|  |  | ||||||
| 	if middlewares != nil { |  | ||||||
| 		for _, m := range *middlewares { |  | ||||||
| 			router.Use(m) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	group := router.Group("/api") |  | ||||||
| 	recorder := httptest.NewRecorder() |  | ||||||
|  |  | ||||||
| 	// Database |  | ||||||
| 	databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{ |  | ||||||
| 		DatabasePath: "/tmp/tinyauth_test.db", |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, databaseService.Init()) |  | ||||||
|  |  | ||||||
| 	database := databaseService.GetDatabase() |  | ||||||
|  |  | ||||||
| 	// Auth service |  | ||||||
| 	authService := service.NewAuthService(service.AuthServiceConfig{ |  | ||||||
| 		Users: []config.User{ |  | ||||||
| 			{ |  | ||||||
| 				Username: "testuser", |  | ||||||
| 				Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				Username:   "totpuser", |  | ||||||
| 				Password:   "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test |  | ||||||
| 				TotpSecret: totpSecret, |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 		OauthWhitelist:    "", |  | ||||||
| 		SessionExpiry:     3600, |  | ||||||
| 		SecureCookie:      false, |  | ||||||
| 		CookieDomain:      "localhost", |  | ||||||
| 		LoginTimeout:      300, |  | ||||||
| 		LoginMaxRetries:   3, |  | ||||||
| 		SessionCookieName: "tinyauth-session", |  | ||||||
| 	}, nil, nil, database) |  | ||||||
|  |  | ||||||
| 	// Controller |  | ||||||
| 	ctrl := controller.NewUserController(controller.UserControllerConfig{ |  | ||||||
| 		CookieDomain: "localhost", |  | ||||||
| 	}, group, authService) |  | ||||||
| 	ctrl.SetupRoutes() |  | ||||||
|  |  | ||||||
| 	return router, recorder |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestLoginHandler(t *testing.T) { |  | ||||||
| 	// Setup |  | ||||||
| 	router, recorder := setupUserController(t, nil) |  | ||||||
|  |  | ||||||
| 	loginReq := controller.LoginRequest{ |  | ||||||
| 		Username: "testuser", |  | ||||||
| 		Password: "test", |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	loginReqJson, err := json.Marshal(loginReq) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	// Test |  | ||||||
| 	req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
|  |  | ||||||
| 	cookie := recorder.Result().Cookies()[0] |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "tinyauth-session", cookie.Name) |  | ||||||
| 	assert.Assert(t, cookie.Value != "") |  | ||||||
|  |  | ||||||
| 	cookieValue = cookie.Value |  | ||||||
|  |  | ||||||
| 	// Test invalid credentials |  | ||||||
| 	loginReq = controller.LoginRequest{ |  | ||||||
| 		Username: "testuser", |  | ||||||
| 		Password: "invalid", |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	loginReqJson, err = json.Marshal(loginReq) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	recorder = httptest.NewRecorder() |  | ||||||
| 	req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 401, recorder.Code) |  | ||||||
|  |  | ||||||
| 	// Test totp required |  | ||||||
| 	loginReq = controller.LoginRequest{ |  | ||||||
| 		Username: "totpuser", |  | ||||||
| 		Password: "test", |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	loginReqJson, err = json.Marshal(loginReq) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	recorder = httptest.NewRecorder() |  | ||||||
| 	req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
|  |  | ||||||
| 	loginResJson, err := json.Marshal(map[string]any{ |  | ||||||
| 		"message":     "TOTP required", |  | ||||||
| 		"status":      200, |  | ||||||
| 		"totpPending": true, |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, string(loginResJson), recorder.Body.String()) |  | ||||||
|  |  | ||||||
| 	// Test invalid json |  | ||||||
| 	recorder = httptest.NewRecorder() |  | ||||||
| 	req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader("{invalid json}")) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 400, recorder.Code) |  | ||||||
|  |  | ||||||
| 	// Test rate limiting |  | ||||||
| 	loginReq = controller.LoginRequest{ |  | ||||||
| 		Username: "testuser", |  | ||||||
| 		Password: "invalid", |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	loginReqJson, err = json.Marshal(loginReq) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	for range 5 { |  | ||||||
| 		recorder = httptest.NewRecorder() |  | ||||||
| 		req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) |  | ||||||
| 		router.ServeHTTP(recorder, req) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 429, recorder.Code) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestLogoutHandler(t *testing.T) { |  | ||||||
| 	// Setup |  | ||||||
| 	router, recorder := setupUserController(t, nil) |  | ||||||
|  |  | ||||||
| 	// Test |  | ||||||
| 	req := httptest.NewRequest("POST", "/api/user/logout", nil) |  | ||||||
|  |  | ||||||
| 	req.AddCookie(&http.Cookie{ |  | ||||||
| 		Name:  "tinyauth-session", |  | ||||||
| 		Value: cookieValue, |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
|  |  | ||||||
| 	cookie := recorder.Result().Cookies()[0] |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "tinyauth-session", cookie.Name) |  | ||||||
| 	assert.Equal(t, "", cookie.Value) |  | ||||||
| 	assert.Equal(t, -1, cookie.MaxAge) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestTotpHandler(t *testing.T) { |  | ||||||
| 	// Setup |  | ||||||
| 	router, recorder := setupUserController(t, &[]gin.HandlerFunc{ |  | ||||||
| 		func(c *gin.Context) { |  | ||||||
| 			c.Set("context", &config.UserContext{ |  | ||||||
| 				Username:    "totpuser", |  | ||||||
| 				Name:        "totpuser", |  | ||||||
| 				Email:       "totpuser@example.com", |  | ||||||
| 				IsLoggedIn:  false, |  | ||||||
| 				OAuth:       false, |  | ||||||
| 				Provider:    "username", |  | ||||||
| 				TotpPending: true, |  | ||||||
| 				OAuthGroups: "", |  | ||||||
| 				TotpEnabled: true, |  | ||||||
| 			}) |  | ||||||
| 			c.Next() |  | ||||||
| 		}, |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	// Test |  | ||||||
| 	code, err := totp.GenerateCode(totpSecret, time.Now()) |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	totpReq := controller.TotpRequest{ |  | ||||||
| 		Code: code, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	totpReqJson, err := json.Marshal(totpReq) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 200, recorder.Code) |  | ||||||
|  |  | ||||||
| 	cookie := recorder.Result().Cookies()[0] |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "tinyauth-session", cookie.Name) |  | ||||||
| 	assert.Assert(t, cookie.Value != "") |  | ||||||
|  |  | ||||||
| 	// Test invalid json |  | ||||||
| 	recorder = httptest.NewRecorder() |  | ||||||
| 	req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader("{invalid json}")) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 400, recorder.Code) |  | ||||||
|  |  | ||||||
| 	// Test rate limiting |  | ||||||
| 	totpReq = controller.TotpRequest{ |  | ||||||
| 		Code: "000000", |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	totpReqJson, err = json.Marshal(totpReq) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	for range 5 { |  | ||||||
| 		recorder = httptest.NewRecorder() |  | ||||||
| 		req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) |  | ||||||
| 		router.ServeHTTP(recorder, req) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 429, recorder.Code) |  | ||||||
|  |  | ||||||
| 	// Test invalid code |  | ||||||
| 	router, recorder = setupUserController(t, &[]gin.HandlerFunc{ |  | ||||||
| 		func(c *gin.Context) { |  | ||||||
| 			c.Set("context", &config.UserContext{ |  | ||||||
| 				Username:    "totpuser", |  | ||||||
| 				Name:        "totpuser", |  | ||||||
| 				Email:       "totpuser@example.com", |  | ||||||
| 				IsLoggedIn:  false, |  | ||||||
| 				OAuth:       false, |  | ||||||
| 				Provider:    "username", |  | ||||||
| 				TotpPending: true, |  | ||||||
| 				OAuthGroups: "", |  | ||||||
| 				TotpEnabled: true, |  | ||||||
| 			}) |  | ||||||
| 			c.Next() |  | ||||||
| 		}, |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 401, recorder.Code) |  | ||||||
|  |  | ||||||
| 	// Test no totp pending |  | ||||||
| 	router, recorder = setupUserController(t, &[]gin.HandlerFunc{ |  | ||||||
| 		func(c *gin.Context) { |  | ||||||
| 			c.Set("context", &config.UserContext{ |  | ||||||
| 				Username:    "totpuser", |  | ||||||
| 				Name:        "totpuser", |  | ||||||
| 				Email:       "totpuser@example.com", |  | ||||||
| 				IsLoggedIn:  false, |  | ||||||
| 				OAuth:       false, |  | ||||||
| 				Provider:    "username", |  | ||||||
| 				TotpPending: false, |  | ||||||
| 				OAuthGroups: "", |  | ||||||
| 				TotpEnabled: false, |  | ||||||
| 			}) |  | ||||||
| 			c.Next() |  | ||||||
| 		}, |  | ||||||
| 	}) |  | ||||||
|  |  | ||||||
| 	req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) |  | ||||||
| 	router.ServeHTTP(recorder, req) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 401, recorder.Code) |  | ||||||
| } |  | ||||||
| @@ -12,20 +12,20 @@ import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| type ContextMiddlewareConfig struct { | type ContextMiddlewareConfig struct { | ||||||
| 	CookieDomain string | 	RootDomain string | ||||||
| } | } | ||||||
|  |  | ||||||
| type ContextMiddleware struct { | type ContextMiddleware struct { | ||||||
| 	config ContextMiddlewareConfig | 	Config ContextMiddlewareConfig | ||||||
| 	auth   *service.AuthService | 	Auth   *service.AuthService | ||||||
| 	broker *service.OAuthBrokerService | 	Broker *service.OAuthBrokerService | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewContextMiddleware(config ContextMiddlewareConfig, auth *service.AuthService, broker *service.OAuthBrokerService) *ContextMiddleware { | func NewContextMiddleware(config ContextMiddlewareConfig, auth *service.AuthService, broker *service.OAuthBrokerService) *ContextMiddleware { | ||||||
| 	return &ContextMiddleware{ | 	return &ContextMiddleware{ | ||||||
| 		config: config, | 		Config: config, | ||||||
| 		auth:   auth, | 		Auth:   auth, | ||||||
| 		broker: broker, | 		Broker: broker, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -35,7 +35,7 @@ func (m *ContextMiddleware) Init() error { | |||||||
|  |  | ||||||
| func (m *ContextMiddleware) Middleware() gin.HandlerFunc { | func (m *ContextMiddleware) Middleware() gin.HandlerFunc { | ||||||
| 	return func(c *gin.Context) { | 	return func(c *gin.Context) { | ||||||
| 		cookie, err := m.auth.GetSessionCookie(c) | 		cookie, err := m.Auth.GetSessionCookie(c) | ||||||
|  |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Debug().Err(err).Msg("No valid session cookie found") | 			log.Debug().Err(err).Msg("No valid session cookie found") | ||||||
| @@ -57,11 +57,11 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { | |||||||
|  |  | ||||||
| 		switch cookie.Provider { | 		switch cookie.Provider { | ||||||
| 		case "username": | 		case "username": | ||||||
| 			userSearch := m.auth.SearchUser(cookie.Username) | 			userSearch := m.Auth.SearchUser(cookie.Username) | ||||||
|  |  | ||||||
| 			if userSearch.Type == "unknown" || userSearch.Type == "error" { | 			if userSearch.Type == "unknown" { | ||||||
| 				log.Debug().Msg("User from session cookie not found") | 				log.Debug().Msg("User from session cookie not found") | ||||||
| 				m.auth.DeleteSessionCookie(c) | 				m.Auth.DeleteSessionCookie(c) | ||||||
| 				goto basic | 				goto basic | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| @@ -75,17 +75,17 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { | |||||||
| 			c.Next() | 			c.Next() | ||||||
| 			return | 			return | ||||||
| 		default: | 		default: | ||||||
| 			_, exists := m.broker.GetService(cookie.Provider) | 			_, exists := m.Broker.GetService(cookie.Provider) | ||||||
|  |  | ||||||
| 			if !exists { | 			if !exists { | ||||||
| 				log.Debug().Msg("OAuth provider from session cookie not found") | 				log.Debug().Msg("OAuth provider from session cookie not found") | ||||||
| 				m.auth.DeleteSessionCookie(c) | 				m.Auth.DeleteSessionCookie(c) | ||||||
| 				goto basic | 				goto basic | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			if !m.auth.IsEmailWhitelisted(cookie.Email) { | 			if !m.Auth.IsEmailWhitelisted(cookie.Email) { | ||||||
| 				log.Debug().Msg("Email from session cookie not whitelisted") | 				log.Debug().Msg("Email from session cookie not whitelisted") | ||||||
| 				m.auth.DeleteSessionCookie(c) | 				m.Auth.DeleteSessionCookie(c) | ||||||
| 				goto basic | 				goto basic | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| @@ -103,7 +103,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { | |||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 	basic: | 	basic: | ||||||
| 		basic := m.auth.GetBasicAuth(c) | 		basic := m.Auth.GetBasicAuth(c) | ||||||
|  |  | ||||||
| 		if basic == nil { | 		if basic == nil { | ||||||
| 			log.Debug().Msg("No basic auth provided") | 			log.Debug().Msg("No basic auth provided") | ||||||
| @@ -111,15 +111,15 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { | |||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		userSearch := m.auth.SearchUser(basic.Username) | 		userSearch := m.Auth.SearchUser(basic.Username) | ||||||
|  |  | ||||||
| 		if userSearch.Type == "unknown" || userSearch.Type == "error" { | 		if userSearch.Type == "unknown" { | ||||||
| 			log.Debug().Msg("User from basic auth not found") | 			log.Debug().Msg("User from basic auth not found") | ||||||
| 			c.Next() | 			c.Next() | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if !m.auth.VerifyUser(userSearch, basic.Password) { | 		if !m.Auth.VerifyUser(userSearch, basic.Password) { | ||||||
| 			log.Debug().Msg("Invalid password for basic auth user") | 			log.Debug().Msg("Invalid password for basic auth user") | ||||||
| 			c.Next() | 			c.Next() | ||||||
| 			return | 			return | ||||||
| @@ -129,12 +129,12 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { | |||||||
| 		case "local": | 		case "local": | ||||||
| 			log.Debug().Msg("Basic auth user is local") | 			log.Debug().Msg("Basic auth user is local") | ||||||
|  |  | ||||||
| 			user := m.auth.GetLocalUser(basic.Username) | 			user := m.Auth.GetLocalUser(basic.Username) | ||||||
|  |  | ||||||
| 			c.Set("context", &config.UserContext{ | 			c.Set("context", &config.UserContext{ | ||||||
| 				Username:    user.Username, | 				Username:    user.Username, | ||||||
| 				Name:        utils.Capitalize(user.Username), | 				Name:        utils.Capitalize(user.Username), | ||||||
| 				Email:       fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.config.CookieDomain), | 				Email:       fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.Config.RootDomain), | ||||||
| 				Provider:    "basic", | 				Provider:    "basic", | ||||||
| 				IsLoggedIn:  true, | 				IsLoggedIn:  true, | ||||||
| 				TotpEnabled: user.TotpSecret != "", | 				TotpEnabled: user.TotpSecret != "", | ||||||
| @@ -146,7 +146,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { | |||||||
| 			c.Set("context", &config.UserContext{ | 			c.Set("context", &config.UserContext{ | ||||||
| 				Username:   basic.Username, | 				Username:   basic.Username, | ||||||
| 				Name:       utils.Capitalize(basic.Username), | 				Name:       utils.Capitalize(basic.Username), | ||||||
| 				Email:      fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.config.CookieDomain), | 				Email:      fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.Config.RootDomain), | ||||||
| 				Provider:   "basic", | 				Provider:   "basic", | ||||||
| 				IsLoggedIn: true, | 				IsLoggedIn: true, | ||||||
| 			}) | 			}) | ||||||
|   | |||||||
| @@ -11,8 +11,8 @@ import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| type UIMiddleware struct { | type UIMiddleware struct { | ||||||
| 	uiFs         fs.FS | 	UIFS         fs.FS | ||||||
| 	uiFileServer http.Handler | 	UIFileServer http.Handler | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewUIMiddleware() *UIMiddleware { | func NewUIMiddleware() *UIMiddleware { | ||||||
| @@ -26,8 +26,8 @@ func (m *UIMiddleware) Init() error { | |||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	m.uiFs = ui | 	m.UIFS = ui | ||||||
| 	m.uiFileServer = http.FileServer(http.FS(ui)) | 	m.UIFileServer = http.FileServer(http.FS(ui)) | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| @@ -42,13 +42,13 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc { | |||||||
| 			c.Next() | 			c.Next() | ||||||
| 			return | 			return | ||||||
| 		default: | 		default: | ||||||
| 			_, err := fs.Stat(m.uiFs, strings.TrimPrefix(c.Request.URL.Path, "/")) | 			_, err := fs.Stat(m.UIFS, strings.TrimPrefix(c.Request.URL.Path, "/")) | ||||||
|  |  | ||||||
| 			if os.IsNotExist(err) { | 			if os.IsNotExist(err) { | ||||||
| 				c.Request.URL.Path = "/" | 				c.Request.URL.Path = "/" | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			m.uiFileServer.ServeHTTP(c.Writer, c.Request) | 			m.UIFileServer.ServeHTTP(c.Writer, c.Request) | ||||||
| 			c.Abort() | 			c.Abort() | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -28,28 +28,28 @@ type AuthServiceConfig struct { | |||||||
| 	OauthWhitelist    string | 	OauthWhitelist    string | ||||||
| 	SessionExpiry     int | 	SessionExpiry     int | ||||||
| 	SecureCookie      bool | 	SecureCookie      bool | ||||||
| 	CookieDomain      string | 	RootDomain        string | ||||||
| 	LoginTimeout      int | 	LoginTimeout      int | ||||||
| 	LoginMaxRetries   int | 	LoginMaxRetries   int | ||||||
| 	SessionCookieName string | 	SessionCookieName string | ||||||
| } | } | ||||||
|  |  | ||||||
| type AuthService struct { | type AuthService struct { | ||||||
| 	config        AuthServiceConfig | 	Config        AuthServiceConfig | ||||||
| 	docker        *DockerService | 	Docker        *DockerService | ||||||
| 	loginAttempts map[string]*LoginAttempt | 	LoginAttempts map[string]*LoginAttempt | ||||||
| 	loginMutex    sync.RWMutex | 	LoginMutex    sync.RWMutex | ||||||
| 	ldap          *LdapService | 	LDAP          *LdapService | ||||||
| 	database      *gorm.DB | 	Database      *gorm.DB | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, database *gorm.DB) *AuthService { | func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, database *gorm.DB) *AuthService { | ||||||
| 	return &AuthService{ | 	return &AuthService{ | ||||||
| 		config:        config, | 		Config:        config, | ||||||
| 		docker:        docker, | 		Docker:        docker, | ||||||
| 		loginAttempts: make(map[string]*LoginAttempt), | 		LoginAttempts: make(map[string]*LoginAttempt), | ||||||
| 		ldap:          ldap, | 		LDAP:          ldap, | ||||||
| 		database:      database, | 		Database:      database, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -65,14 +65,12 @@ func (auth *AuthService) SearchUser(username string) config.UserSearch { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if auth.ldap != nil { | 	if auth.LDAP != nil { | ||||||
| 		userDN, err := auth.ldap.Search(username) | 		userDN, err := auth.LDAP.Search(username) | ||||||
|  |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP") | 			log.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP") | ||||||
| 			return config.UserSearch{ | 			return config.UserSearch{} | ||||||
| 				Type: "error", |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		return config.UserSearch{ | 		return config.UserSearch{ | ||||||
| @@ -92,14 +90,14 @@ func (auth *AuthService) VerifyUser(search config.UserSearch, password string) b | |||||||
| 		user := auth.GetLocalUser(search.Username) | 		user := auth.GetLocalUser(search.Username) | ||||||
| 		return auth.CheckPassword(user, password) | 		return auth.CheckPassword(user, password) | ||||||
| 	case "ldap": | 	case "ldap": | ||||||
| 		if auth.ldap != nil { | 		if auth.LDAP != nil { | ||||||
| 			err := auth.ldap.Bind(search.Username, password) | 			err := auth.LDAP.Bind(search.Username, password) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP") | 				log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP") | ||||||
| 				return false | 				return false | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			err = auth.ldap.Bind(auth.ldap.Config.BindDN, auth.ldap.Config.BindPassword) | 			err = auth.LDAP.Bind(auth.LDAP.Config.BindDN, auth.LDAP.Config.BindPassword) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				log.Error().Err(err).Msg("Failed to rebind with service account after user authentication") | 				log.Error().Err(err).Msg("Failed to rebind with service account after user authentication") | ||||||
| 				return false | 				return false | ||||||
| @@ -117,7 +115,7 @@ func (auth *AuthService) VerifyUser(search config.UserSearch, password string) b | |||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) GetLocalUser(username string) config.User { | func (auth *AuthService) GetLocalUser(username string) config.User { | ||||||
| 	for _, user := range auth.config.Users { | 	for _, user := range auth.Config.Users { | ||||||
| 		if user.Username == username { | 		if user.Username == username { | ||||||
| 			return user | 			return user | ||||||
| 		} | 		} | ||||||
| @@ -132,14 +130,14 @@ func (auth *AuthService) CheckPassword(user config.User, password string) bool { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { | func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { | ||||||
| 	auth.loginMutex.RLock() | 	auth.LoginMutex.RLock() | ||||||
| 	defer auth.loginMutex.RUnlock() | 	defer auth.LoginMutex.RUnlock() | ||||||
|  |  | ||||||
| 	if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 { | 	if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 { | ||||||
| 		return false, 0 | 		return false, 0 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	attempt, exists := auth.loginAttempts[identifier] | 	attempt, exists := auth.LoginAttempts[identifier] | ||||||
| 	if !exists { | 	if !exists { | ||||||
| 		return false, 0 | 		return false, 0 | ||||||
| 	} | 	} | ||||||
| @@ -153,17 +151,17 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) { | func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) { | ||||||
| 	if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 { | 	if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 { | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	auth.loginMutex.Lock() | 	auth.LoginMutex.Lock() | ||||||
| 	defer auth.loginMutex.Unlock() | 	defer auth.LoginMutex.Unlock() | ||||||
|  |  | ||||||
| 	attempt, exists := auth.loginAttempts[identifier] | 	attempt, exists := auth.LoginAttempts[identifier] | ||||||
| 	if !exists { | 	if !exists { | ||||||
| 		attempt = &LoginAttempt{} | 		attempt = &LoginAttempt{} | ||||||
| 		auth.loginAttempts[identifier] = attempt | 		auth.LoginAttempts[identifier] = attempt | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	attempt.LastAttempt = time.Now() | 	attempt.LastAttempt = time.Now() | ||||||
| @@ -176,14 +174,14 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) { | |||||||
|  |  | ||||||
| 	attempt.FailedAttempts++ | 	attempt.FailedAttempts++ | ||||||
|  |  | ||||||
| 	if attempt.FailedAttempts >= auth.config.LoginMaxRetries { | 	if attempt.FailedAttempts >= auth.Config.LoginMaxRetries { | ||||||
| 		attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second) | 		attempt.LockedUntil = time.Now().Add(time.Duration(auth.Config.LoginTimeout) * time.Second) | ||||||
| 		log.Warn().Str("identifier", identifier).Int("timeout", auth.config.LoginTimeout).Msg("Account locked due to too many failed login attempts") | 		log.Warn().Str("identifier", identifier).Int("timeout", auth.Config.LoginTimeout).Msg("Account locked due to too many failed login attempts") | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) IsEmailWhitelisted(email string) bool { | func (auth *AuthService) IsEmailWhitelisted(email string) bool { | ||||||
| 	return utils.CheckFilter(auth.config.OauthWhitelist, email) | 	return utils.CheckFilter(auth.Config.OauthWhitelist, email) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.SessionCookie) error { | func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.SessionCookie) error { | ||||||
| @@ -198,7 +196,7 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio | |||||||
| 	if data.TotpPending { | 	if data.TotpPending { | ||||||
| 		expiry = 3600 | 		expiry = 3600 | ||||||
| 	} else { | 	} else { | ||||||
| 		expiry = auth.config.SessionExpiry | 		expiry = auth.Config.SessionExpiry | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	session := model.Session{ | 	session := model.Session{ | ||||||
| @@ -212,37 +210,37 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio | |||||||
| 		Expiry:      time.Now().Add(time.Duration(expiry) * time.Second).Unix(), | 		Expiry:      time.Now().Add(time.Duration(expiry) * time.Second).Unix(), | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	err = auth.database.Create(&session).Error | 	err = auth.Database.Create(&session).Error | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true) | 	c.SetCookie(auth.Config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.Config.RootDomain), auth.Config.SecureCookie, true) | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error { | func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error { | ||||||
| 	cookie, err := c.Cookie(auth.config.SessionCookieName) | 	cookie, err := c.Cookie(auth.Config.SessionCookieName) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	res := auth.database.Unscoped().Where("uuid = ?", cookie).Delete(&model.Session{}) | 	res := auth.Database.Unscoped().Where("uuid = ?", cookie).Delete(&model.Session{}) | ||||||
|  |  | ||||||
| 	if res.Error != nil { | 	if res.Error != nil { | ||||||
| 		return res.Error | 		return res.Error | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.SetCookie(auth.config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true) | 	c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.RootDomain), auth.Config.SecureCookie, true) | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, error) { | func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, error) { | ||||||
| 	cookie, err := c.Cookie(auth.config.SessionCookieName) | 	cookie, err := c.Cookie(auth.Config.SessionCookieName) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return config.SessionCookie{}, err | 		return config.SessionCookie{}, err | ||||||
| @@ -250,7 +248,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, | |||||||
|  |  | ||||||
| 	var session model.Session | 	var session model.Session | ||||||
|  |  | ||||||
| 	res := auth.database.Unscoped().Where("uuid = ?", cookie).First(&session) | 	res := auth.Database.Unscoped().Where("uuid = ?", cookie).First(&session) | ||||||
|  |  | ||||||
| 	if res.Error != nil { | 	if res.Error != nil { | ||||||
| 		return config.SessionCookie{}, res.Error | 		return config.SessionCookie{}, res.Error | ||||||
| @@ -263,7 +261,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, | |||||||
| 	currentTime := time.Now().Unix() | 	currentTime := time.Now().Unix() | ||||||
|  |  | ||||||
| 	if currentTime > session.Expiry { | 	if currentTime > session.Expiry { | ||||||
| 		res := auth.database.Unscoped().Where("uuid = ?", session.UUID).Delete(&model.Session{}) | 		res := auth.Database.Unscoped().Where("uuid = ?", session.UUID).Delete(&model.Session{}) | ||||||
| 		if res.Error != nil { | 		if res.Error != nil { | ||||||
| 			log.Error().Err(res.Error).Msg("Failed to delete expired session") | 			log.Error().Err(res.Error).Msg("Failed to delete expired session") | ||||||
| 		} | 		} | ||||||
| @@ -282,10 +280,10 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, | |||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) UserAuthConfigured() bool { | func (auth *AuthService) UserAuthConfigured() bool { | ||||||
| 	return len(auth.config.Users) > 0 || auth.ldap != nil | 	return len(auth.Config.Users) > 0 || auth.LDAP != nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, labels config.App) bool { | func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, labels config.AppLabels) bool { | ||||||
| 	if context.OAuth { | 	if context.OAuth { | ||||||
| 		log.Debug().Msg("Checking OAuth whitelist") | 		log.Debug().Msg("Checking OAuth whitelist") | ||||||
| 		return utils.CheckFilter(labels.OAuth.Whitelist, context.Email) | 		return utils.CheckFilter(labels.OAuth.Whitelist, context.Email) | ||||||
| @@ -322,7 +320,7 @@ func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserConte | |||||||
| 	return false | 	return false | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) { | func (auth *AuthService) IsAuthEnabled(uri string, path config.PathLabels) (bool, error) { | ||||||
| 	// Check for block list | 	// Check for block list | ||||||
| 	if path.Block != "" { | 	if path.Block != "" { | ||||||
| 		regex, err := regexp.Compile(path.Block) | 		regex, err := regexp.Compile(path.Block) | ||||||
| @@ -364,7 +362,7 @@ func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { | func (auth *AuthService) CheckIP(labels config.IPLabels, ip string) bool { | ||||||
| 	for _, blocked := range labels.Block { | 	for _, blocked := range labels.Block { | ||||||
| 		res, err := utils.FilterIP(blocked, ip) | 		res, err := utils.FilterIP(blocked, ip) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| @@ -398,7 +396,7 @@ func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { | |||||||
| 	return true | 	return true | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) IsBypassedIP(labels config.AppIP, ip string) bool { | func (auth *AuthService) IsBypassedIP(labels config.IPLabels, ip string) bool { | ||||||
| 	for _, bypassed := range labels.Bypass { | 	for _, bypassed := range labels.Bypass { | ||||||
| 		res, err := utils.FilterIP(bypassed, ip) | 		res, err := utils.FilterIP(bypassed, ip) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|   | |||||||
| @@ -16,18 +16,18 @@ type DatabaseServiceConfig struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| type DatabaseService struct { | type DatabaseService struct { | ||||||
| 	config   DatabaseServiceConfig | 	Config   DatabaseServiceConfig | ||||||
| 	database *gorm.DB | 	Database *gorm.DB | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewDatabaseService(config DatabaseServiceConfig) *DatabaseService { | func NewDatabaseService(config DatabaseServiceConfig) *DatabaseService { | ||||||
| 	return &DatabaseService{ | 	return &DatabaseService{ | ||||||
| 		config: config, | 		Config: config, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (ds *DatabaseService) Init() error { | func (ds *DatabaseService) Init() error { | ||||||
| 	gormDB, err := gorm.Open(sqlite.Open(ds.config.DatabasePath), &gorm.Config{}) | 	gormDB, err := gorm.Open(sqlite.Open(ds.Config.DatabasePath), &gorm.Config{}) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| @@ -47,7 +47,7 @@ func (ds *DatabaseService) Init() error { | |||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	ds.database = gormDB | 	ds.Database = gormDB | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -74,5 +74,5 @@ func (ds *DatabaseService) migrateDatabase(sqlDB *sql.DB) error { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (ds *DatabaseService) GetDatabase() *gorm.DB { | func (ds *DatabaseService) GetDatabase() *gorm.DB { | ||||||
| 	return ds.database | 	return ds.Database | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ import ( | |||||||
| 	"context" | 	"context" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"tinyauth/internal/config" | 	"tinyauth/internal/config" | ||||||
| 	"tinyauth/internal/utils/decoders" | 	"tinyauth/internal/utils" | ||||||
|  |  | ||||||
| 	container "github.com/docker/docker/api/types/container" | 	container "github.com/docker/docker/api/types/container" | ||||||
| 	"github.com/docker/docker/client" | 	"github.com/docker/docker/client" | ||||||
| @@ -12,8 +12,8 @@ import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| type DockerService struct { | type DockerService struct { | ||||||
| 	client  *client.Client | 	Client  *client.Client | ||||||
| 	context context.Context | 	Context context.Context | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewDockerService() *DockerService { | func NewDockerService() *DockerService { | ||||||
| @@ -29,13 +29,13 @@ func (docker *DockerService) Init() error { | |||||||
| 	ctx := context.Background() | 	ctx := context.Background() | ||||||
| 	client.NegotiateAPIVersion(ctx) | 	client.NegotiateAPIVersion(ctx) | ||||||
|  |  | ||||||
| 	docker.client = client | 	docker.Client = client | ||||||
| 	docker.context = ctx | 	docker.Context = ctx | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (docker *DockerService) GetContainers() ([]container.Summary, error) { | func (docker *DockerService) GetContainers() ([]container.Summary, error) { | ||||||
| 	containers, err := docker.client.ContainerList(docker.context, container.ListOptions{}) | 	containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{}) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| @@ -43,7 +43,7 @@ func (docker *DockerService) GetContainers() ([]container.Summary, error) { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (docker *DockerService) InspectContainer(containerId string) (container.InspectResponse, error) { | func (docker *DockerService) InspectContainer(containerId string) (container.InspectResponse, error) { | ||||||
| 	inspect, err := docker.client.ContainerInspect(docker.context, containerId) | 	inspect, err := docker.Client.ContainerInspect(docker.Context, containerId) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return container.InspectResponse{}, err | 		return container.InspectResponse{}, err | ||||||
| 	} | 	} | ||||||
| @@ -51,38 +51,38 @@ func (docker *DockerService) InspectContainer(containerId string) (container.Ins | |||||||
| } | } | ||||||
|  |  | ||||||
| func (docker *DockerService) DockerConnected() bool { | func (docker *DockerService) DockerConnected() bool { | ||||||
| 	_, err := docker.client.Ping(docker.context) | 	_, err := docker.Client.Ping(docker.Context) | ||||||
| 	return err == nil | 	return err == nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (docker *DockerService) GetLabels(appDomain string) (config.App, error) { | func (docker *DockerService) GetLabels(app string, domain string) (config.AppLabels, error) { | ||||||
| 	isConnected := docker.DockerConnected() | 	isConnected := docker.DockerConnected() | ||||||
|  |  | ||||||
| 	if !isConnected { | 	if !isConnected { | ||||||
| 		log.Debug().Msg("Docker not connected, returning empty labels") | 		log.Debug().Msg("Docker not connected, returning empty labels") | ||||||
| 		return config.App{}, nil | 		return config.AppLabels{}, nil | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	containers, err := docker.GetContainers() | 	containers, err := docker.GetContainers() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return config.App{}, err | 		return config.AppLabels{}, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for _, ctr := range containers { | 	for _, container := range containers { | ||||||
| 		inspect, err := docker.InspectContainer(ctr.ID) | 		inspect, err := docker.InspectContainer(container.ID) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Warn().Str("id", ctr.ID).Err(err).Msg("Error inspecting container, skipping") | 			log.Warn().Str("id", container.ID).Err(err).Msg("Error inspecting container, skipping") | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		labels, err := decoders.DecodeLabels(inspect.Config.Labels) | 		labels, err := utils.GetLabels(inspect.Config.Labels) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Warn().Str("id", ctr.ID).Err(err).Msg("Error getting container labels, skipping") | 			log.Warn().Str("id", container.ID).Err(err).Msg("Error getting container labels, skipping") | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		for appName, appLabels := range labels.Apps { | 		for appName, appLabels := range labels.Apps { | ||||||
| 			if appLabels.Config.Domain == appDomain { | 			if appLabels.Config.Domain == domain { | ||||||
| 				log.Debug().Str("id", inspect.ID).Msg("Found matching container by domain") | 				log.Debug().Str("id", inspect.ID).Msg("Found matching container by domain") | ||||||
| 				return appLabels, nil | 				return appLabels, nil | ||||||
| 			} | 			} | ||||||
| @@ -95,5 +95,5 @@ func (docker *DockerService) GetLabels(appDomain string) (config.App, error) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	log.Debug().Msg("No matching container found, returning empty labels") | 	log.Debug().Msg("No matching container found, returning empty labels") | ||||||
| 	return config.App{}, nil | 	return config.AppLabels{}, nil | ||||||
| } | } | ||||||
|   | |||||||
| @@ -16,17 +16,17 @@ import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| type GenericOAuthService struct { | type GenericOAuthService struct { | ||||||
| 	config             oauth2.Config | 	Config             oauth2.Config | ||||||
| 	context            context.Context | 	Context            context.Context | ||||||
| 	token              *oauth2.Token | 	Token              *oauth2.Token | ||||||
| 	verifier           string | 	Verifier           string | ||||||
| 	insecureSkipVerify bool | 	InsecureSkipVerify bool | ||||||
| 	userinfoUrl        string | 	UserinfoURL        string | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthService { | func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthService { | ||||||
| 	return &GenericOAuthService{ | 	return &GenericOAuthService{ | ||||||
| 		config: oauth2.Config{ | 		Config: oauth2.Config{ | ||||||
| 			ClientID:     config.ClientID, | 			ClientID:     config.ClientID, | ||||||
| 			ClientSecret: config.ClientSecret, | 			ClientSecret: config.ClientSecret, | ||||||
| 			RedirectURL:  config.RedirectURL, | 			RedirectURL:  config.RedirectURL, | ||||||
| @@ -36,15 +36,15 @@ func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthServi | |||||||
| 				TokenURL: config.TokenURL, | 				TokenURL: config.TokenURL, | ||||||
| 			}, | 			}, | ||||||
| 		}, | 		}, | ||||||
| 		insecureSkipVerify: config.InsecureSkipVerify, | 		InsecureSkipVerify: config.InsecureSkipVerify, | ||||||
| 		userinfoUrl:        config.UserinfoURL, | 		UserinfoURL:        config.UserinfoURL, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (generic *GenericOAuthService) Init() error { | func (generic *GenericOAuthService) Init() error { | ||||||
| 	transport := &http.Transport{ | 	transport := &http.Transport{ | ||||||
| 		TLSClientConfig: &tls.Config{ | 		TLSClientConfig: &tls.Config{ | ||||||
| 			InsecureSkipVerify: generic.insecureSkipVerify, | 			InsecureSkipVerify: generic.InsecureSkipVerify, | ||||||
| 			MinVersion:         tls.VersionTLS12, | 			MinVersion:         tls.VersionTLS12, | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| @@ -58,8 +58,8 @@ func (generic *GenericOAuthService) Init() error { | |||||||
| 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) | 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) | ||||||
| 	verifier := oauth2.GenerateVerifier() | 	verifier := oauth2.GenerateVerifier() | ||||||
|  |  | ||||||
| 	generic.context = ctx | 	generic.Context = ctx | ||||||
| 	generic.verifier = verifier | 	generic.Verifier = verifier | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -74,26 +74,26 @@ func (generic *GenericOAuthService) GenerateState() string { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (generic *GenericOAuthService) GetAuthURL(state string) string { | func (generic *GenericOAuthService) GetAuthURL(state string) string { | ||||||
| 	return generic.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(generic.verifier)) | 	return generic.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(generic.Verifier)) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (generic *GenericOAuthService) VerifyCode(code string) error { | func (generic *GenericOAuthService) VerifyCode(code string) error { | ||||||
| 	token, err := generic.config.Exchange(generic.context, code, oauth2.VerifierOption(generic.verifier)) | 	token, err := generic.Config.Exchange(generic.Context, code, oauth2.VerifierOption(generic.Verifier)) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	generic.token = token | 	generic.Token = token | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (generic *GenericOAuthService) Userinfo() (config.Claims, error) { | func (generic *GenericOAuthService) Userinfo() (config.Claims, error) { | ||||||
| 	var user config.Claims | 	var user config.Claims | ||||||
|  |  | ||||||
| 	client := generic.config.Client(generic.context, generic.token) | 	client := generic.Config.Client(generic.Context, generic.Token) | ||||||
|  |  | ||||||
| 	res, err := client.Get(generic.userinfoUrl) | 	res, err := client.Get(generic.UserinfoURL) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return user, err | 		return user, err | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -29,15 +29,15 @@ type GithubUserInfoResponse struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| type GithubOAuthService struct { | type GithubOAuthService struct { | ||||||
| 	config   oauth2.Config | 	Config   oauth2.Config | ||||||
| 	context  context.Context | 	Context  context.Context | ||||||
| 	token    *oauth2.Token | 	Token    *oauth2.Token | ||||||
| 	verifier string | 	Verifier string | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService { | func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService { | ||||||
| 	return &GithubOAuthService{ | 	return &GithubOAuthService{ | ||||||
| 		config: oauth2.Config{ | 		Config: oauth2.Config{ | ||||||
| 			ClientID:     config.ClientID, | 			ClientID:     config.ClientID, | ||||||
| 			ClientSecret: config.ClientSecret, | 			ClientSecret: config.ClientSecret, | ||||||
| 			RedirectURL:  config.RedirectURL, | 			RedirectURL:  config.RedirectURL, | ||||||
| @@ -53,8 +53,8 @@ func (github *GithubOAuthService) Init() error { | |||||||
| 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) | 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) | ||||||
| 	verifier := oauth2.GenerateVerifier() | 	verifier := oauth2.GenerateVerifier() | ||||||
|  |  | ||||||
| 	github.context = ctx | 	github.Context = ctx | ||||||
| 	github.verifier = verifier | 	github.Verifier = verifier | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -69,24 +69,24 @@ func (github *GithubOAuthService) GenerateState() string { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (github *GithubOAuthService) GetAuthURL(state string) string { | func (github *GithubOAuthService) GetAuthURL(state string) string { | ||||||
| 	return github.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.verifier)) | 	return github.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.Verifier)) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (github *GithubOAuthService) VerifyCode(code string) error { | func (github *GithubOAuthService) VerifyCode(code string) error { | ||||||
| 	token, err := github.config.Exchange(github.context, code, oauth2.VerifierOption(github.verifier)) | 	token, err := github.Config.Exchange(github.Context, code, oauth2.VerifierOption(github.Verifier)) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	github.token = token | 	github.Token = token | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (github *GithubOAuthService) Userinfo() (config.Claims, error) { | func (github *GithubOAuthService) Userinfo() (config.Claims, error) { | ||||||
| 	var user config.Claims | 	var user config.Claims | ||||||
|  |  | ||||||
| 	client := github.config.Client(github.context, github.token) | 	client := github.Config.Client(github.Context, github.Token) | ||||||
|  |  | ||||||
| 	req, err := http.NewRequest("GET", "https://api.github.com/user", nil) | 	req, err := http.NewRequest("GET", "https://api.github.com/user", nil) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|   | |||||||
| @@ -24,15 +24,15 @@ type GoogleUserInfoResponse struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| type GoogleOAuthService struct { | type GoogleOAuthService struct { | ||||||
| 	config   oauth2.Config | 	Config   oauth2.Config | ||||||
| 	context  context.Context | 	Context  context.Context | ||||||
| 	token    *oauth2.Token | 	Token    *oauth2.Token | ||||||
| 	verifier string | 	Verifier string | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService { | func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService { | ||||||
| 	return &GoogleOAuthService{ | 	return &GoogleOAuthService{ | ||||||
| 		config: oauth2.Config{ | 		Config: oauth2.Config{ | ||||||
| 			ClientID:     config.ClientID, | 			ClientID:     config.ClientID, | ||||||
| 			ClientSecret: config.ClientSecret, | 			ClientSecret: config.ClientSecret, | ||||||
| 			RedirectURL:  config.RedirectURL, | 			RedirectURL:  config.RedirectURL, | ||||||
| @@ -48,8 +48,8 @@ func (google *GoogleOAuthService) Init() error { | |||||||
| 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) | 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) | ||||||
| 	verifier := oauth2.GenerateVerifier() | 	verifier := oauth2.GenerateVerifier() | ||||||
|  |  | ||||||
| 	google.context = ctx | 	google.Context = ctx | ||||||
| 	google.verifier = verifier | 	google.Verifier = verifier | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -64,24 +64,24 @@ func (oauth *GoogleOAuthService) GenerateState() string { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (google *GoogleOAuthService) GetAuthURL(state string) string { | func (google *GoogleOAuthService) GetAuthURL(state string) string { | ||||||
| 	return google.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(google.verifier)) | 	return google.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(google.Verifier)) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (google *GoogleOAuthService) VerifyCode(code string) error { | func (google *GoogleOAuthService) VerifyCode(code string) error { | ||||||
| 	token, err := google.config.Exchange(google.context, code, oauth2.VerifierOption(google.verifier)) | 	token, err := google.Config.Exchange(google.Context, code, oauth2.VerifierOption(google.Verifier)) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	google.token = token | 	google.Token = token | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (google *GoogleOAuthService) Userinfo() (config.Claims, error) { | func (google *GoogleOAuthService) Userinfo() (config.Claims, error) { | ||||||
| 	var user config.Claims | 	var user config.Claims | ||||||
|  |  | ||||||
| 	client := google.config.Client(google.context, google.token) | 	client := google.Config.Client(google.Context, google.Token) | ||||||
|  |  | ||||||
| 	res, err := client.Get("https://www.googleapis.com/userinfo/v2/me") | 	res, err := client.Get("https://www.googleapis.com/userinfo/v2/me") | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ import ( | |||||||
| 	"context" | 	"context" | ||||||
| 	"crypto/tls" | 	"crypto/tls" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"sync" |  | ||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
| 	"github.com/cenkalti/backoff/v5" | 	"github.com/cenkalti/backoff/v5" | ||||||
| @@ -22,9 +21,8 @@ type LdapServiceConfig struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| type LdapService struct { | type LdapService struct { | ||||||
| 	Config LdapServiceConfig // exported so as the auth service can use it | 	Config LdapServiceConfig | ||||||
| 	conn   *ldapgo.Conn | 	Conn   *ldapgo.Conn | ||||||
| 	mutex  sync.RWMutex |  | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewLdapService(config LdapServiceConfig) *LdapService { | func NewLdapService(config LdapServiceConfig) *LdapService { | ||||||
| @@ -57,9 +55,6 @@ func (ldap *LdapService) Init() error { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (ldap *LdapService) connect() (*ldapgo.Conn, error) { | func (ldap *LdapService) connect() (*ldapgo.Conn, error) { | ||||||
| 	ldap.mutex.Lock() |  | ||||||
| 	defer ldap.mutex.Unlock() |  | ||||||
|  |  | ||||||
| 	conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{ | 	conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{ | ||||||
| 		InsecureSkipVerify: ldap.Config.Insecure, | 		InsecureSkipVerify: ldap.Config.Insecure, | ||||||
| 		MinVersion:         tls.VersionTLS12, | 		MinVersion:         tls.VersionTLS12, | ||||||
| @@ -74,7 +69,7 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Set and return the connection | 	// Set and return the connection | ||||||
| 	ldap.conn = conn | 	ldap.Conn = conn | ||||||
| 	return conn, nil | 	return conn, nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -91,10 +86,7 @@ func (ldap *LdapService) Search(username string) (string, error) { | |||||||
| 		nil, | 		nil, | ||||||
| 	) | 	) | ||||||
|  |  | ||||||
| 	ldap.mutex.Lock() | 	searchResult, err := ldap.Conn.Search(searchRequest) | ||||||
| 	defer ldap.mutex.Unlock() |  | ||||||
|  |  | ||||||
| 	searchResult, err := ldap.conn.Search(searchRequest) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return "", err | 		return "", err | ||||||
| 	} | 	} | ||||||
| @@ -108,9 +100,7 @@ func (ldap *LdapService) Search(username string) (string, error) { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (ldap *LdapService) Bind(userDN string, password string) error { | func (ldap *LdapService) Bind(userDN string, password string) error { | ||||||
| 	ldap.mutex.Lock() | 	err := ldap.Conn.Bind(userDN, password) | ||||||
| 	defer ldap.mutex.Unlock() |  | ||||||
| 	err := ldap.conn.Bind(userDN, password) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| @@ -128,9 +118,7 @@ func (ldap *LdapService) heartbeat() error { | |||||||
| 		nil, | 		nil, | ||||||
| 	) | 	) | ||||||
|  |  | ||||||
| 	ldap.mutex.Lock() | 	_, err := ldap.Conn.Search(searchRequest) | ||||||
| 	defer ldap.mutex.Unlock() |  | ||||||
| 	_, err := ldap.conn.Search(searchRequest) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| @@ -149,7 +137,7 @@ func (ldap *LdapService) reconnect() error { | |||||||
| 	exp.Reset() | 	exp.Reset() | ||||||
|  |  | ||||||
| 	operation := func() (*ldapgo.Conn, error) { | 	operation := func() (*ldapgo.Conn, error) { | ||||||
| 		ldap.conn.Close() | 		ldap.Conn.Close() | ||||||
| 		conn, err := ldap.connect() | 		conn, err := ldap.connect() | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			return nil, err | ||||||
|   | |||||||
| @@ -5,7 +5,6 @@ import ( | |||||||
| 	"tinyauth/internal/config" | 	"tinyauth/internal/config" | ||||||
|  |  | ||||||
| 	"github.com/rs/zerolog/log" | 	"github.com/rs/zerolog/log" | ||||||
| 	"golang.org/x/exp/slices" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| type OAuthService interface { | type OAuthService interface { | ||||||
| @@ -17,60 +16,59 @@ type OAuthService interface { | |||||||
| } | } | ||||||
|  |  | ||||||
| type OAuthBrokerService struct { | type OAuthBrokerService struct { | ||||||
| 	services map[string]OAuthService | 	Services map[string]OAuthService | ||||||
| 	configs  map[string]config.OAuthServiceConfig | 	Configs  map[string]config.OAuthServiceConfig | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService { | func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService { | ||||||
| 	return &OAuthBrokerService{ | 	return &OAuthBrokerService{ | ||||||
| 		services: make(map[string]OAuthService), | 		Services: make(map[string]OAuthService), | ||||||
| 		configs:  configs, | 		Configs:  configs, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (broker *OAuthBrokerService) Init() error { | func (broker *OAuthBrokerService) Init() error { | ||||||
| 	for name, cfg := range broker.configs { | 	for name, cfg := range broker.Configs { | ||||||
| 		switch name { | 		switch name { | ||||||
| 		case "github": | 		case "github": | ||||||
| 			service := NewGithubOAuthService(cfg) | 			service := NewGithubOAuthService(cfg) | ||||||
| 			broker.services[name] = service | 			broker.Services[name] = service | ||||||
| 		case "google": | 		case "google": | ||||||
| 			service := NewGoogleOAuthService(cfg) | 			service := NewGoogleOAuthService(cfg) | ||||||
| 			broker.services[name] = service | 			broker.Services[name] = service | ||||||
| 		default: | 		default: | ||||||
| 			service := NewGenericOAuthService(cfg) | 			service := NewGenericOAuthService(cfg) | ||||||
| 			broker.services[name] = service | 			broker.Services[name] = service | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for name, service := range broker.services { | 	for name, service := range broker.Services { | ||||||
| 		err := service.Init() | 		err := service.Init() | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Error().Err(err).Msgf("Failed to initialize OAuth service: %T", name) | 			log.Error().Err(err).Msgf("Failed to initialize OAuth service: %s", name) | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 		log.Info().Msgf("Initialized OAuth service: %T", name) | 		log.Info().Msgf("Initialized OAuth service: %s", name) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (broker *OAuthBrokerService) GetConfiguredServices() []string { | func (broker *OAuthBrokerService) GetConfiguredServices() []string { | ||||||
| 	services := make([]string, 0, len(broker.services)) | 	services := make([]string, 0, len(broker.Services)) | ||||||
| 	for name := range broker.services { | 	for name := range broker.Services { | ||||||
| 		services = append(services, name) | 		services = append(services, name) | ||||||
| 	} | 	} | ||||||
| 	slices.Sort(services) |  | ||||||
| 	return services | 	return services | ||||||
| } | } | ||||||
|  |  | ||||||
| func (broker *OAuthBrokerService) GetService(name string) (OAuthService, bool) { | func (broker *OAuthBrokerService) GetService(name string) (OAuthService, bool) { | ||||||
| 	service, exists := broker.services[name] | 	service, exists := broker.Services[name] | ||||||
| 	return service, exists | 	return service, exists | ||||||
| } | } | ||||||
|  |  | ||||||
| func (broker *OAuthBrokerService) GetUser(service string) (config.Claims, error) { | func (broker *OAuthBrokerService) GetUser(service string) (config.Claims, error) { | ||||||
| 	oauthService, exists := broker.services[service] | 	oauthService, exists := broker.Services[service] | ||||||
| 	if !exists { | 	if !exists { | ||||||
| 		return config.Claims{}, errors.New("oauth service not found") | 		return config.Claims{}, errors.New("oauth service not found") | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -8,38 +8,30 @@ import ( | |||||||
| 	"tinyauth/internal/config" | 	"tinyauth/internal/config" | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
|  |  | ||||||
| 	"github.com/rs/zerolog" | 	"github.com/rs/zerolog" | ||||||
| 	"github.com/weppos/publicsuffix-go/publicsuffix" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Get cookie domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) | // Get root domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) | ||||||
| func GetCookieDomain(u string) (string, error) { | func GetRootDomain(appUrl string) (string, error) { | ||||||
| 	parsed, err := url.Parse(u) | 	appUrlParsed, err := url.Parse(appUrl) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return "", err | 		return "", err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	host := parsed.Hostname() | 	host := appUrlParsed.Hostname() | ||||||
|  |  | ||||||
| 	if netIP := net.ParseIP(host); netIP != nil { | 	if netIP := net.ParseIP(host); netIP != nil { | ||||||
| 		return "", errors.New("IP addresses not allowed") | 		return "", errors.New("IP addresses are not allowed") | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	parts := strings.Split(host, ".") | 	urlParts := strings.Split(host, ".") | ||||||
|  |  | ||||||
| 	if len(parts) < 3 { | 	if len(urlParts) < 2 { | ||||||
| 		return "", errors.New("invalid app url, must be at least second level domain") | 		return "", errors.New("invalid domain, must be at least second level domain") | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	domain := strings.Join(parts[1:], ".") | 	return strings.Join(urlParts[1:], "."), nil | ||||||
|  |  | ||||||
| 	_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil) |  | ||||||
|  |  | ||||||
| 	if err != nil { |  | ||||||
| 		return "", errors.New("domain in public suffix list, cannot set cookies") |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return domain, nil |  | ||||||
| } | } | ||||||
|  |  | ||||||
| func ParseFileToLine(content string) string { | func ParseFileToLine(content string) string { | ||||||
| @@ -57,7 +49,6 @@ func ParseFileToLine(content string) string { | |||||||
| } | } | ||||||
|  |  | ||||||
| func Filter[T any](slice []T, test func(T) bool) (res []T) { | func Filter[T any](slice []T, test func(T) bool) (res []T) { | ||||||
| 	res = make([]T, 0) |  | ||||||
| 	for _, value := range slice { | 	for _, value := range slice { | ||||||
| 		if test(value) { | 		if test(value) { | ||||||
| 			res = append(res, value) | 			res = append(res, value) | ||||||
| @@ -97,13 +88,13 @@ func IsRedirectSafe(redirectURL string, domain string) bool { | |||||||
| 		return false | 		return false | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	cookieDomain, err := GetCookieDomain(redirectURL) | 	upper, err := GetRootDomain(redirectURL) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return false | 		return false | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if cookieDomain != domain { | 	if upper != domain { | ||||||
| 		return false | 		return false | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,202 +0,0 @@ | |||||||
| package utils_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/config" |  | ||||||
| 	"tinyauth/internal/utils" |  | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestGetRootDomain(t *testing.T) { |  | ||||||
| 	// Normal case |  | ||||||
| 	domain := "http://sub.tinyauth.app" |  | ||||||
| 	expected := "tinyauth.app" |  | ||||||
| 	result, err := utils.GetCookieDomain(domain) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Domain with multiple subdomains |  | ||||||
| 	domain = "http://b.c.tinyauth.app" |  | ||||||
| 	expected = "c.tinyauth.app" |  | ||||||
| 	result, err = utils.GetCookieDomain(domain) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Domain with no subdomain |  | ||||||
| 	domain = "http://tinyauth.app" |  | ||||||
| 	expected = "tinyauth.app" |  | ||||||
| 	_, err = utils.GetCookieDomain(domain) |  | ||||||
| 	assert.Error(t, err, "invalid app url, must be at least second level domain") |  | ||||||
|  |  | ||||||
| 	// Invalid domain (only TLD) |  | ||||||
| 	domain = "com" |  | ||||||
| 	_, err = utils.GetCookieDomain(domain) |  | ||||||
| 	assert.ErrorContains(t, err, "invalid app url, must be at least second level domain") |  | ||||||
|  |  | ||||||
| 	// IP address |  | ||||||
| 	domain = "http://10.10.10.10" |  | ||||||
| 	_, err = utils.GetCookieDomain(domain) |  | ||||||
| 	assert.ErrorContains(t, err, "IP addresses not allowed") |  | ||||||
|  |  | ||||||
| 	// Invalid URL |  | ||||||
| 	domain = "http://[::1]:namedport" |  | ||||||
| 	_, err = utils.GetCookieDomain(domain) |  | ||||||
| 	assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host") |  | ||||||
|  |  | ||||||
| 	// URL with scheme and path |  | ||||||
| 	domain = "https://sub.tinyauth.app/path" |  | ||||||
| 	expected = "tinyauth.app" |  | ||||||
| 	result, err = utils.GetCookieDomain(domain) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// URL with port |  | ||||||
| 	domain = "http://sub.tinyauth.app:8080" |  | ||||||
| 	expected = "tinyauth.app" |  | ||||||
| 	result, err = utils.GetCookieDomain(domain) |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Domain managed by ICANN |  | ||||||
| 	domain = "http://example.co.uk" |  | ||||||
| 	_, err = utils.GetCookieDomain(domain) |  | ||||||
| 	assert.Error(t, err, "domain in public suffix list, cannot set cookies") |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestParseFileToLine(t *testing.T) { |  | ||||||
| 	// Normal case |  | ||||||
| 	content := "user1\nuser2\nuser3" |  | ||||||
| 	expected := "user1,user2,user3" |  | ||||||
| 	result := utils.ParseFileToLine(content) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Case with empty lines and spaces |  | ||||||
| 	content = " user1 \n\n user2 \n user3 \n" |  | ||||||
| 	expected = "user1,user2,user3" |  | ||||||
| 	result = utils.ParseFileToLine(content) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Case with only empty lines |  | ||||||
| 	content = "\n\n\n" |  | ||||||
| 	expected = "" |  | ||||||
| 	result = utils.ParseFileToLine(content) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Case with single user |  | ||||||
| 	content = "singleuser" |  | ||||||
| 	expected = "singleuser" |  | ||||||
| 	result = utils.ParseFileToLine(content) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Case with trailing newline |  | ||||||
| 	content = "user1\nuser2\n" |  | ||||||
| 	expected = "user1,user2" |  | ||||||
| 	result = utils.ParseFileToLine(content) |  | ||||||
| 	assert.Equal(t, expected, result) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestFilter(t *testing.T) { |  | ||||||
| 	// Normal case |  | ||||||
| 	slice := []int{1, 2, 3, 4, 5} |  | ||||||
| 	testFunc := func(n int) bool { return n%2 == 0 } |  | ||||||
| 	expected := []int{2, 4} |  | ||||||
| 	result := utils.Filter(slice, testFunc) |  | ||||||
| 	assert.DeepEqual(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Case with no matches |  | ||||||
| 	slice = []int{1, 3, 5} |  | ||||||
| 	testFunc = func(n int) bool { return n%2 == 0 } |  | ||||||
| 	expected = []int{} |  | ||||||
| 	result = utils.Filter(slice, testFunc) |  | ||||||
| 	assert.DeepEqual(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Case with all matches |  | ||||||
| 	slice = []int{2, 4, 6} |  | ||||||
| 	testFunc = func(n int) bool { return n%2 == 0 } |  | ||||||
| 	expected = []int{2, 4, 6} |  | ||||||
| 	result = utils.Filter(slice, testFunc) |  | ||||||
| 	assert.DeepEqual(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Case with empty slice |  | ||||||
| 	slice = []int{} |  | ||||||
| 	testFunc = func(n int) bool { return n%2 == 0 } |  | ||||||
| 	expected = []int{} |  | ||||||
| 	result = utils.Filter(slice, testFunc) |  | ||||||
| 	assert.DeepEqual(t, expected, result) |  | ||||||
|  |  | ||||||
| 	// Case with different type (string) |  | ||||||
| 	sliceStr := []string{"apple", "banana", "cherry"} |  | ||||||
| 	testFuncStr := func(s string) bool { return len(s) > 5 } |  | ||||||
| 	expectedStr := []string{"banana", "cherry"} |  | ||||||
| 	resultStr := utils.Filter(sliceStr, testFuncStr) |  | ||||||
| 	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" |  | ||||||
|  |  | ||||||
| 	// Case with no subdomain |  | ||||||
| 	redirectURL := "http://example.com/welcome" |  | ||||||
| 	result := utils.IsRedirectSafe(redirectURL, domain) |  | ||||||
| 	assert.Equal(t, false, result) |  | ||||||
|  |  | ||||||
| 	// Case with different domain |  | ||||||
| 	redirectURL = "http://malicious.com/phishing" |  | ||||||
| 	result = utils.IsRedirectSafe(redirectURL, domain) |  | ||||||
| 	assert.Equal(t, false, result) |  | ||||||
|  |  | ||||||
| 	// Case with subdomain |  | ||||||
| 	redirectURL = "http://sub.example.com/page" |  | ||||||
| 	result = utils.IsRedirectSafe(redirectURL, domain) |  | ||||||
| 	assert.Equal(t, true, result) |  | ||||||
|  |  | ||||||
| 	// Case with empty redirect URL |  | ||||||
| 	redirectURL = "" |  | ||||||
| 	result = utils.IsRedirectSafe(redirectURL, domain) |  | ||||||
| 	assert.Equal(t, false, result) |  | ||||||
|  |  | ||||||
| 	// Case with invalid URL |  | ||||||
| 	redirectURL = "http://[::1]:namedport" |  | ||||||
| 	result = utils.IsRedirectSafe(redirectURL, domain) |  | ||||||
| 	assert.Equal(t, false, result) |  | ||||||
|  |  | ||||||
| 	// Case with URL having port |  | ||||||
| 	redirectURL = "http://sub.example.com:8080/page" |  | ||||||
| 	result = utils.IsRedirectSafe(redirectURL, domain) |  | ||||||
| 	assert.Equal(t, true, result) |  | ||||||
|  |  | ||||||
| 	// Case with URL having different subdomain |  | ||||||
| 	redirectURL = "http://another.example.com/page" |  | ||||||
| 	result = utils.IsRedirectSafe(redirectURL, domain) |  | ||||||
| 	assert.Equal(t, true, result) |  | ||||||
|  |  | ||||||
| 	// Case with URL having different TLD |  | ||||||
| 	redirectURL = "http://example.org/page" |  | ||||||
| 	result = utils.IsRedirectSafe(redirectURL, domain) |  | ||||||
| 	assert.Equal(t, false, result) |  | ||||||
| } |  | ||||||
| @@ -1,19 +0,0 @@ | |||||||
| package decoders |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"tinyauth/internal/config" |  | ||||||
|  |  | ||||||
| 	"github.com/traefik/paerser/parser" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func DecodeLabels(labels map[string]string) (config.Apps, error) { |  | ||||||
| 	var appLabels config.Apps |  | ||||||
|  |  | ||||||
| 	err := parser.Decode(labels, &appLabels, "tinyauth", "tinyauth.apps") |  | ||||||
|  |  | ||||||
| 	if err != nil { |  | ||||||
| 		return config.Apps{}, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return appLabels, nil |  | ||||||
| } |  | ||||||
| @@ -1,73 +0,0 @@ | |||||||
| package decoders_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"reflect" |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/config" |  | ||||||
| 	"tinyauth/internal/utils/decoders" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestDecodeLabels(t *testing.T) { |  | ||||||
| 	// Variables |  | ||||||
| 	expected := config.Apps{ |  | ||||||
| 		Apps: map[string]config.App{ |  | ||||||
| 			"foo": { |  | ||||||
| 				Config: config.AppConfig{ |  | ||||||
| 					Domain: "example.com", |  | ||||||
| 				}, |  | ||||||
| 				Users: config.AppUsers{ |  | ||||||
| 					Allow: "user1,user2", |  | ||||||
| 					Block: "user3", |  | ||||||
| 				}, |  | ||||||
| 				OAuth: config.AppOAuth{ |  | ||||||
| 					Whitelist: "somebody@example.com", |  | ||||||
| 					Groups:    "group3", |  | ||||||
| 				}, |  | ||||||
| 				IP: config.AppIP{ |  | ||||||
| 					Allow:  []string{"10.71.0.1/24", "10.71.0.2"}, |  | ||||||
| 					Block:  []string{"10.10.10.10", "10.0.0.0/24"}, |  | ||||||
| 					Bypass: []string{"192.168.1.1"}, |  | ||||||
| 				}, |  | ||||||
| 				Response: config.AppResponse{ |  | ||||||
| 					Headers: []string{"X-Foo=Bar", "X-Baz=Qux"}, |  | ||||||
| 					BasicAuth: config.AppBasicAuth{ |  | ||||||
| 						Username:     "admin", |  | ||||||
| 						Password:     "password", |  | ||||||
| 						PasswordFile: "/path/to/passwordfile", |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				Path: config.AppPath{ |  | ||||||
| 					Allow: "/public", |  | ||||||
| 					Block: "/private", |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 	test := map[string]string{ |  | ||||||
| 		"tinyauth.apps.foo.config.domain":                   "example.com", |  | ||||||
| 		"tinyauth.apps.foo.users.allow":                     "user1,user2", |  | ||||||
| 		"tinyauth.apps.foo.users.block":                     "user3", |  | ||||||
| 		"tinyauth.apps.foo.oauth.whitelist":                 "somebody@example.com", |  | ||||||
| 		"tinyauth.apps.foo.oauth.groups":                    "group3", |  | ||||||
| 		"tinyauth.apps.foo.ip.allow":                        "10.71.0.1/24,10.71.0.2", |  | ||||||
| 		"tinyauth.apps.foo.ip.block":                        "10.10.10.10,10.0.0.0/24", |  | ||||||
| 		"tinyauth.apps.foo.ip.bypass":                       "192.168.1.1", |  | ||||||
| 		"tinyauth.apps.foo.response.headers":                "X-Foo=Bar,X-Baz=Qux", |  | ||||||
| 		"tinyauth.apps.foo.response.basicauth.username":     "admin", |  | ||||||
| 		"tinyauth.apps.foo.response.basicauth.password":     "password", |  | ||||||
| 		"tinyauth.apps.foo.response.basicauth.passwordfile": "/path/to/passwordfile", |  | ||||||
| 		"tinyauth.apps.foo.path.allow":                      "/public", |  | ||||||
| 		"tinyauth.apps.foo.path.block":                      "/private", |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Test |  | ||||||
| 	result, err := decoders.DecodeLabels(test) |  | ||||||
|  |  | ||||||
| 	if err != nil { |  | ||||||
| 		t.Fatalf("Unexpected error: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if reflect.DeepEqual(expected, result) == false { |  | ||||||
| 		t.Fatalf("Expected %v but got %v", expected, result) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| @@ -1,31 +0,0 @@ | |||||||
| package utils |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"os" |  | ||||||
| 	"testing" |  | ||||||
|  |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestReadFile(t *testing.T) { |  | ||||||
| 	// Setup |  | ||||||
| 	file, err := os.Create("/tmp/tinyauth_test_file") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	_, err = file.WriteString("file content\n") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	err = file.Close() |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	defer os.Remove("/tmp/tinyauth_test_file") |  | ||||||
|  |  | ||||||
| 	// Normal case |  | ||||||
| 	content, err := ReadFile("/tmp/tinyauth_test_file") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, "file content\n", content) |  | ||||||
|  |  | ||||||
| 	// Non-existing file |  | ||||||
| 	content, err = ReadFile("/tmp/non_existing_file") |  | ||||||
| 	assert.ErrorContains(t, err, "no such file or directory") |  | ||||||
| 	assert.Equal(t, "", content) |  | ||||||
| } |  | ||||||
| @@ -3,8 +3,22 @@ package utils | |||||||
| import ( | import ( | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strings" | 	"strings" | ||||||
|  | 	"tinyauth/internal/config" | ||||||
|  |  | ||||||
|  | 	"github.com/traefik/paerser/parser" | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | func GetLabels(labels map[string]string) (config.Labels, error) { | ||||||
|  | 	var labelsParsed config.Labels | ||||||
|  |  | ||||||
|  | 	err := parser.Decode(labels, &labelsParsed, "tinyauth", "tinyauth.apps") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return config.Labels{}, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return labelsParsed, nil | ||||||
|  | } | ||||||
|  |  | ||||||
| func ParseHeaders(headers []string) map[string]string { | func ParseHeaders(headers []string) map[string]string { | ||||||
| 	headerMap := make(map[string]string) | 	headerMap := make(map[string]string) | ||||||
| 	for _, header := range headers { | 	for _, header := range headers { | ||||||
|   | |||||||
| @@ -1,87 +0,0 @@ | |||||||
| package utils_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/utils" |  | ||||||
|  |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestParseHeaders(t *testing.T) { |  | ||||||
| 	// Normal case |  | ||||||
| 	headers := []string{ |  | ||||||
| 		"X-Custom-Header=Value", |  | ||||||
| 		"Another-Header=AnotherValue", |  | ||||||
| 	} |  | ||||||
| 	expected := map[string]string{ |  | ||||||
| 		"X-Custom-Header": "Value", |  | ||||||
| 		"Another-Header":  "AnotherValue", |  | ||||||
| 	} |  | ||||||
| 	assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) |  | ||||||
|  |  | ||||||
| 	// Case insensitivity and trimming |  | ||||||
| 	headers = []string{ |  | ||||||
| 		"  x-custom-header =  Value  ", |  | ||||||
| 		"ANOTHER-HEADER=AnotherValue", |  | ||||||
| 	} |  | ||||||
| 	expected = map[string]string{ |  | ||||||
| 		"X-Custom-Header": "Value", |  | ||||||
| 		"Another-Header":  "AnotherValue", |  | ||||||
| 	} |  | ||||||
| 	assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) |  | ||||||
|  |  | ||||||
| 	// Invalid headers (missing '=', empty key/value) |  | ||||||
| 	headers = []string{ |  | ||||||
| 		"InvalidHeader", |  | ||||||
| 		"=NoKey", |  | ||||||
| 		"NoValue=", |  | ||||||
| 		"   =   ", |  | ||||||
| 	} |  | ||||||
| 	expected = map[string]string{} |  | ||||||
| 	assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) |  | ||||||
|  |  | ||||||
| 	// Headers with unsafe characters |  | ||||||
| 	headers = []string{ |  | ||||||
| 		"X-Custom-Header=Val\x00ue",       // Null byte |  | ||||||
| 		"Another-Header=Anoth\x7FerValue", // DEL character |  | ||||||
| 		"Good-Header=GoodValue", |  | ||||||
| 	} |  | ||||||
| 	expected = map[string]string{ |  | ||||||
| 		"X-Custom-Header": "Value", |  | ||||||
| 		"Another-Header":  "AnotherValue", |  | ||||||
| 		"Good-Header":     "GoodValue", |  | ||||||
| 	} |  | ||||||
| 	assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) |  | ||||||
|  |  | ||||||
| 	// Header with spaces in key (should be ignored) |  | ||||||
| 	headers = []string{ |  | ||||||
| 		"X Custom Header=Value", |  | ||||||
| 		"Valid-Header=ValidValue", |  | ||||||
| 	} |  | ||||||
| 	expected = map[string]string{ |  | ||||||
| 		"Valid-Header": "ValidValue", |  | ||||||
| 	} |  | ||||||
| 	assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestSanitizeHeader(t *testing.T) { |  | ||||||
| 	// Normal case |  | ||||||
| 	header := "X-Custom-Header" |  | ||||||
| 	expected := "X-Custom-Header" |  | ||||||
| 	assert.Equal(t, expected, utils.SanitizeHeader(header)) |  | ||||||
|  |  | ||||||
| 	// Header with unsafe characters |  | ||||||
| 	header = "X-Cust\x00om-Hea\x7Fder" // Null byte and DEL character |  | ||||||
| 	expected = "X-Custom-Header" |  | ||||||
| 	assert.Equal(t, expected, utils.SanitizeHeader(header)) |  | ||||||
|  |  | ||||||
| 	// Header with only unsafe characters |  | ||||||
| 	header = "\x00\x01\x02\x7F" |  | ||||||
| 	expected = "" |  | ||||||
| 	assert.Equal(t, expected, utils.SanitizeHeader(header)) |  | ||||||
|  |  | ||||||
| 	// Header with spaces and tabs (should be preserved) |  | ||||||
| 	header = "X Custom\tHeader" |  | ||||||
| 	expected = "X Custom\tHeader" |  | ||||||
| 	assert.Equal(t, expected, utils.SanitizeHeader(header)) |  | ||||||
| } |  | ||||||
| @@ -48,12 +48,6 @@ func GetBasicAuth(username string, password string) string { | |||||||
| func FilterIP(filter string, ip string) (bool, error) { | func FilterIP(filter string, ip string) (bool, error) { | ||||||
| 	ipAddr := net.ParseIP(ip) | 	ipAddr := net.ParseIP(ip) | ||||||
|  |  | ||||||
| 	if ipAddr == nil { |  | ||||||
| 		return false, errors.New("invalid IP address") |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	filter = strings.Replace(filter, "-", "/", -1) |  | ||||||
|  |  | ||||||
| 	if strings.Contains(filter, "/") { | 	if strings.Contains(filter, "/") { | ||||||
| 		_, cidr, err := net.ParseCIDR(filter) | 		_, cidr, err := net.ParseCIDR(filter) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|   | |||||||
| @@ -1,151 +0,0 @@ | |||||||
| package utils_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"os" |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/utils" |  | ||||||
|  |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestGetSecret(t *testing.T) { |  | ||||||
| 	// Setup |  | ||||||
| 	file, err := os.Create("/tmp/tinyauth_test_secret") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	_, err = file.WriteString("       secret       \n") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	err = file.Close() |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	defer os.Remove("/tmp/tinyauth_test_secret") |  | ||||||
|  |  | ||||||
| 	// Get from config |  | ||||||
| 	assert.Equal(t, "mysecret", utils.GetSecret("mysecret", "")) |  | ||||||
|  |  | ||||||
| 	// Get from file |  | ||||||
| 	assert.Equal(t, "secret", utils.GetSecret("", "/tmp/tinyauth_test_secret")) |  | ||||||
|  |  | ||||||
| 	// Get from both (config should take precedence) |  | ||||||
| 	assert.Equal(t, "mysecret", utils.GetSecret("mysecret", "/tmp/tinyauth_test_secret")) |  | ||||||
|  |  | ||||||
| 	// Get from none |  | ||||||
| 	assert.Equal(t, "", utils.GetSecret("", "")) |  | ||||||
|  |  | ||||||
| 	// Get from non-existing file |  | ||||||
| 	assert.Equal(t, "", utils.GetSecret("", "/tmp/non_existing_file")) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestParseSecretFile(t *testing.T) { |  | ||||||
| 	// Normal case |  | ||||||
| 	content := "   mysecret   \n" |  | ||||||
| 	assert.Equal(t, "mysecret", utils.ParseSecretFile(content)) |  | ||||||
|  |  | ||||||
| 	// Multiple lines (should take the first non-empty line) |  | ||||||
| 	content = "\n\n   firstsecret   \nsecondsecret\n" |  | ||||||
| 	assert.Equal(t, "firstsecret", utils.ParseSecretFile(content)) |  | ||||||
|  |  | ||||||
| 	// All empty lines |  | ||||||
| 	content = "\n   \n  \n" |  | ||||||
| 	assert.Equal(t, "", utils.ParseSecretFile(content)) |  | ||||||
|  |  | ||||||
| 	// Empty content |  | ||||||
| 	content = "" |  | ||||||
| 	assert.Equal(t, "", utils.ParseSecretFile(content)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestGetBasicAuth(t *testing.T) { |  | ||||||
| 	// Normal case |  | ||||||
| 	username := "user" |  | ||||||
| 	password := "pass" |  | ||||||
| 	expected := "dXNlcjpwYXNz" // base64 of "user:pass" |  | ||||||
| 	assert.Equal(t, expected, utils.GetBasicAuth(username, password)) |  | ||||||
|  |  | ||||||
| 	// Empty username |  | ||||||
| 	username = "" |  | ||||||
| 	password = "pass" |  | ||||||
| 	expected = "OnBhc3M=" // base64 of ":pass" |  | ||||||
| 	assert.Equal(t, expected, utils.GetBasicAuth(username, password)) |  | ||||||
|  |  | ||||||
| 	// Empty password |  | ||||||
| 	username = "user" |  | ||||||
| 	password = "" |  | ||||||
| 	expected = "dXNlcjo=" // base64 of "user:" |  | ||||||
| 	assert.Equal(t, expected, utils.GetBasicAuth(username, password)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestFilterIP(t *testing.T) { |  | ||||||
| 	// Exact match IPv4 |  | ||||||
| 	ok, err := utils.FilterIP("10.10.0.1", "10.10.0.1") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, true, ok) |  | ||||||
|  |  | ||||||
| 	// Non-match IPv4 |  | ||||||
| 	ok, err = utils.FilterIP("10.10.0.1", "10.10.0.2") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, false, ok) |  | ||||||
|  |  | ||||||
| 	// CIDR match IPv4 |  | ||||||
| 	ok, err = utils.FilterIP("10.10.0.0/24", "10.10.0.2") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, true, ok) |  | ||||||
|  |  | ||||||
| 	// CIDR match IPv4 with '-' instead of '/' |  | ||||||
| 	ok, err = utils.FilterIP("10.10.10.0-24", "10.10.10.5") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, true, ok) |  | ||||||
|  |  | ||||||
| 	// CIDR non-match IPv4 |  | ||||||
| 	ok, err = utils.FilterIP("10.10.0.0/24", "10.5.0.1") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	assert.Equal(t, false, ok) |  | ||||||
|  |  | ||||||
| 	// Invalid CIDR |  | ||||||
| 	ok, err = utils.FilterIP("10.10.0.0/222", "10.0.0.1") |  | ||||||
| 	assert.ErrorContains(t, err, "invalid CIDR address") |  | ||||||
| 	assert.Equal(t, false, ok) |  | ||||||
|  |  | ||||||
| 	// Invalid IP in filter |  | ||||||
| 	ok, err = utils.FilterIP("invalid_ip", "10.5.5.5") |  | ||||||
| 	assert.ErrorContains(t, err, "invalid IP address in filter") |  | ||||||
| 	assert.Equal(t, false, ok) |  | ||||||
|  |  | ||||||
| 	// Invalid IP to check |  | ||||||
| 	ok, err = utils.FilterIP("10.10.10.10", "invalid_ip") |  | ||||||
| 	assert.ErrorContains(t, err, "invalid IP address") |  | ||||||
| 	assert.Equal(t, false, ok) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestCheckFilter(t *testing.T) { |  | ||||||
| 	// Empty filter |  | ||||||
| 	assert.Equal(t, true, utils.CheckFilter("", "anystring")) |  | ||||||
|  |  | ||||||
| 	// Exact match |  | ||||||
| 	assert.Equal(t, true, utils.CheckFilter("hello", "hello")) |  | ||||||
|  |  | ||||||
| 	// Regex match |  | ||||||
| 	assert.Equal(t, true, utils.CheckFilter("/^h.*o$/", "hello")) |  | ||||||
|  |  | ||||||
| 	// Invalid regex |  | ||||||
| 	assert.Equal(t, false, utils.CheckFilter("/[unclosed", "test")) |  | ||||||
|  |  | ||||||
| 	// Comma-separated values |  | ||||||
| 	assert.Equal(t, true, utils.CheckFilter("apple, banana, cherry", "banana")) |  | ||||||
|  |  | ||||||
| 	// No match |  | ||||||
| 	assert.Equal(t, false, utils.CheckFilter("apple, banana, cherry", "grape")) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestGenerateIdentifier(t *testing.T) { |  | ||||||
| 	// Consistent output for same input |  | ||||||
| 	id1 := utils.GenerateIdentifier("teststring") |  | ||||||
| 	id2 := utils.GenerateIdentifier("teststring") |  | ||||||
| 	assert.Equal(t, id1, id2) |  | ||||||
|  |  | ||||||
| 	// Different output for different input |  | ||||||
| 	id3 := utils.GenerateIdentifier("differentstring") |  | ||||||
| 	assert.Assert(t, id1 != id3) |  | ||||||
|  |  | ||||||
| 	// Check length (should be 8 characters from first segment of UUID) |  | ||||||
| 	assert.Equal(t, 8, len(id1)) |  | ||||||
| } |  | ||||||
| @@ -1,50 +0,0 @@ | |||||||
| package utils_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/utils" |  | ||||||
|  |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestCapitalize(t *testing.T) { |  | ||||||
| 	// Test empty string |  | ||||||
| 	assert.Equal(t, "", utils.Capitalize("")) |  | ||||||
|  |  | ||||||
| 	// Test single character |  | ||||||
| 	assert.Equal(t, "A", utils.Capitalize("a")) |  | ||||||
|  |  | ||||||
| 	// Test multiple characters |  | ||||||
| 	assert.Equal(t, "Hello", utils.Capitalize("hello")) |  | ||||||
|  |  | ||||||
| 	// Test already capitalized |  | ||||||
| 	assert.Equal(t, "World", utils.Capitalize("World")) |  | ||||||
|  |  | ||||||
| 	// Test non-alphabetic first character |  | ||||||
| 	assert.Equal(t, "1number", utils.Capitalize("1number")) |  | ||||||
|  |  | ||||||
| 	// Test Unicode characters |  | ||||||
| 	assert.Equal(t, "Γειά", utils.Capitalize("γειά")) |  | ||||||
| 	assert.Equal(t, "Привет", utils.Capitalize("привет")) |  | ||||||
|  |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestCoalesceToString(t *testing.T) { |  | ||||||
| 	// Test with []any containing strings |  | ||||||
| 	assert.Equal(t, "a,b,c", utils.CoalesceToString([]any{"a", "b", "c"})) |  | ||||||
|  |  | ||||||
| 	// Test with []any containing mixed types |  | ||||||
| 	assert.Equal(t, "a,c", utils.CoalesceToString([]any{"a", 1, "c", true})) |  | ||||||
|  |  | ||||||
| 	// Test with []any containing no strings |  | ||||||
| 	assert.Equal(t, "", utils.CoalesceToString([]any{1, 2, 3})) |  | ||||||
|  |  | ||||||
| 	// Test with string input |  | ||||||
| 	assert.Equal(t, "hello", utils.CoalesceToString("hello")) |  | ||||||
|  |  | ||||||
| 	// Test with non-string, non-[]any input |  | ||||||
| 	assert.Equal(t, "", utils.CoalesceToString(123)) |  | ||||||
|  |  | ||||||
| 	// Test with nil input |  | ||||||
| 	assert.Equal(t, "", utils.CoalesceToString(nil)) |  | ||||||
| } |  | ||||||
| @@ -1,163 +0,0 @@ | |||||||
| package utils_test |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"os" |  | ||||||
| 	"testing" |  | ||||||
| 	"tinyauth/internal/utils" |  | ||||||
|  |  | ||||||
| 	"gotest.tools/v3/assert" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestGetUsers(t *testing.T) { |  | ||||||
| 	// Setup |  | ||||||
| 	file, err := os.Create("/tmp/tinyauth_users_test.txt") |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	_, err = file.WriteString("      user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G        \n         user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G                    ") // Spacing is on purpose |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	err = file.Close() |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
| 	defer os.Remove("/tmp/tinyauth_users_test.txt") |  | ||||||
|  |  | ||||||
| 	// Test file |  | ||||||
| 	users, err := utils.GetUsers("", "/tmp/tinyauth_users_test.txt") |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 2, len(users)) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user1", users[0].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) |  | ||||||
| 	assert.Equal(t, "user2", users[1].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) |  | ||||||
|  |  | ||||||
| 	// Test config |  | ||||||
| 	users, err = utils.GetUsers("user3:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G,user4:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "") |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 2, len(users)) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user3", users[0].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) |  | ||||||
| 	assert.Equal(t, "user4", users[1].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) |  | ||||||
|  |  | ||||||
| 	// Test both |  | ||||||
| 	users, err = utils.GetUsers("user5:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "/tmp/tinyauth_users_test.txt") |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 3, len(users)) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user5", users[0].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) |  | ||||||
| 	assert.Equal(t, "user1", users[1].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) |  | ||||||
| 	assert.Equal(t, "user2", users[2].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[2].Password) |  | ||||||
|  |  | ||||||
| 	// Test empty |  | ||||||
| 	users, err = utils.GetUsers("", "") |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 0, len(users)) |  | ||||||
|  |  | ||||||
| 	// Test non-existent file |  | ||||||
| 	users, err = utils.GetUsers("", "/tmp/non_existent_file.txt") |  | ||||||
|  |  | ||||||
| 	assert.ErrorContains(t, err, "no such file or directory") |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 0, len(users)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestParseUsers(t *testing.T) { |  | ||||||
| 	// Valid users |  | ||||||
| 	users, err := utils.ParseUsers("user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G,user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF") // user2 has TOTP |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 2, len(users)) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user1", users[0].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) |  | ||||||
| 	assert.Equal(t, "", users[0].TotpSecret) |  | ||||||
| 	assert.Equal(t, "user2", users[1].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) |  | ||||||
| 	assert.Equal(t, "ABCDEF", users[1].TotpSecret) |  | ||||||
|  |  | ||||||
| 	// Valid weirdly spaced users |  | ||||||
| 	users, err = utils.ParseUsers("      user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G        ,         user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF                    ") // Spacing is on purpose |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, 2, len(users)) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user1", users[0].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) |  | ||||||
| 	assert.Equal(t, "", users[0].TotpSecret) |  | ||||||
| 	assert.Equal(t, "user2", users[1].Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) |  | ||||||
| 	assert.Equal(t, "ABCDEF", users[1].TotpSecret) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestParseUser(t *testing.T) { |  | ||||||
| 	// Valid user without TOTP |  | ||||||
| 	user, err := utils.ParseUser("user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G") |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user1", user.Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password) |  | ||||||
| 	assert.Equal(t, "", user.TotpSecret) |  | ||||||
|  |  | ||||||
| 	// Valid user with TOTP |  | ||||||
| 	user, err = utils.ParseUser("user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF") |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user2", user.Username) |  | ||||||
| 	assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password) |  | ||||||
| 	assert.Equal(t, "ABCDEF", user.TotpSecret) |  | ||||||
|  |  | ||||||
| 	// Valid user with $$ in password |  | ||||||
| 	user, err = utils.ParseUser("user3:pa$$word123") |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user3", user.Username) |  | ||||||
| 	assert.Equal(t, "pa$word123", user.Password) |  | ||||||
| 	assert.Equal(t, "", user.TotpSecret) |  | ||||||
|  |  | ||||||
| 	// User with spaces |  | ||||||
| 	user, err = utils.ParseUser("   user4   :   password123   :   TOTPSECRET   ") |  | ||||||
|  |  | ||||||
| 	assert.NilError(t, err) |  | ||||||
|  |  | ||||||
| 	assert.Equal(t, "user4", user.Username) |  | ||||||
| 	assert.Equal(t, "password123", user.Password) |  | ||||||
| 	assert.Equal(t, "TOTPSECRET", user.TotpSecret) |  | ||||||
|  |  | ||||||
| 	// Invalid users |  | ||||||
| 	_, err = utils.ParseUser("user1") // Missing password |  | ||||||
| 	assert.ErrorContains(t, err, "invalid user format") |  | ||||||
|  |  | ||||||
| 	_, err = utils.ParseUser("user1:") |  | ||||||
| 	assert.ErrorContains(t, err, "invalid user format") |  | ||||||
|  |  | ||||||
| 	_, err = utils.ParseUser(":password123") |  | ||||||
| 	assert.ErrorContains(t, err, "invalid user format") |  | ||||||
|  |  | ||||||
| 	_, err = utils.ParseUser("user1:password123:ABC:EXTRA") // Too many parts |  | ||||||
| 	assert.ErrorContains(t, err, "invalid user format") |  | ||||||
|  |  | ||||||
| 	_, err = utils.ParseUser("user1::ABC") |  | ||||||
| 	assert.ErrorContains(t, err, "invalid user format") |  | ||||||
|  |  | ||||||
| 	_, err = utils.ParseUser(":password123:ABC") |  | ||||||
| 	assert.ErrorContains(t, err, "invalid user format") |  | ||||||
|  |  | ||||||
| 	_, err = utils.ParseUser("   :   :   ") |  | ||||||
| 	assert.ErrorContains(t, err, "invalid user format") |  | ||||||
| } |  | ||||||
		Reference in New Issue
	
	Block a user