From 5188089673bef36de1136109e94e59a8f71ea4d9 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 9 Mar 2025 18:39:25 +0200 Subject: [PATCH] Feat/totp (#45) * wip * feat: finalize totp gen code * refactor: split login screen and forms * feat: add totp logic and ui * refactor: make totp pending expiry time fixed * refactor: skip all checks when disable continue is enabled * fix: fix cli not exiting on invalid input --- Makefile | 25 ++++ cmd/root.go | 8 +- cmd/totp/generate/generate.go | 121 ++++++++++++++++ cmd/totp/totp.go | 22 +++ cmd/user/create/create.go | 2 +- cmd/user/verify/verify.go | 71 ++++++---- go.mod | 31 +++-- go.sum | 78 +++++++++-- internal/api/api.go | 113 +++++++++++++-- internal/auth/auth.go | 23 ++- internal/hooks/hooks.go | 58 +++++--- internal/types/types.go | 25 ++-- internal/utils/utils.go | 54 +++++-- internal/utils/utils_test.go | 74 ++++++++-- site/src/components/auth/login-forn.tsx | 46 ++++++ site/src/components/auth/oauth-buttons.tsx | 72 ++++++++++ site/src/components/auth/totp-form.tsx | 40 ++++++ site/src/context/user-context.tsx | 4 +- site/src/main.tsx | 2 + site/src/pages/continue-page.tsx | 37 +++-- site/src/pages/login-page.tsx | 155 ++++----------------- site/src/pages/totp-page.tsx | 62 +++++++++ site/src/schemas/login-schema.ts | 8 ++ site/src/schemas/user-context-schema.ts | 1 + 24 files changed, 862 insertions(+), 270 deletions(-) create mode 100644 Makefile create mode 100644 cmd/totp/generate/generate.go create mode 100644 cmd/totp/totp.go create mode 100644 site/src/components/auth/login-forn.tsx create mode 100644 site/src/components/auth/oauth-buttons.tsx create mode 100644 site/src/components/auth/totp-form.tsx create mode 100644 site/src/pages/totp-page.tsx create mode 100644 site/src/schemas/login-schema.ts diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e4475a2 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +# Build website +web: + cd site; bun run build + +# Copy site assets +assets: web + rm -rf internal/assets/dist + mkdir -p internal/assets/dist + cp -r site/dist/* internal/assets/dist + +# Run development binary +run: assets + go run main.go + +# Test +test: + go test ./... + +# Build +build: assets + go build -o tinyauth + +# Build no site +build-skip-web: + go build -o tinyauth \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index ac60ce3..453d76f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,7 +5,8 @@ import ( "os" "strings" "time" - cmd "tinyauth/cmd/user" + totpCmd "tinyauth/cmd/totp" + userCmd "tinyauth/cmd/user" "tinyauth/internal/api" "tinyauth/internal/assets" "tinyauth/internal/auth" @@ -141,7 +142,10 @@ func HandleError(err error, msg string) { func init() { // Add user command - rootCmd.AddCommand(cmd.UserCmd()) + rootCmd.AddCommand(userCmd.UserCmd()) + + // Add totp command + rootCmd.AddCommand(totpCmd.TotpCmd()) // Read environment variables viper.AutomaticEnv() diff --git a/cmd/totp/generate/generate.go b/cmd/totp/generate/generate.go new file mode 100644 index 0000000..3df3ea6 --- /dev/null +++ b/cmd/totp/generate/generate.go @@ -0,0 +1,121 @@ +package generate + +import ( + "errors" + "fmt" + "os" + "strings" + "tinyauth/internal/utils" + + "github.com/charmbracelet/huh" + "github.com/mdp/qrterminal/v3" + "github.com/pquerna/otp/totp" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +// Interactive flag +var interactive bool + +// i stands for input +var iUser string + +var GenerateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate a totp secret", + Run: func(cmd *cobra.Command, args []string) { + // Setup logger + log.Logger = log.Level(zerolog.InfoLevel) + + // Use simple theme + var baseTheme *huh.Theme = huh.ThemeBase() + + // Interactive + if interactive { + // Create huh form + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Current username:hash").Value(&iUser).Validate((func(s string) error { + if s == "" { + return errors.New("user cannot be empty") + } + return nil + })), + ), + ) + + // Run form + formErr := form.WithTheme(baseTheme).Run() + + if formErr != nil { + log.Fatal().Err(formErr).Msg("Form failed") + } + } + + // Parse user + user, parseErr := utils.ParseUser(iUser) + + if parseErr != nil { + log.Fatal().Err(parseErr).Msg("Failed to parse user") + } + + // Check if user was using docker escape + dockerEscape := false + + if strings.Contains(iUser, "$$") { + dockerEscape = true + } + + // Check it has totp + if user.TotpSecret != "" { + log.Fatal().Msg("User already has a totp secret") + } + + // Generate totp secret + key, keyErr := totp.Generate(totp.GenerateOpts{ + Issuer: "Tinyauth", + AccountName: user.Username, + }) + + if keyErr != nil { + log.Fatal().Err(keyErr).Msg("Failed to generate totp secret") + } + + // Create secret + secret := key.Secret() + + // Print secret and image + log.Info().Str("secret", secret).Msg("Generated totp secret") + + // Print QR code + log.Info().Msg("Generated QR code") + + config := qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + QuietZone: 2, + } + + qrterminal.GenerateWithConfig(key.URL(), config) + + // Add the secret to the user + user.TotpSecret = secret + + // If using docker escape re-escape it + if dockerEscape { + user.Password = strings.ReplaceAll(user.Password, "$", "$$") + } + + // Print success + log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.") + }, +} + +func init() { + // Add interactive flag + GenerateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Run in interactive mode") + GenerateCmd.Flags().StringVar(&iUser, "user", "", "Your current username:hash") +} diff --git a/cmd/totp/totp.go b/cmd/totp/totp.go new file mode 100644 index 0000000..683c950 --- /dev/null +++ b/cmd/totp/totp.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "tinyauth/cmd/totp/generate" + + "github.com/spf13/cobra" +) + +func TotpCmd() *cobra.Command { + // Create the totp command + totpCmd := &cobra.Command{ + Use: "totp", + Short: "Totp utilities", + Long: `Utilities for creating and verifying totp codes.`, + } + + // Add the generate command + totpCmd.AddCommand(generate.GenerateCmd) + + // Return the totp command + return totpCmd +} diff --git a/cmd/user/create/create.go b/cmd/user/create/create.go index 7b895c7..8d3cea5 100644 --- a/cmd/user/create/create.go +++ b/cmd/user/create/create.go @@ -60,7 +60,7 @@ var CreateCmd = &cobra.Command{ // Do we have username and password? if iUsername == "" || iPassword == "" { - log.Error().Msg("Username and password cannot be empty") + log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty") } log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user") diff --git a/cmd/user/verify/verify.go b/cmd/user/verify/verify.go index ba64fb4..138bf91 100644 --- a/cmd/user/verify/verify.go +++ b/cmd/user/verify/verify.go @@ -2,9 +2,10 @@ package verify import ( "errors" - "strings" + "tinyauth/internal/utils" "github.com/charmbracelet/huh" + "github.com/pquerna/otp/totp" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -17,22 +18,26 @@ var docker bool // i stands for input var iUsername string var iPassword string +var iTotp string var iUser string var VerifyCmd = &cobra.Command{ Use: "verify", Short: "Verify a user is set up correctly", - Long: `Verify a user is set up correctly meaning that it has a correct username and password.`, + Long: `Verify a user is set up correctly meaning that it has a correct username, password and totp code.`, Run: func(cmd *cobra.Command, args []string) { // Setup logger log.Logger = log.Level(zerolog.InfoLevel) + // Use simple theme + var baseTheme *huh.Theme = huh.ThemeBase() + // Check if interactive if interactive { // Create huh form form := huh.NewForm( huh.NewGroup( - huh.NewInput().Title("User (username:hash)").Value(&iUser).Validate((func(s string) error { + huh.NewInput().Title("User (username:hash:totp)").Value(&iUser).Validate((func(s string) error { if s == "" { return errors.New("user cannot be empty") } @@ -50,13 +55,11 @@ var VerifyCmd = &cobra.Command{ } return nil })), - huh.NewSelect[bool]().Title("Is the user formatted for docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&docker), + huh.NewInput().Title("Totp Code (if setup)").Value(&iTotp), ), ) - // Use simple theme - var baseTheme *huh.Theme = huh.ThemeBase() - + // Run form formErr := form.WithTheme(baseTheme).Run() if formErr != nil { @@ -64,33 +67,44 @@ var VerifyCmd = &cobra.Command{ } } - // Do we have username, password and user? - if iUsername == "" || iPassword == "" || iUser == "" { - log.Fatal().Msg("Username, password and user cannot be empty") + // Parse user + user, userErr := utils.ParseUser(iUser) + + if userErr != nil { + log.Fatal().Err(userErr).Msg("Failed to parse user") } - log.Info().Str("user", iUser).Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Verifying user") - - // Split username and password hash - username, hash, ok := strings.Cut(iUser, ":") - - if !ok { - log.Fatal().Msg("User is not formatted correctly") + // Compare username + if user.Username != iUsername { + log.Fatal().Msg("Username is incorrect") } - // Replace $$ with $ if formatted for docker - if docker { - hash = strings.ReplaceAll(hash, "$$", "$") + // Compare password + verifyErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(iPassword)) + + if verifyErr != nil { + log.Fatal().Msg("Ppassword is incorrect") } - // Compare username and password - verifyErr := bcrypt.CompareHashAndPassword([]byte(hash), []byte(iPassword)) - - if verifyErr != nil || username != iUsername { - log.Fatal().Msg("Username or password incorrect") - } else { - log.Info().Msg("Verification successful") + // Check if user has 2fa code + if user.TotpSecret == "" { + if iTotp != "" { + log.Warn().Msg("User does not have 2fa secret") + } + log.Info().Msg("User verified") + return } + + // Check totp code + totpOk := totp.Validate(iTotp, user.TotpSecret) + + if !totpOk { + log.Fatal().Msg("Totp code incorrect") + + } + + // Done + log.Info().Msg("User verified") }, } @@ -100,5 +114,6 @@ func init() { VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?") VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username") VerifyCmd.Flags().StringVar(&iPassword, "password", "", "Password") - VerifyCmd.Flags().StringVar(&iUser, "user", "", "Hash (username:hash combination)") + VerifyCmd.Flags().StringVar(&iTotp, "totp", "", "Totp code") + VerifyCmd.Flags().StringVar(&iUser, "user", "", "Hash (username:hash:totp combination)") } diff --git a/go.mod b/go.mod index 7f1c6ad..c2b81ed 100644 --- a/go.mod +++ b/go.mod @@ -13,23 +13,37 @@ require ( golang.org/x/crypto v0.32.0 ) +require ( + github.com/containerd/log v0.1.0 // indirect + github.com/mdp/qrterminal/v3 v3.2.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + golang.org/x/term v0.28.0 // indirect + gotest.tools/v3 v3.5.2 // indirect + rsc.io/qr v0.2.0 // indirect +) + require ( github.com/Microsoft/go-winio v0.4.14 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/boombuler/barcode v1.0.2 // indirect github.com/bytedance/sonic v1.12.7 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/bubbles v0.20.0 // indirect github.com/charmbracelet/bubbletea v1.1.0 // indirect - github.com/charmbracelet/huh v0.6.0 // indirect + github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v0.13.0 // indirect github.com/charmbracelet/x/ansi v0.2.3 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v27.5.1+incompatible // indirect + github.com/docker/docker v27.5.1+incompatible 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 @@ -38,7 +52,7 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.0.0 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -53,7 +67,7 @@ require ( github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/magiconair/properties v1.8.7 github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -70,6 +84,7 @@ require ( github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pquerna/otp v1.4.0 github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -81,15 +96,15 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.13.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.34.0 // indirect - golang.org/x/oauth2 v0.25.0 // indirect + golang.org/x/oauth2 v0.25.0 golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect diff --git a/go.sum b/go.sum index 8a3e7a0..7887980 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,16 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= +github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q= github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -11,6 +18,8 @@ github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wio github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= @@ -28,6 +37,8 @@ github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4c github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -61,8 +72,8 @@ github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9S github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -79,19 +90,23 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -126,17 +141,23 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= +github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -155,11 +176,13 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= @@ -169,6 +192,8 @@ github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgY github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -203,14 +228,24 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E 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/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= @@ -250,10 +285,14 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -262,14 +301,25 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA= +google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/api/api.go b/internal/api/api.go index f17e148..64bcd69 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -19,6 +19,7 @@ import ( "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/google/go-querystring/query" + "github.com/pquerna/otp/totp" "github.com/rs/zerolog/log" ) @@ -321,7 +322,29 @@ func (api *API) SetupRoutes() { return } - log.Debug().Msg("Password correct, logging in") + log.Debug().Msg("Password correct, checking totp") + + // Check if user has totp enabled + if user.TotpSecret != "" { + log.Debug().Msg("Totp enabled") + + // Set totp pending cookie + api.Auth.CreateSessionCookie(c, &types.SessionCookie{ + Username: login.Username, + Provider: "username", + TotpPending: true, + }) + + // Return totp required + c.JSON(200, gin.H{ + "status": 200, + "message": "Waiting for totp", + "totpPending": true, + }) + + // Stop further processing + return + } // Create session cookie with username as provider api.Auth.CreateSessionCookie(c, &types.SessionCookie{ @@ -329,6 +352,80 @@ func (api *API) SetupRoutes() { Provider: "username", }) + // Return logged in + c.JSON(200, gin.H{ + "status": 200, + "message": "Logged in", + "totpPending": false, + }) + }) + + api.Router.POST("/api/totp", func(c *gin.Context) { + // Create totp struct + var totpReq types.Totp + + // Bind JSON + err := c.BindJSON(&totpReq) + + // Handle error + if err != nil { + log.Error().Err(err).Msg("Failed to bind JSON") + c.JSON(400, gin.H{ + "status": 400, + "message": "Bad Request", + }) + return + } + + log.Debug().Msg("Checking totp") + + // Get user context + userContext := api.Hooks.UseUserContext(c) + + // Check if we have a user + if userContext.Username == "" { + log.Debug().Msg("No user context") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + // Get user + user := api.Auth.GetUser(userContext.Username) + + // Check if user exists + if user == nil { + log.Debug().Msg("User not found") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + // Check if totp is correct + totpOk := totp.Validate(totpReq.Code, user.TotpSecret) + + // TOTP is incorrect + if !totpOk { + log.Debug().Msg("Totp incorrect") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + log.Debug().Msg("Totp correct") + + // Create session cookie with username as provider + api.Auth.CreateSessionCookie(c, &types.SessionCookie{ + Username: user.Username, + Provider: "username", + }) + // Return logged in c.JSON(200, gin.H{ "status": 200, @@ -378,6 +475,7 @@ func (api *API) SetupRoutes() { DisableContinue: api.Config.DisableContinue, Title: api.Config.Title, GenericName: api.Config.GenericName, + TotpPending: userContext.TotpPending, } // If we are not logged in we set the status to 401 and add the WWW-Authenticate header else we set it to 200 @@ -392,19 +490,6 @@ func (api *API) SetupRoutes() { status.Message = "Authenticated" } - // // Marshall status to JSON - // statusJson, marshalErr := json.Marshal(status) - - // // Handle error - // if marshalErr != nil { - // log.Error().Err(marshalErr).Msg("Failed to marshal status") - // c.JSON(500, gin.H{ - // "status": 500, - // "message": "Internal Server Error", - // }) - // return - // } - // Return data c.JSON(200, status) }) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 926ee2a..7d03879 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -70,10 +70,20 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) log.Debug().Msg("Setting session cookie") + // Calculate expiry + var sessionExpiry int + + if data.TotpPending { + sessionExpiry = 3600 + } else { + sessionExpiry = auth.SessionExpiry + } + // 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()) + sessions.Set("expiry", time.Now().Add(time.Duration(sessionExpiry)*time.Second).Unix()) + sessions.Set("totpPending", data.TotpPending) // Save session sessions.Save() @@ -102,14 +112,16 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie { cookieUsername := sessions.Get("username") cookieProvider := sessions.Get("provider") cookieExpiry := sessions.Get("expiry") + cookieTotpPending := sessions.Get("totpPending") // Convert interfaces to correct types username, usernameOk := cookieUsername.(string) provider, providerOk := cookieProvider.(string) expiry, expiryOk := cookieExpiry.(int64) + totpPending, totpPendingOk := cookieTotpPending.(bool) // Check if the cookie is invalid - if !usernameOk || !providerOk || !expiryOk { + if !usernameOk || !providerOk || !expiryOk || !totpPendingOk { log.Warn().Msg("Session cookie invalid") return types.SessionCookie{} } @@ -125,12 +137,13 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie { return types.SessionCookie{} } - log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Msg("Parsed cookie") + log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Msg("Parsed cookie") // Return the cookie return types.SessionCookie{ - Username: username, - Provider: provider, + Username: username, + Provider: provider, + TotpPending: totpPending, } } diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index b8f4e48..6921372 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -36,15 +36,29 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { if user != nil && hooks.Auth.CheckPassword(*user, basic.Password) { // Return user context since we are logged in with basic auth return types.UserContext{ - Username: basic.Username, - IsLoggedIn: true, - OAuth: false, - Provider: "basic", + Username: basic.Username, + IsLoggedIn: true, + OAuth: false, + Provider: "basic", + TotpPending: false, } } } + // Check if session cookie has totp pending + if cookie.TotpPending { + log.Debug().Msg("Totp pending") + // Return empty context since we are pending totp + return types.UserContext{ + Username: cookie.Username, + IsLoggedIn: false, + OAuth: false, + Provider: cookie.Provider, + TotpPending: true, + } + } + // Check if session cookie is username/password auth if cookie.Provider == "username" { log.Debug().Msg("Provider is username") @@ -55,10 +69,11 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { // It exists so we are logged in return types.UserContext{ - Username: cookie.Username, - IsLoggedIn: true, - OAuth: false, - Provider: "username", + Username: cookie.Username, + IsLoggedIn: true, + OAuth: false, + Provider: "username", + TotpPending: false, } } } @@ -81,10 +96,11 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { // Return empty context return types.UserContext{ - Username: "", - IsLoggedIn: false, - OAuth: false, - Provider: "", + Username: "", + IsLoggedIn: false, + OAuth: false, + Provider: "", + TotpPending: false, } } @@ -92,18 +108,20 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { // Return user context since we are logged in with oauth return types.UserContext{ - Username: cookie.Username, - IsLoggedIn: true, - OAuth: true, - Provider: cookie.Provider, + Username: cookie.Username, + IsLoggedIn: true, + OAuth: true, + Provider: cookie.Provider, + TotpPending: false, } } // Neither basic auth or oauth is set so we return an empty context return types.UserContext{ - Username: "", - IsLoggedIn: false, - OAuth: false, - Provider: "", + Username: "", + IsLoggedIn: false, + OAuth: false, + Provider: "", + TotpPending: false, } } diff --git a/internal/types/types.go b/internal/types/types.go index d8bdfbb..c6a067c 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -15,8 +15,9 @@ type LoginRequest struct { // User is the struct for a user type User struct { - Username string - Password string + Username string + Password string + TotpSecret string } // Users is a list of users @@ -58,10 +59,11 @@ type Config struct { // UserContext is the context for the user type UserContext struct { - Username string - IsLoggedIn bool - OAuth bool - Provider string + Username string + IsLoggedIn bool + OAuth bool + Provider string + TotpPending bool } // APIConfig is the configuration for the API @@ -114,8 +116,9 @@ type UnauthorizedQuery struct { // SessionCookie is the cookie for the session (exculding the expiry) type SessionCookie struct { - Username string - Provider string + Username string + Provider string + TotpPending bool } // TinyauthLabels is the labels for the tinyauth container @@ -147,4 +150,10 @@ type Status struct { DisableContinue bool `json:"disableContinue"` Title string `json:"title"` GenericName string `json:"genericName"` + TotpPending bool `json:"totpPending"` +} + +// Totp request +type Totp struct { + Code string `json:"code"` } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 89e26eb..1e68aee 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -29,19 +29,15 @@ func ParseUsers(users string) (types.Users, error) { // Loop through the users and split them by colon for _, user := range userList { - // Split the user by colon - userSplit := strings.Split(user, ":") + parsed, parseErr := ParseUser(user) - // Check if the user is in the correct format - if len(userSplit) != 2 { - return types.Users{}, errors.New("invalid user format") + // Check if there was an error + if parseErr != nil { + return types.Users{}, parseErr } // Append the user to the users struct - usersParsed = append(usersParsed, types.User{ - Username: userSplit[0], - Password: userSplit[1], - }) + usersParsed = append(usersParsed, parsed) } log.Debug().Msg("Parsed users") @@ -219,3 +215,43 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) { } return res } + +// Parse user +func ParseUser(user string) (types.User, error) { + // Check if the user is escaped + if strings.Contains(user, "$$") { + user = strings.ReplaceAll(user, "$$", "$") + } + + // Split the user by colon + userSplit := strings.Split(user, ":") + + // Check if the user is in the correct format + if len(userSplit) < 2 || len(userSplit) > 3 { + return types.User{}, errors.New("invalid user format") + } + + // Check if the user has a totp secret + if len(userSplit) == 2 { + // Check for empty username or password + if userSplit[1] == "" || userSplit[0] == "" { + return types.User{}, errors.New("invalid user format") + } + return types.User{ + Username: userSplit[0], + Password: userSplit[1], + }, nil + } + + // Check for empty username, password or totp secret + if userSplit[2] == "" || userSplit[1] == "" || userSplit[0] == "" { + return types.User{}, errors.New("invalid user format") + } + + // Return the user struct + return types.User{ + Username: userSplit[0], + Password: userSplit[1], + TotpSecret: userSplit[2], + }, nil +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 36a1c1b..b3774ce 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -36,18 +36,6 @@ func TestParseUsers(t *testing.T) { if !reflect.DeepEqual(expected, result) { t.Fatalf("Expected %v, got %v", expected, result) } - - t.Log("Testing parse users with an invalid string") - - // Test the parse users function with an invalid string - users = "user1:pass1,user2" - - _, err = utils.ParseUsers(users) - - // There should be an error - if err == nil { - t.Fatalf("Expected error parsing users") - } } // Test the get root url function @@ -334,3 +322,65 @@ func TestFilter(t *testing.T) { t.Fatalf("Expected %v, got %v", expected, result) } } + +// Test parse user +func TestParseUser(t *testing.T) { + t.Log("Testing parse user with a valid user") + + // Create variables + user := "user:pass:secret" + expected := types.User{ + Username: "user", + Password: "pass", + TotpSecret: "secret", + } + + // Test the parse user function + result, err := utils.ParseUser(user) + + // Check if there was an error + if err != nil { + t.Fatalf("Error parsing user: %v", err) + } + + // Check if the result is equal to the expected + if !reflect.DeepEqual(expected, result) { + t.Fatalf("Expected %v, got %v", expected, result) + } + + t.Log("Testing parse user with an escaped user") + + // Create variables + user = "user:p$$ass$$:secret" + expected = types.User{ + Username: "user", + Password: "p$ass$", + TotpSecret: "secret", + } + + // Test the parse user function + result, err = utils.ParseUser(user) + + // Check if there was an error + if err != nil { + t.Fatalf("Error parsing user: %v", err) + } + + // Check if the result is equal to the expected + if !reflect.DeepEqual(expected, result) { + t.Fatalf("Expected %v, got %v", expected, result) + } + + t.Log("Testing parse user with an invalid user") + + // Create variables + user = "user::pass" + + // Test the parse user function + _, err = utils.ParseUser(user) + + // Check if there was an error + if err == nil { + t.Fatalf("Expected error parsing user") + } +} diff --git a/site/src/components/auth/login-forn.tsx b/site/src/components/auth/login-forn.tsx new file mode 100644 index 0000000..8b76e2d --- /dev/null +++ b/site/src/components/auth/login-forn.tsx @@ -0,0 +1,46 @@ +import { TextInput, PasswordInput, Button } from "@mantine/core"; +import { useForm, zodResolver } from "@mantine/form"; +import { LoginFormValues, loginSchema } from "../../schemas/login-schema"; + +interface LoginFormProps { + isLoading: boolean; + onSubmit: (values: LoginFormValues) => void; +} + +export const LoginForm = (props: LoginFormProps) => { + const { isLoading, onSubmit } = props; + + const form = useForm({ + mode: "uncontrolled", + initialValues: { + username: "", + password: "", + }, + validate: zodResolver(loginSchema), + }); + + return ( +
+ + + + + ); +}; diff --git a/site/src/components/auth/oauth-buttons.tsx b/site/src/components/auth/oauth-buttons.tsx new file mode 100644 index 0000000..eb8b239 --- /dev/null +++ b/site/src/components/auth/oauth-buttons.tsx @@ -0,0 +1,72 @@ +import { Grid, Button } from "@mantine/core"; +import { GithubIcon } from "../../icons/github"; +import { GoogleIcon } from "../../icons/google"; +import { OAuthIcon } from "../../icons/oauth"; +import { TailscaleIcon } from "../../icons/tailscale"; + +interface OAuthButtonsProps { + oauthProviders: string[]; + isLoading: boolean; + mutate: (provider: string) => void; + genericName: string; +} + +export const OAuthButtons = (props: OAuthButtonsProps) => { + const { oauthProviders, isLoading, genericName, mutate } = props; + return ( + + {oauthProviders.includes("google") && ( + + + + )} + {oauthProviders.includes("github") && ( + + + + )} + {oauthProviders.includes("tailscale") && ( + + + + )} + {oauthProviders.includes("generic") && ( + + + + )} + + ); +}; diff --git a/site/src/components/auth/totp-form.tsx b/site/src/components/auth/totp-form.tsx new file mode 100644 index 0000000..d860c6e --- /dev/null +++ b/site/src/components/auth/totp-form.tsx @@ -0,0 +1,40 @@ +import { Button, PinInput } from "@mantine/core"; +import { useForm, zodResolver } from "@mantine/form"; +import { z } from "zod"; + +const schema = z.object({ + code: z.string(), +}); + +type FormValues = z.infer; + +interface TotpFormProps { + onSubmit: (values: FormValues) => void; + isLoading: boolean; +} + +export const TotpForm = (props: TotpFormProps) => { + const { onSubmit, isLoading } = props; + + const form = useForm({ + mode: "uncontrolled", + initialValues: { + code: "", + }, + validate: zodResolver(schema), + }); + + return ( +
+ + + + ); +}; diff --git a/site/src/context/user-context.tsx b/site/src/context/user-context.tsx index eabeca6..cc59627 100644 --- a/site/src/context/user-context.tsx +++ b/site/src/context/user-context.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import React, { createContext, useContext } from "react"; -import { UserContextSchemaType } from "../schemas/user-context-schema"; import axios from "axios"; +import { UserContextSchemaType } from "../schemas/user-context-schema"; const UserContext = createContext(null); @@ -15,7 +15,7 @@ export const UserContextProvider = ({ isLoading, error, } = useQuery({ - queryKey: ["isLoggedIn"], + queryKey: ["userContext"], queryFn: async () => { const res = await axios.get("/api/status"); return res.data; diff --git a/site/src/main.tsx b/site/src/main.tsx index a30a5fd..dd88fd8 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -15,6 +15,7 @@ import { ContinuePage } from "./pages/continue-page.tsx"; import { NotFoundPage } from "./pages/not-found-page.tsx"; import { UnauthorizedPage } from "./pages/unauthorized-page.tsx"; import { InternalServerError } from "./pages/internal-server-error.tsx"; +import { TotpPage } from "./pages/totp-page.tsx"; const queryClient = new QueryClient({ defaultOptions: { @@ -34,6 +35,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> + } /> } /> } /> } /> diff --git a/site/src/pages/continue-page.tsx b/site/src/pages/continue-page.tsx index 33b79ae..6a9a662 100644 --- a/site/src/pages/continue-page.tsx +++ b/site/src/pages/continue-page.tsx @@ -50,26 +50,6 @@ export const ContinuePage = () => { ); } - if ( - window.location.protocol === "https:" && - uri.protocol === "http:" - ) { - return ( - - - Insecure Redirect - - - Your are logged in but trying to redirect from https to{" "} - http, please click the button to redirect. - - - - ); - } - if (disableContinue) { window.location.href = redirectUri; return ( @@ -82,6 +62,23 @@ export const ContinuePage = () => { ); } + if (window.location.protocol === "https:" && uri.protocol === "http:") { + return ( + + + Insecure Redirect + + + Your are trying to redirect from https to{" "} + http, are you sure you want to continue? + + + + ); + } + return ( diff --git a/site/src/pages/login-page.tsx b/site/src/pages/login-page.tsx index b11210d..3b026e5 100644 --- a/site/src/pages/login-page.tsx +++ b/site/src/pages/login-page.tsx @@ -1,25 +1,13 @@ -import { - Button, - Paper, - PasswordInput, - TextInput, - Title, - Text, - Divider, - Grid, -} from "@mantine/core"; -import { useForm, zodResolver } from "@mantine/form"; +import { Paper, Title, Text, Divider } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; -import { z } from "zod"; import { useUserContext } from "../context/user-context"; import { Navigate } from "react-router"; import { Layout } from "../components/layouts/layout"; -import { GoogleIcon } from "../icons/google"; -import { GithubIcon } from "../icons/github"; -import { OAuthIcon } from "../icons/oauth"; -import { TailscaleIcon } from "../icons/tailscale"; +import { OAuthButtons } from "../components/auth/oauth-buttons"; +import { LoginFormValues } from "../schemas/login-schema"; +import { LoginForm } from "../components/auth/login-forn"; import { isQueryValid } from "../utils/utils"; export const LoginPage = () => { @@ -27,7 +15,8 @@ export const LoginPage = () => { const params = new URLSearchParams(queryString); const redirectUri = params.get("redirect_uri") ?? ""; - const { isLoggedIn, configuredProviders, title, genericName } = useUserContext(); + const { isLoggedIn, configuredProviders, title, genericName } = + useUserContext(); const oauthProviders = configuredProviders.filter( (value) => value !== "username", @@ -37,24 +26,8 @@ export const LoginPage = () => { return ; } - const schema = z.object({ - username: z.string(), - password: z.string(), - }); - - type FormValues = z.infer; - - const form = useForm({ - mode: "uncontrolled", - initialValues: { - username: "", - password: "", - }, - validate: zodResolver(schema), - }); - const loginMutation = useMutation({ - mutationFn: (login: FormValues) => { + mutationFn: (login: LoginFormValues) => { return axios.post("/api/login", login); }, onError: () => { @@ -64,18 +37,25 @@ export const LoginPage = () => { color: "red", }); }, - onSuccess: () => { + onSuccess: async (data) => { + if (data.data.totpPending) { + window.location.replace(`/totp?redirect_uri=${redirectUri}`); + return; + } + notifications.show({ title: "Logged in", message: "Welcome back!", color: "green", }); + setTimeout(() => { if (!isQueryValid(redirectUri)) { window.location.replace("/"); - } else { - window.location.replace(`/continue?redirect_uri=${redirectUri}`); + return; } + + window.location.replace(`/continue?redirect_uri=${redirectUri}`); }, 500); }, }); @@ -105,7 +85,7 @@ export const LoginPage = () => { }, }); - const handleSubmit = (values: FormValues) => { + const handleSubmit = (values: LoginFormValues) => { loginMutation.mutate(values); }; @@ -118,68 +98,12 @@ export const LoginPage = () => { Welcome back, login with - - {oauthProviders.includes("google") && ( - - - - )} - {oauthProviders.includes("github") && ( - - - - )} - {oauthProviders.includes("tailscale") && ( - - - - )} - {oauthProviders.includes("generic") && ( - - - - )} - + {configuredProviders.includes("username") && ( { )} {configuredProviders.includes("username") && ( -
- - - - + )} diff --git a/site/src/pages/totp-page.tsx b/site/src/pages/totp-page.tsx new file mode 100644 index 0000000..33cc5ae --- /dev/null +++ b/site/src/pages/totp-page.tsx @@ -0,0 +1,62 @@ +import { Navigate } from "react-router"; +import { useUserContext } from "../context/user-context"; +import { Title, Paper, Text } from "@mantine/core"; +import { Layout } from "../components/layouts/layout"; +import { TotpForm } from "../components/auth/totp-form"; +import { useMutation } from "@tanstack/react-query"; +import axios from "axios"; +import { notifications } from "@mantine/notifications"; + +export const TotpPage = () => { + const queryString = window.location.search; + const params = new URLSearchParams(queryString); + const redirectUri = params.get("redirect_uri") ?? ""; + + const { totpPending, isLoggedIn, title } = useUserContext(); + + if (isLoggedIn) { + return ; + } + + if (!totpPending) { + return ; + } + + const totpMutation = useMutation({ + mutationFn: async (totp: { code: string }) => { + await axios.post("/api/totp", totp); + }, + onError: () => { + notifications.show({ + title: "Failed to verify code", + message: "Please try again", + color: "red", + }); + }, + onSuccess: () => { + notifications.show({ + title: "Verified", + message: "Redirecting to your app", + color: "green", + }); + setTimeout(() => { + window.location.replace(`/continue?redirect_uri=${redirectUri}`); + }, 500); + }, + }); + + return ( + + {title} + + + Enter your TOTP code + + totpMutation.mutate(values)} + /> + + + ); +}; diff --git a/site/src/schemas/login-schema.ts b/site/src/schemas/login-schema.ts new file mode 100644 index 0000000..c08f70f --- /dev/null +++ b/site/src/schemas/login-schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + username: z.string(), + password: z.string(), +}); + +export type LoginFormValues = z.infer; \ No newline at end of file diff --git a/site/src/schemas/user-context-schema.ts b/site/src/schemas/user-context-schema.ts index 58db405..83eb2e2 100644 --- a/site/src/schemas/user-context-schema.ts +++ b/site/src/schemas/user-context-schema.ts @@ -9,6 +9,7 @@ export const userContextSchema = z.object({ disableContinue: z.boolean(), title: z.string(), genericName: z.string(), + totpPending: z.boolean(), }); export type UserContextSchemaType = z.infer;