mirror of
https://github.com/1f349/tulip.git
synced 2024-12-22 16:24:10 +00:00
Add OTP and fix up oauth with the new page rendering system
This commit is contained in:
parent
498e42ebe8
commit
b9d456f2fa
@ -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"`
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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 }
|
||||
|
4
go.mod
4
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
|
||||
|
20
go.sum
20
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=
|
||||
|
24
pages/edit-otp.go.html
Normal file
24
pages/edit-otp.go.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>1f349 ID</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>1f349 ID</h1>
|
||||
</header>
|
||||
<main>
|
||||
<form method="POST" action="/edit/otp">
|
||||
<p>
|
||||
<img src="{{.OtpQr}}" alt="OTP QR code not loading"/>
|
||||
</p>
|
||||
<p>Raw OTP string: {{.OtpUrl}}</p>
|
||||
<div>
|
||||
<label for="field_code">OTP Code:</label>
|
||||
<input type="text" name="code" id="field_code" required pattern="[0-9]{6,8}" title="6/7/8 digit one time passcode"/>
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
1
pages/edit-password.go
Normal file
1
pages/edit-password.go
Normal file
@ -0,0 +1 @@
|
||||
package pages
|
1
pages/edit-username.go
Normal file
1
pages/edit-username.go
Normal file
@ -0,0 +1 @@
|
||||
package pages
|
@ -13,19 +13,19 @@
|
||||
<form method="POST" action="/edit">
|
||||
<input type="hidden" name="nonce" value="{{.Nonce}}">
|
||||
<div>
|
||||
<label for="field_name">Name</label>
|
||||
<label for="field_name">Name:</label>
|
||||
<input type="text" name="name" id="field_name" value="{{.User.Name}}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="field_picture">Picture</label>
|
||||
<label for="field_picture">Picture:</label>
|
||||
<input type="text" name="picture" id="field_picture" value="{{.User.Picture}}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="field_website">Website</label>
|
||||
<label for="field_website">Website:</label>
|
||||
<input type="text" name="website" id="field_website" value="{{.User.Website}}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="field_pronouns">Pronouns</label>
|
||||
<label for="field_pronouns">Pronouns:</label>
|
||||
<select name="pronouns" id="field_pronouns">
|
||||
<option value="they/them" {{if eq "they/them" .FieldPronoun}}selected{{end}}>They/Them</option>
|
||||
<option value="he/him" {{if eq "he/him" .FieldPronoun}}selected{{end}}>He/Him</option>
|
||||
@ -36,12 +36,12 @@
|
||||
<label>Reset? <input type="checkbox" name="reset_pronouns"></label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="field_birthdate">Birthdate</label>
|
||||
<label for="field_birthdate">Birthdate:</label>
|
||||
<input type="date" name="birthdate" id="field_birthdate" value="{{.User.Birthdate}}">
|
||||
<label>Reset? <input type="checkbox" name="reset_birthdate"></label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="field_zoneinfo">Time Zone</label>
|
||||
<label for="field_zoneinfo">Time Zone:</label>
|
||||
<input type="text" name="zoneinfo" id="field_zoneinfo" value="{{.User.ZoneInfo}}" list="list_zoneinfo">
|
||||
<datalist id="list_zoneinfo">
|
||||
{{range .ListZoneInfo}}
|
||||
@ -51,7 +51,7 @@
|
||||
<label>Reset? <input type="checkbox" name="reset_zoneinfo"></label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="field_locale">Language</label>
|
||||
<label for="field_locale">Language:</label>
|
||||
<input type="text" name="locale" id="field_locale" value="{{.User.Locale}}" list="list_locale">
|
||||
<datalist id="list_locale">
|
||||
{{range .ListLocale}}
|
||||
|
@ -12,6 +12,20 @@
|
||||
<div>
|
||||
<button onclick="location.href='/edit'">Edit Profile</button>
|
||||
</div>
|
||||
<div>
|
||||
<button onclick="location.href='/edit/username'">Change username</button>
|
||||
</div>
|
||||
<div>
|
||||
<button onclick="location.href='/edit/password'">Change password</button>
|
||||
</div>
|
||||
<div>
|
||||
<form method="GET" action="/edit/otp">
|
||||
<label> <input type="radio" name="digits" value="6"/> 6 digits </label>
|
||||
<label> <input type="radio" name="digits" value="7"/> 7 digits </label>
|
||||
<label> <input type="radio" name="digits" value="8"/> 8 digits </label>
|
||||
<button type="submit">Change OTP</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="nonce" value="{{.Nonce}}">
|
||||
|
21
pages/login-otp.go.html
Normal file
21
pages/login-otp.go.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>1f349 ID</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>1f349 ID</h1>
|
||||
</header>
|
||||
<main>
|
||||
<form method="POST" action="/login/otp">
|
||||
<input type="hidden" name="redirect" value="{{.Redirect}}"/>
|
||||
<div>
|
||||
<label for="field_code">OTP Code:</label>
|
||||
<input type="text" name="code" id="field_code" required pattern="[0-9]{6,8}" title="6/7/8 digit one time passcode"/>
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -8,14 +8,15 @@
|
||||
<h1>1f349 ID</h1>
|
||||
</header>
|
||||
<main>
|
||||
<form method="POST" action="">
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="redirect" value="{{.Redirect}}"/>
|
||||
<div>
|
||||
<label for="username">User Name</label>
|
||||
<input type="text" name="username" id="username" required/>
|
||||
<label for="field_username">User Name:</label>
|
||||
<input type="text" name="username" id="field_username" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" required/>
|
||||
<label for="field_password">Password:</label>
|
||||
<input type="password" name="password" id="field_password" required/>
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
@ -13,19 +13,22 @@
|
||||
<div>
|
||||
<ul>
|
||||
{{range .WantsList}}
|
||||
<li>{{.Label}}</li>
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<input type="hidden" name="response_type" value="{{.ResponseType}}"/>
|
||||
<input type="hidden" name="response_mode" value="{{.ResponseMode}}">
|
||||
<input type="hidden" name="client_id" value="{{.ClientID}}"/>
|
||||
<input type="hidden" name="redirect_uri" value="{{.RedirectUri}}"/>
|
||||
<input type="hidden" name="state" value="{{.State}}"/>
|
||||
<input type="hidden" name="scopes" value="{{.Scope}}"/>
|
||||
<input type="hidden" name="scope" value="{{.Scope}}"/>
|
||||
<input type="hidden" name="nonce" value="{{.Nonce}}"/>
|
||||
<button class="oauth-action-authorize" name="oauth_action" value="authorize">Authorize</button>
|
||||
<button class="oauth-action-cancel" name="oauth_action" value="cancel">Cancel</button>
|
||||
</div>
|
||||
<div>Authorizing this action will redirect you to {{.AppDomain}} with access to the permissions requested above.</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
51
scope/scope.go
Normal file
51
scope/scope.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
26
scope/scope_test.go
Normal file
26
scope/scope_test.go
Normal file
@ -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
|
||||
}
|
@ -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()}
|
||||
}
|
||||
|
71
server/edit.go
Normal file
71
server/edit.go
Normal file
@ -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, "<!DOCTYPE html>\n<html>\n<body>")
|
||||
_, _ = fmt.Fprintln(rw, "<p>400 Bad Request: Failed to parse form data, press the back button in your browser, check your inputs and try again.</p>")
|
||||
_, _ = fmt.Fprintln(rw, "<ul>")
|
||||
for _, i := range errs {
|
||||
_, _ = fmt.Fprintf(rw, " <li>%s</li>\n", i)
|
||||
}
|
||||
_, _ = fmt.Fprintln(rw, "</ul>")
|
||||
_, _ = fmt.Fprintln(rw, "</body>\n</html>")
|
||||
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)
|
||||
}
|
42
server/home.go
Normal file
42
server/home.go
Normal file
@ -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,
|
||||
})
|
||||
}
|
72
server/login.go
Normal file
72
server/login.go
Normal file
@ -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)
|
||||
}
|
116
server/oauth.go
116
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, `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Authorize</title></head>
|
||||
<body>
|
||||
<form method="POST" action="/authorize">
|
||||
<input type="hidden" name="client_id" value="%s">
|
||||
<input type="hidden" name="redirect_uri" value="%s">
|
||||
<input type="hidden" name="scope" value="%s">
|
||||
<input type="hidden" name="state" value="%s">
|
||||
<input type="hidden" name="nonce" value="%s">
|
||||
<input type="hidden" name="response_type" value="%s">
|
||||
<input type="hidden" name="response_mode" value="%s">
|
||||
<div>Scope: %s</div>
|
||||
<div><button type="submit">Authorize</button></div>
|
||||
<div><button type="submit" name="cancel" value="">Cancel</button></div>
|
||||
</form>
|
||||
</html>`, 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
|
||||
}
|
||||
|
159
server/otp.go
Normal file
159
server/otp.go
Normal file
@ -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)
|
||||
}
|
222
server/server.go
222
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, "<!DOCTYPE html>\n<html>\n<body>")
|
||||
_, _ = fmt.Fprintln(rw, "<p>400 Bad Request: Failed to parse form data, press the back button in your browser, check your inputs and try again.</p>")
|
||||
_, _ = fmt.Fprintln(rw, "<ul>")
|
||||
for _, i := range errs {
|
||||
_, _ = fmt.Fprintf(rw, " <li>%s</li>\n", i)
|
||||
}
|
||||
_, _ = fmt.Fprintln(rw, "</ul>")
|
||||
_, _ = fmt.Fprintln(rw, "</body>\n</html>")
|
||||
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 {
|
||||
|
Loading…
Reference in New Issue
Block a user