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; + } +}