mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-10-31 14:15:50 +00:00 
			
		
		
		
	Compare commits
	
		
			22 Commits
		
	
	
		
			v2.0.2-bet
			...
			chore/comm
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 1b145fd531 | ||
|   | 7a3a463489 | ||
|   | e09f241364 | ||
|   | d2ee382f92 | ||
|   | 4e8a2443a6 | ||
|   | 22777a16a1 | ||
|   | 0872556c1a | ||
|   | daad2abc33 | ||
|   | ce567ae3de | ||
|   | 87393d3c64 | ||
|   | 97830a309b | ||
|   | fe594d2755 | ||
|   | b3aac26644 | ||
|   | c37f66abb9 | ||
|   | 2c4f086008 | ||
|   | 6e5f882e0b | ||
|   | 99268f80c9 | ||
|   | dcd816b6c6 | ||
|   | 381f6ef76f | ||
|   | 8a8ba18ded | ||
|   | 29f0a94faf | ||
|   | 6602e8140b | 
							
								
								
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -5,7 +5,7 @@ internal/assets/dist | ||||
| tinyauth | ||||
|  | ||||
| # test docker compose | ||||
| docker-compose.test.yml | ||||
| docker-compose.test* | ||||
|  | ||||
| # users file | ||||
| users.txt | ||||
| @@ -13,3 +13,9 @@ users.txt | ||||
| # secret test file | ||||
| secret.txt | ||||
| secret_oauth.txt | ||||
|  | ||||
| # vscode | ||||
| .vscode | ||||
|  | ||||
| # apple stuff | ||||
| .DS_Store | ||||
| @@ -35,7 +35,7 @@ COPY ./cmd ./cmd | ||||
| COPY ./internal ./internal | ||||
| COPY --from=site-builder /site/dist ./internal/assets/dist | ||||
|  | ||||
| RUN CGO_ENABLED=0 go build | ||||
| RUN CGO_ENABLED=0 go build -ldflags "-s -w" | ||||
|  | ||||
| # Runner | ||||
| FROM alpine:3.21 AS runner | ||||
|   | ||||
| @@ -22,9 +22,13 @@ Tinyauth is a simple authentication middleware that adds simple username/passwor | ||||
| > [!NOTE] | ||||
| > Tinyauth is intended for homelab use and it is not made for production use cases. If you are looking for something production ready please use [authentik](https://goauthentik.io). | ||||
|  | ||||
| ## Discord | ||||
|  | ||||
| I just made a Discord server for Tinyauth! It is not only for Tinyauth but general self-hosting because I just like chatting with people! The link is [here](https://discord.gg/gWpzrksk), see you there! | ||||
|  | ||||
| ## Getting Started | ||||
|  | ||||
| You can easily get started with tinyauth by following the guide on the documentation [here](https://tinyauth.doesmycode.work/docs/getting-started.html). There is also an available docker compose file [here](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities. | ||||
| You can easily get started with tinyauth by following the guide on the [documentation](https://tinyauth.doesmycode.work/docs/getting-started.html). There is also an available [docker compose file](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities. | ||||
|  | ||||
| ## Documentation | ||||
|  | ||||
|   | ||||
							
								
								
									
										22
									
								
								assets/discohook.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								assets/discohook.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| { | ||||
|   "content": null, | ||||
|   "embeds": [ | ||||
|     { | ||||
|       "title": "Welcome to Tinyauth Discord!", | ||||
|       "description": "Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.\n\n**Information**\n\n• Github: <https://github.com/steveiliop56/tinyauth>\n• Website: <https://tinyauth.doesmycode.work>", | ||||
|       "url": "https://tinyauth.doesmycode.work", | ||||
|       "color": 7002085, | ||||
|       "author": { | ||||
|         "name": "Tinyauth" | ||||
|       }, | ||||
|       "footer": { | ||||
|         "text": "Updated at" | ||||
|       }, | ||||
|       "timestamp": "2025-02-06T22:00:00.000Z", | ||||
|       "thumbnail": { | ||||
|         "url": "https://github.com/steveiliop56/tinyauth/blob/main/site/public/logo.png?raw=true" | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|   "attachments": [] | ||||
| } | ||||
							
								
								
									
										46
									
								
								cmd/root.go
									
									
									
									
									
								
							
							
						
						
									
										46
									
								
								cmd/root.go
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | ||||
| package cmd | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| @@ -8,6 +9,7 @@ import ( | ||||
| 	"tinyauth/internal/api" | ||||
| 	"tinyauth/internal/assets" | ||||
| 	"tinyauth/internal/auth" | ||||
| 	"tinyauth/internal/docker" | ||||
| 	"tinyauth/internal/hooks" | ||||
| 	"tinyauth/internal/providers" | ||||
| 	"tinyauth/internal/types" | ||||
| @@ -38,6 +40,7 @@ var rootCmd = &cobra.Command{ | ||||
| 		config.GithubClientSecret = utils.GetSecret(config.GithubClientSecret, config.GithubClientSecretFile) | ||||
| 		config.GoogleClientSecret = utils.GetSecret(config.GoogleClientSecret, config.GoogleClientSecretFile) | ||||
| 		config.GenericClientSecret = utils.GetSecret(config.GenericClientSecret, config.GenericClientSecretFile) | ||||
| 		config.TailscaleClientSecret = utils.GetSecret(config.TailscaleClientSecret, config.TailscaleClientSecretFile) | ||||
|  | ||||
| 		// Validate config | ||||
| 		validator := validator.New() | ||||
| @@ -52,8 +55,10 @@ var rootCmd = &cobra.Command{ | ||||
| 		log.Info().Msg("Parsing users") | ||||
| 		users, usersErr := utils.GetUsers(config.Users, config.UsersFile) | ||||
|  | ||||
| 		if (len(users) == 0 || usersErr != nil) && !utils.OAuthConfigured(config) { | ||||
| 			log.Fatal().Err(usersErr).Msg("Failed to parse users") | ||||
| 		HandleError(usersErr, "Failed to parse users") | ||||
|  | ||||
| 		if len(users) == 0 && !utils.OAuthConfigured(config) { | ||||
| 			HandleError(errors.New("no users or OAuth configured"), "No users or OAuth configured") | ||||
| 		} | ||||
|  | ||||
| 		// Create oauth whitelist | ||||
| @@ -66,6 +71,8 @@ var rootCmd = &cobra.Command{ | ||||
| 			GithubClientSecret:    config.GithubClientSecret, | ||||
| 			GoogleClientId:        config.GoogleClientId, | ||||
| 			GoogleClientSecret:    config.GoogleClientSecret, | ||||
| 			TailscaleClientId:     config.TailscaleClientId, | ||||
| 			TailscaleClientSecret: config.TailscaleClientSecret, | ||||
| 			GenericClientId:       config.GenericClientId, | ||||
| 			GenericClientSecret:   config.GenericClientSecret, | ||||
| 			GenericScopes:         strings.Split(config.GenericScopes, ","), | ||||
| @@ -77,8 +84,15 @@ var rootCmd = &cobra.Command{ | ||||
|  | ||||
| 		log.Debug().Msg("Parsed OAuth config") | ||||
|  | ||||
| 		// Create docker service | ||||
| 		docker := docker.NewDocker() | ||||
|  | ||||
| 		// Initialize docker | ||||
| 		dockerErr := docker.Init() | ||||
| 		HandleError(dockerErr, "Failed to initialize docker") | ||||
|  | ||||
| 		// Create auth service | ||||
| 		auth := auth.NewAuth(users, oauthWhitelist) | ||||
| 		auth := auth.NewAuth(docker, users, oauthWhitelist, config.SessionExpiry) | ||||
|  | ||||
| 		// Create OAuth providers service | ||||
| 		providers := providers.NewProviders(oauthConfig) | ||||
| @@ -97,7 +111,7 @@ var rootCmd = &cobra.Command{ | ||||
| 			AppURL:          config.AppURL, | ||||
| 			CookieSecure:    config.CookieSecure, | ||||
| 			DisableContinue: config.DisableContinue, | ||||
| 			CookieExpiry:    config.CookieExpiry, | ||||
| 			CookieExpiry:    config.SessionExpiry, | ||||
| 		}, hooks, auth, providers) | ||||
|  | ||||
| 		// Setup routes | ||||
| @@ -111,20 +125,24 @@ var rootCmd = &cobra.Command{ | ||||
|  | ||||
| func Execute() { | ||||
| 	err := rootCmd.Execute() | ||||
| 	if err != nil { | ||||
| 		log.Fatal().Err(err).Msg("Failed to execute command") | ||||
| 	} | ||||
| 	HandleError(err, "Failed to execute root command") | ||||
| } | ||||
|  | ||||
| func HandleError(err error, msg string) { | ||||
| 	// If error log it and exit | ||||
| 	if err != nil { | ||||
| 		log.Fatal().Err(err).Msg(msg) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func init() { | ||||
| 	// Add user command | ||||
| 	rootCmd.AddCommand(cmd.UserCmd()) | ||||
|  | ||||
| 	// Read environment variables | ||||
| 	viper.AutomaticEnv() | ||||
|  | ||||
| 	// Flags | ||||
| 	rootCmd.Flags().Int("port", 3000, "Port to run the server on.") | ||||
| 	rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.") | ||||
| 	rootCmd.Flags().String("secret", "", "Secret to use for the cookie.") | ||||
| @@ -139,6 +157,9 @@ func init() { | ||||
| 	rootCmd.Flags().String("google-client-id", "", "Google OAuth client ID.") | ||||
| 	rootCmd.Flags().String("google-client-secret", "", "Google OAuth client secret.") | ||||
| 	rootCmd.Flags().String("google-client-secret-file", "", "Google OAuth client secret file.") | ||||
| 	rootCmd.Flags().String("tailscale-client-id", "", "Tailscale OAuth client ID.") | ||||
| 	rootCmd.Flags().String("tailscale-client-secret", "", "Tailscale OAuth client secret.") | ||||
| 	rootCmd.Flags().String("tailscale-client-secret-file", "", "Tailscale OAuth client secret file.") | ||||
| 	rootCmd.Flags().String("generic-client-id", "", "Generic OAuth client ID.") | ||||
| 	rootCmd.Flags().String("generic-client-secret", "", "Generic OAuth client secret.") | ||||
| 	rootCmd.Flags().String("generic-client-secret-file", "", "Generic OAuth client secret file.") | ||||
| @@ -148,8 +169,10 @@ func init() { | ||||
| 	rootCmd.Flags().String("generic-user-url", "", "Generic OAuth user info URL.") | ||||
| 	rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.") | ||||
| 	rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.") | ||||
| 	rootCmd.Flags().Int("cookie-expiry", 86400, "Cookie expiration time in seconds.") | ||||
| 	rootCmd.Flags().Int("session-expiry", 86400, "Session (cookie) expiration time in seconds.") | ||||
| 	rootCmd.Flags().Int("log-level", 1, "Log level.") | ||||
|  | ||||
| 	// Bind flags to environment | ||||
| 	viper.BindEnv("port", "PORT") | ||||
| 	viper.BindEnv("address", "ADDRESS") | ||||
| 	viper.BindEnv("secret", "SECRET") | ||||
| @@ -164,6 +187,9 @@ func init() { | ||||
| 	viper.BindEnv("google-client-id", "GOOGLE_CLIENT_ID") | ||||
| 	viper.BindEnv("google-client-secret", "GOOGLE_CLIENT_SECRET") | ||||
| 	viper.BindEnv("google-client-secret-file", "GOOGLE_CLIENT_SECRET_FILE") | ||||
| 	viper.BindEnv("tailscale-client-id", "TAILSCALE_CLIENT_ID") | ||||
| 	viper.BindEnv("tailscale-client-secret", "TAILSCALE_CLIENT_SECRET") | ||||
| 	viper.BindEnv("tailscale-client-secret-file", "TAILSCALE_CLIENT_SECRET_FILE") | ||||
| 	viper.BindEnv("generic-client-id", "GENERIC_CLIENT_ID") | ||||
| 	viper.BindEnv("generic-client-secret", "GENERIC_CLIENT_SECRET") | ||||
| 	viper.BindEnv("generic-client-secret-file", "GENERIC_CLIENT_SECRET_FILE") | ||||
| @@ -173,7 +199,9 @@ func init() { | ||||
| 	viper.BindEnv("generic-user-url", "GENERIC_USER_URL") | ||||
| 	viper.BindEnv("disable-continue", "DISABLE_CONTINUE") | ||||
| 	viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST") | ||||
| 	viper.BindEnv("cookie-expiry", "COOKIE_EXPIRY") | ||||
| 	viper.BindEnv("session-expiry", "SESSION_EXPIRY") | ||||
| 	viper.BindEnv("log-level", "LOG_LEVEL") | ||||
|  | ||||
| 	// Bind flags to viper | ||||
| 	viper.BindPFlags(rootCmd.Flags()) | ||||
| } | ||||
|   | ||||
| @@ -22,9 +22,12 @@ var CreateCmd = &cobra.Command{ | ||||
| 	Short: "Create a user", | ||||
| 	Long:  `Create a user either interactively or by passing flags.`, | ||||
| 	Run: func(cmd *cobra.Command, args []string) { | ||||
| 		// Setup logger | ||||
| 		log.Logger = log.Level(zerolog.InfoLevel) | ||||
|  | ||||
| 		// Check if interactive | ||||
| 		if interactive { | ||||
| 			// Create huh form | ||||
| 			form := huh.NewForm( | ||||
| 				huh.NewGroup( | ||||
| 					huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error { | ||||
| @@ -43,6 +46,7 @@ var CreateCmd = &cobra.Command{ | ||||
| 				), | ||||
| 			) | ||||
|  | ||||
| 			// Use simple theme | ||||
| 			var baseTheme *huh.Theme = huh.ThemeBase() | ||||
|  | ||||
| 			formErr := form.WithTheme(baseTheme).Run() | ||||
| @@ -52,12 +56,14 @@ var CreateCmd = &cobra.Command{ | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Do we have username and password? | ||||
| 		if username == "" || password == "" { | ||||
| 			log.Error().Msg("Username and password cannot be empty") | ||||
| 		} | ||||
|  | ||||
| 		log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user") | ||||
|  | ||||
| 		// Hash password | ||||
| 		passwordByte, passwordErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | ||||
|  | ||||
| 		if passwordErr != nil { | ||||
| @@ -66,15 +72,18 @@ var CreateCmd = &cobra.Command{ | ||||
|  | ||||
| 		passwordString := string(passwordByte) | ||||
|  | ||||
| 		// Escape $ for docker | ||||
| 		if docker { | ||||
| 			passwordString = strings.ReplaceAll(passwordString, "$", "$$") | ||||
| 		} | ||||
|  | ||||
| 		// Log user created | ||||
| 		log.Info().Str("user", fmt.Sprintf("%s:%s", username, passwordString)).Msg("User created") | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| func init() { | ||||
| 	// Flags | ||||
| 	CreateCmd.Flags().BoolVar(&interactive, "interactive", false, "Create a user interactively") | ||||
| 	CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker") | ||||
| 	CreateCmd.Flags().StringVar(&username, "username", "", "Username") | ||||
|   | ||||
| @@ -22,9 +22,12 @@ var VerifyCmd = &cobra.Command{ | ||||
| 	Short: "Verify a user is set up correctly", | ||||
| 	Long:  `Verify a user is set up correctly meaning that it has a correct username and password.`, | ||||
| 	Run: func(cmd *cobra.Command, args []string) { | ||||
| 		// Setup logger | ||||
| 		log.Logger = log.Level(zerolog.InfoLevel) | ||||
|  | ||||
| 		// Check if interactive | ||||
| 		if interactive { | ||||
| 			// Create huh form | ||||
| 			form := huh.NewForm( | ||||
| 				huh.NewGroup( | ||||
| 					huh.NewInput().Title("User (username:hash)").Value(&user).Validate((func(s string) error { | ||||
| @@ -49,6 +52,7 @@ var VerifyCmd = &cobra.Command{ | ||||
| 				), | ||||
| 			) | ||||
|  | ||||
| 			// Use simple theme | ||||
| 			var baseTheme *huh.Theme = huh.ThemeBase() | ||||
|  | ||||
| 			formErr := form.WithTheme(baseTheme).Run() | ||||
| @@ -58,22 +62,26 @@ var VerifyCmd = &cobra.Command{ | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Do we have username, password and user? | ||||
| 		if username == "" || password == "" || user == "" { | ||||
| 			log.Fatal().Msg("Username, password and user cannot be empty") | ||||
| 		} | ||||
|  | ||||
| 		log.Info().Str("user", user).Str("username", username).Str("password", password).Bool("docker", docker).Msg("Verifying user") | ||||
|  | ||||
| 		// Split username and password | ||||
| 		userSplit := strings.Split(user, ":") | ||||
|  | ||||
| 		if userSplit[1] == "" { | ||||
| 			log.Fatal().Msg("User is not formatted correctly") | ||||
| 		} | ||||
|  | ||||
| 		// Replace $$ with $ if formatted for docker | ||||
| 		if docker { | ||||
| 			userSplit[1] = strings.ReplaceAll(userSplit[1], "$$", "$") | ||||
| 		} | ||||
|  | ||||
| 		// Compare username and password | ||||
| 		verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password)) | ||||
|  | ||||
| 		if verifyErr != nil || username != userSplit[0] { | ||||
| @@ -85,6 +93,7 @@ var VerifyCmd = &cobra.Command{ | ||||
| } | ||||
|  | ||||
| func init() { | ||||
| 	// Flags | ||||
| 	VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively") | ||||
| 	VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?") | ||||
| 	VerifyCmd.Flags().StringVar(&username, "username", "", "Username") | ||||
|   | ||||
							
								
								
									
										17
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								go.mod
									
									
									
									
									
								
							| @@ -14,6 +14,7 @@ require ( | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/Microsoft/go-winio v0.4.14 // indirect | ||||
| 	github.com/atotto/clipboard v0.1.4 // indirect | ||||
| 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||||
| 	github.com/bytedance/sonic v1.12.7 // indirect | ||||
| @@ -27,14 +28,22 @@ require ( | ||||
| 	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect | ||||
| 	github.com/charmbracelet/x/term v0.2.0 // indirect | ||||
| 	github.com/cloudwego/base64x v0.1.4 // indirect | ||||
| 	github.com/distribution/reference v0.6.0 // indirect | ||||
| 	github.com/docker/docker v27.5.1+incompatible // indirect | ||||
| 	github.com/docker/go-connections v0.5.0 // indirect | ||||
| 	github.com/docker/go-units v0.5.0 // indirect | ||||
| 	github.com/dustin/go-humanize v1.0.1 // indirect | ||||
| 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||||
| 	github.com/felixge/httpsnoop v1.0.4 // indirect | ||||
| 	github.com/fsnotify/fsnotify v1.7.0 // indirect | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect | ||||
| 	github.com/gin-contrib/sse v1.0.0 // indirect | ||||
| 	github.com/go-logr/logr v1.4.1 // indirect | ||||
| 	github.com/go-logr/stdr v1.2.2 // indirect | ||||
| 	github.com/go-playground/locales v0.14.1 // indirect | ||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect | ||||
| 	github.com/goccy/go-json v0.10.4 // indirect | ||||
| 	github.com/gogo/protobuf v1.3.2 // indirect | ||||
| 	github.com/gorilla/context v1.1.2 // indirect | ||||
| 	github.com/gorilla/securecookie v1.1.2 // indirect | ||||
| 	github.com/gorilla/sessions v1.2.2 // indirect | ||||
| @@ -51,12 +60,16 @@ require ( | ||||
| 	github.com/mattn/go-runewidth v0.0.16 // indirect | ||||
| 	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect | ||||
| 	github.com/mitchellh/mapstructure v1.5.0 // indirect | ||||
| 	github.com/moby/docker-image-spec v1.3.1 // indirect | ||||
| 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | ||||
| 	github.com/modern-go/reflect2 v1.0.2 // indirect | ||||
| 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||||
| 	github.com/muesli/cancelreader v0.2.2 // indirect | ||||
| 	github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect | ||||
| 	github.com/opencontainers/go-digest v1.0.0 // indirect | ||||
| 	github.com/opencontainers/image-spec v1.1.0 // indirect | ||||
| 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect | ||||
| 	github.com/pkg/errors v0.9.1 // indirect | ||||
| 	github.com/rivo/uniseg v0.4.7 // indirect | ||||
| 	github.com/sagikazarmark/locafero v0.4.0 // indirect | ||||
| 	github.com/sagikazarmark/slog-shim v0.1.0 // indirect | ||||
| @@ -67,6 +80,10 @@ require ( | ||||
| 	github.com/subosito/gotenv v1.6.0 // indirect | ||||
| 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | ||||
| 	github.com/ugorji/go/codec v1.2.12 // indirect | ||||
| 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect | ||||
| 	go.opentelemetry.io/otel v1.24.0 // indirect | ||||
| 	go.opentelemetry.io/otel/metric v1.24.0 // indirect | ||||
| 	go.opentelemetry.io/otel/trace v1.24.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 | ||||
|   | ||||
							
								
								
									
										69
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										69
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,3 +1,5 @@ | ||||
| github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= | ||||
| github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= | ||||
| github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= | ||||
| github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= | ||||
| github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= | ||||
| @@ -32,10 +34,20 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= | ||||
| github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | ||||
| github.com/docker/docker v27.5.1+incompatible h1:4PYU5dnBYqRQi0294d1FBECqT9ECWeQAIfE8q4YnPY8= | ||||
| github.com/docker/docker v27.5.1+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/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/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= | ||||
| github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | ||||
| github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | ||||
| github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= | ||||
| github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= | ||||
| github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= | ||||
| github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | ||||
| github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | ||||
| github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | ||||
| github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= | ||||
| @@ -48,6 +60,11 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E | ||||
| github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= | ||||
| github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= | ||||
| github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||||
| github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | ||||
| github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= | ||||
| github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||||
| github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= | ||||
| github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= | ||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||
| github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | ||||
| github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | ||||
| @@ -59,6 +76,8 @@ github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1 | ||||
| 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | ||||
| github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= | ||||
| github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= | ||||
| github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= | ||||
| github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||||
| @@ -79,10 +98,13 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 | ||||
| github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||||
| github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | ||||
| github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= | ||||
| github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= | ||||
| github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= | ||||
| github.com/klauspost/cpuid/v2 v2.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/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= | ||||
| github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= | ||||
| github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= | ||||
| github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||
| github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||
| @@ -108,6 +130,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4 | ||||
| github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= | ||||
| github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= | ||||
| github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= | ||||
| github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= | ||||
| github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= | ||||
| github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= | ||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||||
| @@ -119,8 +143,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU | ||||
| github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= | ||||
| github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= | ||||
| github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= | ||||
| github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= | ||||
| 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/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= | ||||
| github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= | ||||
| github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= | ||||
| github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||
| github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | ||||
| github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= | ||||
| @@ -138,6 +168,7 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke | ||||
| github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= | ||||
| github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= | ||||
| github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= | ||||
| github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= | ||||
| github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= | ||||
| github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= | ||||
| github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= | ||||
| @@ -151,9 +182,11 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An | ||||
| github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= | ||||
| github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||||
| github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||||
| github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | ||||
| github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= | ||||
| github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | ||||
| github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| @@ -168,31 +201,67 @@ 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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | ||||
| github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||
| 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/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= | ||||
| go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= | ||||
| go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= | ||||
| go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= | ||||
| go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= | ||||
| go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= | ||||
| go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= | ||||
| go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= | ||||
| go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= | ||||
| 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/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||||
| golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= | ||||
| golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= | ||||
| golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= | ||||
| golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= | ||||
| golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||||
| golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||||
| golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||||
| golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= | ||||
| golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= | ||||
| golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= | ||||
| golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= | ||||
| golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= | ||||
| 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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= | ||||
| golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||
| golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| 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-20190507160741-ecd444e8653b/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-20220811171246-fbc7d0a398ab/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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= | ||||
| golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= | ||||
| golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= | ||||
| 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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= | ||||
| google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
|   | ||||
| @@ -3,6 +3,7 @@ package api | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"io/fs" | ||||
| 	"math/rand/v2" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| @@ -40,11 +41,15 @@ type API struct { | ||||
| } | ||||
|  | ||||
| func (api *API) Init() { | ||||
| 	// Disable gin logs | ||||
| 	gin.SetMode(gin.ReleaseMode) | ||||
|  | ||||
| 	// Create router and use zerolog for logs | ||||
| 	log.Debug().Msg("Setting up router") | ||||
| 	router := gin.New() | ||||
| 	router.Use(zerolog()) | ||||
|  | ||||
| 	// Read UI assets | ||||
| 	log.Debug().Msg("Setting up assets") | ||||
| 	dist, distErr := fs.Sub(assets.Assets, "dist") | ||||
|  | ||||
| @@ -52,11 +57,15 @@ func (api *API) Init() { | ||||
| 		log.Fatal().Err(distErr).Msg("Failed to get UI assets") | ||||
| 	} | ||||
|  | ||||
| 	// Create file server | ||||
| 	log.Debug().Msg("Setting up file server") | ||||
| 	fileServer := http.FileServer(http.FS(dist)) | ||||
|  | ||||
| 	// Setup cookie store | ||||
| 	log.Debug().Msg("Setting up cookie store") | ||||
| 	store := cookie.NewStore([]byte(api.Config.Secret)) | ||||
|  | ||||
| 	// Get domain to use for session cookies | ||||
| 	log.Debug().Msg("Getting domain") | ||||
| 	domain, domainErr := utils.GetRootURL(api.Config.AppURL) | ||||
|  | ||||
| @@ -69,6 +78,7 @@ func (api *API) Init() { | ||||
|  | ||||
| 	api.Domain = fmt.Sprintf(".%s", domain) | ||||
|  | ||||
| 	// Use session middleware | ||||
| 	store.Options(sessions.Options{ | ||||
| 		Domain:   api.Domain, | ||||
| 		Path:     "/", | ||||
| @@ -79,44 +89,93 @@ func (api *API) Init() { | ||||
|  | ||||
| 	router.Use(sessions.Sessions("tinyauth", store)) | ||||
|  | ||||
| 	// UI middleware | ||||
| 	router.Use(func(c *gin.Context) { | ||||
| 		// If not an API request, serve the UI | ||||
| 		if !strings.HasPrefix(c.Request.URL.Path, "/api") { | ||||
| 			_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/")) | ||||
|  | ||||
| 			// If the file doesn't exist, serve the index.html | ||||
| 			if os.IsNotExist(err) { | ||||
| 				c.Request.URL.Path = "/" | ||||
| 			} | ||||
|  | ||||
| 			// Serve the file | ||||
| 			fileServer.ServeHTTP(c.Writer, c.Request) | ||||
|  | ||||
| 			// Stop further processing | ||||
| 			c.Abort() | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	// Set router | ||||
| 	api.Router = router | ||||
| } | ||||
|  | ||||
| func (api *API) SetupRoutes() { | ||||
| 	api.Router.GET("/api/auth", func(c *gin.Context) { | ||||
| 		log.Debug().Msg("Checking auth") | ||||
| 	api.Router.GET("/api/auth/:proxy", func(c *gin.Context) { | ||||
| 		// Create struct for proxy | ||||
| 		var proxy types.Proxy | ||||
|  | ||||
| 		// Bind URI | ||||
| 		bindErr := c.BindUri(&proxy) | ||||
|  | ||||
| 		// Handle error | ||||
| 		if api.handleError(c, "Failed to bind URI", bindErr) { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy") | ||||
|  | ||||
| 		// Get user context | ||||
| 		userContext := api.Hooks.UseUserContext(c) | ||||
|  | ||||
| 		// Get headers | ||||
| 		uri := c.Request.Header.Get("X-Forwarded-Uri") | ||||
| 		proto := c.Request.Header.Get("X-Forwarded-Proto") | ||||
| 		host := c.Request.Header.Get("X-Forwarded-Host") | ||||
|  | ||||
| 		// Check if user is logged in | ||||
| 		if userContext.IsLoggedIn { | ||||
| 			log.Debug().Msg("Authenticated") | ||||
| 			c.JSON(200, gin.H{ | ||||
| 				"status":  200, | ||||
| 				"message": "Authenticated", | ||||
|  | ||||
| 			// Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx | ||||
| 			appAllowed, appAllowedErr := api.Auth.ResourceAllowed(userContext, host) | ||||
|  | ||||
| 			// Check if there was an error | ||||
| 			if appAllowedErr != nil { | ||||
| 				// Return 501 if nginx is the proxy or if the request is using an Authorization header | ||||
| 				if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" { | ||||
| 					log.Error().Err(appAllowedErr).Msg("Failed to check if app is allowed") | ||||
| 					c.JSON(501, gin.H{ | ||||
| 						"status":  501, | ||||
| 						"message": "Internal Server Error", | ||||
| 					}) | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 		uri := c.Request.Header.Get("X-Forwarded-Uri") | ||||
| 		proto := c.Request.Header.Get("X-Forwarded-Proto") | ||||
| 		host := c.Request.Header.Get("X-Forwarded-Host") | ||||
| 		queries, queryErr := query.Values(types.LoginQuery{ | ||||
| 			RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri), | ||||
| 				// Return the internal server error page | ||||
| 				if api.handleError(c, "Failed to check if app is allowed", appAllowedErr) { | ||||
| 					return | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			log.Debug().Bool("appAllowed", appAllowed).Msg("Checking if app is allowed") | ||||
|  | ||||
| 			// The user is not allowed to access the app | ||||
| 			if !appAllowed { | ||||
| 				log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed") | ||||
|  | ||||
| 				// Build query | ||||
| 				queries, queryErr := query.Values(types.UnauthorizedQuery{ | ||||
| 					Username: userContext.Username, | ||||
| 					Resource: strings.Split(host, ".")[0], | ||||
| 				}) | ||||
|  | ||||
| 		log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login") | ||||
|  | ||||
| 				// Check if there was an error | ||||
| 				if queryErr != nil { | ||||
| 					// Return 501 if nginx is the proxy or if the request is using an Authorization header | ||||
| 					if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" { | ||||
| 						log.Error().Err(queryErr).Msg("Failed to build query") | ||||
| 						c.JSON(501, gin.H{ | ||||
| 							"status":  501, | ||||
| @@ -125,14 +184,74 @@ func (api *API) SetupRoutes() { | ||||
| 						return | ||||
| 					} | ||||
|  | ||||
| 					// Return the internal server error page | ||||
| 					if api.handleError(c, "Failed to build query", queryErr) { | ||||
| 						return | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				// Return 401 if nginx is the proxy or if the request is using an Authorization header | ||||
| 				if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" { | ||||
| 					c.JSON(401, gin.H{ | ||||
| 						"status":  401, | ||||
| 						"message": "Unauthorized", | ||||
| 					}) | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				// We are using caddy/traefik so redirect | ||||
| 				c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, queries.Encode())) | ||||
|  | ||||
| 				// Stop further processing | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			// The user is allowed to access the app | ||||
| 			c.JSON(200, gin.H{ | ||||
| 				"status":  200, | ||||
| 				"message": "Authenticated", | ||||
| 			}) | ||||
|  | ||||
| 			// Stop further processing | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// The user is not logged in | ||||
| 		log.Debug().Msg("Unauthorized") | ||||
|  | ||||
| 		// Return 401 if nginx is the proxy or if the request is using an Authorization header | ||||
| 		if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" { | ||||
| 			c.JSON(401, gin.H{ | ||||
| 				"status":  401, | ||||
| 				"message": "Unauthorized", | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// Build query | ||||
| 		queries, queryErr := query.Values(types.LoginQuery{ | ||||
| 			RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri), | ||||
| 		}) | ||||
|  | ||||
| 		log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login") | ||||
|  | ||||
| 		// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik) | ||||
| 		if api.handleError(c, "Failed to build query", queryErr) { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// Redirect to login | ||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode())) | ||||
| 	}) | ||||
|  | ||||
| 	api.Router.POST("/api/login", func(c *gin.Context) { | ||||
| 		// Create login struct | ||||
| 		var login types.LoginRequest | ||||
|  | ||||
| 		// Bind JSON | ||||
| 		err := c.BindJSON(&login) | ||||
|  | ||||
| 		// Handle error | ||||
| 		if err != nil { | ||||
| 			log.Error().Err(err).Msg("Failed to bind JSON") | ||||
| 			c.JSON(400, gin.H{ | ||||
| @@ -144,8 +263,10 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 		log.Debug().Msg("Got login request") | ||||
|  | ||||
| 		// Get user based on username | ||||
| 		user := api.Auth.GetUser(login.Username) | ||||
|  | ||||
| 		// User does not exist | ||||
| 		if user == nil { | ||||
| 			log.Debug().Str("username", login.Username).Msg("User not found") | ||||
| 			c.JSON(401, gin.H{ | ||||
| @@ -155,6 +276,9 @@ func (api *API) SetupRoutes() { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Msg("Got user") | ||||
|  | ||||
| 		// Check if password is correct | ||||
| 		if !api.Auth.CheckPassword(*user, login.Password) { | ||||
| 			log.Debug().Str("username", login.Username).Msg("Password incorrect") | ||||
| 			c.JSON(401, gin.H{ | ||||
| @@ -166,11 +290,13 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 		log.Debug().Msg("Password correct, logging in") | ||||
|  | ||||
| 		// Create session cookie with username as provider | ||||
| 		api.Auth.CreateSessionCookie(c, &types.SessionCookie{ | ||||
| 			Username: login.Username, | ||||
| 			Provider: "username", | ||||
| 		}) | ||||
|  | ||||
| 		// Return logged in | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"status":  200, | ||||
| 			"message": "Logged in", | ||||
| @@ -178,12 +304,17 @@ func (api *API) SetupRoutes() { | ||||
| 	}) | ||||
|  | ||||
| 	api.Router.POST("/api/logout", func(c *gin.Context) { | ||||
| 		log.Debug().Msg("Logging out") | ||||
|  | ||||
| 		// Delete session cookie | ||||
| 		api.Auth.DeleteSessionCookie(c) | ||||
|  | ||||
| 		log.Debug().Msg("Cleaning up redirect cookie") | ||||
|  | ||||
| 		// Clean up redirect cookie if it exists | ||||
| 		c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true) | ||||
|  | ||||
| 		// Return logged out | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"status":  200, | ||||
| 			"message": "Logged out", | ||||
| @@ -192,19 +323,24 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 	api.Router.GET("/api/status", func(c *gin.Context) { | ||||
| 		log.Debug().Msg("Checking status") | ||||
|  | ||||
| 		// Get user context | ||||
| 		userContext := api.Hooks.UseUserContext(c) | ||||
|  | ||||
| 		// Get configured providers | ||||
| 		configuredProviders := api.Providers.GetConfiguredProviders() | ||||
|  | ||||
| 		// We have username/password configured so add it to our providers | ||||
| 		if api.Auth.UserAuthConfigured() { | ||||
| 			configuredProviders = append(configuredProviders, "username") | ||||
| 		} | ||||
|  | ||||
| 		// We are not logged in so return unauthorized | ||||
| 		if !userContext.IsLoggedIn { | ||||
| 			log.Debug().Msg("Unauthenticated") | ||||
| 			log.Debug().Msg("Unauthorized") | ||||
| 			c.JSON(200, gin.H{ | ||||
| 				"status":              200, | ||||
| 				"message":             "Unauthenticated", | ||||
| 				"message":             "Unauthorized", | ||||
| 				"username":            "", | ||||
| 				"isLoggedIn":          false, | ||||
| 				"oauth":               false, | ||||
| @@ -217,6 +353,7 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 		log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated") | ||||
|  | ||||
| 		// We are logged in so return our user context | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"status":              200, | ||||
| 			"message":             "Authenticated", | ||||
| @@ -229,18 +366,14 @@ func (api *API) SetupRoutes() { | ||||
| 		}) | ||||
| 	}) | ||||
|  | ||||
| 	api.Router.GET("/api/healthcheck", func(c *gin.Context) { | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"status":  200, | ||||
| 			"message": "OK", | ||||
| 		}) | ||||
| 	}) | ||||
|  | ||||
| 	api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) { | ||||
| 		// Create struct for OAuth request | ||||
| 		var request types.OAuthRequest | ||||
|  | ||||
| 		// Bind URI | ||||
| 		bindErr := c.BindUri(&request) | ||||
|  | ||||
| 		// Handle error | ||||
| 		if bindErr != nil { | ||||
| 			log.Error().Err(bindErr).Msg("Failed to bind URI") | ||||
| 			c.JSON(400, gin.H{ | ||||
| @@ -252,8 +385,10 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 		log.Debug().Msg("Got OAuth request") | ||||
|  | ||||
| 		// Check if provider exists | ||||
| 		provider := api.Providers.GetProvider(request.Provider) | ||||
|  | ||||
| 		// Provider does not exist | ||||
| 		if provider == nil { | ||||
| 			c.JSON(404, gin.H{ | ||||
| 				"status":  404, | ||||
| @@ -264,17 +399,47 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 		log.Debug().Str("provider", request.Provider).Msg("Got provider") | ||||
|  | ||||
| 		// Get auth URL | ||||
| 		authURL := provider.GetAuthURL() | ||||
|  | ||||
| 		log.Debug().Msg("Got auth URL") | ||||
|  | ||||
| 		// Get redirect URI | ||||
| 		redirectURI := c.Query("redirect_uri") | ||||
|  | ||||
| 		// Set redirect cookie if redirect URI is provided | ||||
| 		if redirectURI != "" { | ||||
| 			log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie") | ||||
| 			c.SetCookie("tinyauth_redirect_uri", redirectURI, 3600, "/", api.Domain, api.Config.CookieSecure, true) | ||||
| 		} | ||||
|  | ||||
| 		// Tailscale does not have an auth url so we create a random code (does not need to be secure) to avoid caching and send it | ||||
| 		if request.Provider == "tailscale" { | ||||
| 			// Build tailscale query | ||||
| 			tailscaleQuery, tailscaleQueryErr := query.Values(types.TailscaleQuery{ | ||||
| 				Code: (1000 + rand.IntN(9000)), | ||||
| 			}) | ||||
|  | ||||
| 			// Handle error | ||||
| 			if tailscaleQueryErr != nil { | ||||
| 				log.Error().Err(tailscaleQueryErr).Msg("Failed to build query") | ||||
| 				c.JSON(500, gin.H{ | ||||
| 					"status":  500, | ||||
| 					"message": "Internal Server Error", | ||||
| 				}) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			// Return tailscale URL (immidiately redirects to the callback) | ||||
| 			c.JSON(200, gin.H{ | ||||
| 				"status":  200, | ||||
| 				"message": "Ok", | ||||
| 				"url":     fmt.Sprintf("%s/api/oauth/callback/tailscale?%s", api.Config.AppURL, tailscaleQuery.Encode()), | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// Return auth URL | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"status":  200, | ||||
| 			"message": "Ok", | ||||
| @@ -283,18 +448,23 @@ func (api *API) SetupRoutes() { | ||||
| 	}) | ||||
|  | ||||
| 	api.Router.GET("/api/oauth/callback/:provider", func(c *gin.Context) { | ||||
| 		// Create struct for OAuth request | ||||
| 		var providerName types.OAuthRequest | ||||
|  | ||||
| 		// Bind URI | ||||
| 		bindErr := c.BindUri(&providerName) | ||||
|  | ||||
| 		if handleApiError(c, "Failed to bind URI", bindErr) { | ||||
| 		// Handle error | ||||
| 		if api.handleError(c, "Failed to bind URI", bindErr) { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Interface("provider", providerName.Provider).Msg("Got provider name") | ||||
|  | ||||
| 		// Get code | ||||
| 		code := c.Query("code") | ||||
|  | ||||
| 		// Code empty so redirect to error | ||||
| 		if code == "" { | ||||
| 			log.Error().Msg("No code provided") | ||||
| 			c.Redirect(http.StatusPermanentRedirect, "/error") | ||||
| @@ -303,51 +473,67 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 		log.Debug().Msg("Got code") | ||||
|  | ||||
| 		// Get provider | ||||
| 		provider := api.Providers.GetProvider(providerName.Provider) | ||||
|  | ||||
| 		log.Debug().Str("provider", providerName.Provider).Msg("Got provider") | ||||
|  | ||||
| 		// Provider does not exist | ||||
| 		if provider == nil { | ||||
| 			c.Redirect(http.StatusPermanentRedirect, "/not-found") | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// Exchange token (authenticates user) | ||||
| 		_, tokenErr := provider.ExchangeToken(code) | ||||
|  | ||||
| 		log.Debug().Msg("Got token") | ||||
|  | ||||
| 		if handleApiError(c, "Failed to exchange token", tokenErr) { | ||||
| 		// Handle error | ||||
| 		if api.handleError(c, "Failed to exchange token", tokenErr) { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// Get email | ||||
| 		email, emailErr := api.Providers.GetUser(providerName.Provider) | ||||
|  | ||||
| 		log.Debug().Str("email", email).Msg("Got email") | ||||
|  | ||||
| 		if handleApiError(c, "Failed to get user", emailErr) { | ||||
| 		// Handle error | ||||
| 		if api.handleError(c, "Failed to get user", emailErr) { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// Email is not whitelisted | ||||
| 		if !api.Auth.EmailWhitelisted(email) { | ||||
| 			log.Warn().Str("email", email).Msg("Email not whitelisted") | ||||
|  | ||||
| 			// Build query | ||||
| 			unauthorizedQuery, unauthorizedQueryErr := query.Values(types.UnauthorizedQuery{ | ||||
| 				Username: email, | ||||
| 			}) | ||||
| 			if handleApiError(c, "Failed to build query", unauthorizedQueryErr) { | ||||
|  | ||||
| 			// Handle error | ||||
| 			if api.handleError(c, "Failed to build query", unauthorizedQueryErr) { | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			// Redirect to unauthorized | ||||
| 			c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, unauthorizedQuery.Encode())) | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Msg("Email whitelisted") | ||||
|  | ||||
| 		// Create session cookie | ||||
| 		api.Auth.CreateSessionCookie(c, &types.SessionCookie{ | ||||
| 			Username: email, | ||||
| 			Provider: providerName.Provider, | ||||
| 		}) | ||||
|  | ||||
| 		// Get redirect URI | ||||
| 		redirectURI, redirectURIErr := c.Cookie("tinyauth_redirect_uri") | ||||
|  | ||||
| 		// If it is empty it means that no redirect_uri was provided to the login screen so we just log in | ||||
| 		if redirectURIErr != nil { | ||||
| 			c.JSON(200, gin.H{ | ||||
| 				"status":  200, | ||||
| @@ -357,40 +543,71 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 		log.Debug().Str("redirectURI", redirectURI).Msg("Got redirect URI") | ||||
|  | ||||
| 		// Clean up redirect cookie since we already have the value | ||||
| 		c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true) | ||||
|  | ||||
| 		// Build query | ||||
| 		redirectQuery, redirectQueryErr := query.Values(types.LoginQuery{ | ||||
| 			RedirectURI: redirectURI, | ||||
| 		}) | ||||
|  | ||||
| 		log.Debug().Msg("Got redirect query") | ||||
|  | ||||
| 		if handleApiError(c, "Failed to build query", redirectQueryErr) { | ||||
| 		// Handle error | ||||
| 		if api.handleError(c, "Failed to build query", redirectQueryErr) { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// Redirect to continue with the redirect URI | ||||
| 		c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", api.Config.AppURL, redirectQuery.Encode())) | ||||
| 	}) | ||||
|  | ||||
| 	// Simple healthcheck | ||||
| 	api.Router.GET("/api/healthcheck", func(c *gin.Context) { | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"status":  200, | ||||
| 			"message": "OK", | ||||
| 		}) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (api *API) Run() { | ||||
| 	log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server") | ||||
|  | ||||
| 	// Run server | ||||
| 	api.Router.Run(fmt.Sprintf("%s:%d", api.Config.Address, api.Config.Port)) | ||||
| } | ||||
|  | ||||
| // handleError logs the error and redirects to the error page (only meant for stuff the user may access does not apply for login paths) | ||||
| func (api *API) handleError(c *gin.Context, msg string, err error) bool { | ||||
| 	// If error is not nil log it and redirect to error page also return true so we can stop further processing | ||||
| 	if err != nil { | ||||
| 		log.Error().Err(err).Msg(msg) | ||||
| 		c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/error", api.Config.AppURL)) | ||||
| 		return true | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| // zerolog is a middleware for gin that logs requests using zerolog | ||||
| func zerolog() gin.HandlerFunc { | ||||
| 	return func(c *gin.Context) { | ||||
| 		// Get initial time | ||||
| 		tStart := time.Now() | ||||
|  | ||||
| 		// Process request | ||||
| 		c.Next() | ||||
|  | ||||
| 		// Get status code, address, method and path | ||||
| 		code := c.Writer.Status() | ||||
| 		address := c.Request.RemoteAddr | ||||
| 		method := c.Request.Method | ||||
| 		path := c.Request.URL.Path | ||||
|  | ||||
| 		// Get latency | ||||
| 		latency := time.Since(tStart).String() | ||||
|  | ||||
| 		// Log request | ||||
| 		switch { | ||||
| 		case code >= 200 && code < 300: | ||||
| 			log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") | ||||
| @@ -401,12 +618,3 @@ func zerolog() gin.HandlerFunc { | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func handleApiError(c *gin.Context, msg string, err error) bool { | ||||
| 	if err != nil { | ||||
| 		log.Error().Err(err).Msg(msg) | ||||
| 		c.Redirect(http.StatusPermanentRedirect, "/error") | ||||
| 		return true | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
|   | ||||
| @@ -4,8 +4,12 @@ import ( | ||||
| 	"embed" | ||||
| ) | ||||
|  | ||||
| // UI assets | ||||
| // | ||||
| //go:embed dist | ||||
| var Assets embed.FS | ||||
|  | ||||
| // Version file | ||||
| // | ||||
| //go:embed version | ||||
| var Version string | ||||
| @@ -1 +1 @@ | ||||
| v2.0.2 | ||||
| v3.0.0 | ||||
| @@ -1,7 +1,12 @@ | ||||
| package auth | ||||
|  | ||||
| import ( | ||||
| 	"slices" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 	"tinyauth/internal/docker" | ||||
| 	"tinyauth/internal/types" | ||||
| 	"tinyauth/internal/utils" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| @@ -9,19 +14,24 @@ import ( | ||||
| 	"golang.org/x/crypto/bcrypt" | ||||
| ) | ||||
|  | ||||
| func NewAuth(userList types.Users, oauthWhitelist []string) *Auth { | ||||
| func NewAuth(docker *docker.Docker, userList types.Users, oauthWhitelist []string, sessionExpiry int) *Auth { | ||||
| 	return &Auth{ | ||||
| 		Docker:         docker, | ||||
| 		Users:          userList, | ||||
| 		OAuthWhitelist: oauthWhitelist, | ||||
| 		SessionExpiry:  sessionExpiry, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type Auth struct { | ||||
| 	Users          types.Users | ||||
| 	Docker         *docker.Docker | ||||
| 	OAuthWhitelist []string | ||||
| 	SessionExpiry  int | ||||
| } | ||||
|  | ||||
| func (auth *Auth) GetUser(username string) *types.User { | ||||
| 	// Loop through users and return the user if the username matches | ||||
| 	for _, user := range auth.Users { | ||||
| 		if user.Username == username { | ||||
| 			return &user | ||||
| @@ -31,61 +41,208 @@ func (auth *Auth) GetUser(username string) *types.User { | ||||
| } | ||||
|  | ||||
| func (auth *Auth) CheckPassword(user types.User, password string) bool { | ||||
| 	hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) | ||||
| 	return hashedPasswordErr == nil | ||||
| 	// Compare the hashed password with the password provided | ||||
| 	return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil | ||||
| } | ||||
|  | ||||
| func (auth *Auth) EmailWhitelisted(emailSrc string) bool { | ||||
| 	// If the whitelist is empty, allow all emails | ||||
| 	if len(auth.OAuthWhitelist) == 0 { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	// Loop through the whitelist and return true if the email matches | ||||
| 	for _, email := range auth.OAuthWhitelist { | ||||
| 		if email == emailSrc { | ||||
| 			return true | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// If no emails match, return false | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) { | ||||
| 	log.Debug().Msg("Creating session cookie") | ||||
|  | ||||
| 	// Get session | ||||
| 	sessions := sessions.Default(c) | ||||
|  | ||||
| 	log.Debug().Msg("Setting session cookie") | ||||
|  | ||||
| 	// Set data | ||||
| 	sessions.Set("username", data.Username) | ||||
| 	sessions.Set("provider", data.Provider) | ||||
| 	sessions.Set("expiry", time.Now().Add(time.Duration(auth.SessionExpiry)*time.Second).Unix()) | ||||
|  | ||||
| 	// Save session | ||||
| 	sessions.Save() | ||||
| } | ||||
|  | ||||
| func (auth *Auth) DeleteSessionCookie(c *gin.Context) { | ||||
| 	log.Debug().Msg("Deleting session cookie") | ||||
|  | ||||
| 	// Get session | ||||
| 	sessions := sessions.Default(c) | ||||
|  | ||||
| 	// Clear session | ||||
| 	sessions.Clear() | ||||
|  | ||||
| 	// Save session | ||||
| 	sessions.Save() | ||||
| } | ||||
|  | ||||
| func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) { | ||||
| func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie { | ||||
| 	log.Debug().Msg("Getting session cookie") | ||||
|  | ||||
| 	// Get session | ||||
| 	sessions := sessions.Default(c) | ||||
|  | ||||
| 	// Get data | ||||
| 	cookieUsername := sessions.Get("username") | ||||
| 	cookieProvider := sessions.Get("provider") | ||||
| 	cookieExpiry := sessions.Get("expiry") | ||||
|  | ||||
| 	// Convert interfaces to correct types | ||||
| 	username, usernameOk := cookieUsername.(string) | ||||
| 	provider, providerOk := cookieProvider.(string) | ||||
| 	expiry, expiryOk := cookieExpiry.(int64) | ||||
|  | ||||
| 	log.Debug().Str("username", username).Str("provider", provider).Msg("Parsed cookie") | ||||
|  | ||||
| 	if !usernameOk || !providerOk { | ||||
| 	// Check if the cookie is invalid | ||||
| 	if !usernameOk || !providerOk || !expiryOk { | ||||
| 		log.Warn().Msg("Session cookie invalid") | ||||
| 		return types.SessionCookie{}, nil | ||||
| 		return types.SessionCookie{} | ||||
| 	} | ||||
|  | ||||
| 	// Check if the cookie has expired | ||||
| 	if time.Now().Unix() > expiry { | ||||
| 		log.Warn().Msg("Session cookie expired") | ||||
|  | ||||
| 		// If it has, delete it | ||||
| 		auth.DeleteSessionCookie(c) | ||||
|  | ||||
| 		// Return empty cookie | ||||
| 		return types.SessionCookie{} | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Msg("Parsed cookie") | ||||
|  | ||||
| 	// Return the cookie | ||||
| 	return types.SessionCookie{ | ||||
| 		Username: username, | ||||
| 		Provider: provider, | ||||
| 	}, nil | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (auth *Auth) UserAuthConfigured() bool { | ||||
| 	// If there are users, return true | ||||
| 	return len(auth.Users) > 0 | ||||
| } | ||||
|  | ||||
| func (auth *Auth) ResourceAllowed(context types.UserContext, host string) (bool, error) { | ||||
| 	// Check if we have access to the Docker API | ||||
| 	isConnected := auth.Docker.DockerConnected() | ||||
|  | ||||
| 	// If we don't have access, it is assumed that the user has access | ||||
| 	if !isConnected { | ||||
| 		log.Debug().Msg("Docker not connected, allowing access") | ||||
| 		return true, nil | ||||
| 	} | ||||
|  | ||||
| 	// Get the app ID from the host | ||||
| 	appId := strings.Split(host, ".")[0] | ||||
|  | ||||
| 	// Get the containers | ||||
| 	containers, containersErr := auth.Docker.GetContainers() | ||||
|  | ||||
| 	// If there is an error, return false | ||||
| 	if containersErr != nil { | ||||
| 		return false, containersErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Got containers") | ||||
|  | ||||
| 	// Loop through the containers | ||||
| 	for _, container := range containers { | ||||
| 		// Inspect the container | ||||
| 		inspect, inspectErr := auth.Docker.InspectContainer(container.ID) | ||||
|  | ||||
| 		// If there is an error, return false | ||||
| 		if inspectErr != nil { | ||||
| 			return false, inspectErr | ||||
| 		} | ||||
|  | ||||
| 		// Get the container name (for some reason it is /name) | ||||
| 		containerName := strings.Split(inspect.Name, "/")[1] | ||||
|  | ||||
| 		// There is a container with the same name as the app ID | ||||
| 		if containerName == appId { | ||||
| 			log.Debug().Str("container", containerName).Msg("Found container") | ||||
|  | ||||
| 			// Get only the tinyauth labels in a struct | ||||
| 			labels := utils.GetTinyauthLabels(inspect.Config.Labels) | ||||
|  | ||||
| 			log.Debug().Msg("Got labels") | ||||
|  | ||||
| 			// If the container has an oauth whitelist, check if the user is in it | ||||
| 			if context.OAuth && len(labels.OAuthWhitelist) != 0 { | ||||
| 				log.Debug().Msg("Checking OAuth whitelist") | ||||
| 				if slices.Contains(labels.OAuthWhitelist, context.Username) { | ||||
| 					return true, nil | ||||
| 				} | ||||
| 				return false, nil | ||||
| 			} | ||||
|  | ||||
| 			// If the container has users, check if the user is in it | ||||
| 			if len(labels.Users) != 0 { | ||||
| 				log.Debug().Msg("Checking users") | ||||
| 				if slices.Contains(labels.Users, context.Username) { | ||||
| 					return true, nil | ||||
| 				} | ||||
| 				return false, nil | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("No matching container found, allowing access") | ||||
|  | ||||
| 	// If no matching container is found, allow access | ||||
| 	return true, nil | ||||
| } | ||||
|  | ||||
| func (auth *Auth) GetBasicAuth(c *gin.Context) types.User { | ||||
| 	// Get the Authorization header | ||||
| 	header := c.GetHeader("Authorization") | ||||
|  | ||||
| 	// If the header is empty, return an empty user | ||||
| 	if header == "" { | ||||
| 		return types.User{} | ||||
| 	} | ||||
|  | ||||
| 	// Split the header | ||||
| 	headerSplit := strings.Split(header, " ") | ||||
|  | ||||
| 	if len(headerSplit) != 2 { | ||||
| 		return types.User{} | ||||
| 	} | ||||
|  | ||||
| 	// Check if the header is Basic | ||||
| 	if headerSplit[0] != "Basic" { | ||||
| 		return types.User{} | ||||
| 	} | ||||
|  | ||||
| 	// Split the credentials | ||||
| 	credentials := strings.Split(headerSplit[1], ":") | ||||
|  | ||||
| 	// If the credentials are not in the correct format, return an empty user | ||||
| 	if len(credentials) != 2 { | ||||
| 		return types.User{} | ||||
| 	} | ||||
|  | ||||
| 	// Return the user | ||||
| 	return types.User{ | ||||
| 		Username: credentials[0], | ||||
| 		Password: credentials[1], | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										7
									
								
								internal/constants/constants.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								internal/constants/constants.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| package constants | ||||
|  | ||||
| // TinyauthLabels is a list of labels that can be used in a tinyauth protected container | ||||
| var TinyauthLabels = []string{ | ||||
| 	"tinyauth.oauth.whitelist", | ||||
| 	"tinyauth.users", | ||||
| } | ||||
							
								
								
									
										67
									
								
								internal/docker/docker.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								internal/docker/docker.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| package docker | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
|  | ||||
| 	"github.com/docker/docker/api/types" | ||||
| 	"github.com/docker/docker/api/types/container" | ||||
| 	"github.com/docker/docker/client" | ||||
| ) | ||||
|  | ||||
| func NewDocker() *Docker { | ||||
| 	return &Docker{} | ||||
| } | ||||
|  | ||||
| type Docker struct { | ||||
| 	Client  *client.Client | ||||
| 	Context context.Context | ||||
| } | ||||
|  | ||||
| func (docker *Docker) Init() error { | ||||
| 	// Create a new docker client | ||||
| 	apiClient, err := client.NewClientWithOpts(client.FromEnv) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Set the context and api client | ||||
| 	docker.Context = context.Background() | ||||
| 	docker.Client = apiClient | ||||
|  | ||||
| 	// Done | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (docker *Docker) GetContainers() ([]types.Container, error) { | ||||
| 	// Get the list of containers | ||||
| 	containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{}) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// Return the containers | ||||
| 	return containers, nil | ||||
| } | ||||
|  | ||||
| func (docker *Docker) InspectContainer(containerId string) (types.ContainerJSON, error) { | ||||
| 	// Inspect the container | ||||
| 	inspect, err := docker.Client.ContainerInspect(docker.Context, containerId) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if err != nil { | ||||
| 		return types.ContainerJSON{}, err | ||||
| 	} | ||||
|  | ||||
| 	// Return the inspect | ||||
| 	return inspect, nil | ||||
| } | ||||
|  | ||||
| func (docker *Docker) DockerConnected() bool { | ||||
| 	// Ping the docker client if there is an error it is not connected | ||||
| 	_, err := docker.Client.Ping(docker.Context) | ||||
| 	return err == nil | ||||
| } | ||||
| @@ -22,39 +22,64 @@ type Hooks struct { | ||||
| } | ||||
|  | ||||
| func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { | ||||
| 	cookie, cookiErr := hooks.Auth.GetSessionCookie(c) | ||||
| 	// Get session cookie and basic auth | ||||
| 	cookie := hooks.Auth.GetSessionCookie(c) | ||||
| 	basic := hooks.Auth.GetBasicAuth(c) | ||||
|  | ||||
| 	if cookiErr != nil { | ||||
| 		log.Error().Err(cookiErr).Msg("Failed to get session cookie") | ||||
| 	// Check if basic auth is set | ||||
| 	if basic.Username != "" { | ||||
| 		log.Debug().Msg("Got basic auth") | ||||
|  | ||||
| 		// Check if user exists and password is correct | ||||
| 		user := hooks.Auth.GetUser(basic.Username) | ||||
|  | ||||
| 		if user != nil && hooks.Auth.CheckPassword(*user, basic.Password) { | ||||
| 			// Return user context since we are logged in with basic auth | ||||
| 			return types.UserContext{ | ||||
| 			Username:   "", | ||||
| 			IsLoggedIn: false, | ||||
| 				Username:   basic.Username, | ||||
| 				IsLoggedIn: true, | ||||
| 				OAuth:      false, | ||||
| 			Provider:   "", | ||||
| 				Provider:   "basic", | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	// Check if session cookie is username/password auth | ||||
| 	if cookie.Provider == "username" { | ||||
| 		log.Debug().Msg("Provider is username") | ||||
|  | ||||
| 		// Check if user exists | ||||
| 		if hooks.Auth.GetUser(cookie.Username) != nil { | ||||
| 			log.Debug().Msg("User exists") | ||||
|  | ||||
| 			// It exists so we are logged in | ||||
| 			return types.UserContext{ | ||||
| 				Username:   cookie.Username, | ||||
| 				IsLoggedIn: true, | ||||
| 				OAuth:      false, | ||||
| 				Provider:   "", | ||||
| 				Provider:   "username", | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Provider is not username") | ||||
|  | ||||
| 	// The provider is not username so we need to check if it is an oauth provider | ||||
| 	provider := hooks.Providers.GetProvider(cookie.Provider) | ||||
|  | ||||
| 	// If we have a provider with this name | ||||
| 	if provider != nil { | ||||
| 		log.Debug().Msg("Provider exists") | ||||
|  | ||||
| 		// Check if the oauth email is whitelisted | ||||
| 		if !hooks.Auth.EmailWhitelisted(cookie.Username) { | ||||
| 			log.Error().Str("email", cookie.Username).Msg("Email is not whitelisted") | ||||
|  | ||||
| 			// It isn't so we delete the cookie and return an empty context | ||||
| 			hooks.Auth.DeleteSessionCookie(c) | ||||
|  | ||||
| 			// Return empty context | ||||
| 			return types.UserContext{ | ||||
| 				Username:   "", | ||||
| 				IsLoggedIn: false, | ||||
| @@ -62,7 +87,10 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { | ||||
| 				Provider:   "", | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Msg("Email is whitelisted") | ||||
|  | ||||
| 		// Return user context since we are logged in with oauth | ||||
| 		return types.UserContext{ | ||||
| 			Username:   cookie.Username, | ||||
| 			IsLoggedIn: true, | ||||
| @@ -71,6 +99,7 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Neither basic auth or oauth is set so we return an empty context | ||||
| 	return types.UserContext{ | ||||
| 		Username:   "", | ||||
| 		IsLoggedIn: false, | ||||
|   | ||||
| @@ -21,23 +21,33 @@ type OAuth struct { | ||||
| } | ||||
|  | ||||
| func (oauth *OAuth) Init() { | ||||
| 	// Create a new context and verifier | ||||
| 	oauth.Context = context.Background() | ||||
| 	oauth.Verifier = oauth2.GenerateVerifier() | ||||
| } | ||||
|  | ||||
| func (oauth *OAuth) GetAuthURL() string { | ||||
| 	// Return the auth url | ||||
| 	return oauth.Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier)) | ||||
| } | ||||
|  | ||||
| func (oauth *OAuth) ExchangeToken(code string) (string, error) { | ||||
| 	// Exchange the code for a token | ||||
| 	token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier)) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	// Set the token | ||||
| 	oauth.Token = token | ||||
|  | ||||
| 	// Return the access token | ||||
| 	return oauth.Token.AccessToken, nil | ||||
| } | ||||
|  | ||||
| func (oauth *OAuth) GetClient() *http.Client { | ||||
| 	// Return the http client with the token set | ||||
| 	return oauth.Config.Client(oauth.Context, oauth.Token) | ||||
| } | ||||
|   | ||||
| @@ -8,36 +8,45 @@ import ( | ||||
| 	"github.com/rs/zerolog/log" | ||||
| ) | ||||
|  | ||||
| // We are assuming that the generic provider will return a JSON object with an email field | ||||
| type GenericUserInfoResponse struct { | ||||
| 	Email string `json:"email"` | ||||
| } | ||||
|  | ||||
| func GetGenericEmail(client *http.Client, url string) (string, error) { | ||||
| 	// Using the oauth client get the user info url | ||||
| 	res, resErr := client.Get(url) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if resErr != nil { | ||||
| 		return "", resErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Got response from generic provider") | ||||
|  | ||||
| 	// Read the body of the response | ||||
| 	body, bodyErr := io.ReadAll(res.Body) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if bodyErr != nil { | ||||
| 		return "", bodyErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Read body from generic provider") | ||||
|  | ||||
| 	// Parse the body into a user struct | ||||
| 	var user GenericUserInfoResponse | ||||
|  | ||||
| 	// Unmarshal the body into the user struct | ||||
| 	jsonErr := json.Unmarshal(body, &user) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if jsonErr != nil { | ||||
| 		return "", jsonErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Parsed user from generic provider") | ||||
|  | ||||
| 	// Return the email | ||||
| 	return user.Email, nil | ||||
| } | ||||
|   | ||||
| @@ -9,47 +9,58 @@ import ( | ||||
| 	"github.com/rs/zerolog/log" | ||||
| ) | ||||
|  | ||||
| // Github has a different response than the generic provider | ||||
| type GithubUserInfoResponse []struct { | ||||
| 	Email   string `json:"email"` | ||||
| 	Primary bool   `json:"primary"` | ||||
| } | ||||
|  | ||||
| // The scopes required for the github provider | ||||
| func GithubScopes() []string { | ||||
| 	return []string{"user:email"} | ||||
| } | ||||
|  | ||||
| func GetGithubEmail(client *http.Client) (string, error) { | ||||
| 	// Get the user emails from github using the oauth http client | ||||
| 	res, resErr := client.Get("https://api.github.com/user/emails") | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if resErr != nil { | ||||
| 		return "", resErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Got response from github") | ||||
|  | ||||
| 	// Read the body of the response | ||||
| 	body, bodyErr := io.ReadAll(res.Body) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if bodyErr != nil { | ||||
| 		return "", bodyErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Read body from github") | ||||
|  | ||||
| 	// Parse the body into a user struct | ||||
| 	var emails GithubUserInfoResponse | ||||
|  | ||||
| 	// Unmarshal the body into the user struct | ||||
| 	jsonErr := json.Unmarshal(body, &emails) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if jsonErr != nil { | ||||
| 		return "", jsonErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Parsed emails from github") | ||||
|  | ||||
| 	// Find and return the primary email | ||||
| 	for _, email := range emails { | ||||
| 		if email.Primary { | ||||
| 			return email.Email, nil | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// User does not have a primary email? | ||||
| 	return "", errors.New("no primary email found") | ||||
| } | ||||
|   | ||||
| @@ -8,40 +8,50 @@ import ( | ||||
| 	"github.com/rs/zerolog/log" | ||||
| ) | ||||
|  | ||||
| // Google works the same as the generic provider | ||||
| type GoogleUserInfoResponse struct { | ||||
| 	Email string `json:"email"` | ||||
| } | ||||
|  | ||||
| // The scopes required for the google provider | ||||
| func GoogleScopes() []string { | ||||
| 	return []string{"https://www.googleapis.com/auth/userinfo.email"} | ||||
| } | ||||
|  | ||||
| func GetGoogleEmail(client *http.Client) (string, error) { | ||||
| 	// Get the user info from google using the oauth http client | ||||
| 	res, resErr := client.Get("https://www.googleapis.com/userinfo/v2/me") | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if resErr != nil { | ||||
| 		return "", resErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Got response from google") | ||||
|  | ||||
| 	// Read the body of the response | ||||
| 	body, bodyErr := io.ReadAll(res.Body) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if bodyErr != nil { | ||||
| 		return "", bodyErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Read body from google") | ||||
|  | ||||
| 	// Parse the body into a user struct | ||||
| 	var user GoogleUserInfoResponse | ||||
|  | ||||
| 	// Unmarshal the body into the user struct | ||||
| 	jsonErr := json.Unmarshal(body, &user) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if jsonErr != nil { | ||||
| 		return "", jsonErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Parsed user from google") | ||||
|  | ||||
| 	// Return the email | ||||
| 	return user.Email, nil | ||||
| } | ||||
|   | ||||
| @@ -20,12 +20,16 @@ type Providers struct { | ||||
| 	Config    types.OAuthConfig | ||||
| 	Github    *oauth.OAuth | ||||
| 	Google    *oauth.OAuth | ||||
| 	Tailscale *oauth.OAuth | ||||
| 	Generic   *oauth.OAuth | ||||
| } | ||||
|  | ||||
| func (providers *Providers) Init() { | ||||
| 	// If we have a client id and secret for github, initialize the oauth provider | ||||
| 	if providers.Config.GithubClientId != "" && providers.Config.GithubClientSecret != "" { | ||||
| 		log.Info().Msg("Initializing Github OAuth") | ||||
|  | ||||
| 		// Create a new oauth provider with the github config | ||||
| 		providers.Github = oauth.NewOAuth(oauth2.Config{ | ||||
| 			ClientID:     providers.Config.GithubClientId, | ||||
| 			ClientSecret: providers.Config.GithubClientSecret, | ||||
| @@ -33,10 +37,16 @@ func (providers *Providers) Init() { | ||||
| 			Scopes:       GithubScopes(), | ||||
| 			Endpoint:     endpoints.GitHub, | ||||
| 		}) | ||||
|  | ||||
| 		// Initialize the oauth provider | ||||
| 		providers.Github.Init() | ||||
| 	} | ||||
|  | ||||
| 	// If we have a client id and secret for google, initialize the oauth provider | ||||
| 	if providers.Config.GoogleClientId != "" && providers.Config.GoogleClientSecret != "" { | ||||
| 		log.Info().Msg("Initializing Google OAuth") | ||||
|  | ||||
| 		// Create a new oauth provider with the google config | ||||
| 		providers.Google = oauth.NewOAuth(oauth2.Config{ | ||||
| 			ClientID:     providers.Config.GoogleClientId, | ||||
| 			ClientSecret: providers.Config.GoogleClientSecret, | ||||
| @@ -44,10 +54,32 @@ func (providers *Providers) Init() { | ||||
| 			Scopes:       GoogleScopes(), | ||||
| 			Endpoint:     endpoints.Google, | ||||
| 		}) | ||||
|  | ||||
| 		// Initialize the oauth provider | ||||
| 		providers.Google.Init() | ||||
| 	} | ||||
|  | ||||
| 	if providers.Config.TailscaleClientId != "" && providers.Config.TailscaleClientSecret != "" { | ||||
| 		log.Info().Msg("Initializing Tailscale OAuth") | ||||
|  | ||||
| 		// Create a new oauth provider with the tailscale config | ||||
| 		providers.Tailscale = oauth.NewOAuth(oauth2.Config{ | ||||
| 			ClientID:     providers.Config.TailscaleClientId, | ||||
| 			ClientSecret: providers.Config.TailscaleClientSecret, | ||||
| 			RedirectURL:  fmt.Sprintf("%s/api/oauth/callback/tailscale", providers.Config.AppURL), | ||||
| 			Scopes:       TailscaleScopes(), | ||||
| 			Endpoint:     TailscaleEndpoint, | ||||
| 		}) | ||||
|  | ||||
| 		// Initialize the oauth provider | ||||
| 		providers.Tailscale.Init() | ||||
| 	} | ||||
|  | ||||
| 	// If we have a client id and secret for generic oauth, initialize the oauth provider | ||||
| 	if providers.Config.GenericClientId != "" && providers.Config.GenericClientSecret != "" { | ||||
| 		log.Info().Msg("Initializing Generic OAuth") | ||||
|  | ||||
| 		// Create a new oauth provider with the generic config | ||||
| 		providers.Generic = oauth.NewOAuth(oauth2.Config{ | ||||
| 			ClientID:     providers.Config.GenericClientId, | ||||
| 			ClientSecret: providers.Config.GenericClientSecret, | ||||
| @@ -58,16 +90,21 @@ func (providers *Providers) Init() { | ||||
| 				TokenURL: providers.Config.GenericTokenURL, | ||||
| 			}, | ||||
| 		}) | ||||
|  | ||||
| 		// Initialize the oauth provider | ||||
| 		providers.Generic.Init() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (providers *Providers) GetProvider(provider string) *oauth.OAuth { | ||||
| 	// Return the provider based on the provider string | ||||
| 	switch provider { | ||||
| 	case "github": | ||||
| 		return providers.Github | ||||
| 	case "google": | ||||
| 		return providers.Google | ||||
| 	case "tailscale": | ||||
| 		return providers.Tailscale | ||||
| 	case "generic": | ||||
| 		return providers.Generic | ||||
| 	default: | ||||
| @@ -76,45 +113,103 @@ func (providers *Providers) GetProvider(provider string) *oauth.OAuth { | ||||
| } | ||||
|  | ||||
| func (providers *Providers) GetUser(provider string) (string, error) { | ||||
| 	// Get the email from the provider | ||||
| 	switch provider { | ||||
| 	case "github": | ||||
| 		// If the github provider is not configured, return an error | ||||
| 		if providers.Github == nil { | ||||
| 			log.Debug().Msg("Github provider not configured") | ||||
| 			return "", nil | ||||
| 		} | ||||
|  | ||||
| 		// Get the client from the github provider | ||||
| 		client := providers.Github.GetClient() | ||||
|  | ||||
| 		log.Debug().Msg("Got client from github") | ||||
|  | ||||
| 		// Get the email from the github provider | ||||
| 		email, emailErr := GetGithubEmail(client) | ||||
|  | ||||
| 		// Check if there was an error | ||||
| 		if emailErr != nil { | ||||
| 			return "", emailErr | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Msg("Got email from github") | ||||
|  | ||||
| 		// Return the email | ||||
| 		return email, nil | ||||
| 	case "google": | ||||
| 		// If the google provider is not configured, return an error | ||||
| 		if providers.Google == nil { | ||||
| 			log.Debug().Msg("Google provider not configured") | ||||
| 			return "", nil | ||||
| 		} | ||||
|  | ||||
| 		// Get the client from the google provider | ||||
| 		client := providers.Google.GetClient() | ||||
|  | ||||
| 		log.Debug().Msg("Got client from google") | ||||
|  | ||||
| 		// Get the email from the google provider | ||||
| 		email, emailErr := GetGoogleEmail(client) | ||||
|  | ||||
| 		// Check if there was an error | ||||
| 		if emailErr != nil { | ||||
| 			return "", emailErr | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Msg("Got email from google") | ||||
|  | ||||
| 		// Return the email | ||||
| 		return email, nil | ||||
| 	case "tailscale": | ||||
| 		// If the tailscale provider is not configured, return an error | ||||
| 		if providers.Tailscale == nil { | ||||
| 			log.Debug().Msg("Tailscale provider not configured") | ||||
| 			return "", nil | ||||
| 		} | ||||
|  | ||||
| 		// Get the client from the tailscale provider | ||||
| 		client := providers.Tailscale.GetClient() | ||||
|  | ||||
| 		log.Debug().Msg("Got client from tailscale") | ||||
|  | ||||
| 		// Get the email from the tailscale provider | ||||
| 		email, emailErr := GetTailscaleEmail(client) | ||||
|  | ||||
| 		// Check if there was an error | ||||
| 		if emailErr != nil { | ||||
| 			return "", emailErr | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Msg("Got email from tailscale") | ||||
|  | ||||
| 		// Return the email | ||||
| 		return email, nil | ||||
| 	case "generic": | ||||
| 		// If the generic provider is not configured, return an error | ||||
| 		if providers.Generic == nil { | ||||
| 			log.Debug().Msg("Generic provider not configured") | ||||
| 			return "", nil | ||||
| 		} | ||||
|  | ||||
| 		// Get the client from the generic provider | ||||
| 		client := providers.Generic.GetClient() | ||||
|  | ||||
| 		log.Debug().Msg("Got client from generic") | ||||
|  | ||||
| 		// Get the email from the generic provider | ||||
| 		email, emailErr := GetGenericEmail(client, providers.Config.GenericUserURL) | ||||
|  | ||||
| 		// Check if there was an error | ||||
| 		if emailErr != nil { | ||||
| 			return "", emailErr | ||||
| 		} | ||||
|  | ||||
| 		log.Debug().Msg("Got email from generic") | ||||
|  | ||||
| 		// Return the email | ||||
| 		return email, nil | ||||
| 	default: | ||||
| 		return "", nil | ||||
| @@ -122,6 +217,7 @@ func (providers *Providers) GetUser(provider string) (string, error) { | ||||
| } | ||||
|  | ||||
| func (provider *Providers) GetConfiguredProviders() []string { | ||||
| 	// Create a list of the configured providers | ||||
| 	providers := []string{} | ||||
| 	if provider.Github != nil { | ||||
| 		providers = append(providers, "github") | ||||
| @@ -129,6 +225,9 @@ func (provider *Providers) GetConfiguredProviders() []string { | ||||
| 	if provider.Google != nil { | ||||
| 		providers = append(providers, "google") | ||||
| 	} | ||||
| 	if provider.Tailscale != nil { | ||||
| 		providers = append(providers, "tailscale") | ||||
| 	} | ||||
| 	if provider.Generic != nil { | ||||
| 		providers = append(providers, "generic") | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										68
									
								
								internal/providers/tailscale.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								internal/providers/tailscale.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| package providers | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
|  | ||||
| 	"github.com/rs/zerolog/log" | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
|  | ||||
| // The tailscale email is the loginName | ||||
| type TailscaleUser struct { | ||||
| 	LoginName string `json:"loginName"` | ||||
| } | ||||
|  | ||||
| // The response from the tailscale user info endpoint | ||||
| type TailscaleUserInfoResponse struct { | ||||
| 	Users []TailscaleUser `json:"users"` | ||||
| } | ||||
|  | ||||
| // The scopes required for the tailscale provider | ||||
| func TailscaleScopes() []string { | ||||
| 	return []string{"users:read"} | ||||
| } | ||||
|  | ||||
| // The tailscale endpoint | ||||
| var TailscaleEndpoint = oauth2.Endpoint{ | ||||
| 	TokenURL: "https://api.tailscale.com/api/v2/oauth/token", | ||||
| } | ||||
|  | ||||
| func GetTailscaleEmail(client *http.Client) (string, error) { | ||||
| 	// Get the user info from tailscale using the oauth http client | ||||
| 	res, resErr := client.Get("https://api.tailscale.com/api/v2/tailnet/-/users") | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if resErr != nil { | ||||
| 		return "", resErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Got response from tailscale") | ||||
|  | ||||
| 	// Read the body of the response | ||||
| 	body, bodyErr := io.ReadAll(res.Body) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if bodyErr != nil { | ||||
| 		return "", bodyErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Read body from tailscale") | ||||
|  | ||||
| 	// Parse the body into a user struct | ||||
| 	var users TailscaleUserInfoResponse | ||||
|  | ||||
| 	// Unmarshal the body into the user struct | ||||
| 	jsonErr := json.Unmarshal(body, &users) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if jsonErr != nil { | ||||
| 		return "", jsonErr | ||||
| 	} | ||||
|  | ||||
| 	log.Debug().Msg("Parsed users from tailscale") | ||||
|  | ||||
| 	// Return the email of the first user | ||||
| 	return users.Users[0].LoginName, nil | ||||
| } | ||||
| @@ -2,22 +2,27 @@ package types | ||||
|  | ||||
| import "tinyauth/internal/oauth" | ||||
|  | ||||
| // LoginQuery is the query parameters for the login endpoint | ||||
| type LoginQuery struct { | ||||
| 	RedirectURI string `url:"redirect_uri"` | ||||
| } | ||||
|  | ||||
| // LoginRequest is the request body for the login endpoint | ||||
| type LoginRequest struct { | ||||
| 	Username string `json:"username"` | ||||
| 	Password string `json:"password"` | ||||
| } | ||||
|  | ||||
| // User is the struct for a user | ||||
| type User struct { | ||||
| 	Username string | ||||
| 	Password string | ||||
| } | ||||
|  | ||||
| // Users is a list of users | ||||
| type Users []User | ||||
|  | ||||
| // Config is the configuration for the tinyauth server | ||||
| type Config struct { | ||||
| 	Port                      int    `mapstructure:"port" validate:"required"` | ||||
| 	Address                   string `validate:"required,ip4_addr" mapstructure:"address"` | ||||
| @@ -33,19 +38,23 @@ type Config struct { | ||||
| 	GoogleClientId            string `mapstructure:"google-client-id"` | ||||
| 	GoogleClientSecret        string `mapstructure:"google-client-secret"` | ||||
| 	GoogleClientSecretFile    string `mapstructure:"google-client-secret-file"` | ||||
| 	TailscaleClientId         string `mapstructure:"tailscale-client-id"` | ||||
| 	TailscaleClientSecret     string `mapstructure:"tailscale-client-secret"` | ||||
| 	TailscaleClientSecretFile string `mapstructure:"tailscale-client-secret-file"` | ||||
| 	GenericClientId           string `mapstructure:"generic-client-id"` | ||||
| 	GenericClientSecret       string `mapstructure:"generic-client-secret"` | ||||
| 	GenericClientSecretFile   string `mapstructure:"generic-client-secret-file"` | ||||
| 	GenericScopes             string `mapstructure:"generic-scopes"` | ||||
| 	GenericAuthURL            string `mapstructure:"generic-auth-url"` | ||||
| 	GenericTokenURL           string `mapstructure:"generic-token-url"` | ||||
| 	GenericUserURL          string `mapstructure:"generic-user-info-url"` | ||||
| 	GenericUserURL            string `mapstructure:"generic-user-url"` | ||||
| 	DisableContinue           bool   `mapstructure:"disable-continue"` | ||||
| 	OAuthWhitelist            string `mapstructure:"oauth-whitelist"` | ||||
| 	CookieExpiry            int    `mapstructure:"cookie-expiry"` | ||||
| 	SessionExpiry             int    `mapstructure:"session-expiry"` | ||||
| 	LogLevel                  int8   `mapstructure:"log-level" validate:"min=-1,max=5"` | ||||
| } | ||||
|  | ||||
| // UserContext is the context for the user | ||||
| type UserContext struct { | ||||
| 	Username   string | ||||
| 	IsLoggedIn bool | ||||
| @@ -53,6 +62,7 @@ type UserContext struct { | ||||
| 	Provider   string | ||||
| } | ||||
|  | ||||
| // APIConfig is the configuration for the API | ||||
| type APIConfig struct { | ||||
| 	Port            int | ||||
| 	Address         string | ||||
| @@ -63,11 +73,14 @@ type APIConfig struct { | ||||
| 	DisableContinue bool | ||||
| } | ||||
|  | ||||
| // OAuthConfig is the configuration for the providers | ||||
| type OAuthConfig struct { | ||||
| 	GithubClientId        string | ||||
| 	GithubClientSecret    string | ||||
| 	GoogleClientId        string | ||||
| 	GoogleClientSecret    string | ||||
| 	TailscaleClientId     string | ||||
| 	TailscaleClientSecret string | ||||
| 	GenericClientId       string | ||||
| 	GenericClientSecret   string | ||||
| 	GenericScopes         []string | ||||
| @@ -77,21 +90,42 @@ type OAuthConfig struct { | ||||
| 	AppURL                string | ||||
| } | ||||
|  | ||||
| // OAuthRequest is the request for the OAuth endpoint | ||||
| type OAuthRequest struct { | ||||
| 	Provider string `uri:"provider" binding:"required"` | ||||
| } | ||||
|  | ||||
| // OAuthProviders is the struct for the OAuth providers | ||||
| type OAuthProviders struct { | ||||
| 	Github    *oauth.OAuth | ||||
| 	Google    *oauth.OAuth | ||||
| 	Microsoft *oauth.OAuth | ||||
| } | ||||
|  | ||||
| // UnauthorizedQuery is the query parameters for the unauthorized endpoint | ||||
| type UnauthorizedQuery struct { | ||||
| 	Username string `url:"username"` | ||||
| 	Resource string `url:"resource"` | ||||
| } | ||||
|  | ||||
| // SessionCookie is the cookie for the session (exculding the expiry) | ||||
| type SessionCookie struct { | ||||
| 	Username string | ||||
| 	Provider string | ||||
| } | ||||
|  | ||||
| // TinyauthLabels is the labels for the tinyauth container | ||||
| type TinyauthLabels struct { | ||||
| 	OAuthWhitelist []string | ||||
| 	Users          []string | ||||
| } | ||||
|  | ||||
| // TailscaleQuery is the query parameters for the tailscale endpoint | ||||
| type TailscaleQuery struct { | ||||
| 	Code int `url:"code"` | ||||
| } | ||||
|  | ||||
| // Proxy is the uri parameters for the proxy endpoint | ||||
| type Proxy struct { | ||||
| 	Proxy string `uri:"proxy" binding:"required"` | ||||
| } | ||||
|   | ||||
| @@ -4,26 +4,40 @@ import ( | ||||
| 	"errors" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"slices" | ||||
| 	"strings" | ||||
| 	"tinyauth/internal/constants" | ||||
| 	"tinyauth/internal/types" | ||||
|  | ||||
| 	"github.com/rs/zerolog/log" | ||||
| ) | ||||
|  | ||||
| // Parses a list of comma separated users in a struct | ||||
| func ParseUsers(users string) (types.Users, error) { | ||||
| 	log.Debug().Msg("Parsing users") | ||||
|  | ||||
| 	// Create a new users struct | ||||
| 	var usersParsed types.Users | ||||
|  | ||||
| 	// Split the users by comma | ||||
| 	userList := strings.Split(users, ",") | ||||
|  | ||||
| 	// Check if there are any users | ||||
| 	if len(userList) == 0 { | ||||
| 		return types.Users{}, errors.New("invalid user format") | ||||
| 	} | ||||
|  | ||||
| 	// Loop through the users and split them by colon | ||||
| 	for _, user := range userList { | ||||
| 		// Split the user by colon | ||||
| 		userSplit := strings.Split(user, ":") | ||||
|  | ||||
| 		// Check if the user is in the correct format | ||||
| 		if len(userSplit) != 2 { | ||||
| 			return types.Users{}, errors.New("invalid user format") | ||||
| 		} | ||||
|  | ||||
| 		// Append the user to the users struct | ||||
| 		usersParsed = append(usersParsed, types.User{ | ||||
| 			Username: userSplit[0], | ||||
| 			Password: userSplit[1], | ||||
| @@ -32,43 +46,61 @@ func ParseUsers(users string) (types.Users, error) { | ||||
|  | ||||
| 	log.Debug().Msg("Parsed users") | ||||
|  | ||||
| 	// Return the users struct | ||||
| 	return usersParsed, nil | ||||
| } | ||||
|  | ||||
| // Root url parses parses a hostname and returns the root domain (e.g. sub1.sub2.domain.com -> domain.com) | ||||
| func GetRootURL(urlSrc string) (string, error) { | ||||
| 	// Make sure the url is valid | ||||
| 	urlParsed, parseErr := url.Parse(urlSrc) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if parseErr != nil { | ||||
| 		return "", parseErr | ||||
| 	} | ||||
|  | ||||
| 	// Split the hostname by period | ||||
| 	urlSplitted := strings.Split(urlParsed.Hostname(), ".") | ||||
|  | ||||
| 	// Get the last part of the url | ||||
| 	urlFinal := strings.Join(urlSplitted[1:], ".") | ||||
|  | ||||
| 	// Return the root domain | ||||
| 	return urlFinal, nil | ||||
| } | ||||
|  | ||||
| // Reads a file and returns the contents | ||||
| func ReadFile(file string) (string, error) { | ||||
| 	// Check if the file exists | ||||
| 	_, statErr := os.Stat(file) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if statErr != nil { | ||||
| 		return "", statErr | ||||
| 	} | ||||
|  | ||||
| 	// Read the file | ||||
| 	data, readErr := os.ReadFile(file) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if readErr != nil { | ||||
| 		return "", readErr | ||||
| 	} | ||||
|  | ||||
| 	// Return the file contents | ||||
| 	return string(data), nil | ||||
| } | ||||
|  | ||||
| // Parses a file into a comma separated list of users | ||||
| func ParseFileToLine(content string) string { | ||||
| 	// Split the content by newline | ||||
| 	lines := strings.Split(content, "\n") | ||||
|  | ||||
| 	// Create a list of users | ||||
| 	users := make([]string, 0) | ||||
|  | ||||
| 	// Loop through the lines, trimming the whitespace and appending to the users list | ||||
| 	for _, line := range lines { | ||||
| 		if strings.TrimSpace(line) == "" { | ||||
| 			continue | ||||
| @@ -77,54 +109,101 @@ func ParseFileToLine(content string) string { | ||||
| 		users = append(users, strings.TrimSpace(line)) | ||||
| 	} | ||||
|  | ||||
| 	// Return the users as a comma separated string | ||||
| 	return strings.Join(users, ",") | ||||
| } | ||||
|  | ||||
| // Get the secret from the config or file | ||||
| func GetSecret(conf string, file string) string { | ||||
| 	// If neither the config or file is set, return an empty string | ||||
| 	if conf == "" && file == "" { | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	// If the config is set, return the config (environment variable) | ||||
| 	if conf != "" { | ||||
| 		return conf | ||||
| 	} | ||||
|  | ||||
| 	// If the file is set, read the file | ||||
| 	contents, err := ReadFile(file) | ||||
|  | ||||
| 	// Check if there was an error | ||||
| 	if err != nil { | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	// Return the contents of the file | ||||
| 	return contents | ||||
| } | ||||
|  | ||||
| // Get the users from the config or file | ||||
| func GetUsers(conf string, file string) (types.Users, error) { | ||||
| 	// Create a string to store the users | ||||
| 	var users string | ||||
|  | ||||
| 	// If neither the config or file is set, return an empty users struct | ||||
| 	if conf == "" && file == "" { | ||||
| 		return types.Users{}, errors.New("no users provided") | ||||
| 		return types.Users{}, nil | ||||
| 	} | ||||
|  | ||||
| 	// If the config (environment) is set, append the users to the users string | ||||
| 	if conf != "" { | ||||
| 		log.Debug().Msg("Using users from config") | ||||
| 		users += conf | ||||
| 	} | ||||
|  | ||||
| 	// If the file is set, read the file and append the users to the users string | ||||
| 	if file != "" { | ||||
| 		// Read the file | ||||
| 		fileContents, fileErr := ReadFile(file) | ||||
|  | ||||
| 		// If there isn't an error we can append the users to the users string | ||||
| 		if fileErr == nil { | ||||
| 			log.Debug().Msg("Using users from file") | ||||
|  | ||||
| 			// Append the users to the users string | ||||
| 			if users != "" { | ||||
| 				users += "," | ||||
| 			} | ||||
|  | ||||
| 			// Parse the file contents into a comma separated list of users | ||||
| 			users += ParseFileToLine(fileContents) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Return the parsed users | ||||
| 	return ParseUsers(users) | ||||
| } | ||||
|  | ||||
| // Check if any of the OAuth providers are configured based on the client id and secret | ||||
| func OAuthConfigured(config types.Config) bool { | ||||
| 	return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") | ||||
| 	return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") || (config.TailscaleClientId != "" && config.TailscaleClientSecret != "") | ||||
| } | ||||
|  | ||||
| // Parse the docker labels to the tinyauth labels struct | ||||
| func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels { | ||||
| 	// Create a new tinyauth labels struct | ||||
| 	var tinyauthLabels types.TinyauthLabels | ||||
|  | ||||
| 	// Loop through the labels | ||||
| 	for label, value := range labels { | ||||
|  | ||||
| 		// Check if the label is in the tinyauth labels | ||||
| 		if slices.Contains(constants.TinyauthLabels, label) { | ||||
|  | ||||
| 			log.Debug().Str("label", label).Msg("Found label") | ||||
|  | ||||
| 			// Add the label value to the tinyauth labels struct | ||||
| 			switch label { | ||||
| 			case "tinyauth.oauth.whitelist": | ||||
| 				tinyauthLabels.OAuthWhitelist = strings.Split(value, ",") | ||||
| 			case "tinyauth.users": | ||||
| 				tinyauthLabels.Users = strings.Split(value, ",") | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Return the tinyauth labels | ||||
| 	return tinyauthLabels | ||||
| } | ||||
|   | ||||
							
								
								
									
										55
									
								
								site/src/icons/tailscale.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								site/src/icons/tailscale.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| import type { SVGProps } from "react"; | ||||
|  | ||||
| export function TailscaleIcon(props: SVGProps<SVGSVGElement>) { | ||||
|   return ( | ||||
|     <svg | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       viewBox="0 0 512 512" | ||||
|       width={24} | ||||
|       height={24} | ||||
|       {...props} | ||||
|     > | ||||
|       <style>{".st0{opacity:0.2;fill:#CCCAC9;}.st1{fill:#FFFFFF;}"}</style> | ||||
|       <g> | ||||
|         <g> | ||||
|           <path | ||||
|             className="st0" | ||||
|             d="M65.6,127.7c35.3,0,63.9-28.6,63.9-63.9S100.9,0,65.6,0S1.8,28.6,1.8,63.9S30.4,127.7,65.6,127.7z" | ||||
|           /> | ||||
|           <path | ||||
|             className="st1" | ||||
|             d="M65.6,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9S1.8,219,1.8,254.2S30.4,318.1,65.6,318.1z" | ||||
|           /> | ||||
|           <path | ||||
|             className="st0" | ||||
|             d="M65.6,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9S1.8,412.9,1.8,448.1S30.4,512,65.6,512z" | ||||
|           /> | ||||
|           <path | ||||
|             className="st1" | ||||
|             d="M257.2,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S221.9,318.1,257.2,318.1z" | ||||
|           /> | ||||
|           <path | ||||
|             className="st1" | ||||
|             d="M257.2,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S221.9,512,257.2,512z" | ||||
|           /> | ||||
|           <path | ||||
|             className="st0" | ||||
|             d="M257.2,127.7c35.3,0,63.9-28.6,63.9-63.9S292.5,0,257.2,0s-63.9,28.6-63.9,63.9S221.9,127.7,257.2,127.7z" | ||||
|           /> | ||||
|           <path | ||||
|             className="st0" | ||||
|             d="M446.4,127.7c35.3,0,63.9-28.6,63.9-63.9S481.6,0,446.4,0c-35.3,0-63.9,28.6-63.9,63.9S411.1,127.7,446.4,127.7z" | ||||
|           /> | ||||
|           <path | ||||
|             className="st1" | ||||
|             d="M446.4,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S411.1,318.1,446.4,318.1z" | ||||
|           /> | ||||
|           <path | ||||
|             className="st0" | ||||
|             d="M446.4,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S411.1,512,446.4,512z" | ||||
|           /> | ||||
|         </g> | ||||
|       </g> | ||||
|     </svg> | ||||
|   ); | ||||
| } | ||||
| @@ -19,6 +19,7 @@ import { Layout } from "../components/layouts/layout"; | ||||
| import { GoogleIcon } from "../icons/google"; | ||||
| import { GithubIcon } from "../icons/github"; | ||||
| import { OAuthIcon } from "../icons/oauth"; | ||||
| import { TailscaleIcon } from "../icons/tailscale"; | ||||
|  | ||||
| export const LoginPage = () => { | ||||
|   const queryString = window.location.search; | ||||
| @@ -26,6 +27,9 @@ export const LoginPage = () => { | ||||
|   const redirectUri = params.get("redirect_uri"); | ||||
|  | ||||
|   const { isLoggedIn, configuredProviders } = useUserContext(); | ||||
|   const oauthProviders = configuredProviders.filter( | ||||
|     (value) => value !== "username", | ||||
|   ); | ||||
|  | ||||
|   if (isLoggedIn) { | ||||
|     return <Navigate to="/logout" />; | ||||
| @@ -107,18 +111,13 @@ export const LoginPage = () => { | ||||
|     <Layout> | ||||
|       <Title ta="center">Tinyauth</Title> | ||||
|       <Paper shadow="md" p="xl" mt={30} radius="md" withBorder> | ||||
|         {configuredProviders.length === 0 && ( | ||||
|           <Text size="lg" mb="md" fw={500} ta="center"> | ||||
|             Welcome back, please login | ||||
|           </Text> | ||||
|         )} | ||||
|         {configuredProviders.length > 0 && ( | ||||
|         {oauthProviders.length > 0 && ( | ||||
|           <> | ||||
|             <Text size="lg" fw={500} ta="center"> | ||||
|               Welcome back, login with | ||||
|             </Text> | ||||
|             <Grid mb="md" mt="md" align="center" justify="center"> | ||||
|               {configuredProviders.includes("google") && ( | ||||
|               {oauthProviders.includes("google") && ( | ||||
|                 <Grid.Col span="content"> | ||||
|                   <Button | ||||
|                     radius="xl" | ||||
| @@ -133,7 +132,7 @@ export const LoginPage = () => { | ||||
|                   </Button> | ||||
|                 </Grid.Col> | ||||
|               )} | ||||
|               {configuredProviders.includes("github") && ( | ||||
|               {oauthProviders.includes("github") && ( | ||||
|                 <Grid.Col span="content"> | ||||
|                   <Button | ||||
|                     radius="xl" | ||||
| @@ -148,7 +147,22 @@ export const LoginPage = () => { | ||||
|                   </Button> | ||||
|                 </Grid.Col> | ||||
|               )} | ||||
|               {configuredProviders.includes("generic") && ( | ||||
|               {oauthProviders.includes("tailscale") && ( | ||||
|                 <Grid.Col span="content"> | ||||
|                   <Button | ||||
|                     radius="xl" | ||||
|                     leftSection={ | ||||
|                       <TailscaleIcon style={{ width: 14, height: 14 }} /> | ||||
|                     } | ||||
|                     variant="default" | ||||
|                     onClick={() => loginOAuthMutation.mutate("tailscale")} | ||||
|                     loading={loginOAuthMutation.isLoading} | ||||
|                   > | ||||
|                     Tailscale | ||||
|                   </Button> | ||||
|                 </Grid.Col> | ||||
|               )} | ||||
|               {oauthProviders.includes("generic") && ( | ||||
|                 <Grid.Col span="content"> | ||||
|                   <Button | ||||
|                     radius="xl" | ||||
|   | ||||
| @@ -45,8 +45,8 @@ export const LogoutPage = () => { | ||||
|         </Text> | ||||
|         <Text> | ||||
|           You are currently logged in as <Code>{username}</Code> | ||||
|           {oauth && ` using ${capitalize(provider)}`}. Click the button below to | ||||
|           log out. | ||||
|           {oauth && ` using ${capitalize(provider)} OAuth`}. Click the button | ||||
|           below to log out. | ||||
|         </Text> | ||||
|         <Button | ||||
|           fullWidth | ||||
|   | ||||
| @@ -1,18 +1,12 @@ | ||||
| import { Button, Code, Paper, Text } from "@mantine/core"; | ||||
| import { Layout } from "../components/layouts/layout"; | ||||
| import { useUserContext } from "../context/user-context"; | ||||
| import { Navigate } from "react-router"; | ||||
|  | ||||
| export const UnauthorizedPage = () => { | ||||
|   const queryString = window.location.search; | ||||
|   const params = new URLSearchParams(queryString); | ||||
|   const username = params.get("username"); | ||||
|  | ||||
|   const { isLoggedIn } = useUserContext(); | ||||
|  | ||||
|   if (isLoggedIn) { | ||||
|     return <Navigate to="/" />; | ||||
|   } | ||||
|   const resource = params.get("resource"); | ||||
|  | ||||
|   if (username === "null") { | ||||
|     return <Navigate to="/" />; | ||||
| @@ -25,8 +19,14 @@ export const UnauthorizedPage = () => { | ||||
|           Unauthorized | ||||
|         </Text> | ||||
|         <Text> | ||||
|           The user with username <Code>{username}</Code> is not authorized to | ||||
|           login. | ||||
|           The user with username <Code>{username}</Code> is not authorized to{" "} | ||||
|           {resource !== "null" ? ( | ||||
|             <span> | ||||
|               access the <Code>{resource}</Code> resource. | ||||
|             </span> | ||||
|           ) : ( | ||||
|             "login." | ||||
|           )} | ||||
|         </Text> | ||||
|         <Button | ||||
|           fullWidth | ||||
|   | ||||
		Reference in New Issue
	
	Block a user