1
0
Fork 0

Dump of changes

master
Ambrose Chua 2017-04-12 00:14:51 +08:00
parent d8b0554682
commit 35408b43e2
15 changed files with 465 additions and 114 deletions

2
.gitignore vendored
View File

@ -9,4 +9,4 @@ dist/
# Temporary files
oid_settings.json
nginx.conf
sample_data.sql
sample_*.sql

View File

@ -7,7 +7,7 @@ module.exports = function (grunt) {
entry: __dirname + '/app/index.jsx',
output: {
filename: 'bundle.js',
path: __dirname + '/dist/app/'
path: __dirname + '/dist/app/',
},
resolve: {
extensions: ['.js', '.jsx'],
@ -25,15 +25,15 @@ module.exports = function (grunt) {
presets: [
['env', {
targets: {
browsers: ['last 2 versions']
browsers: ['last 2 versions'],
},
modules: false
modules: false,
}],
'react'
]
}
}
]
'react',
],
},
},
],
},
{
test: /\.css$/,
@ -45,17 +45,17 @@ module.exports = function (grunt) {
sourceMap: true,
modules: true,
importLoaders: 1,
localIdentName: '[name]--[local]--[hash:base64:8]'
}
localIdentName: '[name]--[local]--[hash:base64:8]',
},
},
{
loader: 'postcss-loader'
}
]
}
]
}
}
loader: 'postcss-loader',
},
],
},
],
},
},
},
copy: {
app: {
@ -64,34 +64,37 @@ module.exports = function (grunt) {
expand: true,
cwd: __dirname + '/app/',
src: ['index.html'],
dest: __dirname + '/dist/app/'
dest: __dirname + '/dist/app/',
},
{
expand: true,
cwd: __dirname + '/app/',
src: ['assets/**'],
dest: __dirname + '/dist/app/assets/'
dest: __dirname + '/dist/app/assets/',
},
{
expand: true,
cwd: __dirname + '/node_modules/oidc-client/',
src: ['dist/**'],
dest: __dirname + '/dist/app/assets/oidc-client/'
}
]
}
dest: __dirname + '/dist/app/assets/oidc-client/',
},
],
},
},
babel: {
options: {
sourceMap: true,
presets: [
['env', {
targets: {
node: 'current'
[
'env',
{
targets: {
node: 'current',
},
modules: 'commonjs',
},
modules: 'commonjs'
}]
]
],
],
},
server: {
files: [
@ -99,10 +102,10 @@ module.exports = function (grunt) {
expand: true,
cwd: __dirname + '/server/',
src: ['**/*.js'],
dest: __dirname + '/dist/server/'
}
]
}
dest: __dirname + '/dist/server/',
},
],
},
},
eslint: {
app: {
@ -111,9 +114,9 @@ module.exports = function (grunt) {
expand: true,
cwd: __dirname + '/app/',
src: ['**/*.js*'],
dest: __dirname + '/dist/app/'
}
]
dest: __dirname + '/dist/app/',
},
],
},
server: {
files: [
@ -121,27 +124,27 @@ module.exports = function (grunt) {
expand: true,
cwd: __dirname + '/server/',
src: ['**/*.js'],
dest: __dirname + '/dist/server/'
}
]
}
dest: __dirname + '/dist/server/',
},
],
},
},
watch: {
app: {
files: '**/*.js*',
tasks: ['app'],
options: {
cwd: __dirname + '/app/'
}
cwd: __dirname + '/app/',
},
},
server: {
files: '**/*.js',
tasks: ['server'],
options: {
cwd: __dirname + '/server/'
}
}
}
cwd: __dirname + '/server/',
},
},
},
});
grunt.loadNpmTasks('grunt-webpack');

View File

@ -31,4 +31,5 @@ A school event planner and timetable
## Security Pitfalls
- Auth mechanism not verified
- Verification of OID tokens is done by upn being the email address
- Succeptable to insecure direct object references

View File

@ -0,0 +1,127 @@
import React from 'react';
import { Dialog, Input, Dropdown, DatePicker, TimePicker } from 'react-toolbox';
export default class AddEventDialog extends React.Component {
constructor(props) {
super(props);
const now = new Date();
this.state = {
group: null,
name: '',
start: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 8),
end: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 10),
groups: [
{ value: 1, label: 'M17502' },
{ value: 2, label: 'Infocomm Club' },
{ value: 3, label: 'Youth Flying Club', disabled: true },
{ value: 4, label: 'Engineering Intrest Group' },
],
};
this.handleGroupChange = this.handleGroupChange.bind(this);
this.handleNameChange = this.handleNameChange.bind(this);
this.handleStartChange = this.handleStartChange.bind(this);
this.handleEndChange = this.handleEndChange.bind(this);
this.actions = [
{ label: 'Cancel', onClick: this.props.onCancel, accent: true },
{ label: 'Add', onClick: this.addEvent, accent: true },
];
// pull user groups, together with the relationship attribute role
// put user groups into state.groups, disabling those not admin
}
handleGroupChange(value) {
this.setState({
group: value,
});
}
handleNameChange(value) {
this.setState({
name: value,
});
}
handleStartChange(value) {
this.setState(prev => ({
start: value,
end: new Date(
value.getFullYear(),
value.getMonth(),
value.getDate(),
prev.end.getHours(),
prev.end.getMinutes(),
),
}));
}
handleEndChange(value) {
this.setState({
end: value,
});
}
render() {
return (
<Dialog
{...this.props}
actions={this.actions}
title="Create a one-time event"
>
<Dropdown
auto
label="Group"
source={this.state.groups}
value={this.state.group}
required
onChange={this.handleGroupChange}
/>
<Input
type="text"
label="Name"
value={this.state.name}
required
onChange={this.handleNameChange}
/>
<DatePicker
label="Start Date"
minDate={new Date()}
value={this.state.start}
required
onChange={this.handleStartChange}
/>
<TimePicker
label="Start Time"
value={this.state.start}
required
onChange={this.handleStartChange}
/>
<DatePicker
label="End Date"
minDate={
new Date(
this.state.start.getFullYear(),
this.state.start.getMonth(),
this.state.start.getDate(),
)
}
value={this.state.end}
required
onChange={this.handleEndChange}
/>
<TimePicker
label="End Time"
value={this.state.end}
required
onChange={this.handleEndChange}
/>
</Dialog>
);
}
}
AddEventDialog.propTypes = {
onCancel: React.PropTypes.func.isRequired,
};

34
app/components/app.jsx Normal file
View File

@ -0,0 +1,34 @@
import React from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import LayoutMain from '../layouts/main';
import PageHome from '../pages/home';
import PageLogin from '../pages/login';
import PageLoginSchool from '../pages/login_school';
export default class App extends React.Component {
getChildContext() {
return {
user: {},
token: null,
};
}
render() {
return (
<Router>
<LayoutMain>
<Route exact path="/" component={PageHome} />
<Route path="/login" component={PageLogin} />
<Route path="/login/:id" component={PageLoginSchool} />
</LayoutMain>
</Router>
);
}
}
App.childContextTypes = {
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
};

View File

@ -1,6 +1,7 @@
<head>
<meta charset="UTF-8" />
<title>Chronos</title>
<meta name="viewport" content="width=device-width" />
<link href="//fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="//fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" />
<style>*, *:before, *:after { box-sizing: border-box; } html, body { margin: 0; padding: 0; }</style>

View File

@ -2,23 +2,10 @@ import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
// eslint-disable-next-line no-unused-vars
import * as oidc from 'oidc-client';
import LayoutMain from './layouts/main';
import PageHome from './pages/home';
import PageLogin from './pages/login';
import PageLoginSchool from './pages/login_school';
import App from './components/app';
ReactDOM.render(
<Router>
<LayoutMain>
<Route exact path="/" component={PageHome} />
<Route path="/login" component={PageLogin} />
<Route path="/login/:id" component={PageLoginSchool} />
</LayoutMain>
</Router>,
<App />,
document.getElementById('root'),
);

View File

@ -14,7 +14,7 @@ export default class LayoutMain extends React.Component {
toggleDrawerActive() {
this.setState({
drawerActive: !this.state.drawerActive,
drawerActive: !this.state.drawerActive, // TODO: use function instead
});
}
@ -27,11 +27,14 @@ export default class LayoutMain extends React.Component {
onOverlayClick={this.toggleDrawerActive}
>
<div style={{ fontSize: '1.2em' }}>
{this.props.user ? `Hello, ${this.props.user.name}!` : 'Not logged in'}
{this.context.user.email ?
<span>
Hello, <span title={this.context.user.email}>{this.context.user.name}</span>!
</span> : 'Not logged in'}
</div>
<List selectable ripple>
{this.props.user ? <ListItem caption="Home" onClick={() => this.context.router.history.push('/')} /> : null}
{this.props.user ? <ListItem caption="Logout" /> : null}
{this.context.user.email ? <ListItem caption="Home" onClick={() => this.context.router.history.push('/')} /> : null}
{this.context.user.email ? <ListItem caption="Logout" /> : null}
<ListItem caption="Help" />
<ListItem caption="About" />
<ListItem to="https://github.com/ambrosechua/chronos" caption="GitHub" />
@ -40,8 +43,7 @@ export default class LayoutMain extends React.Component {
<Panel>
<AppBar title="Chronos" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive}>
<Navigation type="horizontal">
<Link label="Inbox" icon="inbox" onClick={() => this.context.router.history.push('/login')} />
<Link active label="Profile" icon="person" />
<Link label="Login" onClick={() => this.context.router.history.push('/login')} style={{ color: 'var(--color-dark-contrast)' }} />
</Navigation>
</AppBar>
{this.props.children}
@ -53,14 +55,10 @@ export default class LayoutMain extends React.Component {
LayoutMain.defaultProps = {
children: null,
user: null,
};
LayoutMain.propTypes = {
children: React.PropTypes.node,
user: React.PropTypes.shape({
name: React.PropTypes.string,
}),
};
LayoutMain.contextTypes = {
@ -69,5 +67,7 @@ LayoutMain.contextTypes = {
push: React.PropTypes.func.isRequired,
}).isRequired,
}).isRequired,
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
};

View File

@ -1,6 +1,11 @@
import React from 'react';
import { Card, CardTitle, CardText, CardActions, Button } from 'react-toolbox';
// import BigCalendar from 'react-big-calendar';
// import moment from 'moment';
// BigCalendar.setLocalizer(BigCalendar.momentLocalizer(moment));
import AddEventDialog from '../components/addeventdialog';
// eslint-disable-next-line react/prefer-stateless-function
export default class PageHome extends React.Component {
@ -10,22 +15,47 @@ export default class PageHome extends React.Component {
cards: [
{ key: 0, title: 'Math Test', description: 'Meow' },
],
addEventDialogActive: false,
};
this.showAddEventDialog = this.showAddEventDialog.bind(this);
this.hideAddEventDialog = this.hideAddEventDialog.bind(this);
}
showAddEventDialog() {
this.setState({
addEventDialogActive: true,
});
}
hideAddEventDialog() {
this.setState({
addEventDialogActive: false,
});
}
render() {
return (
<div style={{ padding: '1em' }}>
{this.state.cards.map(card =>
<Card key={card.key} style={{ margin: '1em', width: 'auto' }}>
<CardTitle title={card.title} />
<CardText>{card.description}</CardText>
<CardActions>
<Button label="Edit" />
</CardActions>
</Card>,
)}
</div>
<main>
<div style={{ padding: '1em' }}>
{this.state.cards.map(card =>
<Card key={card.key} style={{ margin: '1em', width: 'auto' }}>
<CardTitle title={card.title} />
<CardText>{card.description}</CardText>
<CardActions>
<Button label="Edit" accent />
</CardActions>
</Card>,
)}
</div>
<Button style={{ position: 'fixed', bottom: '1em', right: '1em' }} icon="add" floating accent onClick={this.showAddEventDialog} />
<AddEventDialog
active={this.state.addEventDialogActive}
onCancel={this.hideAddEventDialog}
onEscKeyDown={this.hideAddEventDialog}
onOverlayClick={this.hideAddEventDialog}
/>
</main>
);
}
}

View File

@ -4,6 +4,21 @@ import { Input, Button, Snackbar } from 'react-toolbox';
import { UserManager } from 'oidc-client';
const getParams = (query) => {
if (!query) {
return {};
}
return (/^[?#]/.test(query) ? query.slice(1) : query)
.split('&')
.reduce((params, param) => {
const [key, value] = param.split('=');
const obj = {};
obj[key] = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : '';
return Object.assign(params, obj);
}, {});
};
// eslint-disable-next-line react/prefer-stateless-function
export default class PageLoginSchool extends React.Component {
constructor(props) {
@ -26,16 +41,33 @@ export default class PageLoginSchool extends React.Component {
client_id: this.state.school.auth[0].oid_cid,
redirect_uri: `${window.location.origin}/login/${this.state.school.id}`,
});
this.userManager.getUser()
.then((user) => {
console.log(user);
if (user) {
this.context.router.history.push('/');
}
})
.catch(() => {
});
const params = getParams(window.location.hash);
if (params.id_token) {
// TODO: check auth by sending request to server
// TODO: auth endpoint should return user information
// TODO: use user information here:
const method = 'POST';
const headers = new Headers();
headers.append('Content-Type', 'application/json');
const body = JSON.stringify({
type: 'OID',
id_token: params.id_token,
});
fetch(`/api/v1/schools/${this.state.school.id}/login`, {
method, headers, body,
}).then(async (response) => {
if (response.ok) {
Object.assign(this.context.user, await response.json());
this.context.router.history.push('/');
} else {
console.error(await response.json());
// TODO
}
}).catch((e) => {
// TODO
console.error(e);
});
}
}
});
}
@ -122,4 +154,6 @@ PageLoginSchool.contextTypes = {
push: React.PropTypes.func.isRequired,
}).isRequired,
}).isRequired,
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
};

View File

@ -43,10 +43,17 @@
},
"dependencies": {
"babel-polyfill": "^6.23.0",
"body-parser": "^1.17.1",
"express": "^4.14.1",
"jsonwebtoken": "^7.3.0",
"jwk-to-pem": "^1.2.6",
"moment": "^2.18.1",
"mysql": "^2.13.0",
"node-fetch": "^1.6.3",
"oidc-client": "^1.3.0-beta.3",
"react": "^15.4.2",
"react-big-calendar": "^0.13.0",
"react-css-themr": "^2.0.0",
"react-dom": "^15.4.2",
"react-router-dom": "^4.0.0-beta.6",
"react-toolbox": "^2.0.0-beta.7",

View File

@ -1,10 +1,21 @@
module.exports = {
plugins: {
'postcss-import': {
root: __dirname,
},
'postcss-mixins': {},
'postcss-each': {},
'postcss-cssnext': {}
},
};
plugins: {
'postcss-import': {
root: __dirname,
},
'postcss-mixins': {},
'postcss-each': {},
'postcss-cssnext': {
features: {
customProperties: {
variables: {
'color-primary': 'var(--palette-purple-500)',
'color-primary-dark': 'var(--palette-purple-700)',
'color-accent': 'var(--palette-blue-a200)',
'color-accent-dark': 'var(--palette-blue-700)',
},
},
},
},
},
};

View File

@ -1,6 +1,11 @@
import { get } from 'https';
import Router from 'express';
import { WebError, UnauthenticatedError, NotFoundError } from './errors';
import bodyParser from 'body-parser';
import fetch from 'node-fetch';
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
import { WebError, UnknownError, UnauthenticatedError, NotFoundError, InvalidCredentialsError, BadRequestError } from './errors';
export default class API {
constructor(database) {
@ -9,10 +14,14 @@ export default class API {
// Binds
this.auth = this.auth.bind(this);
// Router
this.router = Router({
strict: true,
});
this.router.use(bodyParser.json());
// Routes
this.router.get('/', (req, res) => {
@ -50,21 +59,33 @@ export default class API {
.catch(next);
});
// Login
this.router.post('/schools/:school/login', (req, res, next) => {
this.checkLogin(req.params.school, req.body)
.then((l) => {
res.json(l);
// Generate and return token
})
.catch(next);
});
// Users
this.router.get('/schools/:school/users/', this.auth, (req, res, next) => {
this.database.getUsers(req.params.school)
.then((data) => {
res.json(data);
}).catch(next);
})
.catch(next);
});
this.router.get('/schools/:school/users/:id', this.auth, (req, res, next) => {
this.database.getUser(req.params.school, req.params.id)
this.database.getUserWithSchool(req.params.school, req.params.id)
.then((data) => {
res.json(Object.assign(data, {
pwd: undefined,
oid_id: undefined,
}));
}).catch(next);
})
.catch(next);
});
this.router.use('/*', (req, res, next) => {
@ -76,6 +97,9 @@ export default class API {
auth(req, res, next) {
// res.end('not implemented');
if (this.validate(req.get('FakeAuth'))) {
req.user = {
id: req.get('FakeID'),
};
return next();
}
return next(new UnauthenticatedError());
@ -86,15 +110,59 @@ export default class API {
return !!token;
}
async checkLogin(school, options) {
const checkLoginPassword = async () => {
// TODO
throw new InvalidCredentialsError('Not implemented');
};
const checkLoginToken = async (sch, token) => {
// do
// - get school
// then
// - fetch school private key
// then
// - verify jwt
const s = await this.database.getSchoolWithAuth(sch);
const oidUrl = s.auth[0].oid_meta;
if (!oidUrl) {
throw new Error();
}
const o = await fetch(oidUrl).then(res => res.json());
const jwksUrl = o.jwks_uri;
const k = await fetch(jwksUrl).then(res => res.json());
const keys = k.keys;
const verified = keys.reduce((a, key) => {
try {
return jwt.verify(token, jwkToPem(key), {
algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'],
ignoreExpiration: true,
});
} catch (e) {
return a;
}
}, null);
if (!verified) {
throw new InvalidCredentialsError('Token not verifiable');
}
return verified;
};
if (options.type === 'PWD') { // not used
return this.database.getUserByEmail(options.email)
.then(data => checkLoginPassword(data.pwd_hash, options.pwd) && data);
} else if (options.type === 'OID') {
return checkLoginToken(school, options.id_token)
.then(data => this.database.getUserByEmail(data.upn));
}
return new BadRequestError();
}
// eslint-disable-next-line no-unused-vars
static error(err, req, res, next) {
if (err instanceof WebError) {
err.responseTo(res);
} else if (err) {
console.error(err);
res.status(500).json({
error: 'Unknown error',
});
new UnknownError().responseTo(res);
}
next();
}

View File

@ -33,13 +33,13 @@ export default class Database {
id: results[0].id,
name: results[0].name,
domain: results[0].domain,
auth: results.map(r => Object.assign(r, {
auth: results[0].type ? results.map(r => Object.assign(r, {
school: undefined,
name: undefined,
domain: undefined,
})),
})) : [],
}))
.catch(err => err.withNoun('School'));
.catch(err => Promise.reject(err.withNoun('School')));
}
// eslint-disable-next-line class-methods-use-this
@ -50,7 +50,17 @@ export default class Database {
WHERE user.school = ?
`, [school]);
}
async getUser(school, id) {
async getUser(id) {
return this.query(`
SELECT *
FROM user
WHERE user.id = ?
`, [id], {
single: true,
})
.catch(err => Promise.reject(err.withNoun('User')));
}
async getUserWithSchool(school, id) {
return this.query(`
SELECT *
FROM user
@ -59,7 +69,18 @@ export default class Database {
`, [school, id], {
single: true,
})
.catch(err => err.withNoun('User'));
.catch(err => Promise.reject(err.withNoun('User')));
}
async getUserByEmail(email) {
// assumes unique email
return this.query(`
SELECT *
FROM user
WHERE user.email = ?
`, [email], {
single: true,
})
.catch(err => Promise.reject(err.withNoun('User')));
}
query(query, values, options = {}) {

View File

@ -11,6 +11,15 @@ export class WebError extends Error {
}
}
export class UnknownError extends WebError {
constructor() {
super();
this.name = this.constructor.name;
this.message = 'Unknown error';
this.code = 500;
}
}
export class NotFoundError extends WebError {
constructor(noun = 'Resource') {
super();
@ -32,3 +41,21 @@ export class UnauthenticatedError extends WebError {
this.code = 403;
}
}
export class InvalidCredentialsError extends WebError {
constructor() {
super();
this.name = this.constructor.name;
this.message = 'Invalid credentials';
this.code = 403;
}
}
export class BadRequestError extends WebError {
constructor() {
super();
this.name = this.constructor.name;
this.message = 'Bad request';
this.code = 400;
}
}