mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-29 05:05:42 +00:00
Compare commits
44 Commits
v1.0.0-bet
...
chore/comm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b145fd531 | ||
|
|
7a3a463489 | ||
|
|
e09f241364 | ||
|
|
d2ee382f92 | ||
|
|
4e8a2443a6 | ||
|
|
22777a16a1 | ||
|
|
0872556c1a | ||
|
|
daad2abc33 | ||
|
|
ce567ae3de | ||
|
|
87393d3c64 | ||
|
|
97830a309b | ||
|
|
fe594d2755 | ||
|
|
b3aac26644 | ||
|
|
c37f66abb9 | ||
|
|
2c4f086008 | ||
|
|
6e5f882e0b | ||
|
|
99268f80c9 | ||
|
|
dcd816b6c6 | ||
|
|
381f6ef76f | ||
|
|
8a8ba18ded | ||
|
|
29f0a94faf | ||
|
|
6602e8140b | ||
|
|
2385599c80 | ||
|
|
6f184856f1 | ||
|
|
e2e3b3bdc6 | ||
|
|
3efcb26db1 | ||
|
|
c54267f50d | ||
|
|
4de12ce5c1 | ||
|
|
0cf0aafc14 | ||
|
|
80ea43184c | ||
|
|
3c4dffd479 | ||
|
|
f19f40f9fc | ||
|
|
a243f22ac8 | ||
|
|
08d382c981 | ||
|
|
94f7debb10 | ||
|
|
3b50d9303b | ||
|
|
d67133aca7 | ||
|
|
989ea8f229 | ||
|
|
708006decf | ||
|
|
682a918812 | ||
|
|
389248cfe1 | ||
|
|
81d25061df | ||
|
|
f59697955d | ||
|
|
47d8f1e5aa |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -5,7 +5,17 @@ internal/assets/dist
|
|||||||
tinyauth
|
tinyauth
|
||||||
|
|
||||||
# test docker compose
|
# test docker compose
|
||||||
docker-compose.test.yml
|
docker-compose.test*
|
||||||
|
|
||||||
# users file
|
# users file
|
||||||
users.txt
|
users.txt
|
||||||
|
|
||||||
|
# secret test file
|
||||||
|
secret.txt
|
||||||
|
secret_oauth.txt
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# apple stuff
|
||||||
|
.DS_Store
|
||||||
@@ -35,7 +35,7 @@ COPY ./cmd ./cmd
|
|||||||
COPY ./internal ./internal
|
COPY ./internal ./internal
|
||||||
COPY --from=site-builder /site/dist ./internal/assets/dist
|
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
|
# Runner
|
||||||
FROM alpine:3.21 AS runner
|
FROM alpine:3.21 AS runner
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Tinyauth is a simple authentication middleware that adds simple email/password login to all of your docker apps. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
|
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. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
|
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
|
||||||
@@ -22,9 +22,13 @@ Tinyauth is a simple authentication middleware that adds simple email/password l
|
|||||||
> [!NOTE]
|
> [!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).
|
> 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
|
## 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
|
## 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": []
|
||||||
|
}
|
||||||
135
cmd/root.go
135
cmd/root.go
@@ -1,15 +1,22 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
cmd "tinyauth/cmd/user"
|
cmd "tinyauth/cmd/user"
|
||||||
"tinyauth/internal/api"
|
"tinyauth/internal/api"
|
||||||
|
"tinyauth/internal/assets"
|
||||||
"tinyauth/internal/auth"
|
"tinyauth/internal/auth"
|
||||||
|
"tinyauth/internal/docker"
|
||||||
"tinyauth/internal/hooks"
|
"tinyauth/internal/hooks"
|
||||||
"tinyauth/internal/providers"
|
"tinyauth/internal/providers"
|
||||||
"tinyauth/internal/types"
|
"tinyauth/internal/types"
|
||||||
"tinyauth/internal/utils"
|
"tinyauth/internal/utils"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -17,65 +24,75 @@ import (
|
|||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "tinyauth",
|
Use: "tinyauth",
|
||||||
Short: "An extremely simple traefik forward auth proxy.",
|
Short: "The simplest way to protect your apps with a login screen.",
|
||||||
Long: `Tinyauth is an extremely simple traefik forward-auth login screen that makes securing your apps easy.`,
|
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) {
|
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
|
// Get config
|
||||||
log.Info().Msg("Parsing config")
|
|
||||||
var config types.Config
|
var config types.Config
|
||||||
parseErr := viper.Unmarshal(&config)
|
parseErr := viper.Unmarshal(&config)
|
||||||
HandleError(parseErr, "Failed to parse config")
|
HandleError(parseErr, "Failed to parse config")
|
||||||
|
|
||||||
|
// 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
|
// Validate config
|
||||||
log.Info().Msg("Validating config")
|
|
||||||
validator := validator.New()
|
validator := validator.New()
|
||||||
validateErr := validator.Struct(config)
|
validateErr := validator.Struct(config)
|
||||||
HandleError(validateErr, "Invalid config")
|
HandleError(validateErr, "Failed to validate config")
|
||||||
|
|
||||||
// Parse users
|
// Logger
|
||||||
|
log.Logger = log.Level(zerolog.Level(config.LogLevel))
|
||||||
|
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
|
||||||
|
|
||||||
|
// Users
|
||||||
log.Info().Msg("Parsing users")
|
log.Info().Msg("Parsing users")
|
||||||
|
users, usersErr := utils.GetUsers(config.Users, config.UsersFile)
|
||||||
|
|
||||||
if config.UsersFile == "" && config.Users == "" {
|
HandleError(usersErr, "Failed to parse users")
|
||||||
log.Fatal().Msg("No users provided")
|
|
||||||
|
if len(users) == 0 && !utils.OAuthConfigured(config) {
|
||||||
|
HandleError(errors.New("no users or OAuth configured"), "No users or OAuth configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
usersString := config.Users
|
|
||||||
|
|
||||||
if config.UsersFile != "" {
|
|
||||||
log.Info().Msg("Reading users from file")
|
|
||||||
usersFromFile, readErr := utils.GetUsersFromFile(config.UsersFile)
|
|
||||||
HandleError(readErr, "Failed to read users from file")
|
|
||||||
usersFromFileParsed := utils.ParseFileToLine(usersFromFile)
|
|
||||||
if usersString != "" {
|
|
||||||
usersString = usersString + "," + usersFromFileParsed
|
|
||||||
} else {
|
|
||||||
usersString = usersFromFileParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
users, parseErr := utils.ParseUsers(usersString)
|
|
||||||
HandleError(parseErr, "Failed to parse users")
|
|
||||||
|
|
||||||
// Create oauth whitelist
|
// Create oauth whitelist
|
||||||
oauthWhitelist := utils.ParseCommaString(config.OAuthWhitelist)
|
oauthWhitelist := strings.Split(config.OAuthWhitelist, ",")
|
||||||
|
log.Debug().Msg("Parsed OAuth whitelist")
|
||||||
|
|
||||||
// Create OAuth config
|
// Create OAuth config
|
||||||
oauthConfig := types.OAuthConfig{
|
oauthConfig := types.OAuthConfig{
|
||||||
GithubClientId: config.GithubClientId,
|
GithubClientId: config.GithubClientId,
|
||||||
GithubClientSecret: config.GithubClientSecret,
|
GithubClientSecret: config.GithubClientSecret,
|
||||||
GoogleClientId: config.GoogleClientId,
|
GoogleClientId: config.GoogleClientId,
|
||||||
GoogleClientSecret: config.GoogleClientSecret,
|
GoogleClientSecret: config.GoogleClientSecret,
|
||||||
GenericClientId: config.GenericClientId,
|
TailscaleClientId: config.TailscaleClientId,
|
||||||
GenericClientSecret: config.GenericClientSecret,
|
TailscaleClientSecret: config.TailscaleClientSecret,
|
||||||
GenericScopes: utils.ParseCommaString(config.GenericScopes),
|
GenericClientId: config.GenericClientId,
|
||||||
GenericAuthURL: config.GenericAuthURL,
|
GenericClientSecret: config.GenericClientSecret,
|
||||||
GenericTokenURL: config.GenericTokenURL,
|
GenericScopes: strings.Split(config.GenericScopes, ","),
|
||||||
GenericUserURL: config.GenericUserURL,
|
GenericAuthURL: config.GenericAuthURL,
|
||||||
AppURL: config.AppURL,
|
GenericTokenURL: config.GenericTokenURL,
|
||||||
|
GenericUserURL: config.GenericUserURL,
|
||||||
|
AppURL: config.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Create auth service
|
||||||
auth := auth.NewAuth(users, oauthWhitelist)
|
auth := auth.NewAuth(docker, users, oauthWhitelist, config.SessionExpiry)
|
||||||
|
|
||||||
// Create OAuth providers service
|
// Create OAuth providers service
|
||||||
providers := providers.NewProviders(oauthConfig)
|
providers := providers.NewProviders(oauthConfig)
|
||||||
@@ -94,7 +111,7 @@ var rootCmd = &cobra.Command{
|
|||||||
AppURL: config.AppURL,
|
AppURL: config.AppURL,
|
||||||
CookieSecure: config.CookieSecure,
|
CookieSecure: config.CookieSecure,
|
||||||
DisableContinue: config.DisableContinue,
|
DisableContinue: config.DisableContinue,
|
||||||
CookieExpiry: config.CookieExpiry,
|
CookieExpiry: config.SessionExpiry,
|
||||||
}, hooks, auth, providers)
|
}, hooks, auth, providers)
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
@@ -108,59 +125,83 @@ var rootCmd = &cobra.Command{
|
|||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
err := rootCmd.Execute()
|
err := rootCmd.Execute()
|
||||||
if err != nil {
|
HandleError(err, "Failed to execute root command")
|
||||||
log.Fatal().Err(err).Msg("Failed to execute command")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleError(err error, msg string) {
|
func HandleError(err error, msg string) {
|
||||||
|
// If error log it and exit
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg(msg)
|
log.Fatal().Err(err).Msg(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
// Add user command
|
||||||
rootCmd.AddCommand(cmd.UserCmd())
|
rootCmd.AddCommand(cmd.UserCmd())
|
||||||
|
|
||||||
|
// Read environment variables
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
|
// Flags
|
||||||
rootCmd.Flags().Int("port", 3000, "Port to run the server on.")
|
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("address", "0.0.0.0", "Address to bind the server to.")
|
||||||
rootCmd.Flags().String("secret", "", "Secret to use for the cookie.")
|
rootCmd.Flags().String("secret", "", "Secret to use for the cookie.")
|
||||||
|
rootCmd.Flags().String("secret-file", "", "Path to a file containing the secret.")
|
||||||
rootCmd.Flags().String("app-url", "", "The tinyauth URL.")
|
rootCmd.Flags().String("app-url", "", "The tinyauth URL.")
|
||||||
rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:bcrypt-hashed-password.")
|
rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:hash.")
|
||||||
rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:bcrypt-hashed-password.")
|
rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:hash.")
|
||||||
rootCmd.Flags().Bool("cookie-secure", false, "Send cookie over secure connection only.")
|
rootCmd.Flags().Bool("cookie-secure", false, "Send cookie over secure connection only.")
|
||||||
rootCmd.Flags().String("github-client-id", "", "Github OAuth client ID.")
|
rootCmd.Flags().String("github-client-id", "", "Github OAuth client ID.")
|
||||||
rootCmd.Flags().String("github-client-secret", "", "Github OAuth client secret.")
|
rootCmd.Flags().String("github-client-secret", "", "Github OAuth client secret.")
|
||||||
|
rootCmd.Flags().String("github-client-secret-file", "", "Github OAuth client secret file.")
|
||||||
rootCmd.Flags().String("google-client-id", "", "Google OAuth client ID.")
|
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", "", "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-id", "", "Generic OAuth client ID.")
|
||||||
rootCmd.Flags().String("generic-client-secret", "", "Generic OAuth client secret.")
|
rootCmd.Flags().String("generic-client-secret", "", "Generic OAuth client secret.")
|
||||||
|
rootCmd.Flags().String("generic-client-secret-file", "", "Generic OAuth client secret file.")
|
||||||
rootCmd.Flags().String("generic-scopes", "", "Generic OAuth scopes.")
|
rootCmd.Flags().String("generic-scopes", "", "Generic OAuth scopes.")
|
||||||
rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.")
|
rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.")
|
||||||
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
|
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
|
||||||
rootCmd.Flags().String("generic-user-url", "", "Generic OAuth user info URL.")
|
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().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().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("port", "PORT")
|
||||||
viper.BindEnv("address", "ADDRESS")
|
viper.BindEnv("address", "ADDRESS")
|
||||||
viper.BindEnv("secret", "SECRET")
|
viper.BindEnv("secret", "SECRET")
|
||||||
|
viper.BindEnv("secret-file", "SECRET_FILE")
|
||||||
viper.BindEnv("app-url", "APP_URL")
|
viper.BindEnv("app-url", "APP_URL")
|
||||||
viper.BindEnv("users", "USERS")
|
viper.BindEnv("users", "USERS")
|
||||||
viper.BindEnv("users-file", "USERS_FILE")
|
viper.BindEnv("users-file", "USERS_FILE")
|
||||||
viper.BindEnv("cookie-secure", "COOKIE_SECURE")
|
viper.BindEnv("cookie-secure", "COOKIE_SECURE")
|
||||||
viper.BindEnv("github-client-id", "GITHUB_CLIENT_ID")
|
viper.BindEnv("github-client-id", "GITHUB_CLIENT_ID")
|
||||||
viper.BindEnv("github-client-secret", "GITHUB_CLIENT_SECRET")
|
viper.BindEnv("github-client-secret", "GITHUB_CLIENT_SECRET")
|
||||||
|
viper.BindEnv("github-client-secret-file", "GITHUB_CLIENT_SECRET_FILE")
|
||||||
viper.BindEnv("google-client-id", "GOOGLE_CLIENT_ID")
|
viper.BindEnv("google-client-id", "GOOGLE_CLIENT_ID")
|
||||||
viper.BindEnv("google-client-secret", "GOOGLE_CLIENT_SECRET")
|
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-id", "GENERIC_CLIENT_ID")
|
||||||
viper.BindEnv("generic-client-secret", "GENERIC_CLIENT_SECRET")
|
viper.BindEnv("generic-client-secret", "GENERIC_CLIENT_SECRET")
|
||||||
|
viper.BindEnv("generic-client-secret-file", "GENERIC_CLIENT_SECRET_FILE")
|
||||||
viper.BindEnv("generic-scopes", "GENERIC_SCOPES")
|
viper.BindEnv("generic-scopes", "GENERIC_SCOPES")
|
||||||
viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL")
|
viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL")
|
||||||
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
|
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
|
||||||
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
|
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
|
||||||
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
|
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
|
||||||
viper.BindEnv("oauth-whitelist", "WHITELIST")
|
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())
|
viper.BindPFlags(rootCmd.Flags())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -21,7 +22,12 @@ var CreateCmd = &cobra.Command{
|
|||||||
Short: "Create a user",
|
Short: "Create a user",
|
||||||
Long: `Create a user either interactively or by passing flags.`,
|
Long: `Create a user either interactively or by passing flags.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Setup logger
|
||||||
|
log.Logger = log.Level(zerolog.InfoLevel)
|
||||||
|
|
||||||
|
// Check if interactive
|
||||||
if interactive {
|
if interactive {
|
||||||
|
// Create huh form
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error {
|
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()
|
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||||
|
|
||||||
formErr := form.WithTheme(baseTheme).Run()
|
formErr := form.WithTheme(baseTheme).Run()
|
||||||
@@ -49,12 +56,14 @@ var CreateCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Do we have username and password?
|
||||||
if username == "" || password == "" {
|
if username == "" || password == "" {
|
||||||
log.Error().Msg("Username and password cannot be empty")
|
log.Error().Msg("Username and password cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user")
|
log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user")
|
||||||
|
|
||||||
|
// Hash password
|
||||||
passwordByte, passwordErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
passwordByte, passwordErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
|
||||||
if passwordErr != nil {
|
if passwordErr != nil {
|
||||||
@@ -63,15 +72,18 @@ var CreateCmd = &cobra.Command{
|
|||||||
|
|
||||||
passwordString := string(passwordByte)
|
passwordString := string(passwordByte)
|
||||||
|
|
||||||
|
// Escape $ for docker
|
||||||
if docker {
|
if docker {
|
||||||
passwordString = strings.ReplaceAll(passwordString, "$", "$$")
|
passwordString = strings.ReplaceAll(passwordString, "$", "$$")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log user created
|
||||||
log.Info().Str("user", fmt.Sprintf("%s:%s", username, passwordString)).Msg("User created")
|
log.Info().Str("user", fmt.Sprintf("%s:%s", username, passwordString)).Msg("User created")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
// Flags
|
||||||
CreateCmd.Flags().BoolVar(&interactive, "interactive", false, "Create a user interactively")
|
CreateCmd.Flags().BoolVar(&interactive, "interactive", false, "Create a user interactively")
|
||||||
CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker")
|
CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker")
|
||||||
CreateCmd.Flags().StringVar(&username, "username", "", "Username")
|
CreateCmd.Flags().StringVar(&username, "username", "", "Username")
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func UserCmd() *cobra.Command {
|
func UserCmd() *cobra.Command {
|
||||||
|
// Create the user command
|
||||||
userCmd := &cobra.Command{
|
userCmd := &cobra.Command{
|
||||||
Use: "user",
|
Use: "user",
|
||||||
Short: "User utilities",
|
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(create.CreateCmd)
|
||||||
userCmd.AddCommand(verify.VerifyCmd)
|
userCmd.AddCommand(verify.VerifyCmd)
|
||||||
|
|
||||||
|
// Return the user command
|
||||||
return userCmd
|
return userCmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -19,12 +20,17 @@ var user string
|
|||||||
var VerifyCmd = &cobra.Command{
|
var VerifyCmd = &cobra.Command{
|
||||||
Use: "verify",
|
Use: "verify",
|
||||||
Short: "Verify a user is set up correctly",
|
Short: "Verify a user is set up correctly",
|
||||||
Long: `Verify a user is set up correctly meaning that it has a correct password.`,
|
Long: `Verify a user is set up correctly meaning that it has a correct username and password.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Setup logger
|
||||||
|
log.Logger = log.Level(zerolog.InfoLevel)
|
||||||
|
|
||||||
|
// Check if interactive
|
||||||
if interactive {
|
if interactive {
|
||||||
|
// Create huh form
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().Title("User (user:hash)").Value(&user).Validate((func(s string) error {
|
huh.NewInput().Title("User (username:hash)").Value(&user).Validate((func(s string) error {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return errors.New("user cannot be empty")
|
return errors.New("user cannot be empty")
|
||||||
}
|
}
|
||||||
@@ -46,6 +52,7 @@ var VerifyCmd = &cobra.Command{
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Use simple theme
|
||||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||||
|
|
||||||
formErr := form.WithTheme(baseTheme).Run()
|
formErr := form.WithTheme(baseTheme).Run()
|
||||||
@@ -55,22 +62,26 @@ var VerifyCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Do we have username, password and user?
|
||||||
if username == "" || password == "" || user == "" {
|
if username == "" || password == "" || user == "" {
|
||||||
log.Fatal().Msg("Username, password and user cannot be empty")
|
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")
|
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, ":")
|
userSplit := strings.Split(user, ":")
|
||||||
|
|
||||||
if userSplit[1] == "" {
|
if userSplit[1] == "" {
|
||||||
log.Fatal().Msg("User is not formatted correctly")
|
log.Fatal().Msg("User is not formatted correctly")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace $$ with $ if formatted for docker
|
||||||
if docker {
|
if docker {
|
||||||
userSplit[1] = strings.ReplaceAll(userSplit[1], "$$", "$")
|
userSplit[1] = strings.ReplaceAll(userSplit[1], "$$", "$")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compare username and password
|
||||||
verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password))
|
verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password))
|
||||||
|
|
||||||
if verifyErr != nil || username != userSplit[0] {
|
if verifyErr != nil || username != userSplit[0] {
|
||||||
@@ -82,9 +93,10 @@ var VerifyCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
// Flags
|
||||||
VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
|
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().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
|
||||||
VerifyCmd.Flags().StringVar(&username, "username", "", "Username")
|
VerifyCmd.Flags().StringVar(&username, "username", "", "Username")
|
||||||
VerifyCmd.Flags().StringVar(&password, "password", "", "Password")
|
VerifyCmd.Flags().StringVar(&password, "password", "", "Password")
|
||||||
VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (user:hash combination)")
|
VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (username:hash combination)")
|
||||||
}
|
}
|
||||||
|
|||||||
17
go.mod
17
go.mod
@@ -14,6 +14,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Microsoft/go-winio v0.4.14 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.12.7 // 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/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
github.com/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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
|
github.com/go-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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.4 // indirect
|
github.com/goccy/go-json v0.10.4 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.2.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/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // 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/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/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.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/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.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/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
golang.org/x/arch v0.13.0 // indirect
|
golang.org/x/arch v0.13.0 // indirect
|
||||||
|
|||||||
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
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-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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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=
|
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 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/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.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 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
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/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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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/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 h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
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 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
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/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.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
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/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 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
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 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
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 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/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/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/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 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
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 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
|
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
|
||||||
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.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 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
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 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
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 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
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 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
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/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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
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-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 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -40,19 +41,32 @@ type API struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) Init() {
|
func (api *API) Init() {
|
||||||
|
// Disable gin logs
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
|
// Create router and use zerolog for logs
|
||||||
|
log.Debug().Msg("Setting up router")
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(zerolog())
|
router.Use(zerolog())
|
||||||
|
|
||||||
|
// Read UI assets
|
||||||
|
log.Debug().Msg("Setting up assets")
|
||||||
dist, distErr := fs.Sub(assets.Assets, "dist")
|
dist, distErr := fs.Sub(assets.Assets, "dist")
|
||||||
|
|
||||||
if distErr != nil {
|
if distErr != nil {
|
||||||
log.Fatal().Err(distErr).Msg("Failed to get UI assets")
|
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))
|
fileServer := http.FileServer(http.FS(dist))
|
||||||
|
|
||||||
|
// Setup cookie store
|
||||||
|
log.Debug().Msg("Setting up cookie store")
|
||||||
store := cookie.NewStore([]byte(api.Config.Secret))
|
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)
|
domain, domainErr := utils.GetRootURL(api.Config.AppURL)
|
||||||
|
|
||||||
if domainErr != nil {
|
if domainErr != nil {
|
||||||
@@ -64,6 +78,7 @@ func (api *API) Init() {
|
|||||||
|
|
||||||
api.Domain = fmt.Sprintf(".%s", domain)
|
api.Domain = fmt.Sprintf(".%s", domain)
|
||||||
|
|
||||||
|
// Use session middleware
|
||||||
store.Options(sessions.Options{
|
store.Options(sessions.Options{
|
||||||
Domain: api.Domain,
|
Domain: api.Domain,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
@@ -74,65 +89,169 @@ func (api *API) Init() {
|
|||||||
|
|
||||||
router.Use(sessions.Sessions("tinyauth", store))
|
router.Use(sessions.Sessions("tinyauth", store))
|
||||||
|
|
||||||
|
// UI middleware
|
||||||
router.Use(func(c *gin.Context) {
|
router.Use(func(c *gin.Context) {
|
||||||
|
// If not an API request, serve the UI
|
||||||
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
|
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
|
||||||
_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/"))
|
_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/"))
|
||||||
|
|
||||||
|
// If the file doesn't exist, serve the index.html
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
c.Request.URL.Path = "/"
|
c.Request.URL.Path = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serve the file
|
||||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
|
||||||
|
// Stop further processing
|
||||||
c.Abort()
|
c.Abort()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set router
|
||||||
api.Router = router
|
api.Router = router
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) SetupRoutes() {
|
func (api *API) SetupRoutes() {
|
||||||
api.Router.GET("/api/auth", func(c *gin.Context) {
|
api.Router.GET("/api/auth/:proxy", func(c *gin.Context) {
|
||||||
userContext, userContextErr := api.Hooks.UseUserContext(c)
|
// Create struct for proxy
|
||||||
|
var proxy types.Proxy
|
||||||
|
|
||||||
if userContextErr != nil {
|
// Bind URI
|
||||||
log.Error().Err(userContextErr).Msg("Failed to get user context")
|
bindErr := c.BindUri(&proxy)
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"status": 500,
|
// Handle error
|
||||||
"message": "Internal Server Error",
|
if api.handleError(c, "Failed to bind URI", bindErr) {
|
||||||
})
|
|
||||||
return
|
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 {
|
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{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Authenticated",
|
"message": "Authenticated",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Stop further processing
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uri := c.Request.Header.Get("X-Forwarded-Uri")
|
// The user is not logged in
|
||||||
proto := c.Request.Header.Get("X-Forwarded-Proto")
|
log.Debug().Msg("Unauthorized")
|
||||||
host := c.Request.Header.Get("X-Forwarded-Host")
|
|
||||||
queries, queryErr := query.Values(types.LoginQuery{
|
|
||||||
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
|
|
||||||
})
|
|
||||||
|
|
||||||
if queryErr != nil {
|
// Return 401 if nginx is the proxy or if the request is using an Authorization header
|
||||||
log.Error().Err(queryErr).Msg("Failed to build query")
|
if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" {
|
||||||
c.JSON(501, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 501,
|
"status": 401,
|
||||||
"message": "Internal Server Error",
|
"message": "Unauthorized",
|
||||||
})
|
})
|
||||||
return
|
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()))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode()))
|
||||||
})
|
})
|
||||||
|
|
||||||
api.Router.POST("/api/login", func(c *gin.Context) {
|
api.Router.POST("/api/login", func(c *gin.Context) {
|
||||||
|
// Create login struct
|
||||||
var login types.LoginRequest
|
var login types.LoginRequest
|
||||||
|
|
||||||
|
// Bind JSON
|
||||||
err := c.BindJSON(&login)
|
err := c.BindJSON(&login)
|
||||||
|
|
||||||
|
// Handle error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to bind JSON")
|
log.Error().Err(err).Msg("Failed to bind JSON")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
@@ -142,9 +261,14 @@ func (api *API) SetupRoutes() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := api.Auth.GetUser(login.Email)
|
log.Debug().Msg("Got login request")
|
||||||
|
|
||||||
|
// Get user based on username
|
||||||
|
user := api.Auth.GetUser(login.Username)
|
||||||
|
|
||||||
|
// User does not exist
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
log.Debug().Str("username", login.Username).Msg("User not found")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 401,
|
"status": 401,
|
||||||
"message": "Unauthorized",
|
"message": "Unauthorized",
|
||||||
@@ -152,7 +276,11 @@ func (api *API) SetupRoutes() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Got user")
|
||||||
|
|
||||||
|
// Check if password is correct
|
||||||
if !api.Auth.CheckPassword(*user, login.Password) {
|
if !api.Auth.CheckPassword(*user, login.Password) {
|
||||||
|
log.Debug().Str("username", login.Username).Msg("Password incorrect")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 401,
|
"status": 401,
|
||||||
"message": "Unauthorized",
|
"message": "Unauthorized",
|
||||||
@@ -160,10 +288,15 @@ func (api *API) SetupRoutes() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session := sessions.Default(c)
|
log.Debug().Msg("Password correct, logging in")
|
||||||
session.Set("tinyauth_sid", fmt.Sprintf("email:%s", login.Email))
|
|
||||||
session.Save()
|
|
||||||
|
|
||||||
|
// 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{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Logged in",
|
"message": "Logged in",
|
||||||
@@ -171,12 +304,17 @@ func (api *API) SetupRoutes() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
api.Router.POST("/api/logout", func(c *gin.Context) {
|
api.Router.POST("/api/logout", func(c *gin.Context) {
|
||||||
session := sessions.Default(c)
|
log.Debug().Msg("Logging out")
|
||||||
session.Delete("tinyauth_sid")
|
|
||||||
session.Save()
|
|
||||||
|
|
||||||
|
// 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)
|
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
|
||||||
|
|
||||||
|
// Return logged out
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Logged out",
|
"message": "Logged out",
|
||||||
@@ -184,55 +322,58 @@ func (api *API) SetupRoutes() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
api.Router.GET("/api/status", func(c *gin.Context) {
|
api.Router.GET("/api/status", func(c *gin.Context) {
|
||||||
userContext, userContextErr := api.Hooks.UseUserContext(c)
|
log.Debug().Msg("Checking status")
|
||||||
|
|
||||||
if userContextErr != nil {
|
// Get user context
|
||||||
log.Error().Err(userContextErr).Msg("Failed to get user context")
|
userContext := api.Hooks.UseUserContext(c)
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"status": 500,
|
// Get configured providers
|
||||||
"message": "Internal Server Error",
|
configuredProviders := api.Providers.GetConfiguredProviders()
|
||||||
})
|
|
||||||
return
|
// 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 {
|
if !userContext.IsLoggedIn {
|
||||||
|
log.Debug().Msg("Unauthorized")
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Unauthenticated",
|
"message": "Unauthorized",
|
||||||
"email": "",
|
"username": "",
|
||||||
"isLoggedIn": false,
|
"isLoggedIn": false,
|
||||||
"oauth": false,
|
"oauth": false,
|
||||||
"provider": "",
|
"provider": "",
|
||||||
"configuredProviders": api.Providers.GetConfiguredProviders(),
|
"configuredProviders": configuredProviders,
|
||||||
"disableContinue": api.Config.DisableContinue,
|
"disableContinue": api.Config.DisableContinue,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Authenticated",
|
"message": "Authenticated",
|
||||||
"email": userContext.Email,
|
"username": userContext.Username,
|
||||||
"isLoggedIn": userContext.IsLoggedIn,
|
"isLoggedIn": userContext.IsLoggedIn,
|
||||||
"oauth": userContext.OAuth,
|
"oauth": userContext.OAuth,
|
||||||
"provider": userContext.Provider,
|
"provider": userContext.Provider,
|
||||||
"configuredProviders": api.Providers.GetConfiguredProviders(),
|
"configuredProviders": configuredProviders,
|
||||||
"disableContinue": api.Config.DisableContinue,
|
"disableContinue": api.Config.DisableContinue,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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) {
|
api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) {
|
||||||
|
// Create struct for OAuth request
|
||||||
var request types.OAuthRequest
|
var request types.OAuthRequest
|
||||||
|
|
||||||
|
// Bind URI
|
||||||
bindErr := c.BindUri(&request)
|
bindErr := c.BindUri(&request)
|
||||||
|
|
||||||
|
// Handle error
|
||||||
if bindErr != nil {
|
if bindErr != nil {
|
||||||
log.Error().Err(bindErr).Msg("Failed to bind URI")
|
log.Error().Err(bindErr).Msg("Failed to bind URI")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
@@ -242,8 +383,12 @@ func (api *API) SetupRoutes() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Got OAuth request")
|
||||||
|
|
||||||
|
// Check if provider exists
|
||||||
provider := api.Providers.GetProvider(request.Provider)
|
provider := api.Providers.GetProvider(request.Provider)
|
||||||
|
|
||||||
|
// Provider does not exist
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
c.JSON(404, gin.H{
|
c.JSON(404, gin.H{
|
||||||
"status": 404,
|
"status": 404,
|
||||||
@@ -252,14 +397,49 @@ func (api *API) SetupRoutes() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Str("provider", request.Provider).Msg("Got provider")
|
||||||
|
|
||||||
|
// Get auth URL
|
||||||
authURL := provider.GetAuthURL()
|
authURL := provider.GetAuthURL()
|
||||||
|
|
||||||
|
log.Debug().Msg("Got auth URL")
|
||||||
|
|
||||||
|
// Get redirect URI
|
||||||
redirectURI := c.Query("redirect_uri")
|
redirectURI := c.Query("redirect_uri")
|
||||||
|
|
||||||
|
// Set redirect cookie if redirect URI is provided
|
||||||
if redirectURI != "" {
|
if redirectURI != "" {
|
||||||
|
log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie")
|
||||||
c.SetCookie("tinyauth_redirect_uri", redirectURI, 3600, "/", api.Domain, api.Config.CookieSecure, true)
|
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{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Ok",
|
"message": "Ok",
|
||||||
@@ -268,58 +448,92 @@ func (api *API) SetupRoutes() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
api.Router.GET("/api/oauth/callback/:provider", func(c *gin.Context) {
|
api.Router.GET("/api/oauth/callback/:provider", func(c *gin.Context) {
|
||||||
|
// Create struct for OAuth request
|
||||||
var providerName types.OAuthRequest
|
var providerName types.OAuthRequest
|
||||||
|
|
||||||
|
// Bind URI
|
||||||
bindErr := c.BindUri(&providerName)
|
bindErr := c.BindUri(&providerName)
|
||||||
|
|
||||||
if handleApiError(c, "Failed to bind URI", bindErr) {
|
// Handle error
|
||||||
|
if api.handleError(c, "Failed to bind URI", bindErr) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Interface("provider", providerName.Provider).Msg("Got provider name")
|
||||||
|
|
||||||
|
// Get code
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
|
|
||||||
|
// Code empty so redirect to error
|
||||||
if code == "" {
|
if code == "" {
|
||||||
log.Error().Msg("No code provided")
|
log.Error().Msg("No code provided")
|
||||||
c.Redirect(http.StatusPermanentRedirect, "/error")
|
c.Redirect(http.StatusPermanentRedirect, "/error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Got code")
|
||||||
|
|
||||||
|
// Get provider
|
||||||
provider := api.Providers.GetProvider(providerName.Provider)
|
provider := api.Providers.GetProvider(providerName.Provider)
|
||||||
|
|
||||||
|
log.Debug().Str("provider", providerName.Provider).Msg("Got provider")
|
||||||
|
|
||||||
|
// Provider does not exist
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
c.Redirect(http.StatusPermanentRedirect, "/not-found")
|
c.Redirect(http.StatusPermanentRedirect, "/not-found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, tokenErr := provider.ExchangeToken(code)
|
// Exchange token (authenticates user)
|
||||||
|
_, tokenErr := provider.ExchangeToken(code)
|
||||||
|
|
||||||
if handleApiError(c, "Failed to exchange token", tokenErr) {
|
log.Debug().Msg("Got token")
|
||||||
|
|
||||||
|
// Handle error
|
||||||
|
if api.handleError(c, "Failed to exchange token", tokenErr) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get email
|
||||||
email, emailErr := api.Providers.GetUser(providerName.Provider)
|
email, emailErr := api.Providers.GetUser(providerName.Provider)
|
||||||
|
|
||||||
if handleApiError(c, "Failed to get user", emailErr) {
|
log.Debug().Str("email", email).Msg("Got email")
|
||||||
|
|
||||||
|
// Handle error
|
||||||
|
if api.handleError(c, "Failed to get user", emailErr) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email is not whitelisted
|
||||||
if !api.Auth.EmailWhitelisted(email) {
|
if !api.Auth.EmailWhitelisted(email) {
|
||||||
log.Warn().Str("email", email).Msg("Email not whitelisted")
|
log.Warn().Str("email", email).Msg("Email not whitelisted")
|
||||||
|
|
||||||
|
// Build query
|
||||||
unauthorizedQuery, unauthorizedQueryErr := query.Values(types.UnauthorizedQuery{
|
unauthorizedQuery, unauthorizedQueryErr := query.Values(types.UnauthorizedQuery{
|
||||||
Email: email,
|
Username: email,
|
||||||
})
|
})
|
||||||
if handleApiError(c, "Failed to build query", unauthorizedQueryErr) {
|
|
||||||
|
// Handle error
|
||||||
|
if api.handleError(c, "Failed to build query", unauthorizedQueryErr) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect to unauthorized
|
||||||
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, unauthorizedQuery.Encode()))
|
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, unauthorizedQuery.Encode()))
|
||||||
}
|
}
|
||||||
|
|
||||||
session := sessions.Default(c)
|
log.Debug().Msg("Email whitelisted")
|
||||||
session.Set("tinyauth_sid", fmt.Sprintf("%s:%s", providerName.Provider, token))
|
|
||||||
session.Save()
|
|
||||||
|
|
||||||
|
// Create session cookie
|
||||||
|
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
|
||||||
|
Username: email,
|
||||||
|
Provider: providerName.Provider,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get redirect URI
|
||||||
redirectURI, redirectURIErr := c.Cookie("tinyauth_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 {
|
if redirectURIErr != nil {
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
@@ -327,38 +541,73 @@ 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)
|
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
|
||||||
|
|
||||||
|
// Build query
|
||||||
redirectQuery, redirectQueryErr := query.Values(types.LoginQuery{
|
redirectQuery, redirectQueryErr := query.Values(types.LoginQuery{
|
||||||
RedirectURI: redirectURI,
|
RedirectURI: redirectURI,
|
||||||
})
|
})
|
||||||
|
|
||||||
if handleApiError(c, "Failed to build query", redirectQueryErr) {
|
log.Debug().Msg("Got redirect query")
|
||||||
|
|
||||||
|
// Handle error
|
||||||
|
if api.handleError(c, "Failed to build query", redirectQueryErr) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect to continue with the redirect URI
|
||||||
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", api.Config.AppURL, redirectQuery.Encode()))
|
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() {
|
func (api *API) Run() {
|
||||||
log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server")
|
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))
|
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 {
|
func zerolog() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
// Get initial time
|
||||||
tStart := time.Now()
|
tStart := time.Now()
|
||||||
|
|
||||||
|
// Process request
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
|
// Get status code, address, method and path
|
||||||
code := c.Writer.Status()
|
code := c.Writer.Status()
|
||||||
address := c.Request.RemoteAddr
|
address := c.Request.RemoteAddr
|
||||||
method := c.Request.Method
|
method := c.Request.Method
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
|
// Get latency
|
||||||
latency := time.Since(tStart).String()
|
latency := time.Since(tStart).String()
|
||||||
|
|
||||||
|
// Log request
|
||||||
switch {
|
switch {
|
||||||
case code >= 200 && code < 300:
|
case code >= 200 && code < 300:
|
||||||
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
|
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
|
||||||
@@ -369,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"
|
"embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// UI assets
|
||||||
|
//
|
||||||
//go:embed dist
|
//go:embed dist
|
||||||
var Assets embed.FS
|
var Assets embed.FS
|
||||||
|
|
||||||
|
// Version file
|
||||||
|
//
|
||||||
//go:embed version
|
//go:embed version
|
||||||
var Version string
|
var Version string
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v1.0.0
|
v3.0.0
|
||||||
@@ -1,26 +1,39 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"tinyauth/internal/docker"
|
||||||
"tinyauth/internal/types"
|
"tinyauth/internal/types"
|
||||||
|
"tinyauth/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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{
|
return &Auth{
|
||||||
|
Docker: docker,
|
||||||
Users: userList,
|
Users: userList,
|
||||||
OAuthWhitelist: oauthWhitelist,
|
OAuthWhitelist: oauthWhitelist,
|
||||||
|
SessionExpiry: sessionExpiry,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
Users types.Users
|
Users types.Users
|
||||||
|
Docker *docker.Docker
|
||||||
OAuthWhitelist []string
|
OAuthWhitelist []string
|
||||||
|
SessionExpiry int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Auth) GetUser(email string) *types.User {
|
func (auth *Auth) GetUser(username string) *types.User {
|
||||||
|
// Loop through users and return the user if the username matches
|
||||||
for _, user := range auth.Users {
|
for _, user := range auth.Users {
|
||||||
if user.Email == email {
|
if user.Username == username {
|
||||||
return &user
|
return &user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,18 +41,208 @@ func (auth *Auth) GetUser(email string) *types.User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Auth) CheckPassword(user types.User, password string) bool {
|
func (auth *Auth) CheckPassword(user types.User, password string) bool {
|
||||||
hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
// Compare the hashed password with the password provided
|
||||||
return hashedPasswordErr == nil
|
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
|
func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
|
||||||
|
// If the whitelist is empty, allow all emails
|
||||||
if len(auth.OAuthWhitelist) == 0 {
|
if len(auth.OAuthWhitelist) == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loop through the whitelist and return true if the email matches
|
||||||
for _, email := range auth.OAuthWhitelist {
|
for _, email := range auth.OAuthWhitelist {
|
||||||
if email == emailSrc {
|
if email == emailSrc {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no emails match, return false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) {
|
||||||
|
log.Debug().Msg("Creating session cookie")
|
||||||
|
|
||||||
|
// Get session
|
||||||
|
sessions := sessions.Default(c)
|
||||||
|
|
||||||
|
log.Debug().Msg("Setting session cookie")
|
||||||
|
|
||||||
|
// Set data
|
||||||
|
sessions.Set("username", data.Username)
|
||||||
|
sessions.Set("provider", data.Provider)
|
||||||
|
sessions.Set("expiry", time.Now().Add(time.Duration(auth.SessionExpiry)*time.Second).Unix())
|
||||||
|
|
||||||
|
// Save session
|
||||||
|
sessions.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *Auth) DeleteSessionCookie(c *gin.Context) {
|
||||||
|
log.Debug().Msg("Deleting session cookie")
|
||||||
|
|
||||||
|
// Get session
|
||||||
|
sessions := sessions.Default(c)
|
||||||
|
|
||||||
|
// Clear session
|
||||||
|
sessions.Clear()
|
||||||
|
|
||||||
|
// Save session
|
||||||
|
sessions.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
|
||||||
|
log.Debug().Msg("Getting session cookie")
|
||||||
|
|
||||||
|
// Get session
|
||||||
|
sessions := sessions.Default(c)
|
||||||
|
|
||||||
|
// Get data
|
||||||
|
cookieUsername := sessions.Get("username")
|
||||||
|
cookieProvider := sessions.Get("provider")
|
||||||
|
cookieExpiry := sessions.Get("expiry")
|
||||||
|
|
||||||
|
// Convert interfaces to correct types
|
||||||
|
username, usernameOk := cookieUsername.(string)
|
||||||
|
provider, providerOk := cookieProvider.(string)
|
||||||
|
expiry, expiryOk := cookieExpiry.(int64)
|
||||||
|
|
||||||
|
// Check if the cookie is invalid
|
||||||
|
if !usernameOk || !providerOk || !expiryOk {
|
||||||
|
log.Warn().Msg("Session cookie invalid")
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
package hooks
|
package hooks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"tinyauth/internal/auth"
|
"tinyauth/internal/auth"
|
||||||
"tinyauth/internal/providers"
|
"tinyauth/internal/providers"
|
||||||
"tinyauth/internal/types"
|
"tinyauth/internal/types"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/oauth2"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewHooks(auth *auth.Auth, providers *providers.Providers) *Hooks {
|
func NewHooks(auth *auth.Auth, providers *providers.Providers) *Hooks {
|
||||||
@@ -23,103 +21,89 @@ type Hooks struct {
|
|||||||
Providers *providers.Providers
|
Providers *providers.Providers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext, error) {
|
func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
|
||||||
session := sessions.Default(c)
|
// Get session cookie and basic auth
|
||||||
sessionCookie := session.Get("tinyauth_sid")
|
cookie := hooks.Auth.GetSessionCookie(c)
|
||||||
|
basic := hooks.Auth.GetBasicAuth(c)
|
||||||
|
|
||||||
if sessionCookie == nil {
|
// Check if basic auth is set
|
||||||
return types.UserContext{
|
if basic.Username != "" {
|
||||||
Email: "",
|
log.Debug().Msg("Got basic auth")
|
||||||
IsLoggedIn: false,
|
|
||||||
OAuth: false,
|
|
||||||
Provider: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data, dataOk := sessionCookie.(string)
|
// Check if user exists and password is correct
|
||||||
|
user := hooks.Auth.GetUser(basic.Username)
|
||||||
|
|
||||||
if !dataOk {
|
if user != nil && hooks.Auth.CheckPassword(*user, basic.Password) {
|
||||||
return types.UserContext{
|
// Return user context since we are logged in with basic auth
|
||||||
Email: "",
|
|
||||||
IsLoggedIn: false,
|
|
||||||
OAuth: false,
|
|
||||||
Provider: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
split := strings.Split(data, ":")
|
|
||||||
|
|
||||||
if len(split) != 2 {
|
|
||||||
return types.UserContext{
|
|
||||||
Email: "",
|
|
||||||
IsLoggedIn: false,
|
|
||||||
OAuth: false,
|
|
||||||
Provider: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionType := split[0]
|
|
||||||
sessionValue := split[1]
|
|
||||||
|
|
||||||
if sessionType == "email" {
|
|
||||||
user := hooks.Auth.GetUser(sessionValue)
|
|
||||||
if user == nil {
|
|
||||||
return types.UserContext{
|
return types.UserContext{
|
||||||
Email: "",
|
Username: basic.Username,
|
||||||
|
IsLoggedIn: true,
|
||||||
|
OAuth: false,
|
||||||
|
Provider: "basic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session cookie is username/password auth
|
||||||
|
if cookie.Provider == "username" {
|
||||||
|
log.Debug().Msg("Provider is username")
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
if hooks.Auth.GetUser(cookie.Username) != nil {
|
||||||
|
log.Debug().Msg("User exists")
|
||||||
|
|
||||||
|
// It exists so we are logged in
|
||||||
|
return types.UserContext{
|
||||||
|
Username: cookie.Username,
|
||||||
|
IsLoggedIn: true,
|
||||||
|
OAuth: false,
|
||||||
|
Provider: "username",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Provider is not username")
|
||||||
|
|
||||||
|
// The provider is not username so we need to check if it is an oauth provider
|
||||||
|
provider := hooks.Providers.GetProvider(cookie.Provider)
|
||||||
|
|
||||||
|
// If we have a provider with this name
|
||||||
|
if provider != nil {
|
||||||
|
log.Debug().Msg("Provider exists")
|
||||||
|
|
||||||
|
// Check if the oauth email is whitelisted
|
||||||
|
if !hooks.Auth.EmailWhitelisted(cookie.Username) {
|
||||||
|
log.Error().Str("email", cookie.Username).Msg("Email is not whitelisted")
|
||||||
|
|
||||||
|
// It isn't so we delete the cookie and return an empty context
|
||||||
|
hooks.Auth.DeleteSessionCookie(c)
|
||||||
|
|
||||||
|
// Return empty context
|
||||||
|
return types.UserContext{
|
||||||
|
Username: "",
|
||||||
IsLoggedIn: false,
|
IsLoggedIn: false,
|
||||||
OAuth: false,
|
OAuth: false,
|
||||||
Provider: "",
|
Provider: "",
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Email is whitelisted")
|
||||||
|
|
||||||
|
// Return user context since we are logged in with oauth
|
||||||
return types.UserContext{
|
return types.UserContext{
|
||||||
Email: sessionValue,
|
Username: cookie.Username,
|
||||||
IsLoggedIn: true,
|
IsLoggedIn: true,
|
||||||
OAuth: false,
|
OAuth: true,
|
||||||
Provider: "",
|
Provider: cookie.Provider,
|
||||||
}, nil
|
}
|
||||||
}
|
|
||||||
|
|
||||||
provider := hooks.Providers.GetProvider(sessionType)
|
|
||||||
|
|
||||||
if provider == nil {
|
|
||||||
return types.UserContext{
|
|
||||||
Email: "",
|
|
||||||
IsLoggedIn: false,
|
|
||||||
OAuth: false,
|
|
||||||
Provider: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
provider.Token = &oauth2.Token{
|
|
||||||
AccessToken: sessionValue,
|
|
||||||
}
|
|
||||||
|
|
||||||
email, emailErr := hooks.Providers.GetUser(sessionType)
|
|
||||||
|
|
||||||
if emailErr != nil {
|
|
||||||
return types.UserContext{
|
|
||||||
Email: "",
|
|
||||||
IsLoggedIn: false,
|
|
||||||
OAuth: false,
|
|
||||||
Provider: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hooks.Auth.EmailWhitelisted(email) {
|
|
||||||
session.Delete("tinyauth_sid")
|
|
||||||
session.Save()
|
|
||||||
return types.UserContext{
|
|
||||||
Email: "",
|
|
||||||
IsLoggedIn: false,
|
|
||||||
OAuth: false,
|
|
||||||
Provider: "",
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neither basic auth or oauth is set so we return an empty context
|
||||||
return types.UserContext{
|
return types.UserContext{
|
||||||
Email: email,
|
Username: "",
|
||||||
IsLoggedIn: true,
|
IsLoggedIn: false,
|
||||||
OAuth: true,
|
OAuth: false,
|
||||||
Provider: sessionType,
|
Provider: "",
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,23 +21,33 @@ type OAuth struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (oauth *OAuth) Init() {
|
func (oauth *OAuth) Init() {
|
||||||
|
// Create a new context and verifier
|
||||||
oauth.Context = context.Background()
|
oauth.Context = context.Background()
|
||||||
oauth.Verifier = oauth2.GenerateVerifier()
|
oauth.Verifier = oauth2.GenerateVerifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oauth *OAuth) GetAuthURL() string {
|
func (oauth *OAuth) GetAuthURL() string {
|
||||||
|
// Return the auth url
|
||||||
return oauth.Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier))
|
return oauth.Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oauth *OAuth) ExchangeToken(code string) (string, error) {
|
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))
|
token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier))
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the token
|
||||||
oauth.Token = token
|
oauth.Token = token
|
||||||
|
|
||||||
|
// Return the access token
|
||||||
return oauth.Token.AccessToken, nil
|
return oauth.Token.AccessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oauth *OAuth) GetClient() *http.Client {
|
func (oauth *OAuth) GetClient() *http.Client {
|
||||||
|
// Return the http client with the token set
|
||||||
return oauth.Config.Client(oauth.Context, oauth.Token)
|
return oauth.Config.Client(oauth.Context, oauth.Token)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,32 +4,49 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// We are assuming that the generic provider will return a JSON object with an email field
|
||||||
type GenericUserInfoResponse struct {
|
type GenericUserInfoResponse struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetGenericEmail(client *http.Client, url string) (string, error) {
|
func GetGenericEmail(client *http.Client, url string) (string, error) {
|
||||||
|
// Using the oauth client get the user info url
|
||||||
res, resErr := client.Get(url)
|
res, resErr := client.Get(url)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if resErr != nil {
|
if resErr != nil {
|
||||||
return "", resErr
|
return "", resErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Got response from generic provider")
|
||||||
|
|
||||||
|
// Read the body of the response
|
||||||
body, bodyErr := io.ReadAll(res.Body)
|
body, bodyErr := io.ReadAll(res.Body)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if bodyErr != nil {
|
if bodyErr != nil {
|
||||||
return "", bodyErr
|
return "", bodyErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Read body from generic provider")
|
||||||
|
|
||||||
|
// Parse the body into a user struct
|
||||||
var user GenericUserInfoResponse
|
var user GenericUserInfoResponse
|
||||||
|
|
||||||
|
// Unmarshal the body into the user struct
|
||||||
jsonErr := json.Unmarshal(body, &user)
|
jsonErr := json.Unmarshal(body, &user)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if jsonErr != nil {
|
if jsonErr != nil {
|
||||||
return "", jsonErr
|
return "", jsonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Parsed user from generic provider")
|
||||||
|
|
||||||
|
// Return the email
|
||||||
return user.Email, nil
|
return user.Email, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,43 +5,62 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Github has a different response than the generic provider
|
||||||
type GithubUserInfoResponse []struct {
|
type GithubUserInfoResponse []struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Primary bool `json:"primary"`
|
Primary bool `json:"primary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The scopes required for the github provider
|
||||||
func GithubScopes() []string {
|
func GithubScopes() []string {
|
||||||
return []string{"user:email"}
|
return []string{"user:email"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetGithubEmail(client *http.Client) (string, error) {
|
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")
|
res, resErr := client.Get("https://api.github.com/user/emails")
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if resErr != nil {
|
if resErr != nil {
|
||||||
return "", resErr
|
return "", resErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Got response from github")
|
||||||
|
|
||||||
|
// Read the body of the response
|
||||||
body, bodyErr := io.ReadAll(res.Body)
|
body, bodyErr := io.ReadAll(res.Body)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if bodyErr != nil {
|
if bodyErr != nil {
|
||||||
return "", bodyErr
|
return "", bodyErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Read body from github")
|
||||||
|
|
||||||
|
// Parse the body into a user struct
|
||||||
var emails GithubUserInfoResponse
|
var emails GithubUserInfoResponse
|
||||||
|
|
||||||
|
// Unmarshal the body into the user struct
|
||||||
jsonErr := json.Unmarshal(body, &emails)
|
jsonErr := json.Unmarshal(body, &emails)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if jsonErr != nil {
|
if jsonErr != nil {
|
||||||
return "", jsonErr
|
return "", jsonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Parsed emails from github")
|
||||||
|
|
||||||
|
// Find and return the primary email
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
if email.Primary {
|
if email.Primary {
|
||||||
return email.Email, nil
|
return email.Email, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User does not have a primary email?
|
||||||
return "", errors.New("no primary email found")
|
return "", errors.New("no primary email found")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,36 +4,54 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Google works the same as the generic provider
|
||||||
type GoogleUserInfoResponse struct {
|
type GoogleUserInfoResponse struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The scopes required for the google provider
|
||||||
func GoogleScopes() []string {
|
func GoogleScopes() []string {
|
||||||
return []string{"https://www.googleapis.com/auth/userinfo.email"}
|
return []string{"https://www.googleapis.com/auth/userinfo.email"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetGoogleEmail(client *http.Client) (string, error) {
|
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")
|
res, resErr := client.Get("https://www.googleapis.com/userinfo/v2/me")
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if resErr != nil {
|
if resErr != nil {
|
||||||
return "", resErr
|
return "", resErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Got response from google")
|
||||||
|
|
||||||
|
// Read the body of the response
|
||||||
body, bodyErr := io.ReadAll(res.Body)
|
body, bodyErr := io.ReadAll(res.Body)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if bodyErr != nil {
|
if bodyErr != nil {
|
||||||
return "", bodyErr
|
return "", bodyErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Read body from google")
|
||||||
|
|
||||||
|
// Parse the body into a user struct
|
||||||
var user GoogleUserInfoResponse
|
var user GoogleUserInfoResponse
|
||||||
|
|
||||||
|
// Unmarshal the body into the user struct
|
||||||
jsonErr := json.Unmarshal(body, &user)
|
jsonErr := json.Unmarshal(body, &user)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if jsonErr != nil {
|
if jsonErr != nil {
|
||||||
return "", jsonErr
|
return "", jsonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Parsed user from google")
|
||||||
|
|
||||||
|
// Return the email
|
||||||
return user.Email, nil
|
return user.Email, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,19 @@ func NewProviders(config types.OAuthConfig) *Providers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Providers struct {
|
type Providers struct {
|
||||||
Config types.OAuthConfig
|
Config types.OAuthConfig
|
||||||
Github *oauth.OAuth
|
Github *oauth.OAuth
|
||||||
Google *oauth.OAuth
|
Google *oauth.OAuth
|
||||||
Generic *oauth.OAuth
|
Tailscale *oauth.OAuth
|
||||||
|
Generic *oauth.OAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
func (providers *Providers) Init() {
|
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 != "" {
|
if providers.Config.GithubClientId != "" && providers.Config.GithubClientSecret != "" {
|
||||||
log.Info().Msg("Initializing Github OAuth")
|
log.Info().Msg("Initializing Github OAuth")
|
||||||
|
|
||||||
|
// Create a new oauth provider with the github config
|
||||||
providers.Github = oauth.NewOAuth(oauth2.Config{
|
providers.Github = oauth.NewOAuth(oauth2.Config{
|
||||||
ClientID: providers.Config.GithubClientId,
|
ClientID: providers.Config.GithubClientId,
|
||||||
ClientSecret: providers.Config.GithubClientSecret,
|
ClientSecret: providers.Config.GithubClientSecret,
|
||||||
@@ -33,10 +37,16 @@ func (providers *Providers) Init() {
|
|||||||
Scopes: GithubScopes(),
|
Scopes: GithubScopes(),
|
||||||
Endpoint: endpoints.GitHub,
|
Endpoint: endpoints.GitHub,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize the oauth provider
|
||||||
providers.Github.Init()
|
providers.Github.Init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we have a client id and secret for google, initialize the oauth provider
|
||||||
if providers.Config.GoogleClientId != "" && providers.Config.GoogleClientSecret != "" {
|
if providers.Config.GoogleClientId != "" && providers.Config.GoogleClientSecret != "" {
|
||||||
log.Info().Msg("Initializing Google OAuth")
|
log.Info().Msg("Initializing Google OAuth")
|
||||||
|
|
||||||
|
// Create a new oauth provider with the google config
|
||||||
providers.Google = oauth.NewOAuth(oauth2.Config{
|
providers.Google = oauth.NewOAuth(oauth2.Config{
|
||||||
ClientID: providers.Config.GoogleClientId,
|
ClientID: providers.Config.GoogleClientId,
|
||||||
ClientSecret: providers.Config.GoogleClientSecret,
|
ClientSecret: providers.Config.GoogleClientSecret,
|
||||||
@@ -44,10 +54,32 @@ func (providers *Providers) Init() {
|
|||||||
Scopes: GoogleScopes(),
|
Scopes: GoogleScopes(),
|
||||||
Endpoint: endpoints.Google,
|
Endpoint: endpoints.Google,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize the oauth provider
|
||||||
providers.Google.Init()
|
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 != "" {
|
if providers.Config.GenericClientId != "" && providers.Config.GenericClientSecret != "" {
|
||||||
log.Info().Msg("Initializing Generic OAuth")
|
log.Info().Msg("Initializing Generic OAuth")
|
||||||
|
|
||||||
|
// Create a new oauth provider with the generic config
|
||||||
providers.Generic = oauth.NewOAuth(oauth2.Config{
|
providers.Generic = oauth.NewOAuth(oauth2.Config{
|
||||||
ClientID: providers.Config.GenericClientId,
|
ClientID: providers.Config.GenericClientId,
|
||||||
ClientSecret: providers.Config.GenericClientSecret,
|
ClientSecret: providers.Config.GenericClientSecret,
|
||||||
@@ -58,16 +90,21 @@ func (providers *Providers) Init() {
|
|||||||
TokenURL: providers.Config.GenericTokenURL,
|
TokenURL: providers.Config.GenericTokenURL,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize the oauth provider
|
||||||
providers.Generic.Init()
|
providers.Generic.Init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (providers *Providers) GetProvider(provider string) *oauth.OAuth {
|
func (providers *Providers) GetProvider(provider string) *oauth.OAuth {
|
||||||
|
// Return the provider based on the provider string
|
||||||
switch provider {
|
switch provider {
|
||||||
case "github":
|
case "github":
|
||||||
return providers.Github
|
return providers.Github
|
||||||
case "google":
|
case "google":
|
||||||
return providers.Google
|
return providers.Google
|
||||||
|
case "tailscale":
|
||||||
|
return providers.Tailscale
|
||||||
case "generic":
|
case "generic":
|
||||||
return providers.Generic
|
return providers.Generic
|
||||||
default:
|
default:
|
||||||
@@ -76,36 +113,103 @@ func (providers *Providers) GetProvider(provider string) *oauth.OAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (providers *Providers) GetUser(provider string) (string, error) {
|
func (providers *Providers) GetUser(provider string) (string, error) {
|
||||||
|
// Get the email from the provider
|
||||||
switch provider {
|
switch provider {
|
||||||
case "github":
|
case "github":
|
||||||
|
// If the github provider is not configured, return an error
|
||||||
if providers.Github == nil {
|
if providers.Github == nil {
|
||||||
|
log.Debug().Msg("Github provider not configured")
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the client from the github provider
|
||||||
client := providers.Github.GetClient()
|
client := providers.Github.GetClient()
|
||||||
|
|
||||||
|
log.Debug().Msg("Got client from github")
|
||||||
|
|
||||||
|
// Get the email from the github provider
|
||||||
email, emailErr := GetGithubEmail(client)
|
email, emailErr := GetGithubEmail(client)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if emailErr != nil {
|
if emailErr != nil {
|
||||||
return "", emailErr
|
return "", emailErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Got email from github")
|
||||||
|
|
||||||
|
// Return the email
|
||||||
return email, nil
|
return email, nil
|
||||||
case "google":
|
case "google":
|
||||||
|
// If the google provider is not configured, return an error
|
||||||
if providers.Google == nil {
|
if providers.Google == nil {
|
||||||
|
log.Debug().Msg("Google provider not configured")
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the client from the google provider
|
||||||
client := providers.Google.GetClient()
|
client := providers.Google.GetClient()
|
||||||
|
|
||||||
|
log.Debug().Msg("Got client from google")
|
||||||
|
|
||||||
|
// Get the email from the google provider
|
||||||
email, emailErr := GetGoogleEmail(client)
|
email, emailErr := GetGoogleEmail(client)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if emailErr != nil {
|
if emailErr != nil {
|
||||||
return "", emailErr
|
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
|
return email, nil
|
||||||
case "generic":
|
case "generic":
|
||||||
|
// If the generic provider is not configured, return an error
|
||||||
if providers.Generic == nil {
|
if providers.Generic == nil {
|
||||||
|
log.Debug().Msg("Generic provider not configured")
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the client from the generic provider
|
||||||
client := providers.Generic.GetClient()
|
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)
|
email, emailErr := GetGenericEmail(client, providers.Config.GenericUserURL)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if emailErr != nil {
|
if emailErr != nil {
|
||||||
return "", emailErr
|
return "", emailErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Got email from generic")
|
||||||
|
|
||||||
|
// Return the email
|
||||||
return email, nil
|
return email, nil
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -113,6 +217,7 @@ func (providers *Providers) GetUser(provider string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (provider *Providers) GetConfiguredProviders() []string {
|
func (provider *Providers) GetConfiguredProviders() []string {
|
||||||
|
// Create a list of the configured providers
|
||||||
providers := []string{}
|
providers := []string{}
|
||||||
if provider.Github != nil {
|
if provider.Github != nil {
|
||||||
providers = append(providers, "github")
|
providers = append(providers, "github")
|
||||||
@@ -120,6 +225,9 @@ func (provider *Providers) GetConfiguredProviders() []string {
|
|||||||
if provider.Google != nil {
|
if provider.Google != nil {
|
||||||
providers = append(providers, "google")
|
providers = append(providers, "google")
|
||||||
}
|
}
|
||||||
|
if provider.Tailscale != nil {
|
||||||
|
providers = append(providers, "tailscale")
|
||||||
|
}
|
||||||
if provider.Generic != nil {
|
if provider.Generic != nil {
|
||||||
providers = append(providers, "generic")
|
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,52 +2,67 @@ package types
|
|||||||
|
|
||||||
import "tinyauth/internal/oauth"
|
import "tinyauth/internal/oauth"
|
||||||
|
|
||||||
|
// LoginQuery is the query parameters for the login endpoint
|
||||||
type LoginQuery struct {
|
type LoginQuery struct {
|
||||||
RedirectURI string `url:"redirect_uri"`
|
RedirectURI string `url:"redirect_uri"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginRequest is the request body for the login endpoint
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Email string `json:"email"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User is the struct for a user
|
||||||
type User struct {
|
type User struct {
|
||||||
Email string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Users is a list of users
|
||||||
type Users []User
|
type Users []User
|
||||||
|
|
||||||
|
// Config is the configuration for the tinyauth server
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int `validate:"number" mapstructure:"port"`
|
Port int `mapstructure:"port" validate:"required"`
|
||||||
Address string `mapstructure:"address, ip4_addr"`
|
Address string `validate:"required,ip4_addr" mapstructure:"address"`
|
||||||
Secret string `validate:"required,len=32" mapstructure:"secret"`
|
Secret string `validate:"required,len=32" mapstructure:"secret"`
|
||||||
AppURL string `validate:"required,url" mapstructure:"app-url"`
|
SecretFile string `mapstructure:"secret-file"`
|
||||||
Users string `mapstructure:"users"`
|
AppURL string `validate:"required,url" mapstructure:"app-url"`
|
||||||
UsersFile string `mapstructure:"users-file"`
|
Users string `mapstructure:"users"`
|
||||||
CookieSecure bool `mapstructure:"cookie-secure"`
|
UsersFile string `mapstructure:"users-file"`
|
||||||
GithubClientId string `mapstructure:"github-client-id"`
|
CookieSecure bool `mapstructure:"cookie-secure"`
|
||||||
GithubClientSecret string `mapstructure:"github-client-secret"`
|
GithubClientId string `mapstructure:"github-client-id"`
|
||||||
GoogleClientId string `mapstructure:"google-client-id"`
|
GithubClientSecret string `mapstructure:"github-client-secret"`
|
||||||
GoogleClientSecret string `mapstructure:"google-client-secret"`
|
GithubClientSecretFile string `mapstructure:"github-client-secret-file"`
|
||||||
GenericClientId string `mapstructure:"generic-client-id"`
|
GoogleClientId string `mapstructure:"google-client-id"`
|
||||||
GenericClientSecret string `mapstructure:"generic-client-secret"`
|
GoogleClientSecret string `mapstructure:"google-client-secret"`
|
||||||
GenericScopes string `mapstructure:"generic-scopes"`
|
GoogleClientSecretFile string `mapstructure:"google-client-secret-file"`
|
||||||
GenericAuthURL string `mapstructure:"generic-auth-url"`
|
TailscaleClientId string `mapstructure:"tailscale-client-id"`
|
||||||
GenericTokenURL string `mapstructure:"generic-token-url"`
|
TailscaleClientSecret string `mapstructure:"tailscale-client-secret"`
|
||||||
GenericUserURL string `mapstructure:"generic-user-info-url"`
|
TailscaleClientSecretFile string `mapstructure:"tailscale-client-secret-file"`
|
||||||
DisableContinue bool `mapstructure:"disable-continue"`
|
GenericClientId string `mapstructure:"generic-client-id"`
|
||||||
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
GenericClientSecret string `mapstructure:"generic-client-secret"`
|
||||||
CookieExpiry int `mapstructure:"cookie-expiry"`
|
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 {
|
type UserContext struct {
|
||||||
Email string
|
Username string
|
||||||
IsLoggedIn bool
|
IsLoggedIn bool
|
||||||
OAuth bool
|
OAuth bool
|
||||||
Provider string
|
Provider string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIConfig is the configuration for the API
|
||||||
type APIConfig struct {
|
type APIConfig struct {
|
||||||
Port int
|
Port int
|
||||||
Address string
|
Address string
|
||||||
@@ -58,30 +73,59 @@ type APIConfig struct {
|
|||||||
DisableContinue bool
|
DisableContinue bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuthConfig is the configuration for the providers
|
||||||
type OAuthConfig struct {
|
type OAuthConfig struct {
|
||||||
GithubClientId string
|
GithubClientId string
|
||||||
GithubClientSecret string
|
GithubClientSecret string
|
||||||
GoogleClientId string
|
GoogleClientId string
|
||||||
GoogleClientSecret string
|
GoogleClientSecret string
|
||||||
GenericClientId string
|
TailscaleClientId string
|
||||||
GenericClientSecret string
|
TailscaleClientSecret string
|
||||||
GenericScopes []string
|
GenericClientId string
|
||||||
GenericAuthURL string
|
GenericClientSecret string
|
||||||
GenericTokenURL string
|
GenericScopes []string
|
||||||
GenericUserURL string
|
GenericAuthURL string
|
||||||
AppURL string
|
GenericTokenURL string
|
||||||
|
GenericUserURL string
|
||||||
|
AppURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuthRequest is the request for the OAuth endpoint
|
||||||
type OAuthRequest struct {
|
type OAuthRequest struct {
|
||||||
Provider string `uri:"provider" binding:"required"`
|
Provider string `uri:"provider" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuthProviders is the struct for the OAuth providers
|
||||||
type OAuthProviders struct {
|
type OAuthProviders struct {
|
||||||
Github *oauth.OAuth
|
Github *oauth.OAuth
|
||||||
Google *oauth.OAuth
|
Google *oauth.OAuth
|
||||||
Microsoft *oauth.OAuth
|
Microsoft *oauth.OAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnauthorizedQuery is the query parameters for the unauthorized endpoint
|
||||||
type UnauthorizedQuery struct {
|
type UnauthorizedQuery struct {
|
||||||
Email string `url:"email"`
|
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,80 +4,206 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"tinyauth/internal/constants"
|
||||||
"tinyauth/internal/types"
|
"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) {
|
func ParseUsers(users string) (types.Users, error) {
|
||||||
|
log.Debug().Msg("Parsing users")
|
||||||
|
|
||||||
|
// Create a new users struct
|
||||||
var usersParsed types.Users
|
var usersParsed types.Users
|
||||||
|
|
||||||
|
// Split the users by comma
|
||||||
userList := strings.Split(users, ",")
|
userList := strings.Split(users, ",")
|
||||||
|
|
||||||
|
// Check if there are any users
|
||||||
if len(userList) == 0 {
|
if len(userList) == 0 {
|
||||||
return types.Users{}, errors.New("invalid user format")
|
return types.Users{}, errors.New("invalid user format")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loop through the users and split them by colon
|
||||||
for _, user := range userList {
|
for _, user := range userList {
|
||||||
|
// Split the user by colon
|
||||||
userSplit := strings.Split(user, ":")
|
userSplit := strings.Split(user, ":")
|
||||||
|
|
||||||
|
// Check if the user is in the correct format
|
||||||
if len(userSplit) != 2 {
|
if len(userSplit) != 2 {
|
||||||
return types.Users{}, errors.New("invalid user format")
|
return types.Users{}, errors.New("invalid user format")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append the user to the users struct
|
||||||
usersParsed = append(usersParsed, types.User{
|
usersParsed = append(usersParsed, types.User{
|
||||||
Email: userSplit[0],
|
Username: userSplit[0],
|
||||||
Password: userSplit[1],
|
Password: userSplit[1],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Parsed users")
|
||||||
|
|
||||||
|
// Return the users struct
|
||||||
return usersParsed, nil
|
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) {
|
func GetRootURL(urlSrc string) (string, error) {
|
||||||
|
// Make sure the url is valid
|
||||||
urlParsed, parseErr := url.Parse(urlSrc)
|
urlParsed, parseErr := url.Parse(urlSrc)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", parseErr
|
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:], ".")
|
urlFinal := strings.Join(urlSplitted[1:], ".")
|
||||||
|
|
||||||
|
// Return the root domain
|
||||||
return urlFinal, nil
|
return urlFinal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUsersFromFile(usersFile string) (string, error) {
|
// Reads a file and returns the contents
|
||||||
_, statErr := os.Stat(usersFile)
|
func ReadFile(file string) (string, error) {
|
||||||
|
// Check if the file exists
|
||||||
|
_, statErr := os.Stat(file)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if statErr != nil {
|
if statErr != nil {
|
||||||
return "", statErr
|
return "", statErr
|
||||||
}
|
}
|
||||||
|
|
||||||
data, readErr := os.ReadFile(usersFile)
|
// Read the file
|
||||||
|
data, readErr := os.ReadFile(file)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return "", readErr
|
return "", readErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return the file contents
|
||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parses a file into a comma separated list of users
|
||||||
func ParseFileToLine(content string) string {
|
func ParseFileToLine(content string) string {
|
||||||
|
// Split the content by newline
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
|
|
||||||
|
// Create a list of users
|
||||||
users := make([]string, 0)
|
users := make([]string, 0)
|
||||||
|
|
||||||
|
// Loop through the lines, trimming the whitespace and appending to the users list
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
users = append(users, line)
|
users = append(users, strings.TrimSpace(line))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return the users as a comma separated string
|
||||||
return strings.Join(users, ",")
|
return strings.Join(users, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseCommaString(str string) []string {
|
// Get the secret from the config or file
|
||||||
if str == "" {
|
func GetSecret(conf string, file string) string {
|
||||||
return []string{}
|
// If neither the config or file is set, return an empty string
|
||||||
|
if conf == "" && file == "" {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return strings.Split(str, ",")
|
|
||||||
|
// If the config is set, return the config (environment variable)
|
||||||
|
if conf != "" {
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file is set, read the file
|
||||||
|
contents, err := ReadFile(file)
|
||||||
|
|
||||||
|
// Check if there was an error
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the contents of the file
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the users from the config or file
|
||||||
|
func GetUsers(conf string, file string) (types.Users, error) {
|
||||||
|
// Create a string to store the users
|
||||||
|
var users string
|
||||||
|
|
||||||
|
// If neither the config or file is set, return an empty users struct
|
||||||
|
if conf == "" && file == "" {
|
||||||
|
return types.Users{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the config (environment) is set, append the users to the users string
|
||||||
|
if conf != "" {
|
||||||
|
log.Debug().Msg("Using users from config")
|
||||||
|
users += conf
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file is set, read the file and append the users to the users string
|
||||||
|
if file != "" {
|
||||||
|
// Read the file
|
||||||
|
fileContents, fileErr := ReadFile(file)
|
||||||
|
|
||||||
|
// If there isn't an error we can append the users to the users string
|
||||||
|
if fileErr == nil {
|
||||||
|
log.Debug().Msg("Using users from file")
|
||||||
|
|
||||||
|
// Append the users to the users string
|
||||||
|
if users != "" {
|
||||||
|
users += ","
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the file contents into a comma separated list of users
|
||||||
|
users += ParseFileToLine(fileContents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the parsed users
|
||||||
|
return ParseUsers(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any of the OAuth providers are configured based on the client id and secret
|
||||||
|
func OAuthConfigured(config types.Config) bool {
|
||||||
|
return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") || (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"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
"tinyauth/cmd"
|
"tinyauth/cmd"
|
||||||
"tinyauth/internal/assets"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -12,8 +11,7 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Logger
|
// Logger
|
||||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger()
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
|
||||||
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
|
|
||||||
|
|
||||||
// Run cmd
|
// Run cmd
|
||||||
cmd.Execute()
|
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 { notifications } from "@mantine/notifications";
|
||||||
import { Navigate } from "react-router";
|
import { Navigate } from "react-router";
|
||||||
import { useUserContext } from "../context/user-context";
|
import { useUserContext } from "../context/user-context";
|
||||||
import { Layout } from "../components/layouts/layout";
|
import { Layout } from "../components/layouts/layout";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export const ContinuePage = () => {
|
export const ContinuePage = () => {
|
||||||
const queryString = window.location.search;
|
const queryString = window.location.search;
|
||||||
@@ -12,11 +13,11 @@ export const ContinuePage = () => {
|
|||||||
const { isLoggedIn, disableContinue } = useUserContext();
|
const { isLoggedIn, disableContinue } = useUserContext();
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return <Navigate to="/login" />;
|
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disableContinue && redirectUri !== "null") {
|
if (redirectUri === "null") {
|
||||||
window.location.replace(redirectUri!);
|
return <Navigate to="/" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirect = () => {
|
const redirect = () => {
|
||||||
@@ -26,31 +27,62 @@ export const ContinuePage = () => {
|
|||||||
color: "blue",
|
color: "blue",
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.replace(redirectUri!);
|
window.location.href = redirectUri!;
|
||||||
}, 500);
|
}, 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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
||||||
{redirectUri !== "null" ? (
|
{children}
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Paper>
|
</Paper>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Layout } from "../components/layouts/layout";
|
|||||||
import { GoogleIcon } from "../icons/google";
|
import { GoogleIcon } from "../icons/google";
|
||||||
import { GithubIcon } from "../icons/github";
|
import { GithubIcon } from "../icons/github";
|
||||||
import { OAuthIcon } from "../icons/oauth";
|
import { OAuthIcon } from "../icons/oauth";
|
||||||
|
import { TailscaleIcon } from "../icons/tailscale";
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const queryString = window.location.search;
|
const queryString = window.location.search;
|
||||||
@@ -26,13 +27,16 @@ export const LoginPage = () => {
|
|||||||
const redirectUri = params.get("redirect_uri");
|
const redirectUri = params.get("redirect_uri");
|
||||||
|
|
||||||
const { isLoggedIn, configuredProviders } = useUserContext();
|
const { isLoggedIn, configuredProviders } = useUserContext();
|
||||||
|
const oauthProviders = configuredProviders.filter(
|
||||||
|
(value) => value !== "username",
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
return <Navigate to="/logout" />;
|
return <Navigate to="/logout" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email(),
|
username: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@ export const LoginPage = () => {
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
mode: "uncontrolled",
|
mode: "uncontrolled",
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
validate: zodResolver(schema),
|
validate: zodResolver(schema),
|
||||||
@@ -54,7 +58,7 @@ export const LoginPage = () => {
|
|||||||
onError: () => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: "Failed to login",
|
title: "Failed to login",
|
||||||
message: "Check your email and password",
|
message: "Check your username and password",
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -65,8 +69,12 @@ export const LoginPage = () => {
|
|||||||
color: "green",
|
color: "green",
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
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) => {
|
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>
|
<Layout>
|
||||||
<Title ta="center">Tinyauth</Title>
|
<Title ta="center">Tinyauth</Title>
|
||||||
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
|
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
|
||||||
{configuredProviders.length === 0 && (
|
{oauthProviders.length > 0 && (
|
||||||
<Text size="lg" mb="md" fw={500} ta="center">
|
|
||||||
Welcome back, please login
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{configuredProviders.length > 0 && (
|
|
||||||
<>
|
<>
|
||||||
<Text size="lg" fw={500} ta="center">
|
<Text size="lg" fw={500} ta="center">
|
||||||
Welcome back, login with
|
Welcome back, login with
|
||||||
</Text>
|
</Text>
|
||||||
<Grid mb="md" mt="md" align="center" justify="center">
|
<Grid mb="md" mt="md" align="center" justify="center">
|
||||||
{configuredProviders.includes("google") && (
|
{oauthProviders.includes("google") && (
|
||||||
<Grid.Col span="content">
|
<Grid.Col span="content">
|
||||||
<Button
|
<Button
|
||||||
radius="xl"
|
radius="xl"
|
||||||
@@ -122,7 +132,7 @@ export const LoginPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
)}
|
)}
|
||||||
{configuredProviders.includes("github") && (
|
{oauthProviders.includes("github") && (
|
||||||
<Grid.Col span="content">
|
<Grid.Col span="content">
|
||||||
<Button
|
<Button
|
||||||
radius="xl"
|
radius="xl"
|
||||||
@@ -137,7 +147,22 @@ export const LoginPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Grid.Col>
|
</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">
|
<Grid.Col span="content">
|
||||||
<Button
|
<Button
|
||||||
radius="xl"
|
radius="xl"
|
||||||
@@ -153,40 +178,44 @@ export const LoginPage = () => {
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Divider
|
{configuredProviders.includes("username") && (
|
||||||
label="Or continue with email"
|
<Divider
|
||||||
labelPosition="center"
|
label="Or continue with password"
|
||||||
my="lg"
|
labelPosition="center"
|
||||||
/>
|
my="lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
{configuredProviders.includes("username") && (
|
||||||
<TextInput
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
label="Email"
|
<TextInput
|
||||||
placeholder="user@example.com"
|
label="Username"
|
||||||
required
|
placeholder="user@example.com"
|
||||||
disabled={loginMutation.isLoading}
|
required
|
||||||
key={form.key("email")}
|
disabled={loginMutation.isLoading}
|
||||||
{...form.getInputProps("email")}
|
key={form.key("username")}
|
||||||
/>
|
{...form.getInputProps("username")}
|
||||||
<PasswordInput
|
/>
|
||||||
label="Password"
|
<PasswordInput
|
||||||
placeholder="password"
|
label="Password"
|
||||||
required
|
placeholder="password"
|
||||||
mt="md"
|
required
|
||||||
disabled={loginMutation.isLoading}
|
mt="md"
|
||||||
key={form.key("password")}
|
disabled={loginMutation.isLoading}
|
||||||
{...form.getInputProps("password")}
|
key={form.key("password")}
|
||||||
/>
|
{...form.getInputProps("password")}
|
||||||
<Button
|
/>
|
||||||
fullWidth
|
<Button
|
||||||
mt="xl"
|
fullWidth
|
||||||
type="submit"
|
mt="xl"
|
||||||
loading={loginMutation.isLoading}
|
type="submit"
|
||||||
>
|
loading={loginMutation.isLoading}
|
||||||
Login
|
>
|
||||||
</Button>
|
Login
|
||||||
</form>
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Layout } from "../components/layouts/layout";
|
|||||||
import { capitalize } from "../utils/utils";
|
import { capitalize } from "../utils/utils";
|
||||||
|
|
||||||
export const LogoutPage = () => {
|
export const LogoutPage = () => {
|
||||||
const { isLoggedIn, email, oauth, provider } = useUserContext();
|
const { isLoggedIn, username, oauth, provider } = useUserContext();
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return <Navigate to="/login" />;
|
return <Navigate to="/login" />;
|
||||||
@@ -32,7 +32,7 @@ export const LogoutPage = () => {
|
|||||||
color: "green",
|
color: "green",
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.replace("/login");
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -44,9 +44,9 @@ export const LogoutPage = () => {
|
|||||||
Logout
|
Logout
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
You are currently logged in as <Code>{email}</Code>
|
You are currently logged in as <Code>{username}</Code>
|
||||||
{oauth && ` using ${capitalize(provider)}`}. Click the button below to
|
{oauth && ` using ${capitalize(provider)} OAuth`}. Click the button
|
||||||
log out.
|
below to log out.
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import { Button, Code, Paper, Text } from "@mantine/core";
|
import { Button, Code, Paper, Text } from "@mantine/core";
|
||||||
import { Layout } from "../components/layouts/layout";
|
import { Layout } from "../components/layouts/layout";
|
||||||
import { useUserContext } from "../context/user-context";
|
|
||||||
import { Navigate } from "react-router";
|
import { Navigate } from "react-router";
|
||||||
|
|
||||||
export const UnauthorizedPage = () => {
|
export const UnauthorizedPage = () => {
|
||||||
const queryString = window.location.search;
|
const queryString = window.location.search;
|
||||||
const params = new URLSearchParams(queryString);
|
const params = new URLSearchParams(queryString);
|
||||||
const email = params.get("email");
|
const username = params.get("username");
|
||||||
|
const resource = params.get("resource");
|
||||||
|
|
||||||
const { isLoggedIn } = useUserContext();
|
if (username === "null") {
|
||||||
|
|
||||||
if (isLoggedIn) {
|
|
||||||
return <Navigate to="/" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email === "null") {
|
|
||||||
return <Navigate to="/" />;
|
return <Navigate to="/" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,8 +19,14 @@ export const UnauthorizedPage = () => {
|
|||||||
Unauthorized
|
Unauthorized
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
The user with email address <Code>{email}</Code> is not authorized to
|
The user with username <Code>{username}</Code> is not authorized to{" "}
|
||||||
login.
|
{resource !== "null" ? (
|
||||||
|
<span>
|
||||||
|
access the <Code>{resource}</Code> resource.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"login."
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const userContextSchema = z.object({
|
export const userContextSchema = z.object({
|
||||||
isLoggedIn: z.boolean(),
|
isLoggedIn: z.boolean(),
|
||||||
email: z.string(),
|
username: z.string(),
|
||||||
oauth: z.boolean(),
|
oauth: z.boolean(),
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
configuredProviders: z.array(z.string()),
|
configuredProviders: z.array(z.string()),
|
||||||
|
|||||||
Reference in New Issue
Block a user