From 92284d51472d24d9e5d826019661856321d561e5 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Mon, 29 Jan 2024 10:44:45 +0000 Subject: [PATCH] Initial merging --- .github/workflows/test.yml | 2 +- README.md | 8 + cmd/{tulip => purple-tulip}/main.go | 0 cmd/purple-tulip/serve.go | 117 +++++ cmd/red-tulip/main.go | 19 + cmd/{tulip => red-tulip}/serve.go | 46 +- database/tx.go | 20 +- database/tx_test.go | 10 +- go.mod | 8 +- go.sum | 10 + issuer/manager.go | 61 +++ issuer/manager_test.go | 53 +++ issuer/sso.go | 114 +++++ issuer/sso_test.go | 1 + mail/mail.go | 2 +- purple-server/conf.go | 21 + purple-server/flow.go | 146 ++++++ purple-server/flow_test.go | 445 ++++++++++++++++++ purple-server/owners.go | 33 ++ purple-server/pages/flow-callback.go.html | 29 ++ purple-server/pages/flow-popup-memory.go.html | 28 ++ purple-server/pages/flow-popup.go.html | 22 + purple-server/pages/pages.go | 46 ++ purple-server/refresh.go | 183 +++++++ purple-server/server.go | 121 +++++ purple-server/verify.go | 33 ++ purple-server/verify_test.go | 61 +++ purple-test-client/index.html | 142 ++++++ purple-test-client/run.sh | 4 + {pages => red-pages}/edit-otp.go.html | 0 {pages => red-pages}/edit-password.go.html | 0 {pages => red-pages}/edit.go.html | 0 {pages => red-pages}/index-guest.go.html | 0 {pages => red-pages}/index.go.html | 0 {pages => red-pages}/login-otp.go.html | 0 {pages => red-pages}/login.go.html | 0 {pages => red-pages}/manage-apps.go.html | 0 {pages => red-pages}/manage-users.go.html | 0 {pages => red-pages}/oauth-authorize.go.html | 0 {pages => red-pages}/pages.go | 4 +- {pages => red-pages}/pages_test.go | 2 +- {pages => red-pages}/remove-otp.go.html | 0 {pages => red-pages}/reset-password.go.html | 0 {server => red-server}/auth.go | 2 +- {server => red-server}/auth_test.go | 2 +- {server => red-server}/conf.go | 2 +- {server => red-server}/db.go | 2 +- {server => red-server}/edit.go | 12 +- {server => red-server}/home.go | 8 +- {server => red-server}/login.go | 10 +- {server => red-server}/mail.go | 6 +- {server => red-server}/manage-apps.go | 6 +- {server => red-server}/manage-users.go | 8 +- {server => red-server}/oauth.go | 12 +- {server => red-server}/otp.go | 10 +- {server => red-server}/server.go | 10 +- {server => red-server}/server_test.go | 2 +- utils/json-url.go | 21 + {lists => utils}/locales.go | 2 +- {lists => utils}/locales_test.go | 2 +- {password => utils}/password.go | 2 +- {scope => utils}/scope.go | 2 +- {scope => utils}/scope_test.go | 2 +- {password => utils}/secret.go | 2 +- {lists => utils}/zoneinfo.go | 2 +- {lists => utils}/zoneinfo_test.go | 2 +- 66 files changed, 1822 insertions(+), 98 deletions(-) rename cmd/{tulip => purple-tulip}/main.go (100%) create mode 100644 cmd/purple-tulip/serve.go create mode 100644 cmd/red-tulip/main.go rename cmd/{tulip => red-tulip}/serve.go (68%) create mode 100644 issuer/manager.go create mode 100644 issuer/manager_test.go create mode 100644 issuer/sso.go create mode 100644 issuer/sso_test.go create mode 100644 purple-server/conf.go create mode 100644 purple-server/flow.go create mode 100644 purple-server/flow_test.go create mode 100644 purple-server/owners.go create mode 100644 purple-server/pages/flow-callback.go.html create mode 100644 purple-server/pages/flow-popup-memory.go.html create mode 100644 purple-server/pages/flow-popup.go.html create mode 100644 purple-server/pages/pages.go create mode 100644 purple-server/refresh.go create mode 100644 purple-server/server.go create mode 100644 purple-server/verify.go create mode 100644 purple-server/verify_test.go create mode 100644 purple-test-client/index.html create mode 100755 purple-test-client/run.sh rename {pages => red-pages}/edit-otp.go.html (100%) rename {pages => red-pages}/edit-password.go.html (100%) rename {pages => red-pages}/edit.go.html (100%) rename {pages => red-pages}/index-guest.go.html (100%) rename {pages => red-pages}/index.go.html (100%) rename {pages => red-pages}/login-otp.go.html (100%) rename {pages => red-pages}/login.go.html (100%) rename {pages => red-pages}/manage-apps.go.html (100%) rename {pages => red-pages}/manage-users.go.html (100%) rename {pages => red-pages}/oauth-authorize.go.html (100%) rename {pages => red-pages}/pages.go (91%) rename {pages => red-pages}/pages_test.go (92%) rename {pages => red-pages}/remove-otp.go.html (100%) rename {pages => red-pages}/reset-password.go.html (100%) rename {server => red-server}/auth.go (99%) rename {server => red-server}/auth_test.go (99%) rename {server => red-server}/conf.go (94%) rename {server => red-server}/db.go (97%) rename {server => red-server}/edit.go (89%) rename {server => red-server}/home.go (86%) rename {server => red-server}/login.go (95%) rename {server => red-server}/mail.go (97%) rename {server => red-server}/manage-apps.go (97%) rename {server => red-server}/manage-users.go (95%) rename {server => red-server}/oauth.go (94%) rename {server => red-server}/otp.go (94%) rename {server => red-server}/server.go (98%) rename {server => red-server}/server_test.go (98%) create mode 100644 utils/json-url.go rename {lists => utils}/locales.go (99%) rename {lists => utils}/locales_test.go (97%) rename {password => utils}/password.go (96%) rename {scope => utils}/scope.go (99%) rename {scope => utils}/scope_test.go (98%) rename {password => utils}/secret.go (96%) rename {lists => utils}/zoneinfo.go (98%) rename {lists => utils}/zoneinfo_test.go (96%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a0b849..7548aa0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,5 +11,5 @@ jobs: with: go-version: ${{ matrix.go-version }} - uses: actions/checkout@v3 - - run: go build ./cmd/tulip/ + - run: go build ./cmd/red-tulip/ - run: go test ./... diff --git a/README.md b/README.md index b692c04..d27b0ab 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ # Tulip +Authentication services for 1f349 and partners. + +## Red Tulip + A login service with OpenID, OAuth2 and SSO support. Enables easy use of a single authentication source for a network of services. + +## Purple Tulip + +An authentication source for multiple login services to be used with a frontend single page application. \ No newline at end of file diff --git a/cmd/tulip/main.go b/cmd/purple-tulip/main.go similarity index 100% rename from cmd/tulip/main.go rename to cmd/purple-tulip/main.go diff --git a/cmd/purple-tulip/serve.go b/cmd/purple-tulip/serve.go new file mode 100644 index 0000000..f6f74cf --- /dev/null +++ b/cmd/purple-tulip/serve.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/x509" + _ "embed" + "encoding/json" + "encoding/pem" + "errors" + "flag" + "github.com/1f349/mjwt" + "github.com/1f349/tulip/purple-server" + "github.com/1f349/tulip/purple-server/pages" + "github.com/1f349/violet/utils" + exitReload "github.com/MrMelon54/exit-reload" + "github.com/google/subcommands" + "log" + "os" + "path/filepath" +) + +type serveCmd struct{ configPath string } + +func (s *serveCmd) Name() string { return "serve" } + +func (s *serveCmd) Synopsis() string { return "Serve API authentication service" } + +func (s *serveCmd) SetFlags(f *flag.FlagSet) { + f.StringVar(&s.configPath, "conf", "", "/path/to/config.json : path to the config file") +} + +func (s *serveCmd) Usage() string { + return `serve [-conf ] + Serve API authentication service using information from the config file +` +} + +func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + log.Println("[Lavender] Starting...") + + if s.configPath == "" { + log.Println("[Lavender] Error: config flag is missing") + return subcommands.ExitUsageError + } + + var conf server.Conf + err := loadConfig(s.configPath, &conf) + if err != nil { + if os.IsNotExist(err) { + log.Println("[Lavender] Error: missing config file") + } else { + log.Println("[Lavender] Error: loading config file: ", err) + } + return subcommands.ExitFailure + } + + configPathAbs, err := filepath.Abs(s.configPath) + if err != nil { + log.Fatal("[Lavender] Failed to get absolute config path") + } + wd := filepath.Dir(configPathAbs) + + mSign, err := mjwt.NewMJwtSignerFromFileOrCreate(conf.Issuer, filepath.Join(wd, "lavender.private.key"), rand.Reader, 4096) + if err != nil { + log.Fatal("[Lavender] Failed to load or create MJWT signer:", err) + } + saveMjwtPubKey(mSign, wd) + + if err := pages.LoadPages(wd); err != nil { + log.Fatal("[Lavender] Failed to load page templates:", err) + } + + srv := server.NewHttpServer(conf, mSign) + log.Printf("[Lavender] Starting HTTP red-server on '%s'\n", srv.Server.Addr) + go utils.RunBackgroundHttp("HTTP", srv.Server) + + exitReload.ExitReload("Lavender", func() { + var conf server.Conf + err := loadConfig(s.configPath, &conf) + if err != nil { + log.Println("[Lavender] Failed to read config:", err) + } + err = srv.UpdateConfig(conf) + if err != nil { + log.Println("[Lavender] Failed to reload config:", err) + } + }, func() { + // stop http red-server + _ = srv.Server.Close() + }) + + return subcommands.ExitSuccess +} + +func loadConfig(configPath string, conf *server.Conf) error { + openConf, err := os.Open(configPath) + if err != nil { + return err + } + + return json.NewDecoder(openConf).Decode(conf) +} + +func saveMjwtPubKey(mSign mjwt.Signer, wd string) { + pubKey := x509.MarshalPKCS1PublicKey(mSign.PublicKey()) + b := new(bytes.Buffer) + err := pem.Encode(b, &pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubKey}) + if err != nil { + log.Fatal("[Lavender] Failed to encode MJWT public key:", err) + } + err = os.WriteFile(filepath.Join(wd, "lavender.public.key"), b.Bytes(), 0600) + if err != nil && !errors.Is(err, os.ErrExist) { + log.Fatal("[Lavender] Failed to save MJWT public key:", err) + } +} diff --git a/cmd/red-tulip/main.go b/cmd/red-tulip/main.go new file mode 100644 index 0000000..f0b4be5 --- /dev/null +++ b/cmd/red-tulip/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "flag" + "github.com/google/subcommands" + "os" +) + +func main() { + subcommands.Register(subcommands.HelpCommand(), "") + subcommands.Register(subcommands.FlagsCommand(), "") + subcommands.Register(subcommands.CommandsCommand(), "") + subcommands.Register(&serveCmd{}, "") + + flag.Parse() + ctx := context.Background() + os.Exit(int(subcommands.Execute(ctx))) +} diff --git a/cmd/tulip/serve.go b/cmd/red-tulip/serve.go similarity index 68% rename from cmd/tulip/serve.go rename to cmd/red-tulip/serve.go index bec34a0..5c13fce 100644 --- a/cmd/tulip/serve.go +++ b/cmd/red-tulip/serve.go @@ -11,8 +11,8 @@ import ( "github.com/1f349/mjwt" "github.com/1f349/tulip/database" "github.com/1f349/tulip/mail/templates" - "github.com/1f349/tulip/pages" - "github.com/1f349/tulip/server" + "github.com/1f349/tulip/red-pages" + "github.com/1f349/tulip/red-server" "github.com/1f349/violet/utils" "github.com/MrMelon54/exit-reload" "github.com/google/subcommands" @@ -39,68 +39,68 @@ func (s *serveCmd) Usage() string { } func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...any) subcommands.ExitStatus { - log.Println("[Tulip] Starting...") + log.Println("[RedTulip] Starting...") if s.configPath == "" { - log.Println("[Tulip] Error: config flag is missing") + log.Println("[RedTulip] Error: config flag is missing") return subcommands.ExitUsageError } openConf, err := os.Open(s.configPath) if err != nil { if os.IsNotExist(err) { - log.Println("[Tulip] Error: missing config file") + log.Println("[RedTulip] Error: missing config file") } else { - log.Println("[Tulip] Error: open config file: ", err) + log.Println("[RedTulip] Error: open config file: ", err) } return subcommands.ExitFailure } - var config server.Conf + var config red_server.Conf err = json.NewDecoder(openConf).Decode(&config) if err != nil { - log.Println("[Tulip] Error: invalid config file: ", err) + log.Println("[RedTulip] Error: invalid config file: ", err) return subcommands.ExitFailure } configPathAbs, err := filepath.Abs(s.configPath) if err != nil { - log.Fatal("[Tulip] Failed to get absolute config path") + log.Fatal("[RedTulip] Failed to get absolute config path") } wd := filepath.Dir(configPathAbs) normalLoad(config, wd) return subcommands.ExitSuccess } -func normalLoad(startUp server.Conf, wd string) { +func normalLoad(startUp red_server.Conf, wd string) { signingKey, err := mjwt.NewMJwtSignerFromFileOrCreate(startUp.OtpIssuer, filepath.Join(wd, "tulip.key.pem"), rand.Reader, 4096) if err != nil { log.Fatal("[Tulip] Failed to open signing key file:", err) } - db, err := database.Open(filepath.Join(wd, "tulip.db.sqlite")) + db, err := database.Open(filepath.Join(wd, "red-red-tulip.db.sqlite")) if err != nil { - log.Fatal("[Tulip] Failed to open database:", err) + log.Fatal("[RedTulip] Failed to open database:", err) } - log.Println("[Tulip] Checking database contains at least one user") + log.Println("[RedTulip] Checking database contains at least one user") if err := checkDbHasUser(db); err != nil { - log.Fatal("[Tulip] Failed check:", err) + log.Fatal("[RedTulip] Failed check:", err) } - if err = pages.LoadPages(wd); err != nil { - log.Fatal("[Tulip] Failed to load page templates:", err) + if err = red_pages.LoadPages(wd); err != nil { + log.Fatal("[RedTulip] Failed to load page templates:", err) } if err := templates.LoadMailTemplates(wd); err != nil { - log.Fatal("[Tulip] Failed to load mail templates:", err) + log.Fatal("[RedTulip] Failed to load mail templates:", err) } - srv := server.NewHttpServer(startUp, db, signingKey) - log.Printf("[Tulip] Starting HTTP server on '%s'\n", srv.Addr) + srv := red_server.NewHttpServer(startUp, db, signingKey) + log.Printf("[RedTulip] Starting HTTP red-server on '%s'\n", srv.Addr) go utils.RunBackgroundHttp("HTTP", srv) - exit_reload.ExitReload("Tulip", func() {}, func() { - // stop http server + exit_reload.ExitReload("RedTulip", func() {}, func() { + // stop http red-server _ = srv.Close() _ = db.Close() }) @@ -110,10 +110,10 @@ func genHmacKey() []byte { a := make([]byte, 32) n, err := rand.Reader.Read(a) if err != nil { - log.Fatal("[Tulip] Failed to generate HMAC key") + log.Fatal("[RedTulip] Failed to generate HMAC key") } if n != 32 { - log.Fatal("[Tulip] Failed to generate HMAC key") + log.Fatal("[RedTulip] Failed to generate HMAC key") } return a } diff --git a/database/tx.go b/database/tx.go index d0023ed..1420aff 100644 --- a/database/tx.go +++ b/database/tx.go @@ -3,7 +3,7 @@ package database import ( "database/sql" "fmt" - "github.com/1f349/tulip/password" + "github.com/1f349/tulip/utils" "github.com/go-oauth2/oauth2/v4" "github.com/google/uuid" "time" @@ -37,7 +37,7 @@ func (t *Tx) HasUser() error { } func (t *Tx) InsertUser(name, un, pw, email string, verifyEmail bool, role UserRole, active bool) (uuid.UUID, error) { - pwHash, err := password.HashPassword(pw) + pwHash, err := utils.HashPassword(pw) if err != nil { return uuid.UUID{}, err } @@ -48,14 +48,14 @@ func (t *Tx) InsertUser(name, un, pw, email string, verifyEmail bool, role UserR func (t *Tx) CheckLogin(un, pw string) (*User, bool, bool, error) { var u User - var pwHash password.HashString + var pwHash utils.HashString var hasOtp, hasVerify bool row := t.tx.QueryRow(`SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject), email, email_verified FROM users WHERE username = ?`, un) err := row.Scan(&u.Sub, &pwHash, &hasOtp, &u.Email, &hasVerify) if err != nil { return nil, false, false, err } - err = password.CheckPasswordHash(pwHash, pw) + err = utils.CheckPasswordHash(pwHash, pw) return &u, hasOtp, hasVerify, err } @@ -94,7 +94,7 @@ func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error { if err != nil { return err } - var pwHash password.HashString + var pwHash utils.HashString if q.Next() { err = q.Scan(&pwHash) if err != nil { @@ -109,11 +109,11 @@ func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error { if err := q.Close(); err != nil { return err } - err = password.CheckPasswordHash(pwHash, pwOld) + err = utils.CheckPasswordHash(pwHash, pwOld) if err != nil { return err } - pwNewHash, err := password.HashPassword(pwNew) + pwNewHash, err := utils.HashPassword(pwNew) if err != nil { return err } @@ -227,7 +227,7 @@ func (t *Tx) GetAppList(offset int) ([]ClientInfoDbOutput, error) { func (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner uuid.UUID) error { u := uuid.New() - secret, err := password.GenerateApiSecret(70) + secret, err := utils.GenerateApiSecret(70) if err != nil { return err } @@ -241,7 +241,7 @@ func (t *Tx) UpdateClientApp(subject, owner uuid.UUID, name, domain string, sso, } func (t *Tx) ResetClientAppSecret(subject, owner uuid.UUID) (string, error) { - secret, err := password.GenerateApiSecret(70) + secret, err := utils.GenerateApiSecret(70) if err != nil { return "", err } @@ -277,7 +277,7 @@ func (t *Tx) VerifyUserEmail(sub uuid.UUID) error { } func (t *Tx) UserResetPassword(sub uuid.UUID, pw string) error { - hashPassword, err := password.HashPassword(pw) + hashPassword, err := utils.HashPassword(pw) if err != nil { return err } diff --git a/database/tx_test.go b/database/tx_test.go index 09fef36..53863d6 100644 --- a/database/tx_test.go +++ b/database/tx_test.go @@ -1,7 +1,7 @@ package database import ( - "github.com/1f349/tulip/password" + "github.com/1f349/tulip/utils" "github.com/MrMelon54/pronouns" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -12,7 +12,7 @@ import ( func TestTx_ChangeUserPassword(t *testing.T) { u := uuid.New() - pw, err := password.HashPassword("test") + pw, err := utils.HashPassword("test") assert.NoError(t, err) d, err := Open("file::memory:") assert.NoError(t, err) @@ -26,16 +26,16 @@ func TestTx_ChangeUserPassword(t *testing.T) { query, err := d.db.Query(`SELECT password FROM users WHERE subject = ? AND username = ?`, u.String(), "test") assert.NoError(t, err) assert.True(t, query.Next()) - var oldPw password.HashString + var oldPw utils.HashString assert.NoError(t, query.Scan(&oldPw)) - assert.NoError(t, password.CheckPasswordHash(oldPw, "new")) + assert.NoError(t, utils.CheckPasswordHash(oldPw, "new")) assert.NoError(t, query.Err()) assert.NoError(t, query.Close()) } func TestTx_ModifyUser(t *testing.T) { u := uuid.New() - pw, err := password.HashPassword("test") + pw, err := utils.HashPassword("test") assert.NoError(t, err) d, err := Open("file::memory:") assert.NoError(t, err) diff --git a/go.mod b/go.mod index 124091e..c962b64 100644 --- a/go.mod +++ b/go.mod @@ -14,23 +14,27 @@ require ( github.com/emersion/go-smtp v0.20.1 github.com/go-oauth2/oauth2/v4 v4.5.2 github.com/go-session/session v3.1.2+incompatible + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/subcommands v1.2.0 github.com/google/uuid v1.5.0 github.com/julienschmidt/httprouter v1.3.0 github.com/mattn/go-sqlite3 v1.14.19 + github.com/rs/cors v1.10.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.8.4 github.com/xlzd/gotp v0.1.0 golang.org/x/crypto v0.18.0 + golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/text v0.14.0 ) require ( github.com/MrMelon54/rescheduler v0.0.2 // indirect + github.com/becheran/wildmatch-go v1.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/protobuf v1.4.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/btree v1.7.0 // indirect @@ -42,5 +46,7 @@ require ( github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect golang.org/x/net v0.19.0 // indirect + google.golang.org/appengine v1.6.6 // indirect + google.golang.org/protobuf v1.23.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 683a70a..afb14eb 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ 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= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +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/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= @@ -52,9 +54,11 @@ github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:x github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= @@ -103,6 +107,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= +github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 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= @@ -190,6 +196,7 @@ 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.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 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= @@ -230,14 +237,17 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn 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 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 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 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/issuer/manager.go b/issuer/manager.go new file mode 100644 index 0000000..c9f7893 --- /dev/null +++ b/issuer/manager.go @@ -0,0 +1,61 @@ +package issuer + +import ( + "fmt" + "regexp" + "strings" +) + +var isValidNamespace = regexp.MustCompile("^[0-9a-z.]+$") + +type Manager struct { + m map[string]*WellKnownOIDC +} + +func NewManager(services []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) + } + + conf, err := i.FetchConfig() + if err != nil { + return nil, err + } + + // save by namespace + l.m[i.Namespace] = conf + } + return l, nil +} + +func NewManagerForTests(services []*WellKnownOIDC) *Manager { + l := &Manager{m: make(map[string]*WellKnownOIDC, len(services))} + for _, i := range services { + if !isValidNamespace.MatchString(i.Config.Namespace) { + panic("Invalid namespace in tests") + } + l.m[i.Config.Namespace] = i + } + return l +} + +func (l *Manager) CheckNamespace(namespace string) bool { + _, ok := l.m[namespace] + return ok +} + +func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC { + // @ should have at least one byte before it + n := strings.IndexByte(login, '@') + if n < 1 { + return nil + } + // there should not be a second @ + n2 := strings.IndexByte(login[n+1:], '@') + if n2 != -1 { + return nil + } + return l.m[login[n+1:]] +} diff --git a/issuer/manager_test.go b/issuer/manager_test.go new file mode 100644 index 0000000..1b0430d --- /dev/null +++ b/issuer/manager_test.go @@ -0,0 +1,53 @@ +package issuer + +import ( + "github.com/1f349/tulip/utils" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "net/url" + "strings" + "testing" +) + +var testAddrUrl = func() utils.JsonUrl { + a, err := url.Parse("https://example.com") + if err != nil { + panic(err) + } + return utils.JsonUrl{URL: a} +}() + +func testBody() io.ReadCloser { + return io.NopCloser(strings.NewReader("{}")) +} + +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", + }, + }) + assert.NoError(t, err) + assert.True(t, manager.CheckNamespace("example.com")) + assert.False(t, manager.CheckNamespace("missing.example.com")) +} + +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", + }, + }) + assert.NoError(t, err) + assert.Equal(t, manager.FindServiceFromLogin("jane@example.com"), manager.m["example.com"]) + assert.Nil(t, manager.FindServiceFromLogin("jane@missing.example.com")) +} diff --git a/issuer/sso.go b/issuer/sso.go new file mode 100644 index 0000000..9410be3 --- /dev/null +++ b/issuer/sso.go @@ -0,0 +1,114 @@ +package issuer + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/1f349/tulip/utils" + "golang.org/x/oauth2" + "net/http" + "net/url" + "slices" + "strings" +) + +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"` +} + +type SsoConfigClient struct { + ID string `json:"id"` + Secret string `json:"secret"` + Scopes []string `json:"scopes"` +} + +func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) { + // generate openid config url + u := s.Addr.String() + if !strings.HasSuffix(u, "/") { + u += "/" + } + u += ".well-known/openid-configuration" + + // fetch metadata + get, err := httpGet(u) + if err != nil { + return nil, err + } + defer get.Body.Close() + + var c WellKnownOIDC + err = json.NewDecoder(get.Body).Decode(&c) + if err != nil { + return nil, err + } + c.Config = s + c.OAuth2Config = oauth2.Config{ + ClientID: c.Config.Client.ID, + ClientSecret: c.Config.Client.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: c.AuthorizationEndpoint, + TokenURL: c.TokenEndpoint, + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: c.Config.Client.Scopes, + } + return &c, nil +} + +type WellKnownOIDC struct { + Config SsoConfig `json:"-"` + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + ResponseTypesSupported []string `json:"response_types_supported"` + ScopesSupported []string `json:"scopes_supported"` + ClaimsSupported []string `json:"claims_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + OAuth2Config oauth2.Config `json:"-"` +} + +func (o WellKnownOIDC) Validate() error { + if o.Issuer == "" { + return errors.New("missing issuer") + } + + // check URLs are valid + if _, err := url.Parse(o.AuthorizationEndpoint); err != nil { + return err + } + if _, err := url.Parse(o.TokenEndpoint); err != nil { + return err + } + if _, err := url.Parse(o.UserInfoEndpoint); err != nil { + return err + } + + // check oidc supported values + if !slices.Contains(o.ResponseTypesSupported, "code") { + return errors.New("missing required response type 'code'") + } + if !slices.Contains(o.ScopesSupported, "openid") { + return errors.New("missing required scope 'openid'") + } + requiredClaims := []string{"sub", "name", "preferred_username", "email", "email_verified"} + for _, i := range requiredClaims { + if !slices.Contains(o.ClaimsSupported, i) { + return fmt.Errorf("missing required claim '%s'", i) + } + } + + // oidc valid + return nil +} + +func (o WellKnownOIDC) ValidReturnUrl(u *url.URL) bool { + return o.Config.Addr.Scheme == u.Scheme && o.Config.Addr.Host == u.Host +} diff --git a/issuer/sso_test.go b/issuer/sso_test.go new file mode 100644 index 0000000..de84991 --- /dev/null +++ b/issuer/sso_test.go @@ -0,0 +1 @@ +package issuer diff --git a/mail/mail.go b/mail/mail.go index 8403664..41d9c7e 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -13,7 +13,7 @@ import ( type Mail struct { Name string `json:"name"` Tls bool `json:"tls"` - Server string `json:"server"` + Server string `json:"red-server"` From FromAddress `json:"from"` Username string `json:"username"` Password string `json:"password"` diff --git a/purple-server/conf.go b/purple-server/conf.go new file mode 100644 index 0000000..43dfe25 --- /dev/null +++ b/purple-server/conf.go @@ -0,0 +1,21 @@ +package server + +import ( + "github.com/1f349/tulip/issuer" + "github.com/1f349/tulip/utils" +) + +type Conf struct { + Listen string `json:"listen"` + BaseUrl string `json:"base_url"` + ServiceName string `json:"service_name"` + Issuer string `json:"issuer"` + SsoServices []issuer.SsoConfig `json:"sso_services"` + AllowedClients []AllowedClient `json:"allowed_clients"` + Users UserConfig `json:"users"` +} + +type AllowedClient struct { + Url utils.JsonUrl `json:"url"` + Permissions []string `json:"permissions"` +} diff --git a/purple-server/flow.go b/purple-server/flow.go new file mode 100644 index 0000000..2e11587 --- /dev/null +++ b/purple-server/flow.go @@ -0,0 +1,146 @@ +package server + +import ( + "context" + _ "embed" + "fmt" + "github.com/1f349/tulip/issuer" + "github.com/1f349/tulip/purple-server/pages" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "golang.org/x/oauth2" + "net/http" + "net/url" + "strings" + "time" +) + +var uuidNewStringState = uuid.NewString +var uuidNewStringAti = uuid.NewString +var uuidNewStringRti = uuid.NewString + +var testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) { + return oa2conf.Exchange(ctx, code) +} + +var testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) { + client := oidc.OAuth2Config.Client(ctx, exchange) + return client.Get(oidc.UserInfoEndpoint) +} + +func (h *HttpServer) flowPopup(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + cookie, err := req.Cookie("lavender-login-name") + if err == nil && cookie.Valid() == nil { + pages.RenderPageTemplate(rw, "flow-popup-memory", map[string]any{ + "ServiceName": h.conf.Load().ServiceName, + "Origin": req.URL.Query().Get("origin"), + "LoginName": cookie.Value, + }) + return + } + pages.RenderPageTemplate(rw, "flow-popup", map[string]any{ + "ServiceName": h.conf.Load().ServiceName, + "Origin": req.URL.Query().Get("origin"), + }) +} + +func (h *HttpServer) flowPopupPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + if req.PostFormValue("not-you") == "1" { + http.SetCookie(rw, &http.Cookie{ + Name: "lavender-login-name", + Value: "", + Path: "/", + MaxAge: -1, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + http.Redirect(rw, req, (&url.URL{ + Path: "/popup", + RawQuery: url.Values{ + "origin": []string{req.PostFormValue("origin")}, + }.Encode(), + }).String(), http.StatusFound) + return + } + loginName := req.PostFormValue("loginname") + login := h.manager.Load().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] + + now := time.Now() + future := now.AddDate(1, 0, 0) + http.SetCookie(rw, &http.Cookie{ + Name: "lavender-login-name", + Value: loginName, + Path: "/", + Expires: future, + MaxAge: int(future.Sub(now).Seconds()), + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + + targetOrigin := req.PostFormValue("origin") + allowedService, found := (*h.services.Load())[targetOrigin] + if !found { + http.Error(rw, "Invalid target origin", http.StatusBadRequest) + return + } + + // save state for use later + state := login.Config.Namespace + ":" + uuidNewStringState() + h.flowState.Set(state, flowStateData{ + login, + allowedService, + }, time.Now().Add(15*time.Minute)) + + // generate oauth2 config and redirect to authorize URL + oa2conf := login.OAuth2Config + oa2conf.RedirectURL = h.conf.Load().BaseUrl + "/callback" + nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn)) + http.Redirect(rw, req, nextUrl, http.StatusFound) +} + +func (h *HttpServer) flowCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + err := req.ParseForm() + if err != nil { + http.Error(rw, "Error parsing form", http.StatusBadRequest) + return + } + + q := req.URL.Query() + state := q.Get("state") + n := strings.IndexByte(state, ':') + if n == -1 || !h.manager.Load().CheckNamespace(state[:n]) { + http.Error(rw, "Invalid state namespace", http.StatusBadRequest) + return + } + v, found := h.flowState.Get(state) + if !found { + http.Error(rw, "Invalid state", http.StatusBadRequest) + return + } + + oa2conf := v.sso.OAuth2Config + oa2conf.RedirectURL = h.conf.Load().BaseUrl + "/callback" + exchange, err := testOa2Exchange(oa2conf, context.Background(), q.Get("code")) + if err != nil { + fmt.Println("Failed exchange:", err) + http.Error(rw, "Failed to exchange code", http.StatusInternalServerError) + return + } + + h.finishTokenGenerateFlow(rw, req, v, exchange, func(accessToken, refreshToken string, v3 map[string]any) { + pages.RenderPageTemplate(rw, "flow-callback", map[string]any{ + "ServiceName": h.conf.Load().ServiceName, + "TargetOrigin": v.target.Url.String(), + "TargetMessage": v3, + "AccessToken": accessToken, + "RefreshToken": refreshToken, + }) + }) +} diff --git a/purple-server/flow_test.go b/purple-server/flow_test.go new file mode 100644 index 0000000..a9ba8b7 --- /dev/null +++ b/purple-server/flow_test.go @@ -0,0 +1,445 @@ +package server + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "github.com/1f349/cache" + "github.com/1f349/mjwt" + "github.com/1f349/tulip/issuer" + "github.com/1f349/tulip/purple-server/pages" + "github.com/1f349/tulip/utils" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + "unicode" +) + +const lavenderDomain = "http://localhost:0" +const clientAppDomain = "http://localhost:1" +const loginDomain = "http://localhost:2" + +var clientAppMeta AllowedClient + +var testSigner mjwt.Signer + +var testOidc = &issuer.WellKnownOIDC{ + Config: issuer.SsoConfig{ + Addr: utils.JsonUrl{}, + Namespace: "example.com", + Client: issuer.SsoConfigClient{ + ID: "test-id", + Secret: "test-secret", + Scopes: []string{"openid"}, + }, + }, + Issuer: "https://example.com", + AuthorizationEndpoint: loginDomain + "/authorize", + TokenEndpoint: loginDomain + "/token", + UserInfoEndpoint: loginDomain + "/userinfo", + ResponseTypesSupported: nil, + ScopesSupported: nil, + ClaimsSupported: nil, + GrantTypesSupported: nil, + OAuth2Config: oauth2.Config{ + ClientID: "test-id", + ClientSecret: "test-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: loginDomain + "/authorize", + TokenURL: loginDomain + "/token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: nil, + }, +} + +var testManager = issuer.NewManagerForTests([]*issuer.WellKnownOIDC{testOidc}) +var testHttpServer = HttpServer{ + r: nil, + flowState: cache.New[string, flowStateData](), +} + +func init() { + testHttpServer.conf.Store(&Conf{ + BaseUrl: lavenderDomain, + ServiceName: "Test Lavender Service", + }) + testHttpServer.manager.Store(testManager) + testHttpServer.services.Store(&map[string]AllowedClient{ + clientAppDomain: {}, + }) + + err := pages.LoadPages("") + if err != nil { + panic(err) + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + + testSigner = mjwt.NewMJwtSigner("https://example.com", key) + testHttpServer.signer = testSigner + + parse, err := url.Parse(clientAppDomain) + if err != nil { + panic(err) + } + + clientAppMeta = AllowedClient{ + Url: utils.JsonUrl{URL: parse}, + Permissions: []string{"test-perm"}, + } +} + +func TestFlowPopup(t *testing.T) { + h := HttpServer{} + h.conf.Store(&Conf{ServiceName: "Test Service Name"}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/popup?"+url.Values{"origin": []string{clientAppDomain}}.Encode(), nil) + h.flowPopup(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, fmt.Sprintf(` + + + Test Service Name + + + +
+

Test Service Name

+
+
+
+ +
+ + +
+ +
+
+ + +`, clientAppDomain), rec.Body.String()) +} + +func TestFlowPopupPost(t *testing.T) { + // test no login service error + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{ + "loginname": []string{"test@missing.example.com"}, + "origin": []string{clientAppDomain}, + }.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowPopupPost(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "No login service defined for this username\n", rec.Body.String()) + + // test invalid target origin error + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{ + "loginname": []string{"test@example.com"}, + "origin": []string{"http://localhost:1010"}, + }.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowPopupPost(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "Invalid target origin\n", rec.Body.String()) + + // test successful request + nextState := uuid.NewString() + uuidNewStringState = func() string { return nextState } + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{ + "loginname": []string{"test@example.com"}, + "origin": []string{clientAppDomain}, + }.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowPopupPost(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "", rec.Body.String()) + assert.Equal(t, loginDomain+"/authorize?"+url.Values{ + "client_id": []string{"test-id"}, + "login_name": []string{"test"}, + "redirect_uri": []string{lavenderDomain + "/callback"}, + "response_type": []string{"code"}, + "state": []string{"example.com:" + nextState}, + }.Encode(), rec.Header().Get("Location")) +} + +func TestFlowCallback(t *testing.T) { + expiryTime := time.Now().Add(15 * time.Minute) + nextState := uuid.NewString() + testHttpServer.flowState.Set("example.com:"+nextState, flowStateData{ + sso: testOidc, + target: clientAppMeta, + }, expiryTime) + + testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) { + return nil, errors.New("no exchange should be made") + } + testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) { + return nil, errors.New("no userinfo should be fetched") + } + + // test parse form error + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/callback?%+"+url.Values{ + "state": []string{"example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "Error parsing form\n", rec.Body.String()) + + // test invalid namespace + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"missing.example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "Invalid state namespace\n", rec.Body.String()) + + // test invalid state + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"example.com:invalid"}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "Invalid state\n", rec.Body.String()) + + // test failed exchange + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "Failed to exchange code\n", rec.Body.String()) + + testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: "abcd1234", + TokenType: "", + RefreshToken: "efgh5678", + Expiry: expiryTime, + }, nil + } + + // test failed userinfo + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "Failed to get userinfo\n", rec.Body.String()) + + testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusInternalServerError) + return rec.Result(), nil + } + + // test failed userinfo status code + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "Failed to get userinfo: unexpected status code\n", rec.Body.String()) + + testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + _, _ = rec.Body.WriteString("{") + return rec.Result(), nil + } + + // test failed userinfo decode + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "Failed to decode userinfo\n", rec.Body.String()) + + testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + _, _ = rec.Body.WriteString("{\"sub\":1}") + return rec.Result(), nil + } + + // test invalid subject in userinfo + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "Invalid subject in userinfo\n", rec.Body.String()) + + testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + _, _ = rec.Body.WriteString("{\"sub\":\"1\",\"aud\":1}") + return rec.Result(), nil + } + + // test invalid audience in userinfo + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "Invalid audience in userinfo\n", rec.Body.String()) + + testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + _, _ = rec.Body.WriteString(fmt.Sprintf(`{ + "sub": "test-user", + "aud": "%s", + "test-field": "ok" +} +`, clientAppDomain)) + return rec.Result(), nil + } + + // test successful request + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{ + "state": []string{"example.com:" + nextState}, + "origin": []string{clientAppDomain}, + }.Encode(), nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + testHttpServer.flowCallback(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusOK, rec.Code) + const p1 = ` + + + Test Lavender Service + + + + +
+

Test Lavender Service

+
+
Loading...
+ + +` + var p1v = fmt.Sprintf(p1, clientAppDomain) + var p2v = fmt.Sprintf(p2, clientAppDomain) + + a := make([]byte, len(p1v)) + n, err := rec.Body.Read(a) + assert.NoError(t, err) + assert.Equal(t, len(p1v), n) + assert.Equal(t, p1v, string(a)) + + var accessToken, refreshToken string + findByte(rec.Body, '{') + findString(rec.Body, "access:") + readQuotedString(rec.Body, &accessToken) + findByte(rec.Body, ',') + findString(rec.Body, "refresh:") + readQuotedString(rec.Body, &refreshToken) + findByte(rec.Body, ',') + findByte(rec.Body, '}') + + assert.Equal(t, p2v, rec.Body.String()) +} + +func findByte(buf *bytes.Buffer, v byte) { + for { + readByte, err := buf.ReadByte() + if err != nil { + panic(err) + } + if readByte == v { + break + } + if !unicode.IsSpace(rune(readByte)) { + panic(fmt.Sprint("Found non space rune: ", readByte)) + } + } +} + +func findString(buf *bytes.Buffer, v string) { + if len(v) == 0 { + panic("Cannot find empty string") + } + findByte(buf, v[0]) + if len(v) > 1 { + a2 := make([]byte, len(v)-1) + n, err := buf.Read(a2) + if err != nil { + panic(err) + } + if n != len(a2) { + panic("Probably found end of buffer") + } + if bytes.Compare([]byte(v[1:]), a2) != 0 { + panic("Failed to find string in buffer") + } + } +} + +func readQuotedString(buf *bytes.Buffer, p *string) { + findByte(buf, '"') + b, err := buf.ReadBytes('"') + if err != nil { + panic(err) + } + *p = string(b) +} diff --git a/purple-server/owners.go b/purple-server/owners.go new file mode 100644 index 0000000..9a13a20 --- /dev/null +++ b/purple-server/owners.go @@ -0,0 +1,33 @@ +package server + +// UserConfig is the structure for storing a user's role and owned domains +type UserConfig map[string]struct { + Roles []string `json:"roles"` + Domains []string `json:"domains"` +} + +func (u UserConfig) AllRoles(user string) []string { + return u[user].Roles +} + +func (u UserConfig) HasRole(user, role string) bool { + for _, i := range u[user].Roles { + if i == role { + return true + } + } + return false +} + +func (u UserConfig) AllDomains(user string) []string { + return u[user].Domains +} + +func (u UserConfig) OwnsDomain(user, domain string) bool { + for _, i := range u[user].Domains { + if i == domain { + return true + } + } + return false +} diff --git a/purple-server/pages/flow-callback.go.html b/purple-server/pages/flow-callback.go.html new file mode 100644 index 0000000..e9eb583 --- /dev/null +++ b/purple-server/pages/flow-callback.go.html @@ -0,0 +1,29 @@ + + + + {{.ServiceName}} + + + + +
+

{{.ServiceName}}

+
+
Loading...
+ + diff --git a/purple-server/pages/flow-popup-memory.go.html b/purple-server/pages/flow-popup-memory.go.html new file mode 100644 index 0000000..47ea38f --- /dev/null +++ b/purple-server/pages/flow-popup-memory.go.html @@ -0,0 +1,28 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
Log in as: {{.LoginName}}
+
+
+ + +
+
+
+
+ + + +
+
+
+ + diff --git a/purple-server/pages/flow-popup.go.html b/purple-server/pages/flow-popup.go.html new file mode 100644 index 0000000..a6c78ea --- /dev/null +++ b/purple-server/pages/flow-popup.go.html @@ -0,0 +1,22 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
+ +
+ + +
+ +
+
+ + diff --git a/purple-server/pages/pages.go b/purple-server/pages/pages.go new file mode 100644 index 0000000..8a2da41 --- /dev/null +++ b/purple-server/pages/pages.go @@ -0,0 +1,46 @@ +package pages + +import ( + "embed" + _ "embed" + "errors" + "github.com/1f349/overlapfs" + "html/template" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "sync" +) + +var ( + //go:embed *.go.html + flowPages embed.FS + flowTemplates *template.Template + loadOnce sync.Once +) + +func LoadPages(wd string) (err error) { + loadOnce.Do(func() { + var o fs.FS = flowPages + if wd != "" { + wwwDir := filepath.Join(wd, "www") + err = os.Mkdir(wwwDir, os.ModePerm) + if err != nil && !errors.Is(err, os.ErrExist) { + return + } + wdFs := os.DirFS(wwwDir) + o = overlapfs.OverlapFS{A: flowPages, B: wdFs} + } + flowTemplates, err = template.ParseFS(o, "*.go.html") + }) + return err +} + +func RenderPageTemplate(wr io.Writer, name string, data any) { + err := flowTemplates.ExecuteTemplate(wr, name+".go.html", data) + if err != nil { + log.Printf("Failed to render page: %s: %s\n", name, err) + } +} diff --git a/purple-server/refresh.go b/purple-server/refresh.go new file mode 100644 index 0000000..6563e2a --- /dev/null +++ b/purple-server/refresh.go @@ -0,0 +1,183 @@ +package server + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/1f349/mjwt/auth" + "github.com/1f349/mjwt/claims" + "github.com/golang-jwt/jwt/v4" + "github.com/julienschmidt/httprouter" + "golang.org/x/oauth2" + "net/http" + "net/mail" + "strings" + "time" +) + +func (h *HttpServer) refreshHandler(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + ref := strings.TrimSuffix(req.Referer(), "/") + allowedClient, ok := (*h.services.Load())[ref] + if !ok { + http.Error(rw, "Invalid origin", http.StatusBadRequest) + return + } + loginNameCookie, err := req.Cookie("lavender-login-name") + if err != nil { + http.Error(rw, "Failed to read cookie", http.StatusBadRequest) + return + } + loginService := h.manager.Load().FindServiceFromLogin(loginNameCookie.Value) + cookie, err := req.Cookie("sso-exchange") + if err != nil { + http.Error(rw, "Failed to read cookie", http.StatusBadRequest) + return + } + rawEncrypt, err := base64.RawURLEncoding.DecodeString(cookie.Value) + if err != nil { + http.Error(rw, "Internal Server Error", http.StatusBadRequest) + return + } + rawTokens, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signer.PrivateKey(), rawEncrypt, []byte("sso-exchange")) + if err != nil { + http.Error(rw, "Internal Server Error", http.StatusBadRequest) + return + } + var exchange oauth2.Token + err = json.Unmarshal(rawTokens, &exchange) + if err != nil { + http.Error(rw, "Internal Server Error", http.StatusBadRequest) + return + } + h.finishTokenGenerateFlow(rw, req, flowStateData{ + sso: loginService, + target: allowedClient, + }, &exchange, func(accessToken string, refreshToken string, v3 map[string]any) { + tokens := map[string]any{ + "target": allowedClient.Url.String(), + "userinfo": v3, + "tokens": map[string]any{ + "access": accessToken, + "refresh": refreshToken, + }, + } + _ = json.NewEncoder(rw).Encode(tokens) + }) +} + +func (h *HttpServer) finishTokenGenerateFlow(rw http.ResponseWriter, req *http.Request, v flowStateData, exchange *oauth2.Token, response func(accessToken string, refreshToken string, v3 map[string]any)) { + // fetch user info + v2, err := testOa2UserInfo(v.sso, req.Context(), exchange) + if err != nil { + fmt.Println("Failed to get userinfo:", err) + http.Error(rw, "Failed to get userinfo", http.StatusInternalServerError) + return + } + defer v2.Body.Close() + if v2.StatusCode != http.StatusOK { + http.Error(rw, "Failed to get userinfo: unexpected status code", http.StatusInternalServerError) + return + } + + // encrypt exchange tokens for cookie storage + marshal, err := json.Marshal(exchange) + if err != nil { + fmt.Println("Failed to marshal exchange tokens", err) + http.Error(rw, "Internal red-server error", http.StatusInternalServerError) + return + } + oaepBytes, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, h.signer.PublicKey(), marshal, []byte("sso-exchange")) + if err != nil { + fmt.Println("Failed to encrypt exchange tokens", err) + http.Error(rw, "Internal red-server error", http.StatusInternalServerError) + return + } + http.SetCookie(rw, &http.Cookie{ + Name: "sso-exchange", + Value: base64.RawURLEncoding.EncodeToString(oaepBytes), + Path: "/", + Expires: time.Now().AddDate(0, 3, 0), + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + var v3 map[string]any + if err = json.NewDecoder(v2.Body).Decode(&v3); err != nil { + fmt.Println("Failed to decode userinfo:", err) + http.Error(rw, "Failed to decode userinfo", http.StatusInternalServerError) + return + } + + sub, ok := v3["sub"].(string) + if !ok { + http.Error(rw, "Invalid subject in userinfo", http.StatusInternalServerError) + return + } + aud, ok := v3["aud"].(string) + if !ok { + http.Error(rw, "Invalid audience in userinfo", http.StatusInternalServerError) + return + } + + var needsMailFlag, needsDomains bool + + ps := claims.NewPermStorage() + for _, i := range v.target.Permissions { + if strings.HasPrefix(i, "dynamic:") { + switch i { + case "dynamic:mail-inbox": + needsMailFlag = true + case "dynamic:domain-owns": + needsDomains = true + } + } else { + ps.Set(i) + } + } + + if needsMailFlag { + if verified, ok := v3["email_verified"].(bool); ok && verified { + if mailAddress, ok := v3["email"].(string); ok { + address, err := mail.ParseAddress(mailAddress) + if err != nil { + http.Error(rw, "Invalid email in userinfo", http.StatusInternalServerError) + return + } + n := strings.IndexByte(address.Address, '@') + if n != -1 { + if address.Address[n+1:] == v.sso.Config.Namespace { + ps.Set("mail:inbox=" + address.Address) + } + } + } + } + } + + if needsDomains { + a := h.conf.Load().Users.AllDomains(sub + "@" + v.sso.Config.Namespace) + for _, i := range a { + ps.Set("domain:owns=" + i) + } + } + + nsSub := sub + "@" + v.sso.Config.Namespace + ati := uuidNewStringAti() + accessToken, err := h.signer.GenerateJwt(nsSub, ati, jwt.ClaimStrings{aud}, 15*time.Minute, auth.AccessTokenClaims{ + Perms: ps, + }) + if err != nil { + http.Error(rw, "Error generating access token", http.StatusInternalServerError) + return + } + + refreshToken, err := h.signer.GenerateJwt(nsSub, uuidNewStringRti(), jwt.ClaimStrings{aud}, 15*time.Minute, auth.RefreshTokenClaims{AccessTokenId: ati}) + if err != nil { + http.Error(rw, "Error generating refresh token", http.StatusInternalServerError) + return + } + + response(accessToken, refreshToken, v3) +} diff --git a/purple-server/server.go b/purple-server/server.go new file mode 100644 index 0000000..6b7892a --- /dev/null +++ b/purple-server/server.go @@ -0,0 +1,121 @@ +package server + +import ( + "bytes" + "fmt" + "github.com/1f349/cache" + "github.com/1f349/mjwt" + "github.com/1f349/tulip/issuer" + "github.com/1f349/tulip/theme" + "github.com/julienschmidt/httprouter" + "github.com/rs/cors" + "log" + "net/http" + "strings" + "sync/atomic" + "time" +) + +type HttpServer struct { + Server *http.Server + r *httprouter.Router + conf atomic.Pointer[Conf] + manager atomic.Pointer[issuer.Manager] + signer mjwt.Signer + flowState *cache.Cache[string, flowStateData] + services atomic.Pointer[map[string]AllowedClient] +} + +type flowStateData struct { + sso *issuer.WellKnownOIDC + target AllowedClient +} + +func NewHttpServer(conf Conf, signer mjwt.Signer) *HttpServer { + r := httprouter.New() + + // remove last slash from baseUrl + { + l := len(conf.BaseUrl) + if conf.BaseUrl[l-1] == '/' { + conf.BaseUrl = conf.BaseUrl[:l-1] + } + } + + hs := &HttpServer{ + Server: &http.Server{ + Addr: conf.Listen, + Handler: r, + ReadTimeout: time.Minute, + ReadHeaderTimeout: time.Minute, + WriteTimeout: time.Minute, + IdleTimeout: time.Minute, + MaxHeaderBytes: 2500, + }, + r: r, + signer: signer, + flowState: cache.New[string, flowStateData](), + } + err := hs.UpdateConfig(conf) + if err != nil { + log.Fatalln("Failed to load initial config:", err) + return nil + } + + r.GET("/", func(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + rw.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintln(rw, "What is this?") + }) + r.GET("/popup", hs.flowPopup) + r.POST("/popup", hs.flowPopupPost) + r.GET("/callback", hs.flowCallback) + + r.GET("/theme/style.css", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + http.ServeContent(rw, req, "style.css", time.Now(), bytes.NewReader(theme.DefaultThemeCss)) + }) + + // setup CORS options for `/verify` and `/refresh` endpoints + var corsAccessControl = cors.New(cors.Options{ + AllowOriginFunc: func(origin string) bool { + load := hs.services.Load() + _, ok := (*load)[strings.TrimSuffix(origin, "/")] + return ok + }, + AllowedMethods: []string{http.MethodPost, http.MethodOptions}, + AllowedHeaders: []string{"Content-Type"}, + AllowCredentials: true, + }) + + // `/verify` and `/refresh` need CORS headers to be usable on other domains + r.POST("/verify", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + corsAccessControl.ServeHTTP(rw, req, func(writer http.ResponseWriter, request *http.Request) { + hs.verifyHandler(rw, req, params) + }) + }) + r.POST("/refresh", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + corsAccessControl.ServeHTTP(rw, req, func(writer http.ResponseWriter, request *http.Request) { + hs.refreshHandler(rw, req, params) + }) + }) + r.OPTIONS("/refresh", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + corsAccessControl.ServeHTTP(rw, req, func(_ http.ResponseWriter, _ *http.Request) {}) + }) + return hs +} + +func (h *HttpServer) UpdateConfig(conf Conf) error { + m, err := issuer.NewManager(conf.SsoServices) + if err != nil { + return fmt.Errorf("failed to reload SSO service manager: %w", err) + } + + clientLookup := make(map[string]AllowedClient) + for _, i := range conf.AllowedClients { + clientLookup[i.Url.String()] = i + } + + h.conf.Store(&conf) + h.manager.Store(m) + h.services.Store(&clientLookup) + return nil +} diff --git a/purple-server/verify.go b/purple-server/verify.go new file mode 100644 index 0000000..53746be --- /dev/null +++ b/purple-server/verify.go @@ -0,0 +1,33 @@ +package server + +import ( + "github.com/1f349/mjwt" + "github.com/1f349/mjwt/auth" + "github.com/1f349/violet/utils" + "github.com/julienschmidt/httprouter" + "net/http" +) + +func (h *HttpServer) verifyHandler(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + // find bearer token + bearer := utils.GetBearer(req) + if bearer == "" { + http.Error(rw, "Missing bearer", http.StatusForbidden) + return + } + + // after this mjwt is considered valid + _, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](h.signer, bearer) + if err != nil { + http.Error(rw, "Invalid token", http.StatusForbidden) + return + } + + // check issuer against config + if b.Issuer != h.conf.Load().Issuer { + http.Error(rw, "Invalid issuer", http.StatusBadRequest) + return + } + + rw.WriteHeader(http.StatusOK) +} diff --git a/purple-server/verify_test.go b/purple-server/verify_test.go new file mode 100644 index 0000000..fb2e907 --- /dev/null +++ b/purple-server/verify_test.go @@ -0,0 +1,61 @@ +package server + +import ( + "crypto/rand" + "crypto/rsa" + "github.com/1f349/mjwt" + "github.com/1f349/mjwt/auth" + "github.com/1f349/mjwt/claims" + "github.com/julienschmidt/httprouter" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestVerifyHandler(t *testing.T) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + invalidSigner := mjwt.NewMJwtSigner("Invalid Issuer", privKey) + h := HttpServer{ + signer: mjwt.NewMJwtSigner("Test Issuer", privKey), + } + h.conf.Store(&Conf{Issuer: "Test Issuer"}) + + // test for missing bearer response + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "https://example.localhost", nil) + h.verifyHandler(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, "Missing bearer\n", rec.Body.String()) + + // test for invalid token response + rec = httptest.NewRecorder() + req.Header.Set("Authorization", "Bearer abcd") + h.verifyHandler(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, "Invalid token\n", rec.Body.String()) + + // test for invalid issuer response + rec = httptest.NewRecorder() + accessToken, err := invalidSigner.GenerateJwt("a", "a", nil, 15*time.Minute, auth.AccessTokenClaims{ + Perms: claims.NewPermStorage(), + }) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+accessToken) + h.verifyHandler(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "Invalid issuer\n", rec.Body.String()) + + // test for invalid issuer response + rec = httptest.NewRecorder() + accessToken, err = h.signer.GenerateJwt("a", "a", nil, 15*time.Minute, auth.AccessTokenClaims{ + Perms: claims.NewPermStorage(), + }) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+accessToken) + h.verifyHandler(rec, req, httprouter.Params{}) + assert.Equal(t, http.StatusOK, rec.Code) +} diff --git a/purple-test-client/index.html b/purple-test-client/index.html new file mode 100644 index 0000000..70dab64 --- /dev/null +++ b/purple-test-client/index.html @@ -0,0 +1,142 @@ + + + + Test Client + + + + +
+

Test Client

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

Permissions:

+
    +
    +
    +
    + + diff --git a/purple-test-client/run.sh b/purple-test-client/run.sh new file mode 100755 index 0000000..d5f47b5 --- /dev/null +++ b/purple-test-client/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd "$(dirname -- "$(readlink -f -- "$0";)";)" + +python3 -m http.server 2020 diff --git a/pages/edit-otp.go.html b/red-pages/edit-otp.go.html similarity index 100% rename from pages/edit-otp.go.html rename to red-pages/edit-otp.go.html diff --git a/pages/edit-password.go.html b/red-pages/edit-password.go.html similarity index 100% rename from pages/edit-password.go.html rename to red-pages/edit-password.go.html diff --git a/pages/edit.go.html b/red-pages/edit.go.html similarity index 100% rename from pages/edit.go.html rename to red-pages/edit.go.html diff --git a/pages/index-guest.go.html b/red-pages/index-guest.go.html similarity index 100% rename from pages/index-guest.go.html rename to red-pages/index-guest.go.html diff --git a/pages/index.go.html b/red-pages/index.go.html similarity index 100% rename from pages/index.go.html rename to red-pages/index.go.html diff --git a/pages/login-otp.go.html b/red-pages/login-otp.go.html similarity index 100% rename from pages/login-otp.go.html rename to red-pages/login-otp.go.html diff --git a/pages/login.go.html b/red-pages/login.go.html similarity index 100% rename from pages/login.go.html rename to red-pages/login.go.html diff --git a/pages/manage-apps.go.html b/red-pages/manage-apps.go.html similarity index 100% rename from pages/manage-apps.go.html rename to red-pages/manage-apps.go.html diff --git a/pages/manage-users.go.html b/red-pages/manage-users.go.html similarity index 100% rename from pages/manage-users.go.html rename to red-pages/manage-users.go.html diff --git a/pages/oauth-authorize.go.html b/red-pages/oauth-authorize.go.html similarity index 100% rename from pages/oauth-authorize.go.html rename to red-pages/oauth-authorize.go.html diff --git a/pages/pages.go b/red-pages/pages.go similarity index 91% rename from pages/pages.go rename to red-pages/pages.go index 03fbd38..d593552 100644 --- a/pages/pages.go +++ b/red-pages/pages.go @@ -1,4 +1,4 @@ -package pages +package red_pages import ( "embed" @@ -33,7 +33,7 @@ func LoadPages(wd string) (err error) { wdFs := os.DirFS(wwwDir) o = overlapfs.OverlapFS{A: wwwPages, B: wdFs} } - wwwTemplates, err = template.New("pages").Funcs(template.FuncMap{ + wwwTemplates, err = template.New("red-pages").Funcs(template.FuncMap{ "emailHide": EmailHide, }).ParseFS(o, "*.go.html") }) diff --git a/pages/pages_test.go b/red-pages/pages_test.go similarity index 92% rename from pages/pages_test.go rename to red-pages/pages_test.go index 7d0c445..25a7d7a 100644 --- a/pages/pages_test.go +++ b/red-pages/pages_test.go @@ -1,4 +1,4 @@ -package pages +package red_pages import ( "github.com/stretchr/testify/assert" diff --git a/pages/remove-otp.go.html b/red-pages/remove-otp.go.html similarity index 100% rename from pages/remove-otp.go.html rename to red-pages/remove-otp.go.html diff --git a/pages/reset-password.go.html b/red-pages/reset-password.go.html similarity index 100% rename from pages/reset-password.go.html rename to red-pages/reset-password.go.html diff --git a/server/auth.go b/red-server/auth.go similarity index 99% rename from server/auth.go rename to red-server/auth.go index 3a174e2..d094dd4 100644 --- a/server/auth.go +++ b/red-server/auth.go @@ -1,4 +1,4 @@ -package server +package red_server import ( "crypto/rand" diff --git a/server/auth_test.go b/red-server/auth_test.go similarity index 99% rename from server/auth_test.go rename to red-server/auth_test.go index 5a56732..cb296af 100644 --- a/server/auth_test.go +++ b/red-server/auth_test.go @@ -1,4 +1,4 @@ -package server +package red_server import ( "context" diff --git a/server/conf.go b/red-server/conf.go similarity index 94% rename from server/conf.go rename to red-server/conf.go index 24827ed..faafd09 100644 --- a/server/conf.go +++ b/red-server/conf.go @@ -1,4 +1,4 @@ -package server +package red_server import "github.com/1f349/tulip/mail" diff --git a/server/db.go b/red-server/db.go similarity index 97% rename from server/db.go rename to red-server/db.go index 4836f93..a43f270 100644 --- a/server/db.go +++ b/red-server/db.go @@ -1,4 +1,4 @@ -package server +package red_server import ( "github.com/1f349/tulip/database" diff --git a/server/edit.go b/red-server/edit.go similarity index 89% rename from server/edit.go rename to red-server/edit.go index 8e35039..2e5a217 100644 --- a/server/edit.go +++ b/red-server/edit.go @@ -1,10 +1,10 @@ -package server +package red_server import ( "fmt" "github.com/1f349/tulip/database" - "github.com/1f349/tulip/lists" - "github.com/1f349/tulip/pages" + "github.com/1f349/tulip/red-pages" + "github.com/1f349/tulip/utils" "github.com/google/uuid" "github.com/julienschmidt/httprouter" "net/http" @@ -30,13 +30,13 @@ func (h *HttpServer) EditGet(rw http.ResponseWriter, _ *http.Request, _ httprout http.Error(rw, "Failed to save session", http.StatusInternalServerError) return } - pages.RenderPageTemplate(rw, "edit", map[string]any{ + red_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(), + "ListZoneInfo": utils.ListZoneInfo(), + "ListLocale": utils.ListLocale(), }) } func (h *HttpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { diff --git a/server/home.go b/red-server/home.go similarity index 86% rename from server/home.go rename to red-server/home.go index 362eeee..0776cab 100644 --- a/server/home.go +++ b/red-server/home.go @@ -1,9 +1,9 @@ -package server +package red_server import ( "fmt" "github.com/1f349/tulip/database" - "github.com/1f349/tulip/pages" + "github.com/1f349/tulip/red-pages" "github.com/google/uuid" "github.com/julienschmidt/httprouter" "net/http" @@ -13,7 +13,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute rw.Header().Set("Content-Type", "text/html") rw.WriteHeader(http.StatusOK) if auth.IsGuest() { - pages.RenderPageTemplate(rw, "index-guest", map[string]any{ + red_pages.RenderPageTemplate(rw, "index-guest", map[string]any{ "ServiceName": h.conf.ServiceName, }) return @@ -41,7 +41,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute }) { return } - pages.RenderPageTemplate(rw, "index", map[string]any{ + red_pages.RenderPageTemplate(rw, "index", map[string]any{ "ServiceName": h.conf.ServiceName, "Auth": auth, "User": userWithName, diff --git a/server/login.go b/red-server/login.go similarity index 95% rename from server/login.go rename to red-server/login.go index 32549ea..d8a419d 100644 --- a/server/login.go +++ b/red-server/login.go @@ -1,4 +1,4 @@ -package server +package red_server import ( "crypto/rand" @@ -9,7 +9,7 @@ import ( "errors" "fmt" "github.com/1f349/tulip/database" - "github.com/1f349/tulip/pages" + "github.com/1f349/tulip/red-pages" "github.com/emersion/go-message/mail" "github.com/google/uuid" "github.com/julienschmidt/httprouter" @@ -46,7 +46,7 @@ func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httpr rw.Header().Set("Content-Type", "text/html") rw.WriteHeader(http.StatusOK) - pages.RenderPageTemplate(rw, "login", map[string]any{ + red_pages.RenderPageTemplate(rw, "login", map[string]any{ "ServiceName": h.conf.ServiceName, "Redirect": req.URL.Query().Get("redirect"), "Mismatch": req.URL.Query().Get("mismatch"), @@ -70,7 +70,7 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http loginMismatch = 1 return nil } - http.Error(rw, "Internal server error", http.StatusInternalServerError) + http.Error(rw, "Internal red-server error", http.StatusInternalServerError) return err } @@ -108,7 +108,7 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http "VerifyUrl": h.conf.BaseUrl + "/mail/verify/" + u.String(), }) if err != nil { - log.Println("[Tulip] Login: Failed to send verification email:", err) + log.Println("[RedTulip] Login: Failed to send verification email:", err) http.Error(rw, "500 Internal Server Error: Failed to send verification email", http.StatusInternalServerError) return } diff --git a/server/mail.go b/red-server/mail.go similarity index 97% rename from server/mail.go rename to red-server/mail.go index a26db0b..9c6eab1 100644 --- a/server/mail.go +++ b/red-server/mail.go @@ -1,8 +1,8 @@ -package server +package red_server import ( "github.com/1f349/tulip/database" - "github.com/1f349/tulip/pages" + "github.com/1f349/tulip/red-pages" "github.com/emersion/go-message/mail" "github.com/go-session/session" "github.com/google/uuid" @@ -67,7 +67,7 @@ func (h *HttpServer) MailPassword(rw http.ResponseWriter, req *http.Request, par return } - pages.RenderPageTemplate(rw, "reset-password", map[string]any{ + red_pages.RenderPageTemplate(rw, "reset-password", map[string]any{ "ServiceName": h.conf.ServiceName, }) } diff --git a/server/manage-apps.go b/red-server/manage-apps.go similarity index 97% rename from server/manage-apps.go rename to red-server/manage-apps.go index 514f28c..d9f4e6e 100644 --- a/server/manage-apps.go +++ b/red-server/manage-apps.go @@ -1,8 +1,8 @@ -package server +package red_server import ( "github.com/1f349/tulip/database" - "github.com/1f349/tulip/pages" + "github.com/1f349/tulip/red-pages" "github.com/go-oauth2/oauth2/v4" "github.com/google/uuid" "github.com/julienschmidt/httprouter" @@ -58,7 +58,7 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ validEdit: rw.Header().Set("Content-Type", "text/html") rw.WriteHeader(http.StatusOK) - pages.RenderPageTemplate(rw, "manage-apps", m) + red_pages.RenderPageTemplate(rw, "manage-apps", m) } func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { diff --git a/server/manage-users.go b/red-server/manage-users.go similarity index 95% rename from server/manage-users.go rename to red-server/manage-users.go index 505d912..6d24df7 100644 --- a/server/manage-users.go +++ b/red-server/manage-users.go @@ -1,9 +1,9 @@ -package server +package red_server import ( "errors" "github.com/1f349/tulip/database" - "github.com/1f349/tulip/pages" + "github.com/1f349/tulip/red-pages" "github.com/emersion/go-message/mail" "github.com/google/uuid" "github.com/julienschmidt/httprouter" @@ -66,7 +66,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ validEdit: rw.Header().Set("Content-Type", "text/html") rw.WriteHeader(http.StatusOK) - pages.RenderPageTemplate(rw, "manage-users", m) + red_pages.RenderPageTemplate(rw, "manage-users", m) } func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { @@ -131,7 +131,7 @@ func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, "RegisterUrl": h.conf.BaseUrl + "/mail/password/" + u.String(), }) if err != nil { - log.Println("[Tulip] Login: Failed to send register email:", err) + log.Println("[RedTulip] Login: Failed to send register email:", err) http.Error(rw, "500 Internal Server Error: Failed to send register email", http.StatusInternalServerError) return } diff --git a/server/oauth.go b/red-server/oauth.go similarity index 94% rename from server/oauth.go rename to red-server/oauth.go index fd971f2..f87abd1 100644 --- a/server/oauth.go +++ b/red-server/oauth.go @@ -1,9 +1,9 @@ -package server +package red_server import ( "github.com/1f349/tulip/database" - "github.com/1f349/tulip/pages" - "github.com/1f349/tulip/scope" + "github.com/1f349/tulip/red-pages" + "github.com/1f349/tulip/utils" "github.com/julienschmidt/httprouter" "net/http" "net/url" @@ -94,18 +94,18 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request } scopeList := form.Get("scope") - if !scope.ScopesExist(scopeList) { + if !utils.ScopesExist(scopeList) { http.Error(rw, "Invalid scopes", http.StatusBadRequest) return } rw.WriteHeader(http.StatusOK) - pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{ + red_pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{ "ServiceName": h.conf.ServiceName, "AppName": appName, "AppDomain": appDomain, "User": user, - "WantsList": scope.FancyScopeList(scopeList), + "WantsList": utils.FancyScopeList(scopeList), "ResponseType": form.Get("response_type"), "ResponseMode": form.Get("response_mode"), "ClientID": form.Get("client_id"), diff --git a/server/otp.go b/red-server/otp.go similarity index 94% rename from server/otp.go rename to red-server/otp.go index c2d6795..f9077c8 100644 --- a/server/otp.go +++ b/red-server/otp.go @@ -1,10 +1,10 @@ -package server +package red_server import ( "bytes" "encoding/base64" "github.com/1f349/tulip/database" - "github.com/1f349/tulip/pages" + "github.com/1f349/tulip/red-pages" "github.com/google/uuid" "github.com/julienschmidt/httprouter" "github.com/skip2/go-qrcode" @@ -21,7 +21,7 @@ func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ ht return } - pages.RenderPageTemplate(rw, "login-otp", map[string]any{ + red_pages.RenderPageTemplate(rw, "login-otp", map[string]any{ "ServiceName": h.conf.ServiceName, "Redirect": req.URL.Query().Get("redirect"), }) @@ -80,7 +80,7 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht if req.Method == http.MethodPost && req.FormValue("remove") == "1" { if !req.Form.Has("code") { // render page - pages.RenderPageTemplate(rw, "remove-otp", map[string]any{ + red_pages.RenderPageTemplate(rw, "remove-otp", map[string]any{ "ServiceName": h.conf.ServiceName, }) return @@ -154,7 +154,7 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht } // render page - pages.RenderPageTemplate(rw, "edit-otp", map[string]any{ + red_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, diff --git a/server/server.go b/red-server/server.go similarity index 98% rename from server/server.go rename to red-server/server.go index 3832a12..5eeddce 100644 --- a/server/server.go +++ b/red-server/server.go @@ -1,4 +1,4 @@ -package server +package red_server import ( "bytes" @@ -11,8 +11,8 @@ import ( clientStore "github.com/1f349/tulip/client-store" "github.com/1f349/tulip/database" "github.com/1f349/tulip/openid" - scope2 "github.com/1f349/tulip/scope" "github.com/1f349/tulip/theme" + scope2 "github.com/1f349/tulip/utils" "github.com/go-oauth2/oauth2/v4/errors" "github.com/go-oauth2/oauth2/v4/generates" "github.com/go-oauth2/oauth2/v4/manage" @@ -164,18 +164,18 @@ func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Ser r.POST("/mail/password", hs.MailPasswordPost) r.GET("/mail/delete/:code", hs.MailDelete) - // edit profile pages + // edit profile red-pages r.GET("/edit", hs.RequireAuthentication(hs.EditGet)) r.POST("/edit", hs.RequireAuthentication(hs.EditPost)) r.POST("/edit/otp", hs.RequireAuthentication(hs.EditOtpPost)) - // management pages + // management red-pages r.GET("/manage/apps", hs.RequireAdminAuthentication(hs.ManageAppsGet)) r.POST("/manage/apps", hs.RequireAdminAuthentication(hs.ManageAppsPost)) r.GET("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersGet)) r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost)) - // oauth pages + // oauth red-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) { diff --git a/server/server_test.go b/red-server/server_test.go similarity index 98% rename from server/server_test.go rename to red-server/server_test.go index 374a981..db9897c 100644 --- a/server/server_test.go +++ b/red-server/server_test.go @@ -1,4 +1,4 @@ -package server +package red_server import ( "fmt" diff --git a/utils/json-url.go b/utils/json-url.go new file mode 100644 index 0000000..74496f7 --- /dev/null +++ b/utils/json-url.go @@ -0,0 +1,21 @@ +package utils + +import ( + "encoding" + "net/url" +) + +type JsonUrl struct { + *url.URL +} + +var _ encoding.TextUnmarshaler = &JsonUrl{} + +func (s *JsonUrl) UnmarshalText(text []byte) error { + parse, err := url.Parse(string(text)) + if err != nil { + return err + } + s.URL = parse + return nil +} diff --git a/lists/locales.go b/utils/locales.go similarity index 99% rename from lists/locales.go rename to utils/locales.go index abf8fdc..bfd84e7 100644 --- a/lists/locales.go +++ b/utils/locales.go @@ -1,4 +1,4 @@ -package lists +package utils import ( "golang.org/x/text/language" diff --git a/lists/locales_test.go b/utils/locales_test.go similarity index 97% rename from lists/locales_test.go rename to utils/locales_test.go index d97a3d1..b5ac165 100644 --- a/lists/locales_test.go +++ b/utils/locales_test.go @@ -1,4 +1,4 @@ -package lists +package utils import ( "github.com/stretchr/testify/assert" diff --git a/password/password.go b/utils/password.go similarity index 96% rename from password/password.go rename to utils/password.go index d7f57e6..96617f7 100644 --- a/password/password.go +++ b/utils/password.go @@ -1,4 +1,4 @@ -package password +package utils import ( "golang.org/x/crypto/bcrypt" diff --git a/scope/scope.go b/utils/scope.go similarity index 99% rename from scope/scope.go rename to utils/scope.go index acdbb69..2458aa9 100644 --- a/scope/scope.go +++ b/utils/scope.go @@ -1,4 +1,4 @@ -package scope +package utils import ( "errors" diff --git a/scope/scope_test.go b/utils/scope_test.go similarity index 98% rename from scope/scope_test.go rename to utils/scope_test.go index 7f32df2..2c20bed 100644 --- a/scope/scope_test.go +++ b/utils/scope_test.go @@ -1,4 +1,4 @@ -package scope +package utils import ( "github.com/stretchr/testify/assert" diff --git a/password/secret.go b/utils/secret.go similarity index 96% rename from password/secret.go rename to utils/secret.go index 1eec1de..cb19a88 100644 --- a/password/secret.go +++ b/utils/secret.go @@ -1,4 +1,4 @@ -package password +package utils import "crypto/rand" diff --git a/lists/zoneinfo.go b/utils/zoneinfo.go similarity index 98% rename from lists/zoneinfo.go rename to utils/zoneinfo.go index 96d378b..f0a49c6 100644 --- a/lists/zoneinfo.go +++ b/utils/zoneinfo.go @@ -1,4 +1,4 @@ -package lists +package utils import ( "os" diff --git a/lists/zoneinfo_test.go b/utils/zoneinfo_test.go similarity index 96% rename from lists/zoneinfo_test.go rename to utils/zoneinfo_test.go index 83e594b..fc74518 100644 --- a/lists/zoneinfo_test.go +++ b/utils/zoneinfo_test.go @@ -1,4 +1,4 @@ -package lists +package utils import ( "github.com/stretchr/testify/assert"