mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-16 01:29:09 +00:00
finish login story
This commit is contained in:
parent
a23184efd2
commit
c9cb4c95bc
34 changed files with 14564 additions and 21666 deletions
|
@ -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" {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
15
src/common/faas/fingerprint.go
Normal file
15
src/common/faas/fingerprint.go
Normal 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
20
src/common/types/User.go
Normal 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"`
|
||||
}
|
31
src/common/types/session.go
Normal file
31
src/common/types/session.go
Normal 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
|
||||
}
|
10
src/common/types/tokens.go
Normal file
10
src/common/types/tokens.go
Normal 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"`
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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!'),
|
||||
}}
|
||||
/>
|
||||
));
|
|
@ -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>
|
||||
|
|
|
@ -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!')}
|
||||
/>
|
||||
));
|
59
src/functions/create-session/createsession.go
Normal file
59
src/functions/create-session/createsession.go
Normal 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)
|
||||
}
|
|
@ -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()
|
||||
|
|
76
src/functions/login-handler/loginhandler.go
Normal file
76
src/functions/login-handler/loginhandler.go
Normal 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)
|
||||
}
|
|
@ -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
11
src/pages/_app.tsx
Normal 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
30
src/pages/_document.tsx
Normal 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
16
src/pages/auth/login.tsx
Normal 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
5
src/pages/index.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import * as React from 'react';
|
||||
import { LandingTemplate } from 'roleypoly/design-system/templates/landing';
|
||||
|
||||
const Index = () => <LandingTemplate />;
|
||||
export default Index;
|
17
src/pages/machinery/error.tsx
Normal file
17
src/pages/machinery/error.tsx
Normal 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;
|
27
src/pages/machinery/new-session.tsx
Normal file
27
src/pages/machinery/new-session.tsx
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue