mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-05-08 21:38:13 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 901f1eb308 | |||
| 1b18e68ce0 |
@@ -65,7 +65,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
|
||||
|
||||
A big thank you to the following people for providing me with more coffee:
|
||||
|
||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/ax-mad"><img src="https://github.com/ax-mad.png" width="64px" alt="User avatar: ax-mad" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <!-- sponsors -->
|
||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/ax-mad"><img src="https://github.com/ax-mad.png" width="64px" alt="User avatar: ax-mad" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <a href="https://github.com/apearson"><img src="https://github.com/apearson.png" width="64px" alt="User avatar: apearson" /></a> <!-- sponsors -->
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -40,8 +40,7 @@ func createUserCmd() *cli.Command {
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
@@ -74,7 +73,7 @@ func createUserCmd() *cli.Command {
|
||||
return errors.New("username and password cannot be empty")
|
||||
}
|
||||
|
||||
log.App.Info().Str("username", tCfg.Username).Msg("Creating user")
|
||||
tlog.App.Info().Str("username", tCfg.Username).Msg("Creating user")
|
||||
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -87,7 +86,7 @@ func createUserCmd() *cli.Command {
|
||||
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
|
||||
}
|
||||
|
||||
log.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
|
||||
tlog.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
@@ -40,8 +40,7 @@ func generateTotpCmd() *cli.Command {
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
@@ -89,9 +88,9 @@ func generateTotpCmd() *cli.Command {
|
||||
|
||||
secret := key.Secret()
|
||||
|
||||
log.App.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
||||
tlog.App.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
||||
|
||||
log.App.Info().Msg("Generated QR code")
|
||||
tlog.App.Info().Msg("Generated QR code")
|
||||
|
||||
config := qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
@@ -110,7 +109,7 @@ func generateTotpCmd() *cli.Command {
|
||||
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
|
||||
}
|
||||
|
||||
log.App.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.")
|
||||
tlog.App.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.")
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
type healthzResponse struct {
|
||||
@@ -26,8 +26,7 @@ func healthcheckCmd() *cli.Command {
|
||||
Resources: nil,
|
||||
AllowArg: true,
|
||||
Run: func(args []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
srvAddr := os.Getenv("TINYAUTH_SERVER_ADDRESS")
|
||||
if srvAddr == "" {
|
||||
@@ -49,7 +48,7 @@ func healthcheckCmd() *cli.Command {
|
||||
return errors.New("Could not determine app URL")
|
||||
}
|
||||
|
||||
log.App.Info().Str("app_url", appUrl).Msg("Performing health check")
|
||||
tlog.App.Info().Str("app_url", appUrl).Msg("Performing health check")
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -87,7 +86,7 @@ func healthcheckCmd() *cli.Command {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
log.App.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
|
||||
tlog.App.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/loaders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
@@ -108,6 +109,11 @@ func main() {
|
||||
}
|
||||
|
||||
func runCmd(cfg model.Config) error {
|
||||
logger := tlog.NewLogger(cfg.Log)
|
||||
logger.Init()
|
||||
|
||||
tlog.App.Info().Str("version", model.Version).Msg("Starting tinyauth")
|
||||
|
||||
app := bootstrap.NewBootstrapApp(cfg)
|
||||
|
||||
err := app.Setup()
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/pquerna/otp/totp"
|
||||
@@ -44,8 +44,7 @@ func verifyUserCmd() *cli.Command {
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
@@ -98,9 +97,9 @@ func verifyUserCmd() *cli.Command {
|
||||
|
||||
if user.TOTPSecret == "" {
|
||||
if tCfg.Totp != "" {
|
||||
log.App.Warn().Msg("User does not have TOTP secret")
|
||||
tlog.App.Warn().Msg("User does not have TOTP secret")
|
||||
}
|
||||
log.App.Info().Msg("User verified")
|
||||
tlog.App.Info().Msg("User verified")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,7 +109,7 @@ func verifyUserCmd() *cli.Command {
|
||||
return fmt.Errorf("TOTP code incorrect")
|
||||
}
|
||||
|
||||
log.App.Info().Msg("User verified")
|
||||
tlog.App.Info().Msg("User verified")
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
+142
-96
@@ -11,45 +11,45 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"axios": "^1.15.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next": "^26.0.10",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.14.0",
|
||||
"react-router": "^7.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zod": "^4.3.6",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.99.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.100.9",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"prettier": "3.8.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "3.8.3",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.8",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.11",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||
|
||||
@@ -88,9 +88,9 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
@@ -338,41 +338,41 @@
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="],
|
||||
|
||||
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.99.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-jVp1AEL7S7BeuQvH5SN1F5UdrNW/AbryKDeWUUMeAKNzh9C+Ik/bRSa/HeuJLlmaN+WOUkdDFbtCK0go7BxnUQ=="],
|
||||
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.9", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-3jZwyxAZWSBqI7EXEdw+rktFfX1opMpqn9Lruwz52DEzQdi7kbKnqixjhR3dJ1xFfG05YxV9vsqXGxXqcLAmjA=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="],
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.100.9", "", {}, "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.99.0", "", { "dependencies": { "@tanstack/query-core": "5.99.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw=="],
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.100.9", "", { "dependencies": { "@tanstack/query-core": "5.100.9" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
"@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
@@ -400,25 +400,25 @@
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
|
||||
"axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
@@ -524,9 +524,9 @@
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="],
|
||||
"eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
|
||||
|
||||
@@ -564,7 +564,7 @@
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
@@ -586,7 +586,7 @@
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
|
||||
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"i18next": ["i18next@26.0.4", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA=="],
|
||||
"i18next": ["i18next@26.0.10", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-k3yGPAlWR2RdMYoVXJoDZDT87qeHIWKH7gVksdZMpRty7QX/D9QZeYGvN08KGbKHke9wn01eYT+EEsrqX/YTlw=="],
|
||||
|
||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||
|
||||
@@ -694,7 +694,7 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
|
||||
"lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
@@ -792,13 +792,13 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="],
|
||||
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
@@ -808,13 +808,13 @@
|
||||
|
||||
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
||||
|
||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.72.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig=="],
|
||||
"react-hook-form": ["react-hook-form@7.75.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA=="],
|
||||
"react-i18next": ["react-i18next@17.0.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.10", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
@@ -822,7 +822,7 @@
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="],
|
||||
"react-router": ["react-router@7.15.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
@@ -868,11 +868,11 @@
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
@@ -886,9 +886,9 @@
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.58.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.1", "@typescript-eslint/parser": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg=="],
|
||||
"typescript-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
@@ -918,7 +918,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
|
||||
|
||||
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||
"vite": ["vite@8.0.11", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
@@ -940,7 +940,7 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
@@ -1002,13 +1002,13 @@
|
||||
|
||||
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
@@ -1016,16 +1016,36 @@
|
||||
|
||||
"@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
"@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
|
||||
"i18next-browser-languagedetector/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"i18next-resources-to-backend/@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="],
|
||||
|
||||
"micromark/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
|
||||
@@ -1038,11 +1058,17 @@
|
||||
|
||||
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"react-i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
|
||||
|
||||
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"vite/rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||
"vite/rolldown": ["rolldown@1.0.0-rc.18", "", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="],
|
||||
|
||||
@@ -1060,45 +1086,65 @@
|
||||
|
||||
"@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
|
||||
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.128.0", "", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
|
||||
"vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.18", "", { "os": "android", "cpu": "arm64" }, "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
|
||||
"vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
|
||||
"vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
|
||||
"vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm" }, "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "s390x" }, "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "x64" }, "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.18", "", { "os": "linux", "cpu": "x64" }, "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.18", "", { "os": "none", "cpu": "arm64" }, "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.18", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.18", "", { "os": "win32", "cpu": "x64" }, "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
|
||||
+21
-21
@@ -17,44 +17,44 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"axios": "^1.15.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next": "^26.0.10",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.14.0",
|
||||
"react-router": "^7.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zod": "^4.3.6"
|
||||
"tailwindcss": "^4.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.99.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.100.9",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"prettier": "3.8.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "3.8.3",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.8"
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
+125
-241
@@ -3,50 +3,39 @@ package bootstrap
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
accessControlService *service.AccessControlsService
|
||||
authService *service.AuthService
|
||||
dockerService *service.DockerService
|
||||
kubernetesService *service.KubernetesService
|
||||
ldapService *service.LdapService
|
||||
oauthBrokerService *service.OAuthBrokerService
|
||||
oidcService *service.OIDCService
|
||||
}
|
||||
|
||||
type BootstrapApp struct {
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
config model.Config
|
||||
context struct {
|
||||
appUrl string
|
||||
uuid string
|
||||
cookieDomain string
|
||||
sessionCookieName string
|
||||
csrfCookieName string
|
||||
redirectCookieName string
|
||||
oauthSessionCookieName string
|
||||
localUsers *[]model.LocalUser
|
||||
oauthProviders map[string]model.OAuthServiceConfig
|
||||
oauthWhitelist []string
|
||||
configuredProviders []controller.Provider
|
||||
oidcClients []model.OIDCClientConfig
|
||||
}
|
||||
services Services
|
||||
log *logger.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
queries *repository.Queries
|
||||
router *gin.Engine
|
||||
db *sql.DB
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewBootstrapApp(config model.Config) *BootstrapApp {
|
||||
@@ -56,69 +45,56 @@ func NewBootstrapApp(config model.Config) *BootstrapApp {
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) Setup() error {
|
||||
// create context
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
app.ctx = ctx
|
||||
app.cancel = cancel
|
||||
|
||||
// setup logger
|
||||
log := logger.NewLogger().WithConfig(app.config.Log)
|
||||
log.Init()
|
||||
app.log = log
|
||||
|
||||
// get app url
|
||||
if app.config.AppURL == "" {
|
||||
return errors.New("app url cannot be empty, perhaps config loading failed")
|
||||
return fmt.Errorf("app URL cannot be empty, perhaps config loading failed")
|
||||
}
|
||||
|
||||
appUrl, err := url.Parse(app.config.AppURL)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse app url: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.runtime.AppURL = appUrl.Scheme + "://" + appUrl.Host
|
||||
app.context.appUrl = appUrl.Scheme + "://" + appUrl.Host
|
||||
|
||||
// validate session config
|
||||
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
|
||||
return errors.New("session max lifetime cannot be less than session expiry")
|
||||
return fmt.Errorf("session max lifetime cannot be less than session expiry")
|
||||
}
|
||||
|
||||
// parse users
|
||||
// Parse users
|
||||
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile, app.config.Auth.UserAttributes)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load users: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.runtime.LocalUsers = *users
|
||||
app.context.localUsers = users
|
||||
|
||||
// load oauth whitelist
|
||||
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load oauth whitelist: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.runtime.OAuthWhitelist = oauthWhitelist
|
||||
app.context.oauthWhitelist = oauthWhitelist
|
||||
|
||||
// Setup oauth providers
|
||||
app.runtime.OAuthProviders = app.config.OAuth.Providers
|
||||
// Setup OAuth providers
|
||||
app.context.oauthProviders = app.config.OAuth.Providers
|
||||
|
||||
for id, provider := range app.runtime.OAuthProviders {
|
||||
for name, provider := range app.context.oauthProviders {
|
||||
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
|
||||
provider.ClientSecret = secret
|
||||
provider.ClientSecretFile = ""
|
||||
|
||||
if provider.RedirectURL == "" {
|
||||
provider.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + id
|
||||
provider.RedirectURL = app.context.appUrl + "/api/oauth/callback/" + name
|
||||
}
|
||||
|
||||
app.runtime.OAuthProviders[id] = provider
|
||||
app.context.oauthProviders[name] = provider
|
||||
}
|
||||
|
||||
// set presets for built-in providers
|
||||
for id, provider := range app.runtime.OAuthProviders {
|
||||
for id, provider := range app.context.oauthProviders {
|
||||
if provider.Name == "" {
|
||||
if name, ok := model.OverrideProviders[id]; ok {
|
||||
provider.Name = name
|
||||
@@ -126,64 +102,71 @@ func (app *BootstrapApp) Setup() error {
|
||||
provider.Name = utils.Capitalize(id)
|
||||
}
|
||||
}
|
||||
app.runtime.OAuthProviders[id] = provider
|
||||
app.context.oauthProviders[id] = provider
|
||||
}
|
||||
|
||||
// setup oidc clients
|
||||
// Setup OIDC clients
|
||||
for id, client := range app.config.OIDC.Clients {
|
||||
client.ID = id
|
||||
app.runtime.OIDCClients = append(app.runtime.OIDCClients, client)
|
||||
app.context.oidcClients = append(app.context.oidcClients, client)
|
||||
}
|
||||
|
||||
// cookie domain
|
||||
// Get cookie domain
|
||||
cookieDomainResolver := utils.GetCookieDomain
|
||||
|
||||
if !app.config.Auth.SubdomainsEnabled {
|
||||
app.log.App.Warn().Msg("Subdomains are disabled, using standalone cookie domain resolver which will not work with subdomains")
|
||||
tlog.App.Info().Msg("Subdomains disabled, automatic authentication for proxied apps will not work")
|
||||
cookieDomainResolver = utils.GetStandaloneCookieDomain
|
||||
}
|
||||
|
||||
cookieDomain, err := cookieDomainResolver(app.runtime.AppURL)
|
||||
cookieDomain, err := cookieDomainResolver(app.context.appUrl)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get cookie domain: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.runtime.CookieDomain = cookieDomain
|
||||
app.context.cookieDomain = cookieDomain
|
||||
|
||||
// cookie names
|
||||
app.runtime.UUID = utils.GenerateUUID(appUrl.Hostname())
|
||||
// Cookie names
|
||||
app.context.uuid = utils.GenerateUUID(appUrl.Hostname())
|
||||
cookieId := strings.Split(app.context.uuid, "-")[0]
|
||||
app.context.sessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
|
||||
app.context.csrfCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
|
||||
app.context.redirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
|
||||
app.context.oauthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
|
||||
|
||||
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
|
||||
// Dumps
|
||||
tlog.App.Trace().Interface("config", app.config).Msg("Config dump")
|
||||
tlog.App.Trace().Interface("users", app.context.localUsers).Msg("Users dump")
|
||||
tlog.App.Trace().Interface("oauthProviders", app.context.oauthProviders).Msg("OAuth providers dump")
|
||||
tlog.App.Trace().Str("cookieDomain", app.context.cookieDomain).Msg("Cookie domain")
|
||||
tlog.App.Trace().Str("sessionCookieName", app.context.sessionCookieName).Msg("Session cookie name")
|
||||
tlog.App.Trace().Str("csrfCookieName", app.context.csrfCookieName).Msg("CSRF cookie name")
|
||||
tlog.App.Trace().Str("redirectCookieName", app.context.redirectCookieName).Msg("Redirect cookie name")
|
||||
|
||||
app.runtime.SessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
|
||||
app.runtime.CSRFCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
|
||||
app.runtime.RedirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
|
||||
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
|
||||
|
||||
// database
|
||||
err = app.SetupDatabase()
|
||||
// Database
|
||||
db, err := app.SetupDatabase(app.config.Database.Path)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup database: %w", err)
|
||||
}
|
||||
|
||||
// queries
|
||||
queries := repository.New(app.db)
|
||||
app.queries = queries
|
||||
// Queries
|
||||
queries := repository.New(db)
|
||||
|
||||
// services
|
||||
err = app.setupServices()
|
||||
// Services
|
||||
services, err := app.initServices(queries)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
|
||||
// configured providers
|
||||
configuredProviders := make([]model.Provider, 0)
|
||||
app.services = services
|
||||
|
||||
for id, provider := range app.runtime.OAuthProviders {
|
||||
configuredProviders = append(configuredProviders, model.Provider{
|
||||
// Configured providers
|
||||
configuredProviders := make([]controller.Provider, 0)
|
||||
|
||||
for id, provider := range app.context.oauthProviders {
|
||||
configuredProviders = append(configuredProviders, controller.Provider{
|
||||
Name: provider.Name,
|
||||
ID: id,
|
||||
OAuth: true,
|
||||
@@ -194,152 +177,70 @@ func (app *BootstrapApp) Setup() error {
|
||||
return configuredProviders[i].Name < configuredProviders[j].Name
|
||||
})
|
||||
|
||||
if app.services.authService.LocalAuthConfigured() {
|
||||
configuredProviders = append(configuredProviders, model.Provider{
|
||||
if services.authService.LocalAuthConfigured() {
|
||||
configuredProviders = append(configuredProviders, controller.Provider{
|
||||
Name: "Local",
|
||||
ID: "local",
|
||||
OAuth: false,
|
||||
})
|
||||
}
|
||||
|
||||
if app.services.authService.LDAPAuthConfigured() {
|
||||
configuredProviders = append(configuredProviders, model.Provider{
|
||||
if services.authService.LDAPAuthConfigured() {
|
||||
configuredProviders = append(configuredProviders, controller.Provider{
|
||||
Name: "LDAP",
|
||||
ID: "ldap",
|
||||
OAuth: false,
|
||||
})
|
||||
}
|
||||
|
||||
tlog.App.Debug().Interface("providers", configuredProviders).Msg("Authentication providers")
|
||||
|
||||
if len(configuredProviders) == 0 {
|
||||
return errors.New("no authentication providers configured")
|
||||
return fmt.Errorf("no authentication providers configured")
|
||||
}
|
||||
|
||||
for _, provider := range app.runtime.ConfiguredProviders {
|
||||
app.log.App.Debug().Str("provider", provider.Name).Msg("Configured authentication provider")
|
||||
}
|
||||
app.context.configuredProviders = configuredProviders
|
||||
|
||||
app.runtime.ConfiguredProviders = configuredProviders
|
||||
|
||||
// setup router
|
||||
err = app.setupRouter()
|
||||
// Setup router
|
||||
router, err := app.setupRouter()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup routes: %w", err)
|
||||
}
|
||||
|
||||
// start db cleanup routine
|
||||
app.log.App.Debug().Msg("Starting database cleanup routine")
|
||||
app.wg.Go(app.dbCleanupRoutine)
|
||||
// Start db cleanup routine
|
||||
tlog.App.Debug().Msg("Starting database cleanup routine")
|
||||
go app.dbCleanupRoutine(queries)
|
||||
|
||||
// if analytics are not disabled, start heartbeat
|
||||
// If analytics are not disabled, start heartbeat
|
||||
if app.config.Analytics.Enabled {
|
||||
app.log.App.Debug().Msg("Starting heartbeat routine")
|
||||
app.wg.Go(app.heartbeatRoutine)
|
||||
tlog.App.Debug().Msg("Starting heartbeat routine")
|
||||
go app.heartbeatRoutine()
|
||||
}
|
||||
|
||||
// create err channel to listen for server errors
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
// serve unix
|
||||
app.wg.Go(func() {
|
||||
if err := app.serveUnix(); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
})
|
||||
|
||||
// serve to http
|
||||
app.wg.Go(func() {
|
||||
if err := app.serveHTTP(); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
})
|
||||
|
||||
// monitor cancellation and server errors
|
||||
for {
|
||||
select {
|
||||
case <-app.ctx.Done():
|
||||
app.wg.Wait()
|
||||
app.log.App.Debug().Msg("Closing database")
|
||||
app.db.Close()
|
||||
app.log.App.Info().Msg("Oh, it's time for me to go, bye!")
|
||||
return nil
|
||||
case err := <-errChan:
|
||||
// If we have an socket path, bind to it
|
||||
if app.config.Server.SocketPath != "" {
|
||||
if _, err := os.Stat(app.config.Server.SocketPath); err == nil {
|
||||
tlog.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
|
||||
err := os.Remove(app.config.Server.SocketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server error: %w", err)
|
||||
return fmt.Errorf("failed to remove existing socket file: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) serveHTTP() error {
|
||||
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
||||
tlog.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
|
||||
if err := router.RunUnix(app.config.Server.SocketPath); err != nil {
|
||||
tlog.App.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
|
||||
app.log.App.Info().Msgf("Starting server on %s", address)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: address,
|
||||
Handler: app.router.Handler(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-app.ctx.Done()
|
||||
app.log.App.Debug().Msg("Shutting down http listener")
|
||||
server.Close()
|
||||
}()
|
||||
|
||||
err := server.ListenAndServe()
|
||||
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("failed to start http listener: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) serveUnix() error {
|
||||
if app.config.Server.SocketPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := os.Stat(app.config.Server.SocketPath)
|
||||
|
||||
if err == nil {
|
||||
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
|
||||
err := os.Remove(app.config.Server.SocketPath)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove existing socket file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
|
||||
|
||||
listener, err := net.Listen("unix", app.config.Server.SocketPath)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create unix socket listner: %w", err)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: app.router.Handler(),
|
||||
}
|
||||
|
||||
defer server.Close()
|
||||
defer listener.Close()
|
||||
defer os.Remove(app.config.Server.SocketPath)
|
||||
|
||||
go func() {
|
||||
<-app.ctx.Done()
|
||||
app.log.App.Debug().Msg("Shutting down unix sokcet listener")
|
||||
server.Close()
|
||||
listener.Close()
|
||||
os.Remove(app.config.Server.SocketPath)
|
||||
}()
|
||||
|
||||
err = server.Serve(listener)
|
||||
|
||||
if err != nil && (!errors.Is(err, net.ErrClosed) || !errors.Is(err, http.ErrServerClosed)) {
|
||||
return fmt.Errorf("failed to start unix socket listener: %w", err)
|
||||
// Start server
|
||||
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
||||
tlog.App.Info().Msgf("Starting server on %s", address)
|
||||
if err := router.Run(address); err != nil {
|
||||
tlog.App.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -349,20 +250,20 @@ func (app *BootstrapApp) heartbeatRoutine() {
|
||||
ticker := time.NewTicker(time.Duration(12) * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
type Heartbeat struct {
|
||||
type heartbeat struct {
|
||||
UUID string `json:"uuid"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
var body Heartbeat
|
||||
var body heartbeat
|
||||
|
||||
body.UUID = app.runtime.UUID
|
||||
body.UUID = app.context.uuid
|
||||
body.Version = model.Version
|
||||
|
||||
bodyJson, err := json.Marshal(body)
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Error().Err(err).Msg("Failed to marshal heartbeat body, heartbeat routine will not start")
|
||||
tlog.App.Error().Err(err).Msg("Failed to marshal heartbeat body")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -372,60 +273,43 @@ func (app *BootstrapApp) heartbeatRoutine() {
|
||||
|
||||
heartbeatURL := model.APIServer + "/v1/instances/heartbeat"
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
app.log.App.Debug().Msg("Sending heartbeat")
|
||||
for range ticker.C {
|
||||
tlog.App.Debug().Msg("Sending heartbeat")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson))
|
||||
req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson))
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Error().Err(err).Msg("Failed to create heartbeat request")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to create heartbeat request")
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
res, err := client.Do(req)
|
||||
res, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Error().Err(err).Msg("Failed to send heartbeat")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to send heartbeat")
|
||||
continue
|
||||
}
|
||||
|
||||
res.Body.Close()
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 && res.StatusCode != 201 {
|
||||
app.log.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
|
||||
}
|
||||
case <-app.ctx.Done():
|
||||
app.log.App.Debug().Msg("Stopping heartbeat routine")
|
||||
ticker.Stop()
|
||||
return
|
||||
if res.StatusCode != 200 && res.StatusCode != 201 {
|
||||
tlog.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) dbCleanupRoutine() {
|
||||
func (app *BootstrapApp) dbCleanupRoutine(queries *repository.Queries) {
|
||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
app.log.App.Debug().Msg("Running database cleanup")
|
||||
|
||||
err := app.queries.DeleteExpiredSessions(app.ctx, time.Now().Unix())
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Error().Err(err).Msg("Failed to delete expired sessions")
|
||||
}
|
||||
|
||||
app.log.App.Debug().Msg("Database cleanup completed")
|
||||
case <-app.ctx.Done():
|
||||
app.log.App.Debug().Msg("Stopping database cleanup routine")
|
||||
ticker.Stop()
|
||||
return
|
||||
for range ticker.C {
|
||||
tlog.App.Debug().Msg("Cleaning up old database sessions")
|
||||
err := queries.DeleteExpiredSessions(ctx, time.Now().Unix())
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to clean up old database sessions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,17 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func (app *BootstrapApp) SetupDatabase() error {
|
||||
dir := filepath.Dir(app.config.Database.Path)
|
||||
func (app *BootstrapApp) SetupDatabase(databasePath string) (*sql.DB, error) {
|
||||
dir := filepath.Dir(databasePath)
|
||||
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
||||
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", app.config.Database.Path)
|
||||
db, err := sql.Open("sqlite", databasePath)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Limit to 1 connection to sequence writes, this may need to be revisited in the future
|
||||
@@ -34,25 +34,24 @@ func (app *BootstrapApp) SetupDatabase() error {
|
||||
migrations, err := iofs.New(assets.Migrations, "migrations")
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrations: %w", err)
|
||||
return nil, fmt.Errorf("failed to create migrations: %w", err)
|
||||
}
|
||||
|
||||
target, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sqlite3 instance: %w", err)
|
||||
return nil, fmt.Errorf("failed to create sqlite3 instance: %w", err)
|
||||
}
|
||||
|
||||
migrator, err := migrate.NewWithInstance("iofs", migrations, "sqlite3", target)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrator: %w", err)
|
||||
return nil, fmt.Errorf("failed to create migrator: %w", err)
|
||||
}
|
||||
|
||||
if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
app.db = db
|
||||
return nil
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -2,16 +2,21 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/middleware"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (app *BootstrapApp) setupRouter() error {
|
||||
// we don't want gin debug mode
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
var DEV_MODES = []string{"main", "test", "development"}
|
||||
|
||||
func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||
if !slices.Contains(DEV_MODES, model.Version) {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Recovery())
|
||||
@@ -20,16 +25,19 @@ func (app *BootstrapApp) setupRouter() error {
|
||||
err := engine.SetTrustedProxies(app.config.Auth.TrustedProxies)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set trusted proxies: %w", err)
|
||||
return nil, fmt.Errorf("failed to set trusted proxies: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
contextMiddleware := middleware.NewContextMiddleware(app.log, app.runtime, app.services.authService, app.services.oauthBrokerService)
|
||||
contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
SessionCookieName: app.context.sessionCookieName,
|
||||
}, app.services.authService, app.services.oauthBrokerService)
|
||||
|
||||
err := contextMiddleware.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize context middleware: %w", err)
|
||||
return nil, fmt.Errorf("failed to initialize context middleware: %w", err)
|
||||
}
|
||||
|
||||
engine.Use(contextMiddleware.Middleware())
|
||||
@@ -39,44 +47,69 @@ func (app *BootstrapApp) setupRouter() error {
|
||||
err = uiMiddleware.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize UI middleware: %w", err)
|
||||
return nil, fmt.Errorf("failed to initialize UI middleware: %w", err)
|
||||
}
|
||||
|
||||
engine.Use(uiMiddleware.Middleware())
|
||||
|
||||
zerologMiddleware := middleware.NewZerologMiddleware(app.log)
|
||||
zerologMiddleware := middleware.NewZerologMiddleware()
|
||||
|
||||
err = zerologMiddleware.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize zerolog middleware: %w", err)
|
||||
return nil, fmt.Errorf("failed to initialize zerolog middleware: %w", err)
|
||||
}
|
||||
|
||||
engine.Use(zerologMiddleware.Middleware())
|
||||
|
||||
apiRouter := engine.Group("/api")
|
||||
|
||||
contextController := controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
|
||||
contextController := controller.NewContextController(controller.ContextControllerConfig{
|
||||
Providers: app.context.configuredProviders,
|
||||
Title: app.config.UI.Title,
|
||||
AppURL: app.config.AppURL,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
ForgotPasswordMessage: app.config.UI.ForgotPasswordMessage,
|
||||
BackgroundImage: app.config.UI.BackgroundImage,
|
||||
OAuthAutoRedirect: app.config.OAuth.AutoRedirect,
|
||||
WarningsEnabled: app.config.UI.WarningsEnabled,
|
||||
}, apiRouter)
|
||||
|
||||
contextController.SetupRoutes()
|
||||
|
||||
oauthController := controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
|
||||
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
SecureCookie: app.config.Auth.SecureCookie,
|
||||
CSRFCookieName: app.context.csrfCookieName,
|
||||
RedirectCookieName: app.context.redirectCookieName,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
OAuthSessionCookieName: app.context.oauthSessionCookieName,
|
||||
SubdomainsEnabled: app.config.Auth.SubdomainsEnabled,
|
||||
}, apiRouter, app.services.authService)
|
||||
|
||||
oauthController.SetupRoutes()
|
||||
|
||||
oidcController := controller.NewOIDCController(app.log, app.services.oidcService, apiRouter)
|
||||
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter)
|
||||
|
||||
oidcController.SetupRoutes()
|
||||
|
||||
proxyController := controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService)
|
||||
proxyController := controller.NewProxyController(controller.ProxyControllerConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
}, apiRouter, app.services.accessControlService, app.services.authService)
|
||||
|
||||
proxyController.SetupRoutes()
|
||||
|
||||
userController := controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
|
||||
userController := controller.NewUserController(controller.UserControllerConfig{
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
SessionCookieName: app.context.sessionCookieName,
|
||||
}, apiRouter, app.services.authService)
|
||||
|
||||
userController.SetupRoutes()
|
||||
|
||||
resourcesController := controller.NewResourcesController(app.config, &engine.RouterGroup)
|
||||
resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{
|
||||
Path: app.config.Resources.Path,
|
||||
Enabled: app.config.Resources.Enabled,
|
||||
}, &engine.RouterGroup)
|
||||
|
||||
resourcesController.SetupRoutes()
|
||||
|
||||
@@ -84,10 +117,9 @@ func (app *BootstrapApp) setupRouter() error {
|
||||
|
||||
healthController.SetupRoutes()
|
||||
|
||||
wellknownController := controller.NewWellKnownController(app.services.oidcService, &engine.RouterGroup)
|
||||
wellknownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, app.services.oidcService, engine)
|
||||
|
||||
wellknownController.SetupRoutes()
|
||||
|
||||
app.router = engine
|
||||
return nil
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
@@ -1,96 +1,131 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
func (app *BootstrapApp) setupServices() error {
|
||||
ldapService := service.NewLdapService(app.log, app.config, app.ctx, &app.wg)
|
||||
type Services struct {
|
||||
accessControlService *service.AccessControlsService
|
||||
authService *service.AuthService
|
||||
dockerService *service.DockerService
|
||||
kubernetesService *service.KubernetesService
|
||||
ldapService *service.LdapService
|
||||
oauthBrokerService *service.OAuthBrokerService
|
||||
oidcService *service.OIDCService
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, error) {
|
||||
services := Services{}
|
||||
|
||||
ldapService := service.NewLdapService(service.LdapServiceConfig{
|
||||
Address: app.config.LDAP.Address,
|
||||
BindDN: app.config.LDAP.BindDN,
|
||||
BindPassword: app.config.LDAP.BindPassword,
|
||||
BaseDN: app.config.LDAP.BaseDN,
|
||||
Insecure: app.config.LDAP.Insecure,
|
||||
SearchFilter: app.config.LDAP.SearchFilter,
|
||||
AuthCert: app.config.LDAP.AuthCert,
|
||||
AuthKey: app.config.LDAP.AuthKey,
|
||||
})
|
||||
|
||||
err := ldapService.Init()
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Warn().Err(err).Msg("Failed to initialize LDAP connection, will continue without it")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to setup LDAP service, starting without it")
|
||||
ldapService.Unconfigure()
|
||||
}
|
||||
|
||||
app.services.ldapService = ldapService
|
||||
services.ldapService = ldapService
|
||||
|
||||
var labelProvider service.LabelProvider
|
||||
var dockerService *service.DockerService
|
||||
var kubernetesService *service.KubernetesService
|
||||
|
||||
useKubernetes := app.config.LabelProvider == "kubernetes" ||
|
||||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
|
||||
|
||||
var labelProvider service.LabelProviderImpl
|
||||
|
||||
if useKubernetes {
|
||||
app.log.App.Debug().Msg("Using Kubernetes label provider")
|
||||
|
||||
kubernetesService := service.NewKubernetesService(app.log, app.ctx, &app.wg)
|
||||
|
||||
tlog.App.Debug().Msg("Using Kubernetes label provider")
|
||||
kubernetesService = service.NewKubernetesService()
|
||||
err = kubernetesService.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize kubernetes service: %w", err)
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
app.services.kubernetesService = kubernetesService
|
||||
services.kubernetesService = kubernetesService
|
||||
labelProvider = kubernetesService
|
||||
} else {
|
||||
app.log.App.Debug().Msg("Using Docker label provider")
|
||||
|
||||
dockerService := service.NewDockerService(app.log, app.ctx, &app.wg)
|
||||
|
||||
tlog.App.Debug().Msg("Using Docker label provider")
|
||||
dockerService = service.NewDockerService()
|
||||
err = dockerService.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize docker service: %w", err)
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
app.services.dockerService = dockerService
|
||||
services.dockerService = dockerService
|
||||
labelProvider = dockerService
|
||||
}
|
||||
|
||||
accessControlsService := service.NewAccessControlsService(app.log, labelProvider, app.config.Apps)
|
||||
accessControlsService := service.NewAccessControlsService(labelProvider, app.config.Apps)
|
||||
|
||||
err = accessControlsService.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize access controls service: %w", err)
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
app.services.accessControlService = accessControlsService
|
||||
services.accessControlService = accessControlsService
|
||||
|
||||
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders)
|
||||
oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders)
|
||||
|
||||
err = oauthBrokerService.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize oauth broker service: %w", err)
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
app.services.oauthBrokerService = oauthBrokerService
|
||||
services.oauthBrokerService = oauthBrokerService
|
||||
|
||||
authService := service.NewAuthService(app.log, app.config, app.runtime, app.ctx, &app.wg, app.services.ldapService, app.queries, app.services.oauthBrokerService)
|
||||
authService := service.NewAuthService(service.AuthServiceConfig{
|
||||
LocalUsers: app.context.localUsers,
|
||||
OauthWhitelist: app.context.oauthWhitelist,
|
||||
SessionExpiry: app.config.Auth.SessionExpiry,
|
||||
SessionMaxLifetime: app.config.Auth.SessionMaxLifetime,
|
||||
SecureCookie: app.config.Auth.SecureCookie,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
LoginTimeout: app.config.Auth.LoginTimeout,
|
||||
LoginMaxRetries: app.config.Auth.LoginMaxRetries,
|
||||
SessionCookieName: app.context.sessionCookieName,
|
||||
IP: app.config.Auth.IP,
|
||||
LDAPGroupsCacheTTL: app.config.LDAP.GroupCacheTTL,
|
||||
SubdomainsEnabled: app.config.Auth.SubdomainsEnabled,
|
||||
}, services.ldapService, queries, services.oauthBrokerService)
|
||||
|
||||
err = authService.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize auth service: %w", err)
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
app.services.authService = authService
|
||||
services.authService = authService
|
||||
|
||||
oidcService := service.NewOIDCService(app.log, app.config, app.runtime, app.queries, app.ctx, &app.wg)
|
||||
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
|
||||
Clients: app.config.OIDC.Clients,
|
||||
PrivateKeyPath: app.config.OIDC.PrivateKeyPath,
|
||||
PublicKeyPath: app.config.OIDC.PublicKeyPath,
|
||||
Issuer: app.config.AppURL,
|
||||
SessionExpiry: app.config.Auth.SessionExpiry,
|
||||
}, queries)
|
||||
|
||||
err = oidcService.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize oidc service: %w", err)
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
app.services.oidcService = oidcService
|
||||
services.oidcService = oidcService
|
||||
|
||||
return nil
|
||||
return services, nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/url"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -24,40 +24,48 @@ type UserContextResponse struct {
|
||||
}
|
||||
|
||||
type AppContextResponse struct {
|
||||
Status int `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Providers []model.Provider `json:"providers"`
|
||||
Title string `json:"title"`
|
||||
AppURL string `json:"appUrl"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
||||
BackgroundImage string `json:"backgroundImage"`
|
||||
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
||||
WarningsEnabled bool `json:"warningsEnabled"`
|
||||
Status int `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Providers []Provider `json:"providers"`
|
||||
Title string `json:"title"`
|
||||
AppURL string `json:"appUrl"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
||||
BackgroundImage string `json:"backgroundImage"`
|
||||
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
||||
WarningsEnabled bool `json:"warningsEnabled"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
OAuth bool `json:"oauth"`
|
||||
}
|
||||
|
||||
type ContextControllerConfig struct {
|
||||
Providers []Provider
|
||||
Title string
|
||||
AppURL string
|
||||
CookieDomain string
|
||||
ForgotPasswordMessage string
|
||||
BackgroundImage string
|
||||
OAuthAutoRedirect string
|
||||
WarningsEnabled bool
|
||||
}
|
||||
|
||||
type ContextController struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
router *gin.RouterGroup
|
||||
config ContextControllerConfig
|
||||
router *gin.RouterGroup
|
||||
}
|
||||
|
||||
func NewContextController(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtimeConfig model.RuntimeConfig,
|
||||
router *gin.RouterGroup,
|
||||
) *ContextController {
|
||||
if !config.UI.WarningsEnabled {
|
||||
log.App.Warn().Msg("UI warnings are disabled. This may lead to security issues if you are not careful. Make sure to enable warnings in production environments.")
|
||||
func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController {
|
||||
if !config.WarningsEnabled {
|
||||
tlog.App.Warn().Msg("UI warnings are disabled. This may expose users to security risks. Proceed with caution.")
|
||||
}
|
||||
|
||||
return &ContextController{
|
||||
log: log,
|
||||
config: config,
|
||||
runtime: runtimeConfig,
|
||||
router: router,
|
||||
config: config,
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +79,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
||||
tlog.App.Debug().Err(err).Msg("No user context found in request")
|
||||
c.JSON(200, UserContextResponse{
|
||||
Status: 401,
|
||||
Message: "Unauthorized",
|
||||
@@ -98,9 +106,8 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
|
||||
|
||||
func (controller *ContextController) appContextHandler(c *gin.Context) {
|
||||
appUrl, err := url.Parse(controller.config.AppURL)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
|
||||
tlog.App.Error().Err(err).Msg("Failed to parse app URL")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -111,13 +118,13 @@ func (controller *ContextController) appContextHandler(c *gin.Context) {
|
||||
c.JSON(200, AppContextResponse{
|
||||
Status: 200,
|
||||
Message: "Success",
|
||||
Providers: controller.runtime.ConfiguredProviders,
|
||||
Title: controller.config.UI.Title,
|
||||
Providers: controller.config.Providers,
|
||||
Title: controller.config.Title,
|
||||
AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
|
||||
CookieDomain: controller.runtime.CookieDomain,
|
||||
ForgotPasswordMessage: controller.config.UI.ForgotPasswordMessage,
|
||||
BackgroundImage: controller.config.UI.BackgroundImage,
|
||||
OAuthAutoRedirect: controller.config.OAuth.AutoRedirect,
|
||||
WarningsEnabled: controller.config.UI.WarningsEnabled,
|
||||
CookieDomain: controller.config.CookieDomain,
|
||||
ForgotPasswordMessage: controller.config.ForgotPasswordMessage,
|
||||
BackgroundImage: controller.config.BackgroundImage,
|
||||
OAuthAutoRedirect: controller.config.OAuthAutoRedirect,
|
||||
WarningsEnabled: controller.config.WarningsEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
@@ -20,27 +19,27 @@ type OAuthRequest struct {
|
||||
Provider string `uri:"provider" binding:"required"`
|
||||
}
|
||||
|
||||
type OAuthController struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
router *gin.RouterGroup
|
||||
auth *service.AuthService
|
||||
type OAuthControllerConfig struct {
|
||||
CSRFCookieName string
|
||||
OAuthSessionCookieName string
|
||||
RedirectCookieName string
|
||||
SecureCookie bool
|
||||
AppURL string
|
||||
CookieDomain string
|
||||
SubdomainsEnabled bool
|
||||
}
|
||||
|
||||
func NewOAuthController(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtimeConfig model.RuntimeConfig,
|
||||
router *gin.RouterGroup,
|
||||
auth *service.AuthService,
|
||||
) *OAuthController {
|
||||
type OAuthController struct {
|
||||
config OAuthControllerConfig
|
||||
router *gin.RouterGroup
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *OAuthController {
|
||||
return &OAuthController{
|
||||
log: log,
|
||||
config: config,
|
||||
runtime: runtimeConfig,
|
||||
router: router,
|
||||
auth: auth,
|
||||
config: config,
|
||||
router: router,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +54,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
|
||||
err := c.BindUri(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -68,7 +67,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
err = c.BindQuery(&reqParams)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind query parameters")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind query parameters")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -77,10 +76,10 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !controller.isOidcRequest(reqParams) {
|
||||
isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.runtime.CookieDomain)
|
||||
isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.config.CookieDomain)
|
||||
|
||||
if !isRedirectSafe {
|
||||
controller.log.App.Warn().Str("redirectUri", reqParams.RedirectURI).Msg("Unsafe redirect URI, ignoring")
|
||||
tlog.App.Warn().Str("redirect_uri", reqParams.RedirectURI).Msg("Unsafe redirect URI detected, ignoring")
|
||||
reqParams.RedirectURI = ""
|
||||
}
|
||||
}
|
||||
@@ -88,7 +87,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
sessionId, _, err := controller.auth.NewOAuthSession(req.Provider, reqParams)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create OAuth session")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -99,7 +98,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
authUrl, err := controller.auth.GetOAuthURL(sessionId)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get OAuth URL for session")
|
||||
tlog.App.Error().Err(err).Msg("Failed to get OAuth URL")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -107,7 +106,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie(controller.runtime.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true)
|
||||
c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", controller.getCookieDomain(), controller.config.SecureCookie, true)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
@@ -121,7 +120,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
|
||||
err := c.BindUri(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -129,20 +128,20 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionIdCookie, err := c.Cookie(controller.runtime.OAuthSessionCookieName)
|
||||
sessionIdCookie, err := c.Cookie(controller.config.OAuthSessionCookieName)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get OAuth session cookie")
|
||||
tlog.App.Warn().Err(err).Msg("OAuth session cookie missing")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie(controller.runtime.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true)
|
||||
c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.SecureCookie, true)
|
||||
|
||||
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get pending OAuth session")
|
||||
tlog.App.Error().Err(err).Msg("Failed to get OAuth pending session")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
@@ -151,7 +150,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
|
||||
state := c.Query("state")
|
||||
if state != oauthPendingSession.State {
|
||||
controller.log.App.Warn().Msg("OAuth state mismatch")
|
||||
tlog.App.Warn().Err(err).Msg("CSRF token mismatch")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
@@ -160,7 +159,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
_, err = controller.auth.GetOAuthToken(sessionIdCookie, code)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to exchange code for token")
|
||||
tlog.App.Error().Err(err).Msg("Failed to exchange code for token")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
@@ -168,21 +167,21 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
user, err := controller.auth.GetOAuthUserinfo(sessionIdCookie)
|
||||
|
||||
if user.Email == "" {
|
||||
controller.log.App.Warn().Msg("OAuth provider did not return an email")
|
||||
tlog.App.Error().Msg("OAuth provider did not return an email")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
if !controller.auth.IsEmailWhitelisted(user.Email) {
|
||||
controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access")
|
||||
controller.log.AuditLoginFailure(user.Email, req.Provider, c.ClientIP(), "email not whitelisted")
|
||||
tlog.App.Warn().Str("email", user.Email).Msg("Email not whitelisted")
|
||||
tlog.AuditLoginFailure(c, user.Email, req.Provider, "email not whitelisted")
|
||||
|
||||
queries, err := query.Values(UnauthorizedQuery{
|
||||
Username: user.Email,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
@@ -194,33 +193,33 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
var name string
|
||||
|
||||
if strings.TrimSpace(user.Name) != "" {
|
||||
controller.log.App.Debug().Msg("Using name from OAuth provider")
|
||||
tlog.App.Debug().Msg("Using name from OAuth provider")
|
||||
name = user.Name
|
||||
} else {
|
||||
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
|
||||
tlog.App.Debug().Msg("No name from OAuth provider, using pseudo name")
|
||||
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
|
||||
}
|
||||
|
||||
var username string
|
||||
|
||||
if strings.TrimSpace(user.PreferredUsername) != "" {
|
||||
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
|
||||
tlog.App.Debug().Msg("Using preferred username from OAuth provider")
|
||||
username = user.PreferredUsername
|
||||
} else {
|
||||
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
|
||||
tlog.App.Debug().Msg("No preferred username from OAuth provider, using pseudo username")
|
||||
username = strings.Replace(user.Email, "@", "_", 1)
|
||||
}
|
||||
|
||||
svc, err := controller.auth.GetOAuthService(sessionIdCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session")
|
||||
tlog.App.Error().Err(err).Msg("Failed to get OAuth service for session")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
if svc.ID() != req.Provider {
|
||||
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID())
|
||||
tlog.App.Error().Msgf("OAuth service ID mismatch: expected %s, got %s", svc.ID(), req.Provider)
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
@@ -235,25 +234,25 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
OAuthSub: user.Sub,
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Msg("Creating session cookie for user")
|
||||
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||
|
||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
|
||||
controller.log.AuditLoginSuccess(sessionCookie.Username, sessionCookie.Provider, c.ClientIP())
|
||||
tlog.AuditLoginSuccess(c, sessionCookie.Username, sessionCookie.Provider)
|
||||
|
||||
if controller.isOidcRequest(oauthPendingSession.CallbackParams) {
|
||||
controller.log.App.Debug().Msg("OIDC request detected, redirecting to authorization endpoint with callback params")
|
||||
tlog.App.Debug().Msg("OIDC request, redirecting to authorize page")
|
||||
queries, err := query.Values(oauthPendingSession.CallbackParams)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode OIDC callback query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode OIDC callback query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
@@ -267,7 +266,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode redirect query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
@@ -287,8 +286,8 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams)
|
||||
}
|
||||
|
||||
func (controller *OAuthController) getCookieDomain() string {
|
||||
if controller.config.Auth.SubdomainsEnabled {
|
||||
return "." + controller.runtime.CookieDomain
|
||||
if controller.config.SubdomainsEnabled {
|
||||
return "." + controller.config.CookieDomain
|
||||
}
|
||||
return controller.runtime.CookieDomain
|
||||
return controller.config.CookieDomain
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
type OIDCControllerConfig struct{}
|
||||
|
||||
type OIDCController struct {
|
||||
log *logger.Logger
|
||||
config OIDCControllerConfig
|
||||
router *gin.RouterGroup
|
||||
oidc *service.OIDCService
|
||||
}
|
||||
@@ -56,12 +58,9 @@ type ClientCredentials struct {
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
func NewOIDCController(
|
||||
log *logger.Logger,
|
||||
oidcService *service.OIDCService,
|
||||
router *gin.RouterGroup) *OIDCController {
|
||||
func NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup) *OIDCController {
|
||||
return &OIDCController{
|
||||
log: log,
|
||||
config: config,
|
||||
oidc: oidcService,
|
||||
router: router,
|
||||
}
|
||||
@@ -81,7 +80,7 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||
|
||||
err := c.BindUri(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -92,7 +91,7 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||
client, ok := controller.oidc.GetClient(req.ClientID)
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Str("clientId", req.ClientID).Msg("Client not found")
|
||||
tlog.App.Warn().Str("client_id", req.ClientID).Msg("Client not found")
|
||||
c.JSON(404, gin.H{
|
||||
"status": 404,
|
||||
"message": "Client not found",
|
||||
@@ -143,7 +142,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
err = controller.oidc.ValidateAuthorizeParams(req)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params")
|
||||
tlog.App.Error().Err(err).Msg("Failed to validate authorize params")
|
||||
if err.Error() != "invalid_request_uri" {
|
||||
controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State)
|
||||
return
|
||||
@@ -175,7 +174,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
err = controller.oidc.StoreUserinfo(c, sub, *userContext, req)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to store user info")
|
||||
tlog.App.Error().Err(err).Msg("Failed to insert user info into database")
|
||||
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
|
||||
return
|
||||
}
|
||||
@@ -199,7 +198,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
|
||||
func (controller *OIDCController) Token(c *gin.Context) {
|
||||
if !controller.oidc.IsConfigured() {
|
||||
controller.log.App.Warn().Msg("Received OIDC request but OIDC server is not configured")
|
||||
tlog.App.Warn().Msg("OIDC not configured")
|
||||
c.JSON(404, gin.H{
|
||||
"error": "not_found",
|
||||
})
|
||||
@@ -210,7 +209,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
err := c.Bind(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to bind token request")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind token request")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -219,7 +218,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
err = controller.oidc.ValidateGrantType(req.GrantType)
|
||||
if err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Invalid grant type")
|
||||
tlog.App.Warn().Str("grant_type", req.GrantType).Msg("Unsupported grant type")
|
||||
c.JSON(400, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
@@ -234,12 +233,12 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
// If it fails, we try basic auth
|
||||
if creds.ClientID == "" || creds.ClientSecret == "" {
|
||||
controller.log.App.Debug().Msg("Client credentials not found in form, trying basic auth")
|
||||
tlog.App.Debug().Msg("Tried form values and they are empty, trying basic auth")
|
||||
|
||||
clientId, clientSecret, ok := c.Request.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Msg("Client credentials not found in basic auth")
|
||||
tlog.App.Error().Msg("Missing authorization header")
|
||||
c.Header("www-authenticate", `Basic realm="Tinyauth OIDC Token Endpoint"`)
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
@@ -256,7 +255,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
client, ok := controller.oidc.GetClient(creds.ClientID)
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Client not found")
|
||||
tlog.App.Warn().Str("client_id", creds.ClientID).Msg("Client not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
@@ -264,7 +263,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
}
|
||||
|
||||
if client.ClientSecret != creds.ClientSecret {
|
||||
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Invalid client secret")
|
||||
tlog.App.Warn().Str("client_id", creds.ClientID).Msg("Invalid client secret")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
@@ -278,30 +277,30 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)
|
||||
if err != nil {
|
||||
if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to delete code")
|
||||
tlog.App.Error().Err(err).Msg("Failed to delete access token by code hash")
|
||||
}
|
||||
if errors.Is(err, service.ErrCodeNotFound) {
|
||||
controller.log.App.Warn().Msg("Code not found")
|
||||
tlog.App.Warn().Msg("Code not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrCodeExpired) {
|
||||
controller.log.App.Warn().Msg("Code expired")
|
||||
tlog.App.Warn().Msg("Code expired")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrInvalidClient) {
|
||||
controller.log.App.Warn().Msg("Code does not belong to client")
|
||||
tlog.App.Warn().Msg("Invalid client ID")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get code entry")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -309,7 +308,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
}
|
||||
|
||||
if entry.RedirectURI != req.RedirectURI {
|
||||
controller.log.App.Warn().Msg("Redirect URI does not match")
|
||||
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI mismatch")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
@@ -319,7 +318,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Msg("PKCE validation failed")
|
||||
tlog.App.Warn().Msg("PKCE validation failed")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
@@ -329,7 +328,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to generate access token")
|
||||
tlog.App.Error().Err(err).Msg("Failed to generate access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -342,7 +341,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTokenExpired) {
|
||||
controller.log.App.Warn().Msg("Refresh token expired")
|
||||
tlog.App.Error().Err(err).Msg("Refresh token expired")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
@@ -350,14 +349,14 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
}
|
||||
|
||||
if errors.Is(err, service.ErrInvalidClient) {
|
||||
controller.log.App.Warn().Msg("Refresh token does not belong to client")
|
||||
tlog.App.Error().Err(err).Msg("Invalid client")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
controller.log.App.Error().Err(err).Msg("Failed to refresh access token")
|
||||
tlog.App.Error().Err(err).Msg("Failed to refresh access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -375,7 +374,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
if !controller.oidc.IsConfigured() {
|
||||
controller.log.App.Warn().Msg("Received OIDC userinfo request but OIDC server is not configured")
|
||||
tlog.App.Warn().Msg("OIDC not configured")
|
||||
c.JSON(404, gin.H{
|
||||
"error": "not_found",
|
||||
})
|
||||
@@ -388,7 +387,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
if authorization != "" {
|
||||
tokenType, bearerToken, ok := strings.Cut(authorization, " ")
|
||||
if !ok {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with invalid authorization header")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with malformed authorization header")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -396,7 +395,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
if strings.ToLower(tokenType) != "bearer" {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with non-bearer token")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token type")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -406,7 +405,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
token = bearerToken
|
||||
} else if c.Request.Method == http.MethodPost {
|
||||
if c.ContentType() != "application/x-www-form-urlencoded" {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo POST accessed with invalid content type")
|
||||
tlog.App.Warn().Msg("OIDC userinfo POST accessed with invalid content type")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -414,14 +413,14 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
}
|
||||
token = c.PostForm("access_token")
|
||||
if token == "" {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo POST accessed without access_token")
|
||||
tlog.App.Warn().Msg("OIDC userinfo POST accessed without access_token in body")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed without authorization header or POST body")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed without authorization header")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -432,14 +431,14 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTokenNotFound) {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with invalid token")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get access token")
|
||||
tlog.App.Err(err).Msg("Failed to get token entry")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -448,7 +447,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
|
||||
// If we don't have the openid scope, return an error
|
||||
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with token missing openid scope")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed without openid scope")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_scope",
|
||||
})
|
||||
@@ -458,7 +457,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
user, err := controller.oidc.GetUserinfo(c, entry.Sub)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get user info")
|
||||
tlog.App.Err(err).Msg("Failed to get user entry")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -469,7 +468,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {
|
||||
controller.log.App.Warn().Err(err).Str("reason", reason).Msg("Authorization error")
|
||||
tlog.App.Error().Err(err).Msg(reason)
|
||||
|
||||
if callback != "" {
|
||||
errorQueries := CallbackError{
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
@@ -50,27 +50,23 @@ type ProxyContext struct {
|
||||
ProxyType ProxyType
|
||||
}
|
||||
|
||||
type ProxyController struct {
|
||||
log *logger.Logger
|
||||
runtime model.RuntimeConfig
|
||||
router *gin.RouterGroup
|
||||
acls *service.AccessControlsService
|
||||
auth *service.AuthService
|
||||
type ProxyControllerConfig struct {
|
||||
AppURL string
|
||||
}
|
||||
|
||||
func NewProxyController(
|
||||
log *logger.Logger,
|
||||
runtime model.RuntimeConfig,
|
||||
router *gin.RouterGroup,
|
||||
acls *service.AccessControlsService,
|
||||
auth *service.AuthService,
|
||||
) *ProxyController {
|
||||
type ProxyController struct {
|
||||
config ProxyControllerConfig
|
||||
router *gin.RouterGroup
|
||||
acls *service.AccessControlsService
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController {
|
||||
return &ProxyController{
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
router: router,
|
||||
acls: acls,
|
||||
auth: auth,
|
||||
config: config,
|
||||
router: router,
|
||||
acls: acls,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +80,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
proxyCtx, err := controller.getProxyContext(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get proxy context from request")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get proxy context")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad request",
|
||||
@@ -92,15 +88,19 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tlog.App.Trace().Interface("ctx", proxyCtx).Msg("Got proxy context")
|
||||
|
||||
// Get acls
|
||||
acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get ACLs for resource")
|
||||
tlog.App.Error().Err(err).Msg("Failed to get access controls for resource")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
tlog.App.Trace().Interface("acls", acls).Msg("ACLs for resource")
|
||||
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
if controller.auth.IsBypassedIP(clientIP, acls) {
|
||||
@@ -115,13 +115,13 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource")
|
||||
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
if !authEnabled {
|
||||
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
|
||||
tlog.App.Debug().Msg("Authentication disabled for resource, allowing access")
|
||||
controller.setHeaders(c, acls)
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
@@ -137,12 +137,12 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -160,24 +160,26 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
userContext, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
|
||||
tlog.App.Debug().Err(err).Msg("No user context found in request, treating as unauthenticated")
|
||||
userContext = &model.UserContext{
|
||||
Authenticated: false,
|
||||
}
|
||||
}
|
||||
|
||||
tlog.App.Trace().Interface("context", userContext).Msg("User context from request")
|
||||
|
||||
if userContext.Authenticated {
|
||||
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
|
||||
|
||||
if !userAllowed {
|
||||
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource")
|
||||
tlog.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource")
|
||||
|
||||
queries, err := query.Values(UnauthorizedQuery{
|
||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
@@ -188,7 +190,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
queries.Set("username", userContext.GetUsername())
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -213,7 +215,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !groupOK {
|
||||
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not in the required group to access resource")
|
||||
tlog.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements")
|
||||
|
||||
queries, err := query.Values(UnauthorizedQuery{
|
||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||
@@ -221,7 +223,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
@@ -232,7 +234,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
queries.Set("username", userContext.GetUsername())
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -275,12 +277,12 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode redirect query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/login?%s", controller.runtime.AppURL, queries.Encode())
|
||||
redirectURL := fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -304,19 +306,20 @@ func (controller *ProxyController) setHeaders(c *gin.Context, acls *model.App) {
|
||||
headers := utils.ParseHeaders(acls.Response.Headers)
|
||||
|
||||
for key, value := range headers {
|
||||
tlog.App.Debug().Str("header", key).Msg("Setting header")
|
||||
c.Header(key, value)
|
||||
}
|
||||
|
||||
basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile)
|
||||
|
||||
if acls.Response.BasicAuth.Username != "" && basicPassword != "" {
|
||||
controller.log.App.Debug().Msg("Setting basic auth header for response")
|
||||
tlog.App.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header")
|
||||
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.EncodeBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) {
|
||||
redirectURL := fmt.Sprintf("%s/error", controller.runtime.AppURL)
|
||||
redirectURL := fmt.Sprintf("%s/error", controller.config.AppURL)
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -517,7 +520,7 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
||||
return ProxyContext{}, err
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Msgf("Determined proxy type: %v", proxy)
|
||||
tlog.App.Debug().Msgf("Proxy: %v", req.Proxy)
|
||||
|
||||
authModules := controller.determineAuthModules(proxy)
|
||||
|
||||
@@ -528,13 +531,13 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
||||
var ctx ProxyContext
|
||||
|
||||
for _, module := range authModules {
|
||||
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module)
|
||||
tlog.App.Debug().Msgf("Trying auth module: %v", module)
|
||||
ctx, err = controller.getContextFromAuthModule(c, module)
|
||||
if err == nil {
|
||||
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
|
||||
tlog.App.Debug().Msgf("Auth module %v succeeded", module)
|
||||
break
|
||||
}
|
||||
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
|
||||
tlog.App.Debug().Err(err).Msgf("Auth module %v failed", module)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -546,9 +549,9 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
||||
isBrowser := BrowserUserAgentRegex.MatchString(userAgent)
|
||||
|
||||
if isBrowser {
|
||||
controller.log.App.Debug().Msg("Request identified as coming from a browser client")
|
||||
tlog.App.Debug().Msg("Request identified as coming from a browser")
|
||||
} else {
|
||||
controller.log.App.Debug().Msg("Request identified as coming from a non-browser client")
|
||||
tlog.App.Debug().Msg("Request identified as coming from a non-browser client")
|
||||
}
|
||||
|
||||
ctx.IsBrowser = isBrowser
|
||||
|
||||
@@ -4,20 +4,21 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
type ResourcesControllerConfig struct {
|
||||
Path string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type ResourcesController struct {
|
||||
config model.Config
|
||||
config ResourcesControllerConfig
|
||||
router *gin.RouterGroup
|
||||
fileServer http.Handler
|
||||
}
|
||||
|
||||
func NewResourcesController(
|
||||
config model.Config,
|
||||
router *gin.RouterGroup,
|
||||
) *ResourcesController {
|
||||
fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Resources.Path)))
|
||||
func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {
|
||||
fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Path)))
|
||||
|
||||
return &ResourcesController{
|
||||
config: config,
|
||||
@@ -31,14 +32,14 @@ func (controller *ResourcesController) SetupRoutes() {
|
||||
}
|
||||
|
||||
func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
|
||||
if controller.config.Resources.Path == "" {
|
||||
if controller.config.Path == "" {
|
||||
c.JSON(404, gin.H{
|
||||
"status": 404,
|
||||
"message": "Resources not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !controller.config.Resources.Enabled {
|
||||
if !controller.config.Enabled {
|
||||
c.JSON(403, gin.H{
|
||||
"status": 403,
|
||||
"message": "Resources are disabled",
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
@@ -25,24 +25,22 @@ type TotpRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
log *logger.Logger
|
||||
runtime model.RuntimeConfig
|
||||
router *gin.RouterGroup
|
||||
auth *service.AuthService
|
||||
type UserControllerConfig struct {
|
||||
CookieDomain string
|
||||
SessionCookieName string
|
||||
}
|
||||
|
||||
func NewUserController(
|
||||
log *logger.Logger,
|
||||
runtimeConfig model.RuntimeConfig,
|
||||
router *gin.RouterGroup,
|
||||
auth *service.AuthService,
|
||||
) *UserController {
|
||||
type UserController struct {
|
||||
config UserControllerConfig
|
||||
router *gin.RouterGroup
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
func NewUserController(config UserControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *UserController {
|
||||
return &UserController{
|
||||
log: log,
|
||||
runtime: runtimeConfig,
|
||||
router: router,
|
||||
auth: auth,
|
||||
config: config,
|
||||
router: router,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +56,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind JSON")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind JSON")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -66,13 +64,13 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt")
|
||||
tlog.App.Debug().Str("username", req.Username).Msg("Login attempt")
|
||||
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
|
||||
|
||||
if isLocked {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
||||
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "account locked")
|
||||
tlog.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
||||
tlog.AuditLoginFailure(c, req.Username, "username", "account locked")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.JSON(429, gin.H{
|
||||
@@ -86,16 +84,16 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrUserNotFound) {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
|
||||
tlog.App.Warn().Str("username", req.Username).Msg("User not found")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
controller.log.AuditLoginFailure(req.Username, "unkown", c.ClientIP(), "user not found")
|
||||
tlog.AuditLoginFailure(c, req.Username, "username", "user not found")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
|
||||
tlog.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -104,13 +102,9 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt")
|
||||
tlog.App.Warn().Err(err).Str("username", req.Username).Msg("Failed to verify password")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
if search.Type == model.UserLocal {
|
||||
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "invalid password")
|
||||
} else {
|
||||
controller.log.AuditLoginFailure(req.Username, "ldap", c.ClientIP(), "invalid password")
|
||||
}
|
||||
tlog.AuditLoginFailure(c, req.Username, "username", "invalid password")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -124,7 +118,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
localUser = controller.auth.GetLocalUser(req.Username)
|
||||
|
||||
if localUser == nil {
|
||||
controller.log.App.Error().Str("username", req.Username).Msg("Local user not found after successful password verification")
|
||||
tlog.App.Warn().Str("username", req.Username).Msg("User disappeared during login")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -133,7 +127,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
if localUser.TOTPSecret != "" {
|
||||
controller.log.App.Debug().Str("username", req.Username).Msg("TOTP required for user, creating pending TOTP session")
|
||||
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
|
||||
|
||||
name := localUser.Attributes.Name
|
||||
if name == "" {
|
||||
@@ -142,7 +136,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
|
||||
email := localUser.Attributes.Email
|
||||
if email == "" {
|
||||
email = utils.CompileUserEmail(localUser.Username, controller.runtime.CookieDomain)
|
||||
email = utils.CompileUserEmail(localUser.Username, controller.config.CookieDomain)
|
||||
}
|
||||
|
||||
cookie, err := controller.auth.CreateSession(c, repository.Session{
|
||||
@@ -154,7 +148,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create pending TOTP session")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -176,7 +170,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
sessionCookie := repository.Session{
|
||||
Username: req.Username,
|
||||
Name: utils.Capitalize(req.Username),
|
||||
Email: utils.CompileUserEmail(req.Username, controller.runtime.CookieDomain),
|
||||
Email: utils.CompileUserEmail(req.Username, controller.config.CookieDomain),
|
||||
Provider: "local",
|
||||
}
|
||||
|
||||
@@ -193,10 +187,12 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
sessionCookie.Provider = "ldap"
|
||||
}
|
||||
|
||||
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||
|
||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create session cookie after successful login")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -206,13 +202,8 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
|
||||
controller.log.App.Info().Str("username", req.Username).Msg("Login successful")
|
||||
|
||||
if search.Type == model.UserLocal {
|
||||
controller.log.AuditLoginSuccess(req.Username, "local", c.ClientIP())
|
||||
} else {
|
||||
controller.log.AuditLoginSuccess(req.Username, "ldap", c.ClientIP())
|
||||
}
|
||||
tlog.App.Info().Str("username", req.Username).Msg("Login successful")
|
||||
tlog.AuditLoginSuccess(c, req.Username, "username")
|
||||
|
||||
controller.auth.RecordLoginAttempt(req.Username, true)
|
||||
|
||||
@@ -223,20 +214,20 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (controller *UserController) logoutHandler(c *gin.Context) {
|
||||
controller.log.App.Debug().Msg("Logout attempt")
|
||||
tlog.App.Debug().Msg("Logout request received")
|
||||
|
||||
uuid, err := c.Cookie(controller.runtime.SessionCookieName)
|
||||
uuid, err := c.Cookie(controller.config.SessionCookieName)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
controller.log.App.Warn().Msg("Logout attempt without session cookie, treating as successful logout")
|
||||
tlog.App.Warn().Msg("No session cookie found on logout request")
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"message": "Logout successful",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.log.App.Error().Err(err).Msg("Error retrieving session cookie on logout")
|
||||
tlog.App.Error().Err(err).Msg("Error retrieving session cookie on logout")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -247,7 +238,7 @@ func (controller *UserController) logoutHandler(c *gin.Context) {
|
||||
cookie, err := controller.auth.DeleteSession(c, uuid)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Error deleting session on logout")
|
||||
tlog.App.Error().Err(err).Msg("Error deleting session on logout")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -258,10 +249,10 @@ func (controller *UserController) logoutHandler(c *gin.Context) {
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err == nil {
|
||||
controller.log.AuditLogout(context.GetUsername(), context.GetProviderID(), c.ClientIP())
|
||||
tlog.AuditLogout(c, context.GetUsername(), context.GetProviderID())
|
||||
} else {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to get user context during logout, logging audit with unknown user")
|
||||
controller.log.AuditLogout("unknown", "unknown", c.ClientIP())
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get user context for logout audit, proceeding without username")
|
||||
tlog.AuditLogout(c, "unknown", "unknown")
|
||||
}
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
@@ -277,7 +268,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind JSON for TOTP verification")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind JSON")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -288,7 +279,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request for TOTP verification")
|
||||
tlog.App.Error().Err(err).Msg("Failed to get user context")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -297,7 +288,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !context.TOTPPending() {
|
||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("TOTP verification attempt without pending TOTP session")
|
||||
tlog.App.Warn().Msg("TOTP attempt without a pending TOTP session")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -305,13 +296,12 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Str("username", context.GetUsername()).Msg("TOTP verification attempt")
|
||||
tlog.App.Debug().Str("username", context.GetUsername()).Msg("TOTP verification attempt")
|
||||
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername())
|
||||
|
||||
if isLocked {
|
||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Account is locked due to too many failed TOTP attempts")
|
||||
controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "account locked")
|
||||
tlog.App.Warn().Str("username", context.GetUsername()).Msg("Account is locked due to too many failed TOTP attempts")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.JSON(429, gin.H{
|
||||
@@ -324,7 +314,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
user := controller.auth.GetLocalUser(context.GetUsername())
|
||||
|
||||
if user == nil {
|
||||
controller.log.App.Error().Str("username", context.GetUsername()).Msg("Local user not found during TOTP verification")
|
||||
tlog.App.Error().Str("username", context.GetUsername()).Msg("User not found in TOTP handler")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -335,9 +325,9 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
ok := totp.Validate(req.Code, user.TOTPSecret)
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code during verification attempt")
|
||||
tlog.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code")
|
||||
controller.auth.RecordLoginAttempt(context.GetUsername(), false)
|
||||
controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "invalid TOTP code")
|
||||
tlog.AuditLoginFailure(c, context.GetUsername(), "totp", "invalid totp code")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -345,15 +335,15 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := c.Cookie(controller.runtime.SessionCookieName)
|
||||
uuid, err := c.Cookie(controller.config.SessionCookieName)
|
||||
|
||||
if err == nil {
|
||||
_, err = controller.auth.DeleteSession(c, uuid)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete pending TOTP session")
|
||||
}
|
||||
} else {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, cannot delete it")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, proceeding without deleting it")
|
||||
}
|
||||
|
||||
controller.auth.RecordLoginAttempt(context.GetUsername(), true)
|
||||
@@ -361,7 +351,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
sessionCookie := repository.Session{
|
||||
Username: user.Username,
|
||||
Name: utils.Capitalize(user.Username),
|
||||
Email: utils.CompileUserEmail(user.Username, controller.runtime.CookieDomain),
|
||||
Email: utils.CompileUserEmail(user.Username, controller.config.CookieDomain),
|
||||
Provider: "local",
|
||||
}
|
||||
|
||||
@@ -372,10 +362,12 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
sessionCookie.Email = user.Attributes.Email
|
||||
}
|
||||
|
||||
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||
|
||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful TOTP verification")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -385,8 +377,8 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
|
||||
controller.log.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful, login complete")
|
||||
controller.log.AuditLoginSuccess(context.GetUsername(), "local", c.ClientIP())
|
||||
tlog.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful")
|
||||
tlog.AuditLoginSuccess(c, context.GetUsername(), "totp")
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
|
||||
@@ -26,21 +26,25 @@ type OpenIDConnectConfiguration struct {
|
||||
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||
}
|
||||
|
||||
type WellKnownControllerConfig struct{}
|
||||
|
||||
type WellKnownController struct {
|
||||
router *gin.RouterGroup
|
||||
config WellKnownControllerConfig
|
||||
engine *gin.Engine
|
||||
oidc *service.OIDCService
|
||||
}
|
||||
|
||||
func NewWellKnownController(oidc *service.OIDCService, router *gin.RouterGroup) *WellKnownController {
|
||||
func NewWellKnownController(config WellKnownControllerConfig, oidc *service.OIDCService, engine *gin.Engine) *WellKnownController {
|
||||
return &WellKnownController{
|
||||
config: config,
|
||||
oidc: oidc,
|
||||
router: router,
|
||||
engine: engine,
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *WellKnownController) SetupRoutes() {
|
||||
controller.router.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
|
||||
controller.router.GET("/.well-known/jwks.json", controller.JWKS)
|
||||
controller.engine.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
|
||||
controller.engine.GET("/.well-known/jwks.json", controller.JWKS)
|
||||
}
|
||||
|
||||
func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -35,24 +35,22 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
type ContextMiddleware struct {
|
||||
log *logger.Logger
|
||||
runtime model.RuntimeConfig
|
||||
auth *service.AuthService
|
||||
broker *service.OAuthBrokerService
|
||||
type ContextMiddlewareConfig struct {
|
||||
CookieDomain string
|
||||
SessionCookieName string
|
||||
}
|
||||
|
||||
func NewContextMiddleware(
|
||||
log *logger.Logger,
|
||||
runtime model.RuntimeConfig,
|
||||
auth *service.AuthService,
|
||||
broker *service.OAuthBrokerService,
|
||||
) *ContextMiddleware {
|
||||
type ContextMiddleware struct {
|
||||
config ContextMiddlewareConfig
|
||||
auth *service.AuthService
|
||||
broker *service.OAuthBrokerService
|
||||
}
|
||||
|
||||
func NewContextMiddleware(config ContextMiddlewareConfig, auth *service.AuthService, broker *service.OAuthBrokerService) *ContextMiddleware {
|
||||
return &ContextMiddleware{
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
auth: auth,
|
||||
broker: broker,
|
||||
config: config,
|
||||
auth: auth,
|
||||
broker: broker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +65,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := c.Cookie(m.runtime.SessionCookieName)
|
||||
uuid, err := c.Cookie(m.config.SessionCookieName)
|
||||
|
||||
if err == nil {
|
||||
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid)
|
||||
@@ -77,12 +75,12 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
}
|
||||
|
||||
m.log.App.Debug().Msgf("Authenticated user %s via session cookie", userContext.GetUsername())
|
||||
tlog.App.Trace().Msgf("Authenticated user from session cookie: %s", userContext.GetUsername())
|
||||
c.Set("context", userContext)
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
m.log.App.Error().Msgf("Error authenticating session cookie: %v", err)
|
||||
tlog.App.Error().Msgf("Error authenticating session cookie: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +90,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
userContext, headers, err := m.basicAuth(username, password)
|
||||
|
||||
if err != nil {
|
||||
m.log.App.Error().Msgf("Error authenticating basic auth: %v", err)
|
||||
tlog.App.Error().Msgf("Error authenticating basic auth: %v", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -143,7 +141,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model
|
||||
}
|
||||
|
||||
if userContext.Local.Attributes.Email == "" {
|
||||
userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain)
|
||||
userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.config.CookieDomain)
|
||||
}
|
||||
case model.ProviderLDAP:
|
||||
search, err := m.auth.SearchUser(userContext.LDAP.Username)
|
||||
@@ -164,7 +162,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model
|
||||
|
||||
userContext.LDAP.Groups = user.Groups
|
||||
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
|
||||
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain)
|
||||
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.config.CookieDomain)
|
||||
case model.ProviderOAuth:
|
||||
_, exists := m.broker.GetService(userContext.OAuth.ID)
|
||||
|
||||
@@ -193,7 +191,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
|
||||
locked, remaining := m.auth.IsAccountLocked(username)
|
||||
|
||||
if locked {
|
||||
m.log.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", username, remaining)
|
||||
tlog.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", username, remaining)
|
||||
headers["x-tinyauth-lock-locked"] = "true"
|
||||
headers["x-tinyauth-lock-reset"] = time.Now().Add(time.Duration(remaining) * time.Second).Format(time.RFC3339)
|
||||
return nil, headers, nil
|
||||
@@ -226,7 +224,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
|
||||
BaseContext: model.BaseContext{
|
||||
Username: user.Username,
|
||||
Name: utils.Capitalize(user.Username),
|
||||
Email: utils.CompileUserEmail(user.Username, m.runtime.CookieDomain),
|
||||
Email: utils.CompileUserEmail(user.Username, m.config.CookieDomain),
|
||||
},
|
||||
Attributes: user.Attributes,
|
||||
}
|
||||
@@ -242,7 +240,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
|
||||
BaseContext: model.BaseContext{
|
||||
Username: username,
|
||||
Name: utils.Capitalize(username),
|
||||
Email: utils.CompileUserEmail(username, m.runtime.CookieDomain),
|
||||
Email: utils.CompileUserEmail(username, m.config.CookieDomain),
|
||||
},
|
||||
Groups: user.Groups,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/assets"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -39,6 +40,8 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
|
||||
tlog.App.Debug().Str("path", path).Msg("path")
|
||||
|
||||
switch strings.SplitN(path, "/", 2)[0] {
|
||||
case "api", "resources", ".well-known":
|
||||
c.Next()
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
// See context middleware for explanation of why we have to do this
|
||||
@@ -17,14 +17,10 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
type ZerologMiddleware struct {
|
||||
log *logger.Logger
|
||||
}
|
||||
type ZerologMiddleware struct{}
|
||||
|
||||
func NewZerologMiddleware(log *logger.Logger) *ZerologMiddleware {
|
||||
return &ZerologMiddleware{
|
||||
log: log,
|
||||
}
|
||||
func NewZerologMiddleware() *ZerologMiddleware {
|
||||
return &ZerologMiddleware{}
|
||||
}
|
||||
|
||||
func (m *ZerologMiddleware) Init() error {
|
||||
@@ -54,7 +50,7 @@ func (m *ZerologMiddleware) Middleware() gin.HandlerFunc {
|
||||
|
||||
latency := time.Since(tStart).String()
|
||||
|
||||
subLogger := m.log.HTTP.With().Str("method", method).
|
||||
subLogger := tlog.HTTP.With().Str("method", method).
|
||||
Str("path", path).
|
||||
Str("address", address).
|
||||
Str("client_ip", clientIP).
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package model
|
||||
|
||||
type RuntimeConfig struct {
|
||||
AppURL string
|
||||
UUID string
|
||||
CookieDomain string
|
||||
SessionCookieName string
|
||||
CSRFCookieName string
|
||||
RedirectCookieName string
|
||||
OAuthSessionCookieName string
|
||||
LocalUsers []LocalUser
|
||||
OAuthProviders map[string]OAuthServiceConfig
|
||||
OAuthWhitelist []string
|
||||
ConfiguredProviders []Provider
|
||||
OIDCClients []OIDCClientConfig
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
OAuth bool `json:"oauth"`
|
||||
}
|
||||
@@ -4,25 +4,20 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
type LabelProviderImpl interface {
|
||||
type LabelProvider interface {
|
||||
GetLabels(appDomain string) (*model.App, error)
|
||||
}
|
||||
|
||||
type AccessControlsService struct {
|
||||
log *logger.Logger
|
||||
labelProvider LabelProviderImpl
|
||||
labelProvider LabelProvider
|
||||
static map[string]model.App
|
||||
}
|
||||
|
||||
func NewAccessControlsService(
|
||||
log *logger.Logger,
|
||||
labelProvider LabelProviderImpl,
|
||||
static map[string]model.App) *AccessControlsService {
|
||||
func NewAccessControlsService(labelProvider LabelProvider, static map[string]model.App) *AccessControlsService {
|
||||
return &AccessControlsService{
|
||||
log: log,
|
||||
labelProvider: labelProvider,
|
||||
static: static,
|
||||
}
|
||||
@@ -36,13 +31,13 @@ func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
||||
var appAcls *model.App
|
||||
for app, config := range acls.static {
|
||||
if config.Config.Domain == domain {
|
||||
acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||
tlog.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||
appAcls = &config
|
||||
break // If we find a match by domain, we can stop searching
|
||||
}
|
||||
|
||||
if strings.SplitN(domain, ".", 2)[0] == app {
|
||||
acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||
tlog.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||
appAcls = &config
|
||||
break // If we find a match by app name, we can stop searching
|
||||
}
|
||||
@@ -55,11 +50,11 @@ func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App,
|
||||
app := acls.lookupStaticACLs(domain)
|
||||
|
||||
if app != nil {
|
||||
acls.log.App.Debug().Msg("Using static ACLs for app")
|
||||
tlog.App.Debug().Msg("Using ACls from static configuration")
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// Fallback to label provider
|
||||
acls.log.App.Debug().Msg("Using label provider for app")
|
||||
tlog.App.Debug().Msg("Falling back to label provider for ACLs")
|
||||
return acls.labelProvider.GetLabels(domain)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"slices"
|
||||
|
||||
@@ -72,43 +72,39 @@ type Lockdown struct {
|
||||
ActiveUntil time.Time
|
||||
}
|
||||
|
||||
type AuthServiceConfig struct {
|
||||
LocalUsers *[]model.LocalUser
|
||||
OauthWhitelist []string
|
||||
SessionExpiry int
|
||||
SessionMaxLifetime int
|
||||
SecureCookie bool
|
||||
CookieDomain string
|
||||
LoginTimeout int
|
||||
LoginMaxRetries int
|
||||
SessionCookieName string
|
||||
IP model.IPConfig
|
||||
LDAPGroupsCacheTTL int
|
||||
SubdomainsEnabled bool
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
context context.Context
|
||||
wg *sync.WaitGroup
|
||||
|
||||
ldap *LdapService
|
||||
queries *repository.Queries
|
||||
oauthBroker *OAuthBrokerService
|
||||
|
||||
config AuthServiceConfig
|
||||
loginAttempts map[string]*LoginAttempt
|
||||
ldapGroupsCache map[string]*LdapGroupsCache
|
||||
oauthPendingSessions map[string]*OAuthPendingSession
|
||||
oauthMutex sync.RWMutex
|
||||
loginMutex sync.RWMutex
|
||||
ldapGroupsMutex sync.RWMutex
|
||||
ldap *LdapService
|
||||
queries *repository.Queries
|
||||
oauthBroker *OAuthBrokerService
|
||||
lockdown *Lockdown
|
||||
lockdownCtx context.Context
|
||||
lockdownCancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtime model.RuntimeConfig,
|
||||
context context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
ldap *LdapService,
|
||||
queries *repository.Queries,
|
||||
oauthBroker *OAuthBrokerService,
|
||||
) *AuthService {
|
||||
func NewAuthService(config AuthServiceConfig, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService {
|
||||
return &AuthService{
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
context: context,
|
||||
wg: wg,
|
||||
config: config,
|
||||
loginAttempts: make(map[string]*LoginAttempt),
|
||||
ldapGroupsCache: make(map[string]*LdapGroupsCache),
|
||||
@@ -120,7 +116,7 @@ func NewAuthService(
|
||||
}
|
||||
|
||||
func (auth *AuthService) Init() error {
|
||||
auth.wg.Go(auth.CleanupOAuthSessionsRoutine)
|
||||
go auth.CleanupOAuthSessionsRoutine()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -177,10 +173,10 @@ func (auth *AuthService) CheckUserPassword(search model.UserSearch, password str
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetLocalUser(username string) *model.LocalUser {
|
||||
if auth.runtime.LocalUsers == nil {
|
||||
if auth.config.LocalUsers == nil {
|
||||
return nil
|
||||
}
|
||||
for _, user := range auth.runtime.LocalUsers {
|
||||
for _, user := range *auth.config.LocalUsers {
|
||||
if user.Username == username {
|
||||
return &user
|
||||
}
|
||||
@@ -213,7 +209,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
auth.ldapGroupsMutex.Lock()
|
||||
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
|
||||
Groups: groups,
|
||||
Expires: time.Now().Add(time.Duration(auth.config.LDAP.GroupCacheTTL) * time.Second),
|
||||
Expires: time.Now().Add(time.Duration(auth.config.LDAPGroupsCacheTTL) * time.Second),
|
||||
}
|
||||
auth.ldapGroupsMutex.Unlock()
|
||||
|
||||
@@ -232,7 +228,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
return true, remaining
|
||||
}
|
||||
|
||||
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
|
||||
if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
@@ -250,7 +246,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
}
|
||||
|
||||
func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
||||
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
|
||||
if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -281,14 +277,14 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
||||
|
||||
attempt.FailedAttempts++
|
||||
|
||||
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
||||
if attempt.FailedAttempts >= auth.config.LoginMaxRetries {
|
||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second)
|
||||
tlog.App.Warn().Str("identifier", identifier).Int("timeout", auth.config.LoginTimeout).Msg("Account locked due to too many failed login attempts")
|
||||
}
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
||||
return utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email)
|
||||
return utils.CheckFilter(strings.Join(auth.config.OauthWhitelist, ","), email)
|
||||
}
|
||||
|
||||
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
|
||||
@@ -303,7 +299,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
if data.TotpPending {
|
||||
expiry = 3600
|
||||
} else {
|
||||
expiry = auth.config.Auth.SessionExpiry
|
||||
expiry = auth.config.SessionExpiry
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
|
||||
@@ -329,13 +325,13 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Name: auth.config.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
Secure: auth.config.SecureCookie,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
@@ -352,8 +348,8 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
|
||||
|
||||
var refreshThreshold int64
|
||||
|
||||
if auth.config.Auth.SessionExpiry <= int(time.Hour.Seconds()) {
|
||||
refreshThreshold = int64(auth.config.Auth.SessionExpiry / 2)
|
||||
if auth.config.SessionExpiry <= int(time.Hour.Seconds()) {
|
||||
refreshThreshold = int64(auth.config.SessionExpiry / 2)
|
||||
} else {
|
||||
refreshThreshold = int64(time.Hour.Seconds())
|
||||
}
|
||||
@@ -382,13 +378,13 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Name: auth.config.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
|
||||
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
|
||||
MaxAge: int(newExpiry - currentTime),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
Secure: auth.config.SecureCookie,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
@@ -399,7 +395,7 @@ func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.
|
||||
err := auth.queries.DeleteSession(ctx, uuid)
|
||||
|
||||
if err != nil {
|
||||
auth.log.App.Error().Err(err).Str("uuid", uuid).Msg("Failed to delete session from database")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete session from database, proceeding to clear cookie anyway")
|
||||
}
|
||||
|
||||
err = auth.queries.DeleteSession(ctx, uuid)
|
||||
@@ -409,13 +405,13 @@ func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Name: auth.config.SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
Secure: auth.config.SecureCookie,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
@@ -433,8 +429,8 @@ func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*reposito
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
if auth.config.Auth.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
|
||||
if currentTime-session.CreatedAt > int64(auth.config.Auth.SessionMaxLifetime) {
|
||||
if auth.config.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
|
||||
if currentTime-session.CreatedAt > int64(auth.config.SessionMaxLifetime) {
|
||||
err = auth.queries.DeleteSession(ctx, uuid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete expired session: %w", err)
|
||||
@@ -455,7 +451,7 @@ func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*reposito
|
||||
}
|
||||
|
||||
func (auth *AuthService) LocalAuthConfigured() bool {
|
||||
return len(auth.runtime.LocalUsers) > 0
|
||||
return auth.config.LocalUsers != nil && len(*auth.config.LocalUsers) > 0
|
||||
}
|
||||
|
||||
func (auth *AuthService) LDAPAuthConfigured() bool {
|
||||
@@ -468,18 +464,18 @@ func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext
|
||||
}
|
||||
|
||||
if context.Provider == model.ProviderOAuth {
|
||||
auth.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
|
||||
tlog.App.Debug().Msg("Checking OAuth whitelist")
|
||||
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
|
||||
}
|
||||
|
||||
if acls.Users.Block != "" {
|
||||
auth.log.App.Debug().Msg("Checking users block list")
|
||||
tlog.App.Debug().Msg("Checking blocked users")
|
||||
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("Checking users allow list")
|
||||
tlog.App.Debug().Msg("Checking users")
|
||||
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
|
||||
}
|
||||
|
||||
@@ -489,23 +485,23 @@ func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContex
|
||||
}
|
||||
|
||||
if !context.IsOAuth() {
|
||||
auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
||||
tlog.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
|
||||
auth.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
|
||||
tlog.App.Debug().Msg("Provider override for OAuth groups enabled, skipping group check")
|
||||
return true
|
||||
}
|
||||
|
||||
for _, userGroup := range context.OAuth.Groups {
|
||||
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
|
||||
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
|
||||
tlog.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("No groups matched")
|
||||
tlog.App.Debug().Msg("No groups matched")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -515,18 +511,18 @@ func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext
|
||||
}
|
||||
|
||||
if !context.IsLDAP() {
|
||||
auth.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
|
||||
tlog.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, userGroup := range context.LDAP.Groups {
|
||||
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
|
||||
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
|
||||
tlog.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("No groups matched")
|
||||
tlog.App.Debug().Msg("No groups matched")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -570,17 +566,17 @@ func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
|
||||
}
|
||||
|
||||
// Merge the global and app IP filter
|
||||
blockedIps := append(auth.config.Auth.IP.Block, acls.IP.Block...)
|
||||
allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...)
|
||||
blockedIps := append(auth.config.IP.Block, acls.IP.Block...)
|
||||
allowedIPs := append(auth.config.IP.Allow, acls.IP.Allow...)
|
||||
|
||||
for _, blocked := range blockedIps {
|
||||
res, err := utils.FilterIP(blocked, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
||||
tlog.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
|
||||
tlog.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -588,21 +584,21 @@ func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
|
||||
for _, allowed := range allowedIPs {
|
||||
res, err := utils.FilterIP(allowed, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
||||
tlog.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
|
||||
tlog.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedIPs) > 0 {
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
||||
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
||||
return false
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default")
|
||||
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -614,16 +610,16 @@ func (auth *AuthService) IsBypassedIP(ip string, acls *model.App) bool {
|
||||
for _, bypassed := range acls.IP.Bypass {
|
||||
res, err := utils.FilterIP(bypassed, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||
tlog.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
|
||||
tlog.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
|
||||
tlog.App.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -727,32 +723,21 @@ func (auth *AuthService) EndOAuthSession(sessionId string) {
|
||||
}
|
||||
|
||||
func (auth *AuthService) CleanupOAuthSessionsRoutine() {
|
||||
auth.log.App.Debug().Msg("Starting OAuth session cleanup routine")
|
||||
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
auth.log.App.Debug().Msg("Running OAuth session cleanup")
|
||||
for range ticker.C {
|
||||
auth.oauthMutex.Lock()
|
||||
|
||||
auth.oauthMutex.Lock()
|
||||
now := time.Now()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for sessionId, session := range auth.oauthPendingSessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
}
|
||||
for sessionId, session := range auth.oauthPendingSessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
}
|
||||
|
||||
auth.oauthMutex.Unlock()
|
||||
auth.log.App.Debug().Msg("OAuth session cleanup completed")
|
||||
case <-auth.context.Done():
|
||||
auth.log.App.Debug().Msg("Stopping OAuth session cleanup routine")
|
||||
return
|
||||
}
|
||||
|
||||
auth.oauthMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,11 +806,11 @@ func (auth *AuthService) lockdownMode() {
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
|
||||
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
|
||||
tlog.App.Warn().Msg("Multiple login attempts detected, possibly DDOS attack. Activating temporary lockdown.")
|
||||
|
||||
auth.lockdown = &Lockdown{
|
||||
Active: true,
|
||||
ActiveUntil: time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second),
|
||||
ActiveUntil: time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second),
|
||||
}
|
||||
|
||||
// At this point all login attemps will also expire so,
|
||||
@@ -842,14 +827,11 @@ func (auth *AuthService) lockdownMode() {
|
||||
// Timer expired, end lockdown
|
||||
case <-ctx.Done():
|
||||
// Context cancelled, end lockdown
|
||||
case <-auth.context.Done():
|
||||
// Service is shutting down, end lockdown
|
||||
}
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
|
||||
auth.log.App.Info().Msg("Exiting lockdown mode")
|
||||
|
||||
tlog.App.Info().Msg("Lockdown period ended, resuming normal operation")
|
||||
auth.lockdown = nil
|
||||
auth.loginMutex.Unlock()
|
||||
}
|
||||
@@ -863,3 +845,10 @@ func (auth *AuthService) ClearRateLimitsTestingOnly() {
|
||||
}
|
||||
auth.loginMutex.Unlock()
|
||||
}
|
||||
|
||||
func (auth *AuthService) getCookieDomain() string {
|
||||
if auth.config.SubdomainsEnabled {
|
||||
return "." + auth.config.CookieDomain
|
||||
}
|
||||
return auth.config.CookieDomain
|
||||
}
|
||||
|
||||
@@ -3,35 +3,23 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
container "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
type DockerService struct {
|
||||
log *logger.Logger
|
||||
client *client.Client
|
||||
context context.Context
|
||||
wg *sync.WaitGroup
|
||||
|
||||
client *client.Client
|
||||
context context.Context
|
||||
isConnected bool
|
||||
}
|
||||
|
||||
func NewDockerService(
|
||||
log *logger.Logger,
|
||||
context context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) *DockerService {
|
||||
return &DockerService{
|
||||
log: log,
|
||||
context: context,
|
||||
wg: wg,
|
||||
}
|
||||
func NewDockerService() *DockerService {
|
||||
return &DockerService{}
|
||||
}
|
||||
|
||||
func (docker *DockerService) Init() error {
|
||||
@@ -40,14 +28,16 @@ func (docker *DockerService) Init() error {
|
||||
return err
|
||||
}
|
||||
|
||||
client.NegotiateAPIVersion(docker.context)
|
||||
ctx := context.Background()
|
||||
client.NegotiateAPIVersion(ctx)
|
||||
|
||||
docker.client = client
|
||||
docker.context = ctx
|
||||
|
||||
_, err = docker.client.Ping(docker.context)
|
||||
|
||||
if err != nil {
|
||||
docker.log.App.Debug().Err(err).Msg("Docker not connected")
|
||||
tlog.App.Debug().Err(err).Msg("Docker not connected")
|
||||
docker.isConnected = false
|
||||
docker.client = nil
|
||||
docker.context = nil
|
||||
@@ -55,9 +45,7 @@ func (docker *DockerService) Init() error {
|
||||
}
|
||||
|
||||
docker.isConnected = true
|
||||
docker.log.App.Debug().Msg("Docker connected successfully")
|
||||
|
||||
docker.wg.Go(docker.watchAndClose)
|
||||
tlog.App.Debug().Msg("Docker connected")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -72,7 +60,7 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
|
||||
|
||||
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
|
||||
if !docker.isConnected {
|
||||
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
|
||||
tlog.App.Debug().Msg("Docker not connected, returning empty labels")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -94,28 +82,17 @@ func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
|
||||
|
||||
for appName, appLabels := range labels.Apps {
|
||||
if appLabels.Config.Domain == appDomain {
|
||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
|
||||
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
|
||||
return &appLabels, nil
|
||||
}
|
||||
|
||||
if strings.SplitN(appDomain, ".", 2)[0] == appName {
|
||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
|
||||
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
|
||||
return &appLabels, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain")
|
||||
tlog.App.Debug().Msg("No matching container found, returning empty labels")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) watchAndClose() {
|
||||
<-docker.context.Done()
|
||||
docker.log.App.Debug().Msg("Closing Docker client")
|
||||
if docker.client != nil {
|
||||
err := docker.client.Close()
|
||||
if err != nil {
|
||||
docker.log.App.Error().Err(err).Msg("Error closing Docker client")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -36,11 +36,9 @@ type ingressApp struct {
|
||||
}
|
||||
|
||||
type KubernetesService struct {
|
||||
log *logger.Logger
|
||||
ctx context.Context
|
||||
wg *sync.WaitGroup
|
||||
|
||||
client dynamic.Interface
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
started bool
|
||||
mu sync.RWMutex
|
||||
ingressApps map[ingressKey][]ingressApp
|
||||
@@ -48,15 +46,8 @@ type KubernetesService struct {
|
||||
appNameIndex map[string]ingressAppKey
|
||||
}
|
||||
|
||||
func NewKubernetesService(
|
||||
log *logger.Logger,
|
||||
context context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) *KubernetesService {
|
||||
func NewKubernetesService() *KubernetesService {
|
||||
return &KubernetesService{
|
||||
log: log,
|
||||
ctx: context,
|
||||
wg: wg,
|
||||
ingressApps: make(map[ingressKey][]ingressApp),
|
||||
domainIndex: make(map[string]ingressAppKey),
|
||||
appNameIndex: make(map[string]ingressAppKey),
|
||||
@@ -142,7 +133,7 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
||||
}
|
||||
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
|
||||
tlog.App.Debug().Err(err).Msg("Failed to decode labels from annotations")
|
||||
k.removeIngress(namespace, name)
|
||||
return
|
||||
}
|
||||
@@ -170,13 +161,13 @@ func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
|
||||
|
||||
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list resources for resync")
|
||||
tlog.App.Debug().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list ingresses during resync")
|
||||
return err
|
||||
}
|
||||
for i := range list.Items {
|
||||
k.updateFromItem(&list.Items[i])
|
||||
}
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete")
|
||||
tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resynced ingress cache")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -190,14 +181,14 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
|
||||
return false
|
||||
case event, ok := <-w.ResultChan():
|
||||
if !ok {
|
||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting watcher")
|
||||
tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting in 5 seconds")
|
||||
w.Stop()
|
||||
time.Sleep(5 * time.Second)
|
||||
return true
|
||||
}
|
||||
item, ok := event.Object.(*unstructured.Unstructured)
|
||||
if !ok {
|
||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Received unexpected event object, skipping")
|
||||
tlog.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Failed to cast watched object")
|
||||
continue
|
||||
}
|
||||
switch event.Type {
|
||||
@@ -208,7 +199,7 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
|
||||
}
|
||||
case <-resyncTicker.C:
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run")
|
||||
tlog.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,29 +210,29 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
|
||||
defer resyncTicker.Stop()
|
||||
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry")
|
||||
tlog.App.Error().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, retrying in 30 seconds")
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-k.ctx.Done():
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Context cancelled, stopping watcher")
|
||||
tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Stopping watcher")
|
||||
return
|
||||
case <-resyncTicker.C:
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry")
|
||||
tlog.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed")
|
||||
}
|
||||
default:
|
||||
ctx, cancel := context.WithCancel(k.ctx)
|
||||
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry")
|
||||
tlog.App.Error().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher")
|
||||
cancel()
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully")
|
||||
tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started")
|
||||
if !k.runWatcher(gvr, watcher, resyncTicker) {
|
||||
cancel()
|
||||
return
|
||||
@@ -266,6 +257,8 @@ func (k *KubernetesService) Init() error {
|
||||
}
|
||||
|
||||
k.client = client
|
||||
k.ctx, k.cancel = context.WithCancel(context.Background())
|
||||
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: "networking.k8s.io",
|
||||
Version: "v1",
|
||||
@@ -274,43 +267,40 @@ func (k *KubernetesService) Init() error {
|
||||
|
||||
accessCtx, accessCancel := context.WithTimeout(k.ctx, 5*time.Second)
|
||||
defer accessCancel()
|
||||
|
||||
_, err = k.client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access Ingress API, Kubernetes label provider will be disabled")
|
||||
tlog.App.Warn().Err(err).Msg("Insufficient permissions for networking.k8s.io/v1 Ingress, Kubernetes label provider will not work")
|
||||
k.started = false
|
||||
return nil
|
||||
}
|
||||
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
|
||||
k.wg.Go(func() {
|
||||
k.watchGVR(gvr)
|
||||
})
|
||||
tlog.App.Debug().Msg("networking.k8s.io/v1 Ingress API accessible")
|
||||
go k.watchGVR(gvr)
|
||||
|
||||
k.started = true
|
||||
k.log.App.Debug().Msg("Kubernetes label provider started successfully")
|
||||
tlog.App.Info().Msg("Kubernetes label provider initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
|
||||
if !k.started {
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping")
|
||||
tlog.App.Debug().Msg("Kubernetes not connected, returning empty labels")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// First check cache
|
||||
app := k.getByDomain(appDomain)
|
||||
if app != nil {
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
|
||||
tlog.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
|
||||
return app, nil
|
||||
}
|
||||
appName := strings.SplitN(appDomain, ".", 2)[0]
|
||||
app = k.getByAppName(appName)
|
||||
if app != nil {
|
||||
k.log.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
|
||||
tlog.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
|
||||
return app, nil
|
||||
}
|
||||
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("No labels found for domain")
|
||||
tlog.App.Debug().Str("domain", appDomain).Msg("Cache miss, no matching ingress found")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -9,33 +9,31 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v5"
|
||||
ldapgo "github.com/go-ldap/ldap/v3"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
type LdapService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
context context.Context
|
||||
wg *sync.WaitGroup
|
||||
type LdapServiceConfig struct {
|
||||
Address string
|
||||
BindDN string
|
||||
BindPassword string
|
||||
BaseDN string
|
||||
Insecure bool
|
||||
SearchFilter string
|
||||
AuthCert string
|
||||
AuthKey string
|
||||
}
|
||||
|
||||
type LdapService struct {
|
||||
config LdapServiceConfig
|
||||
conn *ldapgo.Conn
|
||||
mutex sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
isConfigured bool
|
||||
}
|
||||
|
||||
func NewLdapService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
context context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) *LdapService {
|
||||
func NewLdapService(config LdapServiceConfig) *LdapService {
|
||||
return &LdapService{
|
||||
log: log,
|
||||
config: config,
|
||||
context: context,
|
||||
wg: wg,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +57,7 @@ func (ldap *LdapService) Unconfigure() error {
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Init() error {
|
||||
if ldap.config.LDAP.Address == "" {
|
||||
if ldap.config.Address == "" {
|
||||
ldap.isConfigured = false
|
||||
return nil
|
||||
}
|
||||
@@ -67,13 +65,13 @@ func (ldap *LdapService) Init() error {
|
||||
ldap.isConfigured = true
|
||||
|
||||
// Check whether authentication with client certificate is possible
|
||||
if ldap.config.LDAP.AuthCert != "" && ldap.config.LDAP.AuthKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(ldap.config.LDAP.AuthCert, ldap.config.LDAP.AuthKey)
|
||||
if ldap.config.AuthCert != "" && ldap.config.AuthKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(ldap.config.AuthCert, ldap.config.AuthKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err)
|
||||
}
|
||||
ldap.cert = &cert
|
||||
ldap.log.App.Info().Msg("LDAP mTLS authentication configured successfully")
|
||||
tlog.App.Info().Msg("Using LDAP with mTLS authentication")
|
||||
|
||||
// TODO: Add optional extra CA certificates, instead of `InsecureSkipVerify`
|
||||
/*
|
||||
@@ -91,30 +89,19 @@ func (ldap *LdapService) Init() error {
|
||||
return fmt.Errorf("failed to connect to LDAP server: %w", err)
|
||||
}
|
||||
|
||||
ldap.wg.Go(func() {
|
||||
ldap.log.App.Debug().Msg("Starting LDAP connection heartbeat routine")
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
err := ldap.heartbeat()
|
||||
if err != nil {
|
||||
ldap.log.App.Warn().Err(err).Msg("LDAP connection heartbeat failed, attempting to reconnect")
|
||||
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
|
||||
ldap.log.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
|
||||
continue
|
||||
}
|
||||
ldap.log.App.Info().Msg("Successfully reconnected to LDAP server")
|
||||
go func() {
|
||||
for range time.Tick(time.Duration(5) * time.Minute) {
|
||||
err := ldap.heartbeat()
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("LDAP connection heartbeat failed")
|
||||
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
|
||||
tlog.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
|
||||
continue
|
||||
}
|
||||
case <-ldap.context.Done():
|
||||
ldap.log.App.Debug().Msg("LDAP service context cancelled, stopping heartbeat")
|
||||
return
|
||||
tlog.App.Info().Msg("Successfully reconnected to LDAP server")
|
||||
}
|
||||
}
|
||||
})
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -133,13 +120,13 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
||||
// 2. conn.StartTLS(tlsConfig)
|
||||
// 3. conn.externalBind()
|
||||
if ldap.cert != nil {
|
||||
conn, err = ldapgo.DialURL(ldap.config.LDAP.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{*ldap.cert},
|
||||
}))
|
||||
} else {
|
||||
conn, err = ldapgo.DialURL(ldap.config.LDAP.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: ldap.config.LDAP.Insecure,
|
||||
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: ldap.config.Insecure,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}))
|
||||
}
|
||||
@@ -159,10 +146,10 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
||||
func (ldap *LdapService) GetUserDN(username string) (string, error) {
|
||||
// Escape the username to prevent LDAP injection
|
||||
escapedUsername := ldapgo.EscapeFilter(username)
|
||||
filter := fmt.Sprintf(ldap.config.LDAP.SearchFilter, escapedUsername)
|
||||
filter := fmt.Sprintf(ldap.config.SearchFilter, escapedUsername)
|
||||
|
||||
searchRequest := ldapgo.NewSearchRequest(
|
||||
ldap.config.LDAP.BaseDN,
|
||||
ldap.config.BaseDN,
|
||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
@@ -189,7 +176,7 @@ func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {
|
||||
escapedUserDN := ldapgo.EscapeFilter(userDN)
|
||||
|
||||
searchRequest := ldapgo.NewSearchRequest(
|
||||
ldap.config.LDAP.BaseDN,
|
||||
ldap.config.BaseDN,
|
||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||
fmt.Sprintf("(&(objectclass=groupOfUniqueNames)(uniquemember=%s))", escapedUserDN),
|
||||
[]string{"dn"},
|
||||
@@ -237,7 +224,7 @@ func (ldap *LdapService) BindService(rebind bool) error {
|
||||
if ldap.cert != nil {
|
||||
return ldap.conn.ExternalBind()
|
||||
}
|
||||
return ldap.conn.Bind(ldap.config.LDAP.BindDN, ldap.config.LDAP.BindPassword)
|
||||
return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Bind(userDN string, password string) error {
|
||||
@@ -251,7 +238,7 @@ func (ldap *LdapService) Bind(userDN string, password string) error {
|
||||
}
|
||||
|
||||
func (ldap *LdapService) heartbeat() error {
|
||||
ldap.log.App.Debug().Msg("Performing LDAP connection heartbeat")
|
||||
tlog.App.Debug().Msg("Performing LDAP connection heartbeat")
|
||||
|
||||
searchRequest := ldapgo.NewSearchRequest(
|
||||
"",
|
||||
@@ -273,7 +260,7 @@ func (ldap *LdapService) heartbeat() error {
|
||||
}
|
||||
|
||||
func (ldap *LdapService) reconnect() error {
|
||||
ldap.log.App.Info().Msg("Attempting to reconnect to LDAP server")
|
||||
tlog.App.Info().Msg("Reconnecting to LDAP server")
|
||||
|
||||
exp := backoff.NewExponentialBackOff()
|
||||
exp.InitialInterval = 500 * time.Millisecond
|
||||
|
||||
@@ -2,7 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"slices"
|
||||
|
||||
@@ -19,8 +19,6 @@ type OAuthServiceImpl interface {
|
||||
}
|
||||
|
||||
type OAuthBrokerService struct {
|
||||
log *logger.Logger
|
||||
|
||||
services map[string]OAuthServiceImpl
|
||||
configs map[string]model.OAuthServiceConfig
|
||||
}
|
||||
@@ -30,12 +28,8 @@ var presets = map[string]func(config model.OAuthServiceConfig) *OAuthService{
|
||||
"google": newGoogleOAuthService,
|
||||
}
|
||||
|
||||
func NewOAuthBrokerService(
|
||||
log *logger.Logger,
|
||||
configs map[string]model.OAuthServiceConfig,
|
||||
) *OAuthBrokerService {
|
||||
func NewOAuthBrokerService(configs map[string]model.OAuthServiceConfig) *OAuthBrokerService {
|
||||
return &OAuthBrokerService{
|
||||
log: log,
|
||||
services: make(map[string]OAuthServiceImpl),
|
||||
configs: configs,
|
||||
}
|
||||
@@ -45,10 +39,10 @@ func (broker *OAuthBrokerService) Init() error {
|
||||
for name, cfg := range broker.configs {
|
||||
if presetFunc, exists := presets[name]; exists {
|
||||
broker.services[name] = presetFunc(cfg)
|
||||
broker.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
|
||||
tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
|
||||
} else {
|
||||
broker.services[name] = NewOAuthService(cfg, name)
|
||||
broker.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from custom config")
|
||||
tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from config")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"slices"
|
||||
@@ -26,7 +25,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -112,14 +111,17 @@ type AuthorizeRequest struct {
|
||||
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||
}
|
||||
|
||||
type OIDCService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
queries *repository.Queries
|
||||
context context.Context
|
||||
wg *sync.WaitGroup
|
||||
type OIDCServiceConfig struct {
|
||||
Clients map[string]model.OIDCClientConfig
|
||||
PrivateKeyPath string
|
||||
PublicKeyPath string
|
||||
Issuer string
|
||||
SessionExpiry int
|
||||
}
|
||||
|
||||
type OIDCService struct {
|
||||
config OIDCServiceConfig
|
||||
queries *repository.Queries
|
||||
clients map[string]model.OIDCClientConfig
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey crypto.PublicKey
|
||||
@@ -127,20 +129,10 @@ type OIDCService struct {
|
||||
isConfigured bool
|
||||
}
|
||||
|
||||
func NewOIDCService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtime model.RuntimeConfig,
|
||||
queries *repository.Queries,
|
||||
context context.Context,
|
||||
wg *sync.WaitGroup) *OIDCService {
|
||||
func NewOIDCService(config OIDCServiceConfig, queries *repository.Queries) *OIDCService {
|
||||
return &OIDCService{
|
||||
log: log,
|
||||
config: config,
|
||||
runtime: runtime,
|
||||
queries: queries,
|
||||
context: context,
|
||||
wg: wg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +142,7 @@ func (service *OIDCService) IsConfigured() bool {
|
||||
|
||||
func (service *OIDCService) Init() error {
|
||||
// If not configured, skip init
|
||||
if len(service.runtime.OIDCClients) == 0 {
|
||||
if len(service.config.Clients) == 0 {
|
||||
service.isConfigured = false
|
||||
return nil
|
||||
}
|
||||
@@ -158,7 +150,7 @@ func (service *OIDCService) Init() error {
|
||||
service.isConfigured = true
|
||||
|
||||
// Ensure issuer is https
|
||||
uissuer, err := url.Parse(service.runtime.AppURL)
|
||||
uissuer, err := url.Parse(service.config.Issuer)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -171,14 +163,14 @@ func (service *OIDCService) Init() error {
|
||||
service.issuer = fmt.Sprintf("%s://%s", uissuer.Scheme, uissuer.Host)
|
||||
|
||||
// Create/load private and public keys
|
||||
if strings.TrimSpace(service.config.OIDC.PrivateKeyPath) == "" ||
|
||||
strings.TrimSpace(service.config.OIDC.PublicKeyPath) == "" {
|
||||
if strings.TrimSpace(service.config.PrivateKeyPath) == "" ||
|
||||
strings.TrimSpace(service.config.PublicKeyPath) == "" {
|
||||
return errors.New("private key path and public key path are required")
|
||||
}
|
||||
|
||||
var privateKey *rsa.PrivateKey
|
||||
|
||||
fprivateKey, err := os.ReadFile(service.config.OIDC.PrivateKeyPath)
|
||||
fprivateKey, err := os.ReadFile(service.config.PrivateKeyPath)
|
||||
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
@@ -197,8 +189,8 @@ func (service *OIDCService) Init() error {
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: der,
|
||||
})
|
||||
service.log.App.Trace().Str("type", "RSA PRIVATE KEY").Msg("Generated private RSA key")
|
||||
err = os.WriteFile(service.config.OIDC.PrivateKeyPath, encoded, 0600)
|
||||
tlog.App.Trace().Str("type", "RSA PRIVATE KEY").Msg("Generated private RSA key")
|
||||
err = os.WriteFile(service.config.PrivateKeyPath, encoded, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,7 +200,7 @@ func (service *OIDCService) Init() error {
|
||||
if block == nil {
|
||||
return errors.New("failed to decode private key")
|
||||
}
|
||||
service.log.App.Trace().Str("type", block.Type).Msg("Loaded private key")
|
||||
tlog.App.Trace().Str("type", block.Type).Msg("Loaded private key")
|
||||
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -216,7 +208,7 @@ func (service *OIDCService) Init() error {
|
||||
service.privateKey = privateKey
|
||||
}
|
||||
|
||||
fpublicKey, err := os.ReadFile(service.config.OIDC.PublicKeyPath)
|
||||
fpublicKey, err := os.ReadFile(service.config.PublicKeyPath)
|
||||
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
@@ -232,8 +224,8 @@ func (service *OIDCService) Init() error {
|
||||
Type: "RSA PUBLIC KEY",
|
||||
Bytes: der,
|
||||
})
|
||||
service.log.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key")
|
||||
err = os.WriteFile(service.config.OIDC.PublicKeyPath, encoded, 0644)
|
||||
tlog.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key")
|
||||
err = os.WriteFile(service.config.PublicKeyPath, encoded, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -243,7 +235,7 @@ func (service *OIDCService) Init() error {
|
||||
if block == nil {
|
||||
return errors.New("failed to decode public key")
|
||||
}
|
||||
service.log.App.Trace().Str("type", block.Type).Msg("Loaded public key")
|
||||
tlog.App.Trace().Str("type", block.Type).Msg("Loaded public key")
|
||||
switch block.Type {
|
||||
case "RSA PUBLIC KEY":
|
||||
publicKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
@@ -265,7 +257,7 @@ func (service *OIDCService) Init() error {
|
||||
// We will reorganize the client into a map with the client ID as the key
|
||||
service.clients = make(map[string]model.OIDCClientConfig)
|
||||
|
||||
for id, client := range service.config.OIDC.Clients {
|
||||
for id, client := range service.config.Clients {
|
||||
client.ID = id
|
||||
if client.Name == "" {
|
||||
client.Name = utils.Capitalize(client.ID)
|
||||
@@ -281,12 +273,9 @@ func (service *OIDCService) Init() error {
|
||||
}
|
||||
client.ClientSecretFile = ""
|
||||
service.clients[id] = client
|
||||
service.log.App.Debug().Str("clientId", client.ClientID).Msg("Loaded OIDC client configuration")
|
||||
tlog.App.Info().Str("id", client.ID).Msg("Registered OIDC client")
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
service.wg.Go(service.cleanupRoutine)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -318,7 +307,7 @@ func (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error
|
||||
return errors.New("invalid_scope")
|
||||
}
|
||||
if !slices.Contains(SupportedScopes, scope) {
|
||||
service.log.App.Warn().Str("scope", scope).Msg("Requested unsupported scope")
|
||||
tlog.App.Warn().Str("scope", scope).Msg("Unsupported OIDC scope, will be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +357,7 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
||||
entry.CodeChallenge = req.CodeChallenge
|
||||
} else {
|
||||
entry.CodeChallenge = service.hashAndEncodePKCE(req.CodeChallenge)
|
||||
service.log.App.Warn().Msg("Using plain PKCE code challenge method is not recommended, consider switching to S256 for better security")
|
||||
tlog.App.Warn().Msg("Received plain PKCE code challenge, it's recommended to use S256 for better security")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +449,7 @@ func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, client
|
||||
|
||||
func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {
|
||||
createdAt := time.Now().Unix()
|
||||
expiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
||||
expiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||
|
||||
hasher := sha256.New()
|
||||
|
||||
@@ -540,16 +529,16 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OID
|
||||
accessToken := utils.GenerateString(32)
|
||||
refreshToken := utils.GenerateString(32)
|
||||
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||
|
||||
// Refresh token lives double the time of an access token but can't be used to access userinfo
|
||||
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry*2) * time.Second).Unix()
|
||||
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
|
||||
|
||||
tokenResponse := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(service.config.Auth.SessionExpiry),
|
||||
ExpiresIn: int64(service.config.SessionExpiry),
|
||||
IDToken: idToken,
|
||||
Scope: strings.ReplaceAll(codeEntry.Scope, ",", " "),
|
||||
}
|
||||
@@ -609,14 +598,14 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
||||
accessToken := utils.GenerateString(32)
|
||||
newRefreshToken := utils.GenerateString(32)
|
||||
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
||||
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry*2) * time.Second).Unix()
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
|
||||
|
||||
tokenResponse := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(service.config.Auth.SessionExpiry),
|
||||
ExpiresIn: int64(service.config.SessionExpiry),
|
||||
IDToken: idToken,
|
||||
Scope: strings.ReplaceAll(entry.Scope, ",", " "),
|
||||
}
|
||||
@@ -759,64 +748,56 @@ func (service *OIDCService) DeleteOldSession(ctx context.Context, sub string) er
|
||||
}
|
||||
|
||||
// Cleanup routine - Resource heavy due to the linked tables
|
||||
func (service *OIDCService) cleanupRoutine() {
|
||||
service.log.App.Debug().Msg("Starting OIDC cleanup routine")
|
||||
func (service *OIDCService) Cleanup() {
|
||||
// We need a context for the routine
|
||||
ctx := context.Background()
|
||||
|
||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.log.App.Debug().Msg("Performing OIDC cleanup routine")
|
||||
for range ticker.C {
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
// For the OIDC tokens, if they are expired we delete the userinfo and codes
|
||||
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{
|
||||
TokenExpiresAt: currentTime,
|
||||
RefreshTokenExpiresAt: currentTime,
|
||||
})
|
||||
|
||||
// For the OIDC tokens, if they are expired we delete the userinfo and codes
|
||||
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(service.context, repository.DeleteExpiredOidcTokensParams{
|
||||
TokenExpiresAt: currentTime,
|
||||
RefreshTokenExpiresAt: currentTime,
|
||||
})
|
||||
if err != nil {
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete expired tokens")
|
||||
}
|
||||
|
||||
for _, expiredToken := range expiredTokens {
|
||||
err := service.DeleteOldSession(ctx, expiredToken.Sub)
|
||||
if err != nil {
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete old session")
|
||||
}
|
||||
}
|
||||
|
||||
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
|
||||
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(ctx, currentTime)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
||||
}
|
||||
|
||||
for _, expiredCode := range expiredCodes {
|
||||
token, err := service.queries.GetOidcTokenBySub(ctx, expiredCode.Sub)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired tokens")
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get OIDC token by sub")
|
||||
}
|
||||
|
||||
for _, expiredToken := range expiredTokens {
|
||||
err := service.DeleteOldSession(service.context, expiredToken.Sub)
|
||||
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
|
||||
err := service.DeleteOldSession(ctx, expiredCode.Sub)
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired token")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete session")
|
||||
}
|
||||
}
|
||||
|
||||
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
|
||||
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(service.context, currentTime)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
||||
}
|
||||
|
||||
for _, expiredCode := range expiredCodes {
|
||||
token, err := service.queries.GetOidcTokenBySub(service.context, expiredCode.Sub)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
service.log.App.Warn().Err(err).Msg("Failed to get token by sub for expired code")
|
||||
}
|
||||
|
||||
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
|
||||
err := service.DeleteOldSession(service.context, expiredCode.Sub)
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired code")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.log.App.Debug().Msg("Finished OIDC cleanup routine")
|
||||
case <-service.context.Done():
|
||||
service.log.App.Debug().Msg("Stopping OIDC cleanup routine")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,7 @@ func GetCookieDomain(u string) (string, error) {
|
||||
parts := strings.Split(host, ".")
|
||||
|
||||
if len(parts) == 2 {
|
||||
tlog.App.Warn().Msgf("Running on the root domain, cookies will be set for .%v", host)
|
||||
return host, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
HTTP zerolog.Logger
|
||||
App zerolog.Logger
|
||||
config model.LogConfig
|
||||
base zerolog.Logger
|
||||
audit zerolog.Logger
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
func NewLogger() *Logger {
|
||||
return &Logger{
|
||||
writer: os.Stderr,
|
||||
config: model.LogConfig{
|
||||
Level: "error",
|
||||
Json: true,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
App: model.LogStreamConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
// No reason to enabled audit by default since it will be surpressed by the log level
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) WithConfig(cfg model.LogConfig) *Logger {
|
||||
l.config = cfg
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Logger) WithSimpleConfig() *Logger {
|
||||
l.config = model.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Logger) WithTestConfig() *Logger {
|
||||
l.config = model.LogConfig{
|
||||
Level: "trace",
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Logger) WithWriter(writer io.Writer) *Logger {
|
||||
l.writer = writer
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Logger) Init() {
|
||||
base := log.With().
|
||||
Timestamp().
|
||||
Logger().
|
||||
Level(l.parseLogLevel(l.config.Level)).Output(l.writer)
|
||||
|
||||
if !l.config.Json {
|
||||
base = base.Output(zerolog.ConsoleWriter{
|
||||
Out: l.writer,
|
||||
TimeFormat: time.RFC3339,
|
||||
})
|
||||
}
|
||||
|
||||
if base.GetLevel() == zerolog.TraceLevel || base.GetLevel() == zerolog.DebugLevel {
|
||||
base = base.With().Caller().Logger()
|
||||
}
|
||||
|
||||
l.base = base
|
||||
l.audit = l.createLogger("audit", l.config.Streams.Audit)
|
||||
l.HTTP = l.createLogger("http", l.config.Streams.HTTP)
|
||||
l.App = l.createLogger("app", l.config.Streams.App)
|
||||
}
|
||||
|
||||
func (l *Logger) parseLogLevel(level string) zerolog.Level {
|
||||
if level == "" {
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
parsed, err := zerolog.ParseLevel(strings.ToLower(level))
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("level", level).Msg("Invalid log level, defaulting to error")
|
||||
parsed = zerolog.ErrorLevel
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (l *Logger) createLogger(component string, cfg model.LogStreamConfig) zerolog.Logger {
|
||||
if !cfg.Enabled {
|
||||
return zerolog.Nop()
|
||||
}
|
||||
sub := l.base.With().Str("stream", component).Logger()
|
||||
if cfg.Level != "" {
|
||||
sub = sub.Level(l.parseLogLevel(cfg.Level))
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func (l *Logger) AuditLoginSuccess(username, provider, ip string) {
|
||||
l.audit.Info().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "login").
|
||||
Str("result", "success").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", ip).
|
||||
Send()
|
||||
}
|
||||
|
||||
func (l *Logger) AuditLoginFailure(username, provider, ip, reason string) {
|
||||
l.audit.Warn().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "login").
|
||||
Str("result", "failure").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", ip).
|
||||
Str("reason", reason).
|
||||
Send()
|
||||
}
|
||||
|
||||
func (l *Logger) AuditLogout(username, provider, ip string) {
|
||||
l.audit.Info().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "logout").
|
||||
Str("result", "success").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", ip).
|
||||
Send()
|
||||
}
|
||||
|
||||
// Used for testing
|
||||
func (l *Logger) GetConfig() model.LogConfig {
|
||||
return l.config
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestLogger(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
run func(t *testing.T)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Should create a simple logger with the expected config",
|
||||
run: func(t *testing.T) {
|
||||
l := logger.NewLogger().WithSimpleConfig()
|
||||
l.Init()
|
||||
|
||||
cfg := l.GetConfig()
|
||||
|
||||
assert.Equal(t, cfg, model.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should create a test logger with the expected config",
|
||||
run: func(t *testing.T) {
|
||||
l := logger.NewLogger().WithTestConfig()
|
||||
l.Init()
|
||||
|
||||
cfg := l.GetConfig()
|
||||
|
||||
assert.Equal(t, cfg, model.LogConfig{
|
||||
Level: "trace",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: true},
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should create a logger with a custom config",
|
||||
run: func(t *testing.T) {
|
||||
customCfg := model.LogConfig{
|
||||
Level: "debug",
|
||||
Json: true,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: false},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
l := logger.NewLogger().WithConfig(customCfg)
|
||||
l.Init()
|
||||
|
||||
cfg := l.GetConfig()
|
||||
|
||||
assert.Equal(t, cfg, customCfg)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Default logger should use error type and log json",
|
||||
run: func(t *testing.T) {
|
||||
buf := bytes.Buffer{}
|
||||
|
||||
l := logger.NewLogger().WithWriter(&buf)
|
||||
l.Init()
|
||||
|
||||
cfg := l.GetConfig()
|
||||
|
||||
assert.Equal(t, cfg, model.LogConfig{
|
||||
Level: "error",
|
||||
Json: true,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
})
|
||||
|
||||
l.App.Error().Msg("test")
|
||||
|
||||
var entry map[string]any
|
||||
err := json.Unmarshal(buf.Bytes(), &entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "test", entry["message"])
|
||||
assert.Equal(t, "app", entry["stream"])
|
||||
assert.Equal(t, "error", entry["level"])
|
||||
assert.NotEmpty(t, entry["time"])
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should default to error level if an invalid level is provided",
|
||||
run: func(t *testing.T) {
|
||||
buf := bytes.Buffer{}
|
||||
|
||||
customCfg := model.LogConfig{
|
||||
Level: "invalid",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
l := logger.NewLogger().WithConfig(customCfg).WithWriter(&buf)
|
||||
l.Init()
|
||||
|
||||
assert.Equal(t, zerolog.ErrorLevel, l.App.GetLevel())
|
||||
assert.Equal(t, zerolog.ErrorLevel, l.HTTP.GetLevel())
|
||||
|
||||
// should not get logged
|
||||
l.AuditLoginFailure("test", "test", "test", "test")
|
||||
|
||||
assert.Empty(t, buf.String())
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should use nop logger for disabled streams",
|
||||
run: func(t *testing.T) {
|
||||
buf := bytes.Buffer{}
|
||||
|
||||
customCfg := model.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: false},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
l := logger.NewLogger().WithConfig(customCfg).WithWriter(&buf)
|
||||
l.Init()
|
||||
|
||||
assert.Equal(t, zerolog.Disabled, l.HTTP.GetLevel())
|
||||
|
||||
l.App.Info().Msg("test")
|
||||
|
||||
l.AuditLoginFailure("test", "test", "test", "test")
|
||||
|
||||
assert.NotEmpty(t, buf.String())
|
||||
assert.Equal(t, 81, buf.Len()) // it's the length of the test log entry
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, test.run)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package tlog
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// functions here use CallerSkipFrame to ensure correct caller info is logged
|
||||
|
||||
func AuditLoginSuccess(c *gin.Context, username, provider string) {
|
||||
Audit.Info().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "login").
|
||||
Str("result", "success").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", c.ClientIP()).
|
||||
Send()
|
||||
}
|
||||
|
||||
func AuditLoginFailure(c *gin.Context, username, provider string, reason string) {
|
||||
Audit.Warn().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "login").
|
||||
Str("result", "failure").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", c.ClientIP()).
|
||||
Str("reason", reason).
|
||||
Send()
|
||||
}
|
||||
|
||||
func AuditLogout(c *gin.Context, username, provider string) {
|
||||
Audit.Info().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "logout").
|
||||
Str("result", "success").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", c.ClientIP()).
|
||||
Send()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package tlog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
Audit zerolog.Logger
|
||||
HTTP zerolog.Logger
|
||||
App zerolog.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
Audit zerolog.Logger
|
||||
HTTP zerolog.Logger
|
||||
App zerolog.Logger
|
||||
)
|
||||
|
||||
func NewLogger(cfg model.LogConfig) *Logger {
|
||||
baseLogger := log.With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger().
|
||||
Level(parseLogLevel(cfg.Level))
|
||||
|
||||
if !cfg.Json {
|
||||
baseLogger = baseLogger.Output(zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: time.RFC3339,
|
||||
})
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
Audit: createLogger("audit", cfg.Streams.Audit, baseLogger),
|
||||
HTTP: createLogger("http", cfg.Streams.HTTP, baseLogger),
|
||||
App: createLogger("app", cfg.Streams.App, baseLogger),
|
||||
}
|
||||
}
|
||||
|
||||
func NewSimpleLogger() *Logger {
|
||||
return NewLogger(model.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func NewTestLogger() *Logger {
|
||||
return NewLogger(model.LogConfig{
|
||||
Level: "trace",
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: true},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (l *Logger) Init() {
|
||||
Audit = l.Audit
|
||||
HTTP = l.HTTP
|
||||
App = l.App
|
||||
}
|
||||
|
||||
func createLogger(component string, streamCfg model.LogStreamConfig, baseLogger zerolog.Logger) zerolog.Logger {
|
||||
if !streamCfg.Enabled {
|
||||
return zerolog.Nop()
|
||||
}
|
||||
subLogger := baseLogger.With().Str("log_stream", component).Logger()
|
||||
// override level if specified, otherwise use base level
|
||||
if streamCfg.Level != "" {
|
||||
subLogger = subLogger.Level(parseLogLevel(streamCfg.Level))
|
||||
}
|
||||
return subLogger
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) zerolog.Level {
|
||||
if level == "" {
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
parsedLevel, err := zerolog.ParseLevel(strings.ToLower(level))
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
|
||||
parsedLevel = zerolog.InfoLevel
|
||||
}
|
||||
return parsedLevel
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package tlog_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func TestNewLogger(t *testing.T) {
|
||||
cfg := model.LogConfig{
|
||||
Level: "debug",
|
||||
Json: true,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true, Level: "info"},
|
||||
App: model.LogStreamConfig{Enabled: true, Level: ""},
|
||||
Audit: model.LogStreamConfig{Enabled: false, Level: ""},
|
||||
},
|
||||
}
|
||||
|
||||
logger := tlog.NewLogger(cfg)
|
||||
|
||||
assert.NotNil(t, logger)
|
||||
assert.Equal(t, zerolog.InfoLevel, logger.HTTP.GetLevel())
|
||||
assert.Equal(t, zerolog.DebugLevel, logger.App.GetLevel())
|
||||
assert.Equal(t, zerolog.Disabled, logger.Audit.GetLevel())
|
||||
}
|
||||
|
||||
func TestNewSimpleLogger(t *testing.T) {
|
||||
logger := tlog.NewSimpleLogger()
|
||||
assert.NotNil(t, logger)
|
||||
assert.Equal(t, zerolog.InfoLevel, logger.HTTP.GetLevel())
|
||||
assert.Equal(t, zerolog.InfoLevel, logger.App.GetLevel())
|
||||
assert.Equal(t, zerolog.Disabled, logger.Audit.GetLevel())
|
||||
}
|
||||
|
||||
func TestLoggerInit(t *testing.T) {
|
||||
logger := tlog.NewSimpleLogger()
|
||||
logger.Init()
|
||||
|
||||
assert.NotEqual(t, zerolog.Disabled, tlog.App.GetLevel())
|
||||
}
|
||||
|
||||
func TestLoggerWithDisabledStreams(t *testing.T) {
|
||||
cfg := model.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: false},
|
||||
App: model.LogStreamConfig{Enabled: false},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
logger := tlog.NewLogger(cfg)
|
||||
|
||||
assert.Equal(t, zerolog.Disabled, logger.HTTP.GetLevel())
|
||||
assert.Equal(t, zerolog.Disabled, logger.App.GetLevel())
|
||||
assert.Equal(t, zerolog.Disabled, logger.Audit.GetLevel())
|
||||
}
|
||||
|
||||
func TestLogStreamField(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
cfg := model.LogConfig{
|
||||
Level: "info",
|
||||
Json: true,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
logger := tlog.NewLogger(cfg)
|
||||
|
||||
// Override output for HTTP logger to capture output
|
||||
logger.HTTP = logger.HTTP.Output(&buf)
|
||||
|
||||
logger.HTTP.Info().Msg("test message")
|
||||
|
||||
var logEntry map[string]interface{}
|
||||
err := json.Unmarshal(buf.Bytes(), &logEntry)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "http", logEntry["log_stream"])
|
||||
assert.Equal(t, "test message", logEntry["message"])
|
||||
}
|
||||
Reference in New Issue
Block a user