add basic oauth bounces

This commit is contained in:
41666 2020-11-24 22:27:56 -05:00
parent bebfc862e8
commit a23184efd2
13 changed files with 262 additions and 1 deletions

2
go.mod
View file

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

2
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

66
src/common/faas/bounce.go Normal file
View file

@ -0,0 +1,66 @@
package faas
import (
"html/template"
"net/http"
"time"
)
var bounceHTML = template.Must(template.New("bounceHTML").Parse(
`<!doctype html>
<meta charset="utf8">
<title>Redirecting...</title>
<meta http-equiv="refresh" content="0;URL='{{.Location}}'">
<style>
body {
background-color: #453E3D;
color: #AB9B9A;
}
a {
color: #AB9B9A;
}
</style>
<p>
Redirecting you to <a href="{{.Location}}">{{.Location}}</a>
</p>
`,
))
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)
}

View file

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

View file

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

View file

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

View file

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

View file

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