From b9d456f2faeb3856620ff9930664c74e2584208e Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Sat, 9 Sep 2023 01:38:10 +0100 Subject: [PATCH] Add OTP and fix up oauth with the new page rendering system --- cmd/tulip/conf.go | 5 +- cmd/tulip/serve.go | 7 +- database/init.sql | 9 + database/tx.go | 61 ++++- go.mod | 4 + go.sum | 20 +- pages/edit-otp.go.html | 24 ++ pages/edit-password.go | 1 + pages/edit-username.go | 1 + pages/edit.go.html | 14 +- pages/index.go.html | 14 ++ pages/login-otp.go.html | 21 ++ pages/login.go.html | 11 +- ...horize.go.html => oauth-authorize.go.html} | 7 +- pages/pages.go | 8 +- scope/scope.go | 51 ++++ scope/scope_test.go | 26 ++ server/auth.go | 67 ++++-- server/edit.go | 71 ++++++ server/home.go | 42 ++++ server/login.go | 72 ++++++ server/oauth.go | 116 +++++---- server/otp.go | 159 +++++++++++++ server/server.go | 222 ++++-------------- 24 files changed, 735 insertions(+), 298 deletions(-) create mode 100644 pages/edit-otp.go.html create mode 100644 pages/edit-password.go create mode 100644 pages/edit-username.go create mode 100644 pages/login-otp.go.html rename pages/{authorize.go.html => oauth-authorize.go.html} (71%) create mode 100644 scope/scope.go create mode 100644 scope/scope_test.go create mode 100644 server/edit.go create mode 100644 server/home.go create mode 100644 server/login.go create mode 100644 server/otp.go diff --git a/cmd/tulip/conf.go b/cmd/tulip/conf.go index e2e7572..9ab5243 100644 --- a/cmd/tulip/conf.go +++ b/cmd/tulip/conf.go @@ -1,6 +1,7 @@ package main type startUpConfig struct { - Listen string `json:"listen"` - Domain string `json:"domain"` + Listen string `json:"listen"` + Domain string `json:"domain"` + OtpIssuer string `json:"otp_issuer"` } diff --git a/cmd/tulip/serve.go b/cmd/tulip/serve.go index 213ea45..bebdf4f 100644 --- a/cmd/tulip/serve.go +++ b/cmd/tulip/serve.go @@ -8,7 +8,6 @@ import ( "errors" "flag" "fmt" - clientStore "github.com/1f349/tulip/client-store" "github.com/1f349/tulip/database" "github.com/1f349/tulip/server" "github.com/1f349/violet/utils" @@ -36,7 +35,7 @@ func (s *serveCmd) Usage() string { ` } -func (s *serveCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { +func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...any) subcommands.ExitStatus { log.Println("[Tulip] Starting...") if s.configPath == "" { @@ -83,9 +82,7 @@ func normalLoad(startUp startUpConfig, wd string) { log.Fatal("[Tulip] Failed check:", err) } - cs := clientStore.New(db) - - srv := server.NewHttpServer(startUp.Listen, startUp.Domain, db, key, cs) + srv := server.NewHttpServer(startUp.Listen, startUp.Domain, startUp.OtpIssuer, db, key) log.Printf("[Tulip] Starting HTTP server on '%s'\n", srv.Addr) go utils.RunBackgroundHttp("HTTP", srv) diff --git a/database/init.sql b/database/init.sql index 40bdbd1..9d9cdf5 100644 --- a/database/init.sql +++ b/database/init.sql @@ -16,6 +16,8 @@ CREATE TABLE IF NOT EXISTS users active INTEGER DEFAULT 1 ); +CREATE UNIQUE INDEX IF NOT EXISTS username_index ON users (username); + CREATE TABLE IF NOT EXISTS client_store ( subject TEXT PRIMARY KEY UNIQUE NOT NULL, @@ -25,3 +27,10 @@ CREATE TABLE IF NOT EXISTS client_store sso INTEGER, active INTEGER DEFAULT 1 ); + +CREATE TABLE IF NOT EXISTS otp +( + subject TEXT PRIMARY KEY UNIQUE NOT NULL, + raw BLOB NOT NULL, + FOREIGN KEY (subject) REFERENCES users (subject) +); diff --git a/database/tx.go b/database/tx.go index fb42ae1..d8082ec 100644 --- a/database/tx.go +++ b/database/tx.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "github.com/1f349/tulip/password" + "github.com/1f349/twofactor" "github.com/go-oauth2/oauth2/v4" "github.com/google/uuid" "time" @@ -45,15 +46,16 @@ func (t *Tx) InsertUser(name, un, pw, email string) error { return err } -func (t *Tx) CheckLogin(un, pw string) (*User, error) { +func (t *Tx) CheckLogin(un, pw string) (*User, bool, error) { var u User - row := t.tx.QueryRow(`SELECT subject, password FROM users WHERE username = ? LIMIT 1`, un) - err := row.Scan(&u.Sub, &u.Password) + var hasOtp bool + row := t.tx.QueryRow(`SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) FROM users WHERE username = ?`, un) + err := row.Scan(&u.Sub, &u.Password, &hasOtp) if err != nil { - return nil, err + return nil, false, err } err = password.CheckPasswordHash(u.Password, pw) - return &u, err + return &u, hasOtp, err } func (t *Tx) GetUserDisplayName(sub uuid.UUID) (*User, error) { @@ -66,12 +68,19 @@ func (t *Tx) GetUserDisplayName(sub uuid.UUID) (*User, error) { func (t *Tx) GetUser(sub uuid.UUID) (*User, error) { var u User - row := t.tx.QueryRow(`SELECT name, username, password, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, updated_at, active FROM users WHERE subject = ? LIMIT 1`, sub.String()) + row := t.tx.QueryRow(`SELECT name, username, password, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, updated_at, active FROM users WHERE subject = ?`, sub.String()) err := row.Scan(&u.Name, &u.Username, &u.Password, &u.Picture, &u.Website, &u.Email, &u.EmailVerified, &u.Pronouns, &u.Birthdate, &u.ZoneInfo, &u.Locale, &u.UpdatedAt, &u.Active) u.Sub = sub return &u, err } +func (t *Tx) GetUserEmail(sub uuid.UUID) (string, error) { + var email string + row := t.tx.QueryRow(`SELECT email FROM users WHERE subject = ?`, sub.String()) + err := row.Scan(&email) + return email, err +} + func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error { q, err := t.tx.Query(`SELECT password FROM users WHERE subject = ?`, sub) if err != nil { @@ -149,17 +158,40 @@ WHERE subject = ?`, return nil } +func (t *Tx) SetTwoFactor(sub uuid.UUID, totp *twofactor.Totp) error { + u, err := totp.ToBytes() + if err != nil { + return err + } + _, err = t.tx.Exec(`INSERT INTO otp(subject, raw) VALUES (?, ?) ON CONFLICT(subject) DO UPDATE SET raw = excluded.raw`, sub.String(), u) + return err +} + +func (t *Tx) GetTwoFactor(sub uuid.UUID, issuer string) (*twofactor.Totp, error) { + var u []byte + row := t.tx.QueryRow(`SELECT raw FROM otp WHERE subject = ?`, sub.String()) + err := row.Scan(&u) + if err != nil { + return nil, err + } + return twofactor.TOTPFromBytes(u, issuer) +} + func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) { var u clientInfoDbOutput - row := t.tx.QueryRow(`SELECT secret, domain, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub) - err := row.Scan(&u.secret, &u.domain, &u.sso) + var active bool + 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, &active) u.sub = sub + if !active { + return nil, fmt.Errorf("client is not active") + } return &u, err } type clientInfoDbOutput struct { - sub, secret, domain string - sso bool + sub, name, secret, domain string + sso bool } func (c *clientInfoDbOutput) GetID() string { return c.sub } @@ -167,4 +199,11 @@ 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 "" } -func (c *clientInfoDbOutput) IsSSO() bool { return c.sso } + +// 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 } + +// GetName is an extra field for the oauth handler to display the application +// name +func (c *clientInfoDbOutput) GetName() string { return c.name } diff --git a/go.mod b/go.mod index 7a44844..f407054 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/1f349/tulip go 1.20 require ( + github.com/1f349/twofactor v1.0.4 github.com/1f349/violet v0.0.9 github.com/MrMelon54/exit-reload v0.0.1 github.com/MrMelon54/pronouns v1.0.1 @@ -21,6 +22,9 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sec51/convert v1.0.2 // indirect + github.com/sec51/gf256 v0.0.0-20160126143050-2454accbeb9e // indirect + github.com/sec51/qrcode v0.0.0-20160126144534-b7779abbcaf1 // indirect github.com/tidwall/btree v1.6.0 // indirect github.com/tidwall/buntdb v1.3.0 // indirect github.com/tidwall/gjson v1.16.0 // indirect diff --git a/go.sum b/go.sum index 79f2c46..bd6c4a6 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/1f349/violet v0.0.7 h1:FxCAIVjzUzkgGfhGMX7FcvGj+kaJky45PnLfqKNgA8M= -github.com/1f349/violet v0.0.7/go.mod h1:YfKZX9p55Uot8iSDnbqQbAgU717H0rFNo8ieu2wbxI4= +github.com/1f349/twofactor v1.0.4 h1:kN4EEGFlKRa7fGrxS+FpgwJI+tllES6YzXqCqurk4Uk= +github.com/1f349/twofactor v1.0.4/go.mod h1:gnG80vElwqLWNMnLT57yu4o4L1GdXGPP6pcIPlapXZs= github.com/1f349/violet v0.0.9 h1:eQfc5fDMKJXVFUjS2UiAGTkOVVBamppD5dguhmU4GeU= github.com/1f349/violet v0.0.9/go.mod h1:Uzu6I1pLBP5UEzcUCTQBbk/NTfI5TAABSrowa8DSpR0= github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc= @@ -44,14 +44,12 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO 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.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 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= @@ -82,6 +80,12 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y 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/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/sec51/convert v1.0.2 h1:NoKWIRARjM3rQglNypMpcXSLLqPsN/uTTzaGeqDKbeg= +github.com/sec51/convert v1.0.2/go.mod h1:5qL/cT/oiOIvWXy2SccQ7LnacYftqqy9wdyFkTc1k2w= +github.com/sec51/gf256 v0.0.0-20160126143050-2454accbeb9e h1:wKXba8dfsFjbxkMpzZBKt8gkJAMSm1fIf1OSWQFQrVA= +github.com/sec51/gf256 v0.0.0-20160126143050-2454accbeb9e/go.mod h1:hCjOqSOB9PBw5MdJ+0uSLCBV7FbLy0xwOR+c193HkcE= +github.com/sec51/qrcode v0.0.0-20160126144534-b7779abbcaf1 h1:CI9zS8HvMiibvXM/F3IthY797GW77fNYgioJl/8Xzzk= +github.com/sec51/qrcode v0.0.0-20160126144534-b7779abbcaf1/go.mod h1:uPm44Rj3uXSSOvmKmoeRuAUNUgwH2JHW5KIzqFFS/j4= 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= @@ -103,8 +107,6 @@ github.com/tidwall/buntdb v1.3.0 h1:gdhWO+/YwoB2qZMeAU9JcWWsHSYU3OvcieYgFRS0zwA= github.com/tidwall/buntdb v1.3.0/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.15.0 h1:5n/pM+v3r5ujuNl4YLZLsQ+UE5jlkLVm7jMzT5Mpolw= -github.com/tidwall/gjson v1.15.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg= github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= @@ -144,8 +146,6 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDf github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -180,8 +180,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pages/edit-otp.go.html b/pages/edit-otp.go.html new file mode 100644 index 0000000..c80719a --- /dev/null +++ b/pages/edit-otp.go.html @@ -0,0 +1,24 @@ + + + + 1f349 ID + + +
+

1f349 ID

+
+
+
+

+ OTP QR code not loading +

+

Raw OTP string: {{.OtpUrl}}

+
+ + +
+ +
+
+ + diff --git a/pages/edit-password.go b/pages/edit-password.go new file mode 100644 index 0000000..76e382d --- /dev/null +++ b/pages/edit-password.go @@ -0,0 +1 @@ +package pages diff --git a/pages/edit-username.go b/pages/edit-username.go new file mode 100644 index 0000000..76e382d --- /dev/null +++ b/pages/edit-username.go @@ -0,0 +1 @@ +package pages diff --git a/pages/edit.go.html b/pages/edit.go.html index 31bace3..e328599 100644 --- a/pages/edit.go.html +++ b/pages/edit.go.html @@ -13,19 +13,19 @@
- +
- +
- +
- +
- +
- + {{range .ListZoneInfo}} @@ -51,7 +51,7 @@
- + {{range .ListLocale}} diff --git a/pages/index.go.html b/pages/index.go.html index 153a75e..f6b6684 100644 --- a/pages/index.go.html +++ b/pages/index.go.html @@ -12,6 +12,20 @@
+
+ +
+
+ +
+
+ + + + + + +
diff --git a/pages/login-otp.go.html b/pages/login-otp.go.html new file mode 100644 index 0000000..2fd31de --- /dev/null +++ b/pages/login-otp.go.html @@ -0,0 +1,21 @@ + + + + 1f349 ID + + +
+

1f349 ID

+
+
+ + +
+ + +
+ + +
+ + diff --git a/pages/login.go.html b/pages/login.go.html index bb9e06d..8d38ec3 100644 --- a/pages/login.go.html +++ b/pages/login.go.html @@ -8,14 +8,15 @@

1f349 ID

-
+ +
- - + +
- - + +
diff --git a/pages/authorize.go.html b/pages/oauth-authorize.go.html similarity index 71% rename from pages/authorize.go.html rename to pages/oauth-authorize.go.html index 7cc617b..a92944d 100644 --- a/pages/authorize.go.html +++ b/pages/oauth-authorize.go.html @@ -13,19 +13,22 @@
    {{range .WantsList}} -
  • {{.Label}}
  • +
  • {{.}}
  • {{end}}
+ + - +
+
Authorizing this action will redirect you to {{.AppDomain}} with access to the permissions requested above.
diff --git a/pages/pages.go b/pages/pages.go index f6dd481..71b01a5 100644 --- a/pages/pages.go +++ b/pages/pages.go @@ -5,6 +5,7 @@ import ( _ "embed" "html/template" "io" + "log" ) var ( @@ -19,6 +20,9 @@ func LoadPageTemplates() (err error) { return } -func RenderPageTemplate(wr io.Writer, name string, data any) error { - return pageTemplate.ExecuteTemplate(wr, name+".go.html", data) +func RenderPageTemplate(wr io.Writer, name string, data any) { + err := pageTemplate.ExecuteTemplate(wr, name+".go.html", data) + if err != nil { + log.Printf("Failed to render page: %s: %s\n", name, err) + } } diff --git a/scope/scope.go b/scope/scope.go new file mode 100644 index 0000000..87f6ce9 --- /dev/null +++ b/scope/scope.go @@ -0,0 +1,51 @@ +package scope + +import ( + "strings" +) + +var scopeDescription = map[string]string{ + "openid": "Access user identity and information fields", +} + +// FancyScopeList takes a scope string and outputs a slice of scope descriptions +func FancyScopeList(scope string) (arr []string) { + 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 := scopeDescription[key]; d != "" { + arr = append(arr, d) + } + } +} diff --git a/scope/scope_test.go b/scope/scope_test.go new file mode 100644 index 0000000..6e04d11 --- /dev/null +++ b/scope/scope_test.go @@ -0,0 +1,26 @@ +package scope + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +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 index 13c31b1..c2e0314 100644 --- a/server/auth.go +++ b/server/auth.go @@ -6,46 +6,59 @@ import ( "github.com/google/uuid" "github.com/julienschmidt/httprouter" "net/http" + "net/url" ) type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) type UserAuth struct { - ID uuid.UUID Session session.Store + Data SessionData +} + +type SessionData struct { + ID uuid.UUID + NeedOtp bool +} + +func (u UserAuth) NextFlowUrl(origin *url.URL) *url.URL { + if u.Data.NeedOtp { + return PrepareRedirectUrl("/login/otp", origin) + } + return nil } func (u UserAuth) IsGuest() bool { - return u.ID == uuid.Nil + return u.Data.ID == uuid.Nil } -func (h *HttpServer) RequireAuthentication(error string, code int, next UserHandler) httprouter.Handle { - return h.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { +func (u UserAuth) SaveSessionData() error { + u.Session.Set("session-data", u.Data) + return u.Session.Save() +} + +func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle { + return h.OptionalAuthentication(false, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { if auth.IsGuest() { - http.Error(rw, error, code) + redirectUrl := PrepareRedirectUrl("/login", req.URL) + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) return } next(rw, req, params, auth) }) } -func (h *HttpServer) RequireAuthenticationRedirect(redirect string, code int, next UserHandler) httprouter.Handle { - return h.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - if auth.IsGuest() { - http.Redirect(rw, req, redirect, code) - return - } - next(rw, req, params, auth) - }) -} - -func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle { +func (h *HttpServer) OptionalAuthentication(flowPart bool, next UserHandler) httprouter.Handle { return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { auth, err := h.internalAuthenticationHandler(rw, req) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } + if n := auth.NextFlowUrl(req.URL); n != nil && !flowPart { + http.Redirect(rw, req, n.String(), http.StatusFound) + return + } next(rw, req, params, auth) } } @@ -56,17 +69,31 @@ func (h *HttpServer) internalAuthenticationHandler(rw http.ResponseWriter, req * return UserAuth{}, fmt.Errorf("failed to start session") } - userIdRaw, ok := ss.Get("user") + // get auth object + userIdRaw, ok := ss.Get("session-data") if !ok { return UserAuth{Session: ss}, nil } - userId, ok := userIdRaw.(uuid.UUID) + userData, ok := userIdRaw.(SessionData) if !ok { - ss.Delete("user") + ss.Delete("session-data") err := ss.Save() if err != nil { return UserAuth{Session: ss}, fmt.Errorf("failed to reset invalid session data") } } - return UserAuth{ID: userId, Session: ss}, nil + + return UserAuth{Session: ss, Data: userData}, nil +} + +func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL { + v := url.Values{} + 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/edit.go b/server/edit.go new file mode 100644 index 0000000..13048b7 --- /dev/null +++ b/server/edit.go @@ -0,0 +1,71 @@ +package server + +import ( + "fmt" + "github.com/1f349/tulip/database" + "github.com/1f349/tulip/lists" + "github.com/1f349/tulip/pages" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "net/http" +) + +func (h *HttpServer) EditGet(rw http.ResponseWriter, _ *http.Request, _ httprouter.Params, auth UserAuth) { + var user *database.User + + if h.DbTx(rw, func(tx *database.Tx) error { + var err error + user, err = tx.GetUser(auth.Data.ID) + if err != nil { + return fmt.Errorf("failed to read user data: %w", err) + } + return nil + }) { + 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, "edit", map[string]any{ + "User": user, + "Nonce": lNonce, + "FieldPronoun": user.Pronouns.String(), + "ListZoneInfo": lists.ListZoneInfo(), + "ListLocale": lists.ListLocale(), + }) +} +func (h *HttpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + if req.ParseForm() != nil { + rw.WriteHeader(http.StatusBadRequest) + _, _ = rw.Write([]byte("400 Bad Request\n")) + return + } + + var patch database.UserPatch + errs := patch.ParseFromForm(req.Form) + if len(errs) > 0 { + rw.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintln(rw, "\n\n") + _, _ = fmt.Fprintln(rw, "

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

") + _, _ = fmt.Fprintln(rw, "
    ") + for _, i := range errs { + _, _ = fmt.Fprintf(rw, "
  • %s
  • \n", i) + } + _, _ = fmt.Fprintln(rw, "
") + _, _ = fmt.Fprintln(rw, "\n") + return + } + if h.DbTx(rw, func(tx *database.Tx) error { + if err := tx.ModifyUser(auth.Data.ID, &patch); err != nil { + return fmt.Errorf("failed to modify user info: %w", err) + } + return nil + }) { + return + } + http.Redirect(rw, req, "/edit", http.StatusFound) +} diff --git a/server/home.go b/server/home.go new file mode 100644 index 0000000..01e9426 --- /dev/null +++ b/server/home.go @@ -0,0 +1,42 @@ +package server + +import ( + "fmt" + "github.com/1f349/tulip/database" + "github.com/1f349/tulip/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", nil) + 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 + } + + var userWithName *database.User + if h.DbTx(rw, func(tx *database.Tx) (err error) { + userWithName, err = tx.GetUserDisplayName(auth.Data.ID) + if err != nil { + return fmt.Errorf("failed to get user display name: %w", err) + } + return + }) { + return + } + pages.RenderPageTemplate(rw, "index", map[string]any{ + "Auth": auth, + "User": userWithName, + "Nonce": lNonce, + }) +} diff --git a/server/login.go b/server/login.go new file mode 100644 index 0000000..e9f5d38 --- /dev/null +++ b/server/login.go @@ -0,0 +1,72 @@ +package server + +import ( + "database/sql" + "errors" + "github.com/1f349/tulip/database" + "github.com/1f349/tulip/pages" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "golang.org/x/crypto/bcrypt" + "net/http" + "net/url" +) + +func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + if !auth.IsGuest() { + h.SafeRedirect(rw, req) + return + } + + rw.Header().Set("Content-Type", "text/html") + rw.WriteHeader(http.StatusOK) + pages.RenderPageTemplate(rw, "login", map[string]any{ + "Redirect": req.URL.Query().Get("redirect"), + }) +} + +func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + un := req.FormValue("username") + pw := req.FormValue("password") + var userSub uuid.UUID + var hasOtp bool + if h.DbTx(rw, func(tx *database.Tx) error { + loginUser, hasOtpRaw, err := tx.CheckLogin(un, pw) + if err != nil { + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + http.Redirect(rw, req, "/login?mismatch=1", http.StatusFound) + return nil + } + http.Error(rw, "Internal server error", http.StatusInternalServerError) + return err + } + userSub = loginUser.Sub + hasOtp = hasOtpRaw + return nil + }) { + return + } + + // only continues if the above tx succeeds + auth.Data = SessionData{ + ID: userSub, + NeedOtp: hasOtp, + } + if auth.SaveSessionData() != nil { + http.Error(rw, "Failed to save session", http.StatusInternalServerError) + return + } + + if hasOtp { + originUrl, err := url.Parse(req.FormValue("redirect")) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid redirect URL", http.StatusBadRequest) + return + } + redirectUrl := PrepareRedirectUrl("/login/otp", originUrl) + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) + return + } + + h.SafeRedirect(rw, req) +} diff --git a/server/oauth.go b/server/oauth.go index 892ce2d..62fa257 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -1,34 +1,21 @@ package server import ( - "fmt" - "github.com/go-session/session" + "github.com/1f349/tulip/database" + "github.com/1f349/tulip/pages" + "github.com/1f349/tulip/scope" "github.com/julienschmidt/httprouter" "net/http" "net/url" ) -func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { - ss, err := session.Start(req.Context(), rw, req) - if err != nil { - http.Error(rw, "Failed to load session", http.StatusInternalServerError) - return - } - - userID, err := h.oauthSrv.UserAuthorizationHandler(rw, req) - if err != nil { - http.Error(rw, "Failed to check user", http.StatusInternalServerError) - return - } else if userID == "" { - return - } - +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() + err := req.ParseForm() if err != nil { http.Error(rw, "Failed to parse form", http.StatusInternalServerError) return @@ -72,48 +59,62 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request switch { case isSSO && isPost: - http.Error(rw, "400 Bad Request", http.StatusBadRequest) + 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: - f := func(key string) string { return form.Get(key) } + // 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 + } + } + + var user *database.User + if h.DbTx(rw, func(tx *database.Tx) error { + var err error + user, err = tx.GetUserDisplayName(auth.Data.ID) + return err + }) { + return + } + rw.WriteHeader(http.StatusOK) - _, _ = fmt.Fprintf(rw, ` - - -Authorize - -
- - - - - - - -
Scope: %s
-
-
-
-`, clientID, redirectUri, f("scope"), f("state"), f("nonce"), f("response_type"), f("response_mode"), f("scope")) + pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{ + "AppName": appName, + "AppDomain": appDomain, + "User": user, + "WantsList": scope.FancyScopeList(form.Get("scope")), + "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": form.Get("scope"), + "Nonce": form.Get("nonce"), + }) return - default: - break } - // continue flow - oauthDataRaw, ok := ss.Get("OAuthData") - if ok { - ss.Delete("OAuthData") - if ss.Save() != nil { - http.Error(rw, "Failed to save session", http.StatusInternalServerError) + // redirect with an error if the action is not authorize + if form.Get("oauth_action") != "authorize" { + redirectUri, err := url.Parse(form.Get("redirect_uri")) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid redirect URI", http.StatusBadRequest) return } - oauthData, ok := oauthDataRaw.(url.Values) - if !ok { - http.Error(rw, "Failed to load session", http.StatusInternalServerError) - return - } - req.URL.RawQuery = oauthData.Encode() + q := redirectUri.Query() + q.Set("error", "user_cancelled") + redirectUri.RawQuery = q.Encode() + http.Redirect(rw, req, redirectUri.String(), http.StatusFound) + return } if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil { @@ -144,13 +145,10 @@ func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re http.Error(rw, "405 Method Not Allowed", http.StatusMethodNotAllowed) return "", err } - auth.Session.Set("OAuthData", q) - if auth.Session.Save() != nil { - http.Error(rw, "Failed to save session", http.StatusInternalServerError) - return "", err - } - http.Redirect(rw, req, "/login?redirect=oauth", http.StatusFound) + + redirectUrl := PrepareRedirectUrl("/login", &url.URL{Path: "/authorize", RawQuery: q.Encode()}) + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) return "", nil } - return auth.ID.String(), nil + return auth.Data.ID.String(), nil } diff --git a/server/otp.go b/server/otp.go new file mode 100644 index 0000000..5c03c07 --- /dev/null +++ b/server/otp.go @@ -0,0 +1,159 @@ +package server + +import ( + "crypto" + "encoding/base64" + "github.com/1f349/tulip/database" + "github.com/1f349/tulip/pages" + "github.com/1f349/twofactor" + "github.com/julienschmidt/httprouter" + "html/template" + "net/http" +) + +func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + if !auth.Data.NeedOtp { + h.SafeRedirect(rw, req) + return + } + + pages.RenderPageTemplate(rw, "login-otp", map[string]any{ + "Redirect": req.URL.Query().Get("redirect"), + }) +} + +func (h *HttpServer) LoginOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + if !auth.Data.NeedOtp { + http.Redirect(rw, req, "/", http.StatusFound) + return + } + + otpInput := req.FormValue("code") + + var otp *twofactor.Totp + if h.DbTx(rw, func(tx *database.Tx) (err error) { + otp, err = tx.GetTwoFactor(auth.Data.ID, h.otpIssuer) + return err + }) { + return + } + + err := otp.Validate(otpInput) + if err != nil { + + return + } + + auth.Data.NeedOtp = false + if auth.SaveSessionData() != nil { + http.Error(rw, "500 Internal Server Error: Failed to safe session", http.StatusInternalServerError) + return + } + + h.SafeRedirect(rw, req) +} + +func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + var digits = 0 + switch req.URL.Query().Get("digits") { + case "6": + digits = 6 + case "7": + digits = 7 + case "8": + digits = 8 + default: + http.Error(rw, "400 Bad Request: Invalid number of digits for OTP code", http.StatusBadRequest) + return + } + + var otp *twofactor.Totp + + otpRaw, ok := auth.Session.Get("temp-otp") + if ok { + if otp, ok = otpRaw.(*twofactor.Totp); !ok { + http.Error(rw, "400 Bad Request: invalid session, try clearing your cookies", http.StatusBadRequest) + return + } + + // check OTP code matches number of digits + tempCode, err := otp.OTP() + if err != nil || len(tempCode) != digits { + otp = nil + } + } + + // make a new otp handler if needed + if otp == nil { + // get user email + var email string + if h.DbTx(rw, func(tx *database.Tx) error { + var err error + email, err = tx.GetUserEmail(auth.Data.ID) + return err + }) { + return + } + + // generate OTP key + var err error + otp, err = twofactor.NewTOTP(email, h.otpIssuer, crypto.SHA512, digits) + if err != nil { + http.Error(rw, "500 Internal Server Error: Failed to generate OTP key", http.StatusInternalServerError) + return + } + + // save otp key + auth.Session.Set("temp-otp", otp) + err = auth.Session.Save() + if err != nil { + http.Error(rw, "500 Internal Server Error: Failed to save session", http.StatusInternalServerError) + return + } + } + + // get qr and url + otpQr, err := otp.QR() + if err != nil { + http.Error(rw, "500 Internal Server Error: Failed to generate OTP QR code", http.StatusInternalServerError) + return + } + otpUrl, err := otp.URL() + if err != nil { + http.Error(rw, "500 Internal Server Error: Failed to generate OTP URL", http.StatusInternalServerError) + return + } + + // render page + pages.RenderPageTemplate(rw, "edit-otp", map[string]any{ + "OtpQr": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(otpQr)), + "OtpUrl": otpUrl, + }) +} + +func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + var otp *twofactor.Totp + + otpRaw, ok := auth.Session.Get("temp-otp") + if !ok { + http.Error(rw, "400 Bad Request: invalid session, try clearing your cookies", http.StatusBadRequest) + return + } + if otp, ok = otpRaw.(*twofactor.Totp); !ok { + http.Error(rw, "400 Bad Request: invalid session, try clearing your cookies", http.StatusBadRequest) + return + } + err := otp.Validate(req.FormValue("code")) + if err != nil { + http.Error(rw, "400 Bad Request: invalid OTP code", http.StatusBadRequest) + return + } + + if h.DbTx(rw, func(tx *database.Tx) error { + return tx.SetTwoFactor(auth.Data.ID, otp) + }) { + return + } + + http.Redirect(rw, req, "/edit", http.StatusFound) +} diff --git a/server/server.go b/server/server.go index 19d01b8..9a0e31b 100644 --- a/server/server.go +++ b/server/server.go @@ -2,24 +2,19 @@ package server import ( "crypto/subtle" - "database/sql" _ "embed" "encoding/json" - errors2 "errors" "fmt" + clientStore "github.com/1f349/tulip/client-store" "github.com/1f349/tulip/database" - "github.com/1f349/tulip/lists" "github.com/1f349/tulip/openid" "github.com/1f349/tulip/pages" - "github.com/go-oauth2/oauth2/v4" "github.com/go-oauth2/oauth2/v4/errors" "github.com/go-oauth2/oauth2/v4/generates" "github.com/go-oauth2/oauth2/v4/manage" "github.com/go-oauth2/oauth2/v4/server" "github.com/go-oauth2/oauth2/v4/store" - "github.com/google/uuid" "github.com/julienschmidt/httprouter" - "golang.org/x/crypto/bcrypt" "log" "net/http" "net/url" @@ -29,15 +24,34 @@ import ( var errMissingRequiredScope = errors.New("missing required scope") type HttpServer struct { - r *httprouter.Router - oauthSrv *server.Server - oauthMgr *manage.Manager - db *database.DB - domain string - privKey []byte + r *httprouter.Router + oauthSrv *server.Server + oauthMgr *manage.Manager + db *database.DB + domain string + privKey []byte + otpIssuer string } -func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clientStore oauth2.ClientStore) *http.Server { +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 { + http.Error(rw, "Failed to parse redirect url: "+redirectUrl, http.StatusBadRequest) + return + } + if parse.Scheme != "" && parse.Opaque != "" && parse.User != nil && parse.Host != "" { + http.Error(rw, "Invalid redirect url: "+redirectUrl, http.StatusBadRequest) + return + } + http.Redirect(rw, req, parse.String(), http.StatusFound) +} + +func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey []byte) *http.Server { r := httprouter.New() openIdConf := openid.GenConfig(domain, []string{"openid", "email"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"}) @@ -53,18 +67,19 @@ func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clien oauthManager := manage.NewDefaultManager() oauthSrv := server.NewServer(server.NewConfig(), oauthManager) hs := &HttpServer{ - r: httprouter.New(), - oauthSrv: oauthSrv, - oauthMgr: oauthManager, - db: db, - domain: domain, - privKey: privKey, + r: httprouter.New(), + oauthSrv: oauthSrv, + oauthMgr: oauthManager, + db: db, + domain: domain, + privKey: privKey, + otpIssuer: otpIssuer, } oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) oauthManager.MustTokenStorage(store.NewMemoryTokenStore()) oauthManager.MapAccessGenerate(generates.NewAccessGenerate()) - oauthManager.MapClientStorage(clientStore) + oauthManager.MapClientStorage(clientStore.New(db)) oauthSrv.SetResponseErrorHandler(func(re *errors.Response) { log.Printf("Response error: %#v\n", re) @@ -98,47 +113,15 @@ func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clien rw.WriteHeader(http.StatusOK) _, _ = rw.Write(openIdBytes) }) - r.GET("/", hs.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - rw.Header().Set("Content-Type", "text/html") - rw.WriteHeader(http.StatusOK) - if auth.IsGuest() { - _ = pages.RenderPageTemplate(rw, "index-guest", nil) - 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 - } - - var userWithName *database.User - if hs.DbTx(rw, func(tx *database.Tx) (err error) { - userWithName, err = tx.GetUserDisplayName(auth.ID) - if err != nil { - return fmt.Errorf("failed to get user display name: %w", err) - } - return - }) { - return - } - if err := pages.RenderPageTemplate(rw, "index", map[string]any{ - "Auth": auth, - "User": userWithName, - "Nonce": lNonce, - }); err != nil { - log.Printf("Failed to render page: edit: %s\n", err) - } - })) - r.POST("/logout", hs.RequireAuthentication("403 Forbidden", http.StatusForbidden, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { + r.GET("/", hs.OptionalAuthentication(false, hs.Home)) + 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("user") + auth.Session.Delete("session-data") if auth.Session.Save() != nil { http.Error(rw, "Failed to save session", http.StatusInternalServerError) return @@ -148,130 +131,21 @@ func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clien } http.Error(rw, "Logout failed", http.StatusInternalServerError) })) - r.GET("/login", hs.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - if !auth.IsGuest() { - http.Redirect(rw, req, "/", http.StatusFound) - return - } - rw.Header().Set("Content-Type", "text/html") - rw.WriteHeader(http.StatusOK) - if err := pages.RenderPageTemplate(rw, "login", nil); err != nil { - log.Printf("Failed to render page: edit: %s\n", err) - } - })) - r.POST("/login", hs.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - un := req.FormValue("username") - pw := req.FormValue("password") - var userSub uuid.UUID - if hs.DbTx(rw, func(tx *database.Tx) error { - loginUser, err := tx.CheckLogin(un, pw) - if err != nil { - if errors2.Is(err, sql.ErrNoRows) || errors2.Is(err, bcrypt.ErrMismatchedHashAndPassword) { - http.Redirect(rw, req, "/login?mismatch=1", http.StatusFound) - return nil - } - http.Error(rw, "Internal server error", http.StatusInternalServerError) - return err - } - userSub = loginUser.Sub - return nil - }) { - return - } - - // only continues if the above tx succeeds - auth.Session.Set("user", userSub) - if auth.Session.Save() != nil { - http.Error(rw, "Failed to save session", http.StatusInternalServerError) - return - } - - switch req.URL.Query().Get("redirect") { - case "oauth": - oauthDataRaw, ok := auth.Session.Get("OAuthData") - if !ok { - http.Error(rw, "Failed to load session", http.StatusInternalServerError) - return - } - oauthData, ok := oauthDataRaw.(url.Values) - if !ok { - http.Error(rw, "Failed to load session", http.StatusInternalServerError) - return - } - authUrl := url.URL{Path: "/authorize", RawQuery: oauthData.Encode()} - http.Redirect(rw, req, authUrl.String(), http.StatusFound) - default: - http.Redirect(rw, req, "/", http.StatusFound) - } - })) - r.GET("/authorize", hs.authorizeEndpoint) - r.POST("/authorize", hs.authorizeEndpoint) + r.GET("/login", hs.OptionalAuthentication(false, hs.LoginGet)) + r.POST("/login", hs.OptionalAuthentication(false, hs.LoginPost)) + r.GET("/login/otp", hs.OptionalAuthentication(true, hs.LoginOtpGet)) + r.POST("/login/otp", hs.OptionalAuthentication(true, hs.LoginOtpPost)) + r.GET("/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("/edit", hs.RequireAuthentication("403 Forbidden", http.StatusForbidden, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - var user *database.User - - if hs.DbTx(rw, func(tx *database.Tx) error { - var err error - user, err = tx.GetUser(auth.ID) - if err != nil { - return fmt.Errorf("failed to read user data: %w", err) - } - return nil - }) { - 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 - } - if err := pages.RenderPageTemplate(rw, "edit", map[string]any{ - "User": user, - "Nonce": lNonce, - "FieldPronoun": user.Pronouns.String(), - "ListZoneInfo": lists.ListZoneInfo(), - "ListLocale": lists.ListLocale(), - }); err != nil { - log.Printf("Failed to render page: edit: %s\n", err) - } - })) - r.POST("/edit", hs.RequireAuthentication("403 Forbidden", http.StatusForbidden, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - if req.ParseForm() != nil { - rw.WriteHeader(http.StatusBadRequest) - _, _ = rw.Write([]byte("400 Bad Request\n")) - return - } - - var patch database.UserPatch - errs := patch.ParseFromForm(req.Form) - if len(errs) > 0 { - rw.WriteHeader(http.StatusBadRequest) - _, _ = fmt.Fprintln(rw, "\n\n") - _, _ = fmt.Fprintln(rw, "

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

") - _, _ = fmt.Fprintln(rw, "
    ") - for _, i := range errs { - _, _ = fmt.Fprintf(rw, "
  • %s
  • \n", i) - } - _, _ = fmt.Fprintln(rw, "
") - _, _ = fmt.Fprintln(rw, "\n") - return - } - if hs.DbTx(rw, func(tx *database.Tx) error { - if err := tx.ModifyUser(auth.ID, &patch); err != nil { - return fmt.Errorf("failed to modify user info: %w", err) - } - return nil - }) { - return - } - http.Redirect(rw, req, "/edit", http.StatusFound) - })) + r.GET("/edit", hs.RequireAuthentication(hs.EditGet)) + r.POST("/edit", hs.RequireAuthentication(hs.EditPost)) + r.GET("/edit/otp", hs.RequireAuthentication(hs.EditOtpGet)) + r.POST("/edit/otp", hs.RequireAuthentication(hs.EditOtpPost)) r.GET("/userinfo", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { token, err := oauthSrv.ValidationBearerToken(req) if err != nil {