mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-28 20:55:42 +00:00
Compare commits
27 Commits
v2.0.0
...
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 | ||
|
|
2385599c80 | ||
|
|
6f184856f1 | ||
|
|
e2e3b3bdc6 | ||
|
|
3efcb26db1 | ||
|
|
c54267f50d |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -5,11 +5,17 @@ internal/assets/dist
|
||||
tinyauth
|
||||
|
||||
# test docker compose
|
||||
docker-compose.test.yml
|
||||
docker-compose.test*
|
||||
|
||||
# users file
|
||||
users.txt
|
||||
|
||||
# secret test file
|
||||
secret.txt
|
||||
secret_oauth.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": []
|
||||
}
|
||||
91
cmd/root.go
91
cmd/root.go
@@ -1,10 +1,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
cmd "tinyauth/cmd/user"
|
||||
"tinyauth/internal/api"
|
||||
"tinyauth/internal/assets"
|
||||
"tinyauth/internal/auth"
|
||||
"tinyauth/internal/docker"
|
||||
"tinyauth/internal/hooks"
|
||||
"tinyauth/internal/providers"
|
||||
"tinyauth/internal/types"
|
||||
@@ -22,60 +27,72 @@ var rootCmd = &cobra.Command{
|
||||
Short: "The simplest way to protect your apps with a login screen.",
|
||||
Long: `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.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Logger
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
|
||||
|
||||
// Get config
|
||||
log.Info().Msg("Parsing config")
|
||||
var config types.Config
|
||||
parseErr := viper.Unmarshal(&config)
|
||||
HandleError(parseErr, "Failed to parse config")
|
||||
|
||||
// Secrets
|
||||
log.Info().Msg("Parsing secrets")
|
||||
|
||||
config.Secret = utils.GetSecret(config.Secret, config.SecretFile)
|
||||
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
|
||||
log.Info().Msg("Validating config")
|
||||
validator := validator.New()
|
||||
validateErr := validator.Struct(config)
|
||||
HandleError(validateErr, "Invalid config")
|
||||
HandleError(validateErr, "Failed to validate config")
|
||||
|
||||
// Set log level
|
||||
log.Info().Int8("log_level", config.LogLevel).Msg("Setting log level")
|
||||
log.Logger = log.Logger.Level(zerolog.Level(config.LogLevel))
|
||||
// Logger
|
||||
log.Logger = log.Level(zerolog.Level(config.LogLevel))
|
||||
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
|
||||
|
||||
// Users
|
||||
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
|
||||
oauthWhitelist := strings.Split(config.OAuthWhitelist, ",")
|
||||
log.Debug().Strs("oauth_whitelist", oauthWhitelist).Msg("Parsed OAuth whitelist")
|
||||
log.Debug().Msg("Parsed OAuth whitelist")
|
||||
|
||||
// Create OAuth config
|
||||
oauthConfig := types.OAuthConfig{
|
||||
GithubClientId: config.GithubClientId,
|
||||
GithubClientSecret: config.GithubClientSecret,
|
||||
GoogleClientId: config.GoogleClientId,
|
||||
GoogleClientSecret: config.GoogleClientSecret,
|
||||
GenericClientId: config.GenericClientId,
|
||||
GenericClientSecret: config.GenericClientSecret,
|
||||
GenericScopes: strings.Split(config.GenericScopes, ","),
|
||||
GenericAuthURL: config.GenericAuthURL,
|
||||
GenericTokenURL: config.GenericTokenURL,
|
||||
GenericUserURL: config.GenericUserURL,
|
||||
AppURL: config.AppURL,
|
||||
GithubClientId: config.GithubClientId,
|
||||
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, ","),
|
||||
GenericAuthURL: config.GenericAuthURL,
|
||||
GenericTokenURL: config.GenericTokenURL,
|
||||
GenericUserURL: config.GenericUserURL,
|
||||
AppURL: config.AppURL,
|
||||
}
|
||||
log.Debug().Interface("oauth_config", oauthConfig).Msg("Parsed OAuth config")
|
||||
|
||||
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)
|
||||
@@ -94,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
|
||||
@@ -108,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.")
|
||||
@@ -136,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.")
|
||||
@@ -145,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")
|
||||
@@ -161,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")
|
||||
@@ -170,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())
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -21,7 +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 {
|
||||
@@ -40,6 +46,7 @@ var CreateCmd = &cobra.Command{
|
||||
),
|
||||
)
|
||||
|
||||
// Use simple theme
|
||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||
|
||||
formErr := form.WithTheme(baseTheme).Run()
|
||||
@@ -49,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 {
|
||||
@@ -63,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")
|
||||
|
||||
@@ -8,12 +8,17 @@ import (
|
||||
)
|
||||
|
||||
func UserCmd() *cobra.Command {
|
||||
// Create the user command
|
||||
userCmd := &cobra.Command{
|
||||
Use: "user",
|
||||
Use: "user",
|
||||
Short: "User utilities",
|
||||
Long: `Utilities for creating and verifying tinyauth compatible users.`,
|
||||
Long: `Utilities for creating and verifying tinyauth compatible users.`,
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
userCmd.AddCommand(create.CreateCmd)
|
||||
userCmd.AddCommand(verify.VerifyCmd)
|
||||
|
||||
// Return the user command
|
||||
return userCmd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -21,7 +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 {
|
||||
@@ -46,6 +52,7 @@ var VerifyCmd = &cobra.Command{
|
||||
),
|
||||
)
|
||||
|
||||
// Use simple theme
|
||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||
|
||||
formErr := form.WithTheme(baseTheme).Run()
|
||||
@@ -55,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] {
|
||||
@@ -82,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,60 +89,169 @@ 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")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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],
|
||||
})
|
||||
|
||||
// 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,
|
||||
"message": "Internal Server Error",
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
// The user is not logged in
|
||||
log.Debug().Msg("Unauthorized")
|
||||
|
||||
log.Debug().Interface("queries", queries).Msg("Redirecting to login")
|
||||
|
||||
if queryErr != nil {
|
||||
log.Error().Err(queryErr).Msg("Failed to build query")
|
||||
c.JSON(501, gin.H{
|
||||
"status": 501,
|
||||
"message": "Internal Server Error",
|
||||
// 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{
|
||||
@@ -142,10 +261,12 @@ func (api *API) SetupRoutes() {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Interface("login", login).Msg("Got login request")
|
||||
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{
|
||||
@@ -250,10 +383,12 @@ func (api *API) SetupRoutes() {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Interface("request", request).Msg("Got OAuth request")
|
||||
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().Str("authURL", authURL).Msg("Got auth URL")
|
||||
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,71 +448,92 @@ 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("providerName", providerName).Msg("Got provider name")
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("code", code).Msg("Got code")
|
||||
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
|
||||
}
|
||||
|
||||
token, tokenErr := provider.ExchangeToken(code)
|
||||
// Exchange token (authenticates user)
|
||||
_, tokenErr := provider.ExchangeToken(code)
|
||||
|
||||
log.Debug().Str("token", token).Msg("Got token")
|
||||
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().Interface("redirectQuery", redirectQuery).Msg("Got redirect query")
|
||||
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
|
||||
var Version string
|
||||
|
||||
@@ -1 +1 @@
|
||||
v2.0.0
|
||||
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,64 +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().Interface("data", data).Msg("Setting session cookie")
|
||||
|
||||
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")
|
||||
|
||||
log.Debug().Interface("cookieUsername", cookieUsername).Msg("Got username")
|
||||
log.Debug().Interface("cookieProvider", cookieProvider).Msg("Got provider")
|
||||
|
||||
// Convert interfaces to correct types
|
||||
username, usernameOk := cookieUsername.(string)
|
||||
provider, providerOk := cookieProvider.(string)
|
||||
expiry, expiryOk := cookieExpiry.(int64)
|
||||
|
||||
log.Debug().Str("username", username).Bool("usernameOk", usernameOk).Str("provider", provider).Bool("providerOk", providerOk).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,41 +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")
|
||||
return types.UserContext{
|
||||
Username: "",
|
||||
IsLoggedIn: false,
|
||||
OAuth: false,
|
||||
Provider: "",
|
||||
// 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: basic.Username,
|
||||
IsLoggedIn: true,
|
||||
OAuth: false,
|
||||
Provider: "basic",
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
log.Debug().Interface("cookie", cookie).Msg("Got session cookie")
|
||||
|
||||
// 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().Msgf("Email %s not whitelisted", 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,
|
||||
@@ -64,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,
|
||||
@@ -73,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().Interface("user", user).Msg("Parsed user from generic provider")
|
||||
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().Interface("emails", emails).Msg("Parsed emails from github")
|
||||
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().Interface("user", user).Msg("Parsed user from google")
|
||||
log.Debug().Msg("Parsed user from google")
|
||||
|
||||
// Return the email
|
||||
return user.Email, nil
|
||||
}
|
||||
|
||||
@@ -17,15 +17,19 @@ func NewProviders(config types.OAuthConfig) *Providers {
|
||||
}
|
||||
|
||||
type Providers struct {
|
||||
Config types.OAuthConfig
|
||||
Github *oauth.OAuth
|
||||
Google *oauth.OAuth
|
||||
Generic *oauth.OAuth
|
||||
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,50 +2,59 @@ 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"`
|
||||
Address string `validate:"ip4_addr" mapstructure:"address"`
|
||||
Secret string `validate:"len=32" mapstructure:"secret"`
|
||||
SecretFile string `mapstructure:"secret-file"`
|
||||
AppURL string `validate:"required,url" mapstructure:"app-url"`
|
||||
Users string `mapstructure:"users"`
|
||||
UsersFile string `mapstructure:"users-file"`
|
||||
CookieSecure bool `mapstructure:"cookie-secure"`
|
||||
GithubClientId string `mapstructure:"github-client-id"`
|
||||
GithubClientSecret string `mapstructure:"github-client-secret"`
|
||||
GithubClientSecretFile string `mapstructure:"github-client-secret-file"`
|
||||
GoogleClientId string `mapstructure:"google-client-id"`
|
||||
GoogleClientSecret string `mapstructure:"google-client-secret"`
|
||||
GoogleClientSecretFile string `mapstructure:"google-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"`
|
||||
DisableContinue bool `mapstructure:"disable-continue"`
|
||||
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
||||
CookieExpiry int `mapstructure:"cookie-expiry"`
|
||||
LogLevel int8 `mapstructure:"log-level"`
|
||||
Port int `mapstructure:"port" validate:"required"`
|
||||
Address string `validate:"required,ip4_addr" mapstructure:"address"`
|
||||
Secret string `validate:"required,len=32" mapstructure:"secret"`
|
||||
SecretFile string `mapstructure:"secret-file"`
|
||||
AppURL string `validate:"required,url" mapstructure:"app-url"`
|
||||
Users string `mapstructure:"users"`
|
||||
UsersFile string `mapstructure:"users-file"`
|
||||
CookieSecure bool `mapstructure:"cookie-secure"`
|
||||
GithubClientId string `mapstructure:"github-client-id"`
|
||||
GithubClientSecret string `mapstructure:"github-client-secret"`
|
||||
GithubClientSecretFile string `mapstructure:"github-client-secret-file"`
|
||||
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-url"`
|
||||
DisableContinue bool `mapstructure:"disable-continue"`
|
||||
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
||||
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,35 +73,59 @@ type APIConfig struct {
|
||||
DisableContinue bool
|
||||
}
|
||||
|
||||
// OAuthConfig is the configuration for the providers
|
||||
type OAuthConfig struct {
|
||||
GithubClientId string
|
||||
GithubClientSecret string
|
||||
GoogleClientId string
|
||||
GoogleClientSecret string
|
||||
GenericClientId string
|
||||
GenericClientSecret string
|
||||
GenericScopes []string
|
||||
GenericAuthURL string
|
||||
GenericTokenURL string
|
||||
GenericUserURL string
|
||||
AppURL string
|
||||
GithubClientId string
|
||||
GithubClientSecret string
|
||||
GoogleClientId string
|
||||
GoogleClientSecret string
|
||||
TailscaleClientId string
|
||||
TailscaleClientSecret string
|
||||
GenericClientId string
|
||||
GenericClientSecret string
|
||||
GenericScopes []string
|
||||
GenericAuthURL string
|
||||
GenericTokenURL string
|
||||
GenericUserURL string
|
||||
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,132 +4,206 @@ 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, ",")
|
||||
|
||||
log.Debug().Strs("users", userList).Msg("Splitted 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, ":")
|
||||
log.Debug().Strs("user", userSplit).Msg("Splitting 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],
|
||||
})
|
||||
}
|
||||
|
||||
log.Debug().Interface("users", usersParsed).Msg("Parsed users")
|
||||
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
|
||||
}
|
||||
|
||||
urlSplitted := strings.Split(urlParsed.Host, ".")
|
||||
// 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
|
||||
}
|
||||
|
||||
users = append(users, line)
|
||||
users = append(users, strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
// Return the users as a comma separated string
|
||||
return strings.Join(users, ",")
|
||||
}
|
||||
|
||||
func GetSecret(env string, file string) string {
|
||||
if env == "" && file == "" {
|
||||
log.Debug().Msg("No secret provided")
|
||||
// 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 env != "" {
|
||||
log.Debug().Str("secret", env).Msg("Using secret from env")
|
||||
return env
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
log.Debug().Str("secret", contents).Msg("Using secret from file")
|
||||
|
||||
// Return the contents of the file
|
||||
return contents
|
||||
}
|
||||
|
||||
func GetUsers(env string, file string) (types.Users, error) {
|
||||
// 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 env == "" && file == "" {
|
||||
return types.Users{}, errors.New("no users provided")
|
||||
// If neither the config or file is set, return an empty users struct
|
||||
if conf == "" && file == "" {
|
||||
return types.Users{}, nil
|
||||
}
|
||||
|
||||
if env != "" {
|
||||
log.Debug().Str("users", env).Msg("Using users from env")
|
||||
users += env
|
||||
// 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().Str("users", ParseFileToLine(fileContents)).Msg("Using users from file")
|
||||
users += ","
|
||||
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
|
||||
}
|
||||
|
||||
4
main.go
4
main.go
@@ -4,7 +4,6 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
"tinyauth/cmd"
|
||||
"tinyauth/internal/assets"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -12,8 +11,7 @@ import (
|
||||
|
||||
func main() {
|
||||
// Logger
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger()
|
||||
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
|
||||
|
||||
// Run cmd
|
||||
cmd.Execute()
|
||||
|
||||
BIN
site/bun.lockb
BIN
site/bun.lockb
Binary file not shown.
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Button, Paper, Text } from "@mantine/core";
|
||||
import { Button, Code, Paper, Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { Navigate } from "react-router";
|
||||
import { useUserContext } from "../context/user-context";
|
||||
import { Layout } from "../components/layouts/layout";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const ContinuePage = () => {
|
||||
const queryString = window.location.search;
|
||||
@@ -12,11 +13,11 @@ export const ContinuePage = () => {
|
||||
const { isLoggedIn, disableContinue } = useUserContext();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to="/login" />;
|
||||
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
|
||||
}
|
||||
|
||||
if (disableContinue && redirectUri !== "null") {
|
||||
window.location.replace(redirectUri!);
|
||||
if (redirectUri === "null") {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
const redirect = () => {
|
||||
@@ -26,31 +27,62 @@ export const ContinuePage = () => {
|
||||
color: "blue",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.replace(redirectUri!);
|
||||
window.location.href = redirectUri!;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const urlParsed = URL.parse(redirectUri!);
|
||||
|
||||
if (
|
||||
window.location.protocol === "https:" &&
|
||||
urlParsed!.protocol === "http:"
|
||||
) {
|
||||
return (
|
||||
<ContinuePageLayout>
|
||||
<Text size="xl" fw={700}>
|
||||
Insecure Redirect
|
||||
</Text>
|
||||
<Text>
|
||||
Your are logged in but trying to redirect from <Code>https</Code> to{" "}
|
||||
<Code>http</Code>, please click the button to redirect.
|
||||
</Text>
|
||||
<Button fullWidth mt="xl" onClick={redirect}>
|
||||
Continue
|
||||
</Button>
|
||||
</ContinuePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (disableContinue) {
|
||||
window.location.href = redirectUri!;
|
||||
return (
|
||||
<ContinuePageLayout>
|
||||
<Text size="xl" fw={700}>
|
||||
Redirecting
|
||||
</Text>
|
||||
<Text>You should be redirected to your app soon.</Text>
|
||||
</ContinuePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContinuePageLayout>
|
||||
<Text size="xl" fw={700}>
|
||||
Continue
|
||||
</Text>
|
||||
<Text>Click the button to continue to your app.</Text>
|
||||
<Button fullWidth mt="xl" onClick={redirect}>
|
||||
Continue
|
||||
</Button>
|
||||
</ContinuePageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContinuePageLayout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<Layout>
|
||||
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
||||
{redirectUri !== "null" ? (
|
||||
<>
|
||||
<Text size="xl" fw={700}>
|
||||
Continue
|
||||
</Text>
|
||||
<Text>Click the button to continue to your app.</Text>
|
||||
<Button fullWidth mt="xl" onClick={redirect}>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="xl" fw={700}>
|
||||
Logged in
|
||||
</Text>
|
||||
<Text>You are now signed in and can use your apps.</Text>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</Paper>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -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" />;
|
||||
@@ -65,8 +69,12 @@ export const LoginPage = () => {
|
||||
color: "green",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
|
||||
});
|
||||
if (redirectUri === "null") {
|
||||
window.location.replace("/");
|
||||
} else {
|
||||
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,7 +92,14 @@ export const LoginPage = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
window.location.replace(data.data.url);
|
||||
notifications.show({
|
||||
title: "Redirecting",
|
||||
message: "Redirecting to your OAuth provider",
|
||||
color: "blue",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = data.data.url;
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,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"
|
||||
@@ -122,7 +132,7 @@ export const LoginPage = () => {
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
)}
|
||||
{configuredProviders.includes("github") && (
|
||||
{oauthProviders.includes("github") && (
|
||||
<Grid.Col span="content">
|
||||
<Button
|
||||
radius="xl"
|
||||
@@ -137,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"
|
||||
|
||||
@@ -32,7 +32,7 @@ export const LogoutPage = () => {
|
||||
color: "green",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
window.location.replace("/login");
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
@@ -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