finish login story

This commit is contained in:
41666 2020-12-01 23:13:32 -05:00
parent a23184efd2
commit c9cb4c95bc
34 changed files with 14564 additions and 21666 deletions

View file

@ -43,6 +43,15 @@ func (g GetenvValue) StringSlice(optionalDelimiter ...string) []string {
return strings.Split(g.value, delimiter)
}
// SafeURL removes any trailing slash
func (g GetenvValue) SafeURL() string {
if g.value[len(g.value)-1] == '/' {
return g.value[:len(g.value)-1]
}
return g.value
}
func (g GetenvValue) Bool() bool {
lowercaseValue := strings.ToLower(g.value)
if g.value == "1" || lowercaseValue == "true" || lowercaseValue == "yes" {

View file

@ -2,6 +2,7 @@ package common_test
import (
"os"
"strings"
"testing"
"github.com/onsi/gomega"
@ -14,6 +15,8 @@ var (
"slice": "hello,world",
"slice_no_delim": "hello world",
"slice_set_delim": "hello|world",
"url": "https://google.com",
"url_trailing": "https://google.com/",
"number": "10005",
"number_bad": "abc123",
"bool": "true",
@ -61,6 +64,19 @@ func TestEnvconfigStringSliceSetDelimeter(t *testing.T) {
}
}
func TestEnvconfigSafeURL(t *testing.T) {
testUrl := common.Getenv("test__url").SafeURL()
if strings.HasSuffix(testUrl, "/") {
t.FailNow()
}
}
func TestEnvconfigSafeURLWithTrailing(t *testing.T) {
testUrl := common.Getenv("test__url_trailing").SafeURL()
if strings.HasSuffix(testUrl, "/") {
t.FailNow()
}
}
func TestEnvconfigNumber(t *testing.T) {
testNum := common.Getenv("test__number").Number()
if testNum != 10005 {

View file

@ -62,5 +62,14 @@ func Unstash(rw http.ResponseWriter, req *http.Request, defaultURL string) {
redirectURL = cookie.Value
}
unsetter := http.Cookie{
Name: "rp_stashed_url",
Value: "",
MaxAge: -1,
HttpOnly: true,
}
rw.Header().Set("set-cookie", unsetter.String())
Bounce(rw, redirectURL)
}

View file

@ -0,0 +1,15 @@
package faas
import (
"net/http"
"github.com/roleypoly/roleypoly/src/common/types"
)
func Fingerprint(req *http.Request) types.Fingerprint {
return types.Fingerprint{
UserAgent: req.UserAgent(),
ClientIP: req.RemoteAddr,
ForwardedFor: req.Header.Get("x-forwarded-for"),
}
}

20
src/common/types/User.go Normal file
View file

@ -0,0 +1,20 @@
package types
type DiscordUser struct {
ID string `json:"id,omitempty"`
Username string `json:"username,omitempty"`
Discriminator string `json:"discriminator,omitempty"`
Avatar string `json:"avatar,omitempty"`
Bot bool `json:"bot,omitempty"`
}
type Member struct {
GuildID string `json:"guildid,omitempty"`
Roles []string `json:"rolesList,omitempty"`
Nick string `json:"nick,omitempty"`
User DiscordUser `json:"user,omitempty"`
}
type RoleypolyUser struct {
DiscordUser DiscordUser `json:"discorduser,omitempty"`
}

View file

@ -0,0 +1,31 @@
package types
import "time"
// CreateSessionRequest is the payload to /create-session
type CreateSessionRequest struct {
AccessTokenResponse AccessTokenResponse
Fingerprint Fingerprint
}
type Fingerprint struct {
UserAgent string
ClientIP string
ForwardedFor string
}
type CreateSessionResponse struct {
SessionID string
}
type SessionData struct {
SessionID string
Fingerprint Fingerprint
AccessTokens AccessTokenResponse
UserData UserData
}
type UserData struct {
DataExpires time.Time
UserID string
}

View file

@ -0,0 +1,10 @@
package types
// AccessTokenResponse is the response for Discord's OAuth token grant flow
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}

View file

@ -1,5 +1,6 @@
import styled, { createGlobalStyle } from 'styled-components';
import { palette } from 'roleypoly/design-system/atoms/colors';
import { fontCSS } from 'roleypoly/design-system/atoms/fonts';
export const Content = styled.div<{ small?: boolean }>`
margin: 0 auto;
@ -15,6 +16,7 @@ export const GlobalStyles = createGlobalStyle`
color: ${palette.grey600};
overflow-y: hidden;
scroll-behavior: smooth;
${fontCSS}
}
* {
box-sizing: border-box;

View file

@ -1,16 +0,0 @@
import * as React from 'react';
import { organismStories } from 'roleypoly/design-system/organisms/organisms.story';
import { ErrorBanner } from './ErrorBanner';
import { text } from '@storybook/addon-knobs';
const story = organismStories('Error Banner', module);
story.add('Error Banner', () => (
<ErrorBanner
message={{
english: text('English', 'Primary Text'),
japanese: text('Japanese (Subtext)', 'Subtext'),
friendlyCode: text('Friendly Code', 'Oops!'),
}}
/>
));

View file

@ -6,11 +6,13 @@ import { PreauthGreeting } from 'roleypoly/design-system/molecules/preauth-greet
import { PreauthSecretCode } from 'roleypoly/design-system/molecules/preauth-secret-code';
import { Guild } from 'roleypoly/common/types';
import styled from 'styled-components';
import Link from 'next/link';
export type PreauthProps = {
guildSlug?: Guild;
onSendSecretCode: (code: string) => void;
botName?: string;
discordOAuthLink?: string;
};
const Centered = styled.div`
@ -33,16 +35,18 @@ export const Preauth = (props: PreauthProps) => {
<Centered>
{props.guildSlug && <PreauthGreeting guildSlug={props.guildSlug} />}
<WidthContainer>
<Button
color="discord"
icon={
<div style={{ position: 'relative', top: 3 }}>
<FaDiscord />
</div>
}
>
Sign in with Discord
</Button>
<Link href={props.discordOAuthLink || '#'}>
<Button
color="discord"
icon={
<div style={{ position: 'relative', top: 3 }}>
<FaDiscord />
</div>
}
>
Sign in with Discord
</Button>
</Link>
</WidthContainer>
<Space />
<WidthContainer>

View file

@ -1,18 +0,0 @@
import * as React from 'react';
import { templateStories } from 'templates/templates.story';
import { AuthLogin } from './AuthLogin';
import { action } from '@storybook/addon-actions';
import { guild } from 'roleypoly/common/types/storyData';
const story = templateStories('Login', module);
story.add('No Slug', () => (
<AuthLogin botName="roleypoly#3266" onSendSecretCode={action('secret code!')} />
));
story.add('With Slug', () => (
<AuthLogin
botName="roleypoly#3266"
guildSlug={guild}
onSendSecretCode={action('secret code!')}
/>
));

View file

@ -0,0 +1,59 @@
package createsession
import (
"context"
"encoding/json"
"net/http"
"time"
"cloud.google.com/go/firestore"
"k8s.io/klog"
"github.com/roleypoly/roleypoly/src/common"
"github.com/roleypoly/roleypoly/src/common/types"
)
var (
firestoreClient *firestore.Client
projectID = common.Getenv("GCP_PROJECT_ID").String()
firestoreEndpoint = common.Getenv("FIRESTORE_ENDPOINT").String()
)
func init() {
var err error // shadow avoidance
ctx := context.Background()
firestoreClient, err = firestore.NewClient(ctx, projectID)
if err != nil {
panic(err)
}
}
func CreateSession(rw http.ResponseWriter, req *http.Request) {
requestData := types.CreateSessionRequest{}
json.NewDecoder(req.Body).Decode(&requestData)
klog.Info("Creating session...")
sessionData := types.SessionData{
AccessTokens: requestData.AccessTokenResponse,
Fingerprint: requestData.Fingerprint,
UserData: types.UserData{},
}
ctx, ctxCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer ctxCancel()
docRef, _, err := firestoreClient.Collection("sessions").Add(ctx, sessionData)
if err != nil {
rw.WriteHeader(500)
klog.Error("error: create session in firestore: ", err)
}
klog.Info("Created session ", docRef.ID)
responseData := types.CreateSessionResponse{
SessionID: docRef.ID,
}
json.NewEncoder(rw).Encode(responseData)
}

View file

@ -14,7 +14,7 @@ import (
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}}`,
`https://discord.com/api/oauth2/authorize?client_id={{.ClientID}}&response_type=code&scope=identify%20guilds&redirect_uri={{urlquery .RedirectURI}}&state={{.State}}`,
),
)
clientID = common.Getenv("BOT_CLIENT_ID").String()

View file

@ -0,0 +1,76 @@
package loginhandler
import (
"bytes"
"encoding/json"
"net/http"
"net/url"
"time"
"github.com/roleypoly/roleypoly/src/common"
"github.com/roleypoly/roleypoly/src/common/faas"
"github.com/roleypoly/roleypoly/src/common/types"
"github.com/segmentio/ksuid"
"k8s.io/klog"
)
var (
// HTTPClient is an overridable HTTP client with a 15 second timeout
HTTPClient = http.Client{Timeout: 15 * time.Second}
uiPath = common.Getenv("UI_PUBLIC_URI").SafeURL()
apiPath = common.Getenv("API_PUBLIC_URI").SafeURL()
clientID = common.Getenv("BOT_CLIENT_ID").String()
clientSecret = common.Getenv("BOT_CLIENT_SECRET").String()
redirectURI = common.Getenv("OAUTH_REDIRECT_URI").String()
)
func LoginHandler(rw http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
state := query.Get("state")
stateID, err := ksuid.Parse(state)
if err != nil || stateID.IsNil() || stateID.Time().Add(5*time.Minute).Before(time.Now()) {
faas.Bounce(rw, uiPath+"/machinery/error?error_code=auth_failure")
return
}
code := query.Get("code")
body := url.Values{}
body.Set("client_id", clientID)
body.Set("client_secret", clientSecret)
body.Set("grant_type", "authorization_code")
body.Set("code", code)
body.Set("redirect_uri", redirectURI)
body.Set("scope", "identify guilds")
response, err := HTTPClient.PostForm("https://discord.com/api/v8/oauth2/token", body)
if err != nil {
klog.Error("token fetch failed: ", err)
faas.Bounce(rw, uiPath+"/machinery/error?error_code=auth_failure")
return
}
tokens := types.AccessTokenResponse{}
json.NewDecoder(response.Body).Decode(&tokens)
sessionRequest := types.CreateSessionRequest{
AccessTokenResponse: tokens,
Fingerprint: faas.Fingerprint(req),
}
buf := bytes.Buffer{}
json.NewEncoder(&buf).Encode(sessionRequest)
response, err = HTTPClient.Post(apiPath+"/create-session", "application/json", &buf)
if err != nil {
klog.Error("create session failed: ", err)
faas.Bounce(rw, uiPath+"/machinery/error?error_code=auth_failure")
return
}
session := types.CreateSessionResponse{}
json.NewDecoder(response.Body).Decode(&session)
faas.Bounce(rw, uiPath+"/machinery/new-session?session_id="+session.SessionID)
}

View file

@ -1,7 +0,0 @@
package sessionprewarm
import "net/http"
func SessionPrewarm(rw http.ResponseWriter, r *http.Request) {
rw.Write([]byte("hello work!"))
}

11
src/pages/_app.tsx Normal file
View file

@ -0,0 +1,11 @@
import { AppProps } from 'next/app';
import * as React from 'react';
import { InjectTypekitFont } from 'roleypoly/design-system/atoms/fonts';
const App = (props: AppProps) => (
<>
<InjectTypekitFont />
<props.Component {...props.pageProps} />
</>
);
export default App;

30
src/pages/_document.tsx Normal file
View file

@ -0,0 +1,30 @@
import Document, { DocumentContext } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />),
});
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
),
};
} finally {
sheet.seal();
}
}
}

16
src/pages/auth/login.tsx Normal file
View file

@ -0,0 +1,16 @@
import * as React from 'react';
import { AuthLogin } from 'roleypoly/design-system/templates/auth-login';
const loginPage = () => {
const onSendSecretCode = (code: string) => {
console.log(code);
};
return (
<AuthLogin
onSendSecretCode={onSendSecretCode}
discordOAuthLink="http://localhost:6600/login-bounce"
/>
);
};
export default loginPage;

5
src/pages/index.tsx Normal file
View file

@ -0,0 +1,5 @@
import * as React from 'react';
import { LandingTemplate } from 'roleypoly/design-system/templates/landing';
const Index = () => <LandingTemplate />;
export default Index;

View file

@ -0,0 +1,17 @@
import { NextPageContext } from 'next';
import * as React from 'react';
import { Error } from 'roleypoly/design-system/templates/errors';
type Props = {
errorCode: string | number | any;
};
const ErrorPage = (props: Props) => <Error code={props.errorCode} />;
ErrorPage.getInitialProps = (context: NextPageContext): Props => {
return {
errorCode: context.err || context.query.error_code,
};
};
export default ErrorPage;

View file

@ -0,0 +1,27 @@
import { NextPageContext } from 'next';
import * as React from 'react';
type Props = {
sessionID: string;
};
const NewSession = (props: Props) => {
const { sessionID } = props;
React.useEffect(() => {
sessionStorage.setItem('session_key', sessionID);
location.href = '/';
}, [sessionID]);
return <div>Logging you in...</div>;
};
NewSession.getInitialProps = (context: NextPageContext): Props => {
const sessionID = context.query.session_id;
if (!sessionID) {
throw new Error("I shouldn't be here today.");
}
return { sessionID: sessionID as string };
};
export default NewSession;