Skip to content

Commit

Permalink
Initial work for EULA support
Browse files Browse the repository at this point in the history
  • Loading branch information
navzam committed Jan 25, 2024
1 parent 86b0966 commit 998bb38
Show file tree
Hide file tree
Showing 19 changed files with 1,468 additions and 67 deletions.
15 changes: 15 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ try {
console.error(e);
}

const serviceAccountKeyString = process.env.FIREBASE_SERVICE_ACCOUNT_KEY_STRING;
const serviceAccountKeyFile = process.env.FIREBASE_SERVICE_ACCOUNT_KEY_FILE;

let serviceAccountKey;
if (serviceAccountKeyString) {
serviceAccountKey = JSON.parse(serviceAccountKeyString);
} else if (serviceAccountKeyFile) {
serviceAccountKey = JSON.parse(fs.readFileSync(serviceAccountKeyFile, 'utf8'));
} else {
throw new Error('FIREBASE_SERVICE_ACCOUNT_KEY_STRING or FIREBASE_SERVICE_ACCOUNT_KEY_FILE must be set');
}

module.exports = {
get: () => {
return {
Expand All @@ -23,6 +35,9 @@ module.exports = {
staticMaxAge: getEnvVarOrDefault('CACHING_STATIC_MAX_AGE', 60 * 60 * 1000),
},
dbUrl: getEnvVarOrDefault('API_URL', 'https://db-prerelease.botballacademy.org'),
firebase: {
serviceAccountKey,
},
};
},
};
Expand Down
4 changes: 3 additions & 1 deletion configs/webpack/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ module.exports = {
entry: {
app: './index.tsx',
login: './components/Login/index.tsx',
parentalConsent: './components/ParentalConsent/index.tsx',
'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js',
'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker.js',
},
Expand Down Expand Up @@ -135,8 +136,9 @@ module.exports = {
],
},
plugins: [
new HtmlWebpackPlugin({ template: 'index.html.ejs', excludeChunks: ['login'] }),
new HtmlWebpackPlugin({ template: 'index.html.ejs', excludeChunks: ['login', 'parentalConsent'] }),
new HtmlWebpackPlugin({ template: 'components/Login/login.html.ejs', filename: 'login.html', chunks: ['login'] }),
new HtmlWebpackPlugin({ template: 'components/ParentalConsent/parental-consent.html.ejs', filename: 'parental-consent.html', chunks: ['parentalConsent'] }),
new DefinePlugin({
SIMULATOR_VERSION: JSON.stringify(require('../../package.json').version),
SIMULATOR_GIT_HASH: JSON.stringify(commitHash),
Expand Down
107 changes: 107 additions & 0 deletions express.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const sourceDir = 'dist';
const { get: getConfig } = require('./config');
const { WebhookClient } = require('discord.js');
const proxy = require('express-http-proxy');
const axios = require('axios').default;
const { initializeAuth, getCustomToken, getIdTokenFromCustomToken } = require('./firebaseAuth');


let config;
Expand All @@ -21,6 +23,17 @@ try {
throw e;
}

initializeAuth(config.firebase.serviceAccountKey);

// TODO: acquire/refresh token as needed instead of once
let firebaseIdToken;
getCustomToken()
.then(customToken => {
return getIdTokenFromCustomToken(customToken);
}).then(idToken => {
firebaseIdToken = idToken;
});

app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
Expand Down Expand Up @@ -200,6 +213,51 @@ app.post('/feedback', (req, res) => {
});
});

// API TO GRANT PARENTAL CONSENT TO USER
app.post('/parental-consent/:userId', (req, res) => {
const userId = req.params['userId'];

getParentalConsent(userId, firebaseIdToken)
.then(currentConsent => {
if (currentConsent?.legalAcceptance?.state !== 'awaiting-parental-consent') {
console.error('Current state is not awaiting parental consent');
res.status(400).send();
return;
}

const consentRequestSentAt = currentConsent?.legalAcceptance?.sentAt;
const consentRequestSentAtMs = consentRequestSentAt ? Date.parse(consentRequestSentAt) : NaN;
if (isNaN(consentRequestSentAtMs)) {
console.error('Sent at time is not valid');
res.status(500).send();
return;
}

const currMs = new Date().getTime();
if (currMs - consentRequestSentAtMs > 48 * 60 * 60 * 1000) {
console.error('Consent was requested too long ago');
res.status(400).send();
return;
}

// TODO: save parental consent PDF and get URI
const parentalConsentUri = '';

setParentalConsentObtained(userId, currentConsent, parentalConsentUri, firebaseIdToken)
.then(setConsentResult => {
res.status(200).send();
})
.catch(setConsentError => {
console.error('Failed to set consent', setConsentError);
res.status(400).send();
});
})
.catch(getConsentError => {
console.error('Failed to get current consent state', getConsentError);
res.status(400).send();
});
});

app.use('/static', express.static(`${__dirname}/static`, {
maxAge: config.caching.staticMaxAge,
}));
Expand Down Expand Up @@ -233,6 +291,10 @@ app.get('/login', (req, res) => {
res.sendFile(`${__dirname}/${sourceDir}/login.html`);
});

app.get('/parental-consent/*', (req, res) => {
res.sendFile(`${__dirname}/${sourceDir}/parental-consent.html`);
});

Check failure

Code scanning / CodeQL

Missing rate limiting

This route handler performs [a file system access](1), but is not rate-limited.

app.use('*', (req, res) => {
setCrossOriginIsolationHeaders(res);
res.sendFile(`${__dirname}/${sourceDir}/index.html`);
Expand All @@ -249,4 +311,49 @@ app.listen(config.server.port, () => {
function setCrossOriginIsolationHeaders(res) {
res.header("Cross-Origin-Opener-Policy", "same-origin");
res.header("Cross-Origin-Embedder-Policy", "require-corp");
}

function getParentalConsent(userId, token) {
const requestConfig = {
headers: {
'Authorization': `Bearer ${token}`,
},
};

return axios.get(`${config.dbUrl}/user/${userId}`, requestConfig)

Check failure

Code scanning / CodeQL

Server-side request forgery

The [URL](1) of this request depends on a [user-provided value](2).
.then(response => {
return response.data;
})
.catch(error => {
if (error.response && error.response.status === 404) {
return null;
}

console.error('ERROR:', error)
throw error;
});
}

function setParentalConsentObtained(userId, currUserConsent, parentalConsentUri, token) {
const nextUserConsent = {
...currUserConsent,
legalAcceptance: {
state: 'obtained-parental-consent',
version: 1,
receivedAt: new Date().toISOString(),
parentEmailAddress: currUserConsent.parentEmailAddress,
parentalConsentUri: parentalConsentUri,
},
};

const requestConfig = {
headers: {
'Authorization': `Bearer ${token}`,
},
};

return axios.post(`${config.dbUrl}/user/${userId}`, nextUserConsent, requestConfig)

Check failure

Code scanning / CodeQL

Server-side request forgery

The [URL](1) of this request depends on a [user-provided value](2).
.then(response => {
return response.data;
});
}
56 changes: 56 additions & 0 deletions firebaseAuth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const { cert, initializeApp } = require('firebase-admin/app');
const { getAuth } = require('firebase-admin/auth');
const axios = require('axios').default;

let firebaseAuth = null;

function initializeAuth(serviceAccountKey) {
const firebaseApp = initializeApp({
credential: cert(serviceAccountKey),
});

firebaseAuth = getAuth(firebaseApp);
}

// Create a custom token with specific claims
// Used to exchange for an ID token from firebase
function getCustomToken() {
if (!firebaseAuth) {
throw new Error('Firebase auth not initizlied');
}

return firebaseAuth.createCustomToken('simulator', { 'sim_backend': true });
}

// Get an ID token from firebase using a previously created custom token
function getIdTokenFromCustomToken(customToken) {
if (!firebaseAuth) {
throw new Error('Firebase auth not initizlied');
}

// TODO: move into config somewhere
const apiKey = 'AIzaSyBiVC6umtYRy-aQqDUBv8Nn1txWLssix04';

// Send request to auth emulator if using
const urlPrefix = process.env.FIREBASE_AUTH_EMULATOR_HOST ? `http://${process.env.FIREBASE_AUTH_EMULATOR_HOST}/` : 'https://';
const url = `${urlPrefix}identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`;

return axios.post(url, {
token: customToken,
returnSecureToken: true,
})
.then(response => {
const responseBody = response.data;
return responseBody.idToken;
})
.catch(error => {
console.error('FAILED TO GET ID TOKEN', error?.response?.data?.error);
return null;
});
}

module.exports = {
initializeAuth,
getCustomToken,
getIdTokenFromCustomToken,
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@types/react": "^16.9.25",
"axios": "^1.6.6",
"babylonjs-gltf2interface": "6.18.0",
"body-parser": "^1.19.0",
"colorjs.io": "^0.4.2",
Expand All @@ -71,6 +72,7 @@
"dotenv-expand": "^5.1.0",
"express-http-proxy": "^1.6.3",
"firebase": "^9.0.1",
"firebase-admin": "^12.0.0",
"form-data": "^4.0.0",
"history": "^4.7.2",
"image-loader": "^0.0.1",
Expand Down
27 changes: 26 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import Root from './pages/Root';
import ChallengeRoot from './pages/ChallengeRoot';
import DocumentationWindow from './components/documentation/DocumentationWindow';
import { DARK } from './components/constants/theme';
import db from './db';
import Selector from './db/Selector';
import DbError from './db/Error';
import UserConsent from './consent/UserConsent';
import LegalAcceptance from './consent/LegalAcceptance';

export interface AppPublicProps {

Expand Down Expand Up @@ -68,7 +73,27 @@ class App extends React.Component<Props, State> {
componentDidMount() {
this.onAuthStateChangedSubscription_ = auth.onAuthStateChanged(user => {
if (user) {
this.setState({ loading: false });

// Ensure user has obtained consent before continuing
db.get<UserConsent>(Selector.user(user.uid))
.then(userConsent => {
if (LegalAcceptance.isConsentObtained(userConsent?.legalAcceptance)) {
console.log('Consent verified');
this.setState({ loading: false });
} else {
console.log('Consent not obtained');
this.props.login();
}
})
.catch(error => {
if (DbError.is(error) && error.code === DbError.CODE_NOT_FOUND) {
console.log('Consent info does not exist');
this.props.login();
}

// TODO: show user an error
console.error('Failed to read user consent from DB');
});
} else {
this.props.login();
}
Expand Down
20 changes: 20 additions & 0 deletions src/components/ParentalConsent/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { DARK } from '../constants/theme';

import { Provider as StyletronProvider } from "styletron-react";
import { Client as Styletron } from "styletron-engine-atomic";
import ParentalConsentPage from '../../pages/ParentalConsentPage';

const reactRoot = document.getElementById('reactRoot');

const engine = new Styletron({ prefix: 'style' });

const userId = window.location.pathname.split('/').pop()

ReactDom.render(
<StyletronProvider value={engine} debugAfterHydration>
<ParentalConsentPage theme={DARK} userId={userId} />
</StyletronProvider>,
reactRoot
);
36 changes: 36 additions & 0 deletions src/components/ParentalConsent/parental-consent.html.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
<title>Parental Consent for Simulator</title>
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
}
body {
overscroll-behavior: none;
}
html, body {
position: relative;
margin: 0;
padding: 0;
background-color: #dcdde1;
font-family: 'Roboto', sans-serif;
font-weight: 300;
}
</style>
</head>
<body>
<div id="reactRoot"></div>
</body>
</html>
11 changes: 11 additions & 0 deletions src/components/interface/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ namespace Form {
export const IDENTITY_FINALIZER = (value: string) => value;
export const EMAIL_VALIDATOR = (value: string) => Validators.validate(value, Validators.Types.Email);
export const PASSWORD_VALIDATOR = (value: string) => Validators.validatePassword(value);
export const DATE_VALIDATOR = (value: string) => Validators.validate(value, Validators.Types.Date);
export const NON_EMPTY_VALIDATOR = (value: string) => Validators.validate(value, Validators.Types.Length, 1);


Expand All @@ -245,6 +246,16 @@ namespace Form {
assistText,
});

export const dob = (id: string, text: string, tooltip?: string, assist?: () => void, assistText?: string): Item<string> => ({
id,
text,
tooltip,
validator: DATE_VALIDATOR,
finalizer: IDENTITY_FINALIZER,
assist,
assistText,
});

export const verifier = (id: string, text: string, validType: Validators.Types, tooltip?: string): Item<string> => ({
id,
text,
Expand Down
Loading

0 comments on commit 998bb38

Please sign in to comment.