From a23184efd2550be1e887235a0bc4d7fbd7ccc7aa Mon Sep 17 00:00:00 2001 From: Katalina Okano Date: Tue, 24 Nov 2020 22:27:56 -0500 Subject: [PATCH] add basic oauth bounces --- go.mod | 2 + go.sum | 2 + hack/functions-local/README.md | 3 + .../cmd => hack/functions-local}/main.go | 4 ++ src/common/envconfig.go | 10 +++ src/common/envconfig_test.go | 15 +++++ src/common/faas/auth.go | 3 +- src/common/faas/bounce.go | 66 +++++++++++++++++++ src/functions/bot-join/README.md | 10 +++ src/functions/bot-join/botjoin.go | 45 +++++++++++++ src/functions/bot-join/botjoin_test.go | 36 ++++++++++ src/functions/login-bounce/loginbounce.go | 43 ++++++++++++ .../login-bounce/loginbounce_test.go | 24 +++++++ 13 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 hack/functions-local/README.md rename {src/functions/cmd => hack/functions-local}/main.go (91%) create mode 100644 src/common/faas/bounce.go create mode 100644 src/functions/bot-join/README.md create mode 100644 src/functions/bot-join/botjoin.go create mode 100644 src/functions/bot-join/botjoin_test.go create mode 100644 src/functions/login-bounce/loginbounce.go create mode 100644 src/functions/login-bounce/loginbounce_test.go diff --git a/go.mod b/go.mod index 218c36a..1e350ae 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/gorilla/websocket v1.4.2 // indirect github.com/joho/godotenv v1.3.0 github.com/lampjaw/discordclient v0.0.0-20200923011548-6558fc9e89df + github.com/onsi/gomega v1.8.1 + github.com/segmentio/ksuid v1.0.3 golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9 // indirect golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect k8s.io/klog v1.0.0 diff --git a/go.sum b/go.sum index 444b524..d01b6c3 100644 --- a/go.sum +++ b/go.sum @@ -150,6 +150,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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/segmentio/ksuid v1.0.3 h1:FoResxvleQwYiPAVKe1tMUlEirodZqlqglIuFsdDntY= +github.com/segmentio/ksuid v1.0.3/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= diff --git a/hack/functions-local/README.md b/hack/functions-local/README.md new file mode 100644 index 0000000..90db59c --- /dev/null +++ b/hack/functions-local/README.md @@ -0,0 +1,3 @@ +# FaaS local dev shim + +This tool provides a thin layer to emulate Google Cloud Functions for Roleypoly backend components, as well as a weak UI to understand the app layout. This is not used in production. diff --git a/src/functions/cmd/main.go b/hack/functions-local/main.go similarity index 91% rename from src/functions/cmd/main.go rename to hack/functions-local/main.go index dfb8430..92adef6 100644 --- a/src/functions/cmd/main.go +++ b/hack/functions-local/main.go @@ -8,6 +8,9 @@ import ( "os" "github.com/GoogleCloudPlatform/functions-framework-go/funcframework" + _ "github.com/joho/godotenv/autoload" + + botjoin "github.com/roleypoly/roleypoly/src/functions/bot-join" sessiondata "github.com/roleypoly/roleypoly/src/functions/session-data" sessionprewarm "github.com/roleypoly/roleypoly/src/functions/session-prewarm" ) @@ -15,6 +18,7 @@ import ( var mappings map[string]http.HandlerFunc = map[string]http.HandlerFunc{ "/session-prewarm": sessionprewarm.SessionPrewarm, "/session-data": sessiondata.SessionData, + "/bot-join": botjoin.BotJoin, } var port string diff --git a/src/common/envconfig.go b/src/common/envconfig.go index 897e4e2..426866a 100644 --- a/src/common/envconfig.go +++ b/src/common/envconfig.go @@ -9,6 +9,7 @@ import ( // GetenvValue is a holder type for Getenv to translate any Getenv strings to real types type GetenvValue struct { + key string value string } @@ -25,6 +26,7 @@ func Getenv(key string, defaultValue ...string) GetenvValue { return GetenvValue{ value: strings.TrimSpace(value), + key: key, } } @@ -63,3 +65,11 @@ func (g GetenvValue) Number() int { func (g GetenvValue) JSON(target interface{}) error { return json.Unmarshal([]byte(g.value), target) } + +func (g GetenvValue) OrFatal() GetenvValue { + if g.value == "" { + panic("Getenv value was empty and shouldn't be. key: " + g.key) + } + + return g +} diff --git a/src/common/envconfig_test.go b/src/common/envconfig_test.go index 67c33b2..b5de764 100644 --- a/src/common/envconfig_test.go +++ b/src/common/envconfig_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/onsi/gomega" "github.com/roleypoly/roleypoly/src/common" ) @@ -121,3 +122,17 @@ func TestEnvconfigJSON(t *testing.T) { t.FailNow() } } + +func TestEnvconfigFatal(t *testing.T) { + O := gomega.NewWithT(t) + O.Expect(func() { + _ = common.Getenv("test__thing_that_doesnt_exist").OrFatal().String() + }).Should(gomega.Panic(), "Getenv without a value should panic") +} + +func TestEnvconfigNotFatal(t *testing.T) { + O := gomega.NewWithT(t) + O.Expect(func() { + _ = common.Getenv("test__string").OrFatal().String() + }).ShouldNot(gomega.Panic(), "Getenv with a value should not panic") +} diff --git a/src/common/faas/auth.go b/src/common/faas/auth.go index 93f9499..c7789ad 100644 --- a/src/common/faas/auth.go +++ b/src/common/faas/auth.go @@ -28,7 +28,7 @@ func assertAuthLevel(err error, requiredAuthLevel AuthLevel, assertedAuthLevel A // AuthMustMatch will assert the current session's authorization group/level; only can match for Guest, User, and Super. func AuthMustMatch(request *http.Request, authLevel AuthLevel) error { - authCookie, err := request.Cookie("Authorization") + _, err := request.Cookie("Authorization") if errors.Is(err, http.ErrNoCookie) { // No cookie is present, assert guest. return assertAuthLevel(ErrNotAuthorized, authLevel, AuthGuest) @@ -36,4 +36,5 @@ func AuthMustMatch(request *http.Request, authLevel AuthLevel) error { return err } + return nil } diff --git a/src/common/faas/bounce.go b/src/common/faas/bounce.go new file mode 100644 index 0000000..6b344c0 --- /dev/null +++ b/src/common/faas/bounce.go @@ -0,0 +1,66 @@ +package faas + +import ( + "html/template" + "net/http" + "time" +) + +var bounceHTML = template.Must(template.New("bounceHTML").Parse( + ` + + Redirecting... + + +

+ Redirecting you to {{.Location}} +

+ `, +)) + +type bounceData struct { + Location string +} + +// Bounce will do a 303 See Other response with url. +func Bounce(rw http.ResponseWriter, url string) { + rw.Header().Add("location", url) + rw.WriteHeader(303) + bounceHTML.Execute(rw, bounceData{Location: url}) +} + +// Stash will save the specified URL for later use in Unstash(), e.g. after an OAuth bounce +func Stash(rw http.ResponseWriter, url string) { + if url == "" { + return + } + + cookie := http.Cookie{ + Name: "rp_stashed_url", + Value: url, + HttpOnly: true, + Expires: time.Now().Add(5 * time.Minute), + } + + rw.Header().Add("set-cookie", cookie.String()) +} + +// Unstash will redirect/Bounce() to a previously stashed URL or the defaultURL, whichever is available. +func Unstash(rw http.ResponseWriter, req *http.Request, defaultURL string) { + redirectURL := defaultURL + cookie, _ := req.Cookie("rp_stashed_url") + + if cookie != nil && cookie.Expires.After(time.Now()) && cookie.Value != "" { + redirectURL = cookie.Value + } + + Bounce(rw, redirectURL) +} diff --git a/src/functions/bot-join/README.md b/src/functions/bot-join/README.md new file mode 100644 index 0000000..ee12901 --- /dev/null +++ b/src/functions/bot-join/README.md @@ -0,0 +1,10 @@ +# Bot Join Bounce Service + +This function sends a user to the necessary Discord.com Bot OAuth flow. + +Two cases may be present: + +- General: The user will be sent to allow any of their relevant servers to join + - This flow would be triggered from a generalized button +- Specific: The user will be sent to join one of their servers. + - This flow would be triggered from server picker diff --git a/src/functions/bot-join/botjoin.go b/src/functions/bot-join/botjoin.go new file mode 100644 index 0000000..bafe9d3 --- /dev/null +++ b/src/functions/bot-join/botjoin.go @@ -0,0 +1,45 @@ +package botjoin + +import ( + "bytes" + "net/http" + "regexp" + "text/template" + + "github.com/roleypoly/roleypoly/src/common" + "github.com/roleypoly/roleypoly/src/common/faas" +) + +var ( + validGuildID = regexp.MustCompile(`^[0-9]+$`) + redirectPathTemplate = template.Must( + template.New("redirect").Parse( + `https://discord.com/api/oauth2/authorize?client_id={{.ClientID}}&scope=bot&permissions={{.Permissions}}{{if .GuildID}}&guild_id={{.GuildID}}&disable_guild_select=true{{end}}`, + ), + ) + clientID = common.Getenv("BOT_CLIENT_ID").String() +) + +type redirectPathData struct { + ClientID string + Permissions int + GuildID string +} + +func BotJoin(rw http.ResponseWriter, r *http.Request) { + guildID := r.URL.Query().Get("guild") + if !validGuildID.MatchString(guildID) { + guildID = "" + } + + pathData := redirectPathData{ + ClientID: clientID, + Permissions: 268435456, // MANAGE_ROLES + GuildID: guildID, + } + + pathBuffer := bytes.Buffer{} + redirectPathTemplate.Execute(&pathBuffer, pathData) + + faas.Bounce(rw, pathBuffer.String()) +} diff --git a/src/functions/bot-join/botjoin_test.go b/src/functions/bot-join/botjoin_test.go new file mode 100644 index 0000000..8dccbec --- /dev/null +++ b/src/functions/bot-join/botjoin_test.go @@ -0,0 +1,36 @@ +package botjoin_test + +import ( + "net/http/httptest" + "testing" + + "github.com/onsi/gomega" + botjoin "github.com/roleypoly/roleypoly/src/functions/bot-join" +) + +func TestGeneral(t *testing.T) { + O := gomega.NewWithT(t) + + req := httptest.NewRequest("GET", "/bot-join", nil) + resp := httptest.NewRecorder() + + botjoin.BotJoin(resp, req) + + result := resp.Result() + O.Expect(result.StatusCode).Should(gomega.BeIdenticalTo(303)) + O.Expect(result.Header.Get("location")).ShouldNot(gomega.ContainSubstring("guild_id")) + +} + +func TestGeneralSpecific(t *testing.T) { + O := gomega.NewWithT(t) + + req := httptest.NewRequest("GET", "/bot-join?guild=386659935687147521", nil) + resp := httptest.NewRecorder() + + botjoin.BotJoin(resp, req) + + result := resp.Result() + O.Expect(result.StatusCode).Should(gomega.BeIdenticalTo(303)) + O.Expect(result.Header.Get("location")).Should(gomega.ContainSubstring("guild_id=386659935687147521")) +} diff --git a/src/functions/login-bounce/loginbounce.go b/src/functions/login-bounce/loginbounce.go new file mode 100644 index 0000000..69bbbee --- /dev/null +++ b/src/functions/login-bounce/loginbounce.go @@ -0,0 +1,43 @@ +package loginbounce + +import ( + "bytes" + "net/http" + "text/template" + + "github.com/segmentio/ksuid" + + "github.com/roleypoly/roleypoly/src/common" + "github.com/roleypoly/roleypoly/src/common/faas" +) + +var ( + redirectPathTemplate = template.Must( + template.New("redirect").Parse( + `https://discord.com/api/oauth2/authorize?client_id={{.ClientID}}&scope=identify,guilds&redirect_uri={{.RedirectURI}}&state={{.State}}`, + ), + ) + clientID = common.Getenv("BOT_CLIENT_ID").String() + redirectURI = common.Getenv("OAUTH_REDIRECT_URI").String() +) + +type redirectPathData struct { + ClientID string + RedirectURI string + State string +} + +func LoginBounce(rw http.ResponseWriter, r *http.Request) { + faas.Stash(rw, r.URL.Query().Get("redirect_url")) + + pathData := redirectPathData{ + ClientID: clientID, + RedirectURI: redirectURI, + State: ksuid.New().String(), + } + + pathBuffer := bytes.Buffer{} + redirectPathTemplate.Execute(&pathBuffer, pathData) + + faas.Bounce(rw, pathBuffer.String()) +} diff --git a/src/functions/login-bounce/loginbounce_test.go b/src/functions/login-bounce/loginbounce_test.go new file mode 100644 index 0000000..83899f9 --- /dev/null +++ b/src/functions/login-bounce/loginbounce_test.go @@ -0,0 +1,24 @@ +package loginbounce_test + +import ( + "net/http/httptest" + "testing" + + "github.com/onsi/gomega" + loginbounce "github.com/roleypoly/roleypoly/src/functions/login-bounce" +) + +func TestBounce(t *testing.T) { + O := gomega.NewWithT(t) + + req := httptest.NewRequest("GET", "/login-bounce?redirect_url=https://localhost:6600/test", nil) + rw := httptest.NewRecorder() + + loginbounce.LoginBounce(rw, req) + + resp := rw.Result() + + O.Expect(resp.StatusCode).Should(gomega.BeIdenticalTo(303)) + O.Expect(resp.Header.Get("location")).Should(gomega.ContainSubstring("identify,guild")) + O.Expect(resp.Header.Get("set-cookie")).Should(gomega.ContainSubstring("https://localhost:6600/test")) +}