├── .babelrc ├── .gitignore ├── .jscsrc ├── README.md ├── client.js ├── client ├── _assets │ └── style.scss ├── _config │ └── superagent.js ├── action-creators │ └── index.js ├── components │ ├── home-component.js │ ├── login-form-component.js │ └── manifest │ │ ├── action │ │ ├── index.js │ │ └── index.scss │ │ ├── button │ │ ├── index.js │ │ └── index.scss │ │ ├── index.js │ │ └── index.scss ├── constants │ └── index.js ├── containers │ ├── app-container.js │ ├── app-router.js │ └── home-container.js ├── index.js ├── index.template.ejs └── reducers │ ├── index.js │ ├── login-form-reducer.js │ └── user-reducer.js ├── package.json ├── server.js ├── server ├── config.js ├── index.js ├── middleware │ ├── index.js │ └── refresh-token.js └── resources │ ├── token │ └── index.js │ └── user │ └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store 3 | node_modules 4 | .idea/ 5 | npm-debug.log -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true, 3 | "disallowSpacesInNamedFunctionExpression": { 4 | "beforeOpeningRoundBrace": true 5 | }, 6 | "disallowSpacesInFunctionExpression": { 7 | "beforeOpeningRoundBrace": true 8 | }, 9 | "disallowSpacesInAnonymousFunctionExpression": { 10 | "beforeOpeningRoundBrace": true 11 | }, 12 | "disallowSpacesInFunctionDeclaration": { 13 | "beforeOpeningRoundBrace": true 14 | }, 15 | "disallowEmptyBlocks": true, 16 | "disallowSpacesInCallExpression": true, 17 | "disallowSpacesInsideArrayBrackets": true, 18 | "disallowSpacesInsideParentheses": true, 19 | "disallowQuotedKeysInObjects": true, 20 | "disallowSpaceAfterObjectKeys": true, 21 | "disallowSpaceAfterPrefixUnaryOperators": true, 22 | "disallowSpaceBeforePostfixUnaryOperators": true, 23 | "disallowSpaceBeforeBinaryOperators": [ 24 | "," 25 | ], 26 | "disallowMixedSpacesAndTabs": true, 27 | "disallowTrailingWhitespace": true, 28 | "requireTrailingComma": { "ignoreSingleLine": true }, 29 | "disallowYodaConditions": true, 30 | "disallowKeywords": [ "with" ], 31 | "disallowKeywordsOnNewLine": ["else"], 32 | "disallowMultipleLineBreaks": true, 33 | "disallowMultipleLineStrings": true, 34 | "disallowMultipleVarDecl": true, 35 | "requireSpaceBeforeBlockStatements": true, 36 | "requireParenthesesAroundIIFE": true, 37 | "requireSpacesInConditionalExpression": true, 38 | "requireBlocksOnNewline": 1, 39 | "requireCommaBeforeLineBreak": true, 40 | "requireSpaceBeforeBinaryOperators": true, 41 | "requireSpaceAfterBinaryOperators": true, 42 | "requireCamelCaseOrUpperCaseIdentifiers": true, 43 | "requireLineFeedAtFileEnd": true, 44 | "requireCapitalizedConstructors": true, 45 | "requireDotNotation": true, 46 | "requireSpacesInForStatement": true, 47 | "requireSpaceBetweenArguments": true, 48 | "requireCurlyBraces": [ 49 | "do" 50 | ], 51 | "requireSpaceAfterKeywords": [ 52 | "if", 53 | "else", 54 | "for", 55 | "while", 56 | "do", 57 | "switch", 58 | "case", 59 | "return", 60 | "try", 61 | "catch", 62 | "typeof" 63 | ], 64 | "requirePaddingNewLinesBeforeLineComments": { 65 | "allExcept": "firstAfterCurly" 66 | }, 67 | "requirePaddingNewLinesAfterBlocks": true, 68 | "requireSemicolons": true, 69 | "safeContextKeyword": "_this", 70 | "validateLineBreaks": "LF", 71 | "validateQuoteMarks": "'", 72 | "validateIndentation": 4 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-auth-demo 2 | 3 | ## Initial Requirements 4 | 5 | - [x] The API will accept a POST request to /session containing username/password, and successfully return either a JWT token as a cookie for a valid authentication request, or reject a failed request with a 401 error. 6 | - [ ] The front end will assume authentication and attempt to retrieve user details from /user. If no token cookie has been set, the /user endpoint will return a 401 and the UI will display the login form. If a token cookie is present, the /user endpoint will return user details based on the user, and the app will load. 7 | - [ ] The front end login view will accept a username and password entry, and submit the request to the API POST /session route to attempt to create a session. 8 | - [ ] A successful front end login request will trigger a redux reducer to store the current user's login attributes. 9 | - [ ] When the session reducer is in the initial state – whether via a reset, or application load – the application will route to the login view. 10 | - [ ] Front end can DELETE /session to invalidate the token and log the user out. 11 | 12 | ### Bonus Round 13 | 14 | - [ ] When a login response succeeds, a `setTimeout` could be used to delay an action until just prior to the expiration of the token. At that time, an action could be trigger to update the session reducer of the imminent expiration (`atPeril` attribute or something?) and display a dialog to the user which could be used to request a new JWT token from the API with a renewed expiration. I'm not sure the best method for this, but possibly POSTing the existing token to the API, which could inspect it's validity and if the token is okay respond with a newly issued one with the same characteristics. 15 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var WebpackDevServer = require('webpack-dev-server'); 3 | var config = require('./webpack.config'); 4 | 5 | new WebpackDevServer(webpack(config), { 6 | publicPath: config.output.publicPath, 7 | hot: true, 8 | historyApiFallback: true 9 | }).listen(4000, 'localhost', function (err, result) { 10 | if (err) { 11 | console.log(err); 12 | } 13 | 14 | console.log('Listening at localhost:4000'); 15 | }); 16 | -------------------------------------------------------------------------------- /client/_assets/style.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | background: lightgrey; 6 | color: #222; 7 | } 8 | 9 | main#app-wrapper { 10 | position: fixed; 11 | top: 0; 12 | bottom: 0; 13 | width: 100%; 14 | } 15 | 16 | h1 { 17 | margin: 0; 18 | } 19 | 20 | label { 21 | display: block; 22 | text-align: left; 23 | } 24 | 25 | input[type="username"], 26 | input[type="password"] { 27 | width: 100%; 28 | margin-bottom: 20px; 29 | } -------------------------------------------------------------------------------- /client/_config/superagent.js: -------------------------------------------------------------------------------- 1 | import superagentDefaults from 'superagent-defaults'; 2 | 3 | const superagent = superagentDefaults(); 4 | 5 | superagent 6 | .set('Content-Type', 'application/json') 7 | .withCredentials(); 8 | 9 | export default superagent; 10 | -------------------------------------------------------------------------------- /client/action-creators/index.js: -------------------------------------------------------------------------------- 1 | import * as constants from '../constants'; 2 | import request from '../_config/superagent'; 3 | import { transitionTo } from 'redux-react-router'; 4 | 5 | export function applicationLoaded(data) { 6 | return dispatch => { 7 | dispatch({ 8 | type: constants.APPLICATION_LOADED, 9 | data, 10 | }); 11 | 12 | return request 13 | .get('http://localhost:3000/api/user') 14 | .end((err, res={}) => { 15 | const { body } = res; 16 | 17 | err ? 18 | dispatch(userFetchFailed()) : 19 | dispatch(userFetchSucceeded(body)); 20 | }); 21 | }; 22 | } 23 | 24 | export function loginSubmitted(data) { 25 | return dispatch => { 26 | dispatch({ 27 | type: constants.LOGIN_SUBMITTED, 28 | data, 29 | }); 30 | 31 | return request 32 | .post('http://localhost:3000/api/token') 33 | .send(JSON.stringify(data)) 34 | .end((err, res) => { 35 | err ? 36 | dispatch(loginFailed()) : 37 | window.location.reload(); 38 | }); 39 | }; 40 | } 41 | 42 | export function loginFailed(data) { 43 | return { 44 | type: constants.LOGIN_FAILED, 45 | data, 46 | }; 47 | } 48 | 49 | export function userFetched(data) { 50 | return dispatch => { 51 | dispatch({ 52 | type: constants.USER_FETCHED, 53 | data, 54 | }); 55 | 56 | return request 57 | .get('http://localhost:3000/api/user') 58 | .end((err, res={}) => { 59 | const { body } = res; 60 | 61 | err ? 62 | dispatch(userFetchFailed()) : 63 | dispatch(userFetchSucceeded(body)); 64 | }); 65 | }; 66 | } 67 | 68 | export function userFetchSucceeded(data) { 69 | return { 70 | type: constants.USER_FETCH_SUCCEEDED, 71 | data, 72 | }; 73 | } 74 | 75 | export function userFetchFailed(data) { 76 | return { 77 | type: constants.USER_FETCH_FAILED, 78 | data, 79 | }; 80 | } 81 | 82 | export function tokenDeleted(data) { 83 | return dispatch => { 84 | dispatch({ 85 | type: constants.TOKEN_DELETED, 86 | data, 87 | }); 88 | 89 | return request 90 | .del('http://localhost:3000/api/token') 91 | .end((err, res) => { 92 | err ? 93 | dispatch(tokenDeleteFailed()) : 94 | window.location.reload(); 95 | }); 96 | }; 97 | } 98 | 99 | export function tokenDeleteFailed(data) { 100 | return { 101 | type: constants.TOKEN_DELETE_FAILED, 102 | data, 103 | }; 104 | } -------------------------------------------------------------------------------- /client/components/home-component.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class HomeComponent { 4 | 5 | render() { 6 | const { tokenDeleted, user: { username } } = this.props; 7 | 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /client/components/login-form-component.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | export default class LoginFormComponent { 4 | 5 | static propTypes = { 6 | loginSubmitted: PropTypes.func.isRequired, 7 | } 8 | 9 | handleFormSubmit = (e) => { 10 | e.preventDefault(); 11 | 12 | const { loginSubmitted } = this.props; 13 | 14 | loginSubmitted({ 15 | username: this.refs.username.value, 16 | password: this.refs.password.value, 17 | }) 18 | } 19 | 20 | render() { 21 | const { loginForm: { username, isLoading } } = this.props; 22 | 23 | return ( 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | { isLoading ? Loading... : null } 34 |
35 | ); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /client/components/manifest/action/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | import './index.scss'; 4 | 5 | export default class ManifestActionComponent extends React.Component { 6 | constructor() { 7 | super(); 8 | this.state = { 9 | expanded: false 10 | } 11 | } 12 | 13 | render() { 14 | const {action, diff} = this.props; 15 | const actionClasses = classNames( 16 | 'action', 17 | { 18 | 'action--mutated': diff.length > 0, 19 | 'action--disabled': this.props.skipped 20 | } 21 | ); 22 | 23 | const actionBlock = this.state.expanded ? 24 |
25 |
{JSON.stringify(action)}
26 |
: 27 | null; 28 | 29 | let changes = []; 30 | if (diff.length > 0) { 31 | changes = diff.map(this.renderDiff.bind(this)); 32 | } 33 | 34 | const storeBlock = this.state.expanded && diff.length > 0 ? 35 |
36 |
Store Mutations
37 |
{changes}
38 |
: 39 | null; 40 | 41 | 42 | const enableToggle = this.props.skipped ? 43 | 'enable' : 44 | 'disable'; 45 | 46 | return ( 47 |
48 |
49 |
50 | {action.type} 51 | 52 | {enableToggle} 53 | 54 |
55 | {actionBlock} 56 | {storeBlock} 57 |
58 |
59 | ) 60 | } 61 | 62 | renderDiff(diff, index) { 63 | const oldValue = JSON.stringify(diff.lhs); 64 | const newValue = JSON.stringify(diff.rhs); 65 | 66 | return ( 67 | 68 | {diff.path.join('.')}: {oldValue} {newValue} 69 |
70 |
71 | ) 72 | } 73 | 74 | expandAction() { 75 | this.setState({ 76 | expanded: !this.state.expanded 77 | }) 78 | } 79 | 80 | disableAction() { 81 | this.props.toggleAction(this.props.index); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /client/components/manifest/action/index.scss: -------------------------------------------------------------------------------- 1 | .action { 2 | $this: &; 3 | 4 | background: white; 5 | margin-bottom: 10px; 6 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.5); 7 | 8 | &__toggle { 9 | float: right; 10 | padding: 0 5px; 11 | } 12 | 13 | &__title { 14 | cursor: pointer; 15 | padding: 3px; 16 | background: #ddd; 17 | -webkit-user-select: none; 18 | 19 | #{$this}--mutated & { 20 | background: lightgreen; 21 | } 22 | 23 | #{$this}--disabled & { 24 | background: black; 25 | color: white; 26 | } 27 | } 28 | 29 | &__header { 30 | text-transform: uppercase; 31 | font-weight: bold; 32 | margin: 5px 0; 33 | text-align: center; 34 | width: 100%; 35 | font-size: 0.9em; 36 | } 37 | 38 | pre { 39 | margin: 0; 40 | padding: 5px; 41 | 42 | &.store { 43 | .old { 44 | text-decoration: line-through; 45 | color: lighten(black, 75%); 46 | } 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /client/components/manifest/button/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | import './index.scss'; 4 | 5 | export default class ManifestButton { 6 | render() { 7 | const {label} = this.props; 8 | 9 | return ( 10 |
11 | {label} 12 |
13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/components/manifest/button/index.scss: -------------------------------------------------------------------------------- 1 | .manifestButton { 2 | cursor: pointer; 3 | background: white; 4 | border: 1px solid black; 5 | border-radius: 50px; 6 | display: inline-block; 7 | padding: 5px 10px; 8 | margin: 10px 0; 9 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.5); 10 | 11 | &:not(:first-of-type) { 12 | margin-left: 5px; 13 | } 14 | 15 | &:hover { 16 | background: darken(#eee,15%); 17 | } 18 | } -------------------------------------------------------------------------------- /client/components/manifest/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import ManifestAction from './action'; 4 | import ManifestButton from './button'; 5 | 6 | import deep from 'deep-diff'; 7 | import './index.scss'; 8 | import classNames from 'classnames' 9 | import mousetrap from 'mousetrap'; 10 | 11 | export default class ManifestComponent extends React.Component { 12 | constructor() { 13 | super(); 14 | this.state = { 15 | visible: true 16 | }; 17 | } 18 | 19 | static propTypes = { 20 | // Stuff you can use 21 | computedStates: PropTypes.array.isRequired, 22 | currentStateIndex: PropTypes.number.isRequired, 23 | stagedActions: PropTypes.array.isRequired, 24 | skippedActions: PropTypes.object.isRequired, 25 | 26 | // Stuff you can do 27 | reset: PropTypes.func.isRequired, 28 | commit: PropTypes.func.isRequired, 29 | rollback: PropTypes.func.isRequired, 30 | sweep: PropTypes.func.isRequired, 31 | toggleAction: PropTypes.func.isRequired, // ({ index }) 32 | jumpToState: PropTypes.func.isRequired // ({ index }) 33 | }; 34 | 35 | componentDidMount() { 36 | const self = this; 37 | Mousetrap.bind(['ctrl+]'], function (e) { 38 | self.toggleVisibility(); 39 | return false; 40 | }); 41 | } 42 | 43 | toggleVisibility() { 44 | this.setState({visible: !this.state.visible}) 45 | } 46 | 47 | componentWillUnmount() { 48 | Mousetrap.unbind(['ctrl+]']); 49 | } 50 | 51 | render() { 52 | const actionReports = this.props.stagedActions.map(this.renderAction.bind(this)); 53 | const frameClasses = classNames( 54 | 'frame', 55 | { 56 | 'frame--hidden': this.state.visible === false 57 | } 58 | ); 59 | 60 | return ( 61 |
62 |
63 | 64 | 65 | 66 | 67 |
68 | {actionReports.reverse()} 69 |
70 | ); 71 | } 72 | 73 | renderAction(action, index) { 74 | let newState, oldState, diff; 75 | if (index !== 0) { 76 | newState = this.props.computedStates[index].state; 77 | oldState = this.props.computedStates[index - 1].state; 78 | diff = deep.diff(oldState, newState); 79 | } 80 | 81 | const skippingAction = this.props.skippedActions[index]===true; 82 | 83 | return ( 84 | 91 | ) 92 | } 93 | 94 | jumpingTo(index) { 95 | this.props.jumpToState(index); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /client/components/manifest/index.scss: -------------------------------------------------------------------------------- 1 | .frame { 2 | font-family: 'Arial', san-serif; 3 | font-size: 13px; 4 | position: fixed; 5 | top: 0; 6 | right: 0; 7 | bottom: 0; 8 | width: 350px; 9 | background: #eee; 10 | word-wrap: break-word; 11 | padding: 0 10px 0 10px; 12 | border-left: 1px solid #ddd; 13 | overflow-y: auto; 14 | 15 | &--hidden { 16 | display: none; 17 | } 18 | } -------------------------------------------------------------------------------- /client/constants/index.js: -------------------------------------------------------------------------------- 1 | export const APPLICATION_LOADED = 'APPLICATION_LOADED'; 2 | export const LOGIN_SUBMITTED = 'LOGIN_SUBMITTED'; 3 | export const LOGIN_SUCCEEDED = 'LOGIN_SUCCEEDED'; 4 | export const LOGIN_FAILED = 'LOGIN_FAILED'; 5 | export const USER_FETCHED = 'USER_FETCHED'; 6 | export const USER_FETCH_SUCCEEDED = 'USER_FETCH_SUCCEEDED'; 7 | export const USER_FETCH_FAILED = 'USER_FETCH_FAILED'; 8 | export const TOKEN_DELETED = 'TOKEN_DELETED'; 9 | export const TOKEN_DELETE_SUCCEEDED = 'TOKEN_DELETE_SUCCEEDED'; 10 | export const TOKEN_DELETE_FAILED = 'TOKEN_DELETE_FAILED'; -------------------------------------------------------------------------------- /client/containers/app-container.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { bindActionCreators } from 'redux'; 3 | import * as actionCreators from '../action-creators'; 4 | import { connect } from 'react-redux'; 5 | 6 | import LoginFormComponent from '../components/login-form-component'; 7 | 8 | function select(state) { 9 | return { 10 | user: state.user, 11 | loginForm: state.loginForm, 12 | }; 13 | } 14 | 15 | class AppContainer { 16 | 17 | constructor(props) { 18 | const { dispatch } = props; 19 | dispatch(actionCreators.applicationLoaded()); 20 | } 21 | 22 | render() { 23 | const { dispatch, user, loginForm, children } = this.props; 24 | 25 | const headerBlock = user.authenticated ? 26 |

Logged in as: {user.username}

: 27 |

Please Log In

; 28 | 29 | const contentBlock = user.authenticated ? 30 | children : 31 | ; 32 | 33 | return ( 34 |
41 |
42 | {headerBlock} 43 |
44 | 45 |
46 | {contentBlock} 47 |
48 |
49 | ); 50 | } 51 | } 52 | 53 | export default connect(select)(AppContainer); -------------------------------------------------------------------------------- /client/containers/app-router.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Route, Redirect } from 'react-router'; 3 | import { reduxRouteComponent } from 'redux-react-router'; 4 | import { history } from 'react-router/lib/HashHistory'; 5 | import { store } from '../index.js'; 6 | import AppContainer from './app-container'; 7 | import HomeContainer from '../containers/home-container'; 8 | 9 | export default class AppRouter { 10 | render() { 11 | return( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/containers/home-container.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { bindActionCreators } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | import * as actionCreators from '../action-creators'; 5 | import Home from '../components/home-component'; 6 | 7 | function select(state) { 8 | return { 9 | user: state.user, 10 | }; 11 | } 12 | 13 | class HomeContainer { 14 | 15 | render() { 16 | const { dispatch, user } = this.props; 17 | 18 | return ( 19 | 20 | ); 21 | } 22 | 23 | } 24 | 25 | export default connect(select)(HomeContainer); -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import { devTools, persistState } from 'redux-devtools'; 6 | import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react'; 7 | 8 | import DiffMonitor from 'redux-devtools-diff-monitor'; 9 | 10 | import { routerStateReducer } from 'redux-react-router'; 11 | import thunkMiddleware from 'redux-thunk'; 12 | import AppRouter from './containers/app-router'; 13 | import * as reducers from './reducers'; 14 | 15 | import './_assets/style.scss'; 16 | 17 | function loggerMiddleware(next) { 18 | return next => action => { 19 | next(action); 20 | }; 21 | } 22 | 23 | const reducer = combineReducers({ 24 | router: routerStateReducer, 25 | ...reducers, 26 | }); 27 | const finalCreateStore = compose( 28 | applyMiddleware(thunkMiddleware, loggerMiddleware), 29 | devTools(), 30 | persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)), 31 | createStore 32 | ); 33 | export const store = finalCreateStore(reducer, {}); 34 | 35 | render( 36 |
37 | 38 | {() => } 39 | 40 | 41 |
, 42 | document.getElementById('app-wrapper') 43 | ); 44 | -------------------------------------------------------------------------------- /client/index.template.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {%= o.htmlWebpackPlugin.options.title %} 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /client/reducers/index.js: -------------------------------------------------------------------------------- 1 | export { default as user } from './user-reducer'; 2 | export { default as loginForm } from './login-form-reducer'; 3 | -------------------------------------------------------------------------------- /client/reducers/login-form-reducer.js: -------------------------------------------------------------------------------- 1 | import * as constants from '../constants'; 2 | 3 | const initialState = { 4 | username: '', 5 | isLoading: false, 6 | } 7 | 8 | export default function(state = initialState, action = {}) { 9 | 10 | const { data, type } = action; 11 | 12 | switch (type) { 13 | case constants.LOGIN_SUBMITTED: 14 | return { 15 | ...state, 16 | username: data.username, 17 | isLoading: true, 18 | }; 19 | 20 | case constants.LOGIN_SUCCEEDED: 21 | return initialState; 22 | 23 | case constants.LOGIN_FAILED: 24 | return { 25 | ...state, 26 | isLoading: false, 27 | }; 28 | 29 | default: 30 | return state; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /client/reducers/user-reducer.js: -------------------------------------------------------------------------------- 1 | import * as constants from '../constants'; 2 | 3 | const initialState = { 4 | authenticated: false, 5 | username: null 6 | }; 7 | 8 | export default function (state = initialState, action = {}) { 9 | 10 | const { data, type } = action; 11 | 12 | switch (type) { 13 | case constants.USER_FETCH_SUCCEEDED: 14 | return { 15 | ...state, 16 | authenticated: true, 17 | username: data.username, 18 | }; 19 | 20 | case constants.TOKEN_DELETE_FAILED: 21 | return { 22 | ...state, 23 | authenticated: false, 24 | }; 25 | 26 | default: 27 | return state; 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-auth-demo", 3 | "version": "1.0.0", 4 | "description": "## Initial Requirements", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "nodemon --watch server ./server.js", 8 | "client": "nodemon --watch server ./client.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/iktl/redux-auth-demo.git" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/iktl/redux-auth-demo/issues" 18 | }, 19 | "homepage": "https://github.com/iktl/redux-auth-demo", 20 | "dependencies": { 21 | "babel": "^5.5.5", 22 | "babel-runtime": "^5.5.5", 23 | "body-parser": "^1.13.2", 24 | "classnames": "^2.1.3", 25 | "cookie-parser": "^1.3.5", 26 | "cors": "^2.7.1", 27 | "css-loader": "^0.15.6", 28 | "deep-diff": "^0.3.2", 29 | "errorhandler": "^1.3.6", 30 | "express": "^4.12.4", 31 | "express-jwt": "^3.0.1", 32 | "jsonwebtoken": "^5.0.1", 33 | "morgan": "^1.6.1", 34 | "mousetrap": "^1.5.3", 35 | "node-sass": "^3.2.0", 36 | "react-dom": "^0.14.0-beta1", 37 | "react-router": "^1.0.0-beta3", 38 | "redux-react-router": "^0.2.1", 39 | "sass-loader": "^1.0.3", 40 | "style-loader": "^0.12.3", 41 | "superagent": "^1.2.0", 42 | "superagent-defaults": "^0.1.13" 43 | }, 44 | "devDependencies": { 45 | "babel-core": "^5.8.14", 46 | "babel-loader": "^5.3.2", 47 | "html-webpack-plugin": "^1.6.0", 48 | "jscs": "^2.0.0", 49 | "node-libs-browser": "^0.5.2", 50 | "nodemon": "^1.4.0", 51 | "react": "0.14.0-beta1", 52 | "react-hot-loader": "^1.2.8", 53 | "react-redux": "^0.2.2", 54 | "redux": "^1.0.0-rc", 55 | "redux-devtools": "~0.1.2", 56 | "redux-devtools-diff-monitor": "^0.1.8", 57 | "redux-thunk": "^0.1.0", 58 | "webpack": "^1.10.5", 59 | "webpack-dev-server": "^1.10.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require("babel/register"); 2 | require('./server/index.js'); -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | secret: 'mysupersecretthing', 3 | jwtExpiresInMinutes: 10, 4 | }; -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import logger from 'morgan'; 3 | import errorhandler from 'errorhandler'; 4 | import cors from 'cors'; 5 | import expressJwt from 'express-jwt'; 6 | import jwt from 'jsonwebtoken'; 7 | import bodyParser from 'body-parser'; 8 | import cookieParser from 'cookie-parser'; 9 | 10 | import config from './config'; 11 | import * as middleware from './middleware'; 12 | import token from './resources/token'; 13 | import user from './resources/user'; 14 | 15 | const app = express(); 16 | const router = express.Router(); 17 | 18 | app.use(cookieParser()); 19 | app.use(bodyParser.json()); 20 | app.use(cors({ 21 | origin: true, // TODO: Configure allowed origins 22 | methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], 23 | credentials: true, 24 | })); 25 | app.use(bodyParser.urlencoded({ 26 | extended: true 27 | })); 28 | 29 | app.use(logger('dev')); 30 | app.use(errorhandler()); 31 | 32 | const protect = () => { 33 | return expressJwt({ 34 | secret: config.secret, 35 | getToken: function (req) { 36 | return req.cookies.token; 37 | }, 38 | }).unless({path: ['/api/token']}); 39 | }; 40 | 41 | // set up API routing 42 | app.use('/api', protect(), router); 43 | router.post('/token', token.create); 44 | router.delete('/token', token.delete); 45 | router.get('/user', middleware.refreshToken, user.read); 46 | 47 | // Handle unauthorized errors gracefully. 48 | app.use(function (err, req, res, next) { 49 | if (err.name === 'UnauthorizedError') { 50 | res.status(401).send('Invalid Token'); 51 | } 52 | }); 53 | 54 | app.use(function (req, res, next) { 55 | 56 | }); 57 | 58 | const server = app.listen(process.env.PORT || 3000, () => { 59 | const host = server.address().address; 60 | const port = server.address().port; 61 | console.log(`Example app listening at http://${host}:${port}`); 62 | }); 63 | 64 | export default app; 65 | -------------------------------------------------------------------------------- /server/middleware/index.js: -------------------------------------------------------------------------------- 1 | export { default as refreshToken } from './refresh-token'; -------------------------------------------------------------------------------- /server/middleware/refresh-token.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import config from '../config'; 3 | 4 | export default function refreshToken(req, res, next) { 5 | const { cookies: { token: requestToken } } = req; 6 | const decodedTokenPayload = jwt.decode(requestToken); 7 | const { username } = decodedTokenPayload; 8 | 9 | const updatedToken = jwt.sign({username: username}, config.secret, { 10 | issuer: 'redux-demo', 11 | expiresInMinutes: config.jwtExpiresInMinutes, 12 | }); 13 | 14 | res.cookie('token', updatedToken); 15 | next(); 16 | } -------------------------------------------------------------------------------- /server/resources/token/index.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import config from '../../config'; 3 | 4 | export default { 5 | create(req, res) { 6 | const { username, password } = req.body; 7 | 8 | /* obviously you would normally do a real look-up here */ 9 | if (username === 'test' && password === 'test') { 10 | let token = jwt.sign({username: username}, config.secret, { 11 | issuer: 'redux-demo', 12 | expiresInSeconds: config.jwtExpiresInMinutes, 13 | }); 14 | 15 | res.status(201).cookie('token', token).send(); 16 | } else { 17 | res.status(401).send('Authentication failure.'); 18 | } 19 | }, 20 | 21 | delete(req, res) { 22 | res.status(204).clearCookie('token').send(); 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /server/resources/user/index.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import config from '../../config'; 3 | 4 | export default { 5 | read(req, res) { 6 | const now = new Date().toString(); 7 | 8 | res.status(200).send({ 9 | username: 'test', 10 | lastLogin: now, 11 | }); 12 | } 13 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | var clientPath = path.join(__dirname, 'client'); 6 | 7 | module.exports = { 8 | devtool: 'source-map', 9 | module: { 10 | loaders: [ 11 | {test: /\.js$/, include: path.join(__dirname, 'client'), loaders: ['react-hot', 'babel']}, 12 | {test: /\.scss$/, include: clientPath, loaders: ['style', 'css', 'sass', 'sourceMap']}, 13 | ], 14 | }, 15 | entry: [ 16 | 'webpack-dev-server/client?http://localhost:4000', 17 | 'webpack/hot/only-dev-server', 18 | './client/index.js', 19 | ], 20 | output: { 21 | path: path.join(__dirname, 'build'), 22 | filename: 'index.js', 23 | publicPath: '/auth-demo/', 24 | }, 25 | plugins: [ 26 | new webpack.HotModuleReplacementPlugin(), 27 | new webpack.NoErrorsPlugin(), 28 | new HtmlWebpackPlugin({ 29 | title: 'Redux Auth Demo', 30 | template: './client/index.template.ejs', 31 | inject: 'body', 32 | }), 33 | ], 34 | resolve: { 35 | extensions: ['', '.js',], 36 | }, 37 | }; 38 | --------------------------------------------------------------------------------