mirror of
https://github.com/1f349/tulip.git
synced 2024-12-22 08:14:13 +00:00
Login, edit, oauth, otp and management page changes
This commit is contained in:
parent
b9d456f2fa
commit
fff03ac6ad
@ -1,7 +1,8 @@
|
||||
package main
|
||||
|
||||
type startUpConfig struct {
|
||||
Listen string `json:"listen"`
|
||||
Domain string `json:"domain"`
|
||||
OtpIssuer string `json:"otp_issuer"`
|
||||
Listen string `json:"listen"`
|
||||
Domain string `json:"domain"`
|
||||
OtpIssuer string `json:"otp_issuer"`
|
||||
ServiceName string `json:"service_name"`
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
123
database/tx.go
123
database/tx.go
@ -37,12 +37,12 @@ func (t *Tx) HasUser() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tx) InsertUser(name, un, pw, email string) error {
|
||||
func (t *Tx) InsertUser(name, un, pw, email string, role UserRole, active bool) error {
|
||||
pwHash, err := password.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, uuid.NewString(), name, un, pwHash, email, updatedAt())
|
||||
_, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, role, updated_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, uuid.NewString(), name, un, pwHash, email, role, updatedAt(), active)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -66,6 +66,13 @@ func (t *Tx) GetUserDisplayName(sub uuid.UUID) (*User, error) {
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (t *Tx) GetUserRole(sub uuid.UUID) (UserRole, error) {
|
||||
var r UserRole
|
||||
row := t.tx.QueryRow(`SELECT role FROM users WHERE subject = ? LIMIT 1`, sub.String())
|
||||
err := row.Scan(&r)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (t *Tx) GetUser(sub uuid.UUID) (*User, error) {
|
||||
var u User
|
||||
row := t.tx.QueryRow(`SELECT name, username, password, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, updated_at, active FROM users WHERE subject = ?`, sub.String())
|
||||
@ -177,33 +184,111 @@ func (t *Tx) GetTwoFactor(sub uuid.UUID, issuer string) (*twofactor.Totp, error)
|
||||
return twofactor.TOTPFromBytes(u, issuer)
|
||||
}
|
||||
|
||||
func (t *Tx) HasTwoFactor(sub uuid.UUID) (bool, error) {
|
||||
var hasOtp bool
|
||||
row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM otp WHERE otp.subject = ?)`, sub)
|
||||
err := row.Scan(&hasOtp)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hasOtp, row.Err()
|
||||
}
|
||||
|
||||
func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) {
|
||||
var u clientInfoDbOutput
|
||||
var active bool
|
||||
var u ClientInfoDbOutput
|
||||
row := t.tx.QueryRow(`SELECT secret, name, domain, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub)
|
||||
err := row.Scan(&u.secret, &u.name, &u.domain, &u.sso, &active)
|
||||
u.sub = sub
|
||||
if !active {
|
||||
err := row.Scan(&u.Secret, &u.Name, &u.Domain, &u.SSO, &u.Active)
|
||||
u.Owner = sub
|
||||
if !u.Active {
|
||||
return nil, fmt.Errorf("client is not active")
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
type clientInfoDbOutput struct {
|
||||
sub, name, secret, domain string
|
||||
sso bool
|
||||
func (t *Tx) GetAppList(offset int) ([]ClientInfoDbOutput, error) {
|
||||
var u []ClientInfoDbOutput
|
||||
row, err := t.tx.Query(`SELECT subject, name, domain, owner, sso, active FROM client_store LIMIT 25 OFFSET ?`, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer row.Close()
|
||||
for row.Next() {
|
||||
var a ClientInfoDbOutput
|
||||
err := row.Scan(&a.Sub, &a.Name, &a.Domain, &a.Owner, &a.SSO, &a.Active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u = append(u, a)
|
||||
}
|
||||
return u, row.Err()
|
||||
}
|
||||
|
||||
func (c *clientInfoDbOutput) GetID() string { return c.sub }
|
||||
func (c *clientInfoDbOutput) GetSecret() string { return c.secret }
|
||||
func (c *clientInfoDbOutput) GetDomain() string { return c.domain }
|
||||
func (c *clientInfoDbOutput) IsPublic() bool { return false }
|
||||
func (c *clientInfoDbOutput) GetUserID() string { return "" }
|
||||
func (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner uuid.UUID) error {
|
||||
u := uuid.New()
|
||||
secret, err := password.GenerateApiSecret(70)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = t.tx.Exec(`INSERT INTO client_store (subject, name, secret, domain, owner, sso, active) VALUES (?, ?, ?, ?, ?, ?, ?)`, u.String(), name, secret, domain, owner.String(), sso, active)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsSSO is an extra field for the oauth handler to skip the user input stage
|
||||
// this is for trusted applications to get permissions without asking the user
|
||||
func (c *clientInfoDbOutput) IsSSO() bool { return c.sso }
|
||||
func (t *Tx) UpdateClientApp(subject uuid.UUID, name, domain string, sso, active bool) error {
|
||||
_, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ?`, name, domain, sso, active, subject.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Tx) ResetClientAppSecret(subject uuid.UUID, secret string) error {
|
||||
secret, err := password.GenerateApiSecret(70)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = t.tx.Exec(`UPDATE client_store SET secret = ? WHERE subject = ?`, secret, subject.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Tx) GetUserList(offset int) ([]User, error) {
|
||||
var u []User
|
||||
row, err := t.tx.Query(`SELECT subject, name, username, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, role, updated_at, active FROM users LIMIT 25 OFFSET ?`, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for row.Next() {
|
||||
var a User
|
||||
err := row.Scan(&a.Sub, &a.Name, &a.Username, &a.Picture, &a.Website, &a.Email, &a.EmailVerified, &a.Pronouns, &a.Birthdate, &a.ZoneInfo, &a.Locale, &a.Role, &a.UpdatedAt, &a.Active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u = append(u, a)
|
||||
}
|
||||
return u, row.Err()
|
||||
}
|
||||
|
||||
func (t *Tx) UpdateUser(subject uuid.UUID, role UserRole, active bool) error {
|
||||
_, err := t.tx.Exec(`UPDATE users SET active = ?, role = ? WHERE subject = ?`, active, role, subject)
|
||||
return err
|
||||
}
|
||||
|
||||
type ClientInfoDbOutput struct {
|
||||
Sub, Name, Secret, Domain, Owner string
|
||||
SSO, Active bool
|
||||
}
|
||||
|
||||
var _ oauth2.ClientInfo = &ClientInfoDbOutput{}
|
||||
|
||||
func (c *ClientInfoDbOutput) GetID() string { return c.Sub }
|
||||
func (c *ClientInfoDbOutput) GetSecret() string { return c.Secret }
|
||||
func (c *ClientInfoDbOutput) GetDomain() string { return c.Domain }
|
||||
func (c *ClientInfoDbOutput) IsPublic() bool { return false }
|
||||
func (c *ClientInfoDbOutput) GetUserID() string { return c.Owner }
|
||||
|
||||
// GetName is an extra field for the oauth handler to display the application
|
||||
// name
|
||||
func (c *clientInfoDbOutput) GetName() string { return c.name }
|
||||
func (c *ClientInfoDbOutput) GetName() string { return c.Name }
|
||||
|
||||
// IsSSO is an extra field for the oauth handler to skip the user input stage
|
||||
// this is for trusted applications to get permissions without asking the user
|
||||
func (c *ClientInfoDbOutput) IsSSO() bool { return c.SSO }
|
||||
|
||||
// IsActive is an extra field for the app manager to get the active state
|
||||
func (c *ClientInfoDbOutput) IsActive() bool { return c.Active }
|
||||
|
4
go.mod
4
go.mod
@ -7,6 +7,9 @@ require (
|
||||
github.com/1f349/violet v0.0.9
|
||||
github.com/MrMelon54/exit-reload v0.0.1
|
||||
github.com/MrMelon54/pronouns v1.0.1
|
||||
github.com/emersion/go-message v0.17.0
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
||||
github.com/emersion/go-smtp v0.18.1
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.2
|
||||
github.com/go-session/session v3.1.2+incompatible
|
||||
github.com/google/subcommands v1.2.0
|
||||
@ -20,6 +23,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sec51/convert v1.0.2 // indirect
|
||||
|
31
go.sum
31
go.sum
@ -14,6 +14,15 @@ github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-message v0.17.0 h1:NIdSKHiVUx4qKqdd0HyJFD41cW8iFguM2XJnRZWQH04=
|
||||
github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.18.1 h1:4DFV0jxKhq0Gqt/Br3BRHyKZy5TStk6NIMHAx6GE/LA=
|
||||
github.com/emersion/go-smtp v0.18.1/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
@ -144,22 +153,33 @@ github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FB
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@ -174,16 +194,27 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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
110
pages/manage-apps.go.html
Normal 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
147
pages/manage-users.go.html
Normal 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>
|
@ -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}}">
|
||||
|
@ -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
11
pages/pages_test.go
Normal 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"))
|
||||
}
|
26
pages/verification-code.go.html
Normal file
26
pages/verification-code.go.html
Normal 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
19
password/secret.go
Normal 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
|
||||
}
|
@ -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")
|
||||
|
@ -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()}))
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -13,7 +13,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
if auth.IsGuest() {
|
||||
pages.RenderPageTemplate(rw, "index-guest", nil)
|
||||
pages.RenderPageTemplate(rw, "index-guest", map[string]any{
|
||||
"ServiceName": h.serviceName,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@ -35,8 +37,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
|
||||
return
|
||||
}
|
||||
pages.RenderPageTemplate(rw, "index", map[string]any{
|
||||
"Auth": auth,
|
||||
"User": userWithName,
|
||||
"Nonce": lNonce,
|
||||
"ServiceName": h.serviceName,
|
||||
"Auth": auth,
|
||||
"User": userWithName,
|
||||
"Nonce": lNonce,
|
||||
})
|
||||
}
|
||||
|
@ -21,7 +21,8 @@ func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httpr
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
pages.RenderPageTemplate(rw, "login", map[string]any{
|
||||
"Redirect": req.URL.Query().Get("redirect"),
|
||||
"ServiceName": h.serviceName,
|
||||
"Redirect": req.URL.Query().Get("redirect"),
|
||||
})
|
||||
}
|
||||
|
||||
|
113
server/manage-apps.go
Normal file
113
server/manage-apps.go
Normal 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
132
server/manage-users.go
Normal 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")
|
||||
}
|
@ -78,16 +78,24 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request
|
||||
}
|
||||
|
||||
var user *database.User
|
||||
if h.DbTx(rw, func(tx *database.Tx) error {
|
||||
var err error
|
||||
var hasOtp bool
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
user, err = tx.GetUserDisplayName(auth.Data.ID)
|
||||
return err
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hasOtp, err = tx.HasTwoFactor(auth.Data.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{
|
||||
"ServiceName": h.serviceName,
|
||||
"AppName": appName,
|
||||
"AppDomain": appDomain,
|
||||
"User": user,
|
||||
@ -99,27 +107,35 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request
|
||||
"State": form.Get("state"),
|
||||
"Scope": form.Get("scope"),
|
||||
"Nonce": form.Get("nonce"),
|
||||
"HasOtp": hasOtp,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// redirect with an error if the action is not authorize
|
||||
if form.Get("oauth_action") != "authorize" {
|
||||
redirectUri, err := url.Parse(form.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
http.Error(rw, "400 Bad Request: Invalid redirect URI", http.StatusBadRequest)
|
||||
if !isSSO {
|
||||
otpInput := req.FormValue("code")
|
||||
if h.fetchAndValidateOtp(rw, auth.Data.ID, otpInput) {
|
||||
return
|
||||
}
|
||||
q := redirectUri.Query()
|
||||
q.Set("error", "user_cancelled")
|
||||
redirectUri.RawQuery = q.Encode()
|
||||
http.Redirect(rw, req, redirectUri.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// redirect with an error if the action is not authorize
|
||||
if form.Get("oauth_action") == "authorize" || isSSO {
|
||||
if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||
parsedRedirect, err := url.Parse(redirectUri)
|
||||
if err != nil {
|
||||
http.Error(rw, "400 Bad Request: Invalid redirect URI", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
q := parsedRedirect.Query()
|
||||
q.Set("error", "user_cancelled")
|
||||
parsedRedirect.RawQuery = q.Encode()
|
||||
http.Redirect(rw, req, parsedRedirect.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Request) (string, error) {
|
||||
@ -128,7 +144,7 @@ func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re
|
||||
return "", err
|
||||
}
|
||||
|
||||
auth, err := h.internalAuthenticationHandler(rw, req)
|
||||
auth, err := internalAuthenticationHandler(rw, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/twofactor"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"html/template"
|
||||
"net/http"
|
||||
@ -18,7 +19,8 @@ func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ ht
|
||||
}
|
||||
|
||||
pages.RenderPageTemplate(rw, "login-otp", map[string]any{
|
||||
"Redirect": req.URL.Query().Get("redirect"),
|
||||
"ServiceName": h.serviceName,
|
||||
"Redirect": req.URL.Query().Get("redirect"),
|
||||
})
|
||||
}
|
||||
|
||||
@ -29,18 +31,7 @@ func (h *HttpServer) LoginOtpPost(rw http.ResponseWriter, req *http.Request, _ h
|
||||
}
|
||||
|
||||
otpInput := req.FormValue("code")
|
||||
|
||||
var otp *twofactor.Totp
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
otp, err = tx.GetTwoFactor(auth.Data.ID, h.otpIssuer)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
err := otp.Validate(otpInput)
|
||||
if err != nil {
|
||||
|
||||
if h.fetchAndValidateOtp(rw, auth.Data.ID, otpInput) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -53,6 +44,39 @@ func (h *HttpServer) LoginOtpPost(rw http.ResponseWriter, req *http.Request, _ h
|
||||
h.SafeRedirect(rw, req)
|
||||
}
|
||||
|
||||
func (h *HttpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub uuid.UUID, code string) bool {
|
||||
var hasOtp bool
|
||||
var otp *twofactor.Totp
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
hasOtp, err = tx.HasTwoFactor(sub)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if hasOtp {
|
||||
otp, err = tx.GetTwoFactor(sub, h.otpIssuer)
|
||||
}
|
||||
return
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
|
||||
if hasOtp {
|
||||
defer func() {
|
||||
h.DbTx(rw, func(tx *database.Tx) error {
|
||||
return tx.SetTwoFactor(sub, otp)
|
||||
})
|
||||
}()
|
||||
|
||||
err := otp.Validate(code)
|
||||
if err != nil {
|
||||
http.Error(rw, "400 Bad Request: Invalid OTP code", http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
var digits = 0
|
||||
switch req.URL.Query().Get("digits") {
|
||||
@ -126,8 +150,9 @@ func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ htt
|
||||
|
||||
// render page
|
||||
pages.RenderPageTemplate(rw, "edit-otp", map[string]any{
|
||||
"OtpQr": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(otpQr)),
|
||||
"OtpUrl": otpUrl,
|
||||
"ServiceName": h.serviceName,
|
||||
"OtpQr": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(otpQr)),
|
||||
"OtpUrl": otpUrl,
|
||||
})
|
||||
}
|
||||
|
||||
@ -155,5 +180,5 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(rw, req, "/edit", http.StatusFound)
|
||||
http.Redirect(rw, req, "/", http.StatusFound)
|
||||
}
|
||||
|
@ -24,13 +24,14 @@ import (
|
||||
var errMissingRequiredScope = errors.New("missing required scope")
|
||||
|
||||
type HttpServer struct {
|
||||
r *httprouter.Router
|
||||
oauthSrv *server.Server
|
||||
oauthMgr *manage.Manager
|
||||
db *database.DB
|
||||
domain string
|
||||
privKey []byte
|
||||
otpIssuer string
|
||||
r *httprouter.Router
|
||||
oauthSrv *server.Server
|
||||
oauthMgr *manage.Manager
|
||||
db *database.DB
|
||||
domain string
|
||||
privKey []byte
|
||||
otpIssuer string
|
||||
serviceName string
|
||||
}
|
||||
|
||||
func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
|
||||
@ -51,7 +52,7 @@ func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(rw, req, parse.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey []byte) *http.Server {
|
||||
func NewHttpServer(listen, domain, otpIssuer, serviceName string, db *database.DB, privKey []byte) *http.Server {
|
||||
r := httprouter.New()
|
||||
|
||||
openIdConf := openid.GenConfig(domain, []string{"openid", "email"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"})
|
||||
@ -67,13 +68,14 @@ func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey []
|
||||
oauthManager := manage.NewDefaultManager()
|
||||
oauthSrv := server.NewServer(server.NewConfig(), oauthManager)
|
||||
hs := &HttpServer{
|
||||
r: httprouter.New(),
|
||||
oauthSrv: oauthSrv,
|
||||
oauthMgr: oauthManager,
|
||||
db: db,
|
||||
domain: domain,
|
||||
privKey: privKey,
|
||||
otpIssuer: otpIssuer,
|
||||
r: httprouter.New(),
|
||||
oauthSrv: oauthSrv,
|
||||
oauthMgr: oauthManager,
|
||||
db: db,
|
||||
domain: domain,
|
||||
privKey: privKey,
|
||||
otpIssuer: otpIssuer,
|
||||
serviceName: serviceName,
|
||||
}
|
||||
|
||||
oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
|
||||
@ -113,8 +115,8 @@ func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey []
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_, _ = rw.Write(openIdBytes)
|
||||
})
|
||||
r.GET("/", hs.OptionalAuthentication(false, hs.Home))
|
||||
r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
|
||||
r.GET("/", OptionalAuthentication(false, hs.Home))
|
||||
r.POST("/logout", RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
|
||||
lNonce, ok := auth.Session.Get("action-nonce")
|
||||
if !ok {
|
||||
http.Error(rw, "Missing nonce", http.StatusInternalServerError)
|
||||
@ -131,21 +133,33 @@ func NewHttpServer(listen, domain, otpIssuer string, db *database.DB, privKey []
|
||||
}
|
||||
http.Error(rw, "Logout failed", http.StatusInternalServerError)
|
||||
}))
|
||||
r.GET("/login", hs.OptionalAuthentication(false, hs.LoginGet))
|
||||
r.POST("/login", hs.OptionalAuthentication(false, hs.LoginPost))
|
||||
r.GET("/login/otp", hs.OptionalAuthentication(true, hs.LoginOtpGet))
|
||||
r.POST("/login/otp", hs.OptionalAuthentication(true, hs.LoginOtpPost))
|
||||
r.GET("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint))
|
||||
r.POST("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint))
|
||||
|
||||
// login steps
|
||||
r.GET("/login", OptionalAuthentication(false, hs.LoginGet))
|
||||
r.POST("/login", OptionalAuthentication(false, hs.LoginPost))
|
||||
r.GET("/login/otp", OptionalAuthentication(true, hs.LoginOtpGet))
|
||||
r.POST("/login/otp", OptionalAuthentication(true, hs.LoginOtpPost))
|
||||
|
||||
// edit profile pages
|
||||
r.GET("/edit", RequireAuthentication(hs.EditGet))
|
||||
r.POST("/edit", RequireAuthentication(hs.EditPost))
|
||||
r.GET("/edit/otp", RequireAuthentication(hs.EditOtpGet))
|
||||
r.POST("/edit/otp", RequireAuthentication(hs.EditOtpPost))
|
||||
|
||||
// management pages
|
||||
r.GET("/manage/apps", hs.RequireAdminAuthentication(hs.ManageAppsGet))
|
||||
r.POST("/manage/apps", hs.RequireAdminAuthentication(hs.ManageAppsPost))
|
||||
r.GET("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersGet))
|
||||
r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost))
|
||||
|
||||
// oauth pages
|
||||
r.GET("/authorize", RequireAuthentication(hs.authorizeEndpoint))
|
||||
r.POST("/authorize", RequireAuthentication(hs.authorizeEndpoint))
|
||||
r.POST("/token", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
if err := oauthSrv.HandleTokenRequest(rw, req); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
r.GET("/edit", hs.RequireAuthentication(hs.EditGet))
|
||||
r.POST("/edit", hs.RequireAuthentication(hs.EditPost))
|
||||
r.GET("/edit/otp", hs.RequireAuthentication(hs.EditOtpGet))
|
||||
r.POST("/edit/otp", hs.RequireAuthentication(hs.EditOtpPost))
|
||||
r.GET("/userinfo", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
token, err := oauthSrv.ValidationBearerToken(req)
|
||||
if err != nil {
|
||||
|
Loading…
Reference in New Issue
Block a user