mirror of
https://github.com/1f349/lavender.git
synced 2024-09-08 03:06:37 +01:00
Add oauth support
This commit is contained in:
parent
d230acccaa
commit
a05b2d983e
26
client-store/client-store.go
Normal file
26
client-store/client-store.go
Normal file
@ -0,0 +1,26 @@
|
||||
package client_store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/1f349/lavender/database"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
)
|
||||
|
||||
type ClientStore struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
var _ oauth2.ClientStore = &ClientStore{}
|
||||
|
||||
func New(db *database.DB) *ClientStore {
|
||||
return &ClientStore{db: db}
|
||||
}
|
||||
|
||||
func (c *ClientStore) GetByID(ctx context.Context, id string) (oauth2.ClientInfo, error) {
|
||||
tx, err := c.db.BeginCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
return tx.GetClientInfo(id)
|
||||
}
|
@ -9,12 +9,14 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"github.com/1f349/lavender/database"
|
||||
"github.com/1f349/lavender/pages"
|
||||
"github.com/1f349/lavender/server"
|
||||
"github.com/1f349/lavender/server/pages"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/violet/utils"
|
||||
exitReload "github.com/MrMelon54/exit-reload"
|
||||
"github.com/google/subcommands"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -44,64 +46,56 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{})
|
||||
return subcommands.ExitUsageError
|
||||
}
|
||||
|
||||
var conf server.Conf
|
||||
err := loadConfig(s.configPath, &conf)
|
||||
openConf, err := os.Open(s.configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Println("[Lavender] Error: missing config file")
|
||||
log.Println("[Tulip] Error: missing config file")
|
||||
} else {
|
||||
log.Println("[Lavender] Error: loading config file: ", err)
|
||||
log.Println("[Tulip] Error: open config file: ", err)
|
||||
}
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
var config server.Conf
|
||||
err = json.NewDecoder(openConf).Decode(&config)
|
||||
if err != nil {
|
||||
log.Println("[Tulip] Error: invalid config file: ", err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
configPathAbs, err := filepath.Abs(s.configPath)
|
||||
if err != nil {
|
||||
log.Fatal("[Lavender] Failed to get absolute config path")
|
||||
}
|
||||
wd := filepath.Dir(configPathAbs)
|
||||
|
||||
mSign, err := mjwt.NewMJwtSignerFromFileOrCreate(conf.Issuer, filepath.Join(wd, "lavender.private.key"), rand.Reader, 4096)
|
||||
signingKey, err := mjwt.NewMJwtSignerFromFileOrCreate(config.Issuer, filepath.Join(wd, "lavender.private.key"), rand.Reader, 4096)
|
||||
if err != nil {
|
||||
log.Fatal("[Lavender] Failed to load or create MJWT signer:", err)
|
||||
}
|
||||
saveMjwtPubKey(mSign, wd)
|
||||
saveMjwtPubKey(signingKey, wd)
|
||||
|
||||
db, err := database.Open(filepath.Join(wd, "lavender.db.sqlite"))
|
||||
if err != nil {
|
||||
log.Fatal("[Lavender] Failed to open database:", err)
|
||||
}
|
||||
|
||||
if err := pages.LoadPages(wd); err != nil {
|
||||
log.Fatal("[Lavender] Failed to load page templates:", err)
|
||||
}
|
||||
|
||||
srv := server.NewHttpServer(conf, mSign)
|
||||
log.Printf("[Lavender] Starting HTTP server on '%s'\n", srv.Server.Addr)
|
||||
go utils.RunBackgroundHttp("HTTP", srv.Server)
|
||||
srv := server.NewHttpServer(config, db, signingKey)
|
||||
log.Printf("[Lavender] Starting HTTP server on '%s'\n", srv.Addr)
|
||||
go utils.RunBackgroundHttp("HTTP", srv)
|
||||
|
||||
exitReload.ExitReload("Lavender", func() {
|
||||
var conf server.Conf
|
||||
err := loadConfig(s.configPath, &conf)
|
||||
if err != nil {
|
||||
log.Println("[Lavender] Failed to read config:", err)
|
||||
}
|
||||
err = srv.UpdateConfig(conf)
|
||||
if err != nil {
|
||||
log.Println("[Lavender] Failed to reload config:", err)
|
||||
}
|
||||
}, func() {
|
||||
exitReload.ExitReload("Lavender", func() {}, func() {
|
||||
// stop http server
|
||||
_ = srv.Server.Close()
|
||||
_ = srv.Close()
|
||||
})
|
||||
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
func loadConfig(configPath string, conf *server.Conf) error {
|
||||
openConf, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.NewDecoder(openConf).Decode(conf)
|
||||
}
|
||||
|
||||
func saveMjwtPubKey(mSign mjwt.Signer, wd string) {
|
||||
pubKey := x509.MarshalPKCS1PublicKey(mSign.PublicKey())
|
||||
b := new(bytes.Buffer)
|
||||
|
39
database/db-types.go
Normal file
39
database/db-types.go
Normal file
@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Roles string `json:"roles"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
// 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 }
|
41
database/db.go
Normal file
41
database/db.go
Normal file
@ -0,0 +1,41 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed init.sql
|
||||
var initSql string
|
||||
|
||||
type DB struct{ db *sql.DB }
|
||||
|
||||
func Open(p string) (*DB, error) {
|
||||
db, err := sql.Open("sqlite3", p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = db.Exec(initSql)
|
||||
return &DB{db: db}, err
|
||||
}
|
||||
|
||||
func (d *DB) Begin() (*Tx, error) {
|
||||
begin, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Tx{begin}, err
|
||||
}
|
||||
|
||||
func (d *DB) BeginCtx(ctx context.Context) (*Tx, error) {
|
||||
begin, err := d.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Tx{begin}, err
|
||||
}
|
||||
|
||||
func (d *DB) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
5
database/db_test.go
Normal file
5
database/db_test.go
Normal file
@ -0,0 +1,5 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
21
database/init.sql
Normal file
21
database/init.sql
Normal file
@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
subject TEXT PRIMARY KEY UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
email_verified INTEGER DEFAULT 0 NOT NULL,
|
||||
roles TEXT NOT NULL,
|
||||
updated_at DATETIME,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_store
|
||||
(
|
||||
subject TEXT PRIMARY KEY 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,
|
||||
FOREIGN KEY (owner) REFERENCES users (subject)
|
||||
);
|
145
database/tx.go
Normal file
145
database/tx.go
Normal file
@ -0,0 +1,145 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/1f349/lavender/password"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
func updatedAt() string {
|
||||
return time.Now().UTC().Format(time.DateTime)
|
||||
}
|
||||
|
||||
type Tx struct{ tx *sql.Tx }
|
||||
|
||||
func (t *Tx) Commit() error {
|
||||
return t.tx.Commit()
|
||||
}
|
||||
|
||||
func (t *Tx) Rollback() {
|
||||
_ = t.tx.Rollback()
|
||||
}
|
||||
|
||||
func (t *Tx) HasUser() error {
|
||||
var exists bool
|
||||
row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users)`)
|
||||
err := row.Scan(&exists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tx) InsertUser(subject, email string, verifyEmail bool, roles string, active bool) error {
|
||||
_, err := t.tx.Exec(`INSERT INTO users (subject, email, email_verified, roles, updated_at, active) VALUES (?, ?, ?, ?, ?, ?)`, subject, email, verifyEmail, roles, updatedAt(), active)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Tx) GetUserRoles(sub string) (string, error) {
|
||||
var r string
|
||||
row := t.tx.QueryRow(`SELECT roles FROM users WHERE subject = ? LIMIT 1`, sub)
|
||||
err := row.Scan(&r)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (t *Tx) GetUser(sub string) (*User, error) {
|
||||
var u User
|
||||
row := t.tx.QueryRow(`SELECT email, email_verified, roles, updated_at, active FROM users WHERE subject = ?`, sub)
|
||||
err := row.Scan(&u.Email, &u.EmailVerified, &u.Roles, &u.UpdatedAt, &u.Active)
|
||||
u.Sub = sub
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (t *Tx) GetUserEmail(sub string) (string, error) {
|
||||
var email string
|
||||
row := t.tx.QueryRow(`SELECT email FROM users WHERE subject = ?`, sub)
|
||||
err := row.Scan(&email)
|
||||
return email, err
|
||||
}
|
||||
|
||||
func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) {
|
||||
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, &u.Active)
|
||||
u.Owner = sub
|
||||
if !u.Active {
|
||||
return nil, fmt.Errorf("client is not active")
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
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 (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner string) 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, sso, active)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Tx) UpdateClientApp(subject uuid.UUID, owner string, name, domain string, sso, active bool) error {
|
||||
_, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ? AND owner = ?`, name, domain, sso, active, subject.String(), owner)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Tx) ResetClientAppSecret(subject uuid.UUID, owner string) (string, error) {
|
||||
secret, err := password.GenerateApiSecret(70)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = t.tx.Exec(`UPDATE client_store SET secret = ? WHERE subject = ? AND owner = ?`, secret, subject.String(), owner)
|
||||
return secret, err
|
||||
}
|
||||
|
||||
func (t *Tx) GetUserList(offset int) ([]User, error) {
|
||||
var u []User
|
||||
row, err := t.tx.Query(`SELECT subject, email, email_verified, roles, 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.Email, &a.EmailVerified, &a.Roles, &a.UpdatedAt, &a.Active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u = append(u, a)
|
||||
}
|
||||
return u, row.Err()
|
||||
}
|
||||
|
||||
func (t *Tx) UpdateUser(subject, roles string, active bool) error {
|
||||
_, err := t.tx.Exec(`UPDATE users SET active = ?, roles = ? WHERE subject = ?`, active, roles, subject)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Tx) UserEmailExists(email string) (exists bool, err error) {
|
||||
row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = ? and email_verified = 1)`, email)
|
||||
err = row.Scan(&exists)
|
||||
return
|
||||
}
|
16
go.mod
16
go.mod
@ -8,11 +8,14 @@ require (
|
||||
github.com/1f349/overlapfs v0.0.1
|
||||
github.com/1f349/violet v0.0.13
|
||||
github.com/MrMelon54/exit-reload v0.0.1
|
||||
github.com/emersion/go-message v0.18.0
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.2
|
||||
github.com/go-session/session v3.1.2+incompatible
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/rs/cors v1.10.1
|
||||
github.com/mattn/go-sqlite3 v1.14.18
|
||||
github.com/stretchr/testify v1.8.4
|
||||
golang.org/x/oauth2 v0.16.0
|
||||
)
|
||||
@ -21,9 +24,20 @@ require (
|
||||
github.com/MrMelon54/rescheduler v0.0.2 // indirect
|
||||
github.com/becheran/wildmatch-go v1.0.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 // indirect
|
||||
github.com/tidwall/buntdb v1.1.2 // indirect
|
||||
github.com/tidwall/gjson v1.12.1 // indirect
|
||||
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e // indirect
|
||||
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
|
156
go.sum
156
go.sum
@ -5,18 +5,39 @@ github.com/1f349/mjwt v0.2.1 h1:REdiM/MaNjYQwHvI39LaMPhlvMg4Vy9SgomWMsKTNz8=
|
||||
github.com/1f349/mjwt v0.2.1/go.mod h1:KEs6jd9JjWrQW+8feP2pGAU7pdA3aYTqjkT/YQr73PU=
|
||||
github.com/1f349/overlapfs v0.0.1 h1:LAxBolrXFAgU0yqZtXg/C/aaPq3eoQSPpBc49BHuTp0=
|
||||
github.com/1f349/overlapfs v0.0.1/go.mod h1:I6aItQycr7nrzplmfNXp/QF9tTmKRSgY3fXmu/7Ky2o=
|
||||
github.com/1f349/violet v0.0.12 h1:VIiVYfKptCYJvwaJHFgtOyTUOURRMIltGp5Blw9+isY=
|
||||
github.com/1f349/violet v0.0.12/go.mod h1:8xyh96shYiSBkwumvG/KkiY78tAhxiOomDlT7phZAbA=
|
||||
github.com/1f349/violet v0.0.13 h1:lJpTz15Ea83Uc1VAISXTjtKuzr8Pe8NM4cMGp3Aiyhk=
|
||||
github.com/1f349/violet v0.0.13/go.mod h1:Ga5/hWqI+EkR6J1mAMNzs7aJhuGcA89XFqgQaDXC7Jo=
|
||||
github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc=
|
||||
github.com/MrMelon54/exit-reload v0.0.1/go.mod h1:PLiSfmUzwdpTTQP3BBfUPhkqPwaIZjx0DuXBnM76Bug=
|
||||
github.com/MrMelon54/rescheduler v0.0.2 h1:efrRwr0BYlkaXFucZDjQqRyIawZiMEAnzjea46Bs9Oc=
|
||||
github.com/MrMelon54/rescheduler v0.0.2/go.mod h1:OQDFtZHdS4/qA/r7rtJUQA22/hbpnZ9MGQCXOPjhC6w=
|
||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA=
|
||||
github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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.18.0 h1:7LxAXHRpSeoO/Wom3ZApVZYG7c3d17yCScYce8WiXA8=
|
||||
github.com/emersion/go-message v0.18.0/go.mod h1:Zi69ACvzaoV/MBnrxfVBPV3xWEuCmC2nEN39oJF4B8A=
|
||||
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=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.2 h1:CuZhD3lhGuI6aNLyUbRHXsgG2RwGRBOuCBfd4WQKqBQ=
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.2/go.mod h1:wk/2uLImWIa9VVQDgxz99H2GDbhmfi/9/Xr+GvkSUSQ=
|
||||
github.com/go-session/session v3.1.2+incompatible h1:yStchEObKg4nk2F7JGE7KoFIrA/1Y078peagMWcrncg=
|
||||
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
|
||||
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
|
||||
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@ -26,7 +47,6 @@ github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:x
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
@ -34,73 +54,171 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
|
||||
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
|
||||
github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U=
|
||||
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
|
||||
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 h1:G6Z6HvJuPjG6XfNGi/feOATzeJrfgTNJY+rGrHbA04E=
|
||||
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
|
||||
github.com/tidwall/buntdb v1.1.2 h1:noCrqQXL9EKMtcdwJcmuVKSEjqu1ua99RHHgbLTEHRo=
|
||||
github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI=
|
||||
github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
|
||||
github.com/tidwall/gjson v1.12.1 h1:ikuZsLdhr8Ws0IdROXUS1Gi4v9Z4pGqpX/CvJkxvfpo=
|
||||
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb h1:5NSYaAdrnblKByzd7XByQEJVT8+9v0W/tIY0Oo4OwrE=
|
||||
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
|
||||
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2KoJQD9cTQ6dyP2co9q4yzmT9FZo=
|
||||
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
|
||||
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE=
|
||||
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4=
|
||||
github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
|
||||
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
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/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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
|
||||
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
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 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
@ -109,15 +227,21 @@ google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
@ -41,12 +41,16 @@ func NewManagerForTests(services []*WellKnownOIDC) *Manager {
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Manager) CheckNamespace(namespace string) bool {
|
||||
_, ok := l.m[namespace]
|
||||
func (m *Manager) CheckNamespace(namespace string) bool {
|
||||
_, ok := m.m[namespace]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC {
|
||||
func (m *Manager) GetService(namespace string) *WellKnownOIDC {
|
||||
return m.m[namespace]
|
||||
}
|
||||
|
||||
func (m *Manager) FindServiceFromLogin(login string) *WellKnownOIDC {
|
||||
// @ should have at least one byte before it
|
||||
n := strings.IndexByte(login, '@')
|
||||
if n < 1 {
|
||||
@ -57,5 +61,5 @@ func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC {
|
||||
if n2 != -1 {
|
||||
return nil
|
||||
}
|
||||
return l.m[login[n+1:]]
|
||||
return m.GetService(login[n+1:])
|
||||
}
|
||||
|
34
openid/config.go
Normal file
34
openid/config.go
Normal file
@ -0,0 +1,34 @@
|
||||
package openid
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
}
|
||||
|
||||
func GenConfig(baseUrl string, scopes, claims []string) Config {
|
||||
baseUrlRaw := baseUrl
|
||||
if !strings.HasSuffix(baseUrl, "/") {
|
||||
baseUrl += "/"
|
||||
}
|
||||
|
||||
return Config{
|
||||
Issuer: baseUrlRaw,
|
||||
AuthorizationEndpoint: baseUrl + "authorize",
|
||||
TokenEndpoint: baseUrl + "token",
|
||||
UserInfoEndpoint: baseUrl + "userinfo",
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
ScopesSupported: scopes,
|
||||
ClaimsSupported: claims,
|
||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||
}
|
||||
}
|
19
openid/config_test.go
Normal file
19
openid/config_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package openid
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenConfig(t *testing.T) {
|
||||
assert.Equal(t, Config{
|
||||
Issuer: "https://example.com",
|
||||
AuthorizationEndpoint: "https://example.com/authorize",
|
||||
TokenEndpoint: "https://example.com/token",
|
||||
UserInfoEndpoint: "https://example.com/userinfo",
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
ScopesSupported: []string{"openid", "email"},
|
||||
ClaimsSupported: []string{"name", "email", "preferred_username"},
|
||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||
}, GenConfig("https://example.com", []string{"openid", "email"}, []string{"name", "email", "preferred_username"}))
|
||||
}
|
20
pages/index-guest.go.html
Normal file
20
pages/index-guest.go.html
Normal file
@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{.ServiceName}}</title>
|
||||
<link rel="stylesheet" href="/theme/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{.ServiceName}}</h1>
|
||||
</header>
|
||||
<main>
|
||||
<div>Not logged in</div>
|
||||
<div>
|
||||
<form method="GET" action="/login">
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
31
pages/index.go.html
Normal file
31
pages/index.go.html
Normal file
@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{.ServiceName}}</title>
|
||||
<link rel="stylesheet" href="/theme/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{.ServiceName}}</h1>
|
||||
</header>
|
||||
<main>
|
||||
<div>Logged in as: {{.DisplayName}} ({{.Subject}})</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="POST" action="/logout">
|
||||
<input type="hidden" name="nonce" value="{{.Nonce}}">
|
||||
<button type="submit">Log Out</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -11,13 +11,13 @@
|
||||
<main>
|
||||
<div>Log in as: <span>{{.LoginName}}</span></div>
|
||||
<div>
|
||||
<form method="POST" action="/popup">
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="origin" value="{{.Origin}}"/>
|
||||
<button type="submit" name="not-you" value="1">Not You?</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<form method="POST" action="/popup">
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="origin" value="{{.Origin}}"/>
|
||||
<input type="hidden" name="loginname" value="{{.LoginName}}"/>
|
||||
<button type="submit">Continue</button>
|
@ -9,7 +9,7 @@
|
||||
<h1>{{.ServiceName}}</h1>
|
||||
</header>
|
||||
<main>
|
||||
<form method="POST" action="/popup">
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="origin" value="{{.Origin}}"/>
|
||||
<div>
|
||||
<label for="field_loginname">Login Name:</label>
|
148
pages/manage-apps.go.html
Normal file
148
pages/manage-apps.go.html
Normal file
@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{.ServiceName}}</title>
|
||||
<link rel="stylesheet" href="/theme/style.css">
|
||||
<script>
|
||||
window.addEventListener("load", function () {
|
||||
selectText("app-secret");
|
||||
});
|
||||
|
||||
// Thanks again: https://stackoverflow.com/a/987376
|
||||
function selectText(nodeId) {
|
||||
const node = document.getElementById(nodeId);
|
||||
|
||||
if (document.body.createTextRange) {
|
||||
const range = document.body.createTextRange();
|
||||
range.moveToElementText(node);
|
||||
range.select();
|
||||
} else if (window.getSelection) {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
console.warn("Could not select text in node: Unsupported browser.");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{.ServiceName}}</h1>
|
||||
</header>
|
||||
<main>
|
||||
<form method="GET" action="/">
|
||||
<button type="submit">Home</button>
|
||||
</form>
|
||||
|
||||
{{if .NewAppSecret}}
|
||||
<div>New application secret: <span id="app-secret">{{.NewAppSecret}}</span> for {{.NewAppName}}</div>
|
||||
{{end}}
|
||||
|
||||
{{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>
|
||||
<form method="POST" action="/manage/apps?offset={{$.Offset}}">
|
||||
<input type="hidden" name="action" value="secret"/>
|
||||
<input type="hidden" name="offset" value="{{$.Offset}}"/>
|
||||
<input type="hidden" name="subject" value="{{.Sub}}"/>
|
||||
<button type="submit">Reset Secret</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>
|
100
pages/manage-users.go.html
Normal file
100
pages/manage-users.go.html
Normal file
@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{.ServiceName}}</title>
|
||||
<link rel="stylesheet" href="/theme/style.css">
|
||||
</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}}"/>
|
||||
<div>
|
||||
<label for="field_subject">Subject:</label>
|
||||
<input type="text" name="subject" id="field_subject" value="{{.Edit.Subject}}" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="field_roles">Roles:</label>
|
||||
<input type="text" name="roles" id="field_roles" value="{{.Edit.Roles}}"/>
|
||||
</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>Subject</th>
|
||||
<th>Email</th>
|
||||
<th>Email Verified</th>
|
||||
<th>Roles</th>
|
||||
<th>Last Updated</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.Sub}}</td>
|
||||
<th>
|
||||
{{if $.EmailShow}}
|
||||
<span>{{.Email}}</span>
|
||||
{{else}}
|
||||
<span>{{emailHide .Email}}</span>
|
||||
{{end}}
|
||||
</th>
|
||||
<th>{{.EmailVerified}}</th>
|
||||
<th>{{.Roles}}</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>
|
||||
<form method="POST" action="/reset-password">
|
||||
<input type="hidden" name="email" value="{{.Email}}"/>
|
||||
<button type="submit">Send Reset Password Email</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}}
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
36
pages/oauth-authorize.go.html
Normal file
36
pages/oauth-authorize.go.html
Normal file
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{.ServiceName}}</title>
|
||||
<link rel="stylesheet" href="/theme/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{.ServiceName}}</h1>
|
||||
</header>
|
||||
<main>
|
||||
<form method="POST" action="/authorize">
|
||||
<div>The application {{.AppName}} wants to access your account ({{.DisplayName}}). It requests the following permissions:</div>
|
||||
<div>
|
||||
<ul>
|
||||
{{range .WantsList}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<input type="hidden" name="response_type" value="{{.ResponseType}}"/>
|
||||
<input type="hidden" name="response_mode" value="{{.ResponseMode}}">
|
||||
<input type="hidden" name="client_id" value="{{.ClientID}}"/>
|
||||
<input type="hidden" name="redirect_uri" value="{{.RedirectUri}}"/>
|
||||
<input type="hidden" name="state" value="{{.State}}"/>
|
||||
<input type="hidden" name="scope" value="{{.Scope}}"/>
|
||||
<input type="hidden" name="nonce" value="{{.Nonce}}"/>
|
||||
<button class="oauth-action-authorize" name="oauth_action" value="authorize">Authorize</button>
|
||||
<button class="oauth-action-cancel" name="oauth_action" value="cancel">Cancel</button>
|
||||
</div>
|
||||
<div>Authorizing this action will redirect you to {{.AppDomain}} with access to the permissions requested above.</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -16,14 +16,14 @@ import (
|
||||
|
||||
var (
|
||||
//go:embed *.go.html
|
||||
flowPages embed.FS
|
||||
flowTemplates *template.Template
|
||||
loadOnce sync.Once
|
||||
wwwPages embed.FS
|
||||
wwwTemplates *template.Template
|
||||
loadOnce sync.Once
|
||||
)
|
||||
|
||||
func LoadPages(wd string) (err error) {
|
||||
loadOnce.Do(func() {
|
||||
var o fs.FS = flowPages
|
||||
var o fs.FS = wwwPages
|
||||
if wd != "" {
|
||||
wwwDir := filepath.Join(wd, "www")
|
||||
err = os.Mkdir(wwwDir, os.ModePerm)
|
||||
@ -31,16 +31,28 @@ func LoadPages(wd string) (err error) {
|
||||
return
|
||||
}
|
||||
wdFs := os.DirFS(wwwDir)
|
||||
o = overlapfs.OverlapFS{A: flowPages, B: wdFs}
|
||||
o = overlapfs.OverlapFS{A: wwwPages, B: wdFs}
|
||||
}
|
||||
flowTemplates, err = template.ParseFS(o, "*.go.html")
|
||||
wwwTemplates, err = template.New("pages").Funcs(template.FuncMap{
|
||||
"emailHide": EmailHide,
|
||||
}).ParseFS(o, "*.go.html")
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func RenderPageTemplate(wr io.Writer, name string, data any) {
|
||||
err := flowTemplates.ExecuteTemplate(wr, name+".go.html", data)
|
||||
err := wwwTemplates.ExecuteTemplate(wr, name+".go.html", data)
|
||||
if err != nil {
|
||||
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"))
|
||||
}
|
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
|
||||
}
|
80
scope/scope.go
Normal file
80
scope/scope.go
Normal file
@ -0,0 +1,80 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrInvalidScope = errors.New("invalid scope")
|
||||
|
||||
var scopeDescription = map[string]string{
|
||||
"openid": "Verify your user identity",
|
||||
"name": "Access your name",
|
||||
"username": "Access your username",
|
||||
"profile": "Access your public profile",
|
||||
"email": "Access your email",
|
||||
"birthdate": "Access your birthdate",
|
||||
"age": "Access your current age",
|
||||
"zoneinfo": "Access time zone setting",
|
||||
"locale": "Access your language setting",
|
||||
}
|
||||
|
||||
func ScopesExist(scope string) bool {
|
||||
_, err := internalGetScopes(scope, func(key, desc string) string { return "" })
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// FancyScopeList takes a scope string and outputs a slice of scope descriptions
|
||||
func FancyScopeList(scope string) (arr []string) {
|
||||
a, err := internalGetScopes(scope, func(key, desc string) string {
|
||||
return desc
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func internalGetScopes(scope string, f func(key, desc string) string) (arr []string, err error) {
|
||||
seen := make(map[string]struct{})
|
||||
outer:
|
||||
for {
|
||||
n := strings.IndexAny(scope, ", ")
|
||||
var key string
|
||||
switch n {
|
||||
case 0:
|
||||
// first char is matching, no key name found, just continue
|
||||
scope = scope[1:]
|
||||
continue outer
|
||||
case -1:
|
||||
// no more matching chars, if scope is empty then we are done
|
||||
if len(scope) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// otherwise set the key and empty scope
|
||||
key = scope
|
||||
scope = ""
|
||||
default:
|
||||
// set the key and trim from scope
|
||||
key = scope[:n]
|
||||
scope = scope[n+1:]
|
||||
}
|
||||
|
||||
// check if key has been seen already
|
||||
if _, ok := seen[key]; ok {
|
||||
continue outer
|
||||
}
|
||||
|
||||
// set seen flag
|
||||
seen[key] = struct{}{}
|
||||
|
||||
// output the description
|
||||
if d, ok := scopeDescription[key]; ok && d != "" {
|
||||
arr = append(arr, f(key, d))
|
||||
continue
|
||||
}
|
||||
|
||||
err = ErrInvalidScope
|
||||
}
|
||||
}
|
42
scope/scope_test.go
Normal file
42
scope/scope_test.go
Normal file
@ -0,0 +1,42 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScopesExist(t *testing.T) {
|
||||
desc := scopeDescription
|
||||
scopeDescription = map[string]string{
|
||||
"a": "A",
|
||||
"b": "B",
|
||||
"c": "C",
|
||||
}
|
||||
|
||||
assert.True(t, ScopesExist("a b c"))
|
||||
assert.False(t, ScopesExist("a b d"))
|
||||
assert.True(t, ScopesExist("a,b c"))
|
||||
assert.False(t, ScopesExist("a,b d"))
|
||||
|
||||
scopeDescription = desc
|
||||
}
|
||||
|
||||
func TestFancyScopeList(t *testing.T) {
|
||||
desc := scopeDescription
|
||||
scopeDescription = map[string]string{
|
||||
"a": "A",
|
||||
"b": "B",
|
||||
"c": "C",
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"A"}, FancyScopeList("a"))
|
||||
assert.Equal(t, []string{"A", "B"}, FancyScopeList("a b"))
|
||||
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a b c"))
|
||||
assert.Equal(t, []string{"A", "B"}, FancyScopeList("a,b"))
|
||||
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a,b,c"))
|
||||
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a b,c"))
|
||||
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a,b c"))
|
||||
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a, b, c"))
|
||||
|
||||
scopeDescription = desc
|
||||
}
|
133
server/auth.go
Normal file
133
server/auth.go
Normal file
@ -0,0 +1,133 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/1f349/lavender/database"
|
||||
"github.com/go-session/session"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth)
|
||||
|
||||
type UserAuth struct {
|
||||
Session session.Store
|
||||
Data SessionData
|
||||
}
|
||||
|
||||
type SessionData struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
func (u UserAuth) IsGuest() bool {
|
||||
return u.Data.ID == ""
|
||||
}
|
||||
|
||||
func (u UserAuth) SaveSessionData() error {
|
||||
u.Session.Set("session-data", u.Data)
|
||||
return u.Session.Save()
|
||||
}
|
||||
|
||||
func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle {
|
||||
return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
|
||||
var roles string
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
roles, err = tx.GetUserRoles(auth.Data.ID)
|
||||
return
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if HasRole(roles, "lavender:admin") {
|
||||
http.Error(rw, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(rw, req, params, auth)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle {
|
||||
return h.OptionalAuthentication(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)
|
||||
return
|
||||
}
|
||||
next(rw, req, params, auth)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle {
|
||||
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
auth, err := internalAuthenticationHandler(rw, req)
|
||||
if err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if auth.IsGuest() {
|
||||
if loginCookie, err := req.Cookie("login-data"); err == nil {
|
||||
if decryptedBytes, err := base64.RawStdEncoding.DecodeString(loginCookie.Value); err == nil {
|
||||
if decryptedData, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signingKey.PrivateKey(), decryptedBytes, []byte("login-data")); err == nil {
|
||||
auth.Data.ID = string(decryptedData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next(rw, req, params, auth)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// get auth object
|
||||
userIdRaw, ok := ss.Get("session-data")
|
||||
if !ok {
|
||||
return UserAuth{Session: ss}, nil
|
||||
}
|
||||
userData, ok := userIdRaw.(SessionData)
|
||||
if !ok {
|
||||
ss.Delete("session-data")
|
||||
err := ss.Save()
|
||||
if err != nil {
|
||||
return UserAuth{Session: ss}, fmt.Errorf("failed to reset invalid session data")
|
||||
}
|
||||
}
|
||||
|
||||
return UserAuth{Session: ss, Data: userData}, nil
|
||||
}
|
||||
|
||||
func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL {
|
||||
// find start of query parameters in target path
|
||||
n := strings.IndexByte(targetPath, '?')
|
||||
v := url.Values{}
|
||||
|
||||
// parse existing query parameters
|
||||
if n != -1 {
|
||||
q, err := url.ParseQuery(targetPath[n+1:])
|
||||
if err != nil {
|
||||
panic("PrepareRedirectUrl: invalid hardcoded target path query parameters")
|
||||
}
|
||||
v = q
|
||||
targetPath = targetPath[:n]
|
||||
}
|
||||
|
||||
// add path of origin as a new query parameter
|
||||
orig := origin.Path
|
||||
if origin.RawQuery != "" || origin.ForceQuery {
|
||||
orig += "?" + origin.RawQuery
|
||||
}
|
||||
if orig != "" {
|
||||
v.Set("redirect", orig)
|
||||
}
|
||||
return &url.URL{Path: targetPath, RawQuery: v.Encode()}
|
||||
}
|
@ -2,20 +2,12 @@ package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/lavender/issuer"
|
||||
"github.com/1f349/lavender/utils"
|
||||
)
|
||||
|
||||
type Conf struct {
|
||||
Listen string `json:"listen"`
|
||||
BaseUrl string `json:"base_url"`
|
||||
ServiceName string `json:"service_name"`
|
||||
Issuer string `json:"issuer"`
|
||||
SsoServices []issuer.SsoConfig `json:"sso_services"`
|
||||
AllowedClients []AllowedClient `json:"allowed_clients"`
|
||||
Users UserConfig `json:"users"`
|
||||
}
|
||||
|
||||
type AllowedClient struct {
|
||||
Url utils.JsonUrl `json:"url"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Listen string `json:"listen"`
|
||||
BaseUrl string `json:"base_url"`
|
||||
ServiceName string `json:"service_name"`
|
||||
Issuer string `json:"issuer"`
|
||||
SsoServices []issuer.SsoConfig `json:"sso_services"`
|
||||
}
|
||||
|
33
server/db.go
Normal file
33
server/db.go
Normal file
@ -0,0 +1,33 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/lavender/database"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// DbTx wraps a database transaction with http error messages and a simple action
|
||||
// function. If the action function returns an error the transaction will be
|
||||
// rolled back. If there is no error then the transaction is committed.
|
||||
func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Tx) error) bool {
|
||||
tx, err := h.db.Begin()
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed to begin database transaction", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
err = action(tx)
|
||||
if err != nil {
|
||||
http.Error(rw, "Database error", http.StatusInternalServerError)
|
||||
log.Println("Database action error:", err)
|
||||
return true
|
||||
}
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
http.Error(rw, "Database error", http.StatusInternalServerError)
|
||||
log.Println("Database commit error:", err)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
146
server/flow.go
146
server/flow.go
@ -1,146 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"github.com/1f349/lavender/issuer"
|
||||
"github.com/1f349/lavender/server/pages"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var uuidNewStringState = uuid.NewString
|
||||
var uuidNewStringAti = uuid.NewString
|
||||
var uuidNewStringRti = uuid.NewString
|
||||
|
||||
var testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
return oa2conf.Exchange(ctx, code)
|
||||
}
|
||||
|
||||
var testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
|
||||
client := oidc.OAuth2Config.Client(ctx, exchange)
|
||||
return client.Get(oidc.UserInfoEndpoint)
|
||||
}
|
||||
|
||||
func (h *HttpServer) flowPopup(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
cookie, err := req.Cookie("lavender-login-name")
|
||||
if err == nil && cookie.Valid() == nil {
|
||||
pages.RenderPageTemplate(rw, "flow-popup-memory", map[string]any{
|
||||
"ServiceName": h.conf.Load().ServiceName,
|
||||
"Origin": req.URL.Query().Get("origin"),
|
||||
"LoginName": cookie.Value,
|
||||
})
|
||||
return
|
||||
}
|
||||
pages.RenderPageTemplate(rw, "flow-popup", map[string]any{
|
||||
"ServiceName": h.conf.Load().ServiceName,
|
||||
"Origin": req.URL.Query().Get("origin"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) flowPopupPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
if req.PostFormValue("not-you") == "1" {
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "lavender-login-name",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
http.Redirect(rw, req, (&url.URL{
|
||||
Path: "/popup",
|
||||
RawQuery: url.Values{
|
||||
"origin": []string{req.PostFormValue("origin")},
|
||||
}.Encode(),
|
||||
}).String(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
loginName := req.PostFormValue("loginname")
|
||||
login := h.manager.Load().FindServiceFromLogin(loginName)
|
||||
if login == nil {
|
||||
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// the @ must exist if the service is defined
|
||||
n := strings.IndexByte(loginName, '@')
|
||||
loginUn := loginName[:n]
|
||||
|
||||
now := time.Now()
|
||||
future := now.AddDate(1, 0, 0)
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "lavender-login-name",
|
||||
Value: loginName,
|
||||
Path: "/",
|
||||
Expires: future,
|
||||
MaxAge: int(future.Sub(now).Seconds()),
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
targetOrigin := req.PostFormValue("origin")
|
||||
allowedService, found := (*h.services.Load())[targetOrigin]
|
||||
if !found {
|
||||
http.Error(rw, "Invalid target origin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// save state for use later
|
||||
state := login.Config.Namespace + ":" + uuidNewStringState()
|
||||
h.flowState.Set(state, flowStateData{
|
||||
login,
|
||||
allowedService,
|
||||
}, time.Now().Add(15*time.Minute))
|
||||
|
||||
// generate oauth2 config and redirect to authorize URL
|
||||
oa2conf := login.OAuth2Config
|
||||
oa2conf.RedirectURL = h.conf.Load().BaseUrl + "/callback"
|
||||
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
|
||||
http.Redirect(rw, req, nextUrl, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *HttpServer) flowCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(rw, "Error parsing form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
state := q.Get("state")
|
||||
n := strings.IndexByte(state, ':')
|
||||
if n == -1 || !h.manager.Load().CheckNamespace(state[:n]) {
|
||||
http.Error(rw, "Invalid state namespace", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
v, found := h.flowState.Get(state)
|
||||
if !found {
|
||||
http.Error(rw, "Invalid state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
oa2conf := v.sso.OAuth2Config
|
||||
oa2conf.RedirectURL = h.conf.Load().BaseUrl + "/callback"
|
||||
exchange, err := testOa2Exchange(oa2conf, context.Background(), q.Get("code"))
|
||||
if err != nil {
|
||||
fmt.Println("Failed exchange:", err)
|
||||
http.Error(rw, "Failed to exchange code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.finishTokenGenerateFlow(rw, req, v, exchange, func(accessToken, refreshToken string, v3 map[string]any) {
|
||||
pages.RenderPageTemplate(rw, "flow-callback", map[string]any{
|
||||
"ServiceName": h.conf.Load().ServiceName,
|
||||
"TargetOrigin": v.target.Url.String(),
|
||||
"TargetMessage": v3,
|
||||
"AccessToken": accessToken,
|
||||
"RefreshToken": refreshToken,
|
||||
})
|
||||
})
|
||||
}
|
@ -1,445 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/1f349/cache"
|
||||
"github.com/1f349/lavender/issuer"
|
||||
"github.com/1f349/lavender/server/pages"
|
||||
"github.com/1f349/lavender/utils"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const lavenderDomain = "http://localhost:0"
|
||||
const clientAppDomain = "http://localhost:1"
|
||||
const loginDomain = "http://localhost:2"
|
||||
|
||||
var clientAppMeta AllowedClient
|
||||
|
||||
var testSigner mjwt.Signer
|
||||
|
||||
var testOidc = &issuer.WellKnownOIDC{
|
||||
Config: issuer.SsoConfig{
|
||||
Addr: utils.JsonUrl{},
|
||||
Namespace: "example.com",
|
||||
Client: issuer.SsoConfigClient{
|
||||
ID: "test-id",
|
||||
Secret: "test-secret",
|
||||
Scopes: []string{"openid"},
|
||||
},
|
||||
},
|
||||
Issuer: "https://example.com",
|
||||
AuthorizationEndpoint: loginDomain + "/authorize",
|
||||
TokenEndpoint: loginDomain + "/token",
|
||||
UserInfoEndpoint: loginDomain + "/userinfo",
|
||||
ResponseTypesSupported: nil,
|
||||
ScopesSupported: nil,
|
||||
ClaimsSupported: nil,
|
||||
GrantTypesSupported: nil,
|
||||
OAuth2Config: oauth2.Config{
|
||||
ClientID: "test-id",
|
||||
ClientSecret: "test-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: loginDomain + "/authorize",
|
||||
TokenURL: loginDomain + "/token",
|
||||
AuthStyle: oauth2.AuthStyleInHeader,
|
||||
},
|
||||
Scopes: nil,
|
||||
},
|
||||
}
|
||||
|
||||
var testManager = issuer.NewManagerForTests([]*issuer.WellKnownOIDC{testOidc})
|
||||
var testHttpServer = HttpServer{
|
||||
r: nil,
|
||||
flowState: cache.New[string, flowStateData](),
|
||||
}
|
||||
|
||||
func init() {
|
||||
testHttpServer.conf.Store(&Conf{
|
||||
BaseUrl: lavenderDomain,
|
||||
ServiceName: "Test Lavender Service",
|
||||
})
|
||||
testHttpServer.manager.Store(testManager)
|
||||
testHttpServer.services.Store(&map[string]AllowedClient{
|
||||
clientAppDomain: {},
|
||||
})
|
||||
|
||||
err := pages.LoadPages("")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testSigner = mjwt.NewMJwtSigner("https://example.com", key)
|
||||
testHttpServer.signer = testSigner
|
||||
|
||||
parse, err := url.Parse(clientAppDomain)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
clientAppMeta = AllowedClient{
|
||||
Url: utils.JsonUrl{URL: parse},
|
||||
Permissions: []string{"test-perm"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowPopup(t *testing.T) {
|
||||
h := HttpServer{}
|
||||
h.conf.Store(&Conf{ServiceName: "Test Service Name"})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/popup?"+url.Values{"origin": []string{clientAppDomain}}.Encode(), nil)
|
||||
h.flowPopup(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test Service Name</title>
|
||||
<link rel="stylesheet" href="/theme/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Test Service Name</h1>
|
||||
</header>
|
||||
<main>
|
||||
<form method="POST" action="/popup">
|
||||
<input type="hidden" name="origin" value="%s"/>
|
||||
<div>
|
||||
<label for="field_loginname">Login Name:</label>
|
||||
<input type="text" name="loginname" id="field_loginname" required/>
|
||||
</div>
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`, clientAppDomain), rec.Body.String())
|
||||
}
|
||||
|
||||
func TestFlowPopupPost(t *testing.T) {
|
||||
// test no login service error
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
|
||||
"loginname": []string{"test@missing.example.com"},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowPopupPost(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Equal(t, "No login service defined for this username\n", rec.Body.String())
|
||||
|
||||
// test invalid target origin error
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
|
||||
"loginname": []string{"test@example.com"},
|
||||
"origin": []string{"http://localhost:1010"},
|
||||
}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowPopupPost(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Equal(t, "Invalid target origin\n", rec.Body.String())
|
||||
|
||||
// test successful request
|
||||
nextState := uuid.NewString()
|
||||
uuidNewStringState = func() string { return nextState }
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
|
||||
"loginname": []string{"test@example.com"},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowPopupPost(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
assert.Equal(t, "", rec.Body.String())
|
||||
assert.Equal(t, loginDomain+"/authorize?"+url.Values{
|
||||
"client_id": []string{"test-id"},
|
||||
"login_name": []string{"test"},
|
||||
"redirect_uri": []string{lavenderDomain + "/callback"},
|
||||
"response_type": []string{"code"},
|
||||
"state": []string{"example.com:" + nextState},
|
||||
}.Encode(), rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestFlowCallback(t *testing.T) {
|
||||
expiryTime := time.Now().Add(15 * time.Minute)
|
||||
nextState := uuid.NewString()
|
||||
testHttpServer.flowState.Set("example.com:"+nextState, flowStateData{
|
||||
sso: testOidc,
|
||||
target: clientAppMeta,
|
||||
}, expiryTime)
|
||||
|
||||
testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
return nil, errors.New("no exchange should be made")
|
||||
}
|
||||
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
|
||||
return nil, errors.New("no userinfo should be fetched")
|
||||
}
|
||||
|
||||
// test parse form error
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/callback?%+"+url.Values{
|
||||
"state": []string{"example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Equal(t, "Error parsing form\n", rec.Body.String())
|
||||
|
||||
// test invalid namespace
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"missing.example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Equal(t, "Invalid state namespace\n", rec.Body.String())
|
||||
|
||||
// test invalid state
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"example.com:invalid"},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Equal(t, "Invalid state\n", rec.Body.String())
|
||||
|
||||
// test failed exchange
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Equal(t, "Failed to exchange code\n", rec.Body.String())
|
||||
|
||||
testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
return &oauth2.Token{
|
||||
AccessToken: "abcd1234",
|
||||
TokenType: "",
|
||||
RefreshToken: "efgh5678",
|
||||
Expiry: expiryTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// test failed userinfo
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Equal(t, "Failed to get userinfo\n", rec.Body.String())
|
||||
|
||||
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusInternalServerError)
|
||||
return rec.Result(), nil
|
||||
}
|
||||
|
||||
// test failed userinfo status code
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Equal(t, "Failed to get userinfo: unexpected status code\n", rec.Body.String())
|
||||
|
||||
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
_, _ = rec.Body.WriteString("{")
|
||||
return rec.Result(), nil
|
||||
}
|
||||
|
||||
// test failed userinfo decode
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Equal(t, "Failed to decode userinfo\n", rec.Body.String())
|
||||
|
||||
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
_, _ = rec.Body.WriteString("{\"sub\":1}")
|
||||
return rec.Result(), nil
|
||||
}
|
||||
|
||||
// test invalid subject in userinfo
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Equal(t, "Invalid subject in userinfo\n", rec.Body.String())
|
||||
|
||||
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
_, _ = rec.Body.WriteString("{\"sub\":\"1\",\"aud\":1}")
|
||||
return rec.Result(), nil
|
||||
}
|
||||
|
||||
// test invalid audience in userinfo
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Equal(t, "Invalid audience in userinfo\n", rec.Body.String())
|
||||
|
||||
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
_, _ = rec.Body.WriteString(fmt.Sprintf(`{
|
||||
"sub": "test-user",
|
||||
"aud": "%s",
|
||||
"test-field": "ok"
|
||||
}
|
||||
`, clientAppDomain))
|
||||
return rec.Result(), nil
|
||||
}
|
||||
|
||||
// test successful request
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
|
||||
"state": []string{"example.com:" + nextState},
|
||||
"origin": []string{clientAppDomain},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
testHttpServer.flowCallback(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
const p1 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test Lavender Service</title>
|
||||
<link rel="stylesheet" href="/theme/style.css">
|
||||
<script>
|
||||
let loginData = {
|
||||
target:"%s",
|
||||
tokens: `
|
||||
const p2 = `,
|
||||
userinfo:{"aud":"%s","sub":"test-user","test-field":"ok"},
|
||||
};
|
||||
window.addEventListener("load", function () {
|
||||
window.opener.postMessage(loginData, loginData.target);
|
||||
setTimeout(function () {
|
||||
window.close();
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Test Lavender Service</h1>
|
||||
</header>
|
||||
<main id="mainBody">Loading...</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
var p1v = fmt.Sprintf(p1, clientAppDomain)
|
||||
var p2v = fmt.Sprintf(p2, clientAppDomain)
|
||||
|
||||
a := make([]byte, len(p1v))
|
||||
n, err := rec.Body.Read(a)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, len(p1v), n)
|
||||
assert.Equal(t, p1v, string(a))
|
||||
|
||||
var accessToken, refreshToken string
|
||||
findByte(rec.Body, '{')
|
||||
findString(rec.Body, "access:")
|
||||
readQuotedString(rec.Body, &accessToken)
|
||||
findByte(rec.Body, ',')
|
||||
findString(rec.Body, "refresh:")
|
||||
readQuotedString(rec.Body, &refreshToken)
|
||||
findByte(rec.Body, ',')
|
||||
findByte(rec.Body, '}')
|
||||
|
||||
assert.Equal(t, p2v, rec.Body.String())
|
||||
}
|
||||
|
||||
func findByte(buf *bytes.Buffer, v byte) {
|
||||
for {
|
||||
readByte, err := buf.ReadByte()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if readByte == v {
|
||||
break
|
||||
}
|
||||
if !unicode.IsSpace(rune(readByte)) {
|
||||
panic(fmt.Sprint("Found non space rune: ", readByte))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findString(buf *bytes.Buffer, v string) {
|
||||
if len(v) == 0 {
|
||||
panic("Cannot find empty string")
|
||||
}
|
||||
findByte(buf, v[0])
|
||||
if len(v) > 1 {
|
||||
a2 := make([]byte, len(v)-1)
|
||||
n, err := buf.Read(a2)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if n != len(a2) {
|
||||
panic("Probably found end of buffer")
|
||||
}
|
||||
if bytes.Compare([]byte(v[1:]), a2) != 0 {
|
||||
panic("Failed to find string in buffer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readQuotedString(buf *bytes.Buffer, p *string) {
|
||||
findByte(buf, '"')
|
||||
b, err := buf.ReadBytes('"')
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
*p = string(b)
|
||||
}
|
34
server/home.go
Normal file
34
server/home.go
Normal file
@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/lavender/pages"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
if auth.IsGuest() {
|
||||
pages.RenderPageTemplate(rw, "index-guest", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
lNonce := uuid.NewString()
|
||||
auth.Session.Set("action-nonce", lNonce)
|
||||
if auth.Session.Save() != nil {
|
||||
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pages.RenderPageTemplate(rw, "index", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"Auth": auth,
|
||||
"Subject": auth.Data.ID,
|
||||
"DisplayName": auth.Data.DisplayName,
|
||||
"Nonce": lNonce,
|
||||
})
|
||||
}
|
79
server/login.go
Normal file
79
server/login.go
Normal file
@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/lavender/pages"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
cookie, err := req.Cookie("lavender-login-name")
|
||||
if err == nil && cookie.Valid() == nil {
|
||||
pages.RenderPageTemplate(rw, "login-memory", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"Origin": req.URL.Query().Get("origin"),
|
||||
"LoginName": cookie.Value,
|
||||
})
|
||||
return
|
||||
}
|
||||
pages.RenderPageTemplate(rw, "login", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"Origin": req.URL.Query().Get("origin"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
if req.PostFormValue("not-you") == "1" {
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "lavender-login-name",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
http.Redirect(rw, req, (&url.URL{
|
||||
Path: "/login",
|
||||
RawQuery: url.Values{
|
||||
"origin": []string{req.PostFormValue("origin")},
|
||||
}.Encode(),
|
||||
}).String(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
loginName := req.PostFormValue("loginname")
|
||||
login := h.manager.FindServiceFromLogin(loginName)
|
||||
if login == nil {
|
||||
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// the @ must exist if the service is defined
|
||||
n := strings.IndexByte(loginName, '@')
|
||||
loginUn := loginName[:n]
|
||||
|
||||
now := time.Now()
|
||||
future := now.AddDate(1, 0, 0)
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "lavender-login-name",
|
||||
Value: loginName,
|
||||
Path: "/",
|
||||
Expires: future,
|
||||
MaxAge: int(future.Sub(now).Seconds()),
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
// save state for use later
|
||||
state := login.Config.Namespace + ":" + uuid.NewString()
|
||||
h.flowState.Set(state, flowStateData{login}, time.Now().Add(15*time.Minute))
|
||||
|
||||
// generate oauth2 config and redirect to authorize URL
|
||||
oa2conf := login.OAuth2Config
|
||||
oa2conf.RedirectURL = h.conf.BaseUrl + "/callback"
|
||||
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
|
||||
http.Redirect(rw, req, nextUrl, http.StatusFound)
|
||||
}
|
149
server/manage-apps.go
Normal file
149
server/manage-apps.go
Normal file
@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/lavender/database"
|
||||
"github.com/1f349/lavender/pages"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"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 roles string
|
||||
var appList []database.ClientInfoDbOutput
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
roles, err = tx.GetUserRoles(auth.Data.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
appList, err = tx.GetAppList(offset)
|
||||
return
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
m := map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"Apps": appList,
|
||||
"Offset": offset,
|
||||
"IsAdmin": HasRole(roles, "lavender:admin"),
|
||||
"NewAppName": q.Get("NewAppName"),
|
||||
"NewAppSecret": q.Get("NewAppSecret"),
|
||||
}
|
||||
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 roles string
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
roles, err = tx.GetUserRoles(auth.Data.ID)
|
||||
return
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if HasRole(roles, "lavender:admin") {
|
||||
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, auth.Data.ID, name, domain, sso, active)
|
||||
}) {
|
||||
return
|
||||
}
|
||||
case "secret":
|
||||
var info oauth2.ClientInfo
|
||||
var secret string
|
||||
if h.DbTx(rw, func(tx *database.Tx) error {
|
||||
sub, err := uuid.Parse(req.Form.Get("subject"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err = tx.GetClientInfo(sub.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret, err = tx.ResetClientAppSecret(sub, auth.Data.ID)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
appName := "Unknown..."
|
||||
if getName, ok := info.(interface{ GetName() string }); ok {
|
||||
appName = getName.GetName()
|
||||
}
|
||||
|
||||
h.ManageAppsGet(rw, &http.Request{
|
||||
URL: &url.URL{
|
||||
RawQuery: url.Values{
|
||||
"offset": []string{offset},
|
||||
"NewAppName": []string{appName},
|
||||
"NewAppSecret": []string{secret},
|
||||
}.Encode(),
|
||||
},
|
||||
}, httprouter.Params{}, auth)
|
||||
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)
|
||||
}
|
104
server/manage-users.go
Normal file
104
server/manage-users.go
Normal file
@ -0,0 +1,104 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/lavender/database"
|
||||
"github.com/1f349/lavender/pages"
|
||||
"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 roles string
|
||||
var userList []database.User
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
roles, err = tx.GetUserRoles(auth.Data.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
userList, err = tx.GetUserList(offset)
|
||||
return
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if HasRole(roles, "lavender:admin") {
|
||||
http.Error(rw, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
m := map[string]any{
|
||||
"ServiceName": h.conf.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 == 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 roles string
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
roles, err = tx.GetUserRoles(auth.Data.ID)
|
||||
return
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if HasRole(roles, "lavender:admin") {
|
||||
http.Error(rw, "400 Bad Request: Only admin users can manage users", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
offset := req.Form.Get("offset")
|
||||
action := req.Form.Get("action")
|
||||
newRoles := req.Form.Get("role")
|
||||
active := req.Form.Has("active")
|
||||
|
||||
switch action {
|
||||
case "edit":
|
||||
if h.DbTx(rw, func(tx *database.Tx) error {
|
||||
sub := req.Form.Get("subject")
|
||||
return tx.UpdateUser(sub, newRoles, 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)
|
||||
}
|
151
server/oauth.go
Normal file
151
server/oauth.go
Normal file
@ -0,0 +1,151 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/lavender/pages"
|
||||
"github.com/1f349/lavender/scope"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
// function is only called with GET or POST method
|
||||
isPost := req.Method == http.MethodPost
|
||||
|
||||
var form url.Values
|
||||
if isPost {
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed to parse form", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
form = req.PostForm
|
||||
} else {
|
||||
form = req.URL.Query()
|
||||
}
|
||||
|
||||
clientID := form.Get("client_id")
|
||||
client, err := h.oauthMgr.GetClient(req.Context(), clientID)
|
||||
if err != nil {
|
||||
http.Error(rw, "Invalid client", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
redirectUri := form.Get("redirect_uri")
|
||||
if redirectUri != client.GetDomain() {
|
||||
http.Error(rw, "Incorrect redirect URI", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if form.Has("cancel") {
|
||||
uCancel, err := url.Parse(client.GetDomain())
|
||||
if err != nil {
|
||||
http.Error(rw, "Invalid redirect URI", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
q := uCancel.Query()
|
||||
q.Set("error", "access_denied")
|
||||
uCancel.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(rw, req, uCancel.String(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var isSSO bool
|
||||
if clientIsSSO, ok := client.(interface{ IsSSO() bool }); ok {
|
||||
isSSO = clientIsSSO.IsSSO()
|
||||
}
|
||||
|
||||
switch {
|
||||
case isSSO && isPost:
|
||||
http.Error(rw, "400 Bad Request: Not sure how you even managed to send a POST request for an SSO application", http.StatusBadRequest)
|
||||
return
|
||||
case !isSSO && !isPost:
|
||||
// find application redirect domain and name
|
||||
appUrlFull, err := url.Parse(client.GetDomain())
|
||||
if err != nil {
|
||||
http.Error(rw, "500 Internal Server Error: Failed to parse application redirect URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
appDomain := appUrlFull.Scheme + "://" + appUrlFull.Host
|
||||
appName := appUrlFull.Host
|
||||
if clientGetName, ok := client.(interface{ GetName() string }); ok {
|
||||
n := clientGetName.GetName()
|
||||
if n != "" {
|
||||
appName = n
|
||||
}
|
||||
}
|
||||
|
||||
scopeList := form.Get("scope")
|
||||
if !scope.ScopesExist(scopeList) {
|
||||
http.Error(rw, "Invalid scopes", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"AppName": appName,
|
||||
"AppDomain": appDomain,
|
||||
"DisplayName": auth.Data.DisplayName,
|
||||
"WantsList": scope.FancyScopeList(scopeList),
|
||||
"ResponseType": form.Get("response_type"),
|
||||
"ResponseMode": form.Get("response_mode"),
|
||||
"ClientID": form.Get("client_id"),
|
||||
"RedirectUri": form.Get("redirect_uri"),
|
||||
"State": form.Get("state"),
|
||||
"Scope": scopeList,
|
||||
"Nonce": form.Get("nonce"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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) {
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
auth, err := internalAuthenticationHandler(rw, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if auth.IsGuest() {
|
||||
// handle redirecting to oauth
|
||||
var q url.Values
|
||||
switch req.Method {
|
||||
case http.MethodPost:
|
||||
q = req.PostForm
|
||||
case http.MethodGet:
|
||||
q = req.URL.Query()
|
||||
default:
|
||||
http.Error(rw, "405 Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return "", err
|
||||
}
|
||||
|
||||
redirectUrl := PrepareRedirectUrl("/login", &url.URL{Path: "/authorize", RawQuery: q.Encode()})
|
||||
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
|
||||
return "", nil
|
||||
}
|
||||
return auth.Data.ID, nil
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
package server
|
||||
|
||||
// UserConfig is the structure for storing a user's role and owned domains
|
||||
type UserConfig map[string]struct {
|
||||
Roles []string `json:"roles"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
|
||||
func (u UserConfig) AllRoles(user string) []string {
|
||||
return u[user].Roles
|
||||
}
|
||||
|
||||
func (u UserConfig) HasRole(user, role string) bool {
|
||||
for _, i := range u[user].Roles {
|
||||
if i == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (u UserConfig) AllDomains(user string) []string {
|
||||
return u[user].Domains
|
||||
}
|
||||
|
||||
func (u UserConfig) OwnsDomain(user, domain string) bool {
|
||||
for _, i := range u[user].Domains {
|
||||
if i == domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{.ServiceName}}</title>
|
||||
<link rel="stylesheet" href="/theme/style.css">
|
||||
<script>
|
||||
let loginData = {
|
||||
target:{{.TargetOrigin}},
|
||||
tokens: {
|
||||
access:{{.AccessToken}},
|
||||
refresh:{{.RefreshToken}},
|
||||
},
|
||||
userinfo:{{.TargetMessage}},
|
||||
};
|
||||
window.addEventListener("load", function () {
|
||||
window.opener.postMessage(loginData, loginData.target);
|
||||
setTimeout(function () {
|
||||
window.close();
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{.ServiceName}}</h1>
|
||||
</header>
|
||||
<main id="mainBody">Loading...</main>
|
||||
</body>
|
||||
</html>
|
@ -1,183 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/1f349/mjwt/auth"
|
||||
"github.com/1f349/mjwt/claims"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *HttpServer) refreshHandler(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
ref := strings.TrimSuffix(req.Referer(), "/")
|
||||
allowedClient, ok := (*h.services.Load())[ref]
|
||||
if !ok {
|
||||
http.Error(rw, "Invalid origin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
loginNameCookie, err := req.Cookie("lavender-login-name")
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed to read cookie", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
loginService := h.manager.Load().FindServiceFromLogin(loginNameCookie.Value)
|
||||
cookie, err := req.Cookie("sso-exchange")
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed to read cookie", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rawEncrypt, err := base64.RawURLEncoding.DecodeString(cookie.Value)
|
||||
if err != nil {
|
||||
http.Error(rw, "Internal Server Error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rawTokens, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signer.PrivateKey(), rawEncrypt, []byte("sso-exchange"))
|
||||
if err != nil {
|
||||
http.Error(rw, "Internal Server Error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var exchange oauth2.Token
|
||||
err = json.Unmarshal(rawTokens, &exchange)
|
||||
if err != nil {
|
||||
http.Error(rw, "Internal Server Error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.finishTokenGenerateFlow(rw, req, flowStateData{
|
||||
sso: loginService,
|
||||
target: allowedClient,
|
||||
}, &exchange, func(accessToken string, refreshToken string, v3 map[string]any) {
|
||||
tokens := map[string]any{
|
||||
"target": allowedClient.Url.String(),
|
||||
"userinfo": v3,
|
||||
"tokens": map[string]any{
|
||||
"access": accessToken,
|
||||
"refresh": refreshToken,
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(rw).Encode(tokens)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) finishTokenGenerateFlow(rw http.ResponseWriter, req *http.Request, v flowStateData, exchange *oauth2.Token, response func(accessToken string, refreshToken string, v3 map[string]any)) {
|
||||
// fetch user info
|
||||
v2, err := testOa2UserInfo(v.sso, req.Context(), exchange)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to get userinfo:", err)
|
||||
http.Error(rw, "Failed to get userinfo", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer v2.Body.Close()
|
||||
if v2.StatusCode != http.StatusOK {
|
||||
http.Error(rw, "Failed to get userinfo: unexpected status code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// encrypt exchange tokens for cookie storage
|
||||
marshal, err := json.Marshal(exchange)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to marshal exchange tokens", err)
|
||||
http.Error(rw, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
oaepBytes, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, h.signer.PublicKey(), marshal, []byte("sso-exchange"))
|
||||
if err != nil {
|
||||
fmt.Println("Failed to encrypt exchange tokens", err)
|
||||
http.Error(rw, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "sso-exchange",
|
||||
Value: base64.RawURLEncoding.EncodeToString(oaepBytes),
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 3, 0),
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
var v3 map[string]any
|
||||
if err = json.NewDecoder(v2.Body).Decode(&v3); err != nil {
|
||||
fmt.Println("Failed to decode userinfo:", err)
|
||||
http.Error(rw, "Failed to decode userinfo", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sub, ok := v3["sub"].(string)
|
||||
if !ok {
|
||||
http.Error(rw, "Invalid subject in userinfo", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
aud, ok := v3["aud"].(string)
|
||||
if !ok {
|
||||
http.Error(rw, "Invalid audience in userinfo", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var needsMailFlag, needsDomains bool
|
||||
|
||||
ps := claims.NewPermStorage()
|
||||
for _, i := range v.target.Permissions {
|
||||
if strings.HasPrefix(i, "dynamic:") {
|
||||
switch i {
|
||||
case "dynamic:mail-inbox":
|
||||
needsMailFlag = true
|
||||
case "dynamic:domain-owns":
|
||||
needsDomains = true
|
||||
}
|
||||
} else {
|
||||
ps.Set(i)
|
||||
}
|
||||
}
|
||||
|
||||
if needsMailFlag {
|
||||
if verified, ok := v3["email_verified"].(bool); ok && verified {
|
||||
if mailAddress, ok := v3["email"].(string); ok {
|
||||
address, err := mail.ParseAddress(mailAddress)
|
||||
if err != nil {
|
||||
http.Error(rw, "Invalid email in userinfo", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n := strings.IndexByte(address.Address, '@')
|
||||
if n != -1 {
|
||||
if address.Address[n+1:] == v.sso.Config.Namespace {
|
||||
ps.Set("mail:inbox=" + address.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needsDomains {
|
||||
a := h.conf.Load().Users.AllDomains(sub + "@" + v.sso.Config.Namespace)
|
||||
for _, i := range a {
|
||||
ps.Set("domain:owns=" + i)
|
||||
}
|
||||
}
|
||||
|
||||
nsSub := sub + "@" + v.sso.Config.Namespace
|
||||
ati := uuidNewStringAti()
|
||||
accessToken, err := h.signer.GenerateJwt(nsSub, ati, jwt.ClaimStrings{aud}, 15*time.Minute, auth.AccessTokenClaims{
|
||||
Perms: ps,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(rw, "Error generating access token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := h.signer.GenerateJwt(nsSub, uuidNewStringRti(), jwt.ClaimStrings{aud}, 15*time.Minute, auth.RefreshTokenClaims{AccessTokenId: ati})
|
||||
if err != nil {
|
||||
http.Error(rw, "Error generating refresh token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response(accessToken, refreshToken, v3)
|
||||
}
|
17
server/roles.go
Normal file
17
server/roles.go
Normal file
@ -0,0 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func HasRole(roles, test string) bool {
|
||||
sc := bufio.NewScanner(strings.NewReader(roles))
|
||||
sc.Split(bufio.ScanWords)
|
||||
for sc.Scan() {
|
||||
if sc.Text() == test {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
12
server/roles_test.go
Normal file
12
server/roles_test.go
Normal file
@ -0,0 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHasRole(t *testing.T) {
|
||||
assert.True(t, HasRole("lavender:admin test:something-else", "lavender:admin"))
|
||||
assert.False(t, HasRole("lavender:admin,test:something-else", "lavender:admin"))
|
||||
assert.False(t, HasRole("lavender: test:something-else", "lavender:admin"))
|
||||
}
|
264
server/server.go
264
server/server.go
@ -2,36 +2,48 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/1f349/cache"
|
||||
clientStore "github.com/1f349/lavender/client-store"
|
||||
"github.com/1f349/lavender/database"
|
||||
"github.com/1f349/lavender/issuer"
|
||||
"github.com/1f349/lavender/openid"
|
||||
scope2 "github.com/1f349/lavender/scope"
|
||||
"github.com/1f349/lavender/theme"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/go-oauth2/oauth2/v4/errors"
|
||||
"github.com/go-oauth2/oauth2/v4/generates"
|
||||
"github.com/go-oauth2/oauth2/v4/manage"
|
||||
"github.com/go-oauth2/oauth2/v4/server"
|
||||
"github.com/go-oauth2/oauth2/v4/store"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/rs/cors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errInvalidScope = errors.New("missing required scope")
|
||||
|
||||
type HttpServer struct {
|
||||
Server *http.Server
|
||||
r *httprouter.Router
|
||||
conf atomic.Pointer[Conf]
|
||||
manager atomic.Pointer[issuer.Manager]
|
||||
signer mjwt.Signer
|
||||
flowState *cache.Cache[string, flowStateData]
|
||||
services atomic.Pointer[map[string]AllowedClient]
|
||||
r *httprouter.Router
|
||||
oauthSrv *server.Server
|
||||
oauthMgr *manage.Manager
|
||||
db *database.DB
|
||||
conf Conf
|
||||
signingKey mjwt.Signer
|
||||
manager *issuer.Manager
|
||||
flowState *cache.Cache[string, flowStateData]
|
||||
}
|
||||
|
||||
type flowStateData struct {
|
||||
sso *issuer.WellKnownOIDC
|
||||
target AllowedClient
|
||||
sso *issuer.WellKnownOIDC
|
||||
}
|
||||
|
||||
func NewHttpServer(conf Conf, signer mjwt.Signer) *HttpServer {
|
||||
func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Server {
|
||||
r := httprouter.New()
|
||||
|
||||
// remove last slash from baseUrl
|
||||
@ -42,80 +54,186 @@ func NewHttpServer(conf Conf, signer mjwt.Signer) *HttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
hs := &HttpServer{
|
||||
Server: &http.Server{
|
||||
Addr: conf.Listen,
|
||||
Handler: r,
|
||||
ReadTimeout: time.Minute,
|
||||
ReadHeaderTimeout: time.Minute,
|
||||
WriteTimeout: time.Minute,
|
||||
IdleTimeout: time.Minute,
|
||||
MaxHeaderBytes: 2500,
|
||||
},
|
||||
r: r,
|
||||
signer: signer,
|
||||
flowState: cache.New[string, flowStateData](),
|
||||
}
|
||||
err := hs.UpdateConfig(conf)
|
||||
openIdConf := openid.GenConfig(conf.BaseUrl, []string{"openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"})
|
||||
openIdBytes, err := json.Marshal(openIdConf)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to load initial config:", err)
|
||||
return nil
|
||||
log.Fatalln("Failed to generate OpenID configuration:", err)
|
||||
}
|
||||
|
||||
r.GET("/", func(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(rw, "What is this?")
|
||||
})
|
||||
r.GET("/popup", hs.flowPopup)
|
||||
r.POST("/popup", hs.flowPopupPost)
|
||||
r.GET("/callback", hs.flowCallback)
|
||||
oauthManager := manage.NewDefaultManager()
|
||||
oauthSrv := server.NewServer(server.NewConfig(), oauthManager)
|
||||
hs := &HttpServer{
|
||||
r: httprouter.New(),
|
||||
oauthSrv: oauthSrv,
|
||||
oauthMgr: oauthManager,
|
||||
db: db,
|
||||
conf: conf,
|
||||
signingKey: signingKey,
|
||||
flowState: cache.New[string, flowStateData](),
|
||||
}
|
||||
|
||||
hs.manager, err = issuer.NewManager(conf.SsoServices)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to reload SSO service manager: %w", err)
|
||||
}
|
||||
|
||||
oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
|
||||
oauthManager.MustTokenStorage(store.NewMemoryTokenStore())
|
||||
oauthManager.MapAccessGenerate(generates.NewAccessGenerate())
|
||||
oauthManager.MapClientStorage(clientStore.New(db))
|
||||
|
||||
oauthSrv.SetResponseErrorHandler(func(re *errors.Response) {
|
||||
log.Printf("Response error: %#v\n", re)
|
||||
})
|
||||
oauthSrv.SetClientInfoHandler(func(req *http.Request) (clientID, clientSecret string, err error) {
|
||||
cId, cSecret, err := server.ClientBasicHandler(req)
|
||||
if cId == "" && cSecret == "" {
|
||||
cId, cSecret, err = server.ClientFormHandler(req)
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return cId, cSecret, nil
|
||||
})
|
||||
oauthSrv.SetUserAuthorizationHandler(hs.oauthUserAuthorization)
|
||||
oauthSrv.SetAuthorizeScopeHandler(func(rw http.ResponseWriter, req *http.Request) (scope string, err error) {
|
||||
var form url.Values
|
||||
if req.Method == http.MethodPost {
|
||||
form = req.PostForm
|
||||
} else {
|
||||
form = req.URL.Query()
|
||||
}
|
||||
a := form.Get("scope")
|
||||
if !scope2.ScopesExist(a) {
|
||||
return "", errInvalidScope
|
||||
}
|
||||
return a, nil
|
||||
})
|
||||
|
||||
r.GET("/.well-known/openid-configuration", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_, _ = rw.Write(openIdBytes)
|
||||
})
|
||||
r.GET("/", hs.OptionalAuthentication(hs.Home))
|
||||
|
||||
// login
|
||||
r.GET("/login", hs.loginGet)
|
||||
r.POST("/login", hs.loginPost)
|
||||
r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
|
||||
lNonce, ok := auth.Session.Get("action-nonce")
|
||||
if !ok {
|
||||
http.Error(rw, "Missing nonce", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(lNonce.(string)), []byte(req.PostFormValue("nonce"))) == 1 {
|
||||
auth.Session.Delete("session-data")
|
||||
if auth.Session.Save() != nil {
|
||||
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "login-data",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
http.Redirect(rw, req, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Error(rw, "Logout failed", http.StatusInternalServerError)
|
||||
}))
|
||||
|
||||
// theme styles
|
||||
r.GET("/theme/style.css", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
http.ServeContent(rw, req, "style.css", time.Now(), bytes.NewReader(theme.DefaultThemeCss))
|
||||
})
|
||||
|
||||
// setup CORS options for `/verify` and `/refresh` endpoints
|
||||
var corsAccessControl = cors.New(cors.Options{
|
||||
AllowOriginFunc: func(origin string) bool {
|
||||
load := hs.services.Load()
|
||||
_, ok := (*load)[strings.TrimSuffix(origin, "/")]
|
||||
return ok
|
||||
},
|
||||
AllowedMethods: []string{http.MethodPost, http.MethodOptions},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
AllowCredentials: true,
|
||||
// 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", hs.RequireAuthentication(hs.authorizeEndpoint))
|
||||
r.POST("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint))
|
||||
r.POST("/token", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
if err := oauthSrv.HandleTokenRequest(rw, req); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
r.GET("/userinfo", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
token, err := oauthSrv.ValidationBearerToken(req)
|
||||
if err != nil {
|
||||
http.Error(rw, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
userId := token.GetUserID()
|
||||
|
||||
fmt.Printf("Using token for user: %s by app: %s with scope: '%s'\n", userId, token.GetClientID(), token.GetScope())
|
||||
claims := ParseClaims(token.GetScope())
|
||||
if !claims["openid"] {
|
||||
http.Error(rw, "Invalid scope", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
m := map[string]any{}
|
||||
m["sub"] = userId
|
||||
m["aud"] = token.GetClientID()
|
||||
m["updated_at"] = time.Now().Unix()
|
||||
|
||||
_ = json.NewEncoder(rw).Encode(m)
|
||||
})
|
||||
|
||||
// `/verify` and `/refresh` need CORS headers to be usable on other domains
|
||||
r.POST("/verify", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
corsAccessControl.ServeHTTP(rw, req, func(writer http.ResponseWriter, request *http.Request) {
|
||||
hs.verifyHandler(rw, req, params)
|
||||
})
|
||||
})
|
||||
r.POST("/refresh", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
corsAccessControl.ServeHTTP(rw, req, func(writer http.ResponseWriter, request *http.Request) {
|
||||
hs.refreshHandler(rw, req, params)
|
||||
})
|
||||
})
|
||||
r.OPTIONS("/refresh", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
corsAccessControl.ServeHTTP(rw, req, func(_ http.ResponseWriter, _ *http.Request) {})
|
||||
})
|
||||
return hs
|
||||
return &http.Server{
|
||||
Addr: conf.Listen,
|
||||
Handler: r,
|
||||
ReadTimeout: time.Minute,
|
||||
ReadHeaderTimeout: time.Minute,
|
||||
WriteTimeout: time.Minute,
|
||||
IdleTimeout: time.Minute,
|
||||
MaxHeaderBytes: 2500,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpServer) UpdateConfig(conf Conf) error {
|
||||
m, err := issuer.NewManager(conf.SsoServices)
|
||||
func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
|
||||
redirectUrl := req.FormValue("redirect")
|
||||
if redirectUrl == "" {
|
||||
http.Redirect(rw, req, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
parse, err := url.Parse(redirectUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload SSO service manager: %w", err)
|
||||
http.Error(rw, "Failed to parse redirect url: "+redirectUrl, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientLookup := make(map[string]AllowedClient)
|
||||
for _, i := range conf.AllowedClients {
|
||||
clientLookup[i.Url.String()] = i
|
||||
if parse.Scheme != "" && parse.Opaque != "" && parse.User != nil && parse.Host != "" {
|
||||
http.Error(rw, "Invalid redirect url: "+redirectUrl, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.conf.Store(&conf)
|
||||
h.manager.Store(m)
|
||||
h.services.Store(&clientLookup)
|
||||
return nil
|
||||
http.Redirect(rw, req, parse.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func ParseClaims(claims string) map[string]bool {
|
||||
m := make(map[string]bool)
|
||||
for {
|
||||
n := strings.IndexByte(claims, ' ')
|
||||
if n == -1 {
|
||||
if claims != "" {
|
||||
m[claims] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
a := claims[:n]
|
||||
claims = claims[n+1:]
|
||||
if a != "" {
|
||||
m[a] = true
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
@ -1,33 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/mjwt/auth"
|
||||
"github.com/1f349/violet/utils"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *HttpServer) verifyHandler(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
// find bearer token
|
||||
bearer := utils.GetBearer(req)
|
||||
if bearer == "" {
|
||||
http.Error(rw, "Missing bearer", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// after this mjwt is considered valid
|
||||
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](h.signer, bearer)
|
||||
if err != nil {
|
||||
http.Error(rw, "Invalid token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// check issuer against config
|
||||
if b.Issuer != h.conf.Load().Issuer {
|
||||
http.Error(rw, "Invalid issuer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/mjwt/auth"
|
||||
"github.com/1f349/mjwt/claims"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVerifyHandler(t *testing.T) {
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
assert.NoError(t, err)
|
||||
|
||||
invalidSigner := mjwt.NewMJwtSigner("Invalid Issuer", privKey)
|
||||
h := HttpServer{
|
||||
signer: mjwt.NewMJwtSigner("Test Issuer", privKey),
|
||||
}
|
||||
h.conf.Store(&Conf{Issuer: "Test Issuer"})
|
||||
|
||||
// test for missing bearer response
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.localhost", nil)
|
||||
h.verifyHandler(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Equal(t, "Missing bearer\n", rec.Body.String())
|
||||
|
||||
// test for invalid token response
|
||||
rec = httptest.NewRecorder()
|
||||
req.Header.Set("Authorization", "Bearer abcd")
|
||||
h.verifyHandler(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Equal(t, "Invalid token\n", rec.Body.String())
|
||||
|
||||
// test for invalid issuer response
|
||||
rec = httptest.NewRecorder()
|
||||
accessToken, err := invalidSigner.GenerateJwt("a", "a", nil, 15*time.Minute, auth.AccessTokenClaims{
|
||||
Perms: claims.NewPermStorage(),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
h.verifyHandler(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Equal(t, "Invalid issuer\n", rec.Body.String())
|
||||
|
||||
// test for invalid issuer response
|
||||
rec = httptest.NewRecorder()
|
||||
accessToken, err = h.signer.GenerateJwt("a", "a", nil, 15*time.Minute, auth.AccessTokenClaims{
|
||||
Perms: claims.NewPermStorage(),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
h.verifyHandler(rec, req, httprouter.Params{})
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
Loading…
Reference in New Issue
Block a user