From a6c0751061396e49b2985663193c16bf9df116f7 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Mon, 19 Aug 2024 22:37:30 +0100 Subject: [PATCH 01/10] Add support for tableflip --- cmd/lavender/serve.go | 106 +++++++++++++++++++++++++++++++----------- conf/conf.go | 14 ++++++ go.mod | 27 +++++------ go.sum | 44 +++++++++--------- server/conf.go | 14 ------ server/server.go | 27 ++++------- 6 files changed, 139 insertions(+), 93 deletions(-) create mode 100644 conf/conf.go delete mode 100644 server/conf.go diff --git a/cmd/lavender/serve.go b/cmd/lavender/serve.go index 2429d0f..ec7714c 100644 --- a/cmd/lavender/serve.go +++ b/cmd/lavender/serve.go @@ -2,50 +2,63 @@ package main import ( "context" - "encoding/json" "flag" "github.com/1f349/lavender" + "github.com/1f349/lavender/conf" "github.com/1f349/lavender/logger" "github.com/1f349/lavender/pages" "github.com/1f349/lavender/server" "github.com/1f349/mjwt" - "github.com/1f349/violet/utils" "github.com/charmbracelet/log" + "github.com/cloudflare/tableflip" "github.com/golang-jwt/jwt/v4" "github.com/google/subcommands" _ "github.com/mattn/go-sqlite3" - exitReload "github.com/mrmelon54/exit-reload" "github.com/spf13/afero" + "gopkg.in/yaml.v3" + "net/http" "os" + "os/signal" "path/filepath" + "syscall" + "time" ) type serveCmd struct { configPath string - debugMode bool + debugLog bool + pidFile string } func (s *serveCmd) Name() string { return "serve" } -func (s *serveCmd) Synopsis() string { return "Serve API authentication service" } +func (s *serveCmd) Synopsis() string { return "Serve authentication service" } func (s *serveCmd) SetFlags(f *flag.FlagSet) { f.StringVar(&s.configPath, "conf", "", "/path/to/config.json : path to the config file") - f.BoolVar(&s.debugMode, "debug", false, "enable debug mode") + f.BoolVar(&s.debugLog, "debug", false, "enable debug mode") + f.StringVar(&s.pidFile, "pid-file", "", "path to pid file") } func (s *serveCmd) Usage() string { - return `serve [-conf ] - Serve API authentication service using information from the config file + return `serve [-conf ] [-debug] [-pid-file ] + Serve authentication service using information from the config file ` } func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + if s.debugLog { + logger.Logger.SetLevel(log.DebugLevel) + } logger.Logger.Info("Starting...") - if s.debugMode { - logger.Logger.SetLevel(log.DebugLevel) + upg, err := tableflip.New(tableflip.Options{ + PIDFile: s.pidFile, + }) + if err != nil { + panic(err) } + defer upg.Stop() if s.configPath == "" { logger.Logger.Fatal("Config flag is missing") @@ -62,34 +75,35 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) return subcommands.ExitFailure } - var config server.Conf - err = json.NewDecoder(openConf).Decode(&config) + var config conf.Conf + err = yaml.NewDecoder(openConf).Decode(&config) if err != nil { logger.Logger.Fatal("Invalid config file: ", err) return subcommands.ExitFailure } + if config.Kid == "" { + logger.Logger.Fatal("Invalid kid value") + } + configPathAbs, err := filepath.Abs(s.configPath) if err != nil { - logger.Logger.Fatal("Failed to get absolute config path") + logger.Logger.Fatal("Failed to get absolute config path", "err", err) } wd := filepath.Dir(configPathAbs) - keyDir := filepath.Join(wd, "keys") + // load the keystore private and public keys + keyDir := filepath.Join(wd, "keystore") err = os.MkdirAll(keyDir, 0700) if err != nil { - logger.Logger.Fatal("Failed to create keys dir", "err", err) + logger.Logger.Fatal("Failed to create keystore dir", "err", err) } - keyStore, err := mjwt.NewKeyStoreFromDir(afero.NewBasePathFs(afero.NewOsFs(), keyDir)) + keystore, err := mjwt.NewKeyStoreFromDir(afero.NewBasePathFs(afero.NewOsFs(), keyDir)) if err != nil { logger.Logger.Fatal("Failed to load MJWT keystore", "err", err) } - if config.Kid == "" { - logger.Logger.Fatal("Invalid kid value") - } - - signingKey, err := mjwt.NewIssuerWithKeyStore(config.Issuer, config.Kid, jwt.SigningMethodRS512, keyStore) + signingKey, err := mjwt.NewIssuerWithKeyStore(config.Issuer, config.Kid, jwt.SigningMethodRS512, keystore) if err != nil { logger.Logger.Fatal("Failed to load or create MJWT issuer", "err", err) } @@ -103,14 +117,52 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) logger.Logger.Fatal("Failed to load page templates:", err) } - srv := server.NewHttpServer(config, db, signingKey) - logger.Logger.Info("Starting server", "addr", srv.Addr) - go utils.RunBackgroundHttp(logger.Logger, srv) + ln, err := upg.Listen("tcp", config.Listen) + if err != nil { + logger.Logger.Fatal("Listen failed", "err", err) + } - exitReload.ExitReload("Lavender", func() {}, func() { - // stop http server - _ = srv.Close() + mux := server.NewHttpServer(config, db, signingKey) + srv := &http.Server{ + Handler: mux, + ReadTimeout: time.Minute, + ReadHeaderTimeout: time.Minute, + WriteTimeout: time.Minute, + IdleTimeout: time.Minute, + MaxHeaderBytes: 2500, + } + logger.Logger.Info("Starting server", "addr", config.Listen) + go func() { + err := srv.Serve(ln) + if err != nil { + logger.Logger.Error("Failed to start API server", "err", err) + } + }() + + // Do an upgrade on SIGHUP + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGHUP) + for range sig { + err := upg.Upgrade() + if err != nil { + logger.Logger.Error("Failed upgrade", "err", err) + } + } + }() + + logger.Logger.Info("Ready") + if err := upg.Ready(); err != nil { + panic(err) + } + <-upg.Exit() + + time.AfterFunc(30*time.Second, func() { + logger.Logger.Warn("Graceful shutdown timed out") + os.Exit(1) }) + _ = srv.Shutdown(context.Background()) + return subcommands.ExitSuccess } diff --git a/conf/conf.go b/conf/conf.go new file mode 100644 index 0000000..f50b354 --- /dev/null +++ b/conf/conf.go @@ -0,0 +1,14 @@ +package conf + +import ( + "github.com/1f349/lavender/issuer" +) + +type Conf struct { + Listen string `yaml:"listen"` + BaseUrl string `yaml:"baseUrl"` + ServiceName string `yaml:"serviceName"` + Issuer string `yaml:"issuer"` + Kid string `yaml:"kid"` + SsoServices []issuer.SsoConfig `yaml:"ssoServices"` +} diff --git a/go.mod b/go.mod index 83007cf..00a2837 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/1f349/lavender -go 1.22 +go 1.23.0 require ( github.com/1f349/cache v0.0.3 - github.com/1f349/mjwt v0.4.0 + github.com/1f349/mjwt v0.4.1 github.com/1f349/overlapfs v0.0.1 - github.com/1f349/violet v0.0.14 github.com/charmbracelet/log v0.4.0 + github.com/cloudflare/tableflip v1.2.3 github.com/go-oauth2/oauth2/v4 v4.5.2 github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-migrate/migrate/v4 v4.17.1 @@ -15,10 +15,10 @@ require ( github.com/google/uuid v1.6.0 github.com/julienschmidt/httprouter v1.3.0 github.com/mattn/go-sqlite3 v1.14.22 - github.com/mrmelon54/exit-reload v0.0.2 github.com/spf13/afero v1.11.0 github.com/stretchr/testify v1.9.0 - golang.org/x/oauth2 v0.21.0 + golang.org/x/oauth2 v0.22.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -26,14 +26,16 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/becheran/wildmatch-go v1.0.0 // indirect github.com/charmbracelet/lipgloss v0.12.1 // indirect - github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/charmbracelet/x/ansi v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/klauspost/compress v1.17.9 // 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 @@ -44,18 +46,17 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/tidwall/btree v1.7.0 // indirect github.com/tidwall/buntdb v1.3.1 // indirect - github.com/tidwall/gjson v1.17.1 // indirect + github.com/tidwall/gjson v1.17.3 // indirect github.com/tidwall/grect v0.1.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect golang.org/x/net v0.27.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.17.0 // indirect ) diff --git a/go.sum b/go.sum index d41f1f0..2fbd25d 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,12 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/1f349/cache v0.0.3 h1:7WCc0cSiJ3+wdYLUxGGUJLrc9iMn4ntp1Ot7k1AO2YM= github.com/1f349/cache v0.0.3/go.mod h1:IbqRc1A09wfP1kkMBW7Kce+oIA55kIECWx0cvUPCv7o= -github.com/1f349/mjwt v0.4.0 h1:A7RdHqY63+ElFaGC7144v85Vh41+pZH4xgGwhxvfhCo= -github.com/1f349/mjwt v0.4.0/go.mod h1:qwnzokkqc7Z9YmKA1m9beI3OZL1GvGYHOQU2rOwoV1M= +github.com/1f349/mjwt v0.4.1 h1:ooCroMMw2kcL5c9L3sLbdtxI0H4/QC8RfTxiloKr+4Y= +github.com/1f349/mjwt v0.4.1/go.mod h1:qwnzokkqc7Z9YmKA1m9beI3OZL1GvGYHOQU2rOwoV1M= github.com/1f349/overlapfs v0.0.1 h1:LAxBolrXFAgU0yqZtXg/C/aaPq3eoQSPpBc49BHuTp0= github.com/1f349/overlapfs v0.0.1/go.mod h1:I6aItQycr7nrzplmfNXp/QF9tTmKRSgY3fXmu/7Ky2o= github.com/1f349/rsa-helper v0.0.2 h1:N/fLQqg5wrjIzG6G4zdwa5Xcv9/jIPutCls9YekZr9U= github.com/1f349/rsa-helper v0.0.2/go.mod h1:VUQ++1tYYhYrXeOmVFkQ82BegR24HQEJHl5lHbjg7yg= -github.com/1f349/violet v0.0.14 h1:MpBZ4n1dJjdiIwYMTfh0PBIFll3kjqowxR6DLasafqE= -github.com/1f349/violet v0.0.14/go.mod h1:iAREhm+wxnGXkmuvmBhOuhUx2T7/5w7stLYNgQGbqC8= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= @@ -21,8 +19,11 @@ github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/N github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= 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.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= -github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +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/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= @@ -110,8 +111,6 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= -github.com/mrmelon54/exit-reload v0.0.2 h1:vqgfrMD/bF21HkDsWgg5+NLjFDrD3KGVEN/iTrMn9Ms= -github.com/mrmelon54/exit-reload v0.0.2/go.mod h1:aE3NhsqGMLUqmv6cJZRouC/8gXkZTvVSabRGOpI+Vjc= github.com/mrmelon54/rescheduler v0.0.3 h1:TrkJL6S7PKvXuo1mvdgRgsILA/pk5L1lrXhV/q7IEzQ= github.com/mrmelon54/rescheduler v0.0.3/go.mod h1:q415n6W1xcePPP5Rix6FOiADgcN66BYMyNOsFnNyoWQ= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= @@ -156,8 +155,8 @@ 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/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.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.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/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg= github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= @@ -198,10 +197,10 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 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-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +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/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/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -213,12 +212,12 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -229,21 +228,22 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/server/conf.go b/server/conf.go deleted file mode 100644 index a5ef52b..0000000 --- a/server/conf.go +++ /dev/null @@ -1,14 +0,0 @@ -package server - -import ( - "github.com/1f349/lavender/issuer" -) - -type Conf struct { - Listen string `json:"listen"` - BaseUrl string `json:"base_url"` - ServiceName string `json:"service_name"` - Issuer string `json:"issuer"` - Kid string `json:"kid"` - SsoServices []issuer.SsoConfig `json:"sso_services"` -} diff --git a/server/server.go b/server/server.go index 989f7b2..e989543 100644 --- a/server/server.go +++ b/server/server.go @@ -6,6 +6,7 @@ import ( "encoding/json" "github.com/1f349/cache" clientStore "github.com/1f349/lavender/client-store" + "github.com/1f349/lavender/conf" "github.com/1f349/lavender/database" "github.com/1f349/lavender/issuer" "github.com/1f349/lavender/logger" @@ -32,7 +33,7 @@ type HttpServer struct { oauthSrv *server.Server oauthMgr *manage.Manager db *database.Queries - conf Conf + conf conf.Conf signingKey *mjwt.Issuer manager *issuer.Manager flowState *cache.Cache[string, flowStateData] @@ -44,19 +45,19 @@ type flowStateData struct { redirect string } -func NewHttpServer(conf Conf, db *database.Queries, signingKey *mjwt.Issuer) *http.Server { +func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issuer) *httprouter.Router { r := httprouter.New() contentCache := time.Now() // remove last slash from baseUrl { - l := len(conf.BaseUrl) - if conf.BaseUrl[l-1] == '/' { - conf.BaseUrl = conf.BaseUrl[:l-1] + l := len(config.BaseUrl) + if config.BaseUrl[l-1] == '/' { + config.BaseUrl = config.BaseUrl[:l-1] } } - openIdConf := openid.GenConfig(conf.BaseUrl, []string{"openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"}) + openIdConf := openid.GenConfig(config.BaseUrl, []string{"openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"}) openIdBytes, err := json.Marshal(openIdConf) if err != nil { logger.Logger.Fatal("Failed to generate OpenID configuration", "err", err) @@ -75,12 +76,12 @@ func NewHttpServer(conf Conf, db *database.Queries, signingKey *mjwt.Issuer) *ht oauthSrv: oauthSrv, oauthMgr: oauthManager, db: db, - conf: conf, + conf: config, signingKey: signingKey, flowState: cache.New[string, flowStateData](), } - hs.manager, err = issuer.NewManager(conf.SsoServices) + hs.manager, err = issuer.NewManager(config.SsoServices) if err != nil { logger.Logger.Fatal("Failed to reload SSO service manager", "err", err) } @@ -267,15 +268,7 @@ func NewHttpServer(conf Conf, db *database.Queries, signingKey *mjwt.Issuer) *ht r.GET("/userinfo", userInfoRequest) r.OPTIONS("/userinfo", userInfoRequest) - return &http.Server{ - Addr: conf.Listen, - Handler: r, - ReadTimeout: time.Minute, - ReadHeaderTimeout: time.Minute, - WriteTimeout: time.Minute, - IdleTimeout: time.Minute, - MaxHeaderBytes: 2500, - } + return r } func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) { From a81aa0458a40e7b1c0e4615bbc847cefd738d3c8 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Mon, 19 Aug 2024 22:37:30 +0100 Subject: [PATCH 02/10] Start merging lavender and tulip --- conf/conf.go | 3 + go.mod | 12 ++- go.sum | 39 +++++++- lists/locales.go | 104 +++++++++++++++++++++ lists/locales_test.go | 15 +++ lists/zoneinfo.go | 53 +++++++++++ lists/zoneinfo_test.go | 15 +++ mail/from-address.go | 26 ++++++ mail/mail.go | 96 +++++++++++++++++++ mail/send-template.go | 18 ++++ mail/templates/mail-account-delete.go.html | 10 ++ mail/templates/mail-account-delete.go.txt | 10 ++ mail/templates/mail-register-admin.go.html | 11 +++ mail/templates/mail-register-admin.go.txt | 12 +++ mail/templates/mail-reset-password.go.html | 9 ++ mail/templates/mail-reset-password.go.txt | 8 ++ mail/templates/mail-verify.go.html | 10 ++ mail/templates/mail-verify.go.txt | 10 ++ mail/templates/templates.go | 55 +++++++++++ password/password.go | 17 ++++ 20 files changed, 525 insertions(+), 8 deletions(-) create mode 100644 lists/locales.go create mode 100644 lists/locales_test.go create mode 100644 lists/zoneinfo.go create mode 100644 lists/zoneinfo_test.go create mode 100644 mail/from-address.go create mode 100644 mail/mail.go create mode 100644 mail/send-template.go create mode 100644 mail/templates/mail-account-delete.go.html create mode 100644 mail/templates/mail-account-delete.go.txt create mode 100644 mail/templates/mail-register-admin.go.html create mode 100644 mail/templates/mail-register-admin.go.txt create mode 100644 mail/templates/mail-reset-password.go.html create mode 100644 mail/templates/mail-reset-password.go.txt create mode 100644 mail/templates/mail-verify.go.html create mode 100644 mail/templates/mail-verify.go.txt create mode 100644 mail/templates/templates.go create mode 100644 password/password.go diff --git a/conf/conf.go b/conf/conf.go index f50b354..fd8f314 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -2,6 +2,7 @@ package conf import ( "github.com/1f349/lavender/issuer" + "github.com/1f349/lavender/mail" ) type Conf struct { @@ -10,5 +11,7 @@ type Conf struct { ServiceName string `yaml:"serviceName"` Issuer string `yaml:"issuer"` Kid string `yaml:"kid"` + Namespace string `yaml:"namespace"` + Mail mail.Mail `yaml:"mail"` SsoServices []issuer.SsoConfig `yaml:"ssoServices"` } diff --git a/go.mod b/go.mod index 00a2837..2681e02 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,12 @@ require ( github.com/1f349/cache v0.0.3 github.com/1f349/mjwt v0.4.1 github.com/1f349/overlapfs v0.0.1 + github.com/1f349/tulip v0.0.0-20240725211619-6b19e2d4ca63 github.com/charmbracelet/log v0.4.0 github.com/cloudflare/tableflip v1.2.3 + github.com/emersion/go-message v0.18.1 + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 + github.com/emersion/go-smtp v0.21.3 github.com/go-oauth2/oauth2/v4 v4.5.2 github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-migrate/migrate/v4 v4.17.1 @@ -17,7 +21,9 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 github.com/spf13/afero v1.11.0 github.com/stretchr/testify v1.9.0 + golang.org/x/crypto v0.26.0 golang.org/x/oauth2 v0.22.0 + golang.org/x/text v0.17.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -31,7 +37,7 @@ require ( github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect - github.com/gorilla/websocket v1.5.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/klauspost/compress v1.17.9 // indirect @@ -53,10 +59,8 @@ require ( github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.26.0 // indirect golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect - golang.org/x/net v0.27.0 // indirect + golang.org/x/net v0.28.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.24.0 // indirect - golang.org/x/text v0.17.0 // indirect ) diff --git a/go.sum b/go.sum index 2fbd25d..a260d02 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/1f349/overlapfs v0.0.1 h1:LAxBolrXFAgU0yqZtXg/C/aaPq3eoQSPpBc49BHuTp0 github.com/1f349/overlapfs v0.0.1/go.mod h1:I6aItQycr7nrzplmfNXp/QF9tTmKRSgY3fXmu/7Ky2o= github.com/1f349/rsa-helper v0.0.2 h1:N/fLQqg5wrjIzG6G4zdwa5Xcv9/jIPutCls9YekZr9U= github.com/1f349/rsa-helper v0.0.2/go.mod h1:VUQ++1tYYhYrXeOmVFkQ82BegR24HQEJHl5lHbjg7yg= +github.com/1f349/tulip v0.0.0-20240725211619-6b19e2d4ca63 h1:jPg+0bgKD5kY7yQtRZqeba+BGKFE51evGvwewZwa7Xc= +github.com/1f349/tulip v0.0.0-20240725211619-6b19e2d4ca63/go.mod h1:1zFQhcbgiyPSWHVMp0cXJjmd6FhasP5bf5tWS4ZK61A= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= @@ -27,6 +29,13 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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= +github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= +github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= +github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= @@ -72,8 +81,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -193,29 +202,40 @@ github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FB github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 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/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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +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/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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -233,19 +253,30 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/lists/locales.go b/lists/locales.go new file mode 100644 index 0000000..abf8fdc --- /dev/null +++ b/lists/locales.go @@ -0,0 +1,104 @@ +package lists + +import ( + "golang.org/x/text/language" + "golang.org/x/text/language/display" + "sync" +) + +var ( + localeOnce sync.Once + localeNames []struct{ Value, Label string } +) + +func ListLocale() []struct{ Value, Label string } { + localeOnce.Do(func() { + localeNames = make([]struct{ Value, Label string }, len(localeList)) + for i := range localeList { + localeNames[i] = struct{ Value, Label string }{Value: localeList[i].String(), Label: display.Self.Name(localeList[i])} + } + }) + return localeNames +} + +var localeList = []language.Tag{ + language.Afrikaans, + language.Amharic, + language.Arabic, + language.ModernStandardArabic, + language.Azerbaijani, + language.Bulgarian, + language.Bengali, + language.Catalan, + language.Czech, + language.Danish, + language.German, + language.Greek, + language.English, + language.AmericanEnglish, + language.BritishEnglish, + language.Spanish, + language.EuropeanSpanish, + language.LatinAmericanSpanish, + language.Estonian, + language.Persian, + language.Finnish, + language.Filipino, + language.French, + language.CanadianFrench, + language.Gujarati, + language.Hebrew, + language.Hindi, + language.Croatian, + language.Hungarian, + language.Armenian, + language.Indonesian, + language.Icelandic, + language.Italian, + language.Japanese, + language.Georgian, + language.Kazakh, + language.Khmer, + language.Kannada, + language.Korean, + language.Kirghiz, + language.Lao, + language.Lithuanian, + language.Latvian, + language.Macedonian, + language.Malayalam, + language.Mongolian, + language.Marathi, + language.Malay, + language.Burmese, + language.Nepali, + language.Dutch, + language.Norwegian, + language.Punjabi, + language.Polish, + language.Portuguese, + language.BrazilianPortuguese, + language.EuropeanPortuguese, + language.Romanian, + language.Russian, + language.Sinhala, + language.Slovak, + language.Slovenian, + language.Albanian, + language.Serbian, + language.SerbianLatin, + language.Swedish, + language.Swahili, + language.Tamil, + language.Telugu, + language.Thai, + language.Turkish, + language.Ukrainian, + language.Urdu, + language.Uzbek, + language.Vietnamese, + language.Chinese, + language.SimplifiedChinese, + language.TraditionalChinese, + language.Zulu, +} diff --git a/lists/locales_test.go b/lists/locales_test.go new file mode 100644 index 0000000..d97a3d1 --- /dev/null +++ b/lists/locales_test.go @@ -0,0 +1,15 @@ +package lists + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestListLocale(t *testing.T) { + locales := ListLocale() + assert.True(t, len(locales) > 4) + assert.Equal(t, struct{ Value, Label string }{Value: "af", Label: "Afrikaans"}, locales[0]) + assert.Equal(t, struct{ Value, Label string }{Value: "am", Label: "አማርኛ"}, locales[1]) + assert.Equal(t, struct{ Value, Label string }{Value: "zh-Hant", Label: "繁體中文"}, locales[len(locales)-2]) + assert.Equal(t, struct{ Value, Label string }{Value: "zu", Label: "isiZulu"}, locales[len(locales)-1]) +} diff --git a/lists/zoneinfo.go b/lists/zoneinfo.go new file mode 100644 index 0000000..96d378b --- /dev/null +++ b/lists/zoneinfo.go @@ -0,0 +1,53 @@ +package lists + +import ( + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +var ( + zoneDirs = []string{ + // Update path according to your OS + "/usr/share/zoneinfo/", + "/usr/share/lib/zoneinfo/", + "/usr/lib/locale/TZ/", + } + zoneInfoOnce sync.Once + zoneNames []string +) + +func ListZoneInfo() []string { + zoneInfoOnce.Do(func() { + zoneNames = make([]string, 0) + for _, zoneDir := range zoneDirs { + zoneNames = append(zoneNames, FindTimeZoneFiles(zoneDir)...) + } + sort.Strings(zoneNames) + }) + return zoneNames +} + +func FindTimeZoneFiles(zoneDir string) []string { + dArr := make([]string, 0) + dArr = append(dArr, "") + arr := make([]string, 0) + + for i := 0; i < len(dArr); i++ { + dir := dArr[i] + files, _ := os.ReadDir(filepath.Join(zoneDir, dir)) + for _, f := range files { + if f.Name() != strings.ToUpper(f.Name()[:1])+f.Name()[1:] { + continue + } + if f.IsDir() { + dArr = append(dArr, filepath.Join(dir, f.Name())) + } else { + arr = append(arr, filepath.Join(dir, f.Name())) + } + } + } + return arr +} diff --git a/lists/zoneinfo_test.go b/lists/zoneinfo_test.go new file mode 100644 index 0000000..83e594b --- /dev/null +++ b/lists/zoneinfo_test.go @@ -0,0 +1,15 @@ +package lists + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestListZoneInfo(t *testing.T) { + zoneinfos := ListZoneInfo() + assert.True(t, len(zoneinfos) > 4) + assert.Equal(t, "Africa/Abidjan", zoneinfos[0]) + assert.Equal(t, "Africa/Accra", zoneinfos[1]) + assert.Equal(t, "WET", zoneinfos[len(zoneinfos)-2]) + assert.Equal(t, "Zulu", zoneinfos[len(zoneinfos)-1]) +} diff --git a/mail/from-address.go b/mail/from-address.go new file mode 100644 index 0000000..e52f5f8 --- /dev/null +++ b/mail/from-address.go @@ -0,0 +1,26 @@ +package mail + +import ( + "encoding/json" + "github.com/emersion/go-message/mail" +) + +type FromAddress struct { + *mail.Address +} + +var _ json.Unmarshaler = &FromAddress{} + +func (f *FromAddress) UnmarshalJSON(b []byte) error { + var a string + err := json.Unmarshal(b, &a) + if err != nil { + return err + } + address, err := mail.ParseAddress(a) + if err != nil { + return err + } + f.Address = address + return nil +} diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 0000000..8403664 --- /dev/null +++ b/mail/mail.go @@ -0,0 +1,96 @@ +package mail + +import ( + "bytes" + "github.com/emersion/go-message/mail" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + "io" + "net" + "time" +) + +type Mail struct { + Name string `json:"name"` + Tls bool `json:"tls"` + Server string `json:"server"` + From FromAddress `json:"from"` + Username string `json:"username"` + Password string `json:"password"` +} + +func (m *Mail) loginInfo() sasl.Client { + return sasl.NewPlainClient("", m.Username, m.Password) +} + +func (m *Mail) mailCall(to []string, r io.Reader) error { + host, _, err := net.SplitHostPort(m.Server) + if err != nil { + return err + } + if m.Tls { + return smtp.SendMailTLS(m.Server, m.loginInfo(), m.From.String(), to, r) + } + if host == "localhost" || host == "127.0.0.1" { + // internals of smtp.SendMail without STARTTLS for localhost testing + dial, err := smtp.Dial(m.Server) + if err != nil { + return err + } + err = dial.Auth(m.loginInfo()) + if err != nil { + return err + } + return dial.SendMail(m.From.String(), to, r) + } + return smtp.SendMail(m.Server, m.loginInfo(), m.From.String(), to, r) +} + +func (m *Mail) SendMail(subject string, to []*mail.Address, htmlBody, textBody io.Reader) error { + // generate the email in this template + buf := new(bytes.Buffer) + + // setup mail headers + var h mail.Header + h.SetDate(time.Now()) + h.SetSubject(subject) + h.SetAddressList("From", []*mail.Address{m.From.Address}) + h.SetAddressList("To", to) + h.Set("Content-Type", "multipart/alternative") + + // setup html and text alternative headers + var hHtml, hTxt mail.InlineHeader + hHtml.Set("Content-Type", "text/html; charset=utf-8") + hTxt.Set("Content-Type", "text/plain; charset=utf-8") + + createWriter, err := mail.CreateWriter(buf, h) + if err != nil { + return err + } + inline, err := createWriter.CreateInline() + if err != nil { + return err + } + partHtml, err := inline.CreatePart(hHtml) + if err != nil { + return err + } + if _, err := io.Copy(partHtml, htmlBody); err != nil { + return err + } + partTxt, err := inline.CreatePart(hTxt) + if err != nil { + return err + } + if _, err := io.Copy(partTxt, textBody); err != nil { + return err + } + + // convert all to addresses to strings + toStr := make([]string, len(to)) + for i := range toStr { + toStr[i] = to[i].String() + } + + return m.mailCall(toStr, buf) +} diff --git a/mail/send-template.go b/mail/send-template.go new file mode 100644 index 0000000..5f2c22f --- /dev/null +++ b/mail/send-template.go @@ -0,0 +1,18 @@ +package mail + +import ( + "bytes" + "fmt" + "github.com/1f349/lavender/mail/templates" + "github.com/emersion/go-message/mail" +) + +func (m *Mail) SendEmailTemplate(templateName, subject, nameOfUser string, to *mail.Address, data map[string]any) error { + var bufHtml, bufTxt bytes.Buffer + templates.RenderMailTemplate(&bufHtml, &bufTxt, templateName, map[string]any{ + "ServiceName": m.Name, + "Name": nameOfUser, + "Data": data, + }) + return m.SendMail(fmt.Sprintf("%s - %s", subject, m.Name), []*mail.Address{to}, &bufHtml, &bufTxt) +} diff --git a/mail/templates/mail-account-delete.go.html b/mail/templates/mail-account-delete.go.html new file mode 100644 index 0000000..1b795d2 --- /dev/null +++ b/mail/templates/mail-account-delete.go.html @@ -0,0 +1,10 @@ + + + +

Hello, {{.Name}}

+

Your account with {{.ServiceName}} has been disabled and marked for deletion.

+

Your account will be fully deleted within 48-hours.

+

You will no longer receive emails from {{.ServiceName}}, unless your email address is used to set up an account.

+

Regards,
{{.ServiceName}}

+ + diff --git a/mail/templates/mail-account-delete.go.txt b/mail/templates/mail-account-delete.go.txt new file mode 100644 index 0000000..6fed9ed --- /dev/null +++ b/mail/templates/mail-account-delete.go.txt @@ -0,0 +1,10 @@ +Hello, {{.Name}} + +Your account with {{.ServiceName}} has been disabled and marked for deletion. + +Your account will be fully deleted within 48-hours. + +You will no longer receive emails from {{.ServiceName}}, unless your email address is used to set up an account. + +Regards, +{{.ServiceName}} diff --git a/mail/templates/mail-register-admin.go.html b/mail/templates/mail-register-admin.go.html new file mode 100644 index 0000000..62480d0 --- /dev/null +++ b/mail/templates/mail-register-admin.go.html @@ -0,0 +1,11 @@ + + + +

Hello, {{.Name}}

+

Your email address has been registered with {{.ServiceName}} by an administrator.

+

Please open this link to verify your email address and register your account

+

If you did not wish to register for {{.ServiceName}}, then your email has probably been used by mistake.

+

If the link above is not used to register an account, then no further contact will be made from {{.ServiceName}} and your email address will be deleted from our systems within a 48-hour period.

+

Regards,
{{.ServiceName}}

+ + diff --git a/mail/templates/mail-register-admin.go.txt b/mail/templates/mail-register-admin.go.txt new file mode 100644 index 0000000..d614035 --- /dev/null +++ b/mail/templates/mail-register-admin.go.txt @@ -0,0 +1,12 @@ +Hello, {{.Name}} + +Your email address has been registered with {{.ServiceName}} by an administrator. + +Please open this link to verify your email address and register your account {{.Data.RegisterUrl}} + +If you did not wish to register for {{.ServiceName}}, then your email has probably been used by mistake. + +If the link above is not used to register an account, then no further contact will be made from {{.ServiceName}} and your email address will be deleted from our systems within a 48-hour period. + +Regards, +{{.ServiceName}} diff --git a/mail/templates/mail-reset-password.go.html b/mail/templates/mail-reset-password.go.html new file mode 100644 index 0000000..b511045 --- /dev/null +++ b/mail/templates/mail-reset-password.go.html @@ -0,0 +1,9 @@ + + + +

Hello, {{.Name}}

+

Please open this link to reset your password

+

This link is valid for 10 minutes.

+

Regards,
{{.ServiceName}}

+ + diff --git a/mail/templates/mail-reset-password.go.txt b/mail/templates/mail-reset-password.go.txt new file mode 100644 index 0000000..da735dd --- /dev/null +++ b/mail/templates/mail-reset-password.go.txt @@ -0,0 +1,8 @@ +Hello, {{.Name}} + +Please open this link to reset your password: {{.Data.ResetUrl}} + +This link is valid for 10 minutes. + +Regards, +{{.ServiceName}} diff --git a/mail/templates/mail-verify.go.html b/mail/templates/mail-verify.go.html new file mode 100644 index 0000000..d4fc337 --- /dev/null +++ b/mail/templates/mail-verify.go.html @@ -0,0 +1,10 @@ + + + +

Hello, {{.Name}}

+

Please open this link to verify your email address

+

This link is valid for 10 minutes.

+

If you did not create an account with {{.ServiceName}} then please ignore this email and the account will be deleted within a 48-hour period.

+

Regards,
{{.ServiceName}}

+ + diff --git a/mail/templates/mail-verify.go.txt b/mail/templates/mail-verify.go.txt new file mode 100644 index 0000000..e8ddaa6 --- /dev/null +++ b/mail/templates/mail-verify.go.txt @@ -0,0 +1,10 @@ +Hello, {{.Name}} + +Please open this link to verify your email address: {{.Data.VerifyUrl}} + +This link is valid for 10 minutes. + +If you did not create an account with {{.ServiceName}} then please ignore this email and the account will be deleted within a 48-hour period. + +Regards, +{{.ServiceName}} diff --git a/mail/templates/templates.go b/mail/templates/templates.go new file mode 100644 index 0000000..ed82df3 --- /dev/null +++ b/mail/templates/templates.go @@ -0,0 +1,55 @@ +package templates + +import ( + "embed" + "errors" + "github.com/1f349/overlapfs" + "github.com/1f349/tulip/logger" + htmlTemplate "html/template" + "io" + "io/fs" + "os" + "path/filepath" + "sync" + textTemplate "text/template" +) + +var ( + //go:embed *.go.html *.go.txt + embeddedTemplates embed.FS + mailHtmlTemplates *htmlTemplate.Template + mailTextTemplates *textTemplate.Template + loadOnce sync.Once +) + +func LoadMailTemplates(wd string) (err error) { + loadOnce.Do(func() { + var o fs.FS = embeddedTemplates + if wd != "" { + mailDir := filepath.Join(wd, "mail-templates") + err = os.Mkdir(mailDir, os.ModePerm) + if err != nil && !errors.Is(err, os.ErrExist) { + return + } + wdFs := os.DirFS(mailDir) + o = overlapfs.OverlapFS{A: embeddedTemplates, B: wdFs} + } + mailHtmlTemplates, err = htmlTemplate.New("mail").ParseFS(o, "*.go.html") + if err != nil { + return + } + mailTextTemplates, err = textTemplate.New("mail").ParseFS(o, "*.go.txt") + }) + return +} + +func RenderMailTemplate(wrHtml, wrTxt io.Writer, name string, data any) { + err := mailHtmlTemplates.ExecuteTemplate(wrHtml, name+".go.html", data) + if err != nil { + logger.Logger.Warn("Failed to render mail html", "name", name, "err", err) + } + err = mailTextTemplates.ExecuteTemplate(wrTxt, name+".go.txt", data) + if err != nil { + logger.Logger.Warn("Failed to render mail text", "name", name, "err", err) + } +} diff --git a/password/password.go b/password/password.go new file mode 100644 index 0000000..d7f57e6 --- /dev/null +++ b/password/password.go @@ -0,0 +1,17 @@ +package password + +import ( + "golang.org/x/crypto/bcrypt" +) + +// HashString is used to represent a string containing a password hash +type HashString string + +func HashPassword(password string) (HashString, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + return HashString(bytes), err +} + +func CheckPasswordHash(hash HashString, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} From 33c7ac9b0689deb291b21f3f1175511a551e988e Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Thu, 29 Aug 2024 17:57:31 +0100 Subject: [PATCH 03/10] some stuff --- README.md | 6 ++- conf/conf.go | 16 +++---- config.example.yml | 47 ++++++++++++++++++ .../20240820202502_merge-auth.down.sql | 0 .../20240820202502_merge-auth.up.sql | 0 database/types/userlocale.go | 46 ++++++++++++++++++ database/types/userlocale_test.go | 12 +++++ database/types/userpronoun.go | 46 ++++++++++++++++++ database/types/userpronoun_test.go | 15 ++++++ database/types/userrole.go | 27 +++++++++++ database/types/userzone.go | 48 +++++++++++++++++++ database/types/userzone_test.go | 14 ++++++ database/types/utils_test.go | 11 +++++ go.mod | 1 + go.sum | 2 + issuer/manager.go | 12 ++--- issuer/sso.go | 5 +- sqlc.yaml | 13 +++++ 18 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 config.example.yml create mode 100644 database/migrations/20240820202502_merge-auth.down.sql create mode 100644 database/migrations/20240820202502_merge-auth.up.sql create mode 100644 database/types/userlocale.go create mode 100644 database/types/userlocale_test.go create mode 100644 database/types/userpronoun.go create mode 100644 database/types/userpronoun_test.go create mode 100644 database/types/userrole.go create mode 100644 database/types/userzone.go create mode 100644 database/types/userzone_test.go create mode 100644 database/types/utils_test.go diff --git a/README.md b/README.md index 31470c8..94f72f2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # Lavender -An authentication source for multiple login services to be used with a frontend single page application. +A login service with OpenID, OAuth2 and SSO support. + +Login via third-party services. + +Enables easy use of a single authentication source for a network of services. diff --git a/conf/conf.go b/conf/conf.go index fd8f314..28d17a3 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -6,12 +6,12 @@ import ( ) type Conf struct { - Listen string `yaml:"listen"` - BaseUrl string `yaml:"baseUrl"` - ServiceName string `yaml:"serviceName"` - Issuer string `yaml:"issuer"` - Kid string `yaml:"kid"` - Namespace string `yaml:"namespace"` - Mail mail.Mail `yaml:"mail"` - SsoServices []issuer.SsoConfig `yaml:"ssoServices"` + Listen string `yaml:"listen"` + BaseUrl string `yaml:"baseUrl"` + ServiceName string `yaml:"serviceName"` + Issuer string `yaml:"issuer"` + Kid string `yaml:"kid"` + Namespace string `yaml:"namespace"` + Mail mail.Mail `yaml:"mail"` + SsoServices map[string]issuer.SsoConfig `yaml:"ssoServices"` } diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..f410a1b --- /dev/null +++ b/config.example.yml @@ -0,0 +1,47 @@ +# address to listen on +listen: ':9090' + +# url for absolute links to the login service +baseUrl: 'http://localhost:9090' + +# human-readable service name +serviceName: 'Example Login' + +# name of the login issuer +issuer: 'id.example.com' + +# id of the private key in the keystore +kid: 'fdd2eb6d-b469-44c8-b15b-495bcf34dae4' + +# defines the domain part of login name `user@example.com` +namespace: 'example.com' + +# configure automated emails +mail: + name: 'Example Login' + tls: true + server: 'smtp.example.com:465' + from: 'Example Login ' + username: 'noreply@id.example.com' + password: '#####' + +# enable local accounts +localLogin: true + +# configure SSO login services +ssoServices: + example.net: + addr: 'https://example.net' + client: + id: 'dcea4be8-dff4-49d2-a5e6-c1b202403714' + secret: '#####' + scopes: + - openid + - name + - username + - profile + - email + - birthdate + - age + - zoneinfo + - locale diff --git a/database/migrations/20240820202502_merge-auth.down.sql b/database/migrations/20240820202502_merge-auth.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/database/migrations/20240820202502_merge-auth.up.sql b/database/migrations/20240820202502_merge-auth.up.sql new file mode 100644 index 0000000..e69de29 diff --git a/database/types/userlocale.go b/database/types/userlocale.go new file mode 100644 index 0000000..34a73b2 --- /dev/null +++ b/database/types/userlocale.go @@ -0,0 +1,46 @@ +package types + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "golang.org/x/text/language" +) + +var ( + _ sql.Scanner = &UserLocale{} + _ driver.Valuer = &UserLocale{} + _ json.Marshaler = &UserLocale{} + _ json.Unmarshaler = &UserLocale{} +) + +type UserLocale struct{ language.Tag } + +func (l *UserLocale) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, l) + } + lang, err := language.Parse(s) + if err != nil { + return err + } + l.Tag = lang + return nil +} + +func (l UserLocale) Value() (driver.Value, error) { + return l.Tag.String(), nil +} + +func (l UserLocale) MarshalJSON() ([]byte, error) { return json.Marshal(l.Tag.String()) } + +func (l *UserLocale) UnmarshalJSON(bytes []byte) error { + var a string + err := json.Unmarshal(bytes, &a) + if err != nil { + return err + } + return l.Scan(a) +} diff --git a/database/types/userlocale_test.go b/database/types/userlocale_test.go new file mode 100644 index 0000000..cc53f80 --- /dev/null +++ b/database/types/userlocale_test.go @@ -0,0 +1,12 @@ +package types + +import ( + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + "testing" +) + +func TestUserLocale_MarshalJSON(t *testing.T) { + assert.Equal(t, "\"en-US\"", encode(UserLocale{language.AmericanEnglish})) + assert.Equal(t, "\"en-GB\"", encode(UserLocale{language.BritishEnglish})) +} diff --git a/database/types/userpronoun.go b/database/types/userpronoun.go new file mode 100644 index 0000000..d4068ce --- /dev/null +++ b/database/types/userpronoun.go @@ -0,0 +1,46 @@ +package types + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "github.com/mrmelon54/pronouns" +) + +var ( + _ sql.Scanner = &UserPronoun{} + _ driver.Valuer = &UserPronoun{} + _ json.Marshaler = &UserPronoun{} + _ json.Unmarshaler = &UserPronoun{} +) + +type UserPronoun struct{ pronouns.Pronoun } + +func (p *UserPronoun) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, p) + } + pro, err := pronouns.FindPronoun(s) + if err != nil { + return err + } + p.Pronoun = pro + return nil +} + +func (p UserPronoun) Value() (driver.Value, error) { + return p.Pronoun.String(), nil +} + +func (p UserPronoun) MarshalJSON() ([]byte, error) { return json.Marshal(p.Pronoun.String()) } + +func (p *UserPronoun) UnmarshalJSON(bytes []byte) error { + var a string + err := json.Unmarshal(bytes, &a) + if err != nil { + return err + } + return p.Scan(a) +} diff --git a/database/types/userpronoun_test.go b/database/types/userpronoun_test.go new file mode 100644 index 0000000..ace183d --- /dev/null +++ b/database/types/userpronoun_test.go @@ -0,0 +1,15 @@ +package types + +import ( + "github.com/mrmelon54/pronouns" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestUserPronoun_MarshalJSON(t *testing.T) { + assert.Equal(t, "\"they/them\"", encode(UserPronoun{pronouns.TheyThem})) + assert.Equal(t, "\"he/him\"", encode(UserPronoun{pronouns.HeHim})) + assert.Equal(t, "\"she/her\"", encode(UserPronoun{pronouns.SheHer})) + assert.Equal(t, "\"it/its\"", encode(UserPronoun{pronouns.ItIts})) + assert.Equal(t, "\"one/one's\"", encode(UserPronoun{pronouns.OneOnes})) +} diff --git a/database/types/userrole.go b/database/types/userrole.go new file mode 100644 index 0000000..85fadfd --- /dev/null +++ b/database/types/userrole.go @@ -0,0 +1,27 @@ +package types + +import "fmt" + +type UserRole int64 + +const ( + RoleMember UserRole = iota + RoleAdmin + RoleToDelete +) + +func (r UserRole) String() string { + switch r { + case RoleMember: + return "Member" + case RoleAdmin: + return "Admin" + case RoleToDelete: + return "ToDelete" + } + return fmt.Sprintf("UserRole{ %d }", r) +} + +func (r UserRole) IsValid() bool { + return r == RoleMember || r == RoleAdmin +} diff --git a/database/types/userzone.go b/database/types/userzone.go new file mode 100644 index 0000000..f203ad2 --- /dev/null +++ b/database/types/userzone.go @@ -0,0 +1,48 @@ +package types + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "time" +) + +var ( + _ sql.Scanner = &UserZone{} + _ driver.Valuer = &UserZone{} + _ json.Marshaler = &UserZone{} + _ json.Unmarshaler = &UserZone{} +) + +type UserZone struct{ *time.Location } + +func (l *UserZone) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, l) + } + loc, err := time.LoadLocation(s) + if err != nil { + return err + } + l.Location = loc + return nil +} + +func (l UserZone) Value() (driver.Value, error) { + return l.Location.String(), nil +} + +func (l UserZone) MarshalJSON() ([]byte, error) { + return json.Marshal(l.Location.String()) +} + +func (l *UserZone) UnmarshalJSON(bytes []byte) error { + var a string + err := json.Unmarshal(bytes, &a) + if err != nil { + return err + } + return l.Scan(a) +} diff --git a/database/types/userzone_test.go b/database/types/userzone_test.go new file mode 100644 index 0000000..a1f2ef5 --- /dev/null +++ b/database/types/userzone_test.go @@ -0,0 +1,14 @@ +package types + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestUserZone_MarshalJSON(t *testing.T) { + location, err := time.LoadLocation("Europe/London") + assert.NoError(t, err) + assert.Equal(t, "\"Europe/London\"", encode(UserZone{location})) + assert.Equal(t, "\"UTC\"", encode(UserZone{time.UTC})) +} diff --git a/database/types/utils_test.go b/database/types/utils_test.go new file mode 100644 index 0000000..9f56874 --- /dev/null +++ b/database/types/utils_test.go @@ -0,0 +1,11 @@ +package types + +import "encoding/json" + +func encode(data any) string { + j, err := json.Marshal(data) + if err != nil { + panic(err) + } + return string(j) +} diff --git a/go.mod b/go.mod index 2681e02..efb0d14 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/google/uuid v1.6.0 github.com/julienschmidt/httprouter v1.3.0 github.com/mattn/go-sqlite3 v1.14.22 + github.com/mrmelon54/pronouns v1.0.3 github.com/spf13/afero v1.11.0 github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.26.0 diff --git a/go.sum b/go.sum index a260d02..a362a74 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/mrmelon54/pronouns v1.0.3 h1:VJqOnNxIw44q0dRJrBEvOCkKPYGvPYcNRKwPtLildXg= +github.com/mrmelon54/pronouns v1.0.3/go.mod h1:VF6iGNf72tIokVE78GasPXvxlFUwGib7QFZOfpDNn18= github.com/mrmelon54/rescheduler v0.0.3 h1:TrkJL6S7PKvXuo1mvdgRgsILA/pk5L1lrXhV/q7IEzQ= github.com/mrmelon54/rescheduler v0.0.3/go.mod h1:q415n6W1xcePPP5Rix6FOiADgcN66BYMyNOsFnNyoWQ= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= diff --git a/issuer/manager.go b/issuer/manager.go index b37b8b1..87b74dc 100644 --- a/issuer/manager.go +++ b/issuer/manager.go @@ -12,20 +12,20 @@ type Manager struct { m map[string]*WellKnownOIDC } -func NewManager(services []SsoConfig) (*Manager, error) { +func NewManager(services map[string]SsoConfig) (*Manager, error) { l := &Manager{m: make(map[string]*WellKnownOIDC)} - for _, i := range services { - if !isValidNamespace.MatchString(i.Namespace) { - return nil, fmt.Errorf("invalid namespace: %s", i.Namespace) + for namespace, ssoService := range services { + if !isValidNamespace.MatchString(namespace) { + return nil, fmt.Errorf("invalid namespace: %s", namespace) } - conf, err := i.FetchConfig() + conf, err := ssoService.FetchConfig() if err != nil { return nil, err } // save by namespace - l.m[i.Namespace] = conf + l.m[namespace] = conf } return l, nil } diff --git a/issuer/sso.go b/issuer/sso.go index 5d7d521..4db6feb 100644 --- a/issuer/sso.go +++ b/issuer/sso.go @@ -17,9 +17,8 @@ var httpGet = http.Get // SsoConfig is the base URL for an OAUTH/OPENID/SSO login service // The path `/.well-known/openid-configuration` should be available type SsoConfig struct { - Addr utils.JsonUrl `json:"addr"` // https://login.example.com - Namespace string `json:"namespace"` // example.com - Client SsoConfigClient `json:"client"` + Addr utils.JsonUrl `json:"addr"` // https://login.example.com + Client SsoConfigClient `json:"client"` } type SsoConfigClient struct { diff --git a/sqlc.yaml b/sqlc.yaml index 7e08599..5ace449 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -8,3 +8,16 @@ sql: package: "database" out: "database" emit_json_tags: true + overrides: + - column: "users.password" + go_type: "github.com/1f349/tulip/password.HashString" + - column: "users.birthdate" + go_type: "github.com/hardfinhq/go-date.NullDate" + - column: "users.role" + go_type: "github.com/1f349/tulip/database/types.UserRole" + - column: "users.pronouns" + go_type: "github.com/1f349/tulip/database/types.UserPronoun" + - column: "users.zoneinfo" + go_type: "github.com/1f349/tulip/database/types.UserZone" + - column: "users.locale" + go_type: "github.com/1f349/tulip/database/types.UserLocale" From 51e33322d367a91d00e199c4d6776b548ce1266d Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Mon, 2 Sep 2024 22:54:03 +0100 Subject: [PATCH 04/10] Closer to lavender v2 --- Makefile | 10 + database/clientstore.go | 18 +- database/manage-oauth.sql.go | 95 +++---- database/manage-users.sql.go | 103 ++++++-- .../migrations/20240517171813_init.down.sql | 2 - .../migrations/20240517171813_init.up.sql | 27 -- .../migrations/20240820202502_init.down.sql | 5 + .../migrations/20240820202502_init.up.sql | 69 ++++++ .../20240820202502_merge-auth.down.sql | 0 .../20240820202502_merge-auth.up.sql | 0 database/models.go | 65 +++-- database/otp.sql.go | 81 ++++++ database/password-wrapper.go | 78 ++++++ database/profile-patch.go | 62 +++++ database/profiles.sql.go | 74 ++++++ database/queries/manage-oauth.sql | 17 +- database/queries/manage-users.sql | 24 +- database/queries/otp.sql | 23 ++ database/queries/profiles.sql | 16 ++ database/queries/users.sql | 53 ++-- database/users.sql.go | 233 ++++++++++-------- go.mod | 1 + go.sum | 2 + issuer/manager_test.go | 14 +- issuer/sso.go | 1 + server/auth.go | 2 +- server/home.go | 6 +- server/jwt.go | 7 +- server/roles.go | 21 +- sqlc.yaml | 10 +- 30 files changed, 823 insertions(+), 296 deletions(-) create mode 100644 Makefile delete mode 100644 database/migrations/20240517171813_init.down.sql delete mode 100644 database/migrations/20240517171813_init.up.sql create mode 100644 database/migrations/20240820202502_init.down.sql create mode 100644 database/migrations/20240820202502_init.up.sql delete mode 100644 database/migrations/20240820202502_merge-auth.down.sql delete mode 100644 database/migrations/20240820202502_merge-auth.up.sql create mode 100644 database/otp.sql.go create mode 100644 database/password-wrapper.go create mode 100644 database/profile-patch.go create mode 100644 database/profiles.sql.go create mode 100644 database/queries/otp.sql create mode 100644 database/queries/profiles.sql diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c767da2 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +SQL_SRC_DIR := database +SQL_FILES := $(wildcard $(SQL_SRC_DIR)/{migrations,queries}/*.sql) + +all: sqlc + +sqlc: $(SQL_FILES) + sqlc generate + +build: sqlc + go build ./cmd/lavender diff --git a/database/clientstore.go b/database/clientstore.go index a9a332a..4700693 100644 --- a/database/clientstore.go +++ b/database/clientstore.go @@ -1,6 +1,10 @@ package database -import "github.com/go-oauth2/oauth2/v4" +import ( + "bufio" + "github.com/go-oauth2/oauth2/v4" + "strings" +) var _ oauth2.ClientInfo = &ClientStore{} @@ -8,7 +12,7 @@ func (c *ClientStore) GetID() string { return c.Subject } func (c *ClientStore) GetSecret() string { return c.Secret } func (c *ClientStore) GetDomain() string { return c.Domain } func (c *ClientStore) IsPublic() bool { return c.Public } -func (c *ClientStore) GetUserID() string { return c.Owner } +func (c *ClientStore) GetUserID() string { return c.OwnerSubject } // GetName is an extra field for the oauth handler to display the application // name @@ -22,4 +26,12 @@ func (c *ClientStore) IsSSO() bool { return c.Sso } func (c *ClientStore) IsActive() bool { return c.Active } // UsePerms is an extra field for the userinfo handler to return user permissions matching the requested values -func (c *ClientStore) UsePerms() string { return c.Perms } +func (c *ClientStore) UsePerms() []string { + perms := make([]string, 0) + sc := bufio.NewScanner(strings.NewReader(c.Perms)) + sc.Split(bufio.ScanWords) + if sc.Scan() { + perms = append(perms, sc.Text()) + } + return perms +} diff --git a/database/manage-oauth.sql.go b/database/manage-oauth.sql.go index ebf160d..02d4a56 100644 --- a/database/manage-oauth.sql.go +++ b/database/manage-oauth.sql.go @@ -10,32 +10,39 @@ import ( ) const getAppList = `-- name: GetAppList :many -SELECT subject, name, domain, owner, perms, public, sso, active +SELECT subject, + name, + domain, + owner_subject, + perms, + public, + sso, + active FROM client_store -WHERE owner = ? +WHERE owner_subject = ? OR ? = 1 LIMIT 25 OFFSET ? ` type GetAppListParams struct { - Owner string `json:"owner"` - Column2 interface{} `json:"column_2"` - Offset int64 `json:"offset"` + OwnerSubject string `json:"owner_subject"` + Column2 interface{} `json:"column_2"` + Offset int64 `json:"offset"` } type GetAppListRow struct { - Subject string `json:"subject"` - Name string `json:"name"` - Domain string `json:"domain"` - Owner string `json:"owner"` - Perms string `json:"perms"` - Public bool `json:"public"` - Sso bool `json:"sso"` - Active bool `json:"active"` + Subject string `json:"subject"` + Name string `json:"name"` + Domain string `json:"domain"` + OwnerSubject string `json:"owner_subject"` + Perms string `json:"perms"` + Public bool `json:"public"` + Sso bool `json:"sso"` + Active bool `json:"active"` } func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAppListRow, error) { - rows, err := q.db.QueryContext(ctx, getAppList, arg.Owner, arg.Column2, arg.Offset) + rows, err := q.db.QueryContext(ctx, getAppList, arg.OwnerSubject, arg.Column2, arg.Offset) if err != nil { return nil, err } @@ -47,7 +54,7 @@ func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAp &i.Subject, &i.Name, &i.Domain, - &i.Owner, + &i.OwnerSubject, &i.Perms, &i.Public, &i.Sso, @@ -67,7 +74,7 @@ func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAp } const getClientInfo = `-- name: GetClientInfo :one -SELECT subject, name, secret, domain, owner, perms, public, sso, active +SELECT subject, name, secret, domain, owner_subject, perms, public, sso, active FROM client_store WHERE subject = ? LIMIT 1 @@ -81,7 +88,7 @@ func (q *Queries) GetClientInfo(ctx context.Context, subject string) (ClientStor &i.Name, &i.Secret, &i.Domain, - &i.Owner, + &i.OwnerSubject, &i.Perms, &i.Public, &i.Sso, @@ -91,20 +98,20 @@ func (q *Queries) GetClientInfo(ctx context.Context, subject string) (ClientStor } const insertClientApp = `-- name: InsertClientApp :exec -INSERT INTO client_store (subject, name, secret, domain, owner, perms, public, sso, active) +INSERT INTO client_store (subject, name, secret, domain, perms, public, sso, active, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertClientAppParams struct { - Subject string `json:"subject"` - Name string `json:"name"` - Secret string `json:"secret"` - Domain string `json:"domain"` - Owner string `json:"owner"` - Perms string `json:"perms"` - Public bool `json:"public"` - Sso bool `json:"sso"` - Active bool `json:"active"` + Subject string `json:"subject"` + Name string `json:"name"` + Secret string `json:"secret"` + Domain string `json:"domain"` + Perms string `json:"perms"` + Public bool `json:"public"` + Sso bool `json:"sso"` + Active bool `json:"active"` + OwnerSubject string `json:"owner_subject"` } func (q *Queries) InsertClientApp(ctx context.Context, arg InsertClientAppParams) error { @@ -113,11 +120,11 @@ func (q *Queries) InsertClientApp(ctx context.Context, arg InsertClientAppParams arg.Name, arg.Secret, arg.Domain, - arg.Owner, arg.Perms, arg.Public, arg.Sso, arg.Active, + arg.OwnerSubject, ) return err } @@ -126,17 +133,17 @@ const resetClientAppSecret = `-- name: ResetClientAppSecret :exec UPDATE client_store SET secret = ? WHERE subject = ? - AND owner = ? + AND owner_subject = ? ` type ResetClientAppSecretParams struct { - Secret string `json:"secret"` - Subject string `json:"subject"` - Owner string `json:"owner"` + Secret string `json:"secret"` + Subject string `json:"subject"` + OwnerSubject string `json:"owner_subject"` } func (q *Queries) ResetClientAppSecret(ctx context.Context, arg ResetClientAppSecretParams) error { - _, err := q.db.ExecContext(ctx, resetClientAppSecret, arg.Secret, arg.Subject, arg.Owner) + _, err := q.db.ExecContext(ctx, resetClientAppSecret, arg.Secret, arg.Subject, arg.OwnerSubject) return err } @@ -149,19 +156,19 @@ SET name = ?, sso = ?, active = ? WHERE subject = ? - AND owner = ? + AND owner_subject = ? ` type UpdateClientAppParams struct { - Name string `json:"name"` - Domain string `json:"domain"` - Column3 bool `json:"column_3"` - Perms string `json:"perms"` - Public bool `json:"public"` - Sso bool `json:"sso"` - Active bool `json:"active"` - Subject string `json:"subject"` - Owner string `json:"owner"` + Name string `json:"name"` + Domain string `json:"domain"` + Column3 bool `json:"column_3"` + Perms string `json:"perms"` + Public bool `json:"public"` + Sso bool `json:"sso"` + Active bool `json:"active"` + Subject string `json:"subject"` + OwnerSubject string `json:"owner_subject"` } func (q *Queries) UpdateClientApp(ctx context.Context, arg UpdateClientAppParams) error { @@ -174,7 +181,7 @@ func (q *Queries) UpdateClientApp(ctx context.Context, arg UpdateClientAppParams arg.Sso, arg.Active, arg.Subject, - arg.Owner, + arg.OwnerSubject, ) return err } diff --git a/database/manage-users.sql.go b/database/manage-users.sql.go index 94513d0..8b0a99a 100644 --- a/database/manage-users.sql.go +++ b/database/manage-users.sql.go @@ -7,27 +7,51 @@ package database import ( "context" + "strings" "time" ) +const changeUserActive = `-- name: ChangeUserActive :exec +UPDATE users +SET active = cast(? as boolean) +WHERE subject = ? +` + +type ChangeUserActiveParams struct { + Column1 bool `json:"column_1"` + Subject string `json:"subject"` +} + +func (q *Queries) ChangeUserActive(ctx context.Context, arg ChangeUserActiveParams) error { + _, err := q.db.ExecContext(ctx, changeUserActive, arg.Column1, arg.Subject) + return err +} + const getUserList = `-- name: GetUserList :many -SELECT subject, +SELECT users.subject, + name, + picture, + website, email, email_verified, - roles, - updated_at, + users.updated_at as user_updated_at, + p.updated_at as profile_updated_at, active FROM users -LIMIT 25 OFFSET ? + INNER JOIN main.profiles p on users.subject = p.subject +LIMIT 50 OFFSET ? ` type GetUserListRow struct { - Subject string `json:"subject"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Roles string `json:"roles"` - UpdatedAt time.Time `json:"updated_at"` - Active bool `json:"active"` + Subject string `json:"subject"` + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UserUpdatedAt time.Time `json:"user_updated_at"` + ProfileUpdatedAt time.Time `json:"profile_updated_at"` + Active bool `json:"active"` } func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListRow, error) { @@ -41,10 +65,13 @@ func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListR var i GetUserListRow if err := rows.Scan( &i.Subject, + &i.Name, + &i.Picture, + &i.Website, &i.Email, &i.EmailVerified, - &i.Roles, - &i.UpdatedAt, + &i.UserUpdatedAt, + &i.ProfileUpdatedAt, &i.Active, ); err != nil { return nil, err @@ -60,22 +87,50 @@ func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListR return items, nil } -const updateUser = `-- name: UpdateUser :exec -UPDATE users -SET active = ?, - roles=? -WHERE subject = ? +const getUsersRoles = `-- name: GetUsersRoles :many +SELECT r.role, u.id +FROM users_roles + INNER JOIN roles r on r.id = users_roles.role_id + INNER JOIN users u on u.id = users_roles.user_id +WHERE u.id in /*SLICE:user_ids*/? ` -type UpdateUserParams struct { - Active bool `json:"active"` - Roles string `json:"roles"` - Subject string `json:"subject"` +type GetUsersRolesRow struct { + Role string `json:"role"` + ID int64 `json:"id"` } -func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { - _, err := q.db.ExecContext(ctx, updateUser, arg.Active, arg.Roles, arg.Subject) - return err +func (q *Queries) GetUsersRoles(ctx context.Context, userIds []int64) ([]GetUsersRolesRow, error) { + query := getUsersRoles + var queryParams []interface{} + if len(userIds) > 0 { + for _, v := range userIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:user_ids*/?", strings.Repeat(",?", len(userIds))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:user_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUsersRolesRow + for rows.Next() { + var i GetUsersRolesRow + if err := rows.Scan(&i.Role, &i.ID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const userEmailExists = `-- name: UserEmailExists :one diff --git a/database/migrations/20240517171813_init.down.sql b/database/migrations/20240517171813_init.down.sql deleted file mode 100644 index bdfa645..0000000 --- a/database/migrations/20240517171813_init.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE users; -DROP TABLE client_store; diff --git a/database/migrations/20240517171813_init.up.sql b/database/migrations/20240517171813_init.up.sql deleted file mode 100644 index 67b0724..0000000 --- a/database/migrations/20240517171813_init.up.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE users -( - subject TEXT PRIMARY KEY UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - email_verified BOOLEAN DEFAULT 0 NOT NULL, - roles TEXT NOT NULL, - userinfo TEXT NOT NULL, - access_token TEXT, - refresh_token TEXT, - expiry DATETIME, - updated_at DATETIME NOT NULL, - active BOOLEAN DEFAULT 1 NOT NULL -); - -CREATE TABLE client_store -( - subject TEXT PRIMARY KEY UNIQUE NOT NULL, - name TEXT NOT NULL, - secret TEXT UNIQUE NOT NULL, - domain TEXT NOT NULL, - owner TEXT NOT NULL, - perms TEXT NOT NULL, - public BOOLEAN NOT NULL, - sso BOOLEAN NOT NULL, - active BOOLEAN DEFAULT 1 NOT NULL, - FOREIGN KEY (owner) REFERENCES users (subject) -); diff --git a/database/migrations/20240820202502_init.down.sql b/database/migrations/20240820202502_init.down.sql new file mode 100644 index 0000000..406e2c3 --- /dev/null +++ b/database/migrations/20240820202502_init.down.sql @@ -0,0 +1,5 @@ +DROP TABLE users; +DROP INDEX username_index; +DROP TABLE roles; +DROP TABLE otp; +DROP TABLE client_store; diff --git a/database/migrations/20240820202502_init.up.sql b/database/migrations/20240820202502_init.up.sql new file mode 100644 index 0000000..27795f0 --- /dev/null +++ b/database/migrations/20240820202502_init.up.sql @@ -0,0 +1,69 @@ +CREATE TABLE users +( + id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, + subject TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + + email TEXT NOT NULL, + email_verified BOOLEAN NOT NULL DEFAULT 0, + + updated_at DATETIME NOT NULL, + registered DATETIME NOT NULL, + active BOOLEAN NOT NULL DEFAULT 1 +); + +CREATE INDEX users_subject ON users (subject); + +CREATE TABLE profiles +( + subject TEXT NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + picture TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + pronouns TEXT NOT NULL DEFAULT 'they/them', + birthdate DATE NULL, + zone TEXT NOT NULL DEFAULT 'UTC', + locale TEXT NOT NULL DEFAULT 'en-US', + updated_at DATETIME NOT NULL +); + +CREATE TABLE roles +( + id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL UNIQUE +); + +CREATE TABLE users_roles +( + role_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + + FOREIGN KEY (role_id) REFERENCES roles (id), + FOREIGN KEY (user_id) REFERENCES users (id), + + CONSTRAINT user_role UNIQUE (role_id, user_id) +); + +CREATE TABLE otp +( + subject INTEGER NOT NULL UNIQUE PRIMARY KEY, + secret TEXT NOT NULL, + digits INTEGER NOT NULL, + + FOREIGN KEY (subject) REFERENCES users (subject) +); + +CREATE TABLE client_store +( + subject TEXT NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + secret TEXT NOT NULL UNIQUE, + domain TEXT NOT NULL, + owner_subject TEXT NOT NULL, + perms TEXT NOT NULL, + public BOOLEAN NOT NULL, + sso BOOLEAN NOT NULL, + active BOOLEAN NOT NULL DEFAULT 1, + + FOREIGN KEY (owner_subject) REFERENCES users (subject) +); diff --git a/database/migrations/20240820202502_merge-auth.down.sql b/database/migrations/20240820202502_merge-auth.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/database/migrations/20240820202502_merge-auth.up.sql b/database/migrations/20240820202502_merge-auth.up.sql deleted file mode 100644 index e69de29..0000000 diff --git a/database/models.go b/database/models.go index 41316ab..fc0b48f 100644 --- a/database/models.go +++ b/database/models.go @@ -5,31 +5,58 @@ package database import ( - "database/sql" "time" + + "github.com/1f349/lavender/password" ) type ClientStore struct { - Subject string `json:"subject"` - Name string `json:"name"` + Subject string `json:"subject"` + Name string `json:"name"` + Secret string `json:"secret"` + Domain string `json:"domain"` + OwnerSubject string `json:"owner_subject"` + Perms string `json:"perms"` + Public bool `json:"public"` + Sso bool `json:"sso"` + Active bool `json:"active"` +} + +type Otp struct { + Subject int64 `json:"subject"` Secret string `json:"secret"` - Domain string `json:"domain"` - Owner string `json:"owner"` - Perms string `json:"perms"` - Public bool `json:"public"` - Sso bool `json:"sso"` - Active bool `json:"active"` + Digits int64 `json:"digits"` +} + +type Profile struct { + Subject string `json:"subject"` + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns string `json:"pronouns"` + Birthdate interface{} `json:"birthdate"` + Zone string `json:"zone"` + Locale string `json:"locale"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Role struct { + ID int64 `json:"id"` + Role string `json:"role"` } type User struct { - Subject string `json:"subject"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Roles string `json:"roles"` - Userinfo string `json:"userinfo"` - AccessToken sql.NullString `json:"access_token"` - RefreshToken sql.NullString `json:"refresh_token"` - Expiry sql.NullTime `json:"expiry"` - UpdatedAt time.Time `json:"updated_at"` - Active bool `json:"active"` + ID int64 `json:"id"` + Subject string `json:"subject"` + Password password.HashString `json:"password"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UpdatedAt time.Time `json:"updated_at"` + Registered time.Time `json:"registered"` + Active bool `json:"active"` +} + +type UsersRole struct { + RoleID int64 `json:"role_id"` + UserID int64 `json:"user_id"` } diff --git a/database/otp.sql.go b/database/otp.sql.go new file mode 100644 index 0000000..fc30726 --- /dev/null +++ b/database/otp.sql.go @@ -0,0 +1,81 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: otp.sql + +package database + +import ( + "context" +) + +const deleteOtp = `-- name: DeleteOtp :exec +DELETE +FROM otp +WHERE otp.subject = ? +` + +func (q *Queries) DeleteOtp(ctx context.Context, subject int64) error { + _, err := q.db.ExecContext(ctx, deleteOtp, subject) + return err +} + +const getOtp = `-- name: GetOtp :one +SELECT secret, digits +FROM otp +WHERE subject = ? +` + +type GetOtpRow struct { + Secret string `json:"secret"` + Digits int64 `json:"digits"` +} + +func (q *Queries) GetOtp(ctx context.Context, subject int64) (GetOtpRow, error) { + row := q.db.QueryRowContext(ctx, getOtp, subject) + var i GetOtpRow + err := row.Scan(&i.Secret, &i.Digits) + return i, err +} + +const getUserEmail = `-- name: GetUserEmail :one +SELECT email +FROM users +WHERE subject = ? +` + +func (q *Queries) GetUserEmail(ctx context.Context, subject string) (string, error) { + row := q.db.QueryRowContext(ctx, getUserEmail, subject) + var email string + err := row.Scan(&email) + return email, err +} + +const hasOtp = `-- name: HasOtp :one +SELECT EXISTS(SELECT 1 FROM otp WHERE subject = ?) == 1 as hasOtp +` + +func (q *Queries) HasOtp(ctx context.Context, subject int64) (bool, error) { + row := q.db.QueryRowContext(ctx, hasOtp, subject) + var hasotp bool + err := row.Scan(&hasotp) + return hasotp, err +} + +const setOtp = `-- name: SetOtp :exec +INSERT OR +REPLACE +INTO otp (subject, secret, digits) +VALUES (?, ?, ?) +` + +type SetOtpParams struct { + Subject int64 `json:"subject"` + Secret string `json:"secret"` + Digits int64 `json:"digits"` +} + +func (q *Queries) SetOtp(ctx context.Context, arg SetOtpParams) error { + _, err := q.db.ExecContext(ctx, setOtp, arg.Subject, arg.Secret, arg.Digits) + return err +} diff --git a/database/password-wrapper.go b/database/password-wrapper.go new file mode 100644 index 0000000..e7a0d48 --- /dev/null +++ b/database/password-wrapper.go @@ -0,0 +1,78 @@ +package database + +import ( + "context" + "github.com/1f349/lavender/database/types" + "github.com/1f349/lavender/password" + "github.com/google/uuid" + "time" +) + +type AddUserParams struct { + Name string `json:"name"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Role types.UserRole `json:"role"` + UpdatedAt time.Time `json:"updated_at"` + Active bool `json:"active"` +} + +func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (string, error) { + pwHash, err := password.HashPassword(arg.Password) + if err != nil { + return "", err + } + n := time.Now() + a := addUserParams{ + Subject: uuid.NewString(), + Password: pwHash, + Email: arg.Email, + EmailVerified: false, + UpdatedAt: n, + Registered: n, + Active: true, + } + return a.Subject, q.addUser(ctx, a) +} + +type CheckLoginResult struct { + Subject string `json:"subject"` + HasOtp bool `json:"has_otp"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +func (q *Queries) CheckLogin(ctx context.Context, un, pw string) (CheckLoginResult, error) { + login, err := q.checkLogin(ctx, un) + if err != nil { + return CheckLoginResult{}, err + } + err = password.CheckPasswordHash(login.Password, pw) + if err != nil { + return CheckLoginResult{}, err + } + return CheckLoginResult{ + Subject: login.Subject, + HasOtp: login.HasOtp, + Email: login.Email, + EmailVerified: login.EmailVerified, + }, nil +} + +func (q *Queries) ChangePassword(ctx context.Context, subject, newPw string) error { + userPassword, err := q.getUserPassword(ctx, subject) + if err != nil { + return err + } + newPwHash, err := password.HashPassword(newPw) + if err != nil { + return err + } + return q.changeUserPassword(ctx, changeUserPasswordParams{ + Password: newPwHash, + UpdatedAt: time.Now(), + Subject: subject, + Password_2: userPassword, + }) +} diff --git a/database/profile-patch.go b/database/profile-patch.go new file mode 100644 index 0000000..74f2125 --- /dev/null +++ b/database/profile-patch.go @@ -0,0 +1,62 @@ +package database + +import ( + "fmt" + "github.com/1f349/lavender/database/types" + "github.com/hardfinhq/go-date" + "github.com/mrmelon54/pronouns" + "golang.org/x/text/language" + "net/url" + "time" +) + +type ProfilePatch struct { + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns types.UserPronoun `json:"pronouns"` + Birthdate date.NullDate `json:"birthdate"` + Zone types.UserZone `json:"zone"` + Locale types.UserLocale `json:"locale"` +} + +func (p *ProfilePatch) ParseFromForm(v url.Values) (safeErrs []error) { + var err error + p.Name = v.Get("name") + p.Picture = v.Get("picture") + p.Website = v.Get("website") + if v.Has("reset_pronouns") { + p.Pronouns.Pronoun = pronouns.TheyThem + } else { + p.Pronouns.Pronoun, err = pronouns.FindPronoun(v.Get("pronouns")) + if err != nil { + safeErrs = append(safeErrs, fmt.Errorf("invalid pronoun selected")) + } + } + if v.Has("reset_birthdate") || v.Get("birthdate") == "" { + p.Birthdate = date.NullDate{} + } else { + p.Birthdate = date.NullDate{Valid: true} + p.Birthdate.Date, err = date.FromString(v.Get("birthdate")) + if err != nil { + safeErrs = append(safeErrs, fmt.Errorf("invalid time selected")) + } + } + if v.Has("reset_zoneinfo") { + p.Zone.Location = time.UTC + } else { + p.Zone.Location, err = time.LoadLocation(v.Get("zoneinfo")) + if err != nil { + safeErrs = append(safeErrs, fmt.Errorf("invalid timezone selected")) + } + } + if v.Has("reset_locale") { + p.Locale.Tag = language.AmericanEnglish + } else { + p.Locale.Tag, err = language.Parse(v.Get("locale")) + if err != nil { + safeErrs = append(safeErrs, fmt.Errorf("invalid language selected")) + } + } + return +} diff --git a/database/profiles.sql.go b/database/profiles.sql.go new file mode 100644 index 0000000..fd54f5c --- /dev/null +++ b/database/profiles.sql.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: profiles.sql + +package database + +import ( + "context" + "time" +) + +const getProfile = `-- name: GetProfile :one +SELECT profiles.subject, profiles.name, profiles.picture, profiles.website, profiles.pronouns, profiles.birthdate, profiles.zone, profiles.locale, profiles.updated_at +FROM profiles +WHERE subject = ? +` + +func (q *Queries) GetProfile(ctx context.Context, subject string) (Profile, error) { + row := q.db.QueryRowContext(ctx, getProfile, subject) + var i Profile + err := row.Scan( + &i.Subject, + &i.Name, + &i.Picture, + &i.Website, + &i.Pronouns, + &i.Birthdate, + &i.Zone, + &i.Locale, + &i.UpdatedAt, + ) + return i, err +} + +const modifyProfile = `-- name: ModifyProfile :exec +UPDATE profiles +SET name = ?, + picture = ?, + website = ?, + pronouns = ?, + birthdate = ?, + zone = ?, + locale = ?, + updated_at = ? +WHERE subject = ? +` + +type ModifyProfileParams struct { + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns string `json:"pronouns"` + Birthdate interface{} `json:"birthdate"` + Zone string `json:"zone"` + Locale string `json:"locale"` + UpdatedAt time.Time `json:"updated_at"` + Subject string `json:"subject"` +} + +func (q *Queries) ModifyProfile(ctx context.Context, arg ModifyProfileParams) error { + _, err := q.db.ExecContext(ctx, modifyProfile, + arg.Name, + arg.Picture, + arg.Website, + arg.Pronouns, + arg.Birthdate, + arg.Zone, + arg.Locale, + arg.UpdatedAt, + arg.Subject, + ) + return err +} diff --git a/database/queries/manage-oauth.sql b/database/queries/manage-oauth.sql index 28082aa..7225f40 100644 --- a/database/queries/manage-oauth.sql +++ b/database/queries/manage-oauth.sql @@ -5,14 +5,21 @@ WHERE subject = ? LIMIT 1; -- name: GetAppList :many -SELECT subject, name, domain, owner, perms, public, sso, active +SELECT subject, + name, + domain, + owner_subject, + perms, + public, + sso, + active FROM client_store -WHERE owner = ? +WHERE owner_subject = ? OR ? = 1 LIMIT 25 OFFSET ?; -- name: InsertClientApp :exec -INSERT INTO client_store (subject, name, secret, domain, owner, perms, public, sso, active) +INSERT INTO client_store (subject, name, secret, domain, perms, public, sso, active, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateClientApp :exec @@ -24,10 +31,10 @@ SET name = ?, sso = ?, active = ? WHERE subject = ? - AND owner = ?; + AND owner_subject = ?; -- name: ResetClientAppSecret :exec UPDATE client_store SET secret = ? WHERE subject = ? - AND owner = ?; + AND owner_subject = ?; diff --git a/database/queries/manage-users.sql b/database/queries/manage-users.sql index 344ca4c..587b87e 100644 --- a/database/queries/manage-users.sql +++ b/database/queries/manage-users.sql @@ -1,17 +1,27 @@ -- name: GetUserList :many -SELECT subject, +SELECT users.subject, + name, + picture, + website, email, email_verified, - roles, - updated_at, + users.updated_at as user_updated_at, + p.updated_at as profile_updated_at, active FROM users -LIMIT 25 OFFSET ?; + INNER JOIN main.profiles p on users.subject = p.subject +LIMIT 50 OFFSET ?; --- name: UpdateUser :exec +-- name: GetUsersRoles :many +SELECT r.role, u.id +FROM users_roles + INNER JOIN roles r on r.id = users_roles.role_id + INNER JOIN users u on u.id = users_roles.user_id +WHERE u.id in sqlc.slice(user_ids); + +-- name: ChangeUserActive :exec UPDATE users -SET active = ?, - roles=? +SET active = cast(? as boolean) WHERE subject = ?; -- name: UserEmailExists :one diff --git a/database/queries/otp.sql b/database/queries/otp.sql new file mode 100644 index 0000000..175399a --- /dev/null +++ b/database/queries/otp.sql @@ -0,0 +1,23 @@ +-- name: SetOtp :exec +INSERT OR +REPLACE +INTO otp (subject, secret, digits) +VALUES (?, ?, ?); + +-- name: DeleteOtp :exec +DELETE +FROM otp +WHERE otp.subject = ?; + +-- name: GetOtp :one +SELECT secret, digits +FROM otp +WHERE subject = ?; + +-- name: HasOtp :one +SELECT EXISTS(SELECT 1 FROM otp WHERE subject = ?) == 1 as hasOtp; + +-- name: GetUserEmail :one +SELECT email +FROM users +WHERE subject = ?; diff --git a/database/queries/profiles.sql b/database/queries/profiles.sql new file mode 100644 index 0000000..134da89 --- /dev/null +++ b/database/queries/profiles.sql @@ -0,0 +1,16 @@ +-- name: GetProfile :one +SELECT profiles.* +FROM profiles +WHERE subject = ?; + +-- name: ModifyProfile :exec +UPDATE profiles +SET name = ?, + picture = ?, + website = ?, + pronouns = ?, + birthdate = ?, + zone = ?, + locale = ?, + updated_at = ? +WHERE subject = ?; diff --git a/database/queries/users.sql b/database/queries/users.sql index e565f83..d41fcce 100644 --- a/database/queries/users.sql +++ b/database/queries/users.sql @@ -2,21 +2,15 @@ SELECT count(subject) > 0 AS hasUser FROM users; --- name: AddUser :exec -INSERT INTO users (subject, email, email_verified, roles, userinfo, updated_at, active) +-- name: addUser :exec +INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) VALUES (?, ?, ?, ?, ?, ?, ?); --- name: UpdateUserInfo :exec -UPDATE users -SET email = ?, - email_verified = ?, - userinfo = ? -WHERE subject = ?; - --- name: GetUserRoles :one -SELECT roles +-- name: checkLogin :one +SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified FROM users -WHERE subject = ?; +WHERE users.subject = ? +LIMIT 1; -- name: GetUser :one SELECT * @@ -24,20 +18,29 @@ FROM users WHERE subject = ? LIMIT 1; --- name: UpdateUserToken :exec -UPDATE users -SET access_token = ?, - refresh_token = ?, - expiry = ? -WHERE subject = ?; +-- name: GetUserRoles :many +SELECT r.role +FROM users_roles + INNER JOIN roles r on r.id = users_roles.role_id + INNER JOIN users u on u.id = users_roles.user_id +WHERE u.subject = ?; --- name: GetUserToken :one -SELECT access_token, refresh_token, expiry -FROM users -WHERE subject = ? -LIMIT 1; +-- name: UserHasRole :one +SELECT 1 +FROM roles + INNER JOIN users_roles on users_roles.user_id = roles.id + INNER JOIN users u on u.id = users_roles.user_id = u.id +WHERE roles.role = ? + AND u.subject = ?; --- name: GetUserEmail :one -SELECT email +-- name: getUserPassword :one +SELECT password FROM users WHERE subject = ?; + +-- name: changeUserPassword :exec +UPDATE users +SET password = ?, + updated_at=? +WHERE subject = ? + AND password = ?; diff --git a/database/users.sql.go b/database/users.sql.go index c3044f0..c814a26 100644 --- a/database/users.sql.go +++ b/database/users.sql.go @@ -7,40 +7,13 @@ package database import ( "context" - "database/sql" "time" -) -const addUser = `-- name: AddUser :exec -INSERT INTO users (subject, email, email_verified, roles, userinfo, updated_at, active) -VALUES (?, ?, ?, ?, ?, ?, ?) -` - -type AddUserParams struct { - Subject string `json:"subject"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Roles string `json:"roles"` - Userinfo string `json:"userinfo"` - UpdatedAt time.Time `json:"updated_at"` - Active bool `json:"active"` -} - -func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) error { - _, err := q.db.ExecContext(ctx, addUser, - arg.Subject, - arg.Email, - arg.EmailVerified, - arg.Roles, - arg.Userinfo, - arg.UpdatedAt, - arg.Active, - ) - return err -} + "github.com/1f349/lavender/password" +) const getUser = `-- name: GetUser :one -SELECT subject, email, email_verified, roles, userinfo, access_token, refresh_token, expiry, updated_at, active +SELECT id, subject, password, email, email_verified, updated_at, registered, active FROM users WHERE subject = ? LIMIT 1 @@ -50,64 +23,47 @@ func (q *Queries) GetUser(ctx context.Context, subject string) (User, error) { row := q.db.QueryRowContext(ctx, getUser, subject) var i User err := row.Scan( + &i.ID, &i.Subject, + &i.Password, &i.Email, &i.EmailVerified, - &i.Roles, - &i.Userinfo, - &i.AccessToken, - &i.RefreshToken, - &i.Expiry, &i.UpdatedAt, + &i.Registered, &i.Active, ) return i, err } -const getUserEmail = `-- name: GetUserEmail :one -SELECT email -FROM users -WHERE subject = ? +const getUserRoles = `-- name: GetUserRoles :many +SELECT r.role +FROM users_roles + INNER JOIN roles r on r.id = users_roles.role_id + INNER JOIN users u on u.id = users_roles.user_id +WHERE u.subject = ? ` -func (q *Queries) GetUserEmail(ctx context.Context, subject string) (string, error) { - row := q.db.QueryRowContext(ctx, getUserEmail, subject) - var email string - err := row.Scan(&email) - return email, err -} - -const getUserRoles = `-- name: GetUserRoles :one -SELECT roles -FROM users -WHERE subject = ? -` - -func (q *Queries) GetUserRoles(ctx context.Context, subject string) (string, error) { - row := q.db.QueryRowContext(ctx, getUserRoles, subject) - var roles string - err := row.Scan(&roles) - return roles, err -} - -const getUserToken = `-- name: GetUserToken :one -SELECT access_token, refresh_token, expiry -FROM users -WHERE subject = ? -LIMIT 1 -` - -type GetUserTokenRow struct { - AccessToken sql.NullString `json:"access_token"` - RefreshToken sql.NullString `json:"refresh_token"` - Expiry sql.NullTime `json:"expiry"` -} - -func (q *Queries) GetUserToken(ctx context.Context, subject string) (GetUserTokenRow, error) { - row := q.db.QueryRowContext(ctx, getUserToken, subject) - var i GetUserTokenRow - err := row.Scan(&i.AccessToken, &i.RefreshToken, &i.Expiry) - return i, err +func (q *Queries) GetUserRoles(ctx context.Context, subject string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getUserRoles, subject) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var role string + if err := rows.Scan(&role); err != nil { + return nil, err + } + items = append(items, role) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const hasUser = `-- name: HasUser :one @@ -122,52 +78,117 @@ func (q *Queries) HasUser(ctx context.Context) (bool, error) { return hasuser, err } -const updateUserInfo = `-- name: UpdateUserInfo :exec -UPDATE users -SET email = ?, - email_verified = ?, - userinfo = ? -WHERE subject = ? +const userHasRole = `-- name: UserHasRole :one +SELECT 1 +FROM roles + INNER JOIN users_roles on users_roles.user_id = roles.id + INNER JOIN users u on u.id = users_roles.user_id = u.id +WHERE roles.role = ? + AND u.subject = ? ` -type UpdateUserInfoParams struct { - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Userinfo string `json:"userinfo"` - Subject string `json:"subject"` +type UserHasRoleParams struct { + Role string `json:"role"` + Subject string `json:"subject"` +} + +func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) (int64, error) { + row := q.db.QueryRowContext(ctx, userHasRole, arg.Role, arg.Subject) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err } -func (q *Queries) UpdateUserInfo(ctx context.Context, arg UpdateUserInfoParams) error { - _, err := q.db.ExecContext(ctx, updateUserInfo, +const addUser = `-- name: addUser :exec +INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) +VALUES (?, ?, ?, ?, ?, ?, ?) +` + +type addUserParams struct { + Subject string `json:"subject"` + Password password.HashString `json:"password"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UpdatedAt time.Time `json:"updated_at"` + Registered time.Time `json:"registered"` + Active bool `json:"active"` +} + +func (q *Queries) addUser(ctx context.Context, arg addUserParams) error { + _, err := q.db.ExecContext(ctx, addUser, + arg.Subject, + arg.Password, arg.Email, arg.EmailVerified, - arg.Userinfo, - arg.Subject, + arg.UpdatedAt, + arg.Registered, + arg.Active, ) return err } -const updateUserToken = `-- name: UpdateUserToken :exec +const changeUserPassword = `-- name: changeUserPassword :exec UPDATE users -SET access_token = ?, - refresh_token = ?, - expiry = ? +SET password = ?, + updated_at=? WHERE subject = ? + AND password = ? ` -type UpdateUserTokenParams struct { - AccessToken sql.NullString `json:"access_token"` - RefreshToken sql.NullString `json:"refresh_token"` - Expiry sql.NullTime `json:"expiry"` - Subject string `json:"subject"` +type changeUserPasswordParams struct { + Password password.HashString `json:"password"` + UpdatedAt time.Time `json:"updated_at"` + Subject string `json:"subject"` + Password_2 password.HashString `json:"password_2"` } -func (q *Queries) UpdateUserToken(ctx context.Context, arg UpdateUserTokenParams) error { - _, err := q.db.ExecContext(ctx, updateUserToken, - arg.AccessToken, - arg.RefreshToken, - arg.Expiry, +func (q *Queries) changeUserPassword(ctx context.Context, arg changeUserPasswordParams) error { + _, err := q.db.ExecContext(ctx, changeUserPassword, + arg.Password, + arg.UpdatedAt, arg.Subject, + arg.Password_2, ) return err } + +const checkLogin = `-- name: checkLogin :one +SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified +FROM users +WHERE users.subject = ? +LIMIT 1 +` + +type checkLoginRow struct { + Subject string `json:"subject"` + Password password.HashString `json:"password"` + HasOtp bool `json:"has_otp"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +func (q *Queries) checkLogin(ctx context.Context, subject string) (checkLoginRow, error) { + row := q.db.QueryRowContext(ctx, checkLogin, subject) + var i checkLoginRow + err := row.Scan( + &i.Subject, + &i.Password, + &i.HasOtp, + &i.Email, + &i.EmailVerified, + ) + return i, err +} + +const getUserPassword = `-- name: getUserPassword :one +SELECT password +FROM users +WHERE subject = ? +` + +func (q *Queries) getUserPassword(ctx context.Context, subject string) (password.HashString, error) { + row := q.db.QueryRowContext(ctx, getUserPassword, subject) + var password password.HashString + err := row.Scan(&password) + return password, err +} diff --git a/go.mod b/go.mod index efb0d14..dc2b926 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.17.1 github.com/google/subcommands v1.2.0 github.com/google/uuid v1.6.0 + github.com/hardfinhq/go-date v1.20240411.1 github.com/julienschmidt/httprouter v1.3.0 github.com/mattn/go-sqlite3 v1.14.22 github.com/mrmelon54/pronouns v1.0.3 diff --git a/go.sum b/go.sum index a362a74..98374f7 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hardfinhq/go-date v1.20240411.1 h1:UskRXxgD+4eCEa8CpiiWLiv2/Vnf1eB90Bojkf7AQ64= +github.com/hardfinhq/go-date v1.20240411.1/go.mod h1:7oxaI9XX4W3/MRDeQXec0fLXFnSJDS7BrazIY2XqPXA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/issuer/manager_test.go b/issuer/manager_test.go index 143014e..8f316f3 100644 --- a/issuer/manager_test.go +++ b/issuer/manager_test.go @@ -26,10 +26,9 @@ 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([]SsoConfig{ - { - Addr: testAddrUrl, - Namespace: "example.com", + manager, err := NewManager(map[string]SsoConfig{ + "example.com": { + Addr: testAddrUrl, }, }) assert.NoError(t, err) @@ -41,10 +40,9 @@ 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([]SsoConfig{ - { - Addr: testAddrUrl, - Namespace: "example.com", + manager, err := NewManager(map[string]SsoConfig{ + "example.com": { + Addr: testAddrUrl, }, }) assert.NoError(t, err) diff --git a/issuer/sso.go b/issuer/sso.go index 4db6feb..ee81aec 100644 --- a/issuer/sso.go +++ b/issuer/sso.go @@ -62,6 +62,7 @@ func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) { } type WellKnownOIDC struct { + Namespace string `json:"-"` Config SsoConfig `json:"-"` Issuer string `json:"issuer"` AuthorizationEndpoint string `json:"authorization_endpoint"` diff --git a/server/auth.go b/server/auth.go index 31c0a73..2e7d741 100644 --- a/server/auth.go +++ b/server/auth.go @@ -23,7 +23,7 @@ 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) { - var roles string + var roles []string if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) return diff --git a/server/home.go b/server/home.go index 0142967..75571cb 100644 --- a/server/home.go +++ b/server/home.go @@ -30,9 +30,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute var isAdmin bool h.DbTx(rw, func(tx *database.Queries) (err error) { - roles, err := tx.GetUserRoles(req.Context(), auth.Subject) - isAdmin = HasRole(roles, "lavender:admin") - return err + _, err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{Role: "lavender:admin", Subject: auth.Subject}) + isAdmin = err == nil + return nil }) pages.RenderPageTemplate(rw, "index", map[string]any{ diff --git a/server/jwt.go b/server/jwt.go index 31f14bc..6d23725 100644 --- a/server/jwt.go +++ b/server/jwt.go @@ -30,9 +30,12 @@ func (j *JWTAccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasi return "", "", err } - ps := auth.ParsePermStorage(roles) + ps := auth.NewPermStorage() + for _, role := range roles { + ps.Set(role) + } out := auth.NewPermStorage() - ForEachRole(data.Client.(interface{ UsePerms() string }).UsePerms(), func(role string) { + ForEachRole(data.Client.(interface{ UsePerms() []string }).UsePerms(), func(role string) { for _, i := range ps.Filter(strings.Split(role, " ")).Dump() { out.Set(i) } diff --git a/server/roles.go b/server/roles.go index 0d9af20..b3dcfea 100644 --- a/server/roles.go +++ b/server/roles.go @@ -1,25 +1,16 @@ package server -import ( - "bufio" - "strings" -) - -func HasRole(roles, test string) bool { - sc := bufio.NewScanner(strings.NewReader(roles)) - sc.Split(bufio.ScanWords) - for sc.Scan() { - if sc.Text() == test { +func HasRole(roles []string, test string) bool { + for _, role := range roles { + if role == test { return true } } return false } -func ForEachRole(roles string, next func(role string)) { - sc := bufio.NewScanner(strings.NewReader(roles)) - sc.Split(bufio.ScanWords) - for sc.Scan() { - next(sc.Text()) +func ForEachRole(roles []string, next func(role string)) { + for _, role := range roles { + next(role) } } diff --git a/sqlc.yaml b/sqlc.yaml index 5ace449..2716e86 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -10,14 +10,14 @@ sql: emit_json_tags: true overrides: - column: "users.password" - go_type: "github.com/1f349/tulip/password.HashString" + go_type: "github.com/1f349/lavender/password.HashString" - column: "users.birthdate" go_type: "github.com/hardfinhq/go-date.NullDate" - column: "users.role" - go_type: "github.com/1f349/tulip/database/types.UserRole" + go_type: "github.com/1f349/lavender/database/types.UserRole" - column: "users.pronouns" - go_type: "github.com/1f349/tulip/database/types.UserPronoun" + go_type: "github.com/1f349/lavender/database/types.UserPronoun" - column: "users.zoneinfo" - go_type: "github.com/1f349/tulip/database/types.UserZone" + go_type: "github.com/1f349/lavender/database/types.UserZone" - column: "users.locale" - go_type: "github.com/1f349/tulip/database/types.UserLocale" + go_type: "github.com/1f349/lavender/database/types.UserLocale" From 7064afd55e37c3fe3815a2298d1adfd7ff4cce0b Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Fri, 13 Sep 2024 15:31:40 +0100 Subject: [PATCH 05/10] A load more changes --- auth/auth.go | 11 + auth/login.go | 1 + auth/oauth.go | 1 + {server => auth}/userinfofields.go | 2 +- cmd/lavender/serve.go | 4 +- conf/conf.go | 16 +- .../migrations/20240820202502_init.up.sql | 43 ++-- database/password-wrapper.go | 17 +- database/queries/users.sql | 6 +- database/types/authtype.go | 16 ++ database/users.sql.go | 10 +- issuer/sso.go | 5 +- role/role.go | 5 + server/auth.go | 40 +++- server/db.go | 4 +- server/home.go | 5 +- server/jwt.go | 9 +- server/login.go | 31 ++- server/manage-apps.go | 11 +- server/manage-users.go | 7 +- server/oauth.go | 4 +- server/openid.go | 38 +++ server/roles_test.go | 6 +- server/server.go | 219 ++++-------------- 24 files changed, 242 insertions(+), 269 deletions(-) create mode 100644 auth/auth.go create mode 100644 auth/login.go create mode 100644 auth/oauth.go rename {server => auth}/userinfofields.go (96%) create mode 100644 database/types/authtype.go create mode 100644 role/role.go create mode 100644 server/openid.go diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..413e728 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,11 @@ +package auth + +import "github.com/1f349/lavender/database" + +type LoginProvider interface { + AttemptLogin(username, password string) (database.User, error) +} + +type OAuthProvider interface { + AttemptLogin(username string) (database.User, error) +} diff --git a/auth/login.go b/auth/login.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/auth/login.go @@ -0,0 +1 @@ +package auth diff --git a/auth/oauth.go b/auth/oauth.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/auth/oauth.go @@ -0,0 +1 @@ +package auth diff --git a/server/userinfofields.go b/auth/userinfofields.go similarity index 96% rename from server/userinfofields.go rename to auth/userinfofields.go index 5b28e7c..7f2093c 100644 --- a/server/userinfofields.go +++ b/auth/userinfofields.go @@ -1,4 +1,4 @@ -package server +package auth type UserInfoFields map[string]any diff --git a/cmd/lavender/serve.go b/cmd/lavender/serve.go index ec7714c..ebdb20a 100644 --- a/cmd/lavender/serve.go +++ b/cmd/lavender/serve.go @@ -13,6 +13,7 @@ import ( "github.com/cloudflare/tableflip" "github.com/golang-jwt/jwt/v4" "github.com/google/subcommands" + "github.com/julienschmidt/httprouter" _ "github.com/mattn/go-sqlite3" "github.com/spf13/afero" "gopkg.in/yaml.v3" @@ -122,7 +123,8 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) logger.Logger.Fatal("Listen failed", "err", err) } - mux := server.NewHttpServer(config, db, signingKey) + mux := httprouter.New() + server.SetupRouter(mux, config, db, signingKey) srv := &http.Server{ Handler: mux, ReadTimeout: time.Minute, diff --git a/conf/conf.go b/conf/conf.go index 28d17a3..fd8f314 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -6,12 +6,12 @@ import ( ) type Conf struct { - Listen string `yaml:"listen"` - BaseUrl string `yaml:"baseUrl"` - ServiceName string `yaml:"serviceName"` - Issuer string `yaml:"issuer"` - Kid string `yaml:"kid"` - Namespace string `yaml:"namespace"` - Mail mail.Mail `yaml:"mail"` - SsoServices map[string]issuer.SsoConfig `yaml:"ssoServices"` + Listen string `yaml:"listen"` + BaseUrl string `yaml:"baseUrl"` + ServiceName string `yaml:"serviceName"` + Issuer string `yaml:"issuer"` + Kid string `yaml:"kid"` + Namespace string `yaml:"namespace"` + Mail mail.Mail `yaml:"mail"` + SsoServices []issuer.SsoConfig `yaml:"ssoServices"` } diff --git a/database/migrations/20240820202502_init.up.sql b/database/migrations/20240820202502_init.up.sql index 27795f0..d78164e 100644 --- a/database/migrations/20240820202502_init.up.sql +++ b/database/migrations/20240820202502_init.up.sql @@ -1,32 +1,33 @@ CREATE TABLE users ( - id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, - subject TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, + id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, + subject TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, - email TEXT NOT NULL, - email_verified BOOLEAN NOT NULL DEFAULT 0, + change_password BOOLEAN NOT NULL, - updated_at DATETIME NOT NULL, - registered DATETIME NOT NULL, - active BOOLEAN NOT NULL DEFAULT 1 -); + email TEXT NOT NULL, + email_verified BOOLEAN NOT NULL, -CREATE INDEX users_subject ON users (subject); + updated_at DATETIME NOT NULL, + registered DATETIME NOT NULL, + active BOOLEAN NOT NULL DEFAULT 1, -CREATE TABLE profiles -( - subject TEXT NOT NULL UNIQUE PRIMARY KEY, - name TEXT NOT NULL, - picture TEXT NOT NULL DEFAULT '', - website TEXT NOT NULL DEFAULT '', - pronouns TEXT NOT NULL DEFAULT 'they/them', - birthdate DATE NULL, - zone TEXT NOT NULL DEFAULT 'UTC', - locale TEXT NOT NULL DEFAULT 'en-US', - updated_at DATETIME NOT NULL + name TEXT NOT NULL, + picture TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + pronouns TEXT NOT NULL DEFAULT 'they/them', + birthdate DATE NULL DEFAULT NULL, + zone TEXT NOT NULL DEFAULT 'UTC', + locale TEXT NOT NULL DEFAULT 'en-US', + + auth_type INTEGER NOT NULL, + auth_namespace TEXT NOT NULL, + auth_user TEXT NOT NULL ); +CREATE INDEX users_subject ON users (subject); + CREATE TABLE roles ( id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, diff --git a/database/password-wrapper.go b/database/password-wrapper.go index e7a0d48..07f94ee 100644 --- a/database/password-wrapper.go +++ b/database/password-wrapper.go @@ -2,20 +2,19 @@ package database import ( "context" - "github.com/1f349/lavender/database/types" "github.com/1f349/lavender/password" "github.com/google/uuid" "time" ) type AddUserParams struct { - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - Email string `json:"email"` - Role types.UserRole `json:"role"` - UpdatedAt time.Time `json:"updated_at"` - Active bool `json:"active"` + Name string `json:"name"` + Subject string `json:"subject"` + Password string `json:"password"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UpdatedAt time.Time `json:"updated_at"` + Active bool `json:"active"` } func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (string, error) { @@ -28,7 +27,7 @@ func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (string, error Subject: uuid.NewString(), Password: pwHash, Email: arg.Email, - EmailVerified: false, + EmailVerified: arg.EmailVerified, UpdatedAt: n, Registered: n, Active: true, diff --git a/database/queries/users.sql b/database/queries/users.sql index d41fcce..1a916aa 100644 --- a/database/queries/users.sql +++ b/database/queries/users.sql @@ -6,6 +6,10 @@ FROM users; INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) VALUES (?, ?, ?, ?, ?, ?, ?); +-- name: addOAuthUser :exec +INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) +VALUES (?, ?, ?, ?, ?, ?, ?); + -- name: checkLogin :one SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified FROM users @@ -25,7 +29,7 @@ FROM users_roles INNER JOIN users u on u.id = users_roles.user_id WHERE u.subject = ?; --- name: UserHasRole :one +-- name: UserHasRole :exec SELECT 1 FROM roles INNER JOIN users_roles on users_roles.user_id = roles.id diff --git a/database/types/authtype.go b/database/types/authtype.go new file mode 100644 index 0000000..903f717 --- /dev/null +++ b/database/types/authtype.go @@ -0,0 +1,16 @@ +package types + +type AuthType byte + +const ( + AuthTypeBase AuthType = iota + AuthTypeOauth2 +) + +var authTypeNames = map[AuthType]string{ + AuthTypeOauth2: "OAuth2", +} + +func (t AuthType) String() string { + return authTypeNames[t] +} diff --git a/database/users.sql.go b/database/users.sql.go index c814a26..04c1c02 100644 --- a/database/users.sql.go +++ b/database/users.sql.go @@ -78,7 +78,7 @@ func (q *Queries) HasUser(ctx context.Context) (bool, error) { return hasuser, err } -const userHasRole = `-- name: UserHasRole :one +const userHasRole = `-- name: UserHasRole :exec SELECT 1 FROM roles INNER JOIN users_roles on users_roles.user_id = roles.id @@ -92,11 +92,9 @@ type UserHasRoleParams struct { Subject string `json:"subject"` } -func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) (int64, error) { - row := q.db.QueryRowContext(ctx, userHasRole, arg.Role, arg.Subject) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err +func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) error { + _, err := q.db.ExecContext(ctx, userHasRole, arg.Role, arg.Subject) + return err } const addUser = `-- name: addUser :exec diff --git a/issuer/sso.go b/issuer/sso.go index ee81aec..59ddf40 100644 --- a/issuer/sso.go +++ b/issuer/sso.go @@ -17,8 +17,9 @@ var httpGet = http.Get // SsoConfig is the base URL for an OAUTH/OPENID/SSO login service // The path `/.well-known/openid-configuration` should be available type SsoConfig struct { - Addr utils.JsonUrl `json:"addr"` // https://login.example.com - Client SsoConfigClient `json:"client"` + Addr utils.JsonUrl `json:"addr"` // https://login.example.com + Namespace string `json:"namespace"` // example.com + Client SsoConfigClient `json:"client"` } type SsoConfigClient struct { diff --git a/role/role.go b/role/role.go new file mode 100644 index 0000000..6a6ee2b --- /dev/null +++ b/role/role.go @@ -0,0 +1,5 @@ +package role + +const prefix = "lavender:" + +const LavenderAdmin = prefix + "admin" diff --git a/server/auth.go b/server/auth.go index 2e7d741..60e222f 100644 --- a/server/auth.go +++ b/server/auth.go @@ -1,8 +1,11 @@ package server import ( + "database/sql" "errors" + "github.com/1f349/lavender/auth" "github.com/1f349/lavender/database" + "github.com/1f349/lavender/role" "github.com/julienschmidt/httprouter" "net/http" "net/url" @@ -12,25 +15,42 @@ import ( type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) type UserAuth struct { - Subject string - DisplayName string - UserInfo UserInfoFields + Subject string + NeedOtp bool + UserInfo auth.UserInfoFields } func (u UserAuth) IsGuest() bool { return u.Subject == "" } +func (u UserAuth) NextFlowUrl(origin *url.URL) *url.URL { + if u.NeedOtp { + return PrepareRedirectUrl("/login/otp", origin) + } + return nil +} + var ErrAuthHttpError = errors.New("auth http error") -func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle { +func (h *httpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle { return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - var roles []string + var hasRole bool if h.DbTx(rw, func(tx *database.Queries) (err error) { - roles, err = tx.GetUserRoles(req.Context(), auth.Subject) + err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{ + Role: role.LavenderAdmin, + Subject: auth.Subject, + }) + switch { + case err == nil: + hasRole = true + case errors.Is(err, sql.ErrNoRows): + hasRole = false + err = nil + } return }) { return } - if !HasRole(roles, "lavender:admin") { + if !hasRole { http.Error(rw, "403 Forbidden", http.StatusForbidden) return } @@ -38,7 +58,7 @@ func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Han }) } -func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle { +func (h *httpServer) RequireAuthentication(next UserHandler) httprouter.Handle { return h.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { if auth.IsGuest() { redirectUrl := PrepareRedirectUrl("/login", req.URL) @@ -49,7 +69,7 @@ func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle { }) } -func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle { +func (h *httpServer) OptionalAuthentication(next UserHandler) httprouter.Handle { return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { authUser, err := h.internalAuthenticationHandler(rw, req) if err != nil { @@ -62,7 +82,7 @@ func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle } } -func (h *HttpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) { +func (h *httpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) { // Delete previous login data cookie http.SetCookie(rw, &http.Cookie{ Name: "lavender-login-data", diff --git a/server/db.go b/server/db.go index f41e106..4627152 100644 --- a/server/db.go +++ b/server/db.go @@ -12,7 +12,7 @@ var ErrDatabaseActionFailed = errors.New("database action failed") // DbTx wraps a database transaction with http error messages and a simple action // function. If the action function returns an error the transaction will be // rolled back. If there is no error then the transaction is committed. -func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queries) error) bool { +func (h *httpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queries) error) bool { logger.Logger.Helper() if h.DbTxError(action) != nil { http.Error(rw, "Database error", http.StatusInternalServerError) @@ -22,7 +22,7 @@ func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queri return false } -func (h *HttpServer) DbTxError(action func(tx *database.Queries) error) error { +func (h *httpServer) DbTxError(action func(tx *database.Queries) error) error { logger.Logger.Helper() err := action(h.db) if err != nil { diff --git a/server/home.go b/server/home.go index 75571cb..cc93e98 100644 --- a/server/home.go +++ b/server/home.go @@ -3,13 +3,14 @@ package server import ( "github.com/1f349/lavender/database" "github.com/1f349/lavender/pages" + "github.com/1f349/lavender/role" "github.com/google/uuid" "github.com/julienschmidt/httprouter" "net/http" "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 UserAuth) { rw.Header().Set("Content-Type", "text/html") lNonce := uuid.NewString() http.SetCookie(rw, &http.Cookie{ @@ -30,7 +31,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute var isAdmin bool h.DbTx(rw, func(tx *database.Queries) (err error) { - _, err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{Role: "lavender:admin", Subject: auth.Subject}) + err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{Role: role.LavenderAdmin, Subject: auth.Subject}) isAdmin = err == nil return nil }) diff --git a/server/jwt.go b/server/jwt.go index 6d23725..1b19b7c 100644 --- a/server/jwt.go +++ b/server/jwt.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/base64" - "github.com/1f349/lavender/database" "github.com/1f349/mjwt" "github.com/1f349/mjwt/auth" "github.com/go-oauth2/oauth2/v4" @@ -15,15 +14,19 @@ import ( type JWTAccessGenerate struct { signer *mjwt.Issuer - db *database.Queries + db mjwtGetUserRoles } -func NewJWTAccessGenerate(signer *mjwt.Issuer, db *database.Queries) *JWTAccessGenerate { +func NewMJWTAccessGenerate(signer *mjwt.Issuer, db mjwtGetUserRoles) *JWTAccessGenerate { return &JWTAccessGenerate{signer, db} } var _ oauth2.AccessGenerate = &JWTAccessGenerate{} +type mjwtGetUserRoles interface { + GetUserRoles(ctx context.Context, subject string) ([]string, error) +} + func (j *JWTAccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic, isGenRefresh bool) (access, refresh string, err error) { roles, err := j.db.GetUserRoles(ctx, data.UserID) if err != nil { diff --git a/server/login.go b/server/login.go index fb57bb1..323a03c 100644 --- a/server/login.go +++ b/server/login.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + auth2 "github.com/1f349/lavender/auth" "github.com/1f349/lavender/database" "github.com/1f349/lavender/issuer" "github.com/1f349/lavender/pages" @@ -21,7 +22,7 @@ import ( "time" ) -func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { if !auth.IsGuest() { h.SafeRedirect(rw, req) return @@ -42,7 +43,7 @@ func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr }) } -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 UserAuth) { if !auth.IsGuest() { h.SafeRedirect(rw, req) return @@ -95,7 +96,7 @@ func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http http.Redirect(rw, req, nextUrl, http.StatusFound) } -func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) { +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) @@ -123,7 +124,7 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ h.SafeRedirect(rw, req) } -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) (UserAuth, error) { sessionData, err := h.fetchUserInfo(sso, token) if err != nil || sessionData.Subject == "" { return UserAuth{}, fmt.Errorf("failed to fetch user info") @@ -138,6 +139,16 @@ func (h *HttpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK if errors.Is(err, sql.ErrNoRows) { uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") + id, err := tx.AddUser(req.Context(), database.AddUserParams{ + Name: "", + Subject: sessionData.Subject, + Password: "", + Email: uEmail, + EmailVerified: uEmailVerified, + UpdatedAt: time.Now(), + Active: true, + }) + return err return tx.AddUser(req.Context(), database.AddUserParams{ Subject: sessionData.Subject, Email: uEmail, @@ -180,7 +191,7 @@ const twelveHours = 12 * time.Hour const oneWeek = 7 * 24 * time.Hour type lavenderLoginAccess struct { - UserInfo UserInfoFields `json:"user_info"` + UserInfo auth2.UserInfoFields `json:"user_info"` auth.AccessTokenClaims } @@ -197,7 +208,7 @@ func (l lavenderLoginRefresh) Valid() error { return l.RefreshTokenClaims.Valid( func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" } -func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth, loginName string) bool { +func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData 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{ @@ -248,7 +259,7 @@ 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 *UserAuth) error { loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore()) if err != nil { return h.readLoginRefreshCookie(rw, req, u) @@ -260,7 +271,7 @@ func (h *HttpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Req 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 *UserAuth) error { refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore()) if err != nil { return err @@ -298,14 +309,14 @@ 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) (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") } defer res.Body.Close() - var userInfoJson UserInfoFields + var userInfoJson auth2.UserInfoFields if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil { return UserAuth{}, err } diff --git a/server/manage-apps.go b/server/manage-apps.go index 6605567..d95b752 100644 --- a/server/manage-apps.go +++ b/server/manage-apps.go @@ -4,6 +4,7 @@ import ( "github.com/1f349/lavender/database" "github.com/1f349/lavender/pages" "github.com/1f349/lavender/password" + "github.com/1f349/lavender/role" "github.com/google/uuid" "github.com/julienschmidt/httprouter" "net/http" @@ -11,7 +12,7 @@ import ( "strconv" ) -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 UserAuth) { q := req.URL.Query() offset, _ := strconv.Atoi(q.Get("offset")) @@ -24,7 +25,7 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ } appList, err = tx.GetAppList(req.Context(), database.GetAppListParams{ Owner: auth.Subject, - Column2: HasRole(roles, "lavender:admin"), + Column2: HasRole(roles, role.LavenderAdmin), Offset: int64(offset), }) return @@ -59,7 +60,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 UserAuth) { var roles string if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) @@ -70,7 +71,7 @@ func (h *HttpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Reque m := map[string]any{ "ServiceName": h.conf.ServiceName, - "IsAdmin": HasRole(roles, "lavender:admin"), + "IsAdmin": HasRole(roles, role.LavenderAdmin), } rw.Header().Set("Content-Type", "text/html") @@ -78,7 +79,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 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 02a6f37..b3f3c8f 100644 --- a/server/manage-users.go +++ b/server/manage-users.go @@ -3,13 +3,14 @@ package server import ( "github.com/1f349/lavender/database" "github.com/1f349/lavender/pages" + "github.com/1f349/lavender/role" "github.com/julienschmidt/httprouter" "net/http" "net/url" "strconv" ) -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 UserAuth) { q := req.URL.Query() offset, _ := strconv.Atoi(q.Get("offset")) @@ -25,7 +26,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ }) { return } - if !HasRole(roles, "lavender:admin") { + if !HasRole(roles, role.LavenderAdmin) { http.Error(rw, "403 Forbidden", http.StatusForbidden) return } @@ -56,7 +57,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 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 ed03179..15ba6b7 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -10,7 +10,7 @@ import ( "strings" ) -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 UserAuth) { // function is only called with GET or POST method isPost := req.Method == http.MethodPost @@ -128,7 +128,7 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request http.Redirect(rw, req, parsedRedirect.String(), http.StatusFound) } -func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Request) (string, error) { +func (h *httpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Request) (string, error) { err := req.ParseForm() if err != nil { return "", err diff --git a/server/openid.go b/server/openid.go new file mode 100644 index 0000000..7a56b9c --- /dev/null +++ b/server/openid.go @@ -0,0 +1,38 @@ +package server + +import ( + "bytes" + "encoding/json" + "github.com/1f349/lavender/logger" + "github.com/1f349/lavender/openid" + "github.com/1f349/mjwt" + "github.com/julienschmidt/httprouter" + "net/http" +) + +func SetupOpenId(r *httprouter.Router, baseUrl string, signingKey *mjwt.Issuer) { + openIdConf := openid.GenConfig(baseUrl, []string{ + "openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale", + }, []string{ + "sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at", + }) + openIdBytes, err := json.Marshal(openIdConf) + if err != nil { + logger.Logger.Fatal("Failed to generate OpenID configuration", "err", err) + } + + jwkSetBuffer := new(bytes.Buffer) + err = mjwt.WriteJwkSetJson(jwkSetBuffer, []*mjwt.Issuer{signingKey}) + if err != nil { + logger.Logger.Fatal("Failed to generate JWK Set", "err", err) + } + + r.GET("/.well-known/openid-configuration", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write(openIdBytes) + }) + r.GET("/.well-known/jwks.json", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write(jwkSetBuffer.Bytes()) + }) +} diff --git a/server/roles_test.go b/server/roles_test.go index 10a3cd2..008ef00 100644 --- a/server/roles_test.go +++ b/server/roles_test.go @@ -6,7 +6,7 @@ import ( ) func TestHasRole(t *testing.T) { - assert.True(t, HasRole("lavender:admin test:something-else", "lavender:admin")) - assert.False(t, HasRole("lavender:admin,test:something-else", "lavender:admin")) - assert.False(t, HasRole("lavender: test:something-else", "lavender:admin")) + assert.True(t, HasRole([]string{"lavender:admin", "test:something-else"}, "lavender:admin")) + assert.False(t, HasRole([]string{"lavender:admin", "test:something-else"}, "lavender:admin")) + assert.False(t, HasRole([]string{"lavender:", "test:something-else"}, "lavender:admin")) } diff --git a/server/server.go b/server/server.go index e989543..4af27a6 100644 --- a/server/server.go +++ b/server/server.go @@ -1,20 +1,16 @@ package server import ( - "bytes" - "crypto/subtle" - "encoding/json" + "errors" "github.com/1f349/cache" clientStore "github.com/1f349/lavender/client-store" "github.com/1f349/lavender/conf" "github.com/1f349/lavender/database" "github.com/1f349/lavender/issuer" - "github.com/1f349/lavender/logger" - "github.com/1f349/lavender/openid" "github.com/1f349/lavender/pages" scope2 "github.com/1f349/lavender/scope" "github.com/1f349/mjwt" - "github.com/go-oauth2/oauth2/v4/errors" + "github.com/go-oauth2/oauth2/v4/generates" "github.com/go-oauth2/oauth2/v4/manage" "github.com/go-oauth2/oauth2/v4/server" "github.com/go-oauth2/oauth2/v4/store" @@ -28,7 +24,7 @@ import ( var errInvalidScope = errors.New("missing required scope") -type HttpServer struct { +type httpServer struct { r *httprouter.Router oauthSrv *server.Server oauthMgr *manage.Manager @@ -36,7 +32,12 @@ type HttpServer struct { conf conf.Conf signingKey *mjwt.Issuer manager *issuer.Manager - flowState *cache.Cache[string, flowStateData] + + // 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 { @@ -45,52 +46,44 @@ type flowStateData struct { redirect string } -func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issuer) *httprouter.Router { - r := httprouter.New() - contentCache := time.Now() +type mailLink byte - // remove last slash from baseUrl - { - l := len(config.BaseUrl) - if config.BaseUrl[l-1] == '/' { - config.BaseUrl = config.BaseUrl[:l-1] - } - } +const ( + mailLinkDelete mailLink = iota + mailLinkResetPassword + mailLinkVerifyEmail +) - openIdConf := openid.GenConfig(config.BaseUrl, []string{"openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"}) - openIdBytes, err := json.Marshal(openIdConf) - if err != nil { - logger.Logger.Fatal("Failed to generate OpenID configuration", "err", err) - } +type mailLinkKey struct { + action mailLink + data string +} - jwkSetBuffer := new(bytes.Buffer) - err = mjwt.WriteJwkSetJson(jwkSetBuffer, []*mjwt.Issuer{signingKey}) - if err != nil { - logger.Logger.Fatal("Failed to generate JWK Set", "err", err) - } +func SetupRouter(r *httprouter.Router, config conf.Conf, db *database.Queries, signingKey *mjwt.Issuer) { + // remove last slash from baseUrl + config.BaseUrl = strings.TrimRight(config.BaseUrl, "/") + + contentCache := time.Now() - oauthManager := manage.NewDefaultManager() - oauthSrv := server.NewServer(server.NewConfig(), oauthManager) - hs := &HttpServer{ - r: httprouter.New(), - oauthSrv: oauthSrv, - oauthMgr: oauthManager, + hs := &httpServer{ + r: r, db: db, conf: config, signingKey: signingKey, - flowState: cache.New[string, flowStateData](), - } - hs.manager, err = issuer.NewManager(config.SsoServices) - if err != nil { - logger.Logger.Fatal("Failed to reload SSO service manager", "err", err) + flowState: cache.New[string, flowStateData](), + + mailLinkCache: cache.New[mailLinkKey, string](), } + oauthManager := manage.NewManager() + oauthManager.MapAuthorizeGenerate(generates.NewAuthorizeGenerate()) oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) oauthManager.MustTokenStorage(store.NewMemoryTokenStore()) - oauthManager.MapAccessGenerate(NewJWTAccessGenerate(hs.signingKey, db)) + oauthManager.MapAccessGenerate(NewMJWTAccessGenerate(signingKey, db)) oauthManager.MapClientStorage(clientStore.New(db)) + oauthSrv := server.NewDefaultServer(oauthManager) oauthSrv.SetClientInfoHandler(func(req *http.Request) (clientID, clientSecret string, err error) { cId, cSecret, err := server.ClientBasicHandler(req) if cId == "" && cSecret == "" { @@ -117,47 +110,10 @@ func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issu }) addIdTokenSupport(oauthSrv, db, signingKey) - r.GET("/.well-known/openid-configuration", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - rw.WriteHeader(http.StatusOK) - _, _ = rw.Write(openIdBytes) - }) - r.GET("/.well-known/jwks.json", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - rw.WriteHeader(http.StatusOK) - _, _ = rw.Write(jwkSetBuffer.Bytes()) - }) - r.GET("/", hs.OptionalAuthentication(hs.Home)) - - // login - r.GET("/login", hs.OptionalAuthentication(hs.loginGet)) - r.POST("/login", hs.OptionalAuthentication(hs.loginPost)) - r.GET("/callback", hs.OptionalAuthentication(hs.loginCallback)) - r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - cookie, err := req.Cookie("lavender-nonce") - if err != nil { - http.Error(rw, "Missing nonce", http.StatusBadRequest) - return - } - if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(req.PostFormValue("nonce"))) == 1 { - http.SetCookie(rw, &http.Cookie{ - Name: "lavender-login-access", - Path: "/", - MaxAge: -1, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) - http.SetCookie(rw, &http.Cookie{ - Name: "lavender-login-refresh", - Path: "/", - MaxAge: -1, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) + ssoManager := issuer.NewManager(config.SsoServices) - http.Redirect(rw, req, "/", http.StatusFound) - return - } - http.Error(rw, "Logout failed", http.StatusInternalServerError) - })) + SetupOpenId(r, config.BaseUrl, signingKey) + r.POST("/logout", hs.RequireAuthentication(fu)) // theme styles r.GET("/assets/*filepath", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { @@ -170,108 +126,11 @@ func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issu http.ServeContent(rw, req, path.Base(name), contentCache, out) }) - // management pages - r.GET("/manage/apps", hs.RequireAuthentication(hs.ManageAppsGet)) - r.GET("/manage/apps/create", hs.RequireAuthentication(hs.ManageAppsCreateGet)) - r.POST("/manage/apps", hs.RequireAuthentication(hs.ManageAppsPost)) - r.GET("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersGet)) - r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost)) - - // oauth pages - r.GET("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint)) - r.POST("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint)) - r.POST("/token", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - if err := oauthSrv.HandleTokenRequest(rw, req); err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - } - }) - userInfoRequest := func(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { - rw.Header().Set("Access-Control-Allow-Credentials", "true") - rw.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type") - rw.Header().Set("Access-Control-Allow-Origin", strings.TrimSuffix(req.Referer(), "/")) - rw.Header().Set("Access-Control-Allow-Methods", "GET") - if req.Method == http.MethodOptions { - return - } - - token, err := oauthSrv.ValidationBearerToken(req) - if err != nil { - http.Error(rw, "403 Forbidden", http.StatusForbidden) - return - } - userId := token.GetUserID() - - sso := hs.manager.FindServiceFromLogin(userId) - if sso == nil { - http.Error(rw, "Invalid user", http.StatusBadRequest) - return - } - - var user database.User - if hs.DbTx(rw, func(tx *database.Queries) (err error) { - user, err = tx.GetUser(req.Context(), userId) - return - }) { - return - } - - var userInfo UserInfoFields - err = json.Unmarshal([]byte(user.Userinfo), &userInfo) - if err != nil { - http.Error(rw, "500 Internal Server Error", http.StatusInternalServerError) - return - } - - claims := ParseClaims(token.GetScope()) - if !claims["openid"] { - http.Error(rw, "Invalid scope", http.StatusBadRequest) - return - } - - m := make(map[string]any) - - if claims["name"] { - m["name"] = userInfo["name"] - } - if claims["username"] { - m["preferred_username"] = userInfo["preferred_username"] - m["login"] = userInfo["login"] - } - if claims["profile"] { - m["profile"] = userInfo["profile"] - m["picture"] = userInfo["picture"] - m["website"] = userInfo["website"] - } - if claims["email"] { - m["email"] = userInfo["email"] - m["email_verified"] = userInfo["email_verified"] - } - if claims["birthdate"] { - m["birthdate"] = userInfo["birthdate"] - } - if claims["age"] { - m["age"] = userInfo["age"] - } - if claims["zoneinfo"] { - m["zoneinfo"] = userInfo["zoneinfo"] - } - if claims["locale"] { - m["locale"] = userInfo["locale"] - } - - m["sub"] = userId - m["aud"] = token.GetClientID() - m["updated_at"] = time.Now().Unix() - - _ = json.NewEncoder(rw).Encode(m) - } - r.GET("/userinfo", userInfoRequest) - r.OPTIONS("/userinfo", userInfoRequest) - - return r + SetupManageApps(r) + SetupManageUsers(r) } -func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) { +func (h *httpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) { redirectUrl := req.FormValue("redirect") if redirectUrl == "" { http.Redirect(rw, req, "/", http.StatusFound) From d25f9ae2cace21219fc11b666808645ec99c8433 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Sat, 5 Oct 2024 21:08:02 +0100 Subject: [PATCH 06/10] Fix a bunch more compile breaking issues --- auth/userinfofields.go | 19 ++ cmd/lavender/serve.go | 49 +++++ conf/conf.go | 17 +- database/manage-oauth.sql.go | 8 +- database/manage-users.sql.go | 174 ++++++++++++++-- .../migrations/20240820202502_init.up.sql | 25 ++- database/models.go | 56 ++--- database/otp.sql.go | 44 ++-- database/password-wrapper.go | 66 ++++-- database/profiles.sql.go | 50 +++-- database/queries/manage-oauth.sql | 2 +- database/queries/manage-users.sql | 54 ++++- database/queries/otp.sql | 24 ++- database/queries/profiles.sql | 13 +- database/queries/roles.sql | 8 + database/queries/users.sql | 16 +- database/roles.sql.go | 34 +++ database/tx.go | 26 +++ database/types/authtype.go | 2 +- database/users.sql.go | 66 +++++- go.mod | 10 +- go.sum | 8 +- issuer/manager.go | 1 + mail/from-address.go | 26 --- mail/mail.go | 116 +++-------- mail/send-template.go | 18 -- mail/templates/templates.go | 55 ----- server/auth.go | 12 +- server/auth_test.go | 73 +++++++ server/edit.go | 87 ++++++++ server/home.go | 47 +++++ server/login.go | 113 +++++++--- server/logout.go | 25 +++ server/mail.go | 123 +++++++++++ server/manage-apps.go | 60 +++--- server/manage-users.go | 40 +++- server/oauth.go | 130 +++++++++++- server/otp.go | 196 ++++++++++++++++++ server/roles_test.go | 1 - server/server.go | 60 ++---- sqlc.yaml | 8 + utils/age.go | 28 +++ utils/age_test.go | 30 +++ 43 files changed, 1571 insertions(+), 449 deletions(-) create mode 100644 database/queries/roles.sql create mode 100644 database/roles.sql.go create mode 100644 database/tx.go delete mode 100644 mail/from-address.go delete mode 100644 mail/send-template.go delete mode 100644 mail/templates/templates.go create mode 100644 server/auth_test.go create mode 100644 server/edit.go create mode 100644 server/logout.go create mode 100644 server/mail.go create mode 100644 server/otp.go create mode 100644 utils/age.go create mode 100644 utils/age_test.go diff --git a/auth/userinfofields.go b/auth/userinfofields.go index 7f2093c..3411eef 100644 --- a/auth/userinfofields.go +++ b/auth/userinfofields.go @@ -1,5 +1,7 @@ package auth +import "github.com/hardfinhq/go-date" + type UserInfoFields map[string]any func (u UserInfoFields) GetString(key string) (string, bool) { @@ -20,7 +22,24 @@ func (u UserInfoFields) GetStringOrEmpty(key string) string { return s } +func (u UserInfoFields) GetStringFromKeysOrEmpty(keys ...string) string { + for _, key := range keys { + s, _ := u[key].(string) + if s == "" { + continue + } + return s + } + return "" +} + func (u UserInfoFields) GetBoolean(key string) (bool, bool) { b, ok := u[key].(bool) return b, ok } + +func (u UserInfoFields) GetNullDate(key string) date.NullDate { + s, _ := u[key].(string) + fromStr, err := date.FromString(s) + return date.NullDate{Date: fromStr, Valid: err == nil} +} diff --git a/cmd/lavender/serve.go b/cmd/lavender/serve.go index ebdb20a..f32367f 100644 --- a/cmd/lavender/serve.go +++ b/cmd/lavender/serve.go @@ -3,10 +3,13 @@ package main import ( "context" "flag" + "fmt" "github.com/1f349/lavender" "github.com/1f349/lavender/conf" + "github.com/1f349/lavender/database" "github.com/1f349/lavender/logger" "github.com/1f349/lavender/pages" + "github.com/1f349/lavender/role" "github.com/1f349/lavender/server" "github.com/1f349/mjwt" "github.com/charmbracelet/log" @@ -114,6 +117,10 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) logger.Logger.Fatal("Failed to open database", "err", err) } + if err := checkDbHasUser(db); err != nil { + logger.Logger.Fatal("Failed to add initial user", "err", err) + } + if err := pages.LoadPages(wd); err != nil { logger.Logger.Fatal("Failed to load page templates:", err) } @@ -168,3 +175,45 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) return subcommands.ExitSuccess } + +func checkDbHasUser(db *database.Queries) error { + value, err := db.HasUser(context.Background()) + if err != nil { + return err + } + + if !value { + logger.Logger.Warn("No users are available, setting up initial admin user") + + ctx := context.Background() + err = db.UseTx(ctx, func(tx *database.Queries) error { + adminUuid, err := db.AddLocalUser(context.Background(), database.AddLocalUserParams{ + Password: "admin", + Email: "admin@localhost", + EmailVerified: false, + Name: "Admin", + Username: "admin", + ChangePassword: true, + }) + if err != nil { + return fmt.Errorf("failed to add user: %w", err) + } + roleId, err := db.AddRole(context.Background(), role.LavenderAdmin) + if err != nil { + return fmt.Errorf("failed to add role: %w", err) + } + err = db.AddUserRole(context.Background(), database.AddUserRoleParams{ + RoleID: roleId, + Subject: adminUuid, + }) + if err != nil { + return fmt.Errorf("failed to add user role: %w", err) + } + return nil + }) + if err != nil { + return err + } + } + return nil +} diff --git a/conf/conf.go b/conf/conf.go index fd8f314..49c09eb 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -6,12 +6,13 @@ import ( ) type Conf struct { - Listen string `yaml:"listen"` - BaseUrl string `yaml:"baseUrl"` - ServiceName string `yaml:"serviceName"` - Issuer string `yaml:"issuer"` - Kid string `yaml:"kid"` - Namespace string `yaml:"namespace"` - Mail mail.Mail `yaml:"mail"` - SsoServices []issuer.SsoConfig `yaml:"ssoServices"` + Listen string `yaml:"listen"` + BaseUrl string `yaml:"baseUrl"` + ServiceName string `yaml:"serviceName"` + Issuer string `yaml:"issuer"` + Kid string `yaml:"kid"` + Namespace string `yaml:"namespace"` + OtpIssuer string `yaml:"otpIssuer"` + Mail mail.Mail `yaml:"mail"` + SsoServices map[string]issuer.SsoConfig `yaml:"ssoServices"` } diff --git a/database/manage-oauth.sql.go b/database/manage-oauth.sql.go index 02d4a56..7a7e4dc 100644 --- a/database/manage-oauth.sql.go +++ b/database/manage-oauth.sql.go @@ -20,14 +20,14 @@ SELECT subject, active FROM client_store WHERE owner_subject = ? - OR ? = 1 + OR CAST(? AS BOOLEAN) = 1 LIMIT 25 OFFSET ? ` type GetAppListParams struct { - OwnerSubject string `json:"owner_subject"` - Column2 interface{} `json:"column_2"` - Offset int64 `json:"offset"` + OwnerSubject string `json:"owner_subject"` + Column2 bool `json:"column_2"` + Offset int64 `json:"offset"` } type GetAppListRow struct { diff --git a/database/manage-users.sql.go b/database/manage-users.sql.go index 8b0a99a..aa05314 100644 --- a/database/manage-users.sql.go +++ b/database/manage-users.sql.go @@ -7,10 +7,30 @@ package database import ( "context" + "database/sql" "strings" "time" + + "github.com/1f349/lavender/database/types" ) +const addUserRole = `-- name: AddUserRole :exec +INSERT INTO users_roles(role_id, user_id) +SELECT ?, users.id +FROM users +WHERE subject = ? +` + +type AddUserRoleParams struct { + RoleID int64 `json:"role_id"` + Subject string `json:"subject"` +} + +func (q *Queries) AddUserRole(ctx context.Context, arg AddUserRoleParams) error { + _, err := q.db.ExecContext(ctx, addUserRole, arg.RoleID, arg.Subject) + return err +} + const changeUserActive = `-- name: ChangeUserActive :exec UPDATE users SET active = cast(? as boolean) @@ -34,26 +54,24 @@ SELECT users.subject, website, email, email_verified, - users.updated_at as user_updated_at, - p.updated_at as profile_updated_at, + updated_at, active FROM users - INNER JOIN main.profiles p on users.subject = p.subject LIMIT 50 OFFSET ? ` type GetUserListRow struct { - Subject string `json:"subject"` - Name string `json:"name"` - Picture string `json:"picture"` - Website string `json:"website"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - UserUpdatedAt time.Time `json:"user_updated_at"` - ProfileUpdatedAt time.Time `json:"profile_updated_at"` - Active bool `json:"active"` + Subject string `json:"subject"` + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UpdatedAt time.Time `json:"updated_at"` + Active bool `json:"active"` } +// INNER JOIN main.profiles p on users.subject = p.subject func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListRow, error) { rows, err := q.db.QueryContext(ctx, getUserList, offset) if err != nil { @@ -70,8 +88,7 @@ func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListR &i.Website, &i.Email, &i.EmailVerified, - &i.UserUpdatedAt, - &i.ProfileUpdatedAt, + &i.UpdatedAt, &i.Active, ); err != nil { return nil, err @@ -87,6 +104,25 @@ func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListR return items, nil } +const getUserToken = `-- name: GetUserToken :one +SELECT access_token, refresh_token, token_expiry +FROM users +WHERE subject = ? +` + +type GetUserTokenRow struct { + AccessToken sql.NullString `json:"access_token"` + RefreshToken sql.NullString `json:"refresh_token"` + TokenExpiry sql.NullTime `json:"token_expiry"` +} + +func (q *Queries) GetUserToken(ctx context.Context, subject string) (GetUserTokenRow, error) { + row := q.db.QueryRowContext(ctx, getUserToken, subject) + var i GetUserTokenRow + err := row.Scan(&i.AccessToken, &i.RefreshToken, &i.TokenExpiry) + return i, err +} + const getUsersRoles = `-- name: GetUsersRoles :many SELECT r.role, u.id FROM users_roles @@ -133,6 +169,105 @@ func (q *Queries) GetUsersRoles(ctx context.Context, userIds []int64) ([]GetUser return items, nil } +const modifyUserAuth = `-- name: ModifyUserAuth :exec +UPDATE users +SET auth_type = ?, + auth_namespace=?, + auth_user = ? +WHERE subject = ? +` + +type ModifyUserAuthParams struct { + AuthType types.AuthType `json:"auth_type"` + AuthNamespace string `json:"auth_namespace"` + AuthUser string `json:"auth_user"` + Subject string `json:"subject"` +} + +func (q *Queries) ModifyUserAuth(ctx context.Context, arg ModifyUserAuthParams) error { + _, err := q.db.ExecContext(ctx, modifyUserAuth, + arg.AuthType, + arg.AuthNamespace, + arg.AuthUser, + arg.Subject, + ) + return err +} + +const modifyUserEmail = `-- name: ModifyUserEmail :exec +UPDATE users +SET email = ?, + email_verified=? +WHERE subject = ? +` + +type ModifyUserEmailParams struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Subject string `json:"subject"` +} + +func (q *Queries) ModifyUserEmail(ctx context.Context, arg ModifyUserEmailParams) error { + _, err := q.db.ExecContext(ctx, modifyUserEmail, arg.Email, arg.EmailVerified, arg.Subject) + return err +} + +const modifyUserRemoteLogin = `-- name: ModifyUserRemoteLogin :exec +UPDATE users +SET login = ?, + profile_url = ? +WHERE subject = ? +` + +type ModifyUserRemoteLoginParams struct { + Login string `json:"login"` + ProfileUrl string `json:"profile_url"` + Subject string `json:"subject"` +} + +func (q *Queries) ModifyUserRemoteLogin(ctx context.Context, arg ModifyUserRemoteLoginParams) error { + _, err := q.db.ExecContext(ctx, modifyUserRemoteLogin, arg.Login, arg.ProfileUrl, arg.Subject) + return err +} + +const removeUserRoles = `-- name: RemoveUserRoles :exec +DELETE +FROM users_roles +WHERE user_id IN (SELECT id + FROM users + WHERE subject = ?) +` + +func (q *Queries) RemoveUserRoles(ctx context.Context, subject string) error { + _, err := q.db.ExecContext(ctx, removeUserRoles, subject) + return err +} + +const updateUserToken = `-- name: UpdateUserToken :exec +UPDATE users +SET access_token = ?, + refresh_token=?, + token_expiry = ? +WHERE subject = ? +` + +type UpdateUserTokenParams struct { + AccessToken sql.NullString `json:"access_token"` + RefreshToken sql.NullString `json:"refresh_token"` + TokenExpiry sql.NullTime `json:"token_expiry"` + Subject string `json:"subject"` +} + +func (q *Queries) UpdateUserToken(ctx context.Context, arg UpdateUserTokenParams) error { + _, err := q.db.ExecContext(ctx, updateUserToken, + arg.AccessToken, + arg.RefreshToken, + arg.TokenExpiry, + arg.Subject, + ) + return err +} + const userEmailExists = `-- name: UserEmailExists :one SELECT EXISTS(SELECT 1 FROM users WHERE email = ? AND email_verified = 1) == 1 AS email_exists ` @@ -143,3 +278,14 @@ func (q *Queries) UserEmailExists(ctx context.Context, email string) (bool, erro err := row.Scan(&email_exists) return email_exists, err } + +const verifyUserEmail = `-- name: VerifyUserEmail :exec +UPDATE users +SET email_verified=1 +WHERE subject = ? +` + +func (q *Queries) VerifyUserEmail(ctx context.Context, subject string) error { + _, err := q.db.ExecContext(ctx, verifyUserEmail, subject) + return err +} diff --git a/database/migrations/20240820202502_init.up.sql b/database/migrations/20240820202502_init.up.sql index d78164e..3ba0bf3 100644 --- a/database/migrations/20240820202502_init.up.sql +++ b/database/migrations/20240820202502_init.up.sql @@ -21,9 +21,21 @@ CREATE TABLE users zone TEXT NOT NULL DEFAULT 'UTC', locale TEXT NOT NULL DEFAULT 'en-US', + login TEXT NOT NULL DEFAULT '', + profile_url TEXT NOT NULL DEFAULT '', + auth_type INTEGER NOT NULL, auth_namespace TEXT NOT NULL, - auth_user TEXT NOT NULL + auth_user TEXT NOT NULL, + + access_token TEXT NULL DEFAULT NULL, + refresh_token TEXT NULL DEFAULT NULL, + token_expiry DATETIME NULL DEFAULT NULL, + + otp_secret TEXT NOT NULL DEFAULT '', + otp_digits INTEGER NOT NULL DEFAULT 0, + + to_delete BOOLEAN NOT NULL DEFAULT 0 ); CREATE INDEX users_subject ON users (subject); @@ -39,21 +51,12 @@ CREATE TABLE users_roles role_id INTEGER NOT NULL, user_id INTEGER NOT NULL, - FOREIGN KEY (role_id) REFERENCES roles (id), + FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE RESTRICT, FOREIGN KEY (user_id) REFERENCES users (id), CONSTRAINT user_role UNIQUE (role_id, user_id) ); -CREATE TABLE otp -( - subject INTEGER NOT NULL UNIQUE PRIMARY KEY, - secret TEXT NOT NULL, - digits INTEGER NOT NULL, - - FOREIGN KEY (subject) REFERENCES users (subject) -); - CREATE TABLE client_store ( subject TEXT NOT NULL UNIQUE PRIMARY KEY, diff --git a/database/models.go b/database/models.go index fc0b48f..ce08f56 100644 --- a/database/models.go +++ b/database/models.go @@ -5,9 +5,12 @@ package database import ( + "database/sql" "time" + "github.com/1f349/lavender/database/types" "github.com/1f349/lavender/password" + "github.com/hardfinhq/go-date" ) type ClientStore struct { @@ -22,38 +25,39 @@ type ClientStore struct { Active bool `json:"active"` } -type Otp struct { - Subject int64 `json:"subject"` - Secret string `json:"secret"` - Digits int64 `json:"digits"` -} - -type Profile struct { - Subject string `json:"subject"` - Name string `json:"name"` - Picture string `json:"picture"` - Website string `json:"website"` - Pronouns string `json:"pronouns"` - Birthdate interface{} `json:"birthdate"` - Zone string `json:"zone"` - Locale string `json:"locale"` - UpdatedAt time.Time `json:"updated_at"` -} - type Role struct { ID int64 `json:"id"` Role string `json:"role"` } type User struct { - ID int64 `json:"id"` - Subject string `json:"subject"` - Password password.HashString `json:"password"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - UpdatedAt time.Time `json:"updated_at"` - Registered time.Time `json:"registered"` - Active bool `json:"active"` + ID int64 `json:"id"` + Subject string `json:"subject"` + Password password.HashString `json:"password"` + ChangePassword bool `json:"change_password"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UpdatedAt time.Time `json:"updated_at"` + Registered time.Time `json:"registered"` + Active bool `json:"active"` + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns types.UserPronoun `json:"pronouns"` + Birthdate date.NullDate `json:"birthdate"` + Zone string `json:"zone"` + Locale types.UserLocale `json:"locale"` + Login string `json:"login"` + ProfileUrl string `json:"profile_url"` + AuthType types.AuthType `json:"auth_type"` + AuthNamespace string `json:"auth_namespace"` + AuthUser string `json:"auth_user"` + AccessToken sql.NullString `json:"access_token"` + RefreshToken sql.NullString `json:"refresh_token"` + TokenExpiry sql.NullTime `json:"token_expiry"` + OtpSecret string `json:"otp_secret"` + OtpDigits int64 `json:"otp_digits"` + ToDelete bool `json:"to_delete"` } type UsersRole struct { diff --git a/database/otp.sql.go b/database/otp.sql.go index fc30726..04197cb 100644 --- a/database/otp.sql.go +++ b/database/otp.sql.go @@ -10,31 +10,32 @@ import ( ) const deleteOtp = `-- name: DeleteOtp :exec -DELETE -FROM otp -WHERE otp.subject = ? +UPDATE users +SET otp_secret='', + otp_digits=0 +WHERE subject = ? ` -func (q *Queries) DeleteOtp(ctx context.Context, subject int64) error { +func (q *Queries) DeleteOtp(ctx context.Context, subject string) error { _, err := q.db.ExecContext(ctx, deleteOtp, subject) return err } const getOtp = `-- name: GetOtp :one -SELECT secret, digits -FROM otp +SELECT otp_secret, otp_digits +FROM users WHERE subject = ? ` type GetOtpRow struct { - Secret string `json:"secret"` - Digits int64 `json:"digits"` + OtpSecret string `json:"otp_secret"` + OtpDigits int64 `json:"otp_digits"` } -func (q *Queries) GetOtp(ctx context.Context, subject int64) (GetOtpRow, error) { +func (q *Queries) GetOtp(ctx context.Context, subject string) (GetOtpRow, error) { row := q.db.QueryRowContext(ctx, getOtp, subject) var i GetOtpRow - err := row.Scan(&i.Secret, &i.Digits) + err := row.Scan(&i.OtpSecret, &i.OtpDigits) return i, err } @@ -52,10 +53,13 @@ func (q *Queries) GetUserEmail(ctx context.Context, subject string) (string, err } const hasOtp = `-- name: HasOtp :one -SELECT EXISTS(SELECT 1 FROM otp WHERE subject = ?) == 1 as hasOtp +SELECT CAST(1 AS BOOLEAN) AS hasOtp +FROM users +WHERE subject = ? + AND otp_secret != '' ` -func (q *Queries) HasOtp(ctx context.Context, subject int64) (bool, error) { +func (q *Queries) HasOtp(ctx context.Context, subject string) (bool, error) { row := q.db.QueryRowContext(ctx, hasOtp, subject) var hasotp bool err := row.Scan(&hasotp) @@ -63,19 +67,19 @@ func (q *Queries) HasOtp(ctx context.Context, subject int64) (bool, error) { } const setOtp = `-- name: SetOtp :exec -INSERT OR -REPLACE -INTO otp (subject, secret, digits) -VALUES (?, ?, ?) +UPDATE users +SET otp_secret = ?, + otp_digits=? +WHERE subject = ? ` type SetOtpParams struct { - Subject int64 `json:"subject"` - Secret string `json:"secret"` - Digits int64 `json:"digits"` + OtpSecret string `json:"otp_secret"` + OtpDigits int64 `json:"otp_digits"` + Subject string `json:"subject"` } func (q *Queries) SetOtp(ctx context.Context, arg SetOtpParams) error { - _, err := q.db.ExecContext(ctx, setOtp, arg.Subject, arg.Secret, arg.Digits) + _, err := q.db.ExecContext(ctx, setOtp, arg.OtpSecret, arg.OtpDigits, arg.Subject) return err } diff --git a/database/password-wrapper.go b/database/password-wrapper.go index 07f94ee..f30b251 100644 --- a/database/password-wrapper.go +++ b/database/password-wrapper.go @@ -2,35 +2,69 @@ package database import ( "context" + "github.com/1f349/lavender/database/types" "github.com/1f349/lavender/password" "github.com/google/uuid" "time" ) -type AddUserParams struct { - Name string `json:"name"` - Subject string `json:"subject"` - Password string `json:"password"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - UpdatedAt time.Time `json:"updated_at"` - Active bool `json:"active"` +type AddLocalUserParams struct { + Password string `json:"password"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Username string `json:"username"` + ChangePassword bool `json:"change_password"` } -func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (string, error) { +func (q *Queries) AddLocalUser(ctx context.Context, arg AddLocalUserParams) (string, error) { pwHash, err := password.HashPassword(arg.Password) if err != nil { return "", err } n := time.Now() a := addUserParams{ - Subject: uuid.NewString(), - Password: pwHash, - Email: arg.Email, - EmailVerified: arg.EmailVerified, - UpdatedAt: n, - Registered: n, - Active: true, + Subject: uuid.NewString(), + Password: pwHash, + Email: arg.Email, + EmailVerified: arg.EmailVerified, + UpdatedAt: n, + Registered: n, + Active: true, + Name: arg.Name, + Login: arg.Username, + ChangePassword: arg.ChangePassword, + AuthType: types.AuthTypeLocal, + AuthNamespace: "", + AuthUser: arg.Username, + } + return a.Subject, q.addUser(ctx, a) +} + +type AddOAuthUserParams struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Username string `json:"username"` + AuthNamespace string `json:"auth_namespace"` + AuthUser string `json:"auth_user"` +} + +func (q *Queries) AddOAuthUser(ctx context.Context, arg AddOAuthUserParams) (string, error) { + n := time.Now() + a := addUserParams{ + Subject: uuid.NewString(), + Email: arg.Email, + EmailVerified: arg.EmailVerified, + UpdatedAt: n, + Registered: n, + Active: true, + Name: arg.Name, + Login: arg.Username, + ChangePassword: false, + AuthType: types.AuthTypeOauth2, + AuthNamespace: arg.AuthNamespace, + AuthUser: arg.AuthUser, } return a.Subject, q.addUser(ctx, a) } diff --git a/database/profiles.sql.go b/database/profiles.sql.go index fd54f5c..d92f220 100644 --- a/database/profiles.sql.go +++ b/database/profiles.sql.go @@ -8,17 +8,38 @@ package database import ( "context" "time" + + "github.com/1f349/lavender/database/types" + "github.com/hardfinhq/go-date" ) const getProfile = `-- name: GetProfile :one -SELECT profiles.subject, profiles.name, profiles.picture, profiles.website, profiles.pronouns, profiles.birthdate, profiles.zone, profiles.locale, profiles.updated_at -FROM profiles +SELECT subject, + name, + picture, + website, + pronouns, + birthdate, + zone, + locale +FROM users WHERE subject = ? ` -func (q *Queries) GetProfile(ctx context.Context, subject string) (Profile, error) { +type GetProfileRow struct { + Subject string `json:"subject"` + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns types.UserPronoun `json:"pronouns"` + Birthdate date.NullDate `json:"birthdate"` + Zone string `json:"zone"` + Locale types.UserLocale `json:"locale"` +} + +func (q *Queries) GetProfile(ctx context.Context, subject string) (GetProfileRow, error) { row := q.db.QueryRowContext(ctx, getProfile, subject) - var i Profile + var i GetProfileRow err := row.Scan( &i.Subject, &i.Name, @@ -28,13 +49,12 @@ func (q *Queries) GetProfile(ctx context.Context, subject string) (Profile, erro &i.Birthdate, &i.Zone, &i.Locale, - &i.UpdatedAt, ) return i, err } const modifyProfile = `-- name: ModifyProfile :exec -UPDATE profiles +UPDATE users SET name = ?, picture = ?, website = ?, @@ -47,15 +67,15 @@ WHERE subject = ? ` type ModifyProfileParams struct { - Name string `json:"name"` - Picture string `json:"picture"` - Website string `json:"website"` - Pronouns string `json:"pronouns"` - Birthdate interface{} `json:"birthdate"` - Zone string `json:"zone"` - Locale string `json:"locale"` - UpdatedAt time.Time `json:"updated_at"` - Subject string `json:"subject"` + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns types.UserPronoun `json:"pronouns"` + Birthdate date.NullDate `json:"birthdate"` + Zone string `json:"zone"` + Locale types.UserLocale `json:"locale"` + UpdatedAt time.Time `json:"updated_at"` + Subject string `json:"subject"` } func (q *Queries) ModifyProfile(ctx context.Context, arg ModifyProfileParams) error { diff --git a/database/queries/manage-oauth.sql b/database/queries/manage-oauth.sql index 7225f40..12cb631 100644 --- a/database/queries/manage-oauth.sql +++ b/database/queries/manage-oauth.sql @@ -15,7 +15,7 @@ SELECT subject, active FROM client_store WHERE owner_subject = ? - OR ? = 1 + OR CAST(? AS BOOLEAN) = 1 LIMIT 25 OFFSET ?; -- name: InsertClientApp :exec diff --git a/database/queries/manage-users.sql b/database/queries/manage-users.sql index 587b87e..b9814bb 100644 --- a/database/queries/manage-users.sql +++ b/database/queries/manage-users.sql @@ -5,11 +5,10 @@ SELECT users.subject, website, email, email_verified, - users.updated_at as user_updated_at, - p.updated_at as profile_updated_at, + updated_at, active FROM users - INNER JOIN main.profiles p on users.subject = p.subject +--INNER JOIN main.profiles p on users.subject = p.subject LIMIT 50 OFFSET ?; -- name: GetUsersRoles :many @@ -24,5 +23,54 @@ UPDATE users SET active = cast(? as boolean) WHERE subject = ?; +-- name: VerifyUserEmail :exec +UPDATE users +SET email_verified=1 +WHERE subject = ?; + -- name: UserEmailExists :one SELECT EXISTS(SELECT 1 FROM users WHERE email = ? AND email_verified = 1) == 1 AS email_exists; + +-- name: ModifyUserEmail :exec +UPDATE users +SET email = ?, + email_verified=? +WHERE subject = ?; + +-- name: ModifyUserAuth :exec +UPDATE users +SET auth_type = ?, + auth_namespace=?, + auth_user = ? +WHERE subject = ?; + +-- name: ModifyUserRemoteLogin :exec +UPDATE users +SET login = ?, + profile_url = ? +WHERE subject = ?; + +-- name: UpdateUserToken :exec +UPDATE users +SET access_token = ?, + refresh_token=?, + token_expiry = ? +WHERE subject = ?; + +-- name: GetUserToken :one +SELECT access_token, refresh_token, token_expiry +FROM users +WHERE subject = ?; + +-- name: RemoveUserRoles :exec +DELETE +FROM users_roles +WHERE user_id IN (SELECT id + FROM users + WHERE subject = ?); + +-- name: AddUserRole :exec +INSERT INTO users_roles(role_id, user_id) +SELECT ?, users.id +FROM users +WHERE subject = ?; diff --git a/database/queries/otp.sql b/database/queries/otp.sql index 175399a..94599c5 100644 --- a/database/queries/otp.sql +++ b/database/queries/otp.sql @@ -1,21 +1,25 @@ -- name: SetOtp :exec -INSERT OR -REPLACE -INTO otp (subject, secret, digits) -VALUES (?, ?, ?); +UPDATE users +SET otp_secret = ?, + otp_digits=? +WHERE subject = ?; -- name: DeleteOtp :exec -DELETE -FROM otp -WHERE otp.subject = ?; +UPDATE users +SET otp_secret='', + otp_digits=0 +WHERE subject = ?; -- name: GetOtp :one -SELECT secret, digits -FROM otp +SELECT otp_secret, otp_digits +FROM users WHERE subject = ?; -- name: HasOtp :one -SELECT EXISTS(SELECT 1 FROM otp WHERE subject = ?) == 1 as hasOtp; +SELECT CAST(1 AS BOOLEAN) AS hasOtp +FROM users +WHERE subject = ? + AND otp_secret != ''; -- name: GetUserEmail :one SELECT email diff --git a/database/queries/profiles.sql b/database/queries/profiles.sql index 134da89..74203c6 100644 --- a/database/queries/profiles.sql +++ b/database/queries/profiles.sql @@ -1,10 +1,17 @@ -- name: GetProfile :one -SELECT profiles.* -FROM profiles +SELECT subject, + name, + picture, + website, + pronouns, + birthdate, + zone, + locale +FROM users WHERE subject = ?; -- name: ModifyProfile :exec -UPDATE profiles +UPDATE users SET name = ?, picture = ?, website = ?, diff --git a/database/queries/roles.sql b/database/queries/roles.sql new file mode 100644 index 0000000..3c7d54e --- /dev/null +++ b/database/queries/roles.sql @@ -0,0 +1,8 @@ +-- name: AddRole :execlastid +INSERT OR IGNORE INTO roles(role) +VALUES (?); + +-- name: RemoveRole :exec +DELETE +FROM roles +WHERE role = ?; diff --git a/database/queries/users.sql b/database/queries/users.sql index 1a916aa..0d33bfc 100644 --- a/database/queries/users.sql +++ b/database/queries/users.sql @@ -3,15 +3,11 @@ SELECT count(subject) > 0 AS hasUser FROM users; -- name: addUser :exec -INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) -VALUES (?, ?, ?, ?, ?, ?, ?); - --- name: addOAuthUser :exec -INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) -VALUES (?, ?, ?, ?, ?, ?, ?); +INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active, name, login, change_password, auth_type, auth_namespace, auth_user) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: checkLogin :one -SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified +SELECT subject, password, CAST(otp_secret != '' AS BOOLEAN) AS has_otp, email, email_verified FROM users WHERE users.subject = ? LIMIT 1; @@ -48,3 +44,9 @@ SET password = ?, updated_at=? WHERE subject = ? AND password = ?; + +-- name: FlagUserAsDeleted :exec +UPDATE users +SET active= false, + to_delete = true +WHERE subject = ?; diff --git a/database/roles.sql.go b/database/roles.sql.go new file mode 100644 index 0000000..fa557bd --- /dev/null +++ b/database/roles.sql.go @@ -0,0 +1,34 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: roles.sql + +package database + +import ( + "context" +) + +const addRole = `-- name: AddRole :execlastid +INSERT OR IGNORE INTO roles(role) +VALUES (?) +` + +func (q *Queries) AddRole(ctx context.Context, role string) (int64, error) { + result, err := q.db.ExecContext(ctx, addRole, role) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const removeRole = `-- name: RemoveRole :exec +DELETE +FROM roles +WHERE role = ? +` + +func (q *Queries) RemoveRole(ctx context.Context, role string) error { + _, err := q.db.ExecContext(ctx, removeRole, role) + return err +} diff --git a/database/tx.go b/database/tx.go new file mode 100644 index 0000000..15f8522 --- /dev/null +++ b/database/tx.go @@ -0,0 +1,26 @@ +package database + +import ( + "context" + "database/sql" + "errors" +) + +var errCannotOpenTransactionWithoutSqlDB = errors.New("cannot open transaction without sql.DB") + +func (q *Queries) UseTx(ctx context.Context, cb func(tx *Queries) error) error { + sqlDB, ok := q.db.(*sql.DB) + if !ok { + panic(errCannotOpenTransactionWithoutSqlDB) + } + tx, err := sqlDB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + err = cb(q.WithTx(tx)) + if err != nil { + return err + } + return tx.Commit() +} diff --git a/database/types/authtype.go b/database/types/authtype.go index 903f717..09ee32b 100644 --- a/database/types/authtype.go +++ b/database/types/authtype.go @@ -3,7 +3,7 @@ package types type AuthType byte const ( - AuthTypeBase AuthType = iota + AuthTypeLocal AuthType = iota AuthTypeOauth2 ) diff --git a/database/users.sql.go b/database/users.sql.go index 04c1c02..7b7b6ef 100644 --- a/database/users.sql.go +++ b/database/users.sql.go @@ -9,11 +9,24 @@ import ( "context" "time" + "github.com/1f349/lavender/database/types" "github.com/1f349/lavender/password" ) +const flagUserAsDeleted = `-- name: FlagUserAsDeleted :exec +UPDATE users +SET active= false, + to_delete = true +WHERE subject = ? +` + +func (q *Queries) FlagUserAsDeleted(ctx context.Context, subject string) error { + _, err := q.db.ExecContext(ctx, flagUserAsDeleted, subject) + return err +} + const getUser = `-- name: GetUser :one -SELECT id, subject, password, email, email_verified, updated_at, registered, active +SELECT id, subject, password, change_password, email, email_verified, updated_at, registered, active, name, picture, website, pronouns, birthdate, zone, locale, login, profile_url, auth_type, auth_namespace, auth_user, access_token, refresh_token, token_expiry, otp_secret, otp_digits, to_delete FROM users WHERE subject = ? LIMIT 1 @@ -26,11 +39,30 @@ func (q *Queries) GetUser(ctx context.Context, subject string) (User, error) { &i.ID, &i.Subject, &i.Password, + &i.ChangePassword, &i.Email, &i.EmailVerified, &i.UpdatedAt, &i.Registered, &i.Active, + &i.Name, + &i.Picture, + &i.Website, + &i.Pronouns, + &i.Birthdate, + &i.Zone, + &i.Locale, + &i.Login, + &i.ProfileUrl, + &i.AuthType, + &i.AuthNamespace, + &i.AuthUser, + &i.AccessToken, + &i.RefreshToken, + &i.TokenExpiry, + &i.OtpSecret, + &i.OtpDigits, + &i.ToDelete, ) return i, err } @@ -98,18 +130,24 @@ func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) error } const addUser = `-- name: addUser :exec -INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) -VALUES (?, ?, ?, ?, ?, ?, ?) +INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active, name, login, change_password, auth_type, auth_namespace, auth_user) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type addUserParams struct { - Subject string `json:"subject"` - Password password.HashString `json:"password"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - UpdatedAt time.Time `json:"updated_at"` - Registered time.Time `json:"registered"` - Active bool `json:"active"` + Subject string `json:"subject"` + Password password.HashString `json:"password"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UpdatedAt time.Time `json:"updated_at"` + Registered time.Time `json:"registered"` + Active bool `json:"active"` + Name string `json:"name"` + Login string `json:"login"` + ChangePassword bool `json:"change_password"` + AuthType types.AuthType `json:"auth_type"` + AuthNamespace string `json:"auth_namespace"` + AuthUser string `json:"auth_user"` } func (q *Queries) addUser(ctx context.Context, arg addUserParams) error { @@ -121,6 +159,12 @@ func (q *Queries) addUser(ctx context.Context, arg addUserParams) error { arg.UpdatedAt, arg.Registered, arg.Active, + arg.Name, + arg.Login, + arg.ChangePassword, + arg.AuthType, + arg.AuthNamespace, + arg.AuthUser, ) return err } @@ -151,7 +195,7 @@ func (q *Queries) changeUserPassword(ctx context.Context, arg changeUserPassword } const checkLogin = `-- name: checkLogin :one -SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified +SELECT subject, password, CAST(otp_secret != '' AS BOOLEAN) AS has_otp, email, email_verified FROM users WHERE users.subject = ? LIMIT 1 diff --git a/go.mod b/go.mod index dc2b926..63fa064 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,10 @@ require ( github.com/1f349/cache v0.0.3 github.com/1f349/mjwt v0.4.1 github.com/1f349/overlapfs v0.0.1 - github.com/1f349/tulip v0.0.0-20240725211619-6b19e2d4ca63 + github.com/1f349/simplemail v0.0.5 github.com/charmbracelet/log v0.4.0 github.com/cloudflare/tableflip v1.2.3 github.com/emersion/go-message v0.18.1 - github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 - github.com/emersion/go-smtp v0.21.3 github.com/go-oauth2/oauth2/v4 v4.5.2 github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-migrate/migrate/v4 v4.17.1 @@ -21,10 +19,13 @@ require ( github.com/julienschmidt/httprouter v1.3.0 github.com/mattn/go-sqlite3 v1.14.22 github.com/mrmelon54/pronouns v1.0.3 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/afero v1.11.0 github.com/stretchr/testify v1.9.0 + github.com/xlzd/gotp v0.1.0 golang.org/x/crypto v0.26.0 golang.org/x/oauth2 v0.22.0 + golang.org/x/sync v0.8.0 golang.org/x/text v0.17.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -36,6 +37,8 @@ require ( github.com/charmbracelet/lipgloss v0.12.1 // indirect github.com/charmbracelet/x/ansi v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect + github.com/emersion/go-smtp v0.21.3 // indirect github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect @@ -63,6 +66,5 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect golang.org/x/net v0.28.0 // indirect - golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 98374f7..7717885 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,8 @@ github.com/1f349/overlapfs v0.0.1 h1:LAxBolrXFAgU0yqZtXg/C/aaPq3eoQSPpBc49BHuTp0 github.com/1f349/overlapfs v0.0.1/go.mod h1:I6aItQycr7nrzplmfNXp/QF9tTmKRSgY3fXmu/7Ky2o= github.com/1f349/rsa-helper v0.0.2 h1:N/fLQqg5wrjIzG6G4zdwa5Xcv9/jIPutCls9YekZr9U= github.com/1f349/rsa-helper v0.0.2/go.mod h1:VUQ++1tYYhYrXeOmVFkQ82BegR24HQEJHl5lHbjg7yg= -github.com/1f349/tulip v0.0.0-20240725211619-6b19e2d4ca63 h1:jPg+0bgKD5kY7yQtRZqeba+BGKFE51evGvwewZwa7Xc= -github.com/1f349/tulip v0.0.0-20240725211619-6b19e2d4ca63/go.mod h1:1zFQhcbgiyPSWHVMp0cXJjmd6FhasP5bf5tWS4ZK61A= +github.com/1f349/simplemail v0.0.5 h1:cr+8pdWhFE/+XVSO7ZTjntySbmIbTqmDy2SR9cHAPLE= +github.com/1f349/simplemail v0.0.5/go.mod h1:ppAIqkvVkI6L99EefbR5NgOjpePNK/RKgeoehj5A+kU= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= @@ -146,6 +146,8 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= @@ -199,6 +201,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= +github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= diff --git a/issuer/manager.go b/issuer/manager.go index 87b74dc..8520c15 100644 --- a/issuer/manager.go +++ b/issuer/manager.go @@ -25,6 +25,7 @@ func NewManager(services map[string]SsoConfig) (*Manager, error) { } // save by namespace + conf.Namespace = namespace l.m[namespace] = conf } return l, nil diff --git a/mail/from-address.go b/mail/from-address.go deleted file mode 100644 index e52f5f8..0000000 --- a/mail/from-address.go +++ /dev/null @@ -1,26 +0,0 @@ -package mail - -import ( - "encoding/json" - "github.com/emersion/go-message/mail" -) - -type FromAddress struct { - *mail.Address -} - -var _ json.Unmarshaler = &FromAddress{} - -func (f *FromAddress) UnmarshalJSON(b []byte) error { - var a string - err := json.Unmarshal(b, &a) - if err != nil { - return err - } - address, err := mail.ParseAddress(a) - if err != nil { - return err - } - f.Address = address - return nil -} diff --git a/mail/mail.go b/mail/mail.go index 8403664..dca2e29 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -1,96 +1,48 @@ package mail import ( - "bytes" + "embed" + "errors" + "fmt" + "github.com/1f349/overlapfs" + "github.com/1f349/simplemail" "github.com/emersion/go-message/mail" - "github.com/emersion/go-sasl" - "github.com/emersion/go-smtp" - "io" - "net" - "time" + "io/fs" + "os" + "path/filepath" ) -type Mail struct { - Name string `json:"name"` - Tls bool `json:"tls"` - Server string `json:"server"` - From FromAddress `json:"from"` - Username string `json:"username"` - Password string `json:"password"` -} +//go:embed templates/*.go.html templates/*.go.txt +var embeddedTemplates embed.FS -func (m *Mail) loginInfo() sasl.Client { - return sasl.NewPlainClient("", m.Username, m.Password) +type Mail struct { + mail *simplemail.SimpleMail + name string } -func (m *Mail) mailCall(to []string, r io.Reader) error { - host, _, err := net.SplitHostPort(m.Server) - if err != nil { - return err - } - if m.Tls { - return smtp.SendMailTLS(m.Server, m.loginInfo(), m.From.String(), to, r) - } - if host == "localhost" || host == "127.0.0.1" { - // internals of smtp.SendMail without STARTTLS for localhost testing - dial, err := smtp.Dial(m.Server) - if err != nil { - return err +func New(sender *simplemail.Mail, wd, name string) (*Mail, error) { + var o fs.FS = embeddedTemplates + o, _ = fs.Sub(o, "templates") + if wd != "" { + mailDir := filepath.Join(wd, "mail-templates") + err := os.Mkdir(mailDir, os.ModePerm) + if err == nil || errors.Is(err, os.ErrExist) { + wdFs := os.DirFS(mailDir) + o = overlapfs.OverlapFS{A: embeddedTemplates, B: wdFs} } - err = dial.Auth(m.loginInfo()) - if err != nil { - return err - } - return dial.SendMail(m.From.String(), to, r) } - return smtp.SendMail(m.Server, m.loginInfo(), m.From.String(), to, r) -} - -func (m *Mail) SendMail(subject string, to []*mail.Address, htmlBody, textBody io.Reader) error { - // generate the email in this template - buf := new(bytes.Buffer) - - // setup mail headers - var h mail.Header - h.SetDate(time.Now()) - h.SetSubject(subject) - h.SetAddressList("From", []*mail.Address{m.From.Address}) - h.SetAddressList("To", to) - h.Set("Content-Type", "multipart/alternative") - - // setup html and text alternative headers - var hHtml, hTxt mail.InlineHeader - hHtml.Set("Content-Type", "text/html; charset=utf-8") - hTxt.Set("Content-Type", "text/plain; charset=utf-8") - createWriter, err := mail.CreateWriter(buf, h) - if err != nil { - return err - } - inline, err := createWriter.CreateInline() - if err != nil { - return err - } - partHtml, err := inline.CreatePart(hHtml) - if err != nil { - return err - } - if _, err := io.Copy(partHtml, htmlBody); err != nil { - return err - } - partTxt, err := inline.CreatePart(hTxt) - if err != nil { - return err - } - if _, err := io.Copy(partTxt, textBody); err != nil { - return err - } - - // convert all to addresses to strings - toStr := make([]string, len(to)) - for i := range toStr { - toStr[i] = to[i].String() - } + simpleMail, err := simplemail.New(sender, o) + return &Mail{ + mail: simpleMail, + name: name, + }, err +} - return m.mailCall(toStr, buf) +func (m *Mail) SendEmailTemplate(templateName, subject, nameOfUser string, to *mail.Address, data map[string]any) error { + return m.mail.Send(templateName, fmt.Sprintf("%s - %s", subject, m.name), to, map[string]any{ + "ServiceName": m.name, + "Name": nameOfUser, + "Data": data, + }) } diff --git a/mail/send-template.go b/mail/send-template.go deleted file mode 100644 index 5f2c22f..0000000 --- a/mail/send-template.go +++ /dev/null @@ -1,18 +0,0 @@ -package mail - -import ( - "bytes" - "fmt" - "github.com/1f349/lavender/mail/templates" - "github.com/emersion/go-message/mail" -) - -func (m *Mail) SendEmailTemplate(templateName, subject, nameOfUser string, to *mail.Address, data map[string]any) error { - var bufHtml, bufTxt bytes.Buffer - templates.RenderMailTemplate(&bufHtml, &bufTxt, templateName, map[string]any{ - "ServiceName": m.Name, - "Name": nameOfUser, - "Data": data, - }) - return m.SendMail(fmt.Sprintf("%s - %s", subject, m.Name), []*mail.Address{to}, &bufHtml, &bufTxt) -} diff --git a/mail/templates/templates.go b/mail/templates/templates.go deleted file mode 100644 index ed82df3..0000000 --- a/mail/templates/templates.go +++ /dev/null @@ -1,55 +0,0 @@ -package templates - -import ( - "embed" - "errors" - "github.com/1f349/overlapfs" - "github.com/1f349/tulip/logger" - htmlTemplate "html/template" - "io" - "io/fs" - "os" - "path/filepath" - "sync" - textTemplate "text/template" -) - -var ( - //go:embed *.go.html *.go.txt - embeddedTemplates embed.FS - mailHtmlTemplates *htmlTemplate.Template - mailTextTemplates *textTemplate.Template - loadOnce sync.Once -) - -func LoadMailTemplates(wd string) (err error) { - loadOnce.Do(func() { - var o fs.FS = embeddedTemplates - if wd != "" { - mailDir := filepath.Join(wd, "mail-templates") - err = os.Mkdir(mailDir, os.ModePerm) - if err != nil && !errors.Is(err, os.ErrExist) { - return - } - wdFs := os.DirFS(mailDir) - o = overlapfs.OverlapFS{A: embeddedTemplates, B: wdFs} - } - mailHtmlTemplates, err = htmlTemplate.New("mail").ParseFS(o, "*.go.html") - if err != nil { - return - } - mailTextTemplates, err = textTemplate.New("mail").ParseFS(o, "*.go.txt") - }) - return -} - -func RenderMailTemplate(wrHtml, wrTxt io.Writer, name string, data any) { - err := mailHtmlTemplates.ExecuteTemplate(wrHtml, name+".go.html", data) - if err != nil { - logger.Logger.Warn("Failed to render mail html", "name", name, "err", err) - } - err = mailTextTemplates.ExecuteTemplate(wrTxt, name+".go.txt", data) - if err != nil { - logger.Logger.Warn("Failed to render mail text", "name", name, "err", err) - } -} diff --git a/server/auth.go b/server/auth.go index 60e222f..79ebad8 100644 --- a/server/auth.go +++ b/server/auth.go @@ -59,7 +59,7 @@ func (h *httpServer) RequireAdminAuthentication(next UserHandler) httprouter.Han } func (h *httpServer) RequireAuthentication(next UserHandler) httprouter.Handle { - return h.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { + return h.OptionalAuthentication(false, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { if auth.IsGuest() { redirectUrl := PrepareRedirectUrl("/login", req.URL) http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) @@ -69,16 +69,20 @@ func (h *httpServer) RequireAuthentication(next UserHandler) httprouter.Handle { }) } -func (h *httpServer) OptionalAuthentication(next UserHandler) httprouter.Handle { +func (h *httpServer) OptionalAuthentication(flowPart bool, next UserHandler) httprouter.Handle { return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - authUser, err := h.internalAuthenticationHandler(rw, req) + authData, err := h.internalAuthenticationHandler(rw, req) if err != nil { if !errors.Is(err, ErrAuthHttpError) { http.Error(rw, err.Error(), http.StatusInternalServerError) } return } - next(rw, req, params, authUser) + if n := authData.NextFlowUrl(req.URL); n != nil && !flowPart { + http.Redirect(rw, req, n.String(), http.StatusFound) + return + } + next(rw, req, params, authData) } } diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 0000000..68b6603 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "context" + "github.com/1f349/mjwt" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +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 + assert.Nil(t, u.NextFlowUrl(&url.URL{})) +} + +func TestUserAuth_IsGuest(t *testing.T) { + var u UserAuth + assert.True(t, u.IsGuest()) + u.Subject = uuid.NewString() + assert.False(t, u.IsGuest()) +} + +type fakeSessionStore struct { + m map[string]any + saveFunc func(map[string]any) error +} + +func (f *fakeSessionStore) Context() context.Context { return context.Background() } +func (f *fakeSessionStore) SessionID() string { return "fakeSessionStore" } +func (f *fakeSessionStore) Set(key string, value interface{}) { f.m[key] = value } + +func (f *fakeSessionStore) Get(key string) (a interface{}, ok bool) { + if a, ok = f.m[key]; false { + } + return +} + +func TestRequireAuthentication(t *testing.T) { +} + +func TestOptionalAuthentication(t *testing.T) { + jwtIssuer, err := mjwt.NewIssuer("TestIssuer", uuid.NewString(), jwt.SigningMethodRS512) + h := &httpServer{signingKey: jwtIssuer} + rec := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "https://example.com/hello", nil) + assert.NoError(t, err) + auth, err := h.internalAuthenticationHandler(rec, req) + assert.NoError(t, err) + assert.True(t, auth.IsGuest()) + auth.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", 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()})) +} diff --git a/server/edit.go b/server/edit.go new file mode 100644 index 0000000..981cc0d --- /dev/null +++ b/server/edit.go @@ -0,0 +1,87 @@ +package server + +import ( + "fmt" + "github.com/1f349/lavender/database" + "github.com/1f349/lavender/lists" + "github.com/1f349/lavender/pages" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "net/http" + "time" +) + +func (h *httpServer) EditGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + var user database.User + + if h.DbTx(rw, func(tx *database.Queries) error { + var err error + user, err = tx.GetUser(req.Context(), auth.Subject) + if err != nil { + return fmt.Errorf("failed to read user data: %w", err) + } + return nil + }) { + return + } + + lNonce := uuid.NewString() + http.SetCookie(rw, &http.Cookie{ + Name: "tulip-nonce", + Value: lNonce, + Path: "/", + Expires: time.Now().Add(10 * time.Minute), + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + pages.RenderPageTemplate(rw, "edit", map[string]any{ + "ServiceName": h.conf.ServiceName, + "User": user, + "Nonce": lNonce, + "FieldPronoun": user.Pronouns.String(), + "ListZoneInfo": lists.ListZoneInfo(), + "ListLocale": lists.ListLocale(), + }) +} +func (h *httpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + if req.ParseForm() != nil { + rw.WriteHeader(http.StatusBadRequest) + _, _ = rw.Write([]byte("400 Bad Request\n")) + return + } + + var patch database.ProfilePatch + errs := patch.ParseFromForm(req.Form) + if len(errs) > 0 { + rw.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintln(rw, "\n\n") + _, _ = fmt.Fprintln(rw, "

400 Bad Request: Failed to parse form data, press the back button in your browser, check your inputs and try again.

") + _, _ = fmt.Fprintln(rw, "
    ") + for _, i := range errs { + _, _ = fmt.Fprintf(rw, "
  • %s
  • \n", i) + } + _, _ = fmt.Fprintln(rw, "
") + _, _ = fmt.Fprintln(rw, "\n") + return + } + m := database.ModifyProfileParams{ + Name: patch.Name, + Picture: patch.Picture, + Website: patch.Website, + Pronouns: patch.Pronouns, + Birthdate: patch.Birthdate, + Zone: patch.Zone.String(), + Locale: patch.Locale, + UpdatedAt: time.Now(), + Subject: auth.Subject, + } + if h.DbTx(rw, func(tx *database.Queries) error { + if err := tx.ModifyProfile(req.Context(), m); err != nil { + return fmt.Errorf("failed to modify user info: %w", err) + } + return nil + }) { + return + } + http.Redirect(rw, req, "/edit", http.StatusFound) +} diff --git a/server/home.go b/server/home.go index cc93e98..2b67a64 100644 --- a/server/home.go +++ b/server/home.go @@ -42,4 +42,51 @@ func (h *httpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute "Nonce": lNonce, "IsAdmin": isAdmin, }) + + // rw.Header().Set("Content-Type", "text/html") + // lNonce := uuid.NewString() + // http.SetCookie(rw, &http.Cookie{ + // Name: "tulip-nonce", + // Value: lNonce, + // Path: "/", + // Expires: time.Now().Add(10 * time.Minute), + // Secure: true, + // SameSite: http.SameSiteLaxMode, + // }) + // + // if auth.IsGuest() { + // pages.RenderPageTemplate(rw, "index-guest", map[string]any{ + // "ServiceName": h.conf.ServiceName, + // }) + // return + // } + // + // var userWithName string + // var userRole types.UserRole + // var hasTwoFactor bool + // if h.DbTx(rw, func(tx *database.Queries) (err error) { + // userWithName, err = tx.GetUserDisplayName(req.Context(), auth.Subject) + // if err != nil { + // return fmt.Errorf("failed to get user display name: %w", err) + // } + // hasTwoFactor, err = tx.HasOtp(req.Context(), auth.Subject) + // if err != nil { + // return fmt.Errorf("failed to get user two factor state: %w", err) + // } + // userRole, err = tx.GetUserRole(req.Context(), auth.Subject) + // if err != nil { + // return fmt.Errorf("failed to get user role: %w", err) + // } + // return + // }) { + // return + // } + // pages.RenderPageTemplate(rw, "index", map[string]any{ + // "ServiceName": h.conf.ServiceName, + // "Auth": auth, + // "User": database.User{Subject: auth.Subject, Name: userWithName, Role: userRole}, + // "Nonce": lNonce, + // "OtpEnabled": hasTwoFactor, + // "IsAdmin": userRole == types.RoleAdmin, + // }) } diff --git a/server/login.go b/server/login.go index 323a03c..96c56b6 100644 --- a/server/login.go +++ b/server/login.go @@ -8,6 +8,7 @@ import ( "fmt" auth2 "github.com/1f349/lavender/auth" "github.com/1f349/lavender/database" + "github.com/1f349/lavender/database/types" "github.com/1f349/lavender/issuer" "github.com/1f349/lavender/pages" "github.com/1f349/mjwt" @@ -15,13 +16,31 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" "github.com/julienschmidt/httprouter" + "github.com/mrmelon54/pronouns" "golang.org/x/oauth2" + "golang.org/x/text/language" "net/http" "net/url" "strings" "time" ) +// getUserLoginName finds the `login_name` query parameter within the `/authorize` redirect url +func getUserLoginName(req *http.Request) string { + q := req.URL.Query() + if !q.Has("redirect") { + return "" + } + originUrl, err := url.ParseRequestURI(q.Get("redirect")) + if err != nil { + return "" + } + if originUrl.Path != "/authorize" { + return "" + } + return originUrl.Query().Get("login_name") +} + func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { if !auth.IsGuest() { h.SafeRedirect(rw, req) @@ -131,41 +150,70 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK } err = h.DbTxError(func(tx *database.Queries) error { - jBytes, err := json.Marshal(sessionData.UserInfo) - if err != nil { - return err - } + name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User") + _, err = tx.GetUser(req.Context(), sessionData.Subject) + uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") + uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") if errors.Is(err, sql.ErrNoRows) { - uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") - uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") - id, err := tx.AddUser(req.Context(), database.AddUserParams{ - Name: "", - Subject: sessionData.Subject, - Password: "", + _, err := tx.AddOAuthUser(req.Context(), database.AddOAuthUserParams{ Email: uEmail, EmailVerified: uEmailVerified, - UpdatedAt: time.Now(), - Active: true, + Name: name, + Username: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"), + AuthNamespace: sso.Namespace, + AuthUser: sessionData.UserInfo.GetStringOrEmpty("sub"), }) return err - return tx.AddUser(req.Context(), database.AddUserParams{ - Subject: sessionData.Subject, - Email: uEmail, - EmailVerified: uEmailVerified, - Roles: "", - Userinfo: string(jBytes), - UpdatedAt: time.Now(), - Active: true, - }) } - uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") - uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") - return tx.UpdateUserInfo(req.Context(), database.UpdateUserInfoParams{ - Email: sessionData.Subject, + + err = tx.ModifyUserEmail(req.Context(), database.ModifyUserEmailParams{ + Email: uEmail, EmailVerified: uEmailVerified, - Userinfo: string(jBytes), - Subject: uEmail, + Subject: sessionData.Subject, + }) + if err != nil { + return err + } + + err = tx.ModifyUserAuth(req.Context(), database.ModifyUserAuthParams{ + AuthType: types.AuthTypeOauth2, + AuthNamespace: sso.Namespace, + AuthUser: sessionData.UserInfo.GetStringOrEmpty("sub"), + Subject: sessionData.Subject, + }) + if err != nil { + return err + } + + err = tx.ModifyUserRemoteLogin(req.Context(), database.ModifyUserRemoteLoginParams{ + Login: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"), + ProfileUrl: sessionData.UserInfo.GetStringOrEmpty("profile"), + Subject: sessionData.Subject, + }) + if err != nil { + return err + } + + pronoun, err := pronouns.FindPronoun(sessionData.UserInfo.GetStringOrEmpty("pronouns")) + if err != nil { + pronoun = pronouns.TheyThem + } + locale, err := language.Parse(sessionData.UserInfo.GetStringOrEmpty("locale")) + if err != nil { + locale = language.AmericanEnglish + } + + return tx.ModifyProfile(req.Context(), database.ModifyProfileParams{ + Name: name, + Picture: sessionData.UserInfo.GetStringOrEmpty("profile"), + Website: sessionData.UserInfo.GetStringOrEmpty("website"), + Pronouns: types.UserPronoun{Pronoun: pronoun}, + Birthdate: sessionData.UserInfo.GetNullDate("birthdate"), + Zone: sessionData.UserInfo.GetStringOrDefault("zoneinfo", "UTC"), + Locale: types.UserLocale{Tag: locale}, + UpdatedAt: time.Now(), + Subject: sessionData.Subject, }) }) if err != nil { @@ -177,7 +225,7 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK return tx.UpdateUserToken(req.Context(), database.UpdateUserTokenParams{ AccessToken: sql.NullString{String: token.AccessToken, Valid: true}, RefreshToken: sql.NullString{String: token.RefreshToken, Valid: true}, - Expiry: sql.NullTime{Time: token.Expiry, Valid: true}, + TokenExpiry: sql.NullTime{Time: token.Expiry, Valid: true}, Subject: sessionData.Subject, }) }); err != nil { @@ -208,6 +256,11 @@ 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 there methods + return h.setLoginDataCookie(rw, authData, "") +} + func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth, loginName string) bool { ps := auth.NewPermStorage() accId := uuid.NewString() @@ -286,13 +339,13 @@ func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Re if err != nil { return err } - if !token.AccessToken.Valid || !token.RefreshToken.Valid || !token.Expiry.Valid { + if !token.AccessToken.Valid || !token.RefreshToken.Valid || !token.TokenExpiry.Valid { return fmt.Errorf("invalid oauth token") } oauthToken = &oauth2.Token{ AccessToken: token.AccessToken.String, RefreshToken: token.RefreshToken.String, - Expiry: token.Expiry.Time, + Expiry: token.TokenExpiry.Time, } return nil }) diff --git a/server/logout.go b/server/logout.go new file mode 100644 index 0000000..1d721d2 --- /dev/null +++ b/server/logout.go @@ -0,0 +1,25 @@ +package server + +import ( + "github.com/julienschmidt/httprouter" + "net/http" +) + +func (h *httpServer) logoutPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, _ UserAuth) { + http.SetCookie(rw, &http.Cookie{ + Name: "lavender-login-access", + Path: "/", + MaxAge: -1, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + http.SetCookie(rw, &http.Cookie{ + Name: "lavender-login-refresh", + Path: "/", + MaxAge: -1, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(rw, req, "/", http.StatusFound) +} diff --git a/server/mail.go b/server/mail.go new file mode 100644 index 0000000..55759f7 --- /dev/null +++ b/server/mail.go @@ -0,0 +1,123 @@ +package server + +import ( + "github.com/1f349/lavender/database" + "github.com/1f349/lavender/pages" + "github.com/emersion/go-message/mail" + "github.com/julienschmidt/httprouter" + "net/http" +) + +func (h *httpServer) MailVerify(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + code := params.ByName("code") + + k := mailLinkKey{mailLinkVerifyEmail, code} + + userSub, ok := h.mailLinkCache.Get(k) + if !ok { + http.Error(rw, "Invalid email verification code", http.StatusBadRequest) + return + } + if h.DbTx(rw, func(tx *database.Queries) error { + return tx.VerifyUserEmail(req.Context(), userSub) + }) { + return + } + + h.mailLinkCache.Delete(k) + + http.Error(rw, "Email address has been verified, you may close this tab and return to the login page.", http.StatusOK) +} + +func (h *httpServer) MailPassword(rw http.ResponseWriter, _ *http.Request, params httprouter.Params) { + code := params.ByName("code") + + k := mailLinkKey{mailLinkResetPassword, code} + _, ok := h.mailLinkCache.Get(k) + if !ok { + http.Error(rw, "Invalid password reset code", http.StatusBadRequest) + return + } + + pages.RenderPageTemplate(rw, "reset-password", map[string]any{ + "ServiceName": h.conf.ServiceName, + "Code": code, + }) +} + +func (h *httpServer) MailPasswordPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + pw := req.PostFormValue("new_password") + rpw := req.PostFormValue("confirm_password") + code := req.PostFormValue("code") + + // reverse passwords are possible + if len(pw) == 0 { + http.Error(rw, "Cannot set an empty password", http.StatusBadRequest) + return + } + // bcrypt only allows up to 72 bytes anyway + if len(pw) > 64 { + http.Error(rw, "Security by extremely long password is a weird flex", http.StatusBadRequest) + return + } + if rpw != pw { + http.Error(rw, "Passwords do not match", http.StatusBadRequest) + return + } + + k := mailLinkKey{mailLinkResetPassword, code} + userSub, ok := h.mailLinkCache.Get(k) + if !ok { + http.Error(rw, "Invalid password reset code", http.StatusBadRequest) + return + } + + h.mailLinkCache.Delete(k) + + // reset password database call + if h.DbTx(rw, func(tx *database.Queries) error { + return tx.ChangePassword(req.Context(), userSub, pw) + }) { + return + } + + http.Error(rw, "Reset password successfully, you can login now.", http.StatusOK) +} + +func (h *httpServer) MailDelete(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + code := params.ByName("code") + + k := mailLinkKey{mailLinkDelete, code} + userSub, ok := h.mailLinkCache.Get(k) + if !ok { + http.Error(rw, "Invalid email delete code", http.StatusBadRequest) + return + } + var userInfo database.User + if h.DbTx(rw, func(tx *database.Queries) (err error) { + userInfo, err = tx.GetUser(req.Context(), userSub) + if err != nil { + return + } + return tx.FlagUserAsDeleted(req.Context(), userSub) + }) { + return + } + + h.mailLinkCache.Delete(k) + + // parse email for headers + address, err := mail.ParseAddress(userInfo.Email) + if err != nil { + http.Error(rw, "500 Internal Server Error: Failed to parse user email address", http.StatusInternalServerError) + return + } + + err = h.conf.Mail.SendEmailTemplate("mail-account-delete", "Account Deletion", userInfo.Name, address, nil) + if err != nil { + http.Error(rw, "Failed to send confirmation email.", http.StatusInternalServerError) + return + } + + http.Error(rw, "You will receive an email shortly to verify this action, you may close this tab.", http.StatusOK) +} diff --git a/server/manage-apps.go b/server/manage-apps.go index d95b752..404d46a 100644 --- a/server/manage-apps.go +++ b/server/manage-apps.go @@ -12,11 +12,17 @@ import ( "strconv" ) +func SetupManageApps(r *httprouter.Router, hs *httpServer) { + r.GET("/manage/apps", hs.RequireAuthentication(hs.ManageAppsGet)) + r.GET("/manage/apps/create", hs.RequireAuthentication(hs.ManageAppsCreateGet)) + r.POST("/manage/apps", hs.RequireAuthentication(hs.ManageAppsPost)) +} + func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { q := req.URL.Query() offset, _ := strconv.Atoi(q.Get("offset")) - var roles string + var roles []string var appList []database.GetAppListRow if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) @@ -24,9 +30,9 @@ func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ return } appList, err = tx.GetAppList(req.Context(), database.GetAppListParams{ - Owner: auth.Subject, - Column2: HasRole(roles, role.LavenderAdmin), - Offset: int64(offset), + OwnerSubject: auth.Subject, + Column2: HasRole(roles, role.LavenderAdmin), + Offset: int64(offset), }) return }) { @@ -61,7 +67,7 @@ func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ } func (h *httpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { - var roles string + var roles []string if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) return @@ -96,7 +102,7 @@ func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ active := req.Form.Has("active") if sso || hasPerms { - var roles string + var roles []string if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) return @@ -121,15 +127,15 @@ func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ return err } return tx.InsertClientApp(req.Context(), database.InsertClientAppParams{ - Subject: uuid.NewString(), - Name: name, - Secret: secret, - Domain: domain, - Owner: auth.Subject, - Perms: perms, - Public: public, - Sso: sso, - Active: active, + Subject: uuid.NewString(), + Name: name, + Secret: secret, + Domain: domain, + OwnerSubject: auth.Subject, + Perms: perms, + Public: public, + Sso: sso, + Active: active, }) }) { return @@ -137,15 +143,15 @@ func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ case "edit": if h.DbTx(rw, func(tx *database.Queries) error { return tx.UpdateClientApp(req.Context(), database.UpdateClientAppParams{ - Name: name, - Domain: domain, - Column3: hasPerms, - Perms: perms, - Public: public, - Sso: sso, - Active: active, - Subject: req.FormValue("subject"), - Owner: auth.Subject, + Name: name, + Domain: domain, + Column3: hasPerms, + Perms: perms, + Public: public, + Sso: sso, + Active: active, + Subject: req.FormValue("subject"), + OwnerSubject: auth.Subject, }) }) { return @@ -164,9 +170,9 @@ func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ return err } err = tx.ResetClientAppSecret(req.Context(), database.ResetClientAppSecretParams{ - Secret: secret, - Subject: sub, - Owner: auth.Subject, + Secret: secret, + Subject: sub, + OwnerSubject: auth.Subject, }) return err }) { diff --git a/server/manage-users.go b/server/manage-users.go index b3f3c8f..bf4fa8d 100644 --- a/server/manage-users.go +++ b/server/manage-users.go @@ -5,16 +5,22 @@ import ( "github.com/1f349/lavender/pages" "github.com/1f349/lavender/role" "github.com/julienschmidt/httprouter" + "golang.org/x/sync/errgroup" "net/http" "net/url" "strconv" ) +func SetupManageUsers(r *httprouter.Router, hs *httpServer) { + r.GET("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersGet)) + r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost)) +} + func (h *httpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { q := req.URL.Query() offset, _ := strconv.Atoi(q.Get("offset")) - var roles string + var roles []string var userList []database.GetUserListRow if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) @@ -64,7 +70,7 @@ func (h *httpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, return } - var roles string + var roles []string if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) return @@ -78,17 +84,37 @@ func (h *httpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, offset := req.Form.Get("offset") action := req.Form.Get("action") - newRoles := req.Form.Get("roles") + newRoles := req.Form["roles"] active := req.Form.Has("active") switch action { case "edit": if h.DbTx(rw, func(tx *database.Queries) error { sub := req.Form.Get("subject") - return tx.UpdateUser(req.Context(), database.UpdateUserParams{ - Active: active, - Roles: newRoles, - Subject: sub, + return tx.UseTx(req.Context(), func(tx *database.Queries) (err error) { + err = tx.ChangeUserActive(req.Context(), database.ChangeUserActiveParams{Column1: active, Subject: sub}) + if err != nil { + return err + } + err = tx.RemoveUserRoles(req.Context(), sub) + if err != nil { + return err + } + errGrp := new(errgroup.Group) + errGrp.SetLimit(3) + for _, roleName := range newRoles { + errGrp.Go(func() error { + roleId, err := strconv.ParseInt(roleName, 10, 64) + if err != nil { + return err + } + return tx.AddUserRole(req.Context(), database.AddUserRoleParams{ + RoleID: roleId, + Subject: sub, + }) + }) + } + return errGrp.Wait() }) }) { return diff --git a/server/oauth.go b/server/oauth.go index 15ba6b7..ef11372 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -1,15 +1,143 @@ package server import ( + "encoding/json" + clientStore "github.com/1f349/lavender/client-store" + "github.com/1f349/lavender/database" "github.com/1f349/lavender/logger" "github.com/1f349/lavender/pages" "github.com/1f349/lavender/scope" + "github.com/1f349/lavender/utils" + "github.com/1f349/mjwt" + "github.com/go-oauth2/oauth2/v4/generates" + "github.com/go-oauth2/oauth2/v4/manage" + "github.com/go-oauth2/oauth2/v4/server" + "github.com/go-oauth2/oauth2/v4/store" "github.com/julienschmidt/httprouter" "net/http" "net/url" "strings" + "time" ) +func SetupOAuth2(r *httprouter.Router, hs *httpServer, key *mjwt.Issuer, db *database.Queries) { + oauthManager := manage.NewManager() + oauthManager.MapAuthorizeGenerate(generates.NewAuthorizeGenerate()) + oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) + oauthManager.MustTokenStorage(store.NewMemoryTokenStore()) + oauthManager.MapAccessGenerate(NewMJWTAccessGenerate(key, db)) + oauthManager.MapClientStorage(clientStore.New(db)) + + oauthSrv := server.NewDefaultServer(oauthManager) + oauthSrv.SetClientInfoHandler(func(req *http.Request) (clientID, clientSecret string, err error) { + cId, cSecret, err := server.ClientBasicHandler(req) + if cId == "" && cSecret == "" { + cId, cSecret, err = server.ClientFormHandler(req) + } + if err != nil { + return "", "", err + } + return cId, cSecret, nil + }) + oauthSrv.SetUserAuthorizationHandler(hs.oauthUserAuthorization) + oauthSrv.SetAuthorizeScopeHandler(func(rw http.ResponseWriter, req *http.Request) (string, error) { + var form url.Values + if req.Method == http.MethodPost { + form = req.PostForm + } else { + form = req.URL.Query() + } + a := form.Get("scope") + if !scope.ScopesExist(a) { + return "", errInvalidScope + } + return a, nil + }) + addIdTokenSupport(oauthSrv, db, key) + + r.GET("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint)) + r.POST("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint)) + r.POST("/token", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + if err := oauthSrv.HandleTokenRequest(rw, req); err != nil { + http.Error(rw, "Failed to handle token request", http.StatusInternalServerError) + } + }) +} + +func (h *httpServer) userInfoRequest(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Access-Control-Allow-Credentials", "true") + rw.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type") + rw.Header().Set("Access-Control-Allow-Origin", strings.TrimSuffix(req.Referer(), "/")) + rw.Header().Set("Access-Control-Allow-Methods", "GET") + if req.Method == http.MethodOptions { + return + } + + token, err := h.oauthSrv.ValidationBearerToken(req) + if err != nil { + http.Error(rw, "403 Forbidden", http.StatusForbidden) + return + } + userId := token.GetUserID() + + sso := h.manager.FindServiceFromLogin(userId) + if sso == nil { + http.Error(rw, "Invalid user", http.StatusBadRequest) + return + } + + var user database.User + if h.DbTx(rw, func(tx *database.Queries) (err error) { + user, err = tx.GetUser(req.Context(), userId) + return + }) { + return + } + + claims := ParseClaims(token.GetScope()) + if !claims["openid"] { + http.Error(rw, "Invalid scope", http.StatusBadRequest) + return + } + + m := make(map[string]any) + + if claims["name"] { + m["name"] = user.Name + } + if claims["username"] { + m["preferred_username"] = user.Login + m["login"] = user.Login + } + if claims["profile"] { + m["profile"] = user.ProfileUrl + m["picture"] = user.Picture + m["website"] = user.Website + } + if claims["email"] { + m["email"] = user.Email + m["email_verified"] = user.EmailVerified + } + if claims["birthdate"] && user.Birthdate.Valid { + m["birthdate"] = user.Birthdate.Date + } + if claims["age"] && user.Birthdate.Valid { + m["age"] = utils.Age(user.Birthdate.Date.ToTime()) + } + if claims["zoneinfo"] { + m["zoneinfo"] = user.Zone + } + if claims["locale"] { + m["locale"] = user.Locale + } + + m["sub"] = userId + m["aud"] = token.GetClientID() + m["updated_at"] = time.Now().Unix() + + _ = json.NewEncoder(rw).Encode(m) +} + func (h *httpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { // function is only called with GET or POST method isPost := req.Method == http.MethodPost @@ -95,7 +223,7 @@ func (h *httpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request "ServiceName": h.conf.ServiceName, "AppName": appName, "AppDomain": appDomain, - "DisplayName": auth.DisplayName, + "DisplayName": auth.UserInfo.GetStringOrEmpty("name"), "WantsList": scope.FancyScopeList(scopeList), "ResponseType": form.Get("response_type"), "ResponseMode": form.Get("response_mode"), diff --git a/server/otp.go b/server/otp.go new file mode 100644 index 0000000..0a7e799 --- /dev/null +++ b/server/otp.go @@ -0,0 +1,196 @@ +package server + +import ( + "bytes" + "context" + "encoding/base64" + "github.com/1f349/lavender/database" + "github.com/1f349/lavender/pages" + "github.com/julienschmidt/httprouter" + "github.com/skip2/go-qrcode" + "github.com/xlzd/gotp" + "html/template" + "image/png" + "net/http" + "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) { + if req.Method == http.MethodPost && req.FormValue("remove") == "1" { + if !req.Form.Has("code") { + // render page + pages.RenderPageTemplate(rw, "remove-otp", map[string]any{ + "ServiceName": h.conf.ServiceName, + }) + return + } + + otpInput := req.Form.Get("code") + if h.fetchAndValidateOtp(rw, auth.Subject, otpInput) { + return + } + + if h.DbTx(rw, func(tx *database.Queries) error { + return tx.DeleteOtp(req.Context(), auth.Subject) + }) { + return + } + + http.Redirect(rw, req, "/", http.StatusFound) + return + } + + var digits int + switch req.FormValue("digits") { + case "6": + digits = 6 + case "7": + digits = 7 + case "8": + digits = 8 + default: + http.Error(rw, "400 Bad Request: Invalid number of digits for OTP code", http.StatusBadRequest) + return + } + + secret := req.FormValue("secret") + if !gotp.IsSecretValid(secret) { + http.Error(rw, "400 Bad Request: Invalid secret", http.StatusBadRequest) + return + } + + if secret == "" { + // get user email + var email string + if h.DbTx(rw, func(tx *database.Queries) error { + var err error + email, err = tx.GetUserEmail(req.Context(), auth.Subject) + return err + }) { + return + } + + secret = gotp.RandomSecret(64) + if secret == "" { + http.Error(rw, "500 Internal Server Error: failed to generate OTP secret", http.StatusInternalServerError) + return + } + totp := gotp.NewTOTP(secret, digits, 30, nil) + otpUri := totp.ProvisioningUri(email, h.conf.OtpIssuer) + code, err := qrcode.New(otpUri, qrcode.Medium) + if err != nil { + http.Error(rw, "500 Internal Server Error: failed to generate QR code", http.StatusInternalServerError) + return + } + qrImg := code.Image(60 * 4) + qrBounds := qrImg.Bounds() + qrWidth := qrBounds.Dx() + + qrBuf := new(bytes.Buffer) + if png.Encode(qrBuf, qrImg) != nil { + http.Error(rw, "500 Internal Server Error: failed to generate PNG image of QR code", http.StatusInternalServerError) + return + } + + // render page + pages.RenderPageTemplate(rw, "edit-otp", map[string]any{ + "ServiceName": h.conf.ServiceName, + "OtpQr": template.URL("data:qrImg/png;base64," + base64.StdEncoding.EncodeToString(qrBuf.Bytes())), + "QrWidth": qrWidth, + "OtpUrl": otpUri, + "OtpSecret": secret, + "OtpDigits": digits, + }) + return + } + + totp := gotp.NewTOTP(secret, digits, 30, nil) + + if !verifyTotp(totp, req.FormValue("code")) { + http.Error(rw, "400 Bad Request: invalid OTP code", http.StatusBadRequest) + return + } + + if h.DbTx(rw, func(tx *database.Queries) error { + return tx.SetOtp(req.Context(), database.SetOtpParams{ + Subject: auth.Subject, + OtpSecret: secret, + OtpDigits: int64(digits), + }) + }) { + return + } + + http.Redirect(rw, req, "/", http.StatusFound) +} + +func verifyTotp(totp *gotp.TOTP, code string) bool { + t := time.Now() + if totp.VerifyTime(code, t) { + return true + } + if totp.VerifyTime(code, t.Add(-30*time.Second)) { + return true + } + return totp.VerifyTime(code, t.Add(30*time.Second)) +} diff --git a/server/roles_test.go b/server/roles_test.go index 008ef00..2925f75 100644 --- a/server/roles_test.go +++ b/server/roles_test.go @@ -7,6 +7,5 @@ import ( func TestHasRole(t *testing.T) { assert.True(t, HasRole([]string{"lavender:admin", "test:something-else"}, "lavender:admin")) - assert.False(t, HasRole([]string{"lavender:admin", "test:something-else"}, "lavender:admin")) assert.False(t, HasRole([]string{"lavender:", "test:something-else"}, "lavender:admin")) } diff --git a/server/server.go b/server/server.go index 4af27a6..7e170e6 100644 --- a/server/server.go +++ b/server/server.go @@ -3,17 +3,14 @@ package server import ( "errors" "github.com/1f349/cache" - clientStore "github.com/1f349/lavender/client-store" "github.com/1f349/lavender/conf" "github.com/1f349/lavender/database" "github.com/1f349/lavender/issuer" + "github.com/1f349/lavender/logger" "github.com/1f349/lavender/pages" - scope2 "github.com/1f349/lavender/scope" "github.com/1f349/mjwt" - "github.com/go-oauth2/oauth2/v4/generates" "github.com/go-oauth2/oauth2/v4/manage" "github.com/go-oauth2/oauth2/v4/server" - "github.com/go-oauth2/oauth2/v4/store" "github.com/julienschmidt/httprouter" "net/http" "net/url" @@ -76,44 +73,15 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, db *database.Queries, s mailLinkCache: cache.New[mailLinkKey, string](), } - oauthManager := manage.NewManager() - oauthManager.MapAuthorizeGenerate(generates.NewAuthorizeGenerate()) - oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) - oauthManager.MustTokenStorage(store.NewMemoryTokenStore()) - oauthManager.MapAccessGenerate(NewMJWTAccessGenerate(signingKey, db)) - oauthManager.MapClientStorage(clientStore.New(db)) - - oauthSrv := server.NewDefaultServer(oauthManager) - oauthSrv.SetClientInfoHandler(func(req *http.Request) (clientID, clientSecret string, err error) { - cId, cSecret, err := server.ClientBasicHandler(req) - if cId == "" && cSecret == "" { - cId, cSecret, err = server.ClientFormHandler(req) - } - if err != nil { - return "", "", err - } - return cId, cSecret, nil - }) - oauthSrv.SetUserAuthorizationHandler(hs.oauthUserAuthorization) - oauthSrv.SetAuthorizeScopeHandler(func(rw http.ResponseWriter, req *http.Request) (scope string, err error) { - var form url.Values - if req.Method == http.MethodPost { - form = req.PostForm - } else { - form = req.URL.Query() - } - a := form.Get("scope") - if !scope2.ScopesExist(a) { - return "", errInvalidScope - } - return a, nil - }) - addIdTokenSupport(oauthSrv, db, signingKey) - - ssoManager := issuer.NewManager(config.SsoServices) + var err error + hs.manager, err = issuer.NewManager(config.SsoServices) + if err != nil { + logger.Logger.Fatal("Failed to load SSO services", "err", err) + } SetupOpenId(r, config.BaseUrl, signingKey) - r.POST("/logout", hs.RequireAuthentication(fu)) + r.GET("/", hs.OptionalAuthentication(false, hs.Home)) + r.POST("/logout", hs.RequireAuthentication(hs.logoutPost)) // theme styles r.GET("/assets/*filepath", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { @@ -126,8 +94,16 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, db *database.Queries, s http.ServeContent(rw, req, path.Base(name), contentCache, out) }) - SetupManageApps(r) - SetupManageUsers(r) + // 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) + SetupManageUsers(r, hs) + SetupOAuth2(r, hs, signingKey, db) } func (h *httpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) { diff --git a/sqlc.yaml b/sqlc.yaml index 2716e86..2b22daf 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -21,3 +21,11 @@ sql: go_type: "github.com/1f349/lavender/database/types.UserZone" - column: "users.locale" go_type: "github.com/1f349/lavender/database/types.UserLocale" + - column: "users.auth_type" + go_type: "github.com/1f349/lavender/database/types.AuthType" + - column: "users.access_token" + go_type: "database/sql.NullString" + - column: "users.refresh_token" + go_type: "database/sql.NullString" + - column: "users.token_expiry" + go_type: "database/sql.NullTime" diff --git a/utils/age.go b/utils/age.go new file mode 100644 index 0000000..eaf1b91 --- /dev/null +++ b/utils/age.go @@ -0,0 +1,28 @@ +package utils + +import ( + "time" +) + +var ageTimeNow = time.Now + +func Age(t time.Time) int { + n := ageTimeNow() + + // the birthday is in the future so the age is 0 + if n.Before(t) { + return 0 + } + + // the year difference + dy := n.Year() - t.Year() + + // the birthday in the current year + tCurrent := t.AddDate(dy, 0, 0) + + // minus 1 if the birthday has not yet occurred in the current year + if tCurrent.Before(n) { + dy -= 1 + } + return dy +} diff --git a/utils/age_test.go b/utils/age_test.go new file mode 100644 index 0000000..0972227 --- /dev/null +++ b/utils/age_test.go @@ -0,0 +1,30 @@ +package utils + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestAge(t *testing.T) { + lGmt := time.FixedZone("GMT", 0) + lBst := time.FixedZone("BST", 60*60) + + tPast := time.Date(1939, time.January, 5, 0, 0, 0, 0, lGmt) + tPastDst := time.Date(2001, time.January, 5, 1, 0, 0, 0, lBst) + tCur := time.Date(2005, time.January, 5, 0, 30, 0, 0, lGmt) + tCurDst := time.Date(2005, time.January, 5, 0, 30, 0, 0, lBst) + tFut := time.Date(2008, time.January, 5, 0, 0, 0, 0, time.UTC) + + ageTimeNow = func() time.Time { return tCur } + assert.Equal(t, 65, Age(tPast)) + assert.Equal(t, 3, Age(tPastDst)) + assert.Equal(t, 0, Age(tFut)) + + ageTimeNow = func() time.Time { return tCurDst } + assert.Equal(t, 66, Age(tPast)) + assert.Equal(t, 4, Age(tPastDst)) + fmt.Println(tPastDst.AddDate(4, 0, 0).UTC(), tCur.UTC()) + assert.Equal(t, 0, Age(tFut)) +} From 7a41cd403b6e2a30fee0bc0ec01e628b694e3267 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Sun, 6 Oct 2024 12:03:46 +0100 Subject: [PATCH 07/10] Update dependencies --- go.mod | 26 +++++++++++++------------- go.sum | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 63fa064..4e559db 100644 --- a/go.mod +++ b/go.mod @@ -12,21 +12,21 @@ require ( github.com/emersion/go-message v0.18.1 github.com/go-oauth2/oauth2/v4 v4.5.2 github.com/golang-jwt/jwt/v4 v4.5.0 - github.com/golang-migrate/migrate/v4 v4.17.1 + github.com/golang-migrate/migrate/v4 v4.18.1 github.com/google/subcommands v1.2.0 github.com/google/uuid v1.6.0 github.com/hardfinhq/go-date v1.20240411.1 github.com/julienschmidt/httprouter v1.3.0 - github.com/mattn/go-sqlite3 v1.14.22 + github.com/mattn/go-sqlite3 v1.14.24 github.com/mrmelon54/pronouns v1.0.3 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/afero v1.11.0 github.com/stretchr/testify v1.9.0 github.com/xlzd/gotp v0.1.0 - golang.org/x/crypto v0.26.0 - golang.org/x/oauth2 v0.22.0 + golang.org/x/crypto v0.28.0 + golang.org/x/oauth2 v0.23.0 golang.org/x/sync v0.8.0 - golang.org/x/text v0.17.0 + golang.org/x/text v0.19.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -34,8 +34,8 @@ require ( github.com/1f349/rsa-helper v0.0.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/becheran/wildmatch-go v1.0.0 // indirect - github.com/charmbracelet/lipgloss v0.12.1 // indirect - github.com/charmbracelet/x/ansi v0.2.1 // indirect + github.com/charmbracelet/lipgloss v0.13.0 // indirect + github.com/charmbracelet/x/ansi v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect github.com/emersion/go-smtp v0.21.3 // indirect @@ -45,7 +45,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/klauspost/compress v1.17.9 // 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 @@ -56,15 +56,15 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/tidwall/btree v1.7.0 // indirect - github.com/tidwall/buntdb v1.3.1 // indirect - github.com/tidwall/gjson v1.17.3 // indirect + github.com/tidwall/buntdb v1.3.2 // indirect + github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/grect v0.1.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sys v0.24.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 7717885..ccd5bda 100644 --- a/go.sum +++ b/go.sum @@ -19,10 +19,14 @@ github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2 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= @@ -57,6 +61,8 @@ github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOW 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= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= @@ -101,6 +107,8 @@ github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q 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= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -120,6 +128,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T 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= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/mrmelon54/pronouns v1.0.3 h1:VJqOnNxIw44q0dRJrBEvOCkKPYGvPYcNRKwPtLildXg= @@ -168,10 +178,14 @@ github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EU 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= github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg= github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= @@ -218,8 +232,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y 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= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -236,9 +254,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug 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/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= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -267,6 +288,8 @@ 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= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -279,6 +302,8 @@ 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= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= From 7e5a8b99213bf8250aedc8f6438947b2942a5803 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Sun, 6 Oct 2024 15:50:23 +0100 Subject: [PATCH 08/10] A bunch of changes --- conf/conf.go | 18 ++--- database/queries/users.sql | 7 ++ database/users.sql.go | 21 ++++++ issuer/sso.go | 17 ++--- server/db.go | 17 ++++- server/login.go | 130 +++++++++++++++++++++++-------------- server/oauth.go | 28 +++++--- test-client/pop2.js | 2 +- 8 files changed, 162 insertions(+), 78 deletions(-) diff --git a/conf/conf.go b/conf/conf.go index 49c09eb..bd8f734 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -6,13 +6,13 @@ import ( ) type Conf struct { - Listen string `yaml:"listen"` - BaseUrl string `yaml:"baseUrl"` - ServiceName string `yaml:"serviceName"` - Issuer string `yaml:"issuer"` - Kid string `yaml:"kid"` - Namespace string `yaml:"namespace"` - OtpIssuer string `yaml:"otpIssuer"` - Mail mail.Mail `yaml:"mail"` - SsoServices map[string]issuer.SsoConfig `yaml:"ssoServices"` + Listen string `yaml:"listen"` + BaseUrl string `yaml:"baseUrl"` + ServiceName string `yaml:"serviceName"` + Issuer string `yaml:"issuer"` + Kid string `yaml:"kid"` + Namespace string `yaml:"namespace"` + OtpIssuer string `yaml:"otpIssuer"` + Mail mail.Mail `yaml:"mail"` + SsoServices []issuer.SsoConfig `yaml:"ssoServices"` } diff --git a/database/queries/users.sql b/database/queries/users.sql index 0d33bfc..2c57ab9 100644 --- a/database/queries/users.sql +++ b/database/queries/users.sql @@ -50,3 +50,10 @@ UPDATE users SET active= false, to_delete = true WHERE subject = ?; + +-- name: FindUserByAuth :one +SELECT subject +FROM users +WHERE auth_type = ? + AND auth_namespace = ? + AND auth_user = ?; diff --git a/database/users.sql.go b/database/users.sql.go index 7b7b6ef..ce0da96 100644 --- a/database/users.sql.go +++ b/database/users.sql.go @@ -13,6 +13,27 @@ import ( "github.com/1f349/lavender/password" ) +const findUserByAuth = `-- name: FindUserByAuth :one +SELECT subject +FROM users +WHERE auth_type = ? + AND auth_namespace = ? + AND auth_user = ? +` + +type FindUserByAuthParams struct { + AuthType types.AuthType `json:"auth_type"` + AuthNamespace string `json:"auth_namespace"` + AuthUser string `json:"auth_user"` +} + +func (q *Queries) FindUserByAuth(ctx context.Context, arg FindUserByAuthParams) (string, error) { + row := q.db.QueryRowContext(ctx, findUserByAuth, arg.AuthType, arg.AuthNamespace, arg.AuthUser) + var subject string + err := row.Scan(&subject) + return subject, err +} + const flagUserAsDeleted = `-- name: FlagUserAsDeleted :exec UPDATE users SET active= false, diff --git a/issuer/sso.go b/issuer/sso.go index 59ddf40..3683a17 100644 --- a/issuer/sso.go +++ b/issuer/sso.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "slices" - "strings" ) var httpGet = http.Get @@ -17,9 +16,11 @@ var httpGet = http.Get // SsoConfig is the base URL for an OAUTH/OPENID/SSO login service // The path `/.well-known/openid-configuration` should be available type SsoConfig struct { - Addr utils.JsonUrl `json:"addr"` // https://login.example.com - Namespace string `json:"namespace"` // example.com - Client SsoConfigClient `json:"client"` + Addr utils.JsonUrl `json:"addr" yaml:"addr"` // https://login.example.com + Namespace string `json:"namespace" yaml:"namespace"` // example.com + Registration bool `json:"registration" yaml:"registration"` + LoginWithButton bool `json:"login_with_button" yaml:"loginWithButton"` + Client SsoConfigClient `json:"client" yaml:"client"` } type SsoConfigClient struct { @@ -30,14 +31,10 @@ type SsoConfigClient struct { func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) { // generate openid config url - u := s.Addr.String() - if !strings.HasSuffix(u, "/") { - u += "/" - } - u += ".well-known/openid-configuration" + u := s.Addr.JoinPath(".well-known/openid-configuration") // fetch metadata - get, err := httpGet(u) + get, err := httpGet(u.String()) if err != nil { return nil, err } diff --git a/server/db.go b/server/db.go index 4627152..e32bf5c 100644 --- a/server/db.go +++ b/server/db.go @@ -1,13 +1,24 @@ package server import ( - "errors" "github.com/1f349/lavender/database" "github.com/1f349/lavender/logger" "net/http" ) -var ErrDatabaseActionFailed = errors.New("database action failed") +var _ error = (*ErrDatabaseActionFailed)(nil) + +type ErrDatabaseActionFailed struct { + err error +} + +func (e ErrDatabaseActionFailed) Error() string { + return "database action failed: " + e.err.Error() +} + +func (e ErrDatabaseActionFailed) Unwrap() error { + return e.err +} // DbTx wraps a database transaction with http error messages and a simple action // function. If the action function returns an error the transaction will be @@ -27,7 +38,7 @@ func (h *httpServer) DbTxError(action func(tx *database.Queries) error) error { err := action(h.db) if err != nil { logger.Logger.Warn("Database action error", "err", err) - return ErrDatabaseActionFailed + return ErrDatabaseActionFailed{err: err} } return nil } diff --git a/server/login.go b/server/login.go index 96c56b6..5a55abd 100644 --- a/server/login.go +++ b/server/login.go @@ -149,44 +149,95 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK return UserAuth{}, fmt.Errorf("failed to fetch user info") } - err = h.DbTxError(func(tx *database.Queries) error { - name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User") + // TODO(melon): fix this to use a merging of lavender and tulip auth - _, err = tx.GetUser(req.Context(), sessionData.Subject) - uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") - uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") - if errors.Is(err, sql.ErrNoRows) { - _, err := tx.AddOAuthUser(req.Context(), database.AddOAuthUserParams{ - Email: uEmail, - EmailVerified: uEmailVerified, - Name: name, - Username: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"), - AuthNamespace: sso.Namespace, - AuthUser: sessionData.UserInfo.GetStringOrEmpty("sub"), - }) - return err - } + // find an existing user with the matching oauth2 namespace and subject + var userSubject string + err = h.DbTxError(func(tx *database.Queries) (err error) { + userSubject, err = tx.FindUserByAuth(req.Context(), database.FindUserByAuthParams{ + AuthType: types.AuthTypeOauth2, + AuthNamespace: sso.Namespace, + AuthUser: sessionData.Subject, + }) + return + }) + switch { + case err == nil: + // user already exists + err = h.DbTxError(func(tx *database.Queries) error { + return h.updateOAuth2UserProfile(req.Context(), tx, sessionData) + }) + return UserAuth{ + Subject: userSubject, + NeedOtp: sessionData.NeedOtp, + UserInfo: sessionData.UserInfo, + }, err + case errors.Is(err, sql.ErrNoRows): + // happy path for registration + break + default: + // another error occurred + return UserAuth{}, err + } - err = tx.ModifyUserEmail(req.Context(), database.ModifyUserEmailParams{ + // guard for disabled registration + if !sso.Config.Registration { + return UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source") + } + + // TODO(melon): rework this + name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User") + uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") + uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") + + err = h.DbTxError(func(tx *database.Queries) (err error) { + userSubject, err = tx.AddOAuthUser(req.Context(), database.AddOAuthUserParams{ Email: uEmail, EmailVerified: uEmailVerified, - Subject: sessionData.Subject, - }) - if err != nil { - return err - } - - err = tx.ModifyUserAuth(req.Context(), database.ModifyUserAuthParams{ - AuthType: types.AuthTypeOauth2, + Name: name, + Username: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"), AuthNamespace: sso.Namespace, AuthUser: sessionData.UserInfo.GetStringOrEmpty("sub"), - Subject: sessionData.Subject, }) if err != nil { return err } - err = tx.ModifyUserRemoteLogin(req.Context(), database.ModifyUserRemoteLoginParams{ + // if adding the user succeeds then update the profile + return h.updateOAuth2UserProfile(req.Context(), tx, sessionData) + }) + if err != nil { + return UserAuth{}, err + } + + // only continues if the above tx succeeds + if err := h.DbTxError(func(tx *database.Queries) error { + return tx.UpdateUserToken(req.Context(), database.UpdateUserTokenParams{ + AccessToken: sql.NullString{String: token.AccessToken, Valid: true}, + RefreshToken: sql.NullString{String: token.RefreshToken, Valid: true}, + TokenExpiry: sql.NullTime{Time: token.Expiry, Valid: true}, + Subject: sessionData.Subject, + }) + }); err != nil { + return UserAuth{}, err + } + + // TODO(melon): this feels bad + sessionData = UserAuth{ + Subject: userSubject, + NeedOtp: sessionData.NeedOtp, + UserInfo: sessionData.UserInfo, + } + + return sessionData, nil +} + +func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData UserAuth) error { + // all of these updates must succeed + return tx.UseTx(ctx, func(tx *database.Queries) error { + name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User") + + err := tx.ModifyUserRemoteLogin(ctx, database.ModifyUserRemoteLoginParams{ Login: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"), ProfileUrl: sessionData.UserInfo.GetStringOrEmpty("profile"), Subject: sessionData.Subject, @@ -204,7 +255,7 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK locale = language.AmericanEnglish } - return tx.ModifyProfile(req.Context(), database.ModifyProfileParams{ + return tx.ModifyProfile(ctx, database.ModifyProfileParams{ Name: name, Picture: sessionData.UserInfo.GetStringOrEmpty("profile"), Website: sessionData.UserInfo.GetStringOrEmpty("website"), @@ -216,23 +267,6 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK Subject: sessionData.Subject, }) }) - if err != nil { - return UserAuth{}, err - } - - // only continues if the above tx succeeds - if err := h.DbTxError(func(tx *database.Queries) error { - return tx.UpdateUserToken(req.Context(), database.UpdateUserTokenParams{ - AccessToken: sql.NullString{String: token.AccessToken, Valid: true}, - RefreshToken: sql.NullString{String: token.RefreshToken, Valid: true}, - TokenExpiry: sql.NullTime{Time: token.Expiry, Valid: true}, - Subject: sessionData.Subject, - }) - }); err != nil { - return UserAuth{}, err - } - - return sessionData, nil } const twelveHours = 12 * time.Hour @@ -257,7 +291,7 @@ 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 there methods + // TODO(melon): should probably merge these methods return h.setLoginDataCookie(rw, authData, "") } @@ -377,7 +411,9 @@ func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Toke if !ok { return UserAuth{}, fmt.Errorf("invalid subject") } - subject += "@" + sso.Config.Namespace + + // TODO(melon): there is no need for this + //subject += "@" + sso.Config.Namespace return UserAuth{ Subject: subject, diff --git a/server/oauth.go b/server/oauth.go index ef11372..e79fb4a 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "fmt" clientStore "github.com/1f349/lavender/client-store" "github.com/1f349/lavender/database" "github.com/1f349/lavender/logger" @@ -9,6 +10,8 @@ import ( "github.com/1f349/lavender/scope" "github.com/1f349/lavender/utils" "github.com/1f349/mjwt" + "github.com/go-oauth2/oauth2/v4" + "github.com/go-oauth2/oauth2/v4/errors" "github.com/go-oauth2/oauth2/v4/generates" "github.com/go-oauth2/oauth2/v4/manage" "github.com/go-oauth2/oauth2/v4/server" @@ -16,12 +19,13 @@ import ( "github.com/julienschmidt/httprouter" "net/http" "net/url" + "runtime" "strings" "time" ) func SetupOAuth2(r *httprouter.Router, hs *httpServer, key *mjwt.Issuer, db *database.Queries) { - oauthManager := manage.NewManager() + oauthManager := manage.NewDefaultManager() oauthManager.MapAuthorizeGenerate(generates.NewAuthorizeGenerate()) oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) oauthManager.MustTokenStorage(store.NewMemoryTokenStore()) @@ -53,7 +57,19 @@ func SetupOAuth2(r *httprouter.Router, hs *httpServer, key *mjwt.Issuer, db *dat } return a, nil }) + oauthSrv.ClientAuthorizedHandler = func(clientID string, grant oauth2.GrantType) (allowed bool, err error) { + return true, nil + } addIdTokenSupport(oauthSrv, db, key) + oauthSrv.ResponseErrorHandler = func(re *errors.Response) { + buf := make([]byte, 1<<20) + n := runtime.Stack(buf, false) + fmt.Printf("%#v\n", re) + fmt.Printf("%s\n", buf[:n]) + } + + hs.oauthMgr = oauthManager + hs.oauthSrv = oauthSrv r.GET("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint)) r.POST("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint)) @@ -62,9 +78,11 @@ func SetupOAuth2(r *httprouter.Router, hs *httpServer, key *mjwt.Issuer, db *dat http.Error(rw, "Failed to handle token request", http.StatusInternalServerError) } }) + r.GET("/userinfo", hs.userInfoRequest) + r.OPTIONS("/userinfo", hs.userInfoRequest) } -func (h *httpServer) userInfoRequest(rw http.ResponseWriter, req *http.Request) { +func (h *httpServer) userInfoRequest(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { rw.Header().Set("Access-Control-Allow-Credentials", "true") rw.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type") rw.Header().Set("Access-Control-Allow-Origin", strings.TrimSuffix(req.Referer(), "/")) @@ -80,12 +98,6 @@ func (h *httpServer) userInfoRequest(rw http.ResponseWriter, req *http.Request) } userId := token.GetUserID() - sso := h.manager.FindServiceFromLogin(userId) - if sso == nil { - http.Error(rw, "Invalid user", http.StatusBadRequest) - return - } - var user database.User if h.DbTx(rw, func(tx *database.Queries) (err error) { user, err = tx.GetUser(req.Context(), userId) diff --git a/test-client/pop2.js b/test-client/pop2.js index 0220d39..62199a2 100644 --- a/test-client/pop2.js +++ b/test-client/pop2.js @@ -33,7 +33,7 @@ parseInt(window.location.hash.replace(/^.*expires_in=([^&]+).*$/, '$1')) ); } - if (window.location.search.indexOf('error=')) { + if (window.location.hash.indexOf('error=')) { window.opener.POP2.receiveToken('ERROR'); } } From 2171cece75358391ee2eb061ed53da0a5ee6abd5 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Sun, 6 Oct 2024 21:30:39 +0100 Subject: [PATCH 09/10] Start new auth interfaces --- auth/auth.go | 87 +++++++++++++++++-- auth/login.go | 48 ++++++++++ auth/otp.go | 70 +++++++++++++++ .../migrations/20240820202502_init.up.sql | 3 +- database/models.go | 1 + database/password-wrapper.go | 4 +- database/queries/users.sql | 2 +- database/users.sql.go | 9 +- server/auth.go | 6 +- 9 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 auth/otp.go diff --git a/auth/auth.go b/auth/auth.go index 413e728..bb777ac 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -1,11 +1,88 @@ package auth -import "github.com/1f349/lavender/database" +import ( + "context" + "errors" + "fmt" + "github.com/1f349/lavender/database" + "net/http" +) -type LoginProvider interface { - AttemptLogin(username, password string) (database.User, error) +type Factor byte + +const ( + FactorFirst Factor = 1 << iota + FactorSecond + // FactorAuthorized defines the "authorized" state of a session + FactorAuthorized +) + +type Provider interface { + // Factor defines the factors potentially supported by the provider + // Some factors might be unavailable due to user preference + Factor() Factor + + // Name defines a string value for the provider, useful for template switching + Name() string + + // RenderData stores values to send to the templating function + RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error + + // AttemptLogin processes the login request + 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") + +type UserSafeError struct { + Display string + Code int + Internal error +} + +func (e UserSafeError) Error() string { + return fmt.Sprintf("%s [%d]: %v", e.Display, e.Code, e.Internal) +} + +func (e UserSafeError) Unwrap() error { + return e.Internal +} + +func BasicUserSafeError(code int, message string) UserSafeError { + return UserSafeError{ + Code: code, + Display: message, + Internal: errors.New(message), + } +} + +func AdminSafeError(inner error) UserSafeError { + return UserSafeError{ + Code: http.StatusInternalServerError, + Display: "Internal server error", + Internal: inner, + } +} + +type lookupUserDB interface { + GetUser(ctx context.Context, subject string) (database.User, error) } -type OAuthProvider interface { - AttemptLogin(username string) (database.User, error) +func lookupUser(ctx context.Context, db lookupUserDB, subject string, resolvesTwoFactor bool, user *database.User) error { + getUser, err := db.GetUser(ctx, subject) + if err != nil { + return err + } + *user = getUser + if user.NeedFactor && !resolvesTwoFactor { + return ErrRequiresSecondFactor + } + return nil } diff --git a/auth/login.go b/auth/login.go index 8832b06..6d20092 100644 --- a/auth/login.go +++ b/auth/login.go @@ -1 +1,49 @@ package auth + +import ( + "context" + "database/sql" + "errors" + "github.com/1f349/lavender/database" + "net/http" +) + +type basicLoginDB interface { + lookupUserDB + CheckLogin(ctx context.Context, un, pw string) (database.CheckLoginResult, error) +} + +var _ Provider = (*BasicLogin)(nil) + +type BasicLogin struct { + DB basicLoginDB +} + +func (b *BasicLogin) Factor() Factor { + return FactorFirst +} + +func (b *BasicLogin) Name() string { return "basic" } + +func (b *BasicLogin) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error { + data["username"] = req.FormValue("username") + return nil +} + +func (b *BasicLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { + un := req.FormValue("username") + pw := req.FormValue("password") + if len(pw) < 8 { + return BasicUserSafeError(http.StatusBadRequest, "Password too short") + } + + login, err := b.DB.CheckLogin(ctx, un, pw) + switch { + case err == nil: + return lookupUser(ctx, b.DB, login.Subject, false, user) + case errors.Is(err, sql.ErrNoRows): + return BasicUserSafeError(http.StatusForbidden, "Username or password is invalid") + default: + return err + } +} diff --git a/auth/otp.go b/auth/otp.go new file mode 100644 index 0000000..0f58ea6 --- /dev/null +++ b/auth/otp.go @@ -0,0 +1,70 @@ +package auth + +import ( + "context" + "github.com/1f349/lavender/database" + "github.com/xlzd/gotp" + "net/http" + "time" +) + +func isDigitsSupported(digits int64) bool { + return digits >= 6 && digits <= 8 +} + +type otpLoginDB interface { + lookupUserDB + CheckLogin(ctx context.Context, un, pw string) (database.CheckLoginResult, error) +} + +var _ Provider = (*OtpLogin)(nil) + +type OtpLogin struct { + db otpLoginDB +} + +func (b *OtpLogin) Factor() Factor { + return FactorSecond +} + +func (b *OtpLogin) Name() string { return "basic" } + +func (b *OtpLogin) RenderData(_ context.Context, _ *http.Request, user *database.User, data map[string]any) error { + if user.Subject == "" { + return ErrRequiresPreviousFactor + } + if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) { + return ErrUserDoesNotSupportFactor + } + + // no need to provide render data + return nil +} + +func (b *OtpLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { + if user == nil || user.Subject == "" { + return ErrRequiresPreviousFactor + } + if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) { + return ErrUserDoesNotSupportFactor + } + + code := req.FormValue("code") + + totp := gotp.NewTOTP(user.OtpSecret, int(user.OtpDigits), 30, nil) + if !verifyTotp(totp, code) { + return BasicUserSafeError(http.StatusBadRequest, "invalid OTP code") + } + return nil +} + +func verifyTotp(totp *gotp.TOTP, code string) bool { + t := time.Now() + if totp.VerifyTime(code, t) { + return true + } + if totp.VerifyTime(code, t.Add(-30*time.Second)) { + return true + } + return totp.VerifyTime(code, t.Add(30*time.Second)) +} diff --git a/database/migrations/20240820202502_init.up.sql b/database/migrations/20240820202502_init.up.sql index 3ba0bf3..11d6920 100644 --- a/database/migrations/20240820202502_init.up.sql +++ b/database/migrations/20240820202502_init.up.sql @@ -35,7 +35,8 @@ CREATE TABLE users otp_secret TEXT NOT NULL DEFAULT '', otp_digits INTEGER NOT NULL DEFAULT 0, - to_delete BOOLEAN NOT NULL DEFAULT 0 + to_delete BOOLEAN NOT NULL DEFAULT 0, + need_factor BOOLEAN NOT NULL DEFAULT 0 ); CREATE INDEX users_subject ON users (subject); diff --git a/database/models.go b/database/models.go index ce08f56..fb755b9 100644 --- a/database/models.go +++ b/database/models.go @@ -58,6 +58,7 @@ type User struct { OtpSecret string `json:"otp_secret"` OtpDigits int64 `json:"otp_digits"` ToDelete bool `json:"to_delete"` + NeedFactor bool `json:"need_factor"` } type UsersRole struct { diff --git a/database/password-wrapper.go b/database/password-wrapper.go index f30b251..c665dc2 100644 --- a/database/password-wrapper.go +++ b/database/password-wrapper.go @@ -71,7 +71,7 @@ func (q *Queries) AddOAuthUser(ctx context.Context, arg AddOAuthUserParams) (str type CheckLoginResult struct { Subject string `json:"subject"` - HasOtp bool `json:"has_otp"` + NeedFactor bool `json:"need_factor"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` } @@ -87,7 +87,7 @@ func (q *Queries) CheckLogin(ctx context.Context, un, pw string) (CheckLoginResu } return CheckLoginResult{ Subject: login.Subject, - HasOtp: login.HasOtp, + NeedFactor: login.NeedFactor, Email: login.Email, EmailVerified: login.EmailVerified, }, nil diff --git a/database/queries/users.sql b/database/queries/users.sql index 2c57ab9..1cb5ebf 100644 --- a/database/queries/users.sql +++ b/database/queries/users.sql @@ -7,7 +7,7 @@ INSERT INTO users (subject, password, email, email_verified, updated_at, registe VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: checkLogin :one -SELECT subject, password, CAST(otp_secret != '' AS BOOLEAN) AS has_otp, email, email_verified +SELECT subject, password, need_factor, email, email_verified FROM users WHERE users.subject = ? LIMIT 1; diff --git a/database/users.sql.go b/database/users.sql.go index ce0da96..dafce61 100644 --- a/database/users.sql.go +++ b/database/users.sql.go @@ -47,7 +47,7 @@ func (q *Queries) FlagUserAsDeleted(ctx context.Context, subject string) error { } const getUser = `-- name: GetUser :one -SELECT id, subject, password, change_password, email, email_verified, updated_at, registered, active, name, picture, website, pronouns, birthdate, zone, locale, login, profile_url, auth_type, auth_namespace, auth_user, access_token, refresh_token, token_expiry, otp_secret, otp_digits, to_delete +SELECT id, subject, password, change_password, email, email_verified, updated_at, registered, active, name, picture, website, pronouns, birthdate, zone, locale, login, profile_url, auth_type, auth_namespace, auth_user, access_token, refresh_token, token_expiry, otp_secret, otp_digits, to_delete, need_factor FROM users WHERE subject = ? LIMIT 1 @@ -84,6 +84,7 @@ func (q *Queries) GetUser(ctx context.Context, subject string) (User, error) { &i.OtpSecret, &i.OtpDigits, &i.ToDelete, + &i.NeedFactor, ) return i, err } @@ -216,7 +217,7 @@ func (q *Queries) changeUserPassword(ctx context.Context, arg changeUserPassword } const checkLogin = `-- name: checkLogin :one -SELECT subject, password, CAST(otp_secret != '' AS BOOLEAN) AS has_otp, email, email_verified +SELECT subject, password, need_factor, email, email_verified FROM users WHERE users.subject = ? LIMIT 1 @@ -225,7 +226,7 @@ LIMIT 1 type checkLoginRow struct { Subject string `json:"subject"` Password password.HashString `json:"password"` - HasOtp bool `json:"has_otp"` + NeedFactor bool `json:"need_factor"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` } @@ -236,7 +237,7 @@ func (q *Queries) checkLogin(ctx context.Context, subject string) (checkLoginRow err := row.Scan( &i.Subject, &i.Password, - &i.HasOtp, + &i.NeedFactor, &i.Email, &i.EmailVerified, ) diff --git a/server/auth.go b/server/auth.go index 79ebad8..89f0851 100644 --- a/server/auth.go +++ b/server/auth.go @@ -16,15 +16,15 @@ type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprout type UserAuth struct { Subject string - NeedOtp bool + 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.NeedOtp { - return PrepareRedirectUrl("/login/otp", origin) + if u.Factor < auth.FactorAuthorized { + return PrepareRedirectUrl("/login", origin) } return nil } From a0b3570aab02ef2cd17451b6f9e5eac07796f073 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Fri, 25 Oct 2024 15:08:56 +0100 Subject: [PATCH 10/10] Start new frontend project and other changes --- auth/auth.go | 33 +- auth/login.go | 4 +- auth/oauth.go | 95 ++++++ auth/otp.go | 39 ++- auth/passkey.go | 48 +++ auth/userauth.go | 55 +++ frontend/.gitignore | 24 ++ frontend/.vscode/extensions.json | 3 + frontend/README.md | 47 +++ frontend/index.html | 13 + frontend/package.json | 21 ++ frontend/public/vite.svg | 1 + frontend/src/App.svelte | 47 +++ frontend/src/app.css | 79 +++++ frontend/src/assets/svelte.svg | 1 + frontend/src/lib/Counter.svelte | 10 + frontend/src/main.ts | 8 + frontend/src/vite-env.d.ts | 2 + frontend/svelte.config.js | 7 + frontend/tsconfig.json | 21 ++ frontend/tsconfig.node.json | 12 + frontend/vite.config.ts | 7 + frontend/yarn.lock | 538 ++++++++++++++++++++++++++++++ go.mod | 2 - go.sum | 28 +- issuer/manager.go | 14 +- issuer/manager_test.go | 16 +- pages/edit-otp.go.html | 27 ++ pages/edit-password.go.html | 29 ++ pages/edit.go.html | 72 ++++ pages/index.go.html | 19 +- pages/login.go.html | 56 +++- pages/manage-users-create.go.html | 46 +++ pages/remove-otp.go.html | 22 ++ pages/reset-password.go.html | 26 ++ server/auth.go | 71 +--- server/auth_test.go | 39 +-- server/edit.go | 5 +- server/home.go | 3 +- server/login.go | 152 +++++---- server/logout.go | 3 +- server/manage-apps.go | 7 +- server/manage-users.go | 5 +- server/oauth.go | 5 +- server/otp.go | 68 +--- server/server.go | 35 +- 46 files changed, 1567 insertions(+), 298 deletions(-) create mode 100644 auth/passkey.go create mode 100644 auth/userauth.go create mode 100644 frontend/.gitignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/app.css create mode 100644 frontend/src/assets/svelte.svg create mode 100644 frontend/src/lib/Counter.svelte create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/yarn.lock create mode 100644 pages/edit-otp.go.html create mode 100644 pages/edit-password.go.html create mode 100644 pages/edit.go.html create mode 100644 pages/manage-users-create.go.html create mode 100644 pages/remove-otp.go.html create mode 100644 pages/reset-password.go.html 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) -} - -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) + // 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 } - userAuth, err = h.updateExternalUserInfo(req, flowState.sso, token) - if err != nil { - http.Error(rw, "Failed to update external user info", http.StatusInternalServerError) + err := h.authOAuth.AttemptLogin(ctx, req, nil) + switch { + case errors.As(err, &redirectError): + http.Redirect(rw, req, redirectError.Target, redirectError.Code) 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)