Architecture update (#7)

* Fix errors with profile_rank ENUM

* Upgrade to Hasura 1.2.1

* Rename Profile to Account

* Common JS package

* rm app-react/lib/did

* Better login management
This commit is contained in:
Pacien Boisson
2020-05-08 21:54:00 +02:00
committed by GitHub
parent e2b3691259
commit 7747ff69d0
43 changed files with 2828 additions and 619 deletions

View File

@@ -15,7 +15,7 @@
"@types/react-dom": "^16.9.0",
"@walletconnect/web3-provider": "^1.0.0-beta.47",
"apollo-boost": "^0.4.7",
"ethers": "^4.0.46",
"ethers": "^4.0.47",
"graphql": "^15.0.0",
"graphql-codegen-hasura-core": "^4.8.4",
"react": "^16.13.1",
@@ -27,7 +27,8 @@
"web3modal": "^1.3.0"
},
"scripts": {
"start": "react-scripts start",
"start": "yarn start",
"start:app": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",

View File

@@ -1,9 +1,8 @@
import queries from '../graphql/queries';
import { getSignerAddress } from '../lib/did';
const STORAGE_KEY = 'auth-token';
function getTokenFromStore() {
export function getTokenFromStore() {
return localStorage.getItem(STORAGE_KEY);
}
@@ -15,58 +14,41 @@ function clearToken() {
return localStorage.removeItem(STORAGE_KEY);
}
export function loginLoading(client) {
export function loginLoading(client, loading = true) {
client.writeData({
data: {
authState: 'loading',
authState: loading ? 'loading' : 'anonymous',
},
});
}
export async function login(client, token, ethAddress) {
client.writeData({
data: {
authState: 'loading',
authToken: token,
},
});
setTokenInStore(token);
loginLoading(client);
return client.query({
query: queries.get_MyProfile,
query: queries.get_MyAccount,
variables: { eth_address: ethAddress }
})
.then(async res => {
if(res.data.Profile.length === 0) {
if(res.data.Account.length === 0) {
throw new Error('Impossible to fetch player, not found.');
}
client.writeData({
data: {
authState: 'logged',
playerId: res.data.Profile[0].Player.id,
authToken: token,
playerId: res.data.Account[0].Player.id,
},
});
setTokenInStore(token);
})
.catch(async error => {
client.writeData({
data: {
authState: 'error',
authToken: null,
},
});
logout();
throw error;
});
}
export function logout() {
export function logout(client) {
clearToken();
}
export function checkStoredAuth(client) {
const token = getTokenFromStore();
if(token) {
const address = getSignerAddress(token);
login(client, token, address).catch(console.error)
}
client.resetStore();
}

View File

@@ -2,8 +2,8 @@ import ApolloClient from 'apollo-boost';
import config from '../config';
import { localQueries, logout } from './index';
import { checkStoredAuth } from './auth';
import * as localQueries from './localQueries';
import { logout } from './auth';
export function createApolloClient() {
let client;
@@ -31,7 +31,6 @@ export function createApolloClient() {
if (networkError.statusCode === 401 || graphQLErrors[0]?.extensions?.code === 'invalid-jwt') {
console.error('Authentication error, login out');
logout(client);
client.resetStore();
}
else {
console.error('GraphQL request error:', networkError);
@@ -52,7 +51,6 @@ export function createApolloClient() {
client.writeData({ data: defaultClientState })
});
checkStoredAuth(client);
return client;
}

View File

@@ -1,7 +1,6 @@
import { createApolloClient } from './client';
import { login, logout } from './auth';
import * as localQueries from './localQueries';
export {
localQueries, createApolloClient, login, logout,
localQueries, createApolloClient,
};

View File

@@ -21,23 +21,11 @@ export const Login: React.FC = () => {
return (
<Box>Connected</Box>
);
} else if(data?.authState === 'error') {
} else {
return (
<Box>
Connection error
Unknown state
</Box>
);
} else if(data?.authState === 'anonymous') {
return (
<Box>
<button onClick={connectWeb3}>Connect</button>
</Box>
);
)
}
return (
<Box>
Unknown state
</Box>
)
};

View File

@@ -1,4 +1,4 @@
import React, {createContext, useCallback, useState} from 'react';
import React, {createContext, useCallback, useEffect, useState} from 'react';
import WalletConnectProvider from '@walletconnect/web3-provider';
import Web3Modal from 'web3modal';
import Web3 from 'web3';
@@ -7,8 +7,8 @@ import { AsyncSendable, Web3Provider } from 'ethers/providers';
import {useApolloClient} from '@apollo/react-hooks';
import config from '../config';
import {createToken} from '../lib/did';
import {loginLoading, login} from '../apollo/auth';
import { did } from '@the-game/utils';
import {loginLoading, login, getTokenFromStore} from '../apollo/auth';
type Web3ContextType = {
ethersProvider: Web3Provider | null,
@@ -42,29 +42,44 @@ const Web3ContextProvider: React.FC = props => {
const [ethersProvider, setEthersProvider] = useState<Web3Provider | null>(null);
const connectDID = useCallback(async (ethersProvider: Web3Provider) => {
let token = getTokenFromStore();
if(!token) {
token = await did.createToken(ethersProvider);
}
const signer = ethersProvider.getSigner();
const address = await signer.getAddress();
await login(apolloClient, token, address);
}, [apolloClient]);
const connectWeb3 = useCallback(async () => {
loginLoading(apolloClient);
try {
loginLoading(apolloClient);
const provider = await web3Modal.connect();
const web3Provider = new Web3(provider);
const ethersProvider = new ethers.providers.Web3Provider(web3Provider.currentProvider as AsyncSendable);
const signer = ethersProvider.getSigner();
const address = await signer.getAddress();
await connectDID(ethersProvider);
const token = await createToken(ethersProvider);
console.log(token);
await login(apolloClient, token, address);
setEthersProvider(ethersProvider);
} catch(error) {
console.error('impossible to connect', error);
} catch(e) {
loginLoading(apolloClient, false);
throw e;
}
}, [apolloClient]);
}, [apolloClient, connectDID]);
useEffect(() => {
if(web3Modal.cachedProvider) {
connectWeb3().catch(console.error);
}
}, [connectWeb3]);
return (
<Web3Context.Provider value={{ ethersProvider, connectWeb3 }}>

View File

@@ -5,8 +5,8 @@ fragment PlayerFragment on Player {
id
}
`;
fragments.ProfileFragment = `
fragment ProfileFragment on Profile {
fragments.AccountFragment = `
fragment AccountFragment on Account {
identifier
}
`;

View File

@@ -19,7 +19,7 @@ export function useMyPlayer() {
},
});
}
}, [playerId]);
}, [getMyPlayer, playerId]);
return myPlayerQuery;
}

View File

@@ -24,22 +24,22 @@ query GetPlayer($player_id: uuid) {
${fragments.PlayerFragment}
`;
queries.get_MyProfile = gql`
query GetMyProfile($eth_address: String) {
Profile(
queries.get_MyAccount = gql`
query GetMyAccount($eth_address: String) {
Account(
where: {
identifier: { _eq: $eth_address },
type: { _eq: "ETHEREUM" }
}
) {
...ProfileFragment
...AccountFragment
Player {
...PlayerFragment
}
}
}
${fragments.PlayerFragment}
${fragments.ProfileFragment}
${fragments.AccountFragment}
`;
export default queries;

View File

@@ -18,7 +18,7 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"typeRoots": ["../../node_modules/@types", "./@types"]
"typeRoots": ["./@types"]
},
"include": [
"src",

View File

@@ -5,6 +5,8 @@
"main": "dist/index.js",
"scripts": {
"start": "node ./dist/index.js",
"start:backend": "yarn dev",
"dev": "ts-node-dev --respawn --transpileOnly src/index.ts",
"typecheck": "tsc --noEmit",
"build": "tsc"
},
@@ -14,7 +16,8 @@
"body-parser": "^1.19.0",
"ethers": "^4.0.46",
"express": "^4.17.1",
"node-fetch": "^2.6.0"
"node-fetch": "^2.6.0",
"ts-node-dev": "^1.0.0-pre.44"
},
"devDependencies": {
"@types/express": "^4.17.6",

View File

@@ -1,5 +1,5 @@
export default {
port: process.env.PORT || 3000,
port: process.env.PORT || 4000,
graphqlURL: process.env.GRAPHQL_URL || 'http://localhost:8080/v1/graphql',
adminKey: process.env.HASURA_GRAPHQL_ADMIN_SECRET || 'metagame_secret',
};

View File

@@ -1,18 +0,0 @@
import { ethers } from "ethers";
export function verifyToken(token: string): any {
try {
const rawToken = Buffer.from(token, 'base64').toString();
const [proof, rawClaim] = JSON.parse(rawToken);
const claim = JSON.parse(rawClaim);
const signerAddress = ethers.utils.verifyMessage(rawClaim, proof);
if(signerAddress !== claim.iss) {
return null;
}
return claim;
} catch (e) {
console.error('Token verification failed', e);
return null;
}
}

View File

@@ -1,5 +1,5 @@
import { Request, Response } from 'express';
import { verifyToken } from './did';
import { did } from '@the-game/utils';
import { getPlayer } from './users';
const unauthorizedVariables = {
@@ -24,7 +24,7 @@ const handler = async (req: Request, res: Response) => {
}
else {
const claim = verifyToken(token);
const claim = did.verifyToken(token);
if(!claim) {
res.status(401).send();
return;
@@ -36,6 +36,7 @@ const handler = async (req: Request, res: Response) => {
'X-Hasura-Role': 'player',
'X-Hasura-User-Id': player.id,
};
res.json(hasuraVariables);
}

View File

@@ -4,7 +4,7 @@ import config from '../../config';
const getPlayerQuery = `
query GetPlayerFromETH ($eth_address: String) {
Profile(
Account(
where: {
identifier: { _eq: $eth_address },
type: { _eq: "ETHEREUM" }
@@ -17,23 +17,15 @@ query GetPlayerFromETH ($eth_address: String) {
}
`;
const createPlayerMutation = `
mutation CreatePlayer {
insert_Player(objects: {}) {
returning {
id
}
}
}
`;
const createProfileMutation = `
mutation CreateProfileFromETH ($player_id: uuid, $eth_address: String) {
insert_Profile(
mutation CreateAccountFromETH ($eth_address: String) {
insert_Account(
objects: {
player_id: $player_id,
type: "ETHEREUM",
identifier: $eth_address
Player: {
data: {}
}
}) {
returning {
identifier
@@ -65,17 +57,10 @@ interface IPlayer {
}
export async function createPlayer(ethAddress: string): Promise<IPlayer> {
const resPlayer = await hasuraQuery(createPlayerMutation );
const player = resPlayer.insert_Player.returning[0];
await hasuraQuery(createProfileMutation, {
player_id: player.id,
const resProfile = await hasuraQuery(createProfileMutation, {
eth_address: ethAddress,
});
// TODO do it in only one query
return player;
return resProfile.insert_Account.returning[0].Player;
}
export async function getPlayer(ethAddress: string): Promise<IPlayer> {
@@ -83,10 +68,9 @@ export async function getPlayer(ethAddress: string): Promise<IPlayer> {
eth_address: ethAddress,
});
let player = res.Profile[0]?.Player;
let player = res.Account[0]?.Player;
if(!player) {
// TODO if two requests sent at the same time, collision
player = await createPlayer(ethAddress);
}

1
packages/utils/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist

1
packages/utils/@types/js-base64.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module 'js-base64';

View File

@@ -0,0 +1,19 @@
{
"name": "@the-game/utils",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"private": true,
"scripts": {
"start:app": "tsc -w",
"start:backend": "tsc -w",
"build": "tsc"
},
"dependencies": {
"ethers": "^4.0.47",
"js-base64": "^2.5.2",
"typescript": "^3.8.3",
"uuid": "^8.0.0"
}
}

View File

@@ -1,6 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
import { ethers } from "ethers";
import { Web3Provider } from "ethers/providers";
import { Base64 } from 'js-base64';
const tokenDuration = 1000 * 60 * 60 * 24 * 7; // 7 days
@@ -21,7 +22,7 @@ export async function createToken(provider: Web3Provider) {
const serializedClaim = JSON.stringify(claim);
const proof = await signer.signMessage(serializedClaim);
const DIDToken = btoa(JSON.stringify([proof, serializedClaim]));
const DIDToken = Base64.encode(JSON.stringify([proof, serializedClaim]));
return DIDToken;
}
@@ -29,7 +30,7 @@ export async function createToken(provider: Web3Provider) {
export function getSignerAddress(token: string): any {
try {
const rawToken = atob(token);
const rawToken = Base64.decode(token);
const [proof, rawClaim] = JSON.parse(rawToken);
const signerAddress = ethers.utils.verifyMessage(rawClaim, proof);
return signerAddress;
@@ -38,3 +39,20 @@ export function getSignerAddress(token: string): any {
return null;
}
}
export function verifyToken(token: string): any {
try {
const rawToken = Base64.decode(token, 'base64');
const [proof, rawClaim] = JSON.parse(rawToken);
const claim = JSON.parse(rawClaim);
const signerAddress = ethers.utils.verifyMessage(rawClaim, proof);
if(signerAddress !== claim.iss) {
return null;
}
return claim;
} catch (e) {
console.error('Token verification failed', e);
return null;
}
}

View File

@@ -0,0 +1,3 @@
import * as did from './did';
export { did };

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "./dist",
"module": "CommonJS",
"target": "ES5",
"typeRoots": ["./@types"]
},
"include": [
"./src",
"./@types"
]
}