diff --git a/.gitignore b/.gitignore
index 1ab9435..23e8ae1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,4 @@ dist/
# Temporary files
oid_settings.json
nginx.conf
-sample_data.sql
+sample_*.sql
diff --git a/Gruntfile.js b/Gruntfile.js
index 233d0c0..b33f144 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -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');
diff --git a/README.md b/README.md
index 703eaa5..ce205e2 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/components/addeventdialog.jsx b/app/components/addeventdialog.jsx
new file mode 100644
index 0000000..475d4a6
--- /dev/null
+++ b/app/components/addeventdialog.jsx
@@ -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 (
+
+ );
+ }
+}
+
+AddEventDialog.propTypes = {
+ onCancel: React.PropTypes.func.isRequired,
+};
diff --git a/app/components/app.jsx b/app/components/app.jsx
new file mode 100644
index 0000000..697a2bd
--- /dev/null
+++ b/app/components/app.jsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+App.childContextTypes = {
+ // eslint-disable-next-line react/forbid-prop-types
+ user: React.PropTypes.object.isRequired,
+ token: React.PropTypes.string,
+};
diff --git a/app/index.html b/app/index.html
index 10a63b4..956cf12 100644
--- a/app/index.html
+++ b/app/index.html
@@ -1,6 +1,7 @@
Chronos
+
diff --git a/app/index.jsx b/app/index.jsx
index 5191415..ee3f8d4 100644
--- a/app/index.jsx
+++ b/app/index.jsx
@@ -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(
-
-
-
-
-
-
- ,
+ ,
document.getElementById('root'),
);
diff --git a/app/layouts/main.jsx b/app/layouts/main.jsx
index dcb69f4..0dc968b 100644
--- a/app/layouts/main.jsx
+++ b/app/layouts/main.jsx
@@ -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}
>
- {this.props.user ? `Hello, ${this.props.user.name}!` : 'Not logged in'}
+ {this.context.user.email ?
+
+ Hello, {this.context.user.name}!
+ : 'Not logged in'}
- {this.props.user ? this.context.router.history.push('/')} /> : null}
- {this.props.user ? : null}
+ {this.context.user.email ? this.context.router.history.push('/')} /> : null}
+ {this.context.user.email ? : null}
@@ -40,8 +43,7 @@ export default class LayoutMain extends React.Component {
- this.context.router.history.push('/login')} />
-
+ this.context.router.history.push('/login')} style={{ color: 'var(--color-dark-contrast)' }} />
{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,
};
diff --git a/app/pages/home.jsx b/app/pages/home.jsx
index ee4c8e6..accf76c 100644
--- a/app/pages/home.jsx
+++ b/app/pages/home.jsx
@@ -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 (
-
- {this.state.cards.map(card =>
-
-
- {card.description}
-
-
-
- ,
- )}
-
+
+
+ {this.state.cards.map(card =>
+
+
+ {card.description}
+
+
+
+ ,
+ )}
+
+
+
+
);
}
}
diff --git a/app/pages/login_school.jsx b/app/pages/login_school.jsx
index 87d5b59..c203cbf 100644
--- a/app/pages/login_school.jsx
+++ b/app/pages/login_school.jsx
@@ -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,
};
diff --git a/package.json b/package.json
index eeeb97c..5f2f09b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/postcss.config.js b/postcss.config.js
index 8ae3425..fbaa961 100644
--- a/postcss.config.js
+++ b/postcss.config.js
@@ -1,10 +1,21 @@
module.exports = {
- plugins: {
- 'postcss-import': {
- root: __dirname,
- },
- 'postcss-mixins': {},
- 'postcss-each': {},
- 'postcss-cssnext': {}
- },
-};
\ No newline at end of file
+ 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)',
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/server/api.js b/server/api.js
index 99c2afd..afe86a9 100644
--- a/server/api.js
+++ b/server/api.js
@@ -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();
}
diff --git a/server/database.js b/server/database.js
index 8ec1bc7..e084ae8 100644
--- a/server/database.js
+++ b/server/database.js
@@ -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 = {}) {
diff --git a/server/errors.js b/server/errors.js
index de126a2..5978872 100644
--- a/server/errors.js
+++ b/server/errors.js
@@ -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;
+ }
+}