mirror of
https://github.com/1f349/tulip.git
synced 2024-12-22 08:14:13 +00:00
Initial merging
This commit is contained in:
parent
2c3f5d671b
commit
92284d5147
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -11,5 +11,5 @@ jobs:
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- uses: actions/checkout@v3
|
||||
- run: go build ./cmd/tulip/
|
||||
- run: go build ./cmd/red-tulip/
|
||||
- run: go test ./...
|
||||
|
@ -1,3 +1,11 @@
|
||||
# Tulip
|
||||
|
||||
Authentication services for 1f349 and partners.
|
||||
|
||||
## Red Tulip
|
||||
|
||||
A login service with OpenID, OAuth2 and SSO support. Enables easy use of a single authentication source for a network of services.
|
||||
|
||||
## Purple Tulip
|
||||
|
||||
An authentication source for multiple login services to be used with a frontend single page application.
|
117
cmd/purple-tulip/serve.go
Normal file
117
cmd/purple-tulip/serve.go
Normal file
@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/tulip/purple-server"
|
||||
"github.com/1f349/tulip/purple-server/pages"
|
||||
"github.com/1f349/violet/utils"
|
||||
exitReload "github.com/MrMelon54/exit-reload"
|
||||
"github.com/google/subcommands"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type serveCmd struct{ configPath string }
|
||||
|
||||
func (s *serveCmd) Name() string { return "serve" }
|
||||
|
||||
func (s *serveCmd) Synopsis() string { return "Serve API authentication service" }
|
||||
|
||||
func (s *serveCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.StringVar(&s.configPath, "conf", "", "/path/to/config.json : path to the config file")
|
||||
}
|
||||
|
||||
func (s *serveCmd) Usage() string {
|
||||
return `serve [-conf <config file>]
|
||||
Serve API authentication service using information from the config file
|
||||
`
|
||||
}
|
||||
|
||||
func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
log.Println("[Lavender] Starting...")
|
||||
|
||||
if s.configPath == "" {
|
||||
log.Println("[Lavender] Error: config flag is missing")
|
||||
return subcommands.ExitUsageError
|
||||
}
|
||||
|
||||
var conf server.Conf
|
||||
err := loadConfig(s.configPath, &conf)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Println("[Lavender] Error: missing config file")
|
||||
} else {
|
||||
log.Println("[Lavender] Error: loading 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)
|
||||
if err != nil {
|
||||
log.Fatal("[Lavender] Failed to load or create MJWT signer:", err)
|
||||
}
|
||||
saveMjwtPubKey(mSign, wd)
|
||||
|
||||
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 red-server on '%s'\n", srv.Server.Addr)
|
||||
go utils.RunBackgroundHttp("HTTP", srv.Server)
|
||||
|
||||
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() {
|
||||
// stop http red-server
|
||||
_ = srv.Server.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)
|
||||
err := pem.Encode(b, &pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubKey})
|
||||
if err != nil {
|
||||
log.Fatal("[Lavender] Failed to encode MJWT public key:", err)
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(wd, "lavender.public.key"), b.Bytes(), 0600)
|
||||
if err != nil && !errors.Is(err, os.ErrExist) {
|
||||
log.Fatal("[Lavender] Failed to save MJWT public key:", err)
|
||||
}
|
||||
}
|
19
cmd/red-tulip/main.go
Normal file
19
cmd/red-tulip/main.go
Normal file
@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"github.com/google/subcommands"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
subcommands.Register(subcommands.HelpCommand(), "")
|
||||
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||
subcommands.Register(&serveCmd{}, "")
|
||||
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
os.Exit(int(subcommands.Execute(ctx)))
|
||||
}
|
@ -11,8 +11,8 @@ import (
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/mail/templates"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/server"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/1f349/tulip/red-server"
|
||||
"github.com/1f349/violet/utils"
|
||||
"github.com/MrMelon54/exit-reload"
|
||||
"github.com/google/subcommands"
|
||||
@ -39,68 +39,68 @@ func (s *serveCmd) Usage() string {
|
||||
}
|
||||
|
||||
func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...any) subcommands.ExitStatus {
|
||||
log.Println("[Tulip] Starting...")
|
||||
log.Println("[RedTulip] Starting...")
|
||||
|
||||
if s.configPath == "" {
|
||||
log.Println("[Tulip] Error: config flag is missing")
|
||||
log.Println("[RedTulip] Error: config flag is missing")
|
||||
return subcommands.ExitUsageError
|
||||
}
|
||||
|
||||
openConf, err := os.Open(s.configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Println("[Tulip] Error: missing config file")
|
||||
log.Println("[RedTulip] Error: missing config file")
|
||||
} else {
|
||||
log.Println("[Tulip] Error: open config file: ", err)
|
||||
log.Println("[RedTulip] Error: open config file: ", err)
|
||||
}
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
var config server.Conf
|
||||
var config red_server.Conf
|
||||
err = json.NewDecoder(openConf).Decode(&config)
|
||||
if err != nil {
|
||||
log.Println("[Tulip] Error: invalid config file: ", err)
|
||||
log.Println("[RedTulip] Error: invalid config file: ", err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
configPathAbs, err := filepath.Abs(s.configPath)
|
||||
if err != nil {
|
||||
log.Fatal("[Tulip] Failed to get absolute config path")
|
||||
log.Fatal("[RedTulip] Failed to get absolute config path")
|
||||
}
|
||||
wd := filepath.Dir(configPathAbs)
|
||||
normalLoad(config, wd)
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
func normalLoad(startUp server.Conf, wd string) {
|
||||
func normalLoad(startUp red_server.Conf, wd string) {
|
||||
signingKey, err := mjwt.NewMJwtSignerFromFileOrCreate(startUp.OtpIssuer, filepath.Join(wd, "tulip.key.pem"), rand.Reader, 4096)
|
||||
if err != nil {
|
||||
log.Fatal("[Tulip] Failed to open signing key file:", err)
|
||||
}
|
||||
|
||||
db, err := database.Open(filepath.Join(wd, "tulip.db.sqlite"))
|
||||
db, err := database.Open(filepath.Join(wd, "red-red-tulip.db.sqlite"))
|
||||
if err != nil {
|
||||
log.Fatal("[Tulip] Failed to open database:", err)
|
||||
log.Fatal("[RedTulip] Failed to open database:", err)
|
||||
}
|
||||
|
||||
log.Println("[Tulip] Checking database contains at least one user")
|
||||
log.Println("[RedTulip] Checking database contains at least one user")
|
||||
if err := checkDbHasUser(db); err != nil {
|
||||
log.Fatal("[Tulip] Failed check:", err)
|
||||
log.Fatal("[RedTulip] Failed check:", err)
|
||||
}
|
||||
|
||||
if err = pages.LoadPages(wd); err != nil {
|
||||
log.Fatal("[Tulip] Failed to load page templates:", err)
|
||||
if err = red_pages.LoadPages(wd); err != nil {
|
||||
log.Fatal("[RedTulip] Failed to load page templates:", err)
|
||||
}
|
||||
if err := templates.LoadMailTemplates(wd); err != nil {
|
||||
log.Fatal("[Tulip] Failed to load mail templates:", err)
|
||||
log.Fatal("[RedTulip] Failed to load mail templates:", err)
|
||||
}
|
||||
|
||||
srv := server.NewHttpServer(startUp, db, signingKey)
|
||||
log.Printf("[Tulip] Starting HTTP server on '%s'\n", srv.Addr)
|
||||
srv := red_server.NewHttpServer(startUp, db, signingKey)
|
||||
log.Printf("[RedTulip] Starting HTTP red-server on '%s'\n", srv.Addr)
|
||||
go utils.RunBackgroundHttp("HTTP", srv)
|
||||
|
||||
exit_reload.ExitReload("Tulip", func() {}, func() {
|
||||
// stop http server
|
||||
exit_reload.ExitReload("RedTulip", func() {}, func() {
|
||||
// stop http red-server
|
||||
_ = srv.Close()
|
||||
_ = db.Close()
|
||||
})
|
||||
@ -110,10 +110,10 @@ func genHmacKey() []byte {
|
||||
a := make([]byte, 32)
|
||||
n, err := rand.Reader.Read(a)
|
||||
if err != nil {
|
||||
log.Fatal("[Tulip] Failed to generate HMAC key")
|
||||
log.Fatal("[RedTulip] Failed to generate HMAC key")
|
||||
}
|
||||
if n != 32 {
|
||||
log.Fatal("[Tulip] Failed to generate HMAC key")
|
||||
log.Fatal("[RedTulip] Failed to generate HMAC key")
|
||||
}
|
||||
return a
|
||||
}
|
@ -3,7 +3,7 @@ package database
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/1f349/tulip/password"
|
||||
"github.com/1f349/tulip/utils"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
@ -37,7 +37,7 @@ func (t *Tx) HasUser() error {
|
||||
}
|
||||
|
||||
func (t *Tx) InsertUser(name, un, pw, email string, verifyEmail bool, role UserRole, active bool) (uuid.UUID, error) {
|
||||
pwHash, err := password.HashPassword(pw)
|
||||
pwHash, err := utils.HashPassword(pw)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
@ -48,14 +48,14 @@ func (t *Tx) InsertUser(name, un, pw, email string, verifyEmail bool, role UserR
|
||||
|
||||
func (t *Tx) CheckLogin(un, pw string) (*User, bool, bool, error) {
|
||||
var u User
|
||||
var pwHash password.HashString
|
||||
var pwHash utils.HashString
|
||||
var hasOtp, hasVerify bool
|
||||
row := t.tx.QueryRow(`SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject), email, email_verified FROM users WHERE username = ?`, un)
|
||||
err := row.Scan(&u.Sub, &pwHash, &hasOtp, &u.Email, &hasVerify)
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
err = password.CheckPasswordHash(pwHash, pw)
|
||||
err = utils.CheckPasswordHash(pwHash, pw)
|
||||
return &u, hasOtp, hasVerify, err
|
||||
}
|
||||
|
||||
@ -94,7 +94,7 @@ func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pwHash password.HashString
|
||||
var pwHash utils.HashString
|
||||
if q.Next() {
|
||||
err = q.Scan(&pwHash)
|
||||
if err != nil {
|
||||
@ -109,11 +109,11 @@ func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error {
|
||||
if err := q.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = password.CheckPasswordHash(pwHash, pwOld)
|
||||
err = utils.CheckPasswordHash(pwHash, pwOld)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pwNewHash, err := password.HashPassword(pwNew)
|
||||
pwNewHash, err := utils.HashPassword(pwNew)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -227,7 +227,7 @@ func (t *Tx) GetAppList(offset int) ([]ClientInfoDbOutput, error) {
|
||||
|
||||
func (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner uuid.UUID) error {
|
||||
u := uuid.New()
|
||||
secret, err := password.GenerateApiSecret(70)
|
||||
secret, err := utils.GenerateApiSecret(70)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -241,7 +241,7 @@ func (t *Tx) UpdateClientApp(subject, owner uuid.UUID, name, domain string, sso,
|
||||
}
|
||||
|
||||
func (t *Tx) ResetClientAppSecret(subject, owner uuid.UUID) (string, error) {
|
||||
secret, err := password.GenerateApiSecret(70)
|
||||
secret, err := utils.GenerateApiSecret(70)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -277,7 +277,7 @@ func (t *Tx) VerifyUserEmail(sub uuid.UUID) error {
|
||||
}
|
||||
|
||||
func (t *Tx) UserResetPassword(sub uuid.UUID, pw string) error {
|
||||
hashPassword, err := password.HashPassword(pw)
|
||||
hashPassword, err := utils.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/1f349/tulip/password"
|
||||
"github.com/1f349/tulip/utils"
|
||||
"github.com/MrMelon54/pronouns"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -12,7 +12,7 @@ import (
|
||||
|
||||
func TestTx_ChangeUserPassword(t *testing.T) {
|
||||
u := uuid.New()
|
||||
pw, err := password.HashPassword("test")
|
||||
pw, err := utils.HashPassword("test")
|
||||
assert.NoError(t, err)
|
||||
d, err := Open("file::memory:")
|
||||
assert.NoError(t, err)
|
||||
@ -26,16 +26,16 @@ func TestTx_ChangeUserPassword(t *testing.T) {
|
||||
query, err := d.db.Query(`SELECT password FROM users WHERE subject = ? AND username = ?`, u.String(), "test")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, query.Next())
|
||||
var oldPw password.HashString
|
||||
var oldPw utils.HashString
|
||||
assert.NoError(t, query.Scan(&oldPw))
|
||||
assert.NoError(t, password.CheckPasswordHash(oldPw, "new"))
|
||||
assert.NoError(t, utils.CheckPasswordHash(oldPw, "new"))
|
||||
assert.NoError(t, query.Err())
|
||||
assert.NoError(t, query.Close())
|
||||
}
|
||||
|
||||
func TestTx_ModifyUser(t *testing.T) {
|
||||
u := uuid.New()
|
||||
pw, err := password.HashPassword("test")
|
||||
pw, err := utils.HashPassword("test")
|
||||
assert.NoError(t, err)
|
||||
d, err := Open("file::memory:")
|
||||
assert.NoError(t, err)
|
||||
|
8
go.mod
8
go.mod
@ -14,23 +14,27 @@ require (
|
||||
github.com/emersion/go-smtp v0.20.1
|
||||
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.5.0
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/mattn/go-sqlite3 v1.14.19
|
||||
github.com/rs/cors v1.10.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
golang.org/x/crypto v0.18.0
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
|
||||
golang.org/x/text v0.14.0
|
||||
)
|
||||
|
||||
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.2+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||
github.com/golang/protobuf v1.4.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/tidwall/btree v1.7.0 // indirect
|
||||
@ -42,5 +46,7 @@ require (
|
||||
github.com/tidwall/rtred v0.1.2 // indirect
|
||||
github.com/tidwall/tinyqueue v0.1.1 // indirect
|
||||
golang.org/x/net v0.19.0 // indirect
|
||||
google.golang.org/appengine v1.6.6 // indirect
|
||||
google.golang.org/protobuf v1.23.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
10
go.sum
10
go.sum
@ -17,6 +17,8 @@ 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/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=
|
||||
@ -52,9 +54,11 @@ 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/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-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
@ -103,6 +107,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
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=
|
||||
@ -190,6 +196,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
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/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=
|
||||
@ -230,14 +237,17 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
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/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
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=
|
||||
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=
|
||||
|
61
issuer/manager.go
Normal file
61
issuer/manager.go
Normal file
@ -0,0 +1,61 @@
|
||||
package issuer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var isValidNamespace = regexp.MustCompile("^[0-9a-z.]+$")
|
||||
|
||||
type Manager struct {
|
||||
m map[string]*WellKnownOIDC
|
||||
}
|
||||
|
||||
func NewManager(services []SsoConfig) (*Manager, error) {
|
||||
l := &Manager{m: make(map[string]*WellKnownOIDC)}
|
||||
for _, i := range services {
|
||||
if !isValidNamespace.MatchString(i.Namespace) {
|
||||
return nil, fmt.Errorf("invalid namespace: %s", i.Namespace)
|
||||
}
|
||||
|
||||
conf, err := i.FetchConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// save by namespace
|
||||
l.m[i.Namespace] = conf
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func NewManagerForTests(services []*WellKnownOIDC) *Manager {
|
||||
l := &Manager{m: make(map[string]*WellKnownOIDC, len(services))}
|
||||
for _, i := range services {
|
||||
if !isValidNamespace.MatchString(i.Config.Namespace) {
|
||||
panic("Invalid namespace in tests")
|
||||
}
|
||||
l.m[i.Config.Namespace] = i
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Manager) CheckNamespace(namespace string) bool {
|
||||
_, ok := l.m[namespace]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC {
|
||||
// @ should have at least one byte before it
|
||||
n := strings.IndexByte(login, '@')
|
||||
if n < 1 {
|
||||
return nil
|
||||
}
|
||||
// there should not be a second @
|
||||
n2 := strings.IndexByte(login[n+1:], '@')
|
||||
if n2 != -1 {
|
||||
return nil
|
||||
}
|
||||
return l.m[login[n+1:]]
|
||||
}
|
53
issuer/manager_test.go
Normal file
53
issuer/manager_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package issuer
|
||||
|
||||
import (
|
||||
"github.com/1f349/tulip/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testAddrUrl = func() utils.JsonUrl {
|
||||
a, err := url.Parse("https://example.com")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return utils.JsonUrl{URL: a}
|
||||
}()
|
||||
|
||||
func testBody() io.ReadCloser {
|
||||
return io.NopCloser(strings.NewReader("{}"))
|
||||
}
|
||||
|
||||
func TestManager_CheckNamespace(t *testing.T) {
|
||||
httpGet = func(url string) (resp *http.Response, err error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: testBody()}, nil
|
||||
}
|
||||
manager, err := NewManager([]SsoConfig{
|
||||
{
|
||||
Addr: testAddrUrl,
|
||||
Namespace: "example.com",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, manager.CheckNamespace("example.com"))
|
||||
assert.False(t, manager.CheckNamespace("missing.example.com"))
|
||||
}
|
||||
|
||||
func TestManager_FindServiceFromLogin(t *testing.T) {
|
||||
httpGet = func(url string) (resp *http.Response, err error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: testBody()}, nil
|
||||
}
|
||||
manager, err := NewManager([]SsoConfig{
|
||||
{
|
||||
Addr: testAddrUrl,
|
||||
Namespace: "example.com",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, manager.FindServiceFromLogin("jane@example.com"), manager.m["example.com"])
|
||||
assert.Nil(t, manager.FindServiceFromLogin("jane@missing.example.com"))
|
||||
}
|
114
issuer/sso.go
Normal file
114
issuer/sso.go
Normal file
@ -0,0 +1,114 @@
|
||||
package issuer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/1f349/tulip/utils"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var httpGet = http.Get
|
||||
|
||||
// SsoConfig is the base URL for an OAUTH/OPENID/SSO login service
|
||||
// The path `/.well-known/openid-configuration` should be available
|
||||
type SsoConfig struct {
|
||||
Addr utils.JsonUrl `json:"addr"` // https://login.example.com
|
||||
Namespace string `json:"namespace"` // example.com
|
||||
Client SsoConfigClient `json:"client"`
|
||||
}
|
||||
|
||||
type SsoConfigClient struct {
|
||||
ID string `json:"id"`
|
||||
Secret string `json:"secret"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) {
|
||||
// generate openid config url
|
||||
u := s.Addr.String()
|
||||
if !strings.HasSuffix(u, "/") {
|
||||
u += "/"
|
||||
}
|
||||
u += ".well-known/openid-configuration"
|
||||
|
||||
// fetch metadata
|
||||
get, err := httpGet(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer get.Body.Close()
|
||||
|
||||
var c WellKnownOIDC
|
||||
err = json.NewDecoder(get.Body).Decode(&c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Config = s
|
||||
c.OAuth2Config = oauth2.Config{
|
||||
ClientID: c.Config.Client.ID,
|
||||
ClientSecret: c.Config.Client.Secret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: c.AuthorizationEndpoint,
|
||||
TokenURL: c.TokenEndpoint,
|
||||
AuthStyle: oauth2.AuthStyleInHeader,
|
||||
},
|
||||
Scopes: c.Config.Client.Scopes,
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
type WellKnownOIDC struct {
|
||||
Config SsoConfig `json:"-"`
|
||||
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"`
|
||||
OAuth2Config oauth2.Config `json:"-"`
|
||||
}
|
||||
|
||||
func (o WellKnownOIDC) Validate() error {
|
||||
if o.Issuer == "" {
|
||||
return errors.New("missing issuer")
|
||||
}
|
||||
|
||||
// check URLs are valid
|
||||
if _, err := url.Parse(o.AuthorizationEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := url.Parse(o.TokenEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := url.Parse(o.UserInfoEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check oidc supported values
|
||||
if !slices.Contains(o.ResponseTypesSupported, "code") {
|
||||
return errors.New("missing required response type 'code'")
|
||||
}
|
||||
if !slices.Contains(o.ScopesSupported, "openid") {
|
||||
return errors.New("missing required scope 'openid'")
|
||||
}
|
||||
requiredClaims := []string{"sub", "name", "preferred_username", "email", "email_verified"}
|
||||
for _, i := range requiredClaims {
|
||||
if !slices.Contains(o.ClaimsSupported, i) {
|
||||
return fmt.Errorf("missing required claim '%s'", i)
|
||||
}
|
||||
}
|
||||
|
||||
// oidc valid
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o WellKnownOIDC) ValidReturnUrl(u *url.URL) bool {
|
||||
return o.Config.Addr.Scheme == u.Scheme && o.Config.Addr.Host == u.Host
|
||||
}
|
1
issuer/sso_test.go
Normal file
1
issuer/sso_test.go
Normal file
@ -0,0 +1 @@
|
||||
package issuer
|
@ -13,7 +13,7 @@ import (
|
||||
type Mail struct {
|
||||
Name string `json:"name"`
|
||||
Tls bool `json:"tls"`
|
||||
Server string `json:"server"`
|
||||
Server string `json:"red-server"`
|
||||
From FromAddress `json:"from"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
|
21
purple-server/conf.go
Normal file
21
purple-server/conf.go
Normal file
@ -0,0 +1,21 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/tulip/issuer"
|
||||
"github.com/1f349/tulip/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"`
|
||||
}
|
146
purple-server/flow.go
Normal file
146
purple-server/flow.go
Normal file
@ -0,0 +1,146 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"github.com/1f349/tulip/issuer"
|
||||
"github.com/1f349/tulip/purple-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,
|
||||
})
|
||||
})
|
||||
}
|
445
purple-server/flow_test.go
Normal file
445
purple-server/flow_test.go
Normal file
@ -0,0 +1,445 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/1f349/cache"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/tulip/issuer"
|
||||
"github.com/1f349/tulip/purple-server/pages"
|
||||
"github.com/1f349/tulip/utils"
|
||||
"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)
|
||||
}
|
33
purple-server/owners.go
Normal file
33
purple-server/owners.go
Normal file
@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
29
purple-server/pages/flow-callback.go.html
Normal file
29
purple-server/pages/flow-callback.go.html
Normal file
@ -0,0 +1,29 @@
|
||||
<!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>
|
28
purple-server/pages/flow-popup-memory.go.html
Normal file
28
purple-server/pages/flow-popup-memory.go.html
Normal file
@ -0,0 +1,28 @@
|
||||
<!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>Log in as: <span>{{.LoginName}}</span></div>
|
||||
<div>
|
||||
<form method="POST" action="/popup">
|
||||
<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">
|
||||
<input type="hidden" name="origin" value="{{.Origin}}"/>
|
||||
<input type="hidden" name="loginname" value="{{.LoginName}}"/>
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
22
purple-server/pages/flow-popup.go.html
Normal file
22
purple-server/pages/flow-popup.go.html
Normal file
@ -0,0 +1,22 @@
|
||||
<!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="/popup">
|
||||
<input type="hidden" name="origin" value="{{.Origin}}"/>
|
||||
<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>
|
46
purple-server/pages/pages.go
Normal file
46
purple-server/pages/pages.go
Normal file
@ -0,0 +1,46 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"embed"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"github.com/1f349/overlapfs"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed *.go.html
|
||||
flowPages embed.FS
|
||||
flowTemplates *template.Template
|
||||
loadOnce sync.Once
|
||||
)
|
||||
|
||||
func LoadPages(wd string) (err error) {
|
||||
loadOnce.Do(func() {
|
||||
var o fs.FS = flowPages
|
||||
if wd != "" {
|
||||
wwwDir := filepath.Join(wd, "www")
|
||||
err = os.Mkdir(wwwDir, os.ModePerm)
|
||||
if err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return
|
||||
}
|
||||
wdFs := os.DirFS(wwwDir)
|
||||
o = overlapfs.OverlapFS{A: flowPages, B: wdFs}
|
||||
}
|
||||
flowTemplates, err = template.ParseFS(o, "*.go.html")
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func RenderPageTemplate(wr io.Writer, name string, data any) {
|
||||
err := flowTemplates.ExecuteTemplate(wr, name+".go.html", data)
|
||||
if err != nil {
|
||||
log.Printf("Failed to render page: %s: %s\n", name, err)
|
||||
}
|
||||
}
|
183
purple-server/refresh.go
Normal file
183
purple-server/refresh.go
Normal file
@ -0,0 +1,183 @@
|
||||
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 red-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 red-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)
|
||||
}
|
121
purple-server/server.go
Normal file
121
purple-server/server.go
Normal file
@ -0,0 +1,121 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/1f349/cache"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/tulip/issuer"
|
||||
"github.com/1f349/tulip/theme"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/rs/cors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
type flowStateData struct {
|
||||
sso *issuer.WellKnownOIDC
|
||||
target AllowedClient
|
||||
}
|
||||
|
||||
func NewHttpServer(conf Conf, signer mjwt.Signer) *HttpServer {
|
||||
r := httprouter.New()
|
||||
|
||||
// remove last slash from baseUrl
|
||||
{
|
||||
l := len(conf.BaseUrl)
|
||||
if conf.BaseUrl[l-1] == '/' {
|
||||
conf.BaseUrl = conf.BaseUrl[:l-1]
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to load initial config:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
// `/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
|
||||
}
|
||||
|
||||
func (h *HttpServer) UpdateConfig(conf Conf) error {
|
||||
m, err := issuer.NewManager(conf.SsoServices)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload SSO service manager: %w", err)
|
||||
}
|
||||
|
||||
clientLookup := make(map[string]AllowedClient)
|
||||
for _, i := range conf.AllowedClients {
|
||||
clientLookup[i.Url.String()] = i
|
||||
}
|
||||
|
||||
h.conf.Store(&conf)
|
||||
h.manager.Store(m)
|
||||
h.services.Store(&clientLookup)
|
||||
return nil
|
||||
}
|
33
purple-server/verify.go
Normal file
33
purple-server/verify.go
Normal file
@ -0,0 +1,33 @@
|
||||
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)
|
||||
}
|
61
purple-server/verify_test.go
Normal file
61
purple-server/verify_test.go
Normal file
@ -0,0 +1,61 @@
|
||||
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)
|
||||
}
|
142
purple-test-client/index.html
Normal file
142
purple-test-client/index.html
Normal file
@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test Client</title>
|
||||
<script>
|
||||
let currentLoginPopup = null;
|
||||
let currentTokens = null;
|
||||
const ssoService = "http://localhost:9090";
|
||||
|
||||
function updateTokenInfo(data) {
|
||||
currentTokens = data.tokens;
|
||||
data.tokens = {
|
||||
access: "*****",
|
||||
refresh: "*****",
|
||||
}
|
||||
document.getElementById("someTextArea").textContent = JSON.stringify(data, null, 2);
|
||||
let perms = document.getElementById("somePerms");
|
||||
while (perms.childNodes.length > 0) {
|
||||
perms.childNodes.item(0).remove();
|
||||
}
|
||||
document.getElementById("tokenValues").textContent = JSON.stringify(currentTokens, null, 2);
|
||||
|
||||
let jwt = parseJwt(currentTokens.access);
|
||||
if (jwt.per != null) {
|
||||
jwt.per.forEach(function (x) {
|
||||
let a = document.createElement("li");
|
||||
a.textContent = x;
|
||||
perms.appendChild(a);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
if (event.origin !== ssoService) return;
|
||||
if (isObject(event.data)) {
|
||||
updateTokenInfo(event.data);
|
||||
|
||||
if (currentLoginPopup) currentLoginPopup.close();
|
||||
return;
|
||||
}
|
||||
alert("Failed to log user in: the login data was probably corrupted");
|
||||
});
|
||||
|
||||
function parseJwt(token) {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
|
||||
function isObject(obj) {
|
||||
return obj != null && obj.constructor.name === "Object"
|
||||
}
|
||||
|
||||
function popupCenterScreen(url, title, w, h, focus) {
|
||||
const top = (screen.availHeight - h) / 4, left = (screen.availWidth - w) / 2;
|
||||
const popup = openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
|
||||
if (focus === true && window.focus) popup.focus();
|
||||
return popup;
|
||||
}
|
||||
|
||||
function openWindow(url, winnm, options) {
|
||||
var wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]);
|
||||
var wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]);
|
||||
var top = 0, left = 0;
|
||||
var result;
|
||||
if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]);
|
||||
if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]);
|
||||
if (options) {
|
||||
options = options.replace("top=" + top, "top=" + (parseInt(top) + wTop));
|
||||
options = options.replace("left=" + left, "left=" + (parseInt(left) + wLeft));
|
||||
w = window.open(url, winnm, options);
|
||||
} else w = window.open(url, winnm);
|
||||
return w;
|
||||
}
|
||||
|
||||
function firstAvailableValue(arr) {
|
||||
for (var i = 0; i < arr.length; i++)
|
||||
if (typeof arr[i] != 'undefined')
|
||||
return arr[i];
|
||||
}
|
||||
|
||||
function doThisThing() {
|
||||
if (currentLoginPopup) currentLoginPopup.close();
|
||||
currentLoginPopup = popupCenterScreen(ssoService + '/popup?origin=' + encodeURIComponent(location.origin), 'Login with Lavender', 500, 500, false);
|
||||
}
|
||||
|
||||
async function refreshAllTokens() {
|
||||
let req = await fetch(ssoService + '/refresh', {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({"token": currentTokens.refresh}),
|
||||
});
|
||||
let reqJson = await req.json();
|
||||
updateTokenInfo(reqJson);
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
#someTextArea {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
#tokenValues {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Test Client</h1>
|
||||
</header>
|
||||
<main>
|
||||
<div>
|
||||
<button onclick="doThisThing();">Login</button>
|
||||
<button onclick="refreshAllTokens();">Refresh</button>
|
||||
</div>
|
||||
<div style="display:flex; gap: 2em;">
|
||||
<div>
|
||||
<div>
|
||||
<label for="someTextArea"></label><textarea id="someTextArea"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tokenValues"></label><textarea id="tokenValues"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Permissions:</p>
|
||||
<ul id="somePerms"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
4
purple-test-client/run.sh
Executable file
4
purple-test-client/run.sh
Executable file
@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname -- "$(readlink -f -- "$0";)";)"
|
||||
|
||||
python3 -m http.server 2020
|
@ -1,4 +1,4 @@
|
||||
package pages
|
||||
package red_pages
|
||||
|
||||
import (
|
||||
"embed"
|
||||
@ -33,7 +33,7 @@ func LoadPages(wd string) (err error) {
|
||||
wdFs := os.DirFS(wwwDir)
|
||||
o = overlapfs.OverlapFS{A: wwwPages, B: wdFs}
|
||||
}
|
||||
wwwTemplates, err = template.New("pages").Funcs(template.FuncMap{
|
||||
wwwTemplates, err = template.New("red-pages").Funcs(template.FuncMap{
|
||||
"emailHide": EmailHide,
|
||||
}).ParseFS(o, "*.go.html")
|
||||
})
|
@ -1,4 +1,4 @@
|
||||
package pages
|
||||
package red_pages
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
@ -1,4 +1,4 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
@ -1,4 +1,4 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"context"
|
@ -1,4 +1,4 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import "github.com/1f349/tulip/mail"
|
||||
|
@ -1,4 +1,4 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"github.com/1f349/tulip/database"
|
@ -1,10 +1,10 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/lists"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/1f349/tulip/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
@ -30,13 +30,13 @@ func (h *HttpServer) EditGet(rw http.ResponseWriter, _ *http.Request, _ httprout
|
||||
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pages.RenderPageTemplate(rw, "edit", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "edit", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"User": user,
|
||||
"Nonce": lNonce,
|
||||
"FieldPronoun": user.Pronouns.String(),
|
||||
"ListZoneInfo": lists.ListZoneInfo(),
|
||||
"ListLocale": lists.ListLocale(),
|
||||
"ListZoneInfo": utils.ListZoneInfo(),
|
||||
"ListLocale": utils.ListLocale(),
|
||||
})
|
||||
}
|
||||
func (h *HttpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
@ -1,9 +1,9 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
@ -13,7 +13,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
if auth.IsGuest() {
|
||||
pages.RenderPageTemplate(rw, "index-guest", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "index-guest", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
})
|
||||
return
|
||||
@ -41,7 +41,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
|
||||
}) {
|
||||
return
|
||||
}
|
||||
pages.RenderPageTemplate(rw, "index", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "index", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"Auth": auth,
|
||||
"User": userWithName,
|
@ -1,4 +1,4 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
@ -46,7 +46,7 @@ func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httpr
|
||||
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
pages.RenderPageTemplate(rw, "login", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "login", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"Redirect": req.URL.Query().Get("redirect"),
|
||||
"Mismatch": req.URL.Query().Get("mismatch"),
|
||||
@ -70,7 +70,7 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http
|
||||
loginMismatch = 1
|
||||
return nil
|
||||
}
|
||||
http.Error(rw, "Internal server error", http.StatusInternalServerError)
|
||||
http.Error(rw, "Internal red-server error", http.StatusInternalServerError)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -108,7 +108,7 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http
|
||||
"VerifyUrl": h.conf.BaseUrl + "/mail/verify/" + u.String(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("[Tulip] Login: Failed to send verification email:", err)
|
||||
log.Println("[RedTulip] Login: Failed to send verification email:", err)
|
||||
http.Error(rw, "500 Internal Server Error: Failed to send verification email", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/go-session/session"
|
||||
"github.com/google/uuid"
|
||||
@ -67,7 +67,7 @@ func (h *HttpServer) MailPassword(rw http.ResponseWriter, req *http.Request, par
|
||||
return
|
||||
}
|
||||
|
||||
pages.RenderPageTemplate(rw, "reset-password", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "reset-password", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
})
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
@ -58,7 +58,7 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _
|
||||
validEdit:
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
pages.RenderPageTemplate(rw, "manage-apps", m)
|
||||
red_pages.RenderPageTemplate(rw, "manage-apps", m)
|
||||
}
|
||||
|
||||
func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
@ -1,9 +1,9 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
@ -66,7 +66,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _
|
||||
validEdit:
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
pages.RenderPageTemplate(rw, "manage-users", m)
|
||||
red_pages.RenderPageTemplate(rw, "manage-users", m)
|
||||
}
|
||||
|
||||
func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
@ -131,7 +131,7 @@ func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request,
|
||||
"RegisterUrl": h.conf.BaseUrl + "/mail/password/" + u.String(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("[Tulip] Login: Failed to send register email:", err)
|
||||
log.Println("[RedTulip] Login: Failed to send register email:", err)
|
||||
http.Error(rw, "500 Internal Server Error: Failed to send register email", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/scope"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/1f349/tulip/utils"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -94,18 +94,18 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request
|
||||
}
|
||||
|
||||
scopeList := form.Get("scope")
|
||||
if !scope.ScopesExist(scopeList) {
|
||||
if !utils.ScopesExist(scopeList) {
|
||||
http.Error(rw, "Invalid scopes", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"AppName": appName,
|
||||
"AppDomain": appDomain,
|
||||
"User": user,
|
||||
"WantsList": scope.FancyScopeList(scopeList),
|
||||
"WantsList": utils.FancyScopeList(scopeList),
|
||||
"ResponseType": form.Get("response_type"),
|
||||
"ResponseMode": form.Get("response_mode"),
|
||||
"ClientID": form.Get("client_id"),
|
@ -1,10 +1,10 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/1f349/tulip/red-pages"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/skip2/go-qrcode"
|
||||
@ -21,7 +21,7 @@ func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ ht
|
||||
return
|
||||
}
|
||||
|
||||
pages.RenderPageTemplate(rw, "login-otp", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "login-otp", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"Redirect": req.URL.Query().Get("redirect"),
|
||||
})
|
||||
@ -80,7 +80,7 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht
|
||||
if req.Method == http.MethodPost && req.FormValue("remove") == "1" {
|
||||
if !req.Form.Has("code") {
|
||||
// render page
|
||||
pages.RenderPageTemplate(rw, "remove-otp", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "remove-otp", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
})
|
||||
return
|
||||
@ -154,7 +154,7 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht
|
||||
}
|
||||
|
||||
// render page
|
||||
pages.RenderPageTemplate(rw, "edit-otp", map[string]any{
|
||||
red_pages.RenderPageTemplate(rw, "edit-otp", map[string]any{
|
||||
"ServiceName": h.conf.ServiceName,
|
||||
"OtpQr": template.URL("data:qrImg/png;base64," + base64.StdEncoding.EncodeToString(qrBuf.Bytes())),
|
||||
"QrWidth": qrWidth,
|
@ -1,4 +1,4 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@ -11,8 +11,8 @@ import (
|
||||
clientStore "github.com/1f349/tulip/client-store"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/openid"
|
||||
scope2 "github.com/1f349/tulip/scope"
|
||||
"github.com/1f349/tulip/theme"
|
||||
scope2 "github.com/1f349/tulip/utils"
|
||||
"github.com/go-oauth2/oauth2/v4/errors"
|
||||
"github.com/go-oauth2/oauth2/v4/generates"
|
||||
"github.com/go-oauth2/oauth2/v4/manage"
|
||||
@ -164,18 +164,18 @@ func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Ser
|
||||
r.POST("/mail/password", hs.MailPasswordPost)
|
||||
r.GET("/mail/delete/:code", hs.MailDelete)
|
||||
|
||||
// edit profile pages
|
||||
// edit profile red-pages
|
||||
r.GET("/edit", hs.RequireAuthentication(hs.EditGet))
|
||||
r.POST("/edit", hs.RequireAuthentication(hs.EditPost))
|
||||
r.POST("/edit/otp", hs.RequireAuthentication(hs.EditOtpPost))
|
||||
|
||||
// management pages
|
||||
// management red-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
|
||||
// oauth red-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) {
|
@ -1,4 +1,4 @@
|
||||
package server
|
||||
package red_server
|
||||
|
||||
import (
|
||||
"fmt"
|
21
utils/json-url.go
Normal file
21
utils/json-url.go
Normal file
@ -0,0 +1,21 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type JsonUrl struct {
|
||||
*url.URL
|
||||
}
|
||||
|
||||
var _ encoding.TextUnmarshaler = &JsonUrl{}
|
||||
|
||||
func (s *JsonUrl) UnmarshalText(text []byte) error {
|
||||
parse, err := url.Parse(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.URL = parse
|
||||
return nil
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package lists
|
||||
package utils
|
||||
|
||||
import (
|
||||
"golang.org/x/text/language"
|
@ -1,4 +1,4 @@
|
||||
package lists
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
@ -1,4 +1,4 @@
|
||||
package password
|
||||
package utils
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
@ -1,4 +1,4 @@
|
||||
package scope
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
@ -1,4 +1,4 @@
|
||||
package scope
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
@ -1,4 +1,4 @@
|
||||
package password
|
||||
package utils
|
||||
|
||||
import "crypto/rand"
|
||||
|
@ -1,4 +1,4 @@
|
||||
package lists
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
@ -1,4 +1,4 @@
|
||||
package lists
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
Loading…
Reference in New Issue
Block a user