add CSRF for protect Cross Site Attack

This commit is contained in:
Sukchan Lee 2017-05-19 14:13:44 +09:00
parent ceb1859dba
commit 25f7275677
13 changed files with 515 additions and 74 deletions

View File

@ -0,0 +1,19 @@
import Link from 'next/link'
import React from 'react'
import Package from '../package'
export default() => (
<footer>
<div className="container">
<hr/>
<p>
<Link prefetch href="/"><a><strong>Home</strong></a></Link>
&nbsp;|&nbsp;
<Link href="https://github.com/acetcom/cellwire"><a>NextEPC {Package.version}</a></Link>
&nbsp;|&nbsp;
<Link href="https://github.com/zeit/next.js"><a>nextjs {Package.dependencies.next}</a></Link>
&nbsp;| &copy; {new Date().getYear() + 1900}
</p>
</div>
</footer>
)

View File

@ -0,0 +1,30 @@
/* eslint-disable react/no-danger */
import Head from 'next/head'
import Link from 'next/link'
import React from 'react'
import Menu from './menu'
export default class extends React.Component {
static propTypes() {
return {
session: React.PropTypes.object.isRequired
}
}
render() {
return (
<header>
<Head>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
</Head>
<Menu session={this.props.session}/>
<div className="header">
<h1><Link prefetch href="/"><a>NextEPC Project</a></Link></h1>
<hr/>
</div>
</header>
)
}
}

View File

@ -1,32 +1,26 @@
import Link from 'next/link'
import Head from 'next/head'
import React from 'react'
import Header from './header'
import Footer from './footer'
const Layout = ({
children, title = 'This is the default title',
isAuthenticated, currentUrl }) => {
return (
<div>
<Head>
<title>{ title }</title>
<meta charSet='utf-8' />
<meta name='viewport' content='initial-scale=1.0, width=device-width' />
</Head>
<header>
<nav>
<Link prefetch key='/about' href='/about'><a>About</a></Link> |
<Link prefetch key='/contact' href='/contact'><a>Contact</a></Link>
{isAuthenticated && <p>isAuthenticated</p>}
{currentUrl && <p>{currentUrl}</p>}
</nav>
</header>
export default class extends React.Component {
{ children }
static propTypes() {
return {
session: React.PropTypes.object.isRequired,
children: React.PropTypes.object.isRequired
}
}
render() {
return (
<div>
<Header session={this.props.session}/>
<div className="container">
{ this.props.children }
</div>
<Footer/>
</div>
)
}
<footer>
I'm here to say
</footer>
</div>
);
}
export default Layout;

48
webui/components/menu.js Normal file
View File

@ -0,0 +1,48 @@
/* global window */
import Link from 'next/link'
import React from 'react'
import Session from './session'
export default class extends React.Component {
static propTypes() {
return {
session: React.PropTypes.object.isRequired
}
}
async handleSubmit(event) {
event.preventDefault()
const session = new Session()
await session.signout()
// @FIXME next/router not working reliably so using window.location
window.location = '/'
}
render() {
const session = this.props.session || null
let loginMessage = <p><Link prefetch href="/"><a className="home">Home</a></Link> You are not logged in. <Link prefetch href="/login"><a>Sign in</a></Link></p>
if (session.user) {
loginMessage = (
<form id="signout" method="post" action="/logout" onSubmit={this.handleSubmit}>
<input name="_csrf" type="hidden" value={session.csrfToken}/>
<p>
<Link prefetch href="/"><a className="home">Home</a></Link>Logged in as <strong><Link prefetch href="/login"><a>{session.user.username || session.user.role}</a></Link></strong>
<button type="submit">Sign out</button>
</p>
</form>
)
}
return (
<div className="menubar">
{loginMessage}
</div>
)
}
}

12
webui/components/page.js Normal file
View File

@ -0,0 +1,12 @@
import React from 'react'
import Session from './session'
export default class extends React.Component {
// Expose session to all pages
static async getInitialProps({req}) {
const session = new Session({req})
return {session: await session.getSession()}
}
}

209
webui/components/session.js Normal file
View File

@ -0,0 +1,209 @@
/* global window */
/* global localStorage */
/* global XMLHttpRequest */
/**
* A class to handle signing in and out and caching session data in sessionStore
*
* Note: We use XMLHttpRequest() here rather than fetch because fetch() uses
* Service Workers and they cannot share cookies with the browser session
* yet (!) so if we tried to get or pass the CSRF token it would mismatch.
*/
export default class Session {
constructor({req} = {}) {
this._session = {}
try {
if (req) {
// If running on server we can access the server side environment
this._session = {
csrfToken: req.connection._httpMessage.locals._csrf
}
// If the session is associated with a user add user object to session
if (req.user) {
this._session.user = req.user
}
} else {
// If running on client, attempt to load session from localStorage
this._session = this._getLocalStore('session')
}
} catch (err) {
// Handle if error reading from localStorage or server state is safe to
// ignore (will just cause session data to be fetched by ajax)
return
}
}
static async getCsrfToken() {
return new Promise((resolve, reject) => {
if (typeof window === 'undefined') {
return reject(Error('This method should only be called on the client'))
}
let xhr = new XMLHttpRequest()
xhr.open('GET', '/csrf', true)
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
const responseJson = JSON.parse(xhr.responseText)
resolve(responseJson.csrfToken)
} else {
reject(Error('Unexpected response when trying to get CSRF token'))
}
}
}
xhr.onerror = () => {
reject(Error('XMLHttpRequest error: Unable to get CSRF token'))
}
xhr.send()
})
}
// We can't do async requests in the constructor so access is via asyc method
// This allows us to use XMLHttpRequest when running on the client to fetch it
// Note: We use XMLHttpRequest instead of fetch so auth cookies are passed
async getSession(forceUpdate) {
// If running on the server, return session as will be loaded in constructor
if (typeof window === 'undefined') {
return new Promise(resolve => {
resolve(this._session)
})
}
// If force update is set, clear data from store
if (forceUpdate === true) {
this._session = {}
this._removeLocalStore('session')
}
// Attempt to load session data from sessionStore on every call
this._session = this._getLocalStore('session')
// If session data exists, has not expired AND forceUpdate is not set then
// return the stored session we already have.
if (this._session && Object.keys(this._session).length > 0 && this._session.expires && this._session.expires > Date.now()) {
return new Promise(resolve => {
resolve(this._session)
})
}
// If we don't have session data, or it's expired, or forceUpdate is set
// to true then revalidate it by fetching it again from the server.
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest()
xhr.open('GET', '/session', true)
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// Update session with session info
this._session = JSON.parse(xhr.responseText)
// Set a value we will use to check this client should silently
// revalidate based on the value of clientMaxAge set by the server
this._session.expires = Date.now() + this._session.clientMaxAge
// Save changes to session
this._saveLocalStore('session', this._session)
resolve(this._session)
} else {
reject(Error('XMLHttpRequest failed: Unable to get session'))
}
}
}
xhr.onerror = () => {
reject(Error('XMLHttpRequest error: Unable to get session'))
}
xhr.send()
})
}
async signin(username, password) {
// Sign in to the server
return new Promise(async (resolve, reject) => {
if (typeof window === 'undefined') {
return reject(Error('This method should only be called on the client'))
}
// Make sure we have session in memory
this._session = await this.getSession()
// Make sure we have the latest CSRF Token in our session
this._session.csrfToken = await Session.getCsrfToken()
let xhr = new XMLHttpRequest()
xhr.open('POST', '/login', true)
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded')
xhr.onreadystatechange = async () => {
if (xhr.readyState === 4) {
if (xhr.status !== 200) {
return reject(Error('XMLHttpRequest error: Error while attempting to signin'))
}
return resolve(true)
}
}
xhr.onerror = () => {
return reject(Error('XMLHttpRequest error: Unable to signin'))
}
xhr.send('_csrf=' + encodeURIComponent(this._session.csrfToken) + '&' +
'username=' + encodeURIComponent(username) + '&' +
'password=' + encodeURIComponent(password))
})
}
async signout() {
// Signout from the server
return new Promise(async (resolve, reject) => {
if (typeof window === 'undefined') {
return reject(Error('This method should only be called on the client'))
}
let xhr = new XMLHttpRequest()
xhr.open('POST', '/logout', true)
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded')
xhr.onreadystatechange = async () => {
if (xhr.readyState === 4) {
// @TODO We aren't checking for success, just completion
// Update local session data
this._session = await this.getSession(true)
resolve(true)
}
}
xhr.onerror = () => {
reject(Error('XMLHttpRequest error: Unable to signout'))
}
xhr.send('_csrf=' + encodeURIComponent(this._session.csrfToken))
})
}
// The Web Storage API is widely supported, but not always available (e.g.
// it can be restricted in private browsing mode, triggering an exception).
// We handle that silently by just returning null here.
_getLocalStore(name) {
try {
return JSON.parse(localStorage.getItem(name))
} catch (err) {
return null
}
}
_saveLocalStore(name, data) {
try {
localStorage.setItem(name, JSON.stringify(data))
return true
} catch (err) {
return false
}
}
_removeLocalStore(name) {
try {
localStorage.removeItem(name)
return true
} catch (err) {
return false
}
}
}

View File

@ -11,6 +11,7 @@
"connect-session-sequelize": "^4.1.0",
"express": "^4.15.2",
"express-session": "^1.15.2",
"lusca": "^1.4.1",
"next": "^2.3.1",
"passport": "^0.3.2",
"passport-local": "^1.0.0",

View File

@ -0,0 +1,8 @@
import React from 'react' // React import here just to keep 'xo' linter happy
export default() =>
<div>
<h1>Hello World</h1>
<p>This is the simplest possible example of a page.</p>
<a href="/">Home</a>
</div>

View File

@ -1,23 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import Link from 'next/link';
import DefaultPage from '../hocs/defaultPage';
/**
* The index page uses a layout page that pulls in header and footer components
*/
import Link from 'next/link'
import React from 'react'
import Page from '../components/page'
import Layout from '../components/layout'
export default class extends Page {
render() {
return (
<Layout session={this.props.session}>
<h2>Under construction</h2>
<ul>
<li><Link prefetch href="/helloworld"><a>HelloWorld</a></Link> - The simplest possible example</li>
<li><Link prefetch href="/login"><a>Login</a></Link> - prefetch</li>
</ul>
</Layout>
)
}
const Index = ({ isAuthenticated }) => {
return (
<div>
<h1>Hello Worlds!</h1>
{isAuthenticated && <p>Authenticated!!</p>}
{!isAuthenticated && <p>Not Authriozed</p>}
<p><Link prefetch href='/login'><a>Login</a></Link></p>
<p><Link prefetch href='/logout'><a>Logout</a></Link></p>
<p><Link prefetch href='/secret'><a>Secure Page</a></Link></p>
</div>
);
}
Index.propTypes = {
isAuthenticated: PropTypes.bool.isRequired
}
export default DefaultPage(Index);

View File

@ -1,25 +1,110 @@
import React from 'react';
import React from 'react'
import Page from '../components/page'
import Layout from '../components/layout'
import Session from '../components/session'
export default class extends Page {
static async getInitialProps({req}) {
// On the sign in page we always force get the latest session data from the
// server by passing 'true' to getSession. This page is the destination
// page after logging or linking/unlinking accounts so avoids any weird
// edge cases.
const session = new Session({req})
return {session: await session.getSession(true)}
}
async componentDidMount() {
// Get latest session data after rendering on client
// Any page that is specified as the oauth callback should do this
const session = new Session()
this.state = {
username: this.state.username,
password: this.state.password,
session: await session.getSession(true)
}
}
constructor(props) {
super(props)
this.state = {
username: '',
password: '',
session: this.props.session
}
this.handleSubmit = this.handleSubmit.bind(this)
this.handleUsernameChange = this.handleUsernameChange.bind(this)
this.handlePasswordChange = this.handlePasswordChange.bind(this)
}
handleUsernameChange(event) {
this.setState({
username: event.target.value.trim(),
password: this.state.password,
session: this.state.session
})
}
handlePasswordChange(event) {
this.setState({
username: this.state.username,
password: event.target.value.trim(),
session: this.state.session
})
}
async handleSubmit(event) {
event.preventDefault()
const session = new Session()
session.signin(this.state.username, this.state.password)
.then(() => {
// @FIXME next/router not working reliably so using window.location
window.location = '/'
})
.catch(err => {
// @FIXME Handle error
console.log(err)
})
}
class Login extends React.Component {
render() {
return (
let signinForm = <div/>
if (this.state.session.user) {
signinForm = (
<div>
<form id='login' method='post' action='/login'>
<div>
<label>Username:</label>
<input name='username' type='text' id='username'/>
</div>
<div>
<label>Password:</label>
<input name='password' type='text' id='password'/>
</div>
<div>
<input type='submit' value='Log In'/>
</div>
<h3>You are signed in</h3>
<p>Name: <strong>{this.state.session.user.name}</strong></p>
</div>
)
} else {
signinForm = (
<div>
<form id="signin" method="post" action="/login" onSubmit={this.handleSubmit}>
<input name="_csrf" type="hidden" value={this.state.session.csrfToken}/>
<h3>Sign in with email</h3>
<p>
<label htmlFor="username">Username</label><br/>
<input name="username" type="text" placeholder="j.smith@example.com" id="username" value={this.state.username} onChange={this.handleUsernameChange}/>
</p>
<p>
<label htmlFor="password">Password</label><br/>
<input name="password" type="text" id="password" value={this.state.password} onChange={this.handlePasswordChange}/>
</p>
<p>
<button id="submitButton" type="submit">Sign in</button>
</p>
</form>
</div>
)
}
}
}
export default (Login);
return (
<Layout session={this.state.session}>
<h2>Authentication</h2>
{signinForm}
</Layout>
)
}
}

View File

@ -3,6 +3,7 @@ const session = require('express-session');
const SequelizeStore = require('connect-session-sequelize')(session.Store);
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const csrf = require('lusca').csrf();
const models = require('../models');
exports.configure = ({
@ -10,7 +11,8 @@ exports.configure = ({
server = null,
secret = 'change-me',
store = new SequelizeStore({ db: models.sequelize, table: 'Session' }),
maxAge = 60000 * 60 * 24 * 7 * 4 // 4 weeks
maxAge = 60000 * 60 * 24 * 7 * 4, // 4 weeks
clientMaxAge = 60000 // 60 seconds
} = {}) => {
if (!app) throw new Error('Null param')
if (!server) throw new Error('Null param')
@ -44,6 +46,10 @@ exports.configure = ({
}
}));
server.use((req, res, next) => {
csrf(req, res, next);
})
passport.use(new LocalStrategy((username, password, done) => {
models.User.findOne({ where: {username: username} }).then(user => {
if (!user) {
@ -72,16 +78,32 @@ exports.configure = ({
server.use(passport.initialize());
server.use(passport.session());
server.get('/csrf', (req, res) => {
return res.json({csrfToken: res.locals._csrf});
})
server.get('/session', (req, res) => {
let session = {
clientMaxAge: clientMaxAge,
csrfToken: res.locals._csrf
}
if (req.user) {
session.user = req.user
}
return res.json(session)
})
server.post('/login',
passport.authenticate('local', {
failureRedirect: '/login',
failureRedirect: '/error',
}),
(req, res) => {
res.redirect('/')
res.redirect('/');
}
);
server.get('/logout', (req, res) => {
server.post('/logout', (req, res) => {
req.logout();
res.redirect('/');
});

View File

@ -23,6 +23,13 @@ app.prepare()
return handle(req, res);
});
// Set vary header (good practice)
// Note: This overrides any existing 'Vary' header but is okay in this app
server.use(function (req, res, next) {
res.setHeader('Vary', 'Accept-Encoding')
next()
});
server.listen(3000, err => {
if (err) throw err;
console.log('> Ready on http://localhost:3000');

View File

@ -1106,7 +1106,7 @@ core-js@^2.4.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e"
core-util-is@~1.0.0:
core-util-is@^1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@ -2166,6 +2166,12 @@ lru-cache@^4.0.1:
pseudomap "^1.0.1"
yallist "^2.0.0"
lusca@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/lusca/-/lusca-1.4.1.tgz#498ad9edc4b77d858d0b1c237db8da7f850b36ec"
dependencies:
core-util-is "^1.0.2"
md5-file@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/md5-file/-/md5-file-3.1.1.tgz#db3c92c09bbda5c2de883fa5490dd711fddbbab9"
@ -2718,13 +2724,13 @@ promise@^7.1.1:
dependencies:
asap "~2.0.3"
prop-types@15.5.7, prop-types@^15.5.4, prop-types@^15.5.7, prop-types@~15.5.7:
prop-types@15.5.7:
version "15.5.7"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.7.tgz#231c4f29cdd82e355011d4889386ca9059544dd1"
dependencies:
fbjs "^0.8.9"
prop-types@^15.5.10:
prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.7, prop-types@~15.5.7:
version "15.5.10"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
dependencies: