diff --git a/auth/auth.go b/auth/auth.go index bb777ac..bb7d881 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -11,10 +11,10 @@ import ( type Factor byte const ( - FactorFirst Factor = 1 << iota - FactorSecond // FactorAuthorized defines the "authorized" state of a session - FactorAuthorized + FactorAuthorized Factor = iota + FactorFirst + FactorSecond ) type Provider interface { @@ -32,14 +32,14 @@ type Provider interface { AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error } -// ErrRequiresSecondFactor notifies the ServeHTTP function to ask for another factor -var ErrRequiresSecondFactor = errors.New("requires second factor") - -// ErrRequiresPreviousFactor is a generic error for providers which require a previous factor -var ErrRequiresPreviousFactor = errors.New("requires previous factor") - -// ErrUserDoesNotSupportFactor is a generic error for providers with are unable to support the user -var ErrUserDoesNotSupportFactor = errors.New("user does not support factor") +var ( + // ErrRequiresSecondFactor notifies the ServeHTTP function to ask for another factor + ErrRequiresSecondFactor = errors.New("requires second factor") + // ErrRequiresPreviousFactor is a generic error for providers which require a previous factor + ErrRequiresPreviousFactor = errors.New("requires previous factor") + // ErrUserDoesNotSupportFactor is a generic error for providers with are unable to support the user + ErrUserDoesNotSupportFactor = errors.New("user does not support factor") +) type UserSafeError struct { Display string @@ -71,6 +71,17 @@ func AdminSafeError(inner error) UserSafeError { } } +type RedirectError struct { + Target string + Code int +} + +func (e RedirectError) TargetUrl() string { return e.Target } + +func (e RedirectError) Error() string { + return fmt.Sprintf("redirect to '%s'", e.Target) +} + type lookupUserDB interface { GetUser(ctx context.Context, subject string) (database.User, error) } diff --git a/auth/login.go b/auth/login.go index 6d20092..65baa65 100644 --- a/auth/login.go +++ b/auth/login.go @@ -19,9 +19,7 @@ type BasicLogin struct { DB basicLoginDB } -func (b *BasicLogin) Factor() Factor { - return FactorFirst -} +func (b *BasicLogin) Factor() Factor { return FactorFirst } func (b *BasicLogin) Name() string { return "basic" } diff --git a/auth/oauth.go b/auth/oauth.go index 8832b06..8a6406e 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -1 +1,96 @@ package auth + +import ( + "context" + "fmt" + "github.com/1f349/cache" + "github.com/1f349/lavender/database" + "github.com/1f349/lavender/issuer" + "github.com/google/uuid" + "golang.org/x/oauth2" + "net/http" + "time" +) + +type flowStateData struct { + loginName string + sso *issuer.WellKnownOIDC + redirect string +} + +var _ Provider = (*OAuthLogin)(nil) + +type OAuthLogin struct { + DB *database.Queries + + BaseUrl string + + flow *cache.Cache[string, flowStateData] +} + +func (o OAuthLogin) Init() { + o.flow = cache.New[string, flowStateData]() +} + +func (o OAuthLogin) Factor() Factor { return FactorFirst } + +func (o OAuthLogin) Name() string { return "oauth" } + +func (o OAuthLogin) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error { + //TODO implement me + panic("implement me") +} + +func (o OAuthLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { + login, ok := ctx.Value(oauthServiceLogin(0)).(*issuer.WellKnownOIDC) + if !ok { + return fmt.Errorf("missing issuer wellknown") + } + loginName := ctx.Value("login_full").(string) + loginUn := ctx.Value("login_username").(string) + + // save state for use later + state := login.Config.Namespace + ":" + uuid.NewString() + o.flow.Set(state, flowStateData{loginName, login, req.PostFormValue("redirect")}, time.Now().Add(15*time.Minute)) + + // generate oauth2 config and redirect to authorize URL + oa2conf := login.OAuth2Config + oa2conf.RedirectURL = o.BaseUrl + "/callback" + nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn)) + + return RedirectError{Target: nextUrl, Code: http.StatusFound} +} + +func (o OAuthLogin) OAuthCallback(rw http.ResponseWriter, req *http.Request, info func(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error), cookie func(rw http.ResponseWriter, authData UserAuth, loginName string) bool, redirect func(rw http.ResponseWriter, req *http.Request)) { + flowState, ok := o.flow.Get(req.FormValue("state")) + if !ok { + http.Error(rw, "Invalid flow state", http.StatusBadRequest) + return + } + token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", o.BaseUrl+"/callback")) + if err != nil { + http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError) + return + } + + userAuth, err := info(req, flowState.sso, token) + if err != nil { + http.Error(rw, "Failed to update external user info", http.StatusInternalServerError) + return + } + + if cookie(rw, userAuth, flowState.loginName) { + http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError) + return + } + if flowState.redirect != "" { + req.Form.Set("redirect", flowState.redirect) + } + redirect(rw, req) +} + +type oauthServiceLogin int + +func WithWellKnown(ctx context.Context, login *issuer.WellKnownOIDC) context.Context { + return context.WithValue(ctx, oauthServiceLogin(0), login) +} diff --git a/auth/otp.go b/auth/otp.go index 0f58ea6..27b209a 100644 --- a/auth/otp.go +++ b/auth/otp.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "github.com/1f349/lavender/database" "github.com/xlzd/gotp" "net/http" @@ -13,24 +14,21 @@ func isDigitsSupported(digits int64) bool { } type otpLoginDB interface { - lookupUserDB - CheckLogin(ctx context.Context, un, pw string) (database.CheckLoginResult, error) + GetOtp(ctx context.Context, subject string) (database.GetOtpRow, error) } var _ Provider = (*OtpLogin)(nil) type OtpLogin struct { - db otpLoginDB + DB otpLoginDB } -func (b *OtpLogin) Factor() Factor { - return FactorSecond -} +func (o *OtpLogin) Factor() Factor { return FactorSecond } -func (b *OtpLogin) Name() string { return "basic" } +func (o *OtpLogin) Name() string { return "basic" } -func (b *OtpLogin) RenderData(_ context.Context, _ *http.Request, user *database.User, data map[string]any) error { - if user.Subject == "" { +func (o *OtpLogin) RenderData(_ context.Context, _ *http.Request, user *database.User, data map[string]any) error { + if user == nil || user.Subject == "" { return ErrRequiresPreviousFactor } if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) { @@ -41,7 +39,7 @@ func (b *OtpLogin) RenderData(_ context.Context, _ *http.Request, user *database return nil } -func (b *OtpLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { +func (o *OtpLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { if user == nil || user.Subject == "" { return ErrRequiresPreviousFactor } @@ -51,13 +49,30 @@ func (b *OtpLogin) AttemptLogin(ctx context.Context, req *http.Request, user *da code := req.FormValue("code") - totp := gotp.NewTOTP(user.OtpSecret, int(user.OtpDigits), 30, nil) - if !verifyTotp(totp, code) { + if !validateTotp(user.OtpSecret, int(user.OtpDigits), code) { return BasicUserSafeError(http.StatusBadRequest, "invalid OTP code") } return nil } +var ErrInvalidOtpCode = errors.New("invalid OTP code") + +func (o *OtpLogin) VerifyOtpCode(ctx context.Context, subject, code string) error { + otp, err := o.DB.GetOtp(ctx, subject) + if err != nil { + return err + } + if !validateTotp(otp.OtpSecret, int(otp.OtpDigits), code) { + return ErrInvalidOtpCode + } + return nil +} + +func validateTotp(secret string, digits int, code string) bool { + totp := gotp.NewTOTP(secret, int(digits), 30, nil) + return verifyTotp(totp, code) +} + func verifyTotp(totp *gotp.TOTP, code string) bool { t := time.Now() if totp.VerifyTime(code, t) { diff --git a/auth/passkey.go b/auth/passkey.go new file mode 100644 index 0000000..9b61a8a --- /dev/null +++ b/auth/passkey.go @@ -0,0 +1,48 @@ +package auth + +import ( + "context" + "github.com/1f349/lavender/database" + "net/http" +) + +type passkeyLoginDB interface { + lookupUserDB +} + +var _ Provider = (*PasskeyLogin)(nil) + +type PasskeyLogin struct { + DB passkeyLoginDB +} + +func (p *PasskeyLogin) Factor() Factor { return FactorFirst } + +func (p *PasskeyLogin) Name() string { return "passkey" } + +func (p *PasskeyLogin) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error { + if user == nil || user.Subject == "" { + return ErrRequiresPreviousFactor + } + if user.OtpSecret == "" { + return ErrUserDoesNotSupportFactor + } + + //TODO implement me + panic("implement me") +} + +var passkeyShortcut = true + +func init() { + passkeyShortcut = true +} + +func (p *PasskeyLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { + if user.Subject == "" && !passkeyShortcut { + return ErrRequiresPreviousFactor + } + + //TODO implement me + panic("implement me") +} diff --git a/auth/userauth.go b/auth/userauth.go new file mode 100644 index 0000000..9fdd8d7 --- /dev/null +++ b/auth/userauth.go @@ -0,0 +1,55 @@ +package auth + +import ( + "github.com/julienschmidt/httprouter" + "net/http" + "net/url" + "strings" +) + +type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) + +type UserAuth struct { + Subject string + Factor Factor + UserInfo UserInfoFields +} + +func (u UserAuth) IsGuest() bool { return u.Subject == "" } + +func (u UserAuth) NextFlowUrl(origin *url.URL) *url.URL { + // prevent redirect loops + if origin.Path == "/login" || origin.Path == "/callback" { + return nil + } + if u.Factor < FactorAuthorized { + return PrepareRedirectUrl("/login", origin) + } + return nil +} + +func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL { + // find start of query parameters in target path + n := strings.IndexByte(targetPath, '?') + v := url.Values{} + + // parse existing query parameters + if n != -1 { + q, err := url.ParseQuery(targetPath[n+1:]) + if err != nil { + panic("PrepareRedirectUrl: invalid hardcoded target path query parameters") + } + v = q + targetPath = targetPath[:n] + } + + // add path of origin as a new query parameter + orig := origin.Path + if origin.RawQuery != "" || origin.ForceQuery { + orig += "?" + origin.RawQuery + } + if orig != "" { + v.Set("redirect", orig) + } + return &url.URL{Path: targetPath, RawQuery: v.Encode()} +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..bdef820 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e6cd94f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,47 @@ +# Svelte + TS + Vite + +This template should help get you started developing with Svelte and TypeScript in Vite. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). + +## Need an official Svelte framework? + +Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. + +## Technical considerations + +**Why use this over SvelteKit?** + +- It brings its own routing solution which might not be preferable for some users. +- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. + +This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. + +Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. + +**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** + +Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. + +**Why include `.vscode/extensions.json`?** + +Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. + +**Why enable `allowJs` in the TS template?** + +While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant. + +**Why is HMR not preserving my local component state?** + +HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr). + +If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. + +```ts +// store.ts +// An extremely simple external store +import { writable } from 'svelte/store' +export default writable(0) +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b6c5f0a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + Svelte + TS + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f2886e2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^4.2.19", + "svelte-check": "^4.0.4", + "tslib": "^2.7.0", + "typescript": "^5.5.3", + "vite": "^5.4.8" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..e8b590f --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,47 @@ + + +
+
+ + + + + + +
+

Vite + Svelte

+ +
+ +
+ +

+ Check out SvelteKit, the official Svelte app framework powered by Vite! +

+ +

+ Click on the Vite and Svelte logos to learn more +

+
+ + diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..617f5e9 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,79 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/assets/svelte.svg b/frontend/src/assets/svelte.svg new file mode 100644 index 0000000..c5e0848 --- /dev/null +++ b/frontend/src/assets/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lib/Counter.svelte b/frontend/src/lib/Counter.svelte new file mode 100644 index 0000000..979b4df --- /dev/null +++ b/frontend/src/lib/Counter.svelte @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..4d67e2a --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,8 @@ +import './app.css' +import App from './App.svelte' + +const app = new App({ + target: document.getElementById('app')!, +}) + +export default app diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..b0683fd --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,7 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..df56300 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "moduleDetection": "force" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..6c2d870 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d701969 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [svelte()], +}) diff --git a/frontend/yarn.lock b/frontend/yarn.lock new file mode 100644 index 0000000..cd6d32f --- /dev/null +++ b/frontend/yarn.lock @@ -0,0 +1,538 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ampproject/remapping@^2.2.1": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@rollup/rollup-android-arm-eabi@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz#1661ff5ea9beb362795304cb916049aba7ac9c54" + integrity sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA== + +"@rollup/rollup-android-arm64@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz#2ffaa91f1b55a0082b8a722525741aadcbd3971e" + integrity sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA== + +"@rollup/rollup-darwin-arm64@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz#627007221b24b8cc3063703eee0b9177edf49c1f" + integrity sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA== + +"@rollup/rollup-darwin-x64@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz#0605506142b9e796c370d59c5984ae95b9758724" + integrity sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ== + +"@rollup/rollup-linux-arm-gnueabihf@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz#62dfd196d4b10c0c2db833897164d2d319ee0cbb" + integrity sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA== + +"@rollup/rollup-linux-arm-musleabihf@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz#53ce72aeb982f1f34b58b380baafaf6a240fddb3" + integrity sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw== + +"@rollup/rollup-linux-arm64-gnu@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz#1632990f62a75c74f43e4b14ab3597d7ed416496" + integrity sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA== + +"@rollup/rollup-linux-arm64-musl@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz#8c03a996efb41e257b414b2e0560b7a21f2d9065" + integrity sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw== + +"@rollup/rollup-linux-powerpc64le-gnu@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz#5b98729628d5bcc8f7f37b58b04d6845f85c7b5d" + integrity sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw== + +"@rollup/rollup-linux-riscv64-gnu@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz#48e42e41f4cabf3573cfefcb448599c512e22983" + integrity sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg== + +"@rollup/rollup-linux-s390x-gnu@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz#e0b4f9a966872cb7d3e21b9e412a4b7efd7f0b58" + integrity sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g== + +"@rollup/rollup-linux-x64-gnu@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz#78144741993100f47bd3da72fce215e077ae036b" + integrity sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A== + +"@rollup/rollup-linux-x64-musl@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz#d9fe32971883cd1bd858336bd33a1c3ca6146127" + integrity sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ== + +"@rollup/rollup-win32-arm64-msvc@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz#71fa3ea369316db703a909c790743972e98afae5" + integrity sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ== + +"@rollup/rollup-win32-ia32-msvc@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz#653f5989a60658e17d7576a3996deb3902e342e2" + integrity sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ== + +"@rollup/rollup-win32-x64-msvc@4.24.0": + version "4.24.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz#0574d7e87b44ee8511d08cc7f914bcb802b70818" + integrity sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw== + +"@sveltejs/vite-plugin-svelte-inspector@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz#116ba2b73be43c1d7d93de749f37becc7e45bb8c" + integrity sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg== + dependencies: + debug "^4.3.4" + +"@sveltejs/vite-plugin-svelte@^3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz#be3120b52e6d9facb55d58392b0dad9e5a35ba6f" + integrity sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA== + dependencies: + "@sveltejs/vite-plugin-svelte-inspector" "^2.1.0" + debug "^4.3.4" + deepmerge "^4.3.1" + kleur "^4.1.5" + magic-string "^0.30.10" + svelte-hmr "^0.16.0" + vitefu "^0.2.5" + +"@tsconfig/svelte@^5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/svelte/-/svelte-5.0.4.tgz#8bd0254472bd39a5e750f1b4a05ecb18c9f3bf80" + integrity sha512-BV9NplVgLmSi4mwKzD8BD/NQ8erOY/nUE/GpgWe2ckx+wIQF5RyRirn/QsSSCPeulVpc3RA/iJt6DpfTIZps0Q== + +"@types/estree@*", "@types/estree@1.0.6", "@types/estree@^1.0.0", "@types/estree@^1.0.1": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== + +acorn@^8.10.0, acorn@^8.9.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.13.0.tgz#2a30d670818ad16ddd6a35d3842dacec9e5d7ca3" + integrity sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w== + +aria-query@^5.3.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + +axobject-query@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" + integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== + +chokidar@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.1.tgz#4a6dff66798fb0f72a94f616abbd7e1a19f31d41" + integrity sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA== + dependencies: + readdirp "^4.0.1" + +code-red@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/code-red/-/code-red-1.0.4.tgz#59ba5c9d1d320a4ef795bc10a28bd42bfebe3e35" + integrity sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw== + dependencies: + "@jridgewell/sourcemap-codec" "^1.4.15" + "@types/estree" "^1.0.1" + acorn "^8.10.0" + estree-walker "^3.0.3" + periscopic "^3.1.0" + +css-tree@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" + integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== + dependencies: + mdn-data "2.0.30" + source-map-js "^1.0.1" + +debug@^4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +estree-walker@^3.0.0, estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +fdir@^6.2.0: + version "6.4.2" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.2.tgz#ddaa7ce1831b161bc3657bb99cb36e1622702689" + integrity sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +is-reference@^3.0.0, is-reference@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.2.tgz#154747a01f45cd962404ee89d43837af2cba247c" + integrity sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg== + dependencies: + "@types/estree" "*" + +kleur@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" + integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== + +locate-character@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-character/-/locate-character-3.0.0.tgz#0305c5b8744f61028ef5d01f444009e00779f974" + integrity sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA== + +magic-string@^0.30.10, magic-string@^0.30.4: + version "0.30.12" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.12.tgz#9eb11c9d072b9bcb4940a5b2c2e1a217e4ee1a60" + integrity sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +mdn-data@2.0.30: + version "2.0.30" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" + integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== + +mri@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" + integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +periscopic@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/periscopic/-/periscopic-3.1.0.tgz#7e9037bf51c5855bd33b48928828db4afa79d97a" + integrity sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^3.0.0" + is-reference "^3.0.0" + +picocolors@^1.0.0, picocolors@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +postcss@^8.4.43: + version "8.4.47" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.47.tgz#5bf6c9a010f3e724c503bf03ef7947dcb0fea365" + integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.0" + source-map-js "^1.2.1" + +readdirp@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.0.2.tgz#388fccb8b75665da3abffe2d8f8ed59fe74c230a" + integrity sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA== + +rollup@^4.20.0: + version "4.24.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.24.0.tgz#c14a3576f20622ea6a5c9cad7caca5e6e9555d05" + integrity sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg== + dependencies: + "@types/estree" "1.0.6" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.24.0" + "@rollup/rollup-android-arm64" "4.24.0" + "@rollup/rollup-darwin-arm64" "4.24.0" + "@rollup/rollup-darwin-x64" "4.24.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.24.0" + "@rollup/rollup-linux-arm-musleabihf" "4.24.0" + "@rollup/rollup-linux-arm64-gnu" "4.24.0" + "@rollup/rollup-linux-arm64-musl" "4.24.0" + "@rollup/rollup-linux-powerpc64le-gnu" "4.24.0" + "@rollup/rollup-linux-riscv64-gnu" "4.24.0" + "@rollup/rollup-linux-s390x-gnu" "4.24.0" + "@rollup/rollup-linux-x64-gnu" "4.24.0" + "@rollup/rollup-linux-x64-musl" "4.24.0" + "@rollup/rollup-win32-arm64-msvc" "4.24.0" + "@rollup/rollup-win32-ia32-msvc" "4.24.0" + "@rollup/rollup-win32-x64-msvc" "4.24.0" + fsevents "~2.3.2" + +sade@^1.7.4: + version "1.8.1" + resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" + integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== + dependencies: + mri "^1.1.0" + +source-map-js@^1.0.1, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +svelte-check@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-4.0.5.tgz#5cd910c3b1d50f38159c17cc3bae127cbbb55c8d" + integrity sha512-icBTBZ3ibBaywbXUat3cK6hB5Du+Kq9Z8CRuyLmm64XIe2/r+lQcbuBx/IQgsbrC+kT2jQ0weVpZSSRIPwB6jQ== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + chokidar "^4.0.1" + fdir "^6.2.0" + picocolors "^1.0.0" + sade "^1.7.4" + +svelte-hmr@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/svelte-hmr/-/svelte-hmr-0.16.0.tgz#9f345b7d1c1662f1613747ed7e82507e376c1716" + integrity sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA== + +svelte@^4.2.19: + version "4.2.19" + resolved "https://registry.yarnpkg.com/svelte/-/svelte-4.2.19.tgz#4e6e84a8818e2cd04ae0255fcf395bc211e61d4c" + integrity sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw== + dependencies: + "@ampproject/remapping" "^2.2.1" + "@jridgewell/sourcemap-codec" "^1.4.15" + "@jridgewell/trace-mapping" "^0.3.18" + "@types/estree" "^1.0.1" + acorn "^8.9.0" + aria-query "^5.3.0" + axobject-query "^4.0.0" + code-red "^1.0.3" + css-tree "^2.3.1" + estree-walker "^3.0.3" + is-reference "^3.0.1" + locate-character "^3.0.0" + magic-string "^0.30.4" + periscopic "^3.1.0" + +tslib@^2.7.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b" + integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA== + +typescript@^5.5.3: + version "5.6.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" + integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== + +vite@^5.4.8: + version "5.4.9" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.9.tgz#215c80cbebfd09ccbb9ceb8c0621391c9abdc19c" + integrity sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitefu@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-0.2.5.tgz#c1b93c377fbdd3e5ddd69840ea3aa70b40d90969" + integrity sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q== diff --git a/go.mod b/go.mod index 4e559db..2b6e876 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,6 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/klauspost/compress v1.17.10 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -65,6 +64,5 @@ require ( github.com/tidwall/tinyqueue v0.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6 // indirect - golang.org/x/net v0.29.0 // indirect golang.org/x/sys v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index ccd5bda..00fccbf 100644 --- a/go.sum +++ b/go.sum @@ -17,19 +17,14 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= -github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= -github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= -github.com/charmbracelet/x/ansi v0.2.1 h1:8G2jgVEHdyFJJwToL/gWvxH1/qmEY7bybjacefoffxk= -github.com/charmbracelet/x/ansi v0.2.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/cloudflare/tableflip v1.2.3 h1:8I+B99QnnEWPHOY3fWipwVKxS70LGgUsslG7CSfmHMw= github.com/cloudflare/tableflip v1.2.3/go.mod h1:P4gRehmV6Z2bY5ao5ml9Pd8u6kuEnlB37pUFMmv7j2E= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -59,8 +54,6 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= -github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -105,8 +98,6 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0= github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -126,8 +117,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= @@ -176,14 +165,10 @@ github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI= github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY= github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI= -github.com/tidwall/buntdb v1.3.1 h1:HKoDF01/aBhl9RjYtbaLnvX9/OuenwvQiC3OP1CcL4o= -github.com/tidwall/buntdb v1.3.1/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg= github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94= -github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= @@ -230,12 +215,8 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6 h1:1wqE9dj9NpSm04INVsJhhEUzhuDVjbcyKH91sVyPATw= golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -252,12 +233,9 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= -golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -286,8 +264,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -300,8 +276,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/issuer/manager.go b/issuer/manager.go index 8520c15..8585f86 100644 --- a/issuer/manager.go +++ b/issuer/manager.go @@ -8,15 +8,18 @@ import ( var isValidNamespace = regexp.MustCompile("^[0-9a-z.]+$") +var MeWellKnown = &WellKnownOIDC{} + type Manager struct { m map[string]*WellKnownOIDC } -func NewManager(services map[string]SsoConfig) (*Manager, error) { +func NewManager(myNamespace string, services []SsoConfig) (*Manager, error) { l := &Manager{m: make(map[string]*WellKnownOIDC)} - for namespace, ssoService := range services { - if !isValidNamespace.MatchString(namespace) { - return nil, fmt.Errorf("invalid namespace: %s", namespace) + l.m[myNamespace] = MeWellKnown + for _, ssoService := range services { + if !isValidNamespace.MatchString(ssoService.Namespace) { + return nil, fmt.Errorf("invalid namespace: %s", ssoService.Namespace) } conf, err := ssoService.FetchConfig() @@ -25,8 +28,7 @@ func NewManager(services map[string]SsoConfig) (*Manager, error) { } // save by namespace - conf.Namespace = namespace - l.m[namespace] = conf + l.m[ssoService.Namespace] = conf } return l, nil } diff --git a/issuer/manager_test.go b/issuer/manager_test.go index 8f316f3..c92ac8b 100644 --- a/issuer/manager_test.go +++ b/issuer/manager_test.go @@ -26,12 +26,14 @@ func TestManager_CheckNamespace(t *testing.T) { httpGet = func(url string) (resp *http.Response, err error) { return &http.Response{StatusCode: http.StatusOK, Body: testBody()}, nil } - manager, err := NewManager(map[string]SsoConfig{ - "example.com": { - Addr: testAddrUrl, + manager, err := NewManager("example.org", []SsoConfig{ + { + Addr: testAddrUrl, + Namespace: "example.com", }, }) assert.NoError(t, err) + assert.True(t, manager.CheckNamespace("example.org")) assert.True(t, manager.CheckNamespace("example.com")) assert.False(t, manager.CheckNamespace("missing.example.com")) } @@ -40,12 +42,14 @@ func TestManager_FindServiceFromLogin(t *testing.T) { httpGet = func(url string) (resp *http.Response, err error) { return &http.Response{StatusCode: http.StatusOK, Body: testBody()}, nil } - manager, err := NewManager(map[string]SsoConfig{ - "example.com": { - Addr: testAddrUrl, + manager, err := NewManager("example.org", []SsoConfig{ + { + Addr: testAddrUrl, + Namespace: "example.com", }, }) assert.NoError(t, err) + assert.Equal(t, manager.FindServiceFromLogin("jane@example.org"), MeWellKnown) assert.Equal(t, manager.FindServiceFromLogin("jane@example.com"), manager.m["example.com"]) assert.Nil(t, manager.FindServiceFromLogin("jane@missing.example.com")) } diff --git a/pages/edit-otp.go.html b/pages/edit-otp.go.html new file mode 100644 index 0000000..8525fca --- /dev/null +++ b/pages/edit-otp.go.html @@ -0,0 +1,27 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
+ + +

+ OTP QR code not loading +

+

Raw OTP string: {{.OtpUrl}}

+
+ + +
+ +
+
+ + diff --git a/pages/edit-password.go.html b/pages/edit-password.go.html new file mode 100644 index 0000000..1ef09c1 --- /dev/null +++ b/pages/edit-password.go.html @@ -0,0 +1,29 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + diff --git a/pages/edit.go.html b/pages/edit.go.html new file mode 100644 index 0000000..ef67d3d --- /dev/null +++ b/pages/edit.go.html @@ -0,0 +1,72 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
Logged in as: {{.User.Name}} ({{.User.Subject}})
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+
+ + + + {{range .ListZoneInfo}} + + {{end}} + + +
+
+ + + + {{range .ListLocale}} + + {{end}} + + +
+ +
+
+ +
+
+
+ + diff --git a/pages/index.go.html b/pages/index.go.html index 22bf121..b6e891d 100644 --- a/pages/index.go.html +++ b/pages/index.go.html @@ -2,7 +2,7 @@ {{.ServiceName}} - + @@ -21,6 +21,23 @@ {{end}} + {{if .OtpEnabled}} +
+
+ + +
+
+ {{else}} +
+
+ + + + +
+
+ {{end}}
diff --git a/pages/login.go.html b/pages/login.go.html index fe45b01..281c22e 100644 --- a/pages/login.go.html +++ b/pages/login.go.html @@ -2,20 +2,60 @@ {{.ServiceName}} - + {{template "header.go.html" .}}
- - -
- - + {{if eq .Mismatch "1"}} +

Invalid username or password

+ {{else if eq .Mismatch "2"}} +

Check your inbox for a verification email

+ {{end}} + {{if eq .Source "start"}} + + +
+ + +
+ + + +
+

Enter your email address below to receive an email with instructions on how to reset your password.

+

Please note this only works if your email address is already verified.

+
+ + +
+ +
+ {{else if eq .Source "password"}} +
+ + +
+ + +
+ +
+ {{else if eq .Source "otp"}} +
+ +
+ + +
+ +
+ {{end}}
diff --git a/pages/manage-users-create.go.html b/pages/manage-users-create.go.html new file mode 100644 index 0000000..d8a0720 --- /dev/null +++ b/pages/manage-users-create.go.html @@ -0,0 +1,46 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
+ +
+ +

Create User

+
+ + +
+ + +
+
+ + +
+
+ +

Using an `@{{.Namespace}}` email address will automatically verify as it is owned by this login + service.

+ +
+
+ + +
+
+ +
+ +
+
+ + diff --git a/pages/remove-otp.go.html b/pages/remove-otp.go.html new file mode 100644 index 0000000..22f5997 --- /dev/null +++ b/pages/remove-otp.go.html @@ -0,0 +1,22 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
+ +
+ + +
+ +
+
+ + diff --git a/pages/reset-password.go.html b/pages/reset-password.go.html new file mode 100644 index 0000000..97dcebe --- /dev/null +++ b/pages/reset-password.go.html @@ -0,0 +1,26 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
+ +
+ + +
+
+ + +
+ +
+
+ + diff --git a/server/auth.go b/server/auth.go index 89f0851..d179f62 100644 --- a/server/auth.go +++ b/server/auth.go @@ -8,36 +8,17 @@ import ( "github.com/1f349/lavender/role" "github.com/julienschmidt/httprouter" "net/http" - "net/url" - "strings" ) -type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) - -type UserAuth struct { - Subject string - Factor auth.Factor - UserInfo auth.UserInfoFields -} - -func (u UserAuth) IsGuest() bool { return u.Subject == "" } - -func (u UserAuth) NextFlowUrl(origin *url.URL) *url.URL { - if u.Factor < auth.FactorAuthorized { - return PrepareRedirectUrl("/login", origin) - } - return nil -} - var ErrAuthHttpError = errors.New("auth http error") -func (h *httpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle { - return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { +func (h *httpServer) RequireAdminAuthentication(next auth.UserHandler) httprouter.Handle { + return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, userAuth auth.UserAuth) { var hasRole bool if h.DbTx(rw, func(tx *database.Queries) (err error) { err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{ Role: role.LavenderAdmin, - Subject: auth.Subject, + Subject: userAuth.Subject, }) switch { case err == nil: @@ -54,22 +35,22 @@ func (h *httpServer) RequireAdminAuthentication(next UserHandler) httprouter.Han http.Error(rw, "403 Forbidden", http.StatusForbidden) return } - next(rw, req, params, auth) + next(rw, req, params, userAuth) }) } -func (h *httpServer) RequireAuthentication(next UserHandler) httprouter.Handle { - return h.OptionalAuthentication(false, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - if auth.IsGuest() { - redirectUrl := PrepareRedirectUrl("/login", req.URL) +func (h *httpServer) RequireAuthentication(next auth.UserHandler) httprouter.Handle { + return h.OptionalAuthentication(false, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, userAuth auth.UserAuth) { + if userAuth.IsGuest() { + redirectUrl := auth.PrepareRedirectUrl("/login", req.URL) http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) return } - next(rw, req, params, auth) + next(rw, req, params, userAuth) }) } -func (h *httpServer) OptionalAuthentication(flowPart bool, next UserHandler) httprouter.Handle { +func (h *httpServer) OptionalAuthentication(flowPart bool, next auth.UserHandler) httprouter.Handle { return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { authData, err := h.internalAuthenticationHandler(rw, req) if err != nil { @@ -86,7 +67,7 @@ func (h *httpServer) OptionalAuthentication(flowPart bool, next UserHandler) htt } } -func (h *httpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) { +func (h *httpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (auth.UserAuth, error) { // Delete previous login data cookie http.SetCookie(rw, &http.Cookie{ Name: "lavender-login-data", @@ -96,37 +77,11 @@ func (h *httpServer) internalAuthenticationHandler(rw http.ResponseWriter, req * SameSite: http.SameSiteLaxMode, }) - var u UserAuth + var u auth.UserAuth err := h.readLoginAccessCookie(rw, req, &u) if err != nil { // not logged in - return UserAuth{}, nil + return auth.UserAuth{}, nil } return u, nil } - -func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL { - // find start of query parameters in target path - n := strings.IndexByte(targetPath, '?') - v := url.Values{} - - // parse existing query parameters - if n != -1 { - q, err := url.ParseQuery(targetPath[n+1:]) - if err != nil { - panic("PrepareRedirectUrl: invalid hardcoded target path query parameters") - } - v = q - targetPath = targetPath[:n] - } - - // add path of origin as a new query parameter - orig := origin.Path - if origin.RawQuery != "" || origin.ForceQuery { - orig += "?" + origin.RawQuery - } - if orig != "" { - v.Set("redirect", orig) - } - return &url.URL{Path: targetPath, RawQuery: v.Encode()} -} diff --git a/server/auth_test.go b/server/auth_test.go index 68b6603..384e90c 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "github.com/1f349/lavender/auth" "github.com/1f349/mjwt" "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" @@ -13,16 +14,16 @@ import ( ) func TestUserAuth_NextFlowUrl(t *testing.T) { - u := UserAuth{NeedOtp: true} - assert.Equal(t, url.URL{Path: "/login/otp"}, *u.NextFlowUrl(&url.URL{})) - assert.Equal(t, url.URL{Path: "/login/otp", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello"})) - assert.Equal(t, url.URL{Path: "/login/otp", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) - u.NeedOtp = false + u := auth.UserAuth{Factor: 0} + assert.Equal(t, url.URL{Path: "/login"}, *u.NextFlowUrl(&url.URL{})) + assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello"})) + assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) + u.Factor = auth.FactorAuthorized assert.Nil(t, u.NextFlowUrl(&url.URL{})) } func TestUserAuth_IsGuest(t *testing.T) { - var u UserAuth + var u auth.UserAuth assert.True(t, u.IsGuest()) u.Subject = uuid.NewString() assert.False(t, u.IsGuest()) @@ -52,22 +53,22 @@ func TestOptionalAuthentication(t *testing.T) { rec := httptest.NewRecorder() req, err := http.NewRequest(http.MethodGet, "https://example.com/hello", nil) assert.NoError(t, err) - auth, err := h.internalAuthenticationHandler(rec, req) + authData, err := h.internalAuthenticationHandler(rec, req) assert.NoError(t, err) - assert.True(t, auth.IsGuest()) - auth.Subject = "567" + assert.True(t, authData.IsGuest()) + authData.Subject = "567" } func TestPrepareRedirectUrl(t *testing.T) { - assert.Equal(t, url.URL{Path: "/hello"}, *PrepareRedirectUrl("/hello", &url.URL{})) - assert.Equal(t, url.URL{Path: "/world"}, *PrepareRedirectUrl("/world", &url.URL{})) - assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello"})) - assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) - assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A&b=B"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()})) + assert.Equal(t, url.URL{Path: "/hello"}, *auth.PrepareRedirectUrl("/hello", &url.URL{})) + assert.Equal(t, url.URL{Path: "/world"}, *auth.PrepareRedirectUrl("/world", &url.URL{})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *auth.PrepareRedirectUrl("/a", &url.URL{Path: "/hello"})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *auth.PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A&b=B"}}.Encode()}, *auth.PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()})) - assert.Equal(t, url.URL{Path: "/hello", RawQuery: "z=y"}, *PrepareRedirectUrl("/hello?z=y", &url.URL{})) - assert.Equal(t, url.URL{Path: "/world", RawQuery: "z=y"}, *PrepareRedirectUrl("/world?z=y", &url.URL{})) - assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello"})) - assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) - assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A&b=B"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()})) + assert.Equal(t, url.URL{Path: "/hello", RawQuery: "z=y"}, *auth.PrepareRedirectUrl("/hello?z=y", &url.URL{})) + assert.Equal(t, url.URL{Path: "/world", RawQuery: "z=y"}, *auth.PrepareRedirectUrl("/world?z=y", &url.URL{})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello"}}.Encode()}, *auth.PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello"})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A"}}.Encode()}, *auth.PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A&b=B"}}.Encode()}, *auth.PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()})) } diff --git a/server/edit.go b/server/edit.go index 981cc0d..30ea342 100644 --- a/server/edit.go +++ b/server/edit.go @@ -2,6 +2,7 @@ package server import ( "fmt" + auth2 "github.com/1f349/lavender/auth" "github.com/1f349/lavender/database" "github.com/1f349/lavender/lists" "github.com/1f349/lavender/pages" @@ -11,7 +12,7 @@ import ( "time" ) -func (h *httpServer) EditGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) EditGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { var user database.User if h.DbTx(rw, func(tx *database.Queries) error { @@ -43,7 +44,7 @@ func (h *httpServer) EditGet(rw http.ResponseWriter, req *http.Request, _ httpro "ListLocale": lists.ListLocale(), }) } -func (h *httpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { if req.ParseForm() != nil { rw.WriteHeader(http.StatusBadRequest) _, _ = rw.Write([]byte("400 Bad Request\n")) diff --git a/server/home.go b/server/home.go index 2b67a64..b3edfce 100644 --- a/server/home.go +++ b/server/home.go @@ -1,6 +1,7 @@ package server import ( + auth2 "github.com/1f349/lavender/auth" "github.com/1f349/lavender/database" "github.com/1f349/lavender/pages" "github.com/1f349/lavender/role" @@ -10,7 +11,7 @@ import ( "time" ) -func (h *httpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { rw.Header().Set("Content-Type", "text/html") lNonce := uuid.NewString() http.SetCookie(rw, &http.Cookie{ diff --git a/server/login.go b/server/login.go index 5a55abd..6e64719 100644 --- a/server/login.go +++ b/server/login.go @@ -41,7 +41,22 @@ func getUserLoginName(req *http.Request) string { return originUrl.Query().Get("login_name") } -func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) testAuthSources(req *http.Request, user *database.User, factor auth2.Factor) map[string]bool { + authSource := make(map[string]bool) + data := make(map[string]any) + for _, i := range h.authSources { + // ignore not-supported factors + if i.Factor()&factor == 0 { + continue + } + err := i.RenderData(req.Context(), req, user, data) + authSource[i.Name()] = err == nil + clear(data) + } + return authSource +} + +func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { if !auth.IsGuest() { h.SafeRedirect(rw, req) return @@ -49,20 +64,41 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr cookie, err := req.Cookie("lavender-login-name") if err == nil && cookie.Valid() == nil { + user, err := h.db.GetUser(req.Context(), auth.Subject) + var userPtr *database.User + switch { + case err == nil: + userPtr = &user + case errors.Is(err, sql.ErrNoRows): + userPtr = nil + default: + http.Error(rw, "Internal server error", http.StatusInternalServerError) + return + } + + fmt.Printf("%#v\n", h.testAuthSources(req, userPtr, auth2.FactorFirst)) + pages.RenderPageTemplate(rw, "login-memory", map[string]any{ "ServiceName": h.conf.ServiceName, "LoginName": cookie.Value, "Redirect": req.URL.Query().Get("redirect"), + "Source": "start", + "Auth": h.testAuthSources(req, userPtr, auth2.FactorFirst), }) return } + + // render different page sources pages.RenderPageTemplate(rw, "login", map[string]any{ "ServiceName": h.conf.ServiceName, + "LoginName": "", "Redirect": req.URL.Query().Get("redirect"), + "Source": "start", + "Auth": h.testAuthSources(req, nil, auth2.FactorFirst), }) } -func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { if !auth.IsGuest() { h.SafeRedirect(rw, req) return @@ -83,15 +119,29 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http return } loginName := req.PostFormValue("loginname") + + // append local namespace if @ is missing + n := strings.IndexByte(loginName, '@') + if n < 0 { + // correct the @ index + n = len(loginName) + loginName += "@" + h.conf.Namespace + } + login := h.manager.FindServiceFromLogin(loginName) if login == nil { http.Error(rw, "No login service defined for this username", http.StatusBadRequest) return } + // the @ must exist if the service is defined - n := strings.IndexByte(loginName, '@') loginUn := loginName[:n] + ctx := auth2.WithWellKnown(req.Context(), login) + ctx = context.WithValue(ctx, "login_username", loginUn) + ctx = context.WithValue(ctx, "login_full", loginName) + + // TODO(melon): only do if remember-me is enabled now := time.Now() future := now.AddDate(1, 0, 0) http.SetCookie(rw, &http.Cookie{ @@ -104,49 +154,36 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http SameSite: http.SameSiteLaxMode, }) - // save state for use later - state := login.Config.Namespace + ":" + uuid.NewString() - h.flowState.Set(state, flowStateData{loginName, login, req.PostFormValue("redirect")}, time.Now().Add(15*time.Minute)) + var redirectError auth2.RedirectError - // generate oauth2 config and redirect to authorize URL - oa2conf := login.OAuth2Config - oa2conf.RedirectURL = h.conf.BaseUrl + "/callback" - nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn)) - http.Redirect(rw, req, nextUrl, http.StatusFound) + // if the login is the local server + if login == issuer.MeWellKnown { + // TODO(melon): work on this + err := h.authBasic.AttemptLogin(ctx, req, nil) + switch { + case errors.As(err, &redirectError): + http.Redirect(rw, req, redirectError.Target, redirectError.Code) + return + } + return + } + + err := h.authOAuth.AttemptLogin(ctx, req, nil) + switch { + case errors.As(err, &redirectError): + http.Redirect(rw, req, redirectError.Target, redirectError.Code) + return + } } -func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) { - flowState, ok := h.flowState.Get(req.FormValue("state")) - if !ok { - http.Error(rw, "Invalid flow state", http.StatusBadRequest) - return - } - token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", h.conf.BaseUrl+"/callback")) - if err != nil { - http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError) - return - } - - userAuth, err = h.updateExternalUserInfo(req, flowState.sso, token) - if err != nil { - http.Error(rw, "Failed to update external user info", http.StatusInternalServerError) - return - } - - if h.setLoginDataCookie(rw, userAuth, flowState.loginName) { - http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError) - return - } - if flowState.redirect != "" { - req.Form.Set("redirect", flowState.redirect) - } - h.SafeRedirect(rw, req) +func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth auth2.UserAuth) { + h.authOAuth.OAuthCallback(rw, req, h.updateExternalUserInfo, h.setLoginDataCookie, h.SafeRedirect) } -func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) { +func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth2.UserAuth, error) { sessionData, err := h.fetchUserInfo(sso, token) if err != nil || sessionData.Subject == "" { - return UserAuth{}, fmt.Errorf("failed to fetch user info") + return auth2.UserAuth{}, fmt.Errorf("failed to fetch user info") } // TODO(melon): fix this to use a merging of lavender and tulip auth @@ -167,9 +204,9 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK err = h.DbTxError(func(tx *database.Queries) error { return h.updateOAuth2UserProfile(req.Context(), tx, sessionData) }) - return UserAuth{ + return auth2.UserAuth{ Subject: userSubject, - NeedOtp: sessionData.NeedOtp, + Factor: auth2.FactorAuthorized, UserInfo: sessionData.UserInfo, }, err case errors.Is(err, sql.ErrNoRows): @@ -177,12 +214,12 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK break default: // another error occurred - return UserAuth{}, err + return auth2.UserAuth{}, err } // guard for disabled registration if !sso.Config.Registration { - return UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source") + return auth2.UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source") } // TODO(melon): rework this @@ -207,7 +244,7 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK return h.updateOAuth2UserProfile(req.Context(), tx, sessionData) }) if err != nil { - return UserAuth{}, err + return auth2.UserAuth{}, err } // only continues if the above tx succeeds @@ -219,20 +256,20 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK Subject: sessionData.Subject, }) }); err != nil { - return UserAuth{}, err + return auth2.UserAuth{}, err } // TODO(melon): this feels bad - sessionData = UserAuth{ + sessionData = auth2.UserAuth{ Subject: userSubject, - NeedOtp: sessionData.NeedOtp, + Factor: auth2.FactorAuthorized, UserInfo: sessionData.UserInfo, } return sessionData, nil } -func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData UserAuth) error { +func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData auth2.UserAuth) error { // all of these updates must succeed return tx.UseTx(ctx, func(tx *database.Queries) error { name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User") @@ -274,6 +311,7 @@ const oneWeek = 7 * 24 * time.Hour type lavenderLoginAccess struct { UserInfo auth2.UserInfoFields `json:"user_info"` + Factor auth2.Factor `json:"factor"` auth.AccessTokenClaims } @@ -290,16 +328,12 @@ func (l lavenderLoginRefresh) Valid() error { return l.RefreshTokenClaims.Valid( func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" } -func (h *httpServer) setLoginDataCookie2(rw http.ResponseWriter, authData UserAuth) bool { - // TODO(melon): should probably merge these methods - return h.setLoginDataCookie(rw, authData, "") -} - -func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth, loginName string) bool { +func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData auth2.UserAuth, loginName string) bool { ps := auth.NewPermStorage() accId := uuid.NewString() gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, lavenderLoginAccess{ UserInfo: authData.UserInfo, + Factor: authData.Factor, AccessTokenClaims: auth.AccessTokenClaims{Perms: ps}, }) if err != nil { @@ -346,19 +380,20 @@ func readJwtCookie[T mjwt.Claims](req *http.Request, cookieName string, signingK return b, nil } -func (h *httpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) error { +func (h *httpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *auth2.UserAuth) error { loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore()) if err != nil { return h.readLoginRefreshCookie(rw, req, u) } - *u = UserAuth{ + *u = auth2.UserAuth{ Subject: loginData.Subject, + Factor: loginData.Claims.Factor, UserInfo: loginData.Claims.UserInfo, } return nil } -func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *UserAuth) error { +func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *auth2.UserAuth) error { refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore()) if err != nil { return err @@ -396,27 +431,28 @@ func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Re return nil } -func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) { +func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth2.UserAuth, error) { res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint) if err != nil || res.StatusCode != http.StatusOK { - return UserAuth{}, fmt.Errorf("request failed") + return auth2.UserAuth{}, fmt.Errorf("request failed") } defer res.Body.Close() var userInfoJson auth2.UserInfoFields if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil { - return UserAuth{}, err + return auth2.UserAuth{}, err } subject, ok := userInfoJson.GetString("sub") if !ok { - return UserAuth{}, fmt.Errorf("invalid subject") + return auth2.UserAuth{}, fmt.Errorf("invalid subject") } // TODO(melon): there is no need for this //subject += "@" + sso.Config.Namespace - return UserAuth{ + return auth2.UserAuth{ Subject: subject, + Factor: auth2.FactorAuthorized, UserInfo: userInfoJson, }, nil } diff --git a/server/logout.go b/server/logout.go index 1d721d2..46aa7dd 100644 --- a/server/logout.go +++ b/server/logout.go @@ -1,11 +1,12 @@ package server import ( + auth2 "github.com/1f349/lavender/auth" "github.com/julienschmidt/httprouter" "net/http" ) -func (h *httpServer) logoutPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, _ UserAuth) { +func (h *httpServer) logoutPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, _ auth2.UserAuth) { http.SetCookie(rw, &http.Cookie{ Name: "lavender-login-access", Path: "/", diff --git a/server/manage-apps.go b/server/manage-apps.go index 404d46a..a246a40 100644 --- a/server/manage-apps.go +++ b/server/manage-apps.go @@ -1,6 +1,7 @@ package server import ( + auth2 "github.com/1f349/lavender/auth" "github.com/1f349/lavender/database" "github.com/1f349/lavender/pages" "github.com/1f349/lavender/password" @@ -18,7 +19,7 @@ func SetupManageApps(r *httprouter.Router, hs *httpServer) { r.POST("/manage/apps", hs.RequireAuthentication(hs.ManageAppsPost)) } -func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { q := req.URL.Query() offset, _ := strconv.Atoi(q.Get("offset")) @@ -66,7 +67,7 @@ func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ pages.RenderPageTemplate(rw, "manage-apps", m) } -func (h *httpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { var roles []string if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) @@ -85,7 +86,7 @@ func (h *httpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Reque pages.RenderPageTemplate(rw, "manage-apps-create", m) } -func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { err := req.ParseForm() if err != nil { http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest) diff --git a/server/manage-users.go b/server/manage-users.go index bf4fa8d..7d243e9 100644 --- a/server/manage-users.go +++ b/server/manage-users.go @@ -1,6 +1,7 @@ package server import ( + auth2 "github.com/1f349/lavender/auth" "github.com/1f349/lavender/database" "github.com/1f349/lavender/pages" "github.com/1f349/lavender/role" @@ -16,7 +17,7 @@ func SetupManageUsers(r *httprouter.Router, hs *httpServer) { r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost)) } -func (h *httpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { q := req.URL.Query() offset, _ := strconv.Atoi(q.Get("offset")) @@ -63,7 +64,7 @@ func (h *httpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ pages.RenderPageTemplate(rw, "manage-users", m) } -func (h *httpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { err := req.ParseForm() if err != nil { http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest) diff --git a/server/oauth.go b/server/oauth.go index e79fb4a..445fa41 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "fmt" + auth2 "github.com/1f349/lavender/auth" clientStore "github.com/1f349/lavender/client-store" "github.com/1f349/lavender/database" "github.com/1f349/lavender/logger" @@ -150,7 +151,7 @@ func (h *httpServer) userInfoRequest(rw http.ResponseWriter, req *http.Request, _ = json.NewEncoder(rw).Encode(m) } -func (h *httpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { // function is only called with GET or POST method isPost := req.Method == http.MethodPost @@ -292,7 +293,7 @@ func (h *httpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re return "", err } - redirectUrl := PrepareRedirectUrl("/login", &url.URL{Path: "/authorize", RawQuery: q.Encode()}) + redirectUrl := auth2.PrepareRedirectUrl("/login", &url.URL{Path: "/authorize", RawQuery: q.Encode()}) http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) return "", nil } diff --git a/server/otp.go b/server/otp.go index 0a7e799..cd38e7e 100644 --- a/server/otp.go +++ b/server/otp.go @@ -2,8 +2,8 @@ package server import ( "bytes" - "context" "encoding/base64" + auth2 "github.com/1f349/lavender/auth" "github.com/1f349/lavender/database" "github.com/1f349/lavender/pages" "github.com/julienschmidt/httprouter" @@ -15,67 +15,7 @@ import ( "time" ) -func (h *httpServer) loginOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { - if !auth.NeedOtp { - h.SafeRedirect(rw, req) - return - } - - pages.RenderPageTemplate(rw, "login-otp", map[string]any{ - "ServiceName": h.conf.ServiceName, - "Redirect": req.URL.Query().Get("redirect"), - }) -} - -func (h *httpServer) loginOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { - if !auth.NeedOtp { - http.Redirect(rw, req, "/", http.StatusFound) - return - } - - otpInput := req.FormValue("code") - if h.fetchAndValidateOtp(rw, auth.Subject, otpInput) { - return - } - - auth.NeedOtp = false - - h.setLoginDataCookie2(rw, auth) - h.SafeRedirect(rw, req) -} - -func (h *httpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub, code string) bool { - var hasOtp bool - var otpRow database.GetOtpRow - var secret string - var digits int64 - if h.DbTx(rw, func(tx *database.Queries) (err error) { - hasOtp, err = tx.HasOtp(context.Background(), sub) - if err != nil { - return - } - if hasOtp { - otpRow, err = tx.GetOtp(context.Background(), sub) - secret = otpRow.OtpSecret - digits = otpRow.OtpDigits - } - return - }) { - return true - } - - if hasOtp { - totp := gotp.NewTOTP(secret, int(digits), 30, nil) - if !verifyTotp(totp, code) { - http.Error(rw, "400 Bad Request: Invalid OTP code", http.StatusBadRequest) - return true - } - } - - return false -} - -func (h *httpServer) editOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) editOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { if req.Method == http.MethodPost && req.FormValue("remove") == "1" { if !req.Form.Has("code") { // render page @@ -86,7 +26,9 @@ func (h *httpServer) editOtpPost(rw http.ResponseWriter, req *http.Request, _ ht } otpInput := req.Form.Get("code") - if h.fetchAndValidateOtp(rw, auth.Subject, otpInput) { + err := h.authOtp.VerifyOtpCode(req.Context(), auth.Subject, otpInput) + if err != nil { + http.Error(rw, "Invalid OTP code", http.StatusBadRequest) return } diff --git a/server/server.go b/server/server.go index 7e170e6..a1cfa82 100644 --- a/server/server.go +++ b/server/server.go @@ -3,6 +3,7 @@ package server import ( "errors" "github.com/1f349/cache" + "github.com/1f349/lavender/auth" "github.com/1f349/lavender/conf" "github.com/1f349/lavender/database" "github.com/1f349/lavender/issuer" @@ -30,17 +31,14 @@ type httpServer struct { signingKey *mjwt.Issuer manager *issuer.Manager - // flowState contains the - flowState *cache.Cache[string, flowStateData] - // mailLinkCache contains a mapping of verify uuids to user uuids mailLinkCache *cache.Cache[mailLinkKey, string] -} -type flowStateData struct { - loginName string - sso *issuer.WellKnownOIDC - redirect string + authBasic *auth.BasicLogin + authOtp *auth.OtpLogin + authOAuth *auth.OAuthLogin + + authSources []auth.Provider } type mailLink byte @@ -62,19 +60,32 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, db *database.Queries, s contentCache := time.Now() + authBasic := &auth.BasicLogin{DB: db} + authOtp := &auth.OtpLogin{DB: db} + authOAuth := &auth.OAuthLogin{DB: db, BaseUrl: config.BaseUrl} + authOAuth.Init() + hs := &httpServer{ r: r, db: db, conf: config, signingKey: signingKey, - flowState: cache.New[string, flowStateData](), - mailLinkCache: cache.New[mailLinkKey, string](), + + authBasic: authBasic, + authOtp: authOtp, + authOAuth: authOAuth, + //authPasskey: &auth.PasskeyLogin{DB: db}, + + authSources: []auth.Provider{ + authBasic, + authOtp, + }, } var err error - hs.manager, err = issuer.NewManager(config.SsoServices) + hs.manager, err = issuer.NewManager(config.Namespace, config.SsoServices) if err != nil { logger.Logger.Fatal("Failed to load SSO services", "err", err) } @@ -97,8 +108,6 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, db *database.Queries, s // login steps r.GET("/login", hs.OptionalAuthentication(false, hs.loginGet)) r.POST("/login", hs.OptionalAuthentication(false, hs.loginPost)) - r.GET("/login/otp", hs.OptionalAuthentication(true, hs.loginOtpGet)) - r.POST("/login/otp", hs.OptionalAuthentication(true, hs.loginOtpPost)) r.GET("/callback", hs.OptionalAuthentication(false, hs.loginCallback)) SetupManageApps(r, hs)