diff --git a/cmd/tulip/conf.go b/cmd/tulip/conf.go index 9ab5243..89a8337 100644 --- a/cmd/tulip/conf.go +++ b/cmd/tulip/conf.go @@ -1,7 +1,8 @@ package main type startUpConfig struct { - Listen string `json:"listen"` - Domain string `json:"domain"` - OtpIssuer string `json:"otp_issuer"` + Listen string `json:"listen"` + Domain string `json:"domain"` + OtpIssuer string `json:"otp_issuer"` + ServiceName string `json:"service_name"` } diff --git a/cmd/tulip/serve.go b/cmd/tulip/serve.go index bebdf4f..874a1e2 100644 --- a/cmd/tulip/serve.go +++ b/cmd/tulip/serve.go @@ -82,7 +82,7 @@ func normalLoad(startUp startUpConfig, wd string) { log.Fatal("[Tulip] Failed check:", err) } - srv := server.NewHttpServer(startUp.Listen, startUp.Domain, startUp.OtpIssuer, db, key) + srv := server.NewHttpServer(startUp.Listen, startUp.Domain, startUp.OtpIssuer, startUp.ServiceName, db, key) log.Printf("[Tulip] Starting HTTP server on '%s'\n", srv.Addr) go utils.RunBackgroundHttp("HTTP", srv) @@ -113,7 +113,7 @@ func checkDbHasUser(db *database.DB) error { defer tx.Rollback() if err := tx.HasUser(); err != nil { if errors.Is(err, sql.ErrNoRows) { - err := tx.InsertUser("Admin", "admin", "admin", "admin@localhost") + err := tx.InsertUser("Admin", "admin", "admin", "admin@localhost", database.RoleAdmin, false) if err != nil { return fmt.Errorf("failed to add user: %w", err) } diff --git a/database/db-types.go b/database/db-types.go index e80e73a..a5e6f7c 100644 --- a/database/db-types.go +++ b/database/db-types.go @@ -23,10 +23,32 @@ type User struct { Birthdate NullDateScanner `json:"birthdate,omitempty"` ZoneInfo LocationScanner `json:"zoneinfo,omitempty"` Locale LocaleScanner `json:"locale,omitempty"` + Role UserRole `json:"role"` UpdatedAt time.Time `json:"updated_at"` Active bool `json:"active"` } +type UserRole int + +const ( + RoleMember UserRole = iota + RoleAdmin +) + +func (r UserRole) String() string { + switch r { + case RoleMember: + return "Member" + case RoleAdmin: + return "Admin" + } + return fmt.Sprintf("UserRole{ %d }", r) +} + +func (r UserRole) IsValid() bool { + return r == RoleMember || r == RoleAdmin +} + type UserPatch struct { Name string Picture string diff --git a/database/init.sql b/database/init.sql index 9d9cdf5..4fb328e 100644 --- a/database/init.sql +++ b/database/init.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS users birthdate DATE, zoneinfo TEXT DEFAULT "UTC" NOT NULL, locale TEXT DEFAULT "en-US" NOT NULL, + role INTEGER DEFAULT 0 NOT NULL, updated_at DATETIME, active INTEGER DEFAULT 1 ); @@ -21,11 +22,13 @@ 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, - name TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, secret TEXT UNIQUE NOT NULL, domain TEXT NOT NULL, + owner TEXT NOT NULL, sso INTEGER, - active INTEGER DEFAULT 1 + active INTEGER DEFAULT 1, + FOREIGN KEY (owner) REFERENCES users (subject) ); CREATE TABLE IF NOT EXISTS otp diff --git a/database/tx.go b/database/tx.go index d8082ec..872f481 100644 --- a/database/tx.go +++ b/database/tx.go @@ -37,12 +37,12 @@ func (t *Tx) HasUser() error { return nil } -func (t *Tx) InsertUser(name, un, pw, email string) error { +func (t *Tx) InsertUser(name, un, pw, email string, role UserRole, active bool) error { pwHash, err := password.HashPassword(pw) if err != nil { return err } - _, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, uuid.NewString(), name, un, pwHash, email, updatedAt()) + _, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, role, updated_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, uuid.NewString(), name, un, pwHash, email, role, updatedAt(), active) return err } @@ -66,6 +66,13 @@ func (t *Tx) GetUserDisplayName(sub uuid.UUID) (*User, error) { return &u, err } +func (t *Tx) GetUserRole(sub uuid.UUID) (UserRole, error) { + var r UserRole + row := t.tx.QueryRow(`SELECT role FROM users WHERE subject = ? LIMIT 1`, sub.String()) + err := row.Scan(&r) + return r, err +} + 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 = ?`, sub.String()) @@ -177,33 +184,111 @@ func (t *Tx) GetTwoFactor(sub uuid.UUID, issuer string) (*twofactor.Totp, error) return twofactor.TOTPFromBytes(u, issuer) } +func (t *Tx) HasTwoFactor(sub uuid.UUID) (bool, error) { + var hasOtp bool + row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM otp WHERE otp.subject = ?)`, sub) + err := row.Scan(&hasOtp) + if err != nil { + return false, err + } + return hasOtp, row.Err() +} + func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) { - var u clientInfoDbOutput - var active bool + var u ClientInfoDbOutput row := t.tx.QueryRow(`SELECT secret, name, domain, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub) - err := row.Scan(&u.secret, &u.name, &u.domain, &u.sso, &active) - u.sub = sub - if !active { + err := row.Scan(&u.Secret, &u.Name, &u.Domain, &u.SSO, &u.Active) + u.Owner = sub + if !u.Active { return nil, fmt.Errorf("client is not active") } return &u, err } -type clientInfoDbOutput struct { - sub, name, secret, domain string - sso bool +func (t *Tx) GetAppList(offset int) ([]ClientInfoDbOutput, error) { + var u []ClientInfoDbOutput + row, err := t.tx.Query(`SELECT subject, name, domain, owner, sso, active FROM client_store LIMIT 25 OFFSET ?`, offset) + if err != nil { + return nil, err + } + defer row.Close() + for row.Next() { + var a ClientInfoDbOutput + err := row.Scan(&a.Sub, &a.Name, &a.Domain, &a.Owner, &a.SSO, &a.Active) + if err != nil { + return nil, err + } + u = append(u, a) + } + return u, row.Err() } -func (c *clientInfoDbOutput) GetID() string { return c.sub } -func (c *clientInfoDbOutput) GetSecret() string { return c.secret } -func (c *clientInfoDbOutput) GetDomain() string { return c.domain } -func (c *clientInfoDbOutput) IsPublic() bool { return false } -func (c *clientInfoDbOutput) GetUserID() string { return "" } +func (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner uuid.UUID) error { + u := uuid.New() + secret, err := password.GenerateApiSecret(70) + if err != nil { + return err + } + _, err = t.tx.Exec(`INSERT INTO client_store (subject, name, secret, domain, owner, sso, active) VALUES (?, ?, ?, ?, ?, ?, ?)`, u.String(), name, secret, domain, owner.String(), sso, active) + return err +} -// 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 } +func (t *Tx) UpdateClientApp(subject uuid.UUID, name, domain string, sso, active bool) error { + _, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ?`, name, domain, sso, active, subject.String()) + return err +} + +func (t *Tx) ResetClientAppSecret(subject uuid.UUID, secret string) error { + secret, err := password.GenerateApiSecret(70) + if err != nil { + return err + } + _, err = t.tx.Exec(`UPDATE client_store SET secret = ? WHERE subject = ?`, secret, subject.String()) + return err +} + +func (t *Tx) GetUserList(offset int) ([]User, error) { + var u []User + row, err := t.tx.Query(`SELECT subject, name, username, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, role, updated_at, active FROM users LIMIT 25 OFFSET ?`, offset) + if err != nil { + return nil, err + } + for row.Next() { + var a User + err := row.Scan(&a.Sub, &a.Name, &a.Username, &a.Picture, &a.Website, &a.Email, &a.EmailVerified, &a.Pronouns, &a.Birthdate, &a.ZoneInfo, &a.Locale, &a.Role, &a.UpdatedAt, &a.Active) + if err != nil { + return nil, err + } + u = append(u, a) + } + return u, row.Err() +} + +func (t *Tx) UpdateUser(subject uuid.UUID, role UserRole, active bool) error { + _, err := t.tx.Exec(`UPDATE users SET active = ?, role = ? WHERE subject = ?`, active, role, subject) + return err +} + +type ClientInfoDbOutput struct { + Sub, Name, Secret, Domain, Owner string + SSO, Active bool +} + +var _ oauth2.ClientInfo = &ClientInfoDbOutput{} + +func (c *ClientInfoDbOutput) GetID() string { return c.Sub } +func (c *ClientInfoDbOutput) GetSecret() string { return c.Secret } +func (c *ClientInfoDbOutput) GetDomain() string { return c.Domain } +func (c *ClientInfoDbOutput) IsPublic() bool { return false } +func (c *ClientInfoDbOutput) GetUserID() string { return c.Owner } // GetName is an extra field for the oauth handler to display the application // name -func (c *clientInfoDbOutput) GetName() string { return c.name } +func (c *ClientInfoDbOutput) GetName() string { return c.Name } + +// IsSSO is an extra field for the oauth handler to skip the user input stage +// this is for trusted applications to get permissions without asking the user +func (c *ClientInfoDbOutput) IsSSO() bool { return c.SSO } + +// IsActive is an extra field for the app manager to get the active state +func (c *ClientInfoDbOutput) IsActive() bool { return c.Active } diff --git a/go.mod b/go.mod index f407054..d6aa5a8 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,9 @@ require ( github.com/1f349/violet v0.0.9 github.com/MrMelon54/exit-reload v0.0.1 github.com/MrMelon54/pronouns v1.0.1 + github.com/emersion/go-message v0.17.0 + github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead + github.com/emersion/go-smtp v0.18.1 github.com/go-oauth2/oauth2/v4 v4.5.2 github.com/go-session/session v3.1.2+incompatible github.com/google/subcommands v1.2.0 @@ -20,6 +23,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sec51/convert v1.0.2 // indirect diff --git a/go.sum b/go.sum index bd6c4a6..b182558 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,15 @@ github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-message v0.17.0 h1:NIdSKHiVUx4qKqdd0HyJFD41cW8iFguM2XJnRZWQH04= +github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y= +github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.18.1 h1:4DFV0jxKhq0Gqt/Br3BRHyKZy5TStk6NIMHAx6GE/LA= +github.com/emersion/go-smtp v0.18.1/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= @@ -144,22 +153,33 @@ github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FB github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -174,16 +194,27 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.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= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/pages/edit-otp.go.html b/pages/edit-otp.go.html index c80719a..dc62d23 100644 --- a/pages/edit-otp.go.html +++ b/pages/edit-otp.go.html @@ -1,11 +1,11 @@ - 1f349 ID + {{.ServiceName}}
-

1f349 ID

+

{{.ServiceName}}

diff --git a/pages/edit-password.go b/pages/edit-password.go.html similarity index 100% rename from pages/edit-password.go rename to pages/edit-password.go.html diff --git a/pages/edit-username.go b/pages/edit-username.go.html similarity index 100% rename from pages/edit-username.go rename to pages/edit-username.go.html diff --git a/pages/edit.go.html b/pages/edit.go.html index e328599..be6331c 100644 --- a/pages/edit.go.html +++ b/pages/edit.go.html @@ -1,11 +1,11 @@ - 1f349 ID + {{.ServiceName}}
-

1f349 ID

+

{{.ServiceName}}

Logged in as: {{.User.Name}} ({{.User.Sub}})
@@ -62,6 +62,9 @@ +
+ +
diff --git a/pages/index-guest.go.html b/pages/index-guest.go.html index a31fcbf..54b0671 100644 --- a/pages/index-guest.go.html +++ b/pages/index-guest.go.html @@ -1,16 +1,18 @@ - 1f349 ID + {{.ServiceName}}
-

1f349 ID

+

{{.ServiceName}}

Not logged in
- +
+ +
diff --git a/pages/index.go.html b/pages/index.go.html index f6b6684..f5551b8 100644 --- a/pages/index.go.html +++ b/pages/index.go.html @@ -1,28 +1,44 @@ - 1f349 ID + {{.ServiceName}}
-

1f349 ID

+

{{.ServiceName}}

Logged in as: {{.User.Name}} ({{.User.Sub}})
- +
+ +
- +
+ +
- +
+ +
+
+
+
+ +
+
+
+
+ +
- - - + + +
diff --git a/pages/login-otp.go.html b/pages/login-otp.go.html index 2fd31de..23f88b8 100644 --- a/pages/login-otp.go.html +++ b/pages/login-otp.go.html @@ -1,11 +1,11 @@ - 1f349 ID + {{.ServiceName}}
-

1f349 ID

+

{{.ServiceName}}

diff --git a/pages/login.go.html b/pages/login.go.html index 8d38ec3..9b8d6b8 100644 --- a/pages/login.go.html +++ b/pages/login.go.html @@ -1,11 +1,11 @@ - 1f349 ID + {{.ServiceName}}
-

1f349 ID

+

{{.ServiceName}}

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

{{.ServiceName}}

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

Edit Client Application

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

Manage Client Applications

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

Create Client Application

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

{{.ServiceName}}

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

Edit User

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

Manage Users

+ {{if eq (len .Users) 0}} +
No users found, this is definitely a bug.
+ {{else}} + + + + + + + + + + + + + + + + + + {{range .Users}} + + + + + + + + + + + + + + {{end}} + +
IDNameUsernamePictureWebsiteEmailEmail VerifiedRoleLast UpdatedActiveActions
{{.Sub}}{{.Name}}{{.Username}} + {{if .Picture}} + {{.Name}} Profile Picture + {{end}} + {{.Website}} + {{if $.EmailShow}} + {{.Email}} + {{else}} + {{emailHide .Email}} + {{end}} + {{.EmailVerified}}{{.Role}}{{.UpdatedAt}}{{.Active}} + {{if eq $.CurrentAdmin .Sub}} + + {{else}} +
+ + + +
+ {{end}} +
+
+ + {{if not .EmailShow}} + + {{end}} + +
+ {{end}} + +

Create User

+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ {{end}} +
+ + diff --git a/pages/oauth-authorize.go.html b/pages/oauth-authorize.go.html index a92944d..eaf637e 100644 --- a/pages/oauth-authorize.go.html +++ b/pages/oauth-authorize.go.html @@ -1,11 +1,11 @@ - 1f349 ID + {{.ServiceName}}
-

1f349 ID

+

{{.ServiceName}}

@@ -17,6 +17,12 @@ {{end}} + {{if .HasOtp}} +
+ + +
+ {{end}}
diff --git a/pages/pages.go b/pages/pages.go index 71b01a5..963e2cd 100644 --- a/pages/pages.go +++ b/pages/pages.go @@ -16,7 +16,9 @@ var ( ) func LoadPageTemplates() (err error) { - pageTemplate, err = template.New("pages").ParseFS(embeddedTemplates, "*.go.html") + pageTemplate, err = template.New("pages").Funcs(template.FuncMap{ + "emailHide": EmailHide, + }).ParseFS(embeddedTemplates, "*.go.html") return } @@ -26,3 +28,13 @@ func RenderPageTemplate(wr io.Writer, name string, data any) { log.Printf("Failed to render page: %s: %s\n", name, err) } } + +func EmailHide(a string) string { + b := []byte(a) + for i := range b { + if b[i] != '@' && b[i] != '.' { + b[i] = 'x' + } + } + return string(b) +} diff --git a/pages/pages_test.go b/pages/pages_test.go new file mode 100644 index 0000000..7d0c445 --- /dev/null +++ b/pages/pages_test.go @@ -0,0 +1,11 @@ +package pages + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestEmailHide(t *testing.T) { + assert.Equal(t, "xx", EmailHide("hi")) + assert.Equal(t, "xxxxxxx@xxxxxxx.xxx", EmailHide("example@example.com")) +} diff --git a/pages/verification-code.go.html b/pages/verification-code.go.html new file mode 100644 index 0000000..c8ca642 --- /dev/null +++ b/pages/verification-code.go.html @@ -0,0 +1,26 @@ + + + + {{.Title}} + + + +
+

{{.Title}}

+

Hi {{.Name}},

+

Here is your email verification code: {{.Code}}

+

{{.ServiceName}}

+
+ + diff --git a/password/secret.go b/password/secret.go new file mode 100644 index 0000000..1eec1de --- /dev/null +++ b/password/secret.go @@ -0,0 +1,19 @@ +package password + +import "crypto/rand" + +func GenerateApiSecret(length int) (string, error) { + const secretChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_." + var _ = secretChars[63] // compiler check: ensure there is at least 64 chars here + + b := make([]byte, length) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + for i := range b { + b[i] = secretChars[b[i]&0x3f] // only use the lower 6 bits + } + return string(b), nil +} diff --git a/server/auth.go b/server/auth.go index c2e0314..4127188 100644 --- a/server/auth.go +++ b/server/auth.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "github.com/1f349/tulip/database" "github.com/go-session/session" "github.com/google/uuid" "github.com/julienschmidt/httprouter" @@ -37,8 +38,25 @@ func (u UserAuth) SaveSessionData() error { 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) { +func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle { + return RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { + var role database.UserRole + if h.DbTx(rw, func(tx *database.Tx) (err error) { + role, err = tx.GetUserRole(auth.Data.ID) + return + }) { + return + } + if role != database.RoleAdmin { + http.Error(rw, "403 Forbidden", http.StatusForbidden) + return + } + next(rw, req, params, auth) + }) +} + +func RequireAuthentication(next UserHandler) httprouter.Handle { + return OptionalAuthentication(false, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { if auth.IsGuest() { redirectUrl := PrepareRedirectUrl("/login", req.URL) http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) @@ -48,9 +66,9 @@ func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle { }) } -func (h *HttpServer) OptionalAuthentication(flowPart bool, next UserHandler) httprouter.Handle { +func OptionalAuthentication(flowPart bool, next UserHandler) httprouter.Handle { return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - auth, err := h.internalAuthenticationHandler(rw, req) + auth, err := internalAuthenticationHandler(rw, req) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -63,7 +81,7 @@ func (h *HttpServer) OptionalAuthentication(flowPart bool, next UserHandler) htt } } -func (h *HttpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) { +func internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) { ss, err := session.Start(req.Context(), rw, req) if err != nil { return UserAuth{}, fmt.Errorf("failed to start session") diff --git a/server/auth_test.go b/server/auth_test.go index a4a06bb..660b9c6 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -1,11 +1,117 @@ package server import ( + "context" + "fmt" + "github.com/google/uuid" "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "net/url" "testing" ) +func TestUserAuth_NextFlowUrl(t *testing.T) { + u := UserAuth{Data: SessionData{NeedOtp: true}} + assert.Equal(t, url.URL{Path: "/login/otp"}, *u.NextFlowUrl(&url.URL{})) + assert.Equal(t, url.URL{Path: "/login/otp", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello"})) + assert.Equal(t, url.URL{Path: "/login/otp", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) + u.Data.NeedOtp = false + assert.Nil(t, u.NextFlowUrl(&url.URL{})) +} + func TestUserAuth_IsGuest(t *testing.T) { var u UserAuth assert.True(t, u.IsGuest()) + u.Data.ID = uuid.New() + assert.False(t, u.IsGuest()) +} + +type fakeSessionStore struct { + m map[string]any + saveFunc func(map[string]any) error +} + +func (f *fakeSessionStore) Context() context.Context { return context.Background() } +func (f *fakeSessionStore) SessionID() string { return "fakeSessionStore" } +func (f *fakeSessionStore) Set(key string, value interface{}) { f.m[key] = value } + +func (f *fakeSessionStore) Get(key string) (a interface{}, ok bool) { + if a, ok = f.m[key]; false { + } + return +} + +func (f *fakeSessionStore) Delete(key string) (i interface{}) { + i = f.m[key] + delete(f.m, key) + return +} + +func (f *fakeSessionStore) Save() error { + return f.saveFunc(f.m) +} + +func (f *fakeSessionStore) Flush() error { + return nil +} + +func TestUserAuth_SaveSessionData(t *testing.T) { + f := &fakeSessionStore{m: make(map[string]any)} + u := UserAuth{Data: SessionData{ID: uuid.UUID{5, 6, 7}, NeedOtp: true}, Session: f} + + // fail to save + f.saveFunc = func(m map[string]any) error { return fmt.Errorf("failed") } + assert.Error(t, u.SaveSessionData()) + + // try with success + var m2 map[string]any + f.saveFunc = func(m map[string]any) error { + m2 = m + return nil + } + assert.NoError(t, u.SaveSessionData()) + assert.Equal(t, map[string]any{"session-data": SessionData{ID: uuid.UUID{5, 6, 7}, NeedOtp: true}}, m2) +} + +func TestRequireAuthentication(t *testing.T) { +} + +func TestOptionalAuthentication(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://example.com/hello", nil) + assert.NoError(t, err) + rec := httptest.NewRecorder() + auth, err := internalAuthenticationHandler(rec, req) + assert.NoError(t, err) + assert.True(t, auth.IsGuest()) + auth.Data.ID = uuid.UUID{5, 6, 7} + assert.NoError(t, auth.SaveSessionData()) +} + +func Test_internalAuthenticationHandler(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://example.com/hello", nil) + assert.NoError(t, err) + rec := httptest.NewRecorder() + auth, err := internalAuthenticationHandler(rec, req) + assert.NoError(t, err) + assert.True(t, auth.IsGuest()) + auth.Data.ID = uuid.UUID{5, 6, 7} + assert.NoError(t, auth.SaveSessionData()) + + req, err = http.NewRequest(http.MethodGet, "https://example.com/world", nil) + assert.NoError(t, err) + req.Header.Set("Cookie", rec.Header().Get("Set-Cookie")) + rec = httptest.NewRecorder() + auth, err = internalAuthenticationHandler(rec, req) + assert.NoError(t, err) + assert.False(t, auth.IsGuest()) + assert.Equal(t, uuid.UUID{5, 6, 7}, auth.Data.ID) +} + +func TestPrepareRedirectUrl(t *testing.T) { + assert.Equal(t, url.URL{Path: "/hello"}, *PrepareRedirectUrl("/hello", &url.URL{})) + assert.Equal(t, url.URL{Path: "/world"}, *PrepareRedirectUrl("/world", &url.URL{})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello"})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) + assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A&b=B"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()})) } diff --git a/server/edit.go b/server/edit.go index 13048b7..058c862 100644 --- a/server/edit.go +++ b/server/edit.go @@ -31,6 +31,7 @@ func (h *HttpServer) EditGet(rw http.ResponseWriter, _ *http.Request, _ httprout return } pages.RenderPageTemplate(rw, "edit", map[string]any{ + "ServiceName": h.serviceName, "User": user, "Nonce": lNonce, "FieldPronoun": user.Pronouns.String(), diff --git a/server/home.go b/server/home.go index 01e9426..b2353e1 100644 --- a/server/home.go +++ b/server/home.go @@ -13,7 +13,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute rw.Header().Set("Content-Type", "text/html") rw.WriteHeader(http.StatusOK) if auth.IsGuest() { - pages.RenderPageTemplate(rw, "index-guest", nil) + pages.RenderPageTemplate(rw, "index-guest", map[string]any{ + "ServiceName": h.serviceName, + }) return } @@ -35,8 +37,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute return } pages.RenderPageTemplate(rw, "index", map[string]any{ - "Auth": auth, - "User": userWithName, - "Nonce": lNonce, + "ServiceName": h.serviceName, + "Auth": auth, + "User": userWithName, + "Nonce": lNonce, }) } diff --git a/server/login.go b/server/login.go index e9f5d38..01fe4ee 100644 --- a/server/login.go +++ b/server/login.go @@ -21,7 +21,8 @@ func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httpr rw.Header().Set("Content-Type", "text/html") rw.WriteHeader(http.StatusOK) pages.RenderPageTemplate(rw, "login", map[string]any{ - "Redirect": req.URL.Query().Get("redirect"), + "ServiceName": h.serviceName, + "Redirect": req.URL.Query().Get("redirect"), }) } diff --git a/server/manage-apps.go b/server/manage-apps.go new file mode 100644 index 0000000..5638edb --- /dev/null +++ b/server/manage-apps.go @@ -0,0 +1,113 @@ +package server + +import ( + "github.com/1f349/tulip/database" + "github.com/1f349/tulip/pages" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "net/http" + "net/url" + "strconv" +) + +func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + offset := 0 + q := req.URL.Query() + if q.Has("offset") { + var err error + offset, err = strconv.Atoi(q.Get("offset")) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid offset", http.StatusBadRequest) + return + } + } + + var role database.UserRole + var appList []database.ClientInfoDbOutput + if h.DbTx(rw, func(tx *database.Tx) (err error) { + role, err = tx.GetUserRole(auth.Data.ID) + if err != nil { + return + } + appList, err = tx.GetAppList(offset) + return + }) { + return + } + + m := map[string]any{ + "ServiceName": h.serviceName, + "Apps": appList, + "Offset": offset, + "IsAdmin": role == database.RoleAdmin, + } + if q.Has("edit") { + for _, i := range appList { + if i.Sub == q.Get("edit") { + m["Edit"] = i + goto validEdit + } + } + http.Error(rw, "400 Bad Request: Invalid client app to edit", http.StatusBadRequest) + return + } + +validEdit: + rw.Header().Set("Content-Type", "text/html") + rw.WriteHeader(http.StatusOK) + pages.RenderPageTemplate(rw, "manage-apps", m) +} + +func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + err := req.ParseForm() + if err != nil { + http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest) + return + } + + offset := req.Form.Get("offset") + action := req.Form.Get("action") + name := req.Form.Get("name") + domain := req.Form.Get("domain") + sso := req.Form.Has("sso") + active := req.Form.Has("active") + + if sso { + var role database.UserRole + if h.DbTx(rw, func(tx *database.Tx) (err error) { + role, err = tx.GetUserRole(auth.Data.ID) + return + }) { + return + } + if role != database.RoleAdmin { + http.Error(rw, "400 Bad Request: Only admin users can create SSO client applications", http.StatusBadRequest) + return + } + } + + switch action { + case "create": + if h.DbTx(rw, func(tx *database.Tx) error { + return tx.InsertClientApp(name, domain, sso, active, auth.Data.ID) + }) { + return + } + case "edit": + if h.DbTx(rw, func(tx *database.Tx) error { + sub, err := uuid.Parse(req.Form.Get("subject")) + if err != nil { + return err + } + return tx.UpdateClientApp(sub, name, domain, sso, active) + }) { + return + } + default: + http.Error(rw, "400 Bad Request: Invalid action", http.StatusBadRequest) + return + } + + redirectUrl := url.URL{Path: "/manage/apps", RawQuery: url.Values{"offset": []string{offset}}.Encode()} + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) +} diff --git a/server/manage-users.go b/server/manage-users.go new file mode 100644 index 0000000..76b3b1b --- /dev/null +++ b/server/manage-users.go @@ -0,0 +1,132 @@ +package server + +import ( + "errors" + "github.com/1f349/tulip/database" + "github.com/1f349/tulip/pages" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "net/http" + "net/url" + "strconv" +) + +func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + offset := 0 + q := req.URL.Query() + if q.Has("offset") { + var err error + offset, err = strconv.Atoi(q.Get("offset")) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid offset", http.StatusBadRequest) + return + } + } + + var role database.UserRole + var userList []database.User + if h.DbTx(rw, func(tx *database.Tx) (err error) { + role, err = tx.GetUserRole(auth.Data.ID) + if err != nil { + return + } + userList, err = tx.GetUserList(offset) + return + }) { + return + } + if role != database.RoleAdmin { + http.Error(rw, "403 Forbidden", http.StatusForbidden) + return + } + + m := map[string]any{ + "ServiceName": h.serviceName, + "Users": userList, + "Offset": offset, + "EmailShow": req.URL.Query().Has("show-email"), + "CurrentAdmin": auth.Data.ID, + } + if q.Has("edit") { + for _, i := range userList { + if i.Sub.String() == q.Get("edit") { + m["Edit"] = i + goto validEdit + } + } + http.Error(rw, "400 Bad Request: Invalid user to edit", http.StatusBadRequest) + return + } + +validEdit: + rw.Header().Set("Content-Type", "text/html") + rw.WriteHeader(http.StatusOK) + pages.RenderPageTemplate(rw, "manage-users", m) +} + +func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { + err := req.ParseForm() + if err != nil { + http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest) + return + } + + var role database.UserRole + if h.DbTx(rw, func(tx *database.Tx) (err error) { + role, err = tx.GetUserRole(auth.Data.ID) + return + }) { + return + } + if role != database.RoleAdmin { + http.Error(rw, "400 Bad Request: Only admin users can create SSO client applications", http.StatusBadRequest) + return + } + + offset := req.Form.Get("offset") + action := req.Form.Get("action") + name := req.Form.Get("name") + username := req.Form.Get("username") + email := req.Form.Get("email") + newRole, err := parseRoleValue(req.Form.Get("role")) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid role", http.StatusBadRequest) + return + } + active := req.Form.Has("active") + + switch action { + case "create": + if h.DbTx(rw, func(tx *database.Tx) error { + return tx.InsertUser(name, username, "", email, newRole, active) + }) { + return + } + case "edit": + if h.DbTx(rw, func(tx *database.Tx) error { + sub, err := uuid.Parse(req.Form.Get("subject")) + if err != nil { + return err + } + return tx.UpdateUser(sub, newRole, active) + }) { + return + } + default: + http.Error(rw, "400 Bad Request: Invalid action", http.StatusBadRequest) + return + } + + redirectUrl := url.URL{Path: "/manage/users", RawQuery: url.Values{"offset": []string{offset}}.Encode()} + http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) +} + +func parseRoleValue(role string) (database.UserRole, error) { + switch role { + case "member": + return database.RoleMember, nil + case "admin": + return database.RoleAdmin, nil + } + return 0, errors.New("invalid role value") +} diff --git a/server/oauth.go b/server/oauth.go index 62fa257..770bc98 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -78,16 +78,24 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request } var user *database.User - if h.DbTx(rw, func(tx *database.Tx) error { - var err error + var hasOtp bool + if h.DbTx(rw, func(tx *database.Tx) (err error) { user, err = tx.GetUserDisplayName(auth.Data.ID) - return err + if err != nil { + return + } + hasOtp, err = tx.HasTwoFactor(auth.Data.ID) + if err != nil { + return + } + return }) { return } rw.WriteHeader(http.StatusOK) pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{ + "ServiceName": h.serviceName, "AppName": appName, "AppDomain": appDomain, "User": user, @@ -99,27 +107,35 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request "State": form.Get("state"), "Scope": form.Get("scope"), "Nonce": form.Get("nonce"), + "HasOtp": hasOtp, }) return } - // 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) + if !isSSO { + otpInput := req.FormValue("code") + if h.fetchAndValidateOtp(rw, auth.Data.ID, otpInput) { return } - q := redirectUri.Query() - q.Set("error", "user_cancelled") - redirectUri.RawQuery = q.Encode() - http.Redirect(rw, req, redirectUri.String(), http.StatusFound) + } + + // redirect with an error if the action is not authorize + if form.Get("oauth_action") == "authorize" || isSSO { + if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + } return } - if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) + parsedRedirect, err := url.Parse(redirectUri) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid redirect URI", http.StatusBadRequest) + return } + q := parsedRedirect.Query() + q.Set("error", "user_cancelled") + parsedRedirect.RawQuery = q.Encode() + http.Redirect(rw, req, parsedRedirect.String(), http.StatusFound) } func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Request) (string, error) { @@ -128,7 +144,7 @@ func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re return "", err } - auth, err := h.internalAuthenticationHandler(rw, req) + auth, err := internalAuthenticationHandler(rw, req) if err != nil { return "", err } diff --git a/server/otp.go b/server/otp.go index 5c03c07..cfd4ce6 100644 --- a/server/otp.go +++ b/server/otp.go @@ -6,6 +6,7 @@ import ( "github.com/1f349/tulip/database" "github.com/1f349/tulip/pages" "github.com/1f349/twofactor" + "github.com/google/uuid" "github.com/julienschmidt/httprouter" "html/template" "net/http" @@ -18,7 +19,8 @@ func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ ht } pages.RenderPageTemplate(rw, "login-otp", map[string]any{ - "Redirect": req.URL.Query().Get("redirect"), + "ServiceName": h.serviceName, + "Redirect": req.URL.Query().Get("redirect"), }) } @@ -29,18 +31,7 @@ func (h *HttpServer) LoginOtpPost(rw http.ResponseWriter, req *http.Request, _ h } 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 { - + if h.fetchAndValidateOtp(rw, auth.Data.ID, otpInput) { return } @@ -53,6 +44,39 @@ func (h *HttpServer) LoginOtpPost(rw http.ResponseWriter, req *http.Request, _ h h.SafeRedirect(rw, req) } +func (h *HttpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub uuid.UUID, code string) bool { + var hasOtp bool + var otp *twofactor.Totp + if h.DbTx(rw, func(tx *database.Tx) (err error) { + hasOtp, err = tx.HasTwoFactor(sub) + if err != nil { + return + } + if hasOtp { + otp, err = tx.GetTwoFactor(sub, h.otpIssuer) + } + return + }) { + return true + } + + if hasOtp { + defer func() { + h.DbTx(rw, func(tx *database.Tx) error { + return tx.SetTwoFactor(sub, otp) + }) + }() + + err := otp.Validate(code) + if err != nil { + http.Error(rw, "400 Bad Request: Invalid OTP code", http.StatusBadRequest) + return true + } + } + + return false +} + func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { var digits = 0 switch req.URL.Query().Get("digits") { @@ -126,8 +150,9 @@ func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ htt // render page pages.RenderPageTemplate(rw, "edit-otp", map[string]any{ - "OtpQr": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(otpQr)), - "OtpUrl": otpUrl, + "ServiceName": h.serviceName, + "OtpQr": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(otpQr)), + "OtpUrl": otpUrl, }) } @@ -155,5 +180,5 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht return } - http.Redirect(rw, req, "/edit", http.StatusFound) + http.Redirect(rw, req, "/", http.StatusFound) } diff --git a/server/server.go b/server/server.go index 9a0e31b..e0219a5 100644 --- a/server/server.go +++ b/server/server.go @@ -24,13 +24,14 @@ 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 - otpIssuer string + r *httprouter.Router + oauthSrv *server.Server + oauthMgr *manage.Manager + db *database.DB + domain string + privKey []byte + otpIssuer string + serviceName string } func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) { @@ -51,7 +52,7 @@ func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) { http.Redirect(rw, req, parse.String(), http.StatusFound) } -func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey []byte) *http.Server { +func NewHttpServer(listen, domain, otpIssuer, serviceName 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"}) @@ -67,13 +68,14 @@ func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey [] oauthManager := manage.NewDefaultManager() oauthSrv := server.NewServer(server.NewConfig(), oauthManager) hs := &HttpServer{ - r: httprouter.New(), - oauthSrv: oauthSrv, - oauthMgr: oauthManager, - db: db, - domain: domain, - privKey: privKey, - otpIssuer: otpIssuer, + r: httprouter.New(), + oauthSrv: oauthSrv, + oauthMgr: oauthManager, + db: db, + domain: domain, + privKey: privKey, + otpIssuer: otpIssuer, + serviceName: serviceName, } oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) @@ -113,8 +115,8 @@ func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey [] rw.WriteHeader(http.StatusOK) _, _ = rw.Write(openIdBytes) }) - r.GET("/", hs.OptionalAuthentication(false, hs.Home)) - r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { + r.GET("/", OptionalAuthentication(false, hs.Home)) + r.POST("/logout", 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) @@ -131,21 +133,33 @@ func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey [] } http.Error(rw, "Logout failed", http.StatusInternalServerError) })) - 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)) + + // login steps + r.GET("/login", OptionalAuthentication(false, hs.LoginGet)) + r.POST("/login", OptionalAuthentication(false, hs.LoginPost)) + r.GET("/login/otp", OptionalAuthentication(true, hs.LoginOtpGet)) + r.POST("/login/otp", OptionalAuthentication(true, hs.LoginOtpPost)) + + // edit profile pages + r.GET("/edit", RequireAuthentication(hs.EditGet)) + r.POST("/edit", RequireAuthentication(hs.EditPost)) + r.GET("/edit/otp", RequireAuthentication(hs.EditOtpGet)) + r.POST("/edit/otp", RequireAuthentication(hs.EditOtpPost)) + + // management pages + r.GET("/manage/apps", hs.RequireAdminAuthentication(hs.ManageAppsGet)) + r.POST("/manage/apps", hs.RequireAdminAuthentication(hs.ManageAppsPost)) + r.GET("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersGet)) + r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost)) + + // oauth pages + r.GET("/authorize", RequireAuthentication(hs.authorizeEndpoint)) + r.POST("/authorize", 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(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 {