From a05b2d983eebe79b8e6f030baf2e03bbcf70de1b Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Wed, 7 Feb 2024 01:18:17 +0000 Subject: [PATCH] Add oauth support --- client-store/client-store.go | 26 + cmd/lavender/serve.go | 56 +-- database/db-types.go | 39 ++ database/db.go | 41 ++ database/db_test.go | 5 + database/init.sql | 21 + database/tx.go | 145 ++++++ go.mod | 16 +- go.sum | 156 +++++- issuer/manager.go | 12 +- openid/config.go | 34 ++ openid/config_test.go | 19 + pages/index-guest.go.html | 20 + pages/index.go.html | 31 ++ .../login-memory.go.html | 4 +- .../flow-popup.go.html => pages/login.go.html | 2 +- pages/manage-apps.go.html | 148 ++++++ pages/manage-users.go.html | 100 ++++ pages/oauth-authorize.go.html | 36 ++ {server/pages => pages}/pages.go | 26 +- pages/pages_test.go | 11 + password/secret.go | 19 + scope/scope.go | 80 ++++ scope/scope_test.go | 42 ++ server/auth.go | 133 ++++++ server/conf.go | 18 +- server/db.go | 33 ++ server/flow.go | 146 ------ server/flow_test.go | 445 ------------------ server/home.go | 34 ++ server/login.go | 79 ++++ server/manage-apps.go | 149 ++++++ server/manage-users.go | 104 ++++ server/oauth.go | 151 ++++++ server/owners.go | 33 -- server/pages/flow-callback.go.html | 29 -- server/refresh.go | 183 ------- server/roles.go | 17 + server/roles_test.go | 12 + server/server.go | 264 ++++++++--- server/verify.go | 33 -- server/verify_test.go | 61 --- 42 files changed, 1935 insertions(+), 1078 deletions(-) create mode 100644 client-store/client-store.go create mode 100644 database/db-types.go create mode 100644 database/db.go create mode 100644 database/db_test.go create mode 100644 database/init.sql create mode 100644 database/tx.go create mode 100644 openid/config.go create mode 100644 openid/config_test.go create mode 100644 pages/index-guest.go.html create mode 100644 pages/index.go.html rename server/pages/flow-popup-memory.go.html => pages/login-memory.go.html (88%) rename server/pages/flow-popup.go.html => pages/login.go.html (92%) create mode 100644 pages/manage-apps.go.html create mode 100644 pages/manage-users.go.html create mode 100644 pages/oauth-authorize.go.html rename {server/pages => pages}/pages.go (55%) create mode 100644 pages/pages_test.go create mode 100644 password/secret.go create mode 100644 scope/scope.go create mode 100644 scope/scope_test.go create mode 100644 server/auth.go create mode 100644 server/db.go delete mode 100644 server/flow.go delete mode 100644 server/flow_test.go create mode 100644 server/home.go create mode 100644 server/login.go create mode 100644 server/manage-apps.go create mode 100644 server/manage-users.go create mode 100644 server/oauth.go delete mode 100644 server/owners.go delete mode 100644 server/pages/flow-callback.go.html delete mode 100644 server/refresh.go create mode 100644 server/roles.go create mode 100644 server/roles_test.go delete mode 100644 server/verify.go delete mode 100644 server/verify_test.go diff --git a/client-store/client-store.go b/client-store/client-store.go new file mode 100644 index 0000000..97da29a --- /dev/null +++ b/client-store/client-store.go @@ -0,0 +1,26 @@ +package client_store + +import ( + "context" + "github.com/1f349/lavender/database" + "github.com/go-oauth2/oauth2/v4" +) + +type ClientStore struct { + db *database.DB +} + +var _ oauth2.ClientStore = &ClientStore{} + +func New(db *database.DB) *ClientStore { + return &ClientStore{db: db} +} + +func (c *ClientStore) GetByID(ctx context.Context, id string) (oauth2.ClientInfo, error) { + tx, err := c.db.BeginCtx(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback() + return tx.GetClientInfo(id) +} diff --git a/cmd/lavender/serve.go b/cmd/lavender/serve.go index 86f97cc..8ba6b42 100644 --- a/cmd/lavender/serve.go +++ b/cmd/lavender/serve.go @@ -9,12 +9,14 @@ import ( "encoding/pem" "errors" "flag" + "github.com/1f349/lavender/database" + "github.com/1f349/lavender/pages" "github.com/1f349/lavender/server" - "github.com/1f349/lavender/server/pages" "github.com/1f349/mjwt" "github.com/1f349/violet/utils" exitReload "github.com/MrMelon54/exit-reload" "github.com/google/subcommands" + _ "github.com/mattn/go-sqlite3" "log" "os" "path/filepath" @@ -44,64 +46,56 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) return subcommands.ExitUsageError } - var conf server.Conf - err := loadConfig(s.configPath, &conf) + openConf, err := os.Open(s.configPath) if err != nil { if os.IsNotExist(err) { - log.Println("[Lavender] Error: missing config file") + log.Println("[Tulip] Error: missing config file") } else { - log.Println("[Lavender] Error: loading config file: ", err) + log.Println("[Tulip] Error: open config file: ", err) } return subcommands.ExitFailure } + var config server.Conf + err = json.NewDecoder(openConf).Decode(&config) + if err != nil { + log.Println("[Tulip] Error: invalid 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) + signingKey, err := mjwt.NewMJwtSignerFromFileOrCreate(config.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) + saveMjwtPubKey(signingKey, wd) + + db, err := database.Open(filepath.Join(wd, "lavender.db.sqlite")) + if err != nil { + log.Fatal("[Lavender] Failed to open database:", err) + } 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 server on '%s'\n", srv.Server.Addr) - go utils.RunBackgroundHttp("HTTP", srv.Server) + srv := server.NewHttpServer(config, db, signingKey) + log.Printf("[Lavender] Starting HTTP server on '%s'\n", srv.Addr) + go utils.RunBackgroundHttp("HTTP", srv) - 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() { + exitReload.ExitReload("Lavender", func() {}, func() { // stop http server - _ = srv.Server.Close() + _ = srv.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) diff --git a/database/db-types.go b/database/db-types.go new file mode 100644 index 0000000..cae329d --- /dev/null +++ b/database/db-types.go @@ -0,0 +1,39 @@ +package database + +import ( + "github.com/go-oauth2/oauth2/v4" + "time" +) + +type User struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Roles string `json:"roles"` + UpdatedAt time.Time `json:"updated_at"` + Active bool `json:"active"` +} + +type ClientInfoDbOutput struct { + Sub, Name, Secret, Domain, Owner string + SSO, Active bool +} + +var _ oauth2.ClientInfo = &ClientInfoDbOutput{} + +func (c *ClientInfoDbOutput) GetID() string { return c.Sub } +func (c *ClientInfoDbOutput) GetSecret() string { return c.Secret } +func (c *ClientInfoDbOutput) GetDomain() string { return c.Domain } +func (c *ClientInfoDbOutput) IsPublic() bool { return false } +func (c *ClientInfoDbOutput) GetUserID() string { return c.Owner } + +// GetName is an extra field for the oauth handler to display the application +// name +func (c *ClientInfoDbOutput) GetName() string { return c.Name } + +// IsSSO is an extra field for the oauth handler to skip the user input stage +// this is for trusted applications to get permissions without asking the user +func (c *ClientInfoDbOutput) IsSSO() bool { return c.SSO } + +// IsActive is an extra field for the app manager to get the active state +func (c *ClientInfoDbOutput) IsActive() bool { return c.Active } diff --git a/database/db.go b/database/db.go new file mode 100644 index 0000000..0e38862 --- /dev/null +++ b/database/db.go @@ -0,0 +1,41 @@ +package database + +import ( + "context" + "database/sql" + _ "embed" +) + +//go:embed init.sql +var initSql string + +type DB struct{ db *sql.DB } + +func Open(p string) (*DB, error) { + db, err := sql.Open("sqlite3", p) + if err != nil { + return nil, err + } + _, err = db.Exec(initSql) + return &DB{db: db}, err +} + +func (d *DB) Begin() (*Tx, error) { + begin, err := d.db.Begin() + if err != nil { + return nil, err + } + return &Tx{begin}, err +} + +func (d *DB) BeginCtx(ctx context.Context) (*Tx, error) { + begin, err := d.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + return &Tx{begin}, err +} + +func (d *DB) Close() error { + return d.db.Close() +} diff --git a/database/db_test.go b/database/db_test.go new file mode 100644 index 0000000..5d73aab --- /dev/null +++ b/database/db_test.go @@ -0,0 +1,5 @@ +package database + +import ( + _ "github.com/mattn/go-sqlite3" +) diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..3ff0d6f --- /dev/null +++ b/database/init.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS users +( + subject TEXT PRIMARY KEY UNIQUE NOT NULL, + email TEXT NOT NULL, + email_verified INTEGER DEFAULT 0 NOT NULL, + roles TEXT NOT NULL, + updated_at DATETIME, + active INTEGER DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS 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, + sso INTEGER, + active INTEGER DEFAULT 1, + FOREIGN KEY (owner) REFERENCES users (subject) +); diff --git a/database/tx.go b/database/tx.go new file mode 100644 index 0000000..a7cc3a1 --- /dev/null +++ b/database/tx.go @@ -0,0 +1,145 @@ +package database + +import ( + "database/sql" + "fmt" + "github.com/1f349/lavender/password" + "github.com/go-oauth2/oauth2/v4" + "github.com/google/uuid" + "time" +) + +func updatedAt() string { + return time.Now().UTC().Format(time.DateTime) +} + +type Tx struct{ tx *sql.Tx } + +func (t *Tx) Commit() error { + return t.tx.Commit() +} + +func (t *Tx) Rollback() { + _ = t.tx.Rollback() +} + +func (t *Tx) HasUser() error { + var exists bool + row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users)`) + err := row.Scan(&exists) + if err != nil { + return err + } + if !exists { + return sql.ErrNoRows + } + return nil +} + +func (t *Tx) InsertUser(subject, email string, verifyEmail bool, roles string, active bool) error { + _, err := t.tx.Exec(`INSERT INTO users (subject, email, email_verified, roles, updated_at, active) VALUES (?, ?, ?, ?, ?, ?)`, subject, email, verifyEmail, roles, updatedAt(), active) + return err +} + +func (t *Tx) GetUserRoles(sub string) (string, error) { + var r string + row := t.tx.QueryRow(`SELECT roles FROM users WHERE subject = ? LIMIT 1`, sub) + err := row.Scan(&r) + return r, err +} + +func (t *Tx) GetUser(sub string) (*User, error) { + var u User + row := t.tx.QueryRow(`SELECT email, email_verified, roles, updated_at, active FROM users WHERE subject = ?`, sub) + err := row.Scan(&u.Email, &u.EmailVerified, &u.Roles, &u.UpdatedAt, &u.Active) + u.Sub = sub + return &u, err +} + +func (t *Tx) GetUserEmail(sub string) (string, error) { + var email string + row := t.tx.QueryRow(`SELECT email FROM users WHERE subject = ?`, sub) + err := row.Scan(&email) + return email, err +} + +func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) { + var u ClientInfoDbOutput + row := t.tx.QueryRow(`SELECT secret, name, domain, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub) + err := row.Scan(&u.Secret, &u.Name, &u.Domain, &u.SSO, &u.Active) + u.Owner = sub + if !u.Active { + return nil, fmt.Errorf("client is not active") + } + return &u, err +} + +func (t *Tx) GetAppList(offset int) ([]ClientInfoDbOutput, error) { + var u []ClientInfoDbOutput + row, err := t.tx.Query(`SELECT subject, name, domain, owner, sso, active FROM client_store LIMIT 25 OFFSET ?`, offset) + if err != nil { + return nil, err + } + defer row.Close() + for row.Next() { + var a ClientInfoDbOutput + err := row.Scan(&a.Sub, &a.Name, &a.Domain, &a.Owner, &a.SSO, &a.Active) + if err != nil { + return nil, err + } + u = append(u, a) + } + return u, row.Err() +} + +func (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner string) error { + u := uuid.New() + secret, err := password.GenerateApiSecret(70) + if err != nil { + return err + } + _, err = t.tx.Exec(`INSERT INTO client_store (subject, name, secret, domain, owner, sso, active) VALUES (?, ?, ?, ?, ?, ?, ?)`, u.String(), name, secret, domain, owner, sso, active) + return err +} + +func (t *Tx) UpdateClientApp(subject uuid.UUID, owner string, name, domain string, sso, active bool) error { + _, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ? AND owner = ?`, name, domain, sso, active, subject.String(), owner) + return err +} + +func (t *Tx) ResetClientAppSecret(subject uuid.UUID, owner string) (string, error) { + secret, err := password.GenerateApiSecret(70) + if err != nil { + return "", err + } + _, err = t.tx.Exec(`UPDATE client_store SET secret = ? WHERE subject = ? AND owner = ?`, secret, subject.String(), owner) + return secret, err +} + +func (t *Tx) GetUserList(offset int) ([]User, error) { + var u []User + row, err := t.tx.Query(`SELECT subject, email, email_verified, roles, updated_at, active FROM users LIMIT 25 OFFSET ?`, offset) + if err != nil { + return nil, err + } + for row.Next() { + var a User + err := row.Scan(&a.Sub, &a.Email, &a.EmailVerified, &a.Roles, &a.UpdatedAt, &a.Active) + if err != nil { + return nil, err + } + u = append(u, a) + } + return u, row.Err() +} + +func (t *Tx) UpdateUser(subject, roles string, active bool) error { + _, err := t.tx.Exec(`UPDATE users SET active = ?, roles = ? WHERE subject = ?`, active, roles, subject) + return err +} + +func (t *Tx) UserEmailExists(email string) (exists bool, err error) { + row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = ? and email_verified = 1)`, email) + err = row.Scan(&exists) + return +} diff --git a/go.mod b/go.mod index 29181f4..d55a14f 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,14 @@ require ( github.com/1f349/overlapfs v0.0.1 github.com/1f349/violet v0.0.13 github.com/MrMelon54/exit-reload v0.0.1 + github.com/emersion/go-message v0.18.0 + 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.6.0 github.com/julienschmidt/httprouter v1.3.0 - github.com/rs/cors v1.10.1 + github.com/mattn/go-sqlite3 v1.14.18 github.com/stretchr/testify v1.8.4 golang.org/x/oauth2 v0.16.0 ) @@ -21,9 +24,20 @@ 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.1+incompatible // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 // indirect + github.com/tidwall/buntdb v1.1.2 // indirect + github.com/tidwall/gjson v1.12.1 // indirect + github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e // indirect + github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 // indirect golang.org/x/net v0.20.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.32.0 // indirect diff --git a/go.sum b/go.sum index d4254a6..9f37cdc 100644 --- a/go.sum +++ b/go.sum @@ -5,18 +5,39 @@ github.com/1f349/mjwt v0.2.1 h1:REdiM/MaNjYQwHvI39LaMPhlvMg4Vy9SgomWMsKTNz8= github.com/1f349/mjwt v0.2.1/go.mod h1:KEs6jd9JjWrQW+8feP2pGAU7pdA3aYTqjkT/YQr73PU= 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/violet v0.0.12 h1:VIiVYfKptCYJvwaJHFgtOyTUOURRMIltGp5Blw9+isY= -github.com/1f349/violet v0.0.12/go.mod h1:8xyh96shYiSBkwumvG/KkiY78tAhxiOomDlT7phZAbA= github.com/1f349/violet v0.0.13 h1:lJpTz15Ea83Uc1VAISXTjtKuzr8Pe8NM4cMGp3Aiyhk= github.com/1f349/violet v0.0.13/go.mod h1:Ga5/hWqI+EkR6J1mAMNzs7aJhuGcA89XFqgQaDXC7Jo= github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc= github.com/MrMelon54/exit-reload v0.0.1/go.mod h1:PLiSfmUzwdpTTQP3BBfUPhkqPwaIZjx0DuXBnM76Bug= github.com/MrMelon54/rescheduler v0.0.2 h1:efrRwr0BYlkaXFucZDjQqRyIawZiMEAnzjea46Bs9Oc= github.com/MrMelon54/rescheduler v0.0.2/go.mod h1:OQDFtZHdS4/qA/r7rtJUQA22/hbpnZ9MGQCXOPjhC6w= +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/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= +github.com/emersion/go-message v0.18.0 h1:7LxAXHRpSeoO/Wom3ZApVZYG7c3d17yCScYce8WiXA8= +github.com/emersion/go-message v0.18.0/go.mod h1:Zi69ACvzaoV/MBnrxfVBPV3xWEuCmC2nEN39oJF4B8A= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +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= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/go-oauth2/oauth2/v4 v4.5.2 h1:CuZhD3lhGuI6aNLyUbRHXsgG2RwGRBOuCBfd4WQKqBQ= +github.com/go-oauth2/oauth2/v4 v4.5.2/go.mod h1:wk/2uLImWIa9VVQDgxz99H2GDbhmfi/9/Xr+GvkSUSQ= +github.com/go-session/session v3.1.2+incompatible h1:yStchEObKg4nk2F7JGE7KoFIrA/1Y078peagMWcrncg= +github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0= +github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= +github.com/golang-jwt/jwt v3.2.1+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/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -26,7 +47,6 @@ 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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -34,73 +54,171 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 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-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 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 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= +github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +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= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= +github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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= +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= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 h1:G6Z6HvJuPjG6XfNGi/feOATzeJrfgTNJY+rGrHbA04E= +github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8= +github.com/tidwall/buntdb v1.1.2 h1:noCrqQXL9EKMtcdwJcmuVKSEjqu1ua99RHHgbLTEHRo= +github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI= +github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= +github.com/tidwall/gjson v1.12.1 h1:ikuZsLdhr8Ws0IdROXUS1Gi4v9Z4pGqpX/CvJkxvfpo= +github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb h1:5NSYaAdrnblKByzd7XByQEJVT8+9v0W/tIY0Oo4OwrE= +github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= +github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2KoJQD9cTQ6dyP2co9q4yzmT9FZo= +github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao= +github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE= +github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= +github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +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/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= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +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= 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/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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -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/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +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/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= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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-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.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/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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +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/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 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/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= @@ -109,15 +227,21 @@ google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ 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= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/issuer/manager.go b/issuer/manager.go index c9f7893..a5a6ff5 100644 --- a/issuer/manager.go +++ b/issuer/manager.go @@ -41,12 +41,16 @@ func NewManagerForTests(services []*WellKnownOIDC) *Manager { return l } -func (l *Manager) CheckNamespace(namespace string) bool { - _, ok := l.m[namespace] +func (m *Manager) CheckNamespace(namespace string) bool { + _, ok := m.m[namespace] return ok } -func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC { +func (m *Manager) GetService(namespace string) *WellKnownOIDC { + return m.m[namespace] +} + +func (m *Manager) FindServiceFromLogin(login string) *WellKnownOIDC { // @ should have at least one byte before it n := strings.IndexByte(login, '@') if n < 1 { @@ -57,5 +61,5 @@ func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC { if n2 != -1 { return nil } - return l.m[login[n+1:]] + return m.GetService(login[n+1:]) } diff --git a/openid/config.go b/openid/config.go new file mode 100644 index 0000000..3abb73a --- /dev/null +++ b/openid/config.go @@ -0,0 +1,34 @@ +package openid + +import ( + "strings" +) + +type Config struct { + 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"` +} + +func GenConfig(baseUrl string, scopes, claims []string) Config { + baseUrlRaw := baseUrl + if !strings.HasSuffix(baseUrl, "/") { + baseUrl += "/" + } + + return Config{ + Issuer: baseUrlRaw, + AuthorizationEndpoint: baseUrl + "authorize", + TokenEndpoint: baseUrl + "token", + UserInfoEndpoint: baseUrl + "userinfo", + ResponseTypesSupported: []string{"code"}, + ScopesSupported: scopes, + ClaimsSupported: claims, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + } +} diff --git a/openid/config_test.go b/openid/config_test.go new file mode 100644 index 0000000..6cd2cc9 --- /dev/null +++ b/openid/config_test.go @@ -0,0 +1,19 @@ +package openid + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGenConfig(t *testing.T) { + assert.Equal(t, Config{ + Issuer: "https://example.com", + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + UserInfoEndpoint: "https://example.com/userinfo", + ResponseTypesSupported: []string{"code"}, + ScopesSupported: []string{"openid", "email"}, + ClaimsSupported: []string{"name", "email", "preferred_username"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + }, GenConfig("https://example.com", []string{"openid", "email"}, []string{"name", "email", "preferred_username"})) +} diff --git a/pages/index-guest.go.html b/pages/index-guest.go.html new file mode 100644 index 0000000..e4c9927 --- /dev/null +++ b/pages/index-guest.go.html @@ -0,0 +1,20 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
Not logged in
+
+
+ +
+
+
+ + diff --git a/pages/index.go.html b/pages/index.go.html new file mode 100644 index 0000000..1b04c0f --- /dev/null +++ b/pages/index.go.html @@ -0,0 +1,31 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
Logged in as: {{.DisplayName}} ({{.Subject}})
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+ + diff --git a/server/pages/flow-popup-memory.go.html b/pages/login-memory.go.html similarity index 88% rename from server/pages/flow-popup-memory.go.html rename to pages/login-memory.go.html index 47ea38f..9d9f143 100644 --- a/server/pages/flow-popup-memory.go.html +++ b/pages/login-memory.go.html @@ -11,13 +11,13 @@
Log in as: {{.LoginName}}
-
+
-
+ diff --git a/server/pages/flow-popup.go.html b/pages/login.go.html similarity index 92% rename from server/pages/flow-popup.go.html rename to pages/login.go.html index a6c78ea..f19eac4 100644 --- a/server/pages/flow-popup.go.html +++ b/pages/login.go.html @@ -9,7 +9,7 @@

{{.ServiceName}}

- +
diff --git a/pages/manage-apps.go.html b/pages/manage-apps.go.html new file mode 100644 index 0000000..3d05460 --- /dev/null +++ b/pages/manage-apps.go.html @@ -0,0 +1,148 @@ + + + + {{.ServiceName}} + + + + +
+

{{.ServiceName}}

+
+
+ + + + + {{if .NewAppSecret}} +
New application secret: {{.NewAppSecret}} for {{.NewAppName}}
+ {{end}} + + {{if .Edit}} +

Edit Client Application

+
+ + + +
+ +
+
+ + +
+
+ + +
+ {{if .IsAdmin}} +
+ +
+ {{end}} +
+ +
+ +
+
+ + +
+ {{else}} +

Manage Client Applications

+ {{if eq (len .Apps) 0}} +
No client applications found
+ {{else}} + + + + + + + + + + + + + + {{range .Apps}} + + + + + + + + + + {{end}} + +
IDNameDomainSSOActiveOwnerActions
{{.Sub}}{{.Name}}{{.Domain}}{{.SSO}}{{.Active}}{{.Owner}} +
+ + + +
+
+ + + + +
+
+ {{end}} + +

Create Client Application

+
+ + +
+ + +
+
+ + +
+ {{if .IsAdmin}} +
+ +
+ {{end}} +
+ +
+ +
+ {{end}} +
+ + diff --git a/pages/manage-users.go.html b/pages/manage-users.go.html new file mode 100644 index 0000000..84c4677 --- /dev/null +++ b/pages/manage-users.go.html @@ -0,0 +1,100 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
+ +
+ + {{if .Edit}} +

Edit User

+
+ + +
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+ {{else}} +

Manage Users

+ {{if eq (len .Users) 0}} +
No users found, this is definitely a bug.
+ {{else}} + + + + + + + + + + + + + + {{range .Users}} + + + + + + + + + + {{end}} + +
SubjectEmailEmail VerifiedRolesLast UpdatedActiveActions
{{.Sub}} + {{if $.EmailShow}} + {{.Email}} + {{else}} + {{emailHide .Email}} + {{end}} + {{.EmailVerified}}{{.Roles}}{{.UpdatedAt}}{{.Active}} + {{if eq $.CurrentAdmin .Sub}} + + {{else}} +
+ + + +
+
+ + +
+ {{end}} +
+
+ + {{if not .EmailShow}} + + {{end}} + +
+ {{end}} + {{end}} +
+ + diff --git a/pages/oauth-authorize.go.html b/pages/oauth-authorize.go.html new file mode 100644 index 0000000..ae31166 --- /dev/null +++ b/pages/oauth-authorize.go.html @@ -0,0 +1,36 @@ + + + + {{.ServiceName}} + + + +
+

{{.ServiceName}}

+
+
+
+
The application {{.AppName}} wants to access your account ({{.DisplayName}}). It requests the following permissions:
+
+
    + {{range .WantsList}} +
  • {{.}}
  • + {{end}} +
+
+
+ + + + + + + + + +
+
Authorizing this action will redirect you to {{.AppDomain}} with access to the permissions requested above.
+
+
+ + diff --git a/server/pages/pages.go b/pages/pages.go similarity index 55% rename from server/pages/pages.go rename to pages/pages.go index 8a2da41..03fbd38 100644 --- a/server/pages/pages.go +++ b/pages/pages.go @@ -16,14 +16,14 @@ import ( var ( //go:embed *.go.html - flowPages embed.FS - flowTemplates *template.Template - loadOnce sync.Once + wwwPages embed.FS + wwwTemplates *template.Template + loadOnce sync.Once ) func LoadPages(wd string) (err error) { loadOnce.Do(func() { - var o fs.FS = flowPages + var o fs.FS = wwwPages if wd != "" { wwwDir := filepath.Join(wd, "www") err = os.Mkdir(wwwDir, os.ModePerm) @@ -31,16 +31,28 @@ func LoadPages(wd string) (err error) { return } wdFs := os.DirFS(wwwDir) - o = overlapfs.OverlapFS{A: flowPages, B: wdFs} + o = overlapfs.OverlapFS{A: wwwPages, B: wdFs} } - flowTemplates, err = template.ParseFS(o, "*.go.html") + wwwTemplates, err = template.New("pages").Funcs(template.FuncMap{ + "emailHide": EmailHide, + }).ParseFS(o, "*.go.html") }) return err } func RenderPageTemplate(wr io.Writer, name string, data any) { - err := flowTemplates.ExecuteTemplate(wr, name+".go.html", data) + err := wwwTemplates.ExecuteTemplate(wr, name+".go.html", data) if err != nil { log.Printf("Failed to render page: %s: %s\n", name, err) } } + +func EmailHide(a string) string { + b := []byte(a) + for i := range b { + if b[i] != '@' && b[i] != '.' { + b[i] = 'x' + } + } + return string(b) +} diff --git a/pages/pages_test.go b/pages/pages_test.go new file mode 100644 index 0000000..7d0c445 --- /dev/null +++ b/pages/pages_test.go @@ -0,0 +1,11 @@ +package pages + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestEmailHide(t *testing.T) { + assert.Equal(t, "xx", EmailHide("hi")) + assert.Equal(t, "xxxxxxx@xxxxxxx.xxx", EmailHide("example@example.com")) +} diff --git a/password/secret.go b/password/secret.go new file mode 100644 index 0000000..1eec1de --- /dev/null +++ b/password/secret.go @@ -0,0 +1,19 @@ +package password + +import "crypto/rand" + +func GenerateApiSecret(length int) (string, error) { + const secretChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_." + var _ = secretChars[63] // compiler check: ensure there is at least 64 chars here + + b := make([]byte, length) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + for i := range b { + b[i] = secretChars[b[i]&0x3f] // only use the lower 6 bits + } + return string(b), nil +} diff --git a/scope/scope.go b/scope/scope.go new file mode 100644 index 0000000..5a62816 --- /dev/null +++ b/scope/scope.go @@ -0,0 +1,80 @@ +package scope + +import ( + "errors" + "strings" +) + +var ErrInvalidScope = errors.New("invalid scope") + +var scopeDescription = map[string]string{ + "openid": "Verify your user identity", + "name": "Access your name", + "username": "Access your username", + "profile": "Access your public profile", + "email": "Access your email", + "birthdate": "Access your birthdate", + "age": "Access your current age", + "zoneinfo": "Access time zone setting", + "locale": "Access your language setting", +} + +func ScopesExist(scope string) bool { + _, err := internalGetScopes(scope, func(key, desc string) string { return "" }) + return err == nil +} + +// FancyScopeList takes a scope string and outputs a slice of scope descriptions +func FancyScopeList(scope string) (arr []string) { + a, err := internalGetScopes(scope, func(key, desc string) string { + return desc + }) + if err != nil { + return nil + } + return a +} + +func internalGetScopes(scope string, f func(key, desc string) string) (arr []string, err error) { + seen := make(map[string]struct{}) +outer: + for { + n := strings.IndexAny(scope, ", ") + var key string + switch n { + case 0: + // first char is matching, no key name found, just continue + scope = scope[1:] + continue outer + case -1: + // no more matching chars, if scope is empty then we are done + if len(scope) == 0 { + return + } + + // otherwise set the key and empty scope + key = scope + scope = "" + default: + // set the key and trim from scope + key = scope[:n] + scope = scope[n+1:] + } + + // check if key has been seen already + if _, ok := seen[key]; ok { + continue outer + } + + // set seen flag + seen[key] = struct{}{} + + // output the description + if d, ok := scopeDescription[key]; ok && d != "" { + arr = append(arr, f(key, d)) + continue + } + + err = ErrInvalidScope + } +} diff --git a/scope/scope_test.go b/scope/scope_test.go new file mode 100644 index 0000000..7f32df2 --- /dev/null +++ b/scope/scope_test.go @@ -0,0 +1,42 @@ +package scope + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestScopesExist(t *testing.T) { + desc := scopeDescription + scopeDescription = map[string]string{ + "a": "A", + "b": "B", + "c": "C", + } + + assert.True(t, ScopesExist("a b c")) + assert.False(t, ScopesExist("a b d")) + assert.True(t, ScopesExist("a,b c")) + assert.False(t, ScopesExist("a,b d")) + + scopeDescription = desc +} + +func TestFancyScopeList(t *testing.T) { + desc := scopeDescription + scopeDescription = map[string]string{ + "a": "A", + "b": "B", + "c": "C", + } + + assert.Equal(t, []string{"A"}, FancyScopeList("a")) + assert.Equal(t, []string{"A", "B"}, FancyScopeList("a b")) + assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a b c")) + assert.Equal(t, []string{"A", "B"}, FancyScopeList("a,b")) + assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a,b,c")) + assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a b,c")) + assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a,b c")) + assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a, b, c")) + + scopeDescription = desc +} diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 0000000..e94cce5 --- /dev/null +++ b/server/auth.go @@ -0,0 +1,133 @@ +package server + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "fmt" + "github.com/1f349/lavender/database" + "github.com/go-session/session" + "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 { + Session session.Store + Data SessionData +} + +type SessionData struct { + ID string + DisplayName string +} + +func (u UserAuth) IsGuest() bool { + return u.Data.ID == "" +} + +func (u UserAuth) SaveSessionData() error { + u.Session.Set("session-data", u.Data) + return u.Session.Save() +} + +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 + if h.DbTx(rw, func(tx *database.Tx) (err error) { + roles, err = tx.GetUserRoles(auth.Data.ID) + return + }) { + return + } + if HasRole(roles, "lavender:admin") { + http.Error(rw, "403 Forbidden", http.StatusForbidden) + return + } + next(rw, req, params, auth) + }) +} + +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) + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) + return + } + next(rw, req, params, auth) + }) +} + +func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle { + return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + auth, err := internalAuthenticationHandler(rw, req) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + if auth.IsGuest() { + if loginCookie, err := req.Cookie("login-data"); err == nil { + if decryptedBytes, err := base64.RawStdEncoding.DecodeString(loginCookie.Value); err == nil { + if decryptedData, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signingKey.PrivateKey(), decryptedBytes, []byte("login-data")); err == nil { + auth.Data.ID = string(decryptedData) + } + } + } + } + next(rw, req, params, auth) + } +} + +func internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) { + ss, err := session.Start(req.Context(), rw, req) + if err != nil { + return UserAuth{}, fmt.Errorf("failed to start session") + } + + // get auth object + userIdRaw, ok := ss.Get("session-data") + if !ok { + return UserAuth{Session: ss}, nil + } + userData, ok := userIdRaw.(SessionData) + if !ok { + ss.Delete("session-data") + err := ss.Save() + if err != nil { + return UserAuth{Session: ss}, fmt.Errorf("failed to reset invalid session data") + } + } + + return UserAuth{Session: ss, Data: userData}, 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/conf.go b/server/conf.go index 719db7c..0f497a6 100644 --- a/server/conf.go +++ b/server/conf.go @@ -2,20 +2,12 @@ package server import ( "github.com/1f349/lavender/issuer" - "github.com/1f349/lavender/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"` + Listen string `json:"listen"` + BaseUrl string `json:"base_url"` + ServiceName string `json:"service_name"` + Issuer string `json:"issuer"` + SsoServices []issuer.SsoConfig `json:"sso_services"` } diff --git a/server/db.go b/server/db.go new file mode 100644 index 0000000..7b389be --- /dev/null +++ b/server/db.go @@ -0,0 +1,33 @@ +package server + +import ( + "github.com/1f349/lavender/database" + "log" + "net/http" +) + +// 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.Tx) error) bool { + tx, err := h.db.Begin() + if err != nil { + http.Error(rw, "Failed to begin database transaction", http.StatusInternalServerError) + return true + } + defer tx.Rollback() + + err = action(tx) + if err != nil { + http.Error(rw, "Database error", http.StatusInternalServerError) + log.Println("Database action error:", err) + return true + } + err = tx.Commit() + if err != nil { + http.Error(rw, "Database error", http.StatusInternalServerError) + log.Println("Database commit error:", err) + } + + return false +} diff --git a/server/flow.go b/server/flow.go deleted file mode 100644 index 8030f06..0000000 --- a/server/flow.go +++ /dev/null @@ -1,146 +0,0 @@ -package server - -import ( - "context" - _ "embed" - "fmt" - "github.com/1f349/lavender/issuer" - "github.com/1f349/lavender/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/server/flow_test.go b/server/flow_test.go deleted file mode 100644 index c4a1efa..0000000 --- a/server/flow_test.go +++ /dev/null @@ -1,445 +0,0 @@ -package server - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/rsa" - "errors" - "fmt" - "github.com/1f349/cache" - "github.com/1f349/lavender/issuer" - "github.com/1f349/lavender/server/pages" - "github.com/1f349/lavender/utils" - "github.com/1f349/mjwt" - "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/server/home.go b/server/home.go new file mode 100644 index 0000000..62d6f16 --- /dev/null +++ b/server/home.go @@ -0,0 +1,34 @@ +package server + +import ( + "github.com/1f349/lavender/pages" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "net/http" +) + +func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + rw.Header().Set("Content-Type", "text/html") + rw.WriteHeader(http.StatusOK) + if auth.IsGuest() { + pages.RenderPageTemplate(rw, "index-guest", map[string]any{ + "ServiceName": h.conf.ServiceName, + }) + return + } + + lNonce := uuid.NewString() + auth.Session.Set("action-nonce", lNonce) + if auth.Session.Save() != nil { + http.Error(rw, "Failed to save session", http.StatusInternalServerError) + return + } + + pages.RenderPageTemplate(rw, "index", map[string]any{ + "ServiceName": h.conf.ServiceName, + "Auth": auth, + "Subject": auth.Data.ID, + "DisplayName": auth.Data.DisplayName, + "Nonce": lNonce, + }) +} diff --git a/server/login.go b/server/login.go new file mode 100644 index 0000000..e84dea5 --- /dev/null +++ b/server/login.go @@ -0,0 +1,79 @@ +package server + +import ( + "github.com/1f349/lavender/pages" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "golang.org/x/oauth2" + "net/http" + "net/url" + "strings" + "time" +) + +func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + cookie, err := req.Cookie("lavender-login-name") + if err == nil && cookie.Valid() == nil { + pages.RenderPageTemplate(rw, "login-memory", map[string]any{ + "ServiceName": h.conf.ServiceName, + "Origin": req.URL.Query().Get("origin"), + "LoginName": cookie.Value, + }) + return + } + pages.RenderPageTemplate(rw, "login", map[string]any{ + "ServiceName": h.conf.ServiceName, + "Origin": req.URL.Query().Get("origin"), + }) +} + +func (h *HttpServer) loginPost(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: "/login", + RawQuery: url.Values{ + "origin": []string{req.PostFormValue("origin")}, + }.Encode(), + }).String(), http.StatusFound) + return + } + loginName := req.PostFormValue("loginname") + 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] + + 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, + }) + + // save state for use later + state := login.Config.Namespace + ":" + uuid.NewString() + h.flowState.Set(state, flowStateData{login}, time.Now().Add(15*time.Minute)) + + // 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) +} diff --git a/server/manage-apps.go b/server/manage-apps.go new file mode 100644 index 0000000..5d5b6a8 --- /dev/null +++ b/server/manage-apps.go @@ -0,0 +1,149 @@ +package server + +import ( + "github.com/1f349/lavender/database" + "github.com/1f349/lavender/pages" + "github.com/go-oauth2/oauth2/v4" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "net/http" + "net/url" + "strconv" +) + +func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + offset := 0 + q := req.URL.Query() + if q.Has("offset") { + var err error + offset, err = strconv.Atoi(q.Get("offset")) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid offset", http.StatusBadRequest) + return + } + } + + var roles string + var appList []database.ClientInfoDbOutput + if h.DbTx(rw, func(tx *database.Tx) (err error) { + roles, err = tx.GetUserRoles(auth.Data.ID) + if err != nil { + return + } + appList, err = tx.GetAppList(offset) + return + }) { + return + } + + m := map[string]any{ + "ServiceName": h.conf.ServiceName, + "Apps": appList, + "Offset": offset, + "IsAdmin": HasRole(roles, "lavender:admin"), + "NewAppName": q.Get("NewAppName"), + "NewAppSecret": q.Get("NewAppSecret"), + } + if q.Has("edit") { + for _, i := range appList { + if i.Sub == q.Get("edit") { + m["Edit"] = i + goto validEdit + } + } + http.Error(rw, "400 Bad Request: Invalid client app to edit", http.StatusBadRequest) + return + } + +validEdit: + rw.Header().Set("Content-Type", "text/html") + rw.WriteHeader(http.StatusOK) + pages.RenderPageTemplate(rw, "manage-apps", m) +} + +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) + return + } + + offset := req.Form.Get("offset") + action := req.Form.Get("action") + name := req.Form.Get("name") + domain := req.Form.Get("domain") + sso := req.Form.Has("sso") + active := req.Form.Has("active") + + if sso { + var roles string + if h.DbTx(rw, func(tx *database.Tx) (err error) { + roles, err = tx.GetUserRoles(auth.Data.ID) + return + }) { + return + } + if HasRole(roles, "lavender:admin") { + http.Error(rw, "400 Bad Request: Only admin users can create SSO client applications", http.StatusBadRequest) + return + } + } + + switch action { + case "create": + if h.DbTx(rw, func(tx *database.Tx) error { + return tx.InsertClientApp(name, domain, sso, active, auth.Data.ID) + }) { + return + } + case "edit": + if h.DbTx(rw, func(tx *database.Tx) error { + sub, err := uuid.Parse(req.Form.Get("subject")) + if err != nil { + return err + } + return tx.UpdateClientApp(sub, auth.Data.ID, name, domain, sso, active) + }) { + return + } + case "secret": + var info oauth2.ClientInfo + var secret string + if h.DbTx(rw, func(tx *database.Tx) error { + sub, err := uuid.Parse(req.Form.Get("subject")) + if err != nil { + return err + } + info, err = tx.GetClientInfo(sub.String()) + if err != nil { + return err + } + secret, err = tx.ResetClientAppSecret(sub, auth.Data.ID) + return err + }) { + return + } + + appName := "Unknown..." + if getName, ok := info.(interface{ GetName() string }); ok { + appName = getName.GetName() + } + + h.ManageAppsGet(rw, &http.Request{ + URL: &url.URL{ + RawQuery: url.Values{ + "offset": []string{offset}, + "NewAppName": []string{appName}, + "NewAppSecret": []string{secret}, + }.Encode(), + }, + }, httprouter.Params{}, auth) + return + default: + http.Error(rw, "400 Bad Request: Invalid action", http.StatusBadRequest) + return + } + + redirectUrl := url.URL{Path: "/manage/apps", RawQuery: url.Values{"offset": []string{offset}}.Encode()} + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) +} diff --git a/server/manage-users.go b/server/manage-users.go new file mode 100644 index 0000000..fe5fce0 --- /dev/null +++ b/server/manage-users.go @@ -0,0 +1,104 @@ +package server + +import ( + "github.com/1f349/lavender/database" + "github.com/1f349/lavender/pages" + "github.com/julienschmidt/httprouter" + "net/http" + "net/url" + "strconv" +) + +func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + offset := 0 + q := req.URL.Query() + if q.Has("offset") { + var err error + offset, err = strconv.Atoi(q.Get("offset")) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid offset", http.StatusBadRequest) + return + } + } + + var roles string + var userList []database.User + if h.DbTx(rw, func(tx *database.Tx) (err error) { + roles, err = tx.GetUserRoles(auth.Data.ID) + if err != nil { + return + } + userList, err = tx.GetUserList(offset) + return + }) { + return + } + if HasRole(roles, "lavender:admin") { + http.Error(rw, "403 Forbidden", http.StatusForbidden) + return + } + + m := map[string]any{ + "ServiceName": h.conf.ServiceName, + "Users": userList, + "Offset": offset, + "EmailShow": req.URL.Query().Has("show-email"), + "CurrentAdmin": auth.Data.ID, + } + if q.Has("edit") { + for _, i := range userList { + if i.Sub == q.Get("edit") { + m["Edit"] = i + goto validEdit + } + } + http.Error(rw, "400 Bad Request: Invalid user to edit", http.StatusBadRequest) + return + } + +validEdit: + rw.Header().Set("Content-Type", "text/html") + rw.WriteHeader(http.StatusOK) + pages.RenderPageTemplate(rw, "manage-users", m) +} + +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) + return + } + + var roles string + if h.DbTx(rw, func(tx *database.Tx) (err error) { + roles, err = tx.GetUserRoles(auth.Data.ID) + return + }) { + return + } + if HasRole(roles, "lavender:admin") { + http.Error(rw, "400 Bad Request: Only admin users can manage users", http.StatusBadRequest) + return + } + + offset := req.Form.Get("offset") + action := req.Form.Get("action") + newRoles := req.Form.Get("role") + active := req.Form.Has("active") + + switch action { + case "edit": + if h.DbTx(rw, func(tx *database.Tx) error { + sub := req.Form.Get("subject") + return tx.UpdateUser(sub, newRoles, active) + }) { + return + } + default: + http.Error(rw, "400 Bad Request: Invalid action", http.StatusBadRequest) + return + } + + redirectUrl := url.URL{Path: "/manage/users", RawQuery: url.Values{"offset": []string{offset}}.Encode()} + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) +} diff --git a/server/oauth.go b/server/oauth.go new file mode 100644 index 0000000..409f294 --- /dev/null +++ b/server/oauth.go @@ -0,0 +1,151 @@ +package server + +import ( + "github.com/1f349/lavender/pages" + "github.com/1f349/lavender/scope" + "github.com/julienschmidt/httprouter" + "net/http" + "net/url" +) + +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 + + var form url.Values + if isPost { + err := req.ParseForm() + if err != nil { + http.Error(rw, "Failed to parse form", http.StatusInternalServerError) + return + } + form = req.PostForm + } else { + form = req.URL.Query() + } + + clientID := form.Get("client_id") + client, err := h.oauthMgr.GetClient(req.Context(), clientID) + if err != nil { + http.Error(rw, "Invalid client", http.StatusBadRequest) + return + } + + redirectUri := form.Get("redirect_uri") + if redirectUri != client.GetDomain() { + http.Error(rw, "Incorrect redirect URI", http.StatusBadRequest) + return + } + + if form.Has("cancel") { + uCancel, err := url.Parse(client.GetDomain()) + if err != nil { + http.Error(rw, "Invalid redirect URI", http.StatusBadRequest) + return + } + q := uCancel.Query() + q.Set("error", "access_denied") + uCancel.RawQuery = q.Encode() + + http.Redirect(rw, req, uCancel.String(), http.StatusFound) + return + } + + var isSSO bool + if clientIsSSO, ok := client.(interface{ IsSSO() bool }); ok { + isSSO = clientIsSSO.IsSSO() + } + + switch { + case isSSO && isPost: + http.Error(rw, "400 Bad Request: Not sure how you even managed to send a POST request for an SSO application", http.StatusBadRequest) + return + case !isSSO && !isPost: + // find application redirect domain and name + appUrlFull, err := url.Parse(client.GetDomain()) + if err != nil { + http.Error(rw, "500 Internal Server Error: Failed to parse application redirect URL", http.StatusInternalServerError) + return + } + appDomain := appUrlFull.Scheme + "://" + appUrlFull.Host + appName := appUrlFull.Host + if clientGetName, ok := client.(interface{ GetName() string }); ok { + n := clientGetName.GetName() + if n != "" { + appName = n + } + } + + scopeList := form.Get("scope") + if !scope.ScopesExist(scopeList) { + http.Error(rw, "Invalid scopes", http.StatusBadRequest) + return + } + + rw.WriteHeader(http.StatusOK) + pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{ + "ServiceName": h.conf.ServiceName, + "AppName": appName, + "AppDomain": appDomain, + "DisplayName": auth.Data.DisplayName, + "WantsList": scope.FancyScopeList(scopeList), + "ResponseType": form.Get("response_type"), + "ResponseMode": form.Get("response_mode"), + "ClientID": form.Get("client_id"), + "RedirectUri": form.Get("redirect_uri"), + "State": form.Get("state"), + "Scope": scopeList, + "Nonce": form.Get("nonce"), + }) + return + } + + // redirect with an error if the action is not authorize + if form.Get("oauth_action") == "authorize" || isSSO { + if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + } + return + } + + parsedRedirect, err := url.Parse(redirectUri) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid redirect URI", http.StatusBadRequest) + return + } + q := parsedRedirect.Query() + q.Set("error", "user_cancelled") + parsedRedirect.RawQuery = q.Encode() + http.Redirect(rw, req, parsedRedirect.String(), http.StatusFound) +} + +func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Request) (string, error) { + err := req.ParseForm() + if err != nil { + return "", err + } + + auth, err := internalAuthenticationHandler(rw, req) + if err != nil { + return "", err + } + + if auth.IsGuest() { + // handle redirecting to oauth + var q url.Values + switch req.Method { + case http.MethodPost: + q = req.PostForm + case http.MethodGet: + q = req.URL.Query() + default: + http.Error(rw, "405 Method Not Allowed", http.StatusMethodNotAllowed) + return "", err + } + + redirectUrl := PrepareRedirectUrl("/login", &url.URL{Path: "/authorize", RawQuery: q.Encode()}) + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) + return "", nil + } + return auth.Data.ID, nil +} diff --git a/server/owners.go b/server/owners.go deleted file mode 100644 index 9a13a20..0000000 --- a/server/owners.go +++ /dev/null @@ -1,33 +0,0 @@ -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/server/pages/flow-callback.go.html b/server/pages/flow-callback.go.html deleted file mode 100644 index e9eb583..0000000 --- a/server/pages/flow-callback.go.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - {{.ServiceName}} - - - - -
-

{{.ServiceName}}

-
-
Loading...
- - diff --git a/server/refresh.go b/server/refresh.go deleted file mode 100644 index 607739f..0000000 --- a/server/refresh.go +++ /dev/null @@ -1,183 +0,0 @@ -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 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 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/server/roles.go b/server/roles.go new file mode 100644 index 0000000..7dd2168 --- /dev/null +++ b/server/roles.go @@ -0,0 +1,17 @@ +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 { + return true + } + } + return false +} diff --git a/server/roles_test.go b/server/roles_test.go new file mode 100644 index 0000000..10a3cd2 --- /dev/null +++ b/server/roles_test.go @@ -0,0 +1,12 @@ +package server + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +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")) +} diff --git a/server/server.go b/server/server.go index 4eea512..d33c1d4 100644 --- a/server/server.go +++ b/server/server.go @@ -2,36 +2,48 @@ package server import ( "bytes" + "crypto/subtle" + "encoding/json" "fmt" "github.com/1f349/cache" + clientStore "github.com/1f349/lavender/client-store" + "github.com/1f349/lavender/database" "github.com/1f349/lavender/issuer" + "github.com/1f349/lavender/openid" + scope2 "github.com/1f349/lavender/scope" "github.com/1f349/lavender/theme" "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" "github.com/julienschmidt/httprouter" - "github.com/rs/cors" "log" "net/http" + "net/url" "strings" - "sync/atomic" "time" ) +var errInvalidScope = errors.New("missing required scope") + 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] + r *httprouter.Router + oauthSrv *server.Server + oauthMgr *manage.Manager + db *database.DB + conf Conf + signingKey mjwt.Signer + manager *issuer.Manager + flowState *cache.Cache[string, flowStateData] } type flowStateData struct { - sso *issuer.WellKnownOIDC - target AllowedClient + sso *issuer.WellKnownOIDC } -func NewHttpServer(conf Conf, signer mjwt.Signer) *HttpServer { +func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Server { r := httprouter.New() // remove last slash from baseUrl @@ -42,80 +54,186 @@ func NewHttpServer(conf Conf, signer mjwt.Signer) *HttpServer { } } - 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) + 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"}) + openIdBytes, err := json.Marshal(openIdConf) if err != nil { - log.Fatalln("Failed to load initial config:", err) - return nil + log.Fatalln("Failed to generate OpenID configuration:", err) } - 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) + oauthManager := manage.NewDefaultManager() + oauthSrv := server.NewServer(server.NewConfig(), oauthManager) + hs := &HttpServer{ + r: httprouter.New(), + oauthSrv: oauthSrv, + oauthMgr: oauthManager, + db: db, + conf: conf, + signingKey: signingKey, + flowState: cache.New[string, flowStateData](), + } + hs.manager, err = issuer.NewManager(conf.SsoServices) + if err != nil { + log.Fatal("Failed to reload SSO service manager: %w", err) + } + + oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) + oauthManager.MustTokenStorage(store.NewMemoryTokenStore()) + oauthManager.MapAccessGenerate(generates.NewAccessGenerate()) + oauthManager.MapClientStorage(clientStore.New(db)) + + oauthSrv.SetResponseErrorHandler(func(re *errors.Response) { + log.Printf("Response error: %#v\n", re) + }) + 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 + }) + + 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("/", hs.OptionalAuthentication(hs.Home)) + + // login + r.GET("/login", hs.loginGet) + r.POST("/login", hs.loginPost) + r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { + lNonce, ok := auth.Session.Get("action-nonce") + if !ok { + http.Error(rw, "Missing nonce", http.StatusInternalServerError) + return + } + if subtle.ConstantTimeCompare([]byte(lNonce.(string)), []byte(req.PostFormValue("nonce"))) == 1 { + auth.Session.Delete("session-data") + if auth.Session.Save() != nil { + http.Error(rw, "Failed to save session", http.StatusInternalServerError) + return + } + + http.SetCookie(rw, &http.Cookie{ + Name: "login-data", + Path: "/", + MaxAge: -1, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + + http.Redirect(rw, req, "/", http.StatusFound) + return + } + http.Error(rw, "Logout failed", http.StatusInternalServerError) + })) + + // theme styles 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, + // management 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 + 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) + } + }) + r.GET("/userinfo", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + token, err := oauthSrv.ValidationBearerToken(req) + if err != nil { + http.Error(rw, "403 Forbidden", http.StatusForbidden) + return + } + userId := token.GetUserID() + + fmt.Printf("Using token for user: %s by app: %s with scope: '%s'\n", userId, token.GetClientID(), token.GetScope()) + claims := ParseClaims(token.GetScope()) + if !claims["openid"] { + http.Error(rw, "Invalid scope", http.StatusBadRequest) + return + } + + m := map[string]any{} + m["sub"] = userId + m["aud"] = token.GetClientID() + m["updated_at"] = time.Now().Unix() + + _ = json.NewEncoder(rw).Encode(m) }) - // `/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 + return &http.Server{ + Addr: conf.Listen, + Handler: r, + ReadTimeout: time.Minute, + ReadHeaderTimeout: time.Minute, + WriteTimeout: time.Minute, + IdleTimeout: time.Minute, + MaxHeaderBytes: 2500, + } } -func (h *HttpServer) UpdateConfig(conf Conf) error { - m, err := issuer.NewManager(conf.SsoServices) +func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) { + redirectUrl := req.FormValue("redirect") + if redirectUrl == "" { + http.Redirect(rw, req, "/", http.StatusFound) + return + } + parse, err := url.Parse(redirectUrl) if err != nil { - return fmt.Errorf("failed to reload SSO service manager: %w", err) + http.Error(rw, "Failed to parse redirect url: "+redirectUrl, http.StatusBadRequest) + return } - - clientLookup := make(map[string]AllowedClient) - for _, i := range conf.AllowedClients { - clientLookup[i.Url.String()] = i + if parse.Scheme != "" && parse.Opaque != "" && parse.User != nil && parse.Host != "" { + http.Error(rw, "Invalid redirect url: "+redirectUrl, http.StatusBadRequest) + return } - - h.conf.Store(&conf) - h.manager.Store(m) - h.services.Store(&clientLookup) - return nil + http.Redirect(rw, req, parse.String(), http.StatusFound) +} + +func ParseClaims(claims string) map[string]bool { + m := make(map[string]bool) + for { + n := strings.IndexByte(claims, ' ') + if n == -1 { + if claims != "" { + m[claims] = true + } + break + } + + a := claims[:n] + claims = claims[n+1:] + if a != "" { + m[a] = true + } + } + + return m } diff --git a/server/verify.go b/server/verify.go deleted file mode 100644 index 53746be..0000000 --- a/server/verify.go +++ /dev/null @@ -1,33 +0,0 @@ -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/server/verify_test.go b/server/verify_test.go deleted file mode 100644 index fb2e907..0000000 --- a/server/verify_test.go +++ /dev/null @@ -1,61 +0,0 @@ -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) -}