diff --git a/.env.example b/.env.example index 9983df70..6bc2fa4b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ NODE_ENV=development DOCKER_HOST_URL=http://host.docker.internal HASURA_SECRET=secret +HASURA_GRAPHQL_AUTH_HOOK=http://localhost:3000/hasura POSTGRES_USER=postgres POSTGRES_PASSWORD=test POSTGRES_DB=gameofblocks_dev @@ -15,5 +16,4 @@ AUTH0_CLIENT_SECRET=123456 AUTH0_CALLBACK_URL=http://localhost:3000/callback SENDGRID_API_KEY=123456 BASE_URL=http://localhost:3000 -HASURA_JWT_SECRET= GRAPHQL_SERVER_URI=http://localhost:5000 \ No newline at end of file diff --git a/.k8s/hasura/deployment.yaml b/.k8s/hasura/deployment.yaml index 70f8dcd0..7ffe74b0 100644 --- a/.k8s/hasura/deployment.yaml +++ b/.k8s/hasura/deployment.yaml @@ -37,6 +37,8 @@ spec: value: ${HASURA_SECRET} - name: HASURA_GRAPHQL_ENABLE_CONSOLE value: 'true' + - name: HASURA_GRAPHQL_AUTH_HOOK + value: gameofblocks-${CI_ENVIRONMENT_SLUG}/hasura ports: - name: http-metrics protocol: TCP diff --git a/docker-compose.yaml b/docker-compose.yaml index 67a84cb7..2ff020f9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -28,7 +28,7 @@ services: HASURA_GRAPHQL_DATABASE_URL: postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@db:5432/$POSTGRES_DB HASURA_GRAPHQL_ENABLE_CONSOLE: "true" HASURA_GRAPHQL_ADMIN_SECRET: $HASURA_SECRET - HASURA_GRAPHQL_JWT_SECRET: $HASURA_JWT_SECRET + HASURA_GRAPHQL_AUTH_HOOK: $HASURA_GRAPHQL_AUTH_HOOK volumes: gameofblocks-pgdata: diff --git a/packages/app/components/profile/Profile.tsx b/packages/app/components/profile/Profile.tsx new file mode 100644 index 00000000..a2030663 --- /dev/null +++ b/packages/app/components/profile/Profile.tsx @@ -0,0 +1,33 @@ +import React, { FunctionComponent } from 'react'; +import { useQuery } from '@apollo/react-hooks'; + +import { USER, UserQueryProps } from './queries'; + +export const Profile: FunctionComponent = () => { + const { data, error, loading } = useQuery(USER); + if (error) { + return
{error.message}
; + } + + if (loading) { + return
Chargement...
; + } + + const [user] = data.user; + const lastLoginDate = new Date(user.last_login); + + return ( +
+ {user.picture && avatar} +
{`Email: ${user.email}`}
+
+ {`Last login: ${lastLoginDate.toLocaleDateString()} ${lastLoginDate.toLocaleTimeString()}`} +
+ + + +
+ ); +}; + +export default Profile; diff --git a/packages/app/components/profile/index.ts b/packages/app/components/profile/index.ts new file mode 100644 index 00000000..8668bd17 --- /dev/null +++ b/packages/app/components/profile/index.ts @@ -0,0 +1 @@ +export { Profile } from './Profile'; diff --git a/packages/app/components/profile/queries.ts b/packages/app/components/profile/queries.ts new file mode 100644 index 00000000..4f17f871 --- /dev/null +++ b/packages/app/components/profile/queries.ts @@ -0,0 +1,19 @@ +import gql from 'graphql-tag'; + +export interface UserQueryProps { + user: { + picture: string; + email: string; + last_login: string; + }[]; +} + +export const USER = gql` + query user { + user { + picture + email + last_login + } + } +`; diff --git a/packages/app/lib/init-apollo.ts b/packages/app/lib/init-apollo.ts new file mode 100644 index 00000000..6cecee20 --- /dev/null +++ b/packages/app/lib/init-apollo.ts @@ -0,0 +1,59 @@ +import { + ApolloClient, + NormalizedCacheObject, + InMemoryCache, +} from 'apollo-boost'; +import { createHttpLink } from 'apollo-link-http'; +import fetch from 'isomorphic-unfetch'; +import { InitApolloOptions } from 'next-with-apollo'; + +const { GRAPHQL_SERVER_URI } = process.env; + +let apolloClient = null; + +function create( + options: InitApolloOptions +): ApolloClient { + const isBrowser = typeof window !== 'undefined'; + + const enchancedFetch = async (url, init): Promise => { + const response = await fetch(url, { + ...init, + headers: { + ...init.headers, + Cookie: options.ctx.req.headers.cookie, + }, + }); + return response; + }; + + return new ApolloClient({ + connectToDevTools: isBrowser, + ssrMode: !isBrowser, + link: createHttpLink({ + fetch: !isBrowser ? enchancedFetch : fetch, + uri: GRAPHQL_SERVER_URI, + credentials: 'include', + }), + cache: new InMemoryCache().restore(options.initialState || {}), + }); +} + +export function initApollo( + options: InitApolloOptions +): ApolloClient { + // Make sure to create a new client for every server-side request so that data + // isn't shared between connections (which would be bad) + if (typeof window === 'undefined') { + return create(options); + } + + // Reuse client on the client-side + if (!apolloClient) { + apolloClient = create(options); + } + + return apolloClient; +} + +export default initApollo; diff --git a/packages/app/next.config.js b/packages/app/next.config.js index dca74224..9aac2d3c 100644 --- a/packages/app/next.config.js +++ b/packages/app/next.config.js @@ -1,14 +1,13 @@ -require('dotenv').config(); - +const env = require('@gameofblocks/env'); const webpack = require('webpack'); module.exports = { publicRuntimeConfig: { - GRAPHQL_SERVER_URI: process.env.GRAPHQL_SERVER_URI, - NODE_ENV: process.env.NODE_ENV, + GRAPHQL_SERVER_URI: env.GRAPHQL_SERVER_URI, + NODE_ENV: env.NODE_ENV, }, webpack: (config) => { - config.plugins.push(new webpack.EnvironmentPlugin(process.env)); + config.plugins.push(new webpack.EnvironmentPlugin(env)); return config; }, }; diff --git a/packages/app/package.json b/packages/app/package.json index 3ef78b29..8b16eed3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -12,6 +12,7 @@ "lint:fix": "yarn lint --fix" }, "dependencies": { + "@apollo/react-hooks": "^3.1.4", "@gameofblocks/env": "^1.0.0", "@rebass/forms": "4.0.6", "@rebass/preset": "4.0.5", @@ -36,6 +37,7 @@ "memorystore": "1.6.2", "next": "9.3.4", "next-cookies": "2.0.3", + "next-with-apollo": "^5.0.0", "passport": "0.4.1", "passport-auth0": "1.3.2", "pino": "6.1.1", diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index c097b702..465b1ce1 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -1,13 +1,26 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable react/jsx-props-no-spreading */ import React from 'react'; import { AppProps } from 'next/app'; -import { NextPage } from 'next'; +import { ApolloProvider } from 'react-apollo'; +import withApollo, { WithApolloProps } from 'next-with-apollo'; +import { initApollo } from '../lib/init-apollo'; -const GameOfBlocks: NextPage = ({ - Component, - pageProps, -}: AppProps) => { - return ; -}; +interface MainComponentProps extends WithApolloProps, AppProps {} -export default GameOfBlocks; +class GameOfBlocks extends React.Component { + static async getInitialProps() { + return {}; + } + + render() { + const { Component, pageProps, apollo } = this.props; + return ( + + + + ); + } +} + +export default withApollo(initApollo)(GameOfBlocks); diff --git a/packages/app/pages/profile.tsx b/packages/app/pages/profile.tsx new file mode 100644 index 00000000..0cae6375 --- /dev/null +++ b/packages/app/pages/profile.tsx @@ -0,0 +1,9 @@ +import React, { FunctionComponent } from 'react'; + +import { Profile } from '../components/profile'; + +const ProfilePage: FunctionComponent = () => { + return ; +}; + +export default ProfilePage; diff --git a/packages/app/server/graphql/client.ts b/packages/app/server/graphql/client.ts index 71f7f641..bddc1425 100644 --- a/packages/app/server/graphql/client.ts +++ b/packages/app/server/graphql/client.ts @@ -6,6 +6,7 @@ const { GRAPHQL_SERVER_URI, HASURA_SECRET } = env; export const client = new ApolloClient({ uri: GRAPHQL_SERVER_URI, + headers: { 'x-hasura-admin-secret': HASURA_SECRET, }, diff --git a/packages/app/server/graphql/queries.ts b/packages/app/server/graphql/queries.ts index f19e1996..0f45a6b1 100644 --- a/packages/app/server/graphql/queries.ts +++ b/packages/app/server/graphql/queries.ts @@ -3,6 +3,7 @@ import gql from 'graphql-tag'; export const GET_USER = gql` query user($authId: String!) { user(limit: 1, where: { auth_id: { _eq: $authId } }) { + id auth_id email picture diff --git a/packages/app/server/index.ts b/packages/app/server/index.ts index c8cc2707..4d09bcd7 100644 --- a/packages/app/server/index.ts +++ b/packages/app/server/index.ts @@ -3,16 +3,16 @@ import nextJs from 'next'; import { createServer } from 'http'; import expressPinoLogger from 'express-pino-logger'; import uid from 'uid-safe'; -import session from 'express-session'; import cors from 'cors'; import passport from 'passport'; import Auth0Strategy from 'passport-auth0'; -import MemoryStore from 'memorystore'; import env from '@gameofblocks/env'; +import session from 'express-session'; import { logger } from '../utils/logger'; import { handleError } from '../utils/error-handler'; import authRoutes from './routes/auth'; +import store, { MAX_AGE } from './session-store'; const dev = env.NODE_ENV !== 'production'; const app = nextJs({ dev }); @@ -25,17 +25,12 @@ const handle = app.getRequestHandler(); await app.prepare(); const server = express(); - const SessionMemoryStore = MemoryStore(session); - const MAX_AGE = 86400000; // prune expired entries every 24h - const sessionConfig = { secret: uid.sync(18), cookie: { maxAge: MAX_AGE, }, - store: new SessionMemoryStore({ - checkPeriod: MAX_AGE, - }), + store, resave: false, saveUninitialized: true, }; diff --git a/packages/app/server/models/types.ts b/packages/app/server/models/types.ts index 98fe5668..88e39d50 100644 --- a/packages/app/server/models/types.ts +++ b/packages/app/server/models/types.ts @@ -6,23 +6,32 @@ export interface UserQueryVariables { authId: string; } -export interface UserCreationVariables { +export interface CreateUserVariables { email: string; picture: string; authId: string; } +export interface CreateUserMutationResult { + insert_user: { + affected_rows: number; + returning: User[]; + }; +} + export interface UpdateLastLoginVariables { authId: string; lastLogin: string; } -export interface UserMutationResult { - affected_rows: number; - returning: User[]; +export interface Auth0User { + auth_id: string; + email: string; + picture: string; } export interface User { + id: string; auth_id: string; email: string; picture: string; diff --git a/packages/app/server/models/user.ts b/packages/app/server/models/user.ts index 70a7a20a..d884c4ad 100644 --- a/packages/app/server/models/user.ts +++ b/packages/app/server/models/user.ts @@ -4,13 +4,15 @@ import { CREATE_USER, UPDATE_LAST_LOGIN } from '../graphql/mutations'; import { UserQueryResult, UserQueryVariables, - UserMutationResult, - UserCreationVariables, + CreateUserMutationResult, + CreateUserVariables, UpdateLastLoginVariables, User, + Auth0User, } from './types'; +import { logger } from '../../utils/logger'; -async function find(authId: string): Promise { +export async function find(authId: string): Promise { const { data } = await client.query({ query: GET_USER, variables: { authId }, @@ -19,17 +21,20 @@ async function find(authId: string): Promise { return data ? data.user[0] : null; } -async function create(userToCreate: User): Promise { +async function create(userToCreate: Auth0User): Promise { const { email, picture, auth_id: authId } = userToCreate; const { data } = await client.mutate< - UserMutationResult, - UserCreationVariables + CreateUserMutationResult, + CreateUserVariables >({ mutation: CREATE_USER, variables: { email, picture, authId }, }); - const [user] = data.returning; + const { + insert_user: { returning }, + } = data; + const [user] = returning; return user; } @@ -43,12 +48,14 @@ async function updateLastLogin(authId: string): Promise { }); } -export async function loginUser(userToTest: User): Promise { - const { auth_id: authId } = userToTest; +export async function loginUser(auth0User: Auth0User): Promise { + const { auth_id: authId } = auth0User; let user = await find(authId); if (!user) { - user = await create(userToTest); + logger.info(`🚫 User ${authId} does not exist. User creation attempt...`); + user = await create(auth0User); } else { + logger.info(`👋 User ${authId} logged in successfully`); await updateLastLogin(authId); } return user; diff --git a/packages/app/server/routes/auth.ts b/packages/app/server/routes/auth.ts index 6af0ac65..7eac1a07 100644 --- a/packages/app/server/routes/auth.ts +++ b/packages/app/server/routes/auth.ts @@ -4,7 +4,7 @@ import bodyParser from 'body-parser'; import env from '@gameofblocks/env'; import { logger } from '../../utils/logger'; -import { loginUser } from '../models/user'; +import { loginUser, find } from '../models/user'; const router = Router(); router.use(bodyParser.json()); @@ -19,6 +19,31 @@ router.get( (req, res) => res.redirect('/') ); +router.get('/hasura', async (req, res) => { + logger.info('🔒 Hasura webhook. Checking authentication with session id...'); + if (!req.isAuthenticated()) { + logger.info('🚫 Authentication is rejected'); + res.status(401); + } else { + logger.info('✅ Authentication is successful'); + if (req.user) { + // TODO(remiroyc): find a better way to type req.user. + // @types/password defines an empty interface for User. + const { id } = req.user as { id: string }; + const user = await find(id); + res.status(200).json({ + 'X-Hasura-User-Id': user.id, + 'X-Hasura-Role': 'user', + }); + } else { + res.status(200).json({ + 'X-Hasura-Role': 'anonymous', + }); + } + } + res.end(); +}); + router.get('/callback', (req, res, next) => { // eslint-disable-next-line consistent-return passport.authenticate('auth0', (error, user) => { @@ -31,7 +56,6 @@ router.get('/callback', (req, res, next) => { req.logIn(user, async (err) => { if (err) return next(err); - logger.info(user, 'Auhentication success'); const { id, diff --git a/packages/app/server/session-store.ts b/packages/app/server/session-store.ts new file mode 100644 index 00000000..003d34fd --- /dev/null +++ b/packages/app/server/session-store.ts @@ -0,0 +1,12 @@ +import MemoryStore from 'memorystore'; +import session from 'express-session'; + +export const MAX_AGE = 86400000; + +const SessionMemoryStore = MemoryStore(session); + +const store = new SessionMemoryStore({ + checkPeriod: MAX_AGE, +}); + +export default store; diff --git a/yarn.lock b/yarn.lock index 9591c0cb..1b56813d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6720,7 +6720,7 @@ isobject@^4.0.0: resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0" integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA== -isomorphic-unfetch@3.0.0: +isomorphic-unfetch@3.0.0, isomorphic-unfetch@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/isomorphic-unfetch/-/isomorphic-unfetch-3.0.0.tgz#de6d80abde487b17de2c400a7ef9e5ecc2efb362" integrity sha512-V0tmJSYfkKokZ5mgl0cmfQMTb7MLHsBMngTkbLY0eXvKqiVRRoZP04Ly+KhKrJfKtzC9E6Pp15Jo+bwh7Vi2XQ== @@ -7638,6 +7638,13 @@ next-tick@~1.0.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= +next-with-apollo@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/next-with-apollo/-/next-with-apollo-5.0.0.tgz#022e58c1a5c2117a6190534271e297a7fa039319" + integrity sha512-JcMecKv6XtzsLf1hPKrbxM6g9sygnGdQugglmCDkfWhHZt1yziPa+g515qtw7in8A7Ed5W9PdaaaUn7CWWQ71w== + dependencies: + isomorphic-unfetch "^3.0.0" + next@*, next@9.3.4: version "9.3.4" resolved "https://registry.yarnpkg.com/next/-/next-9.3.4.tgz#7860d414ae01e2425bf8038277f1573f9d121b57"