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

@ -4,4 +4,5 @@ type startUpConfig struct {
Listen string `json:"listen"`
Domain string `json:"domain"`
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)
}
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)
}

View File

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

View File

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

View File

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

4
go.mod
View File

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

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.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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 (
"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")

View File

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

View File

@ -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(),

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.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,6 +37,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
return
}
pages.RenderPageTemplate(rw, "index", map[string]any{
"ServiceName": h.serviceName,
"Auth": auth,
"User": userWithName,
"Nonce": lNonce,

View File

@ -21,6 +21,7 @@ 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{
"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
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
}
if !isSSO {
otpInput := req.FormValue("code")
if h.fetchAndValidateOtp(rw, auth.Data.ID, otpInput) {
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 form.Get("oauth_action") == "authorize" || isSSO {
if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
}
return
}
parsedRedirect, err := url.Parse(redirectUri)
if err != nil {
http.Error(rw, "400 Bad Request: Invalid redirect URI", http.StatusBadRequest)
return
}
q := redirectUri.Query()
q := parsedRedirect.Query()
q.Set("error", "user_cancelled")
redirectUri.RawQuery = q.Encode()
http.Redirect(rw, req, redirectUri.String(), http.StatusFound)
return
}
if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
}
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
}

View File

@ -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,6 +19,7 @@ func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ ht
}
pages.RenderPageTemplate(rw, "login-otp", map[string]any{
"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,6 +150,7 @@ func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ htt
// render page
pages.RenderPageTemplate(rw, "edit-otp", map[string]any{
"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)
}

View File

@ -31,6 +31,7 @@ type HttpServer struct {
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"})
@ -74,6 +75,7 @@ func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey []
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 {