Login, edit, oauth, otp and management page changes

This commit is contained in:
Melon 2023-09-15 13:06:31 +01:00
parent b9d456f2fa
commit fff03ac6ad
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
32 changed files with 1043 additions and 116 deletions

View File

@ -1,7 +1,8 @@
package main package main
type startUpConfig struct { type startUpConfig struct {
Listen string `json:"listen"` Listen string `json:"listen"`
Domain string `json:"domain"` Domain string `json:"domain"`
OtpIssuer string `json:"otp_issuer"` OtpIssuer string `json:"otp_issuer"`
ServiceName string `json:"service_name"`
} }

View File

@ -82,7 +82,7 @@ func normalLoad(startUp startUpConfig, wd string) {
log.Fatal("[Tulip] Failed check:", err) 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) log.Printf("[Tulip] Starting HTTP server on '%s'\n", srv.Addr)
go utils.RunBackgroundHttp("HTTP", srv) go utils.RunBackgroundHttp("HTTP", srv)
@ -113,7 +113,7 @@ func checkDbHasUser(db *database.DB) error {
defer tx.Rollback() defer tx.Rollback()
if err := tx.HasUser(); err != nil { if err := tx.HasUser(); err != nil {
if errors.Is(err, sql.ErrNoRows) { 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 { if err != nil {
return fmt.Errorf("failed to add user: %w", err) return fmt.Errorf("failed to add user: %w", err)
} }

View File

@ -23,10 +23,32 @@ type User struct {
Birthdate NullDateScanner `json:"birthdate,omitempty"` Birthdate NullDateScanner `json:"birthdate,omitempty"`
ZoneInfo LocationScanner `json:"zoneinfo,omitempty"` ZoneInfo LocationScanner `json:"zoneinfo,omitempty"`
Locale LocaleScanner `json:"locale,omitempty"` Locale LocaleScanner `json:"locale,omitempty"`
Role UserRole `json:"role"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Active bool `json:"active"` 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 { type UserPatch struct {
Name string Name string
Picture string Picture string

View File

@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS users
birthdate DATE, birthdate DATE,
zoneinfo TEXT DEFAULT "UTC" NOT NULL, zoneinfo TEXT DEFAULT "UTC" NOT NULL,
locale TEXT DEFAULT "en-US" NOT NULL, locale TEXT DEFAULT "en-US" NOT NULL,
role INTEGER DEFAULT 0 NOT NULL,
updated_at DATETIME, updated_at DATETIME,
active INTEGER DEFAULT 1 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 CREATE TABLE IF NOT EXISTS client_store
( (
subject TEXT PRIMARY KEY UNIQUE NOT NULL, subject TEXT PRIMARY KEY UNIQUE NOT NULL,
name TEXT UNIQUE NOT NULL, name TEXT NOT NULL,
secret TEXT UNIQUE NOT NULL, secret TEXT UNIQUE NOT NULL,
domain TEXT NOT NULL, domain TEXT NOT NULL,
owner TEXT NOT NULL,
sso INTEGER, sso INTEGER,
active INTEGER DEFAULT 1 active INTEGER DEFAULT 1,
FOREIGN KEY (owner) REFERENCES users (subject)
); );
CREATE TABLE IF NOT EXISTS otp CREATE TABLE IF NOT EXISTS otp

View File

@ -37,12 +37,12 @@ func (t *Tx) HasUser() error {
return nil 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) pwHash, err := password.HashPassword(pw)
if err != nil { if err != nil {
return err 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 return err
} }
@ -66,6 +66,13 @@ func (t *Tx) GetUserDisplayName(sub uuid.UUID) (*User, error) {
return &u, err 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) { func (t *Tx) GetUser(sub uuid.UUID) (*User, error) {
var u User 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()) 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) 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) { func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) {
var u clientInfoDbOutput var u ClientInfoDbOutput
var active bool
row := t.tx.QueryRow(`SELECT secret, name, domain, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub) 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) err := row.Scan(&u.Secret, &u.Name, &u.Domain, &u.SSO, &u.Active)
u.sub = sub u.Owner = sub
if !active { if !u.Active {
return nil, fmt.Errorf("client is not active") return nil, fmt.Errorf("client is not active")
} }
return &u, err return &u, err
} }
type clientInfoDbOutput struct { func (t *Tx) GetAppList(offset int) ([]ClientInfoDbOutput, error) {
sub, name, secret, domain string var u []ClientInfoDbOutput
sso bool 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 (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner uuid.UUID) error {
func (c *clientInfoDbOutput) GetSecret() string { return c.secret } u := uuid.New()
func (c *clientInfoDbOutput) GetDomain() string { return c.domain } secret, err := password.GenerateApiSecret(70)
func (c *clientInfoDbOutput) IsPublic() bool { return false } if err != nil {
func (c *clientInfoDbOutput) GetUserID() string { return "" } 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 func (t *Tx) UpdateClientApp(subject uuid.UUID, name, domain string, sso, active bool) error {
// this is for trusted applications to get permissions without asking the user _, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ?`, name, domain, sso, active, subject.String())
func (c *clientInfoDbOutput) IsSSO() bool { return c.sso } 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 // GetName is an extra field for the oauth handler to display the application
// name // 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 }

4
go.mod
View File

@ -7,6 +7,9 @@ require (
github.com/1f349/violet v0.0.9 github.com/1f349/violet v0.0.9
github.com/MrMelon54/exit-reload v0.0.1 github.com/MrMelon54/exit-reload v0.0.1
github.com/MrMelon54/pronouns v1.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-oauth2/oauth2/v4 v4.5.2
github.com/go-session/session v3.1.2+incompatible github.com/go-session/session v3.1.2+incompatible
github.com/google/subcommands v1.2.0 github.com/google/subcommands v1.2.0
@ -20,6 +23,7 @@ require (
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sec51/convert v1.0.2 // indirect github.com/sec51/convert v1.0.2 // indirect

31
go.sum
View File

@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 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 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= 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/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-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.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 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 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-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-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-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-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-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-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-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-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/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 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-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-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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-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-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-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-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-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.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.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.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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.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 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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-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-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= 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.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=

View File

@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>1f349 ID</title> <title>{{.ServiceName}}</title>
</head> </head>
<body> <body>
<header> <header>
<h1>1f349 ID</h1> <h1>{{.ServiceName}}</h1>
</header> </header>
<main> <main>
<form method="POST" action="/edit/otp"> <form method="POST" action="/edit/otp">

View File

@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>1f349 ID</title> <title>{{.ServiceName}}</title>
</head> </head>
<body> <body>
<header> <header>
<h1>1f349 ID</h1> <h1>{{.ServiceName}}</h1>
</header> </header>
<main> <main>
<div>Logged in as: {{.User.Name}} ({{.User.Sub}})</div> <div>Logged in as: {{.User.Name}} ({{.User.Sub}})</div>
@ -62,6 +62,9 @@
</div> </div>
<button type="submit">Edit</button> <button type="submit">Edit</button>
</form> </form>
<form method="GET" action="/">
<button type="submit">Cancel</button>
</form>
</div> </div>
</main> </main>
</body> </body>

View File

@ -1,16 +1,18 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>1f349 ID</title> <title>{{.ServiceName}}</title>
</head> </head>
<body> <body>
<header> <header>
<h1>1f349 ID</h1> <h1>{{.ServiceName}}</h1>
</header> </header>
<main> <main>
<div>Not logged in</div> <div>Not logged in</div>
<div> <div>
<button onclick="location.href='/login'">Login</button> <form method="GET" action="/login">
<button type="submit">Login</button>
</form>
</div> </div>
</main> </main>
</body> </body>

View File

@ -1,28 +1,44 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>1f349 ID</title> <title>{{.ServiceName}}</title>
</head> </head>
<body> <body>
<header> <header>
<h1>1f349 ID</h1> <h1>{{.ServiceName}}</h1>
</header> </header>
<main> <main>
<div>Logged in as: {{.User.Name}} ({{.User.Sub}})</div> <div>Logged in as: {{.User.Name}} ({{.User.Sub}})</div>
<div> <div>
<button onclick="location.href='/edit'">Edit Profile</button> <form method="GET" action="/edit">
<button type="submit">Edit Profile</button>
</form>
</div> </div>
<div> <div>
<button onclick="location.href='/edit/username'">Change username</button> <form method="GET" action="/edit/username">
<button type="submit">Change Username</button>
</form>
</div> </div>
<div> <div>
<button onclick="location.href='/edit/password'">Change password</button> <form method="GET" action="/edit/password">
<button type="submit">Change Password</button>
</form>
</div>
<div>
<form method="GET" action="/manage/apps">
<button type="submit">Manage Applications</button>
</form>
</div>
<div>
<form method="GET" action="/manage/users">
<button type="submit">Manage Users</button>
</form>
</div> </div>
<div> <div>
<form method="GET" action="/edit/otp"> <form method="GET" action="/edit/otp">
<label> <input type="radio" name="digits" value="6"/> 6 digits </label> <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="7"/> 7 digits</label>
<label> <input type="radio" name="digits" value="8"/> 8 digits </label> <label><input type="radio" name="digits" value="8"/> 8 digits</label>
<button type="submit">Change OTP</button> <button type="submit">Change OTP</button>
</form> </form>
</div> </div>

View File

@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>1f349 ID</title> <title>{{.ServiceName}}</title>
</head> </head>
<body> <body>
<header> <header>
<h1>1f349 ID</h1> <h1>{{.ServiceName}}</h1>
</header> </header>
<main> <main>
<form method="POST" action="/login/otp"> <form method="POST" action="/login/otp">

View File

@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>1f349 ID</title> <title>{{.ServiceName}}</title>
</head> </head>
<body> <body>
<header> <header>
<h1>1f349 ID</h1> <h1>{{.ServiceName}}</h1>
</header> </header>
<main> <main>
<form method="POST" action="/login"> <form method="POST" action="/login">

110
pages/manage-apps.go.html Normal file
View File

@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main>
<form method="GET" action="/">
<button type="submit">Home</button>
</form>
{{if .Edit}}
<h2>Edit Client Application</h2>
<form method="POST" action="/manage/apps">
<input type="hidden" name="action" value="edit"/>
<input type="hidden" name="offset" value="{{.Offset}}"/>
<input type="hidden" name="subject" value="{{.Edit.Sub}}"/>
<div>
<label>ID: {{.Edit.Sub}}</label>
</div>
<div>
<label for="field_name">Name:</label>
<input type="text" name="name" id="field_name" value="{{.Edit.Name}}" required/>
</div>
<div>
<label for="field_domain">Domain:</label>
<input type="text" name="domain" id="field_domain" value="{{.Edit.Domain}}" required/>
</div>
{{if .IsAdmin}}
<div>
<label for="field_sso">SSO: <input type="checkbox" name="sso" id="field_sso" {{if .Edit.SSO}}checked{{end}}/></label>
</div>
{{end}}
<div>
<label for="field_active">Active: <input type="checkbox" name="active" id="field_active" {{if .Edit.Active}}checked{{end}}/></label>
</div>
<button type="submit">Edit</button>
</form>
<form method="GET" action="/manage/apps">
<input type="hidden" name="offset" value="{{.Offset}}"/>
<button type="submit">Cancel</button>
</form>
{{else}}
<h2>Manage Client Applications</h2>
{{if eq (len .Apps) 0}}
<div>No client applications found</div>
{{else}}
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Domain</th>
<th>SSO</th>
<th>Active</th>
<th>Owner</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Apps}}
<tr>
<td>{{.Sub}}</td>
<td>{{.Name}}</td>
<td>{{.Domain}}</td>
<td>{{.SSO}}</td>
<td>{{.Active}}</td>
<td>{{.Owner}}</td>
<td>
<form method="GET" action="/manage/apps">
<input type="hidden" name="offset" value="{{$.Offset}}"/>
<input type="hidden" name="edit" value="{{.Sub}}"/>
<button type="submit">Edit</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<h2>Create Client Application</h2>
<form method="POST" action="/manage/apps">
<input type="hidden" name="action" value="create"/>
<input type="hidden" name="offset" value="{{.Offset}}"/>
<div>
<label for="field_name">Name:</label>
<input type="text" name="name" id="field_name" required/>
</div>
<div>
<label for="field_domain">Domain:</label>
<input type="text" name="domain" id="field_domain" required/>
</div>
{{if .IsAdmin}}
<div>
<label for="field_sso">SSO: <input type="checkbox" name="sso" id="field_sso"/></label>
</div>
{{end}}
<div>
<label for="field_active">Active: <input type="checkbox" name="active" id="field_active" checked/></label>
</div>
<button type="submit">Create</button>
</form>
{{end}}
</main>
</body>
</html>

147
pages/manage-users.go.html Normal file
View File

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main>
<form method="GET" action="/">
<button type="submit">Home</button>
</form>
{{if .Edit}}
<h2>Edit User</h2>
<form method="POST" action="/manage/users">
<input type="hidden" name="action" value="edit"/>
<input type="hidden" name="offset" value="{{.Offset}}"/>
<input type="hidden" name="subject" value="{{.Edit.Sub}}"/>
<div>
<label>ID: {{.Edit.Sub}}</label>
</div>
<div>
<label for="field_name">Name:</label>
<input type="text" name="name" id="field_name" value="{{.Edit.Name}}" required/>
</div>
<div>
<label for="field_username">Username:</label>
<input type="text" name="username" id="field_username" value="{{.Edit.Username}}" required/>
</div>
<div>
<label for="field_role">Role:</label>
<select name="role" id="field_role" required>
<option value="member" {{if (eq .Edit.Role 0)}}selected{{end}}>Member</option>
<option value="admin" {{if (eq .Edit.Role 1)}}selected{{end}}>Admin</option>
</select>
</div>
<div>
<label for="field_active">Active: <input type="checkbox" name="active" id="field_active" checked/></label>
</div>
<button type="submit">Edit</button>
</form>
<form method="GET" action="/manage/users">
<input type="hidden" name="offset" value="{{.Offset}}"/>
<button type="submit">Cancel</button>
</form>
{{else}}
<h2>Manage Users</h2>
{{if eq (len .Users) 0}}
<div>No users found, this is definitely a bug.</div>
{{else}}
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Username</th>
<th>Picture</th>
<th>Website</th>
<th>Email</th>
<th>Email Verified</th>
<th>Role</th>
<th>Last Updated</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.Sub}}</td>
<td>{{.Name}}</td>
<td>{{.Username}}</td>
<td>
{{if .Picture}}
<img src="{{.Picture}}" alt="{{.Name}} Profile Picture"/>
{{end}}
</td>
<td><a href="{{.Website}}" target="_blank">{{.Website}}</a></td>
<th>
{{if $.EmailShow}}
<span>{{.Email}}</span>
{{else}}
<span>{{emailHide .Email}}</span>
{{end}}
</th>
<th>{{.EmailVerified}}</th>
<th>{{.Role}}</th>
<th>{{.UpdatedAt}}</th>
<td>{{.Active}}</td>
<td>
{{if eq $.CurrentAdmin .Sub}}
<span></span>
{{else}}
<form method="GET" action="/manage/users">
<input type="hidden" name="offset" value="{{$.Offset}}"/>
<input type="hidden" name="edit" value="{{.Sub}}"/>
<button type="submit">Edit</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<form method="GET" action="/manage/users">
<input type="hidden" name="offset" value="{{.Offset}}"/>
{{if not .EmailShow}}
<input type="hidden" name="show-email"/>
{{end}}
<button type="submit">{{if .EmailShow}}Hide Email Addresses{{else}}Show email addresses{{end}}</button>
</form>
{{end}}
<h2>Create User</h2>
<form method="POST" action="/manage/users">
<input type="hidden" name="action" value="create"/>
<input type="hidden" name="offset" value="{{.Offset}}"/>
<div>
<label for="field_name">Name:</label>
<input type="text" name="name" id="field_name" required/>
</div>
<div>
<label for="field_username">Username:</label>
<input type="text" name="username" id="field_username" required/>
</div>
<div>
<label for="field_email">Email:</label>
<input type="text" name="email" id="field_email" required/>
</div>
<div>
<label for="field_role">Role:</label>
<select name="role" id="field_role" required>
<option value="member" selected>Member</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label for="field_active">Active: <input type="checkbox" name="active" id="field_active" checked/></label>
</div>
<button type="submit">Create</button>
</form>
{{end}}
</main>
</body>
</html>

View File

@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>1f349 ID</title> <title>{{.ServiceName}}</title>
</head> </head>
<body> <body>
<header> <header>
<h1>1f349 ID</h1> <h1>{{.ServiceName}}</h1>
</header> </header>
<main> <main>
<form method="POST" action="/authorize"> <form method="POST" action="/authorize">
@ -17,6 +17,12 @@
{{end}} {{end}}
</ul> </ul>
</div> </div>
{{if .HasOtp}}
<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>
{{end}}
<div> <div>
<input type="hidden" name="response_type" value="{{.ResponseType}}"/> <input type="hidden" name="response_type" value="{{.ResponseType}}"/>
<input type="hidden" name="response_mode" value="{{.ResponseMode}}"> <input type="hidden" name="response_mode" value="{{.ResponseMode}}">

View File

@ -16,7 +16,9 @@ var (
) )
func LoadPageTemplates() (err error) { 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 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) 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)
}

11
pages/pages_test.go Normal file
View File

@ -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"))
}

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.Title}}</title>
<style>
#app > h1 {
text-decoration: underline;
}
@media screen and (prefers-color-scheme: dark) {
:root {
color: #d2d2d2;
background-color: #1c1b22;
}
}
</style>
</head>
<body>
<div id="app">
<h1>{{.Title}}</h1>
<p>Hi {{.Name}},</p>
<p>Here is your email verification code: <span>{{.Code}}</span></p>
<p>{{.ServiceName}}</p>
</div>
</body>
</html>

19
password/secret.go Normal file
View File

@ -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
}

View File

@ -2,6 +2,7 @@ package server
import ( import (
"fmt" "fmt"
"github.com/1f349/tulip/database"
"github.com/go-session/session" "github.com/go-session/session"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
@ -37,8 +38,25 @@ func (u UserAuth) SaveSessionData() error {
return u.Session.Save() return u.Session.Save()
} }
func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle { func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle {
return h.OptionalAuthentication(false, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { 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() { if auth.IsGuest() {
redirectUrl := PrepareRedirectUrl("/login", req.URL) redirectUrl := PrepareRedirectUrl("/login", req.URL)
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) 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) { 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 { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return 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) ss, err := session.Start(req.Context(), rw, req)
if err != nil { if err != nil {
return UserAuth{}, fmt.Errorf("failed to start session") return UserAuth{}, fmt.Errorf("failed to start session")

View File

@ -1,11 +1,117 @@
package server package server
import ( import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"net/url"
"testing" "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) { func TestUserAuth_IsGuest(t *testing.T) {
var u UserAuth var u UserAuth
assert.True(t, u.IsGuest()) 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()}))
} }

View File

@ -31,6 +31,7 @@ func (h *HttpServer) EditGet(rw http.ResponseWriter, _ *http.Request, _ httprout
return return
} }
pages.RenderPageTemplate(rw, "edit", map[string]any{ pages.RenderPageTemplate(rw, "edit", map[string]any{
"ServiceName": h.serviceName,
"User": user, "User": user,
"Nonce": lNonce, "Nonce": lNonce,
"FieldPronoun": user.Pronouns.String(), "FieldPronoun": user.Pronouns.String(),

View File

@ -13,7 +13,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
rw.Header().Set("Content-Type", "text/html") rw.Header().Set("Content-Type", "text/html")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
if auth.IsGuest() { if auth.IsGuest() {
pages.RenderPageTemplate(rw, "index-guest", nil) pages.RenderPageTemplate(rw, "index-guest", map[string]any{
"ServiceName": h.serviceName,
})
return return
} }
@ -35,8 +37,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
return return
} }
pages.RenderPageTemplate(rw, "index", map[string]any{ pages.RenderPageTemplate(rw, "index", map[string]any{
"Auth": auth, "ServiceName": h.serviceName,
"User": userWithName, "Auth": auth,
"Nonce": lNonce, "User": userWithName,
"Nonce": lNonce,
}) })
} }

View File

@ -21,7 +21,8 @@ func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httpr
rw.Header().Set("Content-Type", "text/html") rw.Header().Set("Content-Type", "text/html")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
pages.RenderPageTemplate(rw, "login", map[string]any{ pages.RenderPageTemplate(rw, "login", map[string]any{
"Redirect": req.URL.Query().Get("redirect"), "ServiceName": h.serviceName,
"Redirect": req.URL.Query().Get("redirect"),
}) })
} }

113
server/manage-apps.go Normal file
View File

@ -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)
}

132
server/manage-users.go Normal file
View File

@ -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")
}

View File

@ -78,16 +78,24 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request
} }
var user *database.User var user *database.User
if h.DbTx(rw, func(tx *database.Tx) error { var hasOtp bool
var err error if h.DbTx(rw, func(tx *database.Tx) (err error) {
user, err = tx.GetUserDisplayName(auth.Data.ID) 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 return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{ pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{
"ServiceName": h.serviceName,
"AppName": appName, "AppName": appName,
"AppDomain": appDomain, "AppDomain": appDomain,
"User": user, "User": user,
@ -99,27 +107,35 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request
"State": form.Get("state"), "State": form.Get("state"),
"Scope": form.Get("scope"), "Scope": form.Get("scope"),
"Nonce": form.Get("nonce"), "Nonce": form.Get("nonce"),
"HasOtp": hasOtp,
}) })
return return
} }
// redirect with an error if the action is not authorize if !isSSO {
if form.Get("oauth_action") != "authorize" { otpInput := req.FormValue("code")
redirectUri, err := url.Parse(form.Get("redirect_uri")) if h.fetchAndValidateOtp(rw, auth.Data.ID, otpInput) {
if err != nil {
http.Error(rw, "400 Bad Request: Invalid redirect URI", http.StatusBadRequest)
return return
} }
q := redirectUri.Query() }
q.Set("error", "user_cancelled")
redirectUri.RawQuery = q.Encode() // redirect with an error if the action is not authorize
http.Redirect(rw, req, redirectUri.String(), http.StatusFound) if form.Get("oauth_action") == "authorize" || isSSO {
if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
}
return return
} }
if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil { parsedRedirect, err := url.Parse(redirectUri)
http.Error(rw, err.Error(), http.StatusBadRequest) 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) { 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 return "", err
} }
auth, err := h.internalAuthenticationHandler(rw, req) auth, err := internalAuthenticationHandler(rw, req)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@ -6,6 +6,7 @@ import (
"github.com/1f349/tulip/database" "github.com/1f349/tulip/database"
"github.com/1f349/tulip/pages" "github.com/1f349/tulip/pages"
"github.com/1f349/twofactor" "github.com/1f349/twofactor"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"html/template" "html/template"
"net/http" "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{ 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") otpInput := req.FormValue("code")
if h.fetchAndValidateOtp(rw, auth.Data.ID, otpInput) {
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 return
} }
@ -53,6 +44,39 @@ func (h *HttpServer) LoginOtpPost(rw http.ResponseWriter, req *http.Request, _ h
h.SafeRedirect(rw, req) 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) { func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
var digits = 0 var digits = 0
switch req.URL.Query().Get("digits") { switch req.URL.Query().Get("digits") {
@ -126,8 +150,9 @@ func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ htt
// render page // render page
pages.RenderPageTemplate(rw, "edit-otp", map[string]any{ pages.RenderPageTemplate(rw, "edit-otp", map[string]any{
"OtpQr": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(otpQr)), "ServiceName": h.serviceName,
"OtpUrl": otpUrl, "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 return
} }
http.Redirect(rw, req, "/edit", http.StatusFound) http.Redirect(rw, req, "/", http.StatusFound)
} }

View File

@ -24,13 +24,14 @@ import (
var errMissingRequiredScope = errors.New("missing required scope") var errMissingRequiredScope = errors.New("missing required scope")
type HttpServer struct { type HttpServer struct {
r *httprouter.Router r *httprouter.Router
oauthSrv *server.Server oauthSrv *server.Server
oauthMgr *manage.Manager oauthMgr *manage.Manager
db *database.DB db *database.DB
domain string domain string
privKey []byte privKey []byte
otpIssuer string otpIssuer string
serviceName string
} }
func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) { 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) 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() 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"}) 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() oauthManager := manage.NewDefaultManager()
oauthSrv := server.NewServer(server.NewConfig(), oauthManager) oauthSrv := server.NewServer(server.NewConfig(), oauthManager)
hs := &HttpServer{ hs := &HttpServer{
r: httprouter.New(), r: httprouter.New(),
oauthSrv: oauthSrv, oauthSrv: oauthSrv,
oauthMgr: oauthManager, oauthMgr: oauthManager,
db: db, db: db,
domain: domain, domain: domain,
privKey: privKey, privKey: privKey,
otpIssuer: otpIssuer, otpIssuer: otpIssuer,
serviceName: serviceName,
} }
oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
@ -113,8 +115,8 @@ func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey []
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(openIdBytes) _, _ = rw.Write(openIdBytes)
}) })
r.GET("/", hs.OptionalAuthentication(false, hs.Home)) r.GET("/", OptionalAuthentication(false, hs.Home))
r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { r.POST("/logout", RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
lNonce, ok := auth.Session.Get("action-nonce") lNonce, ok := auth.Session.Get("action-nonce")
if !ok { if !ok {
http.Error(rw, "Missing nonce", http.StatusInternalServerError) 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) http.Error(rw, "Logout failed", http.StatusInternalServerError)
})) }))
r.GET("/login", hs.OptionalAuthentication(false, hs.LoginGet))
r.POST("/login", hs.OptionalAuthentication(false, hs.LoginPost)) // login steps
r.GET("/login/otp", hs.OptionalAuthentication(true, hs.LoginOtpGet)) r.GET("/login", OptionalAuthentication(false, hs.LoginGet))
r.POST("/login/otp", hs.OptionalAuthentication(true, hs.LoginOtpPost)) r.POST("/login", OptionalAuthentication(false, hs.LoginPost))
r.GET("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint)) r.GET("/login/otp", OptionalAuthentication(true, hs.LoginOtpGet))
r.POST("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint)) 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) { r.POST("/token", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
if err := oauthSrv.HandleTokenRequest(rw, req); err != nil { if err := oauthSrv.HandleTokenRequest(rw, req); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) 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) { r.GET("/userinfo", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
token, err := oauthSrv.ValidationBearerToken(req) token, err := oauthSrv.ValidationBearerToken(req)
if err != nil { if err != nil {