2

I don't think it matters for the purpose of this question what my exact setup is, but I just noticed this in my React and React Native apps, and suddenly realized they are not actually checking any kind of validity of the JWT that is stored.

Here is the code:

const tokenOnLoad = localStorage.getItem('token')

if (tokenOnLoad) store.dispatch({ type: AUTH_USER })

It's probably not really an issue, because the token is attached to the headers and the server will ignore any request without a valid token, but is there a way I can upgrade this to be better (ie: more secure && less chance of loading UI that detonates due to malformed token or if someone hacked in their own 'token') ?

Here is the token getting attached to every request:

networkInterface.use([{
  applyMiddleware(req, next) {
    if (!req.options.headers) req.options.headers = {}
    const token = localStorage.getItem('token')
    req.options.headers.authorization = token || null
    next()
  }
}])

Should I add some logic to at least check the length of the token or decode it and check if it has a user id in it? Or, is that a waste of CPU and time when the server does it?

I'm just looking to see if there are any low-cost ways to further validate the token and harden the app.

I do also use a requireAuth() higher-order component that kicks users out if they are not logged in. I feel like there could be some bad UX if the app somehow did localStorage.setItem('token', 'lkjashkjdf').

agm1984
  • 15,500
  • 6
  • 89
  • 113

1 Answers1

1

Your solution is not optimal as you stated you don't really check the validity of the user's token.

Let me detail how you can handle it:

1. Check token at start time

  1. Wait for the redux-persist to finish loading and injecting in the Provider component
  2. Set the Login component as the parent of all the other components
  3. Check if the token is still valid 3.1. Yes: Display the children 3.2. No: Display the login form

2. When the user is currently using the application

You should use the power of middlewares and check the token validity in every dispatch the user makes.

If the token is expired, dispatch an action to invalidate the token. Otherwise, continue as if nothing happened.

Take a look at the middleware token.js below.


I wrote a whole sample of code for your to use and adapt it if needed.

The solution I propose below is router agnostic. You can use it if you use react-router but also with any other router.

App entry point: app.js

See that the Login component is on top of the routers

import React from 'react';

import { Provider } from 'react-redux';
import { browserHistory } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux';

import createRoutes from './routes'; // Contains the routes
import { initStore, persistReduxStore } from './store';
import { appExample } from './container/reducers';

import Login from './views/login';

const store = initStore(appExample);

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { rehydrated: false };
  }

  componentWillMount() {
    persistReduxStore(store)(() => this.setState({ rehydrated: true }));
  }

  render() {
    const history = syncHistoryWithStore(browserHistory, store);
    return (
      <Provider store={store}>
        <Login>
          {createRoutes(history)}
        </Login>
      </Provider>
    );
  }
}

store.js

The key to remember here is to use redux-persist and keep the login reducer in the local storage (or whatever storage).

import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { persistStore, autoRehydrate } from 'redux-persist';
import localForage from 'localforage';
import { routerReducer } from 'react-router-redux';

import reducers from './container/reducers';
import middlewares from './middlewares';

const reducer = combineReducers({
  ...reducers,
  routing: routerReducer,
});

export const initStore = (state) => {
  const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
  const store = createStore(
    reducer,
    {},
    composeEnhancers(
      applyMiddleware(...middlewares),
      autoRehydrate(),
    ),
  );

  persistStore(store, {
    storage: localForage,
    whitelist: ['login'],
  });

  return store;
};

export const persistReduxStore = store => (callback) => {
  return persistStore(store, {
    storage: localForage,
    whitelist: ['login'],
  }, callback);
};

Middleware: token.js

This is a middleware to add in order to check wether the token is still valid.

If the token is no longer valid, a dispatch is trigger to invalidate it.

import jwtDecode from 'jwt-decode';
import isAfter from 'date-fns/is_after';

import * as actions from '../container/actions';

export default function checkToken({ dispatch, getState }) {
  return next => (action) => {
    const login = getState().login;

    if (!login.isInvalidated) {
      const exp = new Date(jwtDecode(login.token).exp * 1000);
      if (isAfter(new Date(), exp)) {
        setTimeout(() => dispatch(actions.invalidateToken()), 0);
      }
    }

    return next(action);
  };
}

Login Component

The most important thing here is the test of if (!login.isInvalidated).

If the login data is not invalidated, it means that the user is connected and the token is still valid. (Otherwise it would have been invalidated with the middleware token.js)

import React from 'react';
import { connect } from 'react-redux';

import * as actions from '../../container/actions';

const Login = (props) => {
  const {
    dispatch,
    login,
    children,
  } = props;

  if (!login.isInvalidated) {
    return <div>children</div>;
  }

  return (
    <form onSubmit={(event) => {
      dispatch(actions.submitLogin(login.values));
      event.preventDefault();
    }}>
      <input
        value={login.values.email}
        onChange={event => dispatch({ type: 'setLoginValues', values: { email: event.target.value } })}
      />
      <input
        value={login.values.password}
        onChange={event => dispatch({ type: 'setLoginValues', values: { password: event.target.value } })}
      />
      <button>Login</button>
    </form>
  );
};

const mapStateToProps = (reducers) => {
  return {
    login: reducers.login,
  };
};

export default connect(mapStateToProps)(Login);

Login actions

export function submitLogin(values) {
  return (dispatch, getState) => {
    dispatch({ type: 'readLogin' });
    return fetch({}) // !!! Call your API with the login & password !!!
      .then((result) => {
        dispatch(setToken(result));
        setUserToken(result.token);
      })
      .catch(error => dispatch(addLoginError(error)));
  };
}

export function setToken(result) {
  return {
    type: 'setToken',
    ...result,
  };
}

export function addLoginError(error) {
  return {
    type: 'addLoginError',
    error,
  };
}

export function setLoginValues(values) {
  return {
    type: 'setLoginValues',
    values,
  };
}

export function setLoginErrors(errors) {
  return {
    type: 'setLoginErrors',
    errors,
  };
}

export function invalidateToken() {
  return {
    type: 'invalidateToken',
  };
}

Login reducers

import { combineReducers } from 'redux';
import assign from 'lodash/assign';
import jwtDecode from 'jwt-decode';

export default combineReducers({
  isInvalidated,
  isFetching,
  token,
  tokenExpires,
  userId,
  values,
  errors,
});

function isInvalidated(state = true, action) {
  switch (action.type) {
    case 'readLogin':
    case 'invalidateToken':
      return true;
    case 'setToken':
      return false;
    default:
      return state;
  }
}

function isFetching(state = false, action) {
  switch (action.type) {
    case 'readLogin':
      return true;
    case 'setToken':
      return false;
    default:
      return state;
  }
}

export function values(state = {}, action) {
  switch (action.type) {
    case 'resetLoginValues':
    case 'invalidateToken':
      return {};
    case 'setLoginValues':
      return assign({}, state, action.values);
    default:
      return state;
  }
}

export function token(state = null, action) {
  switch (action.type) {
    case 'invalidateToken':
      return null;
    case 'setToken':
      return action.token;
    default:
      return state;
  }
}

export function userId(state = null, action) {
  switch (action.type) {
    case 'invalidateToken':
      return null;
    case 'setToken': {
      const { user_id } = jwtDecode(action.token);
      return user_id;
    }
    default:
      return state;
  }
}

export function tokenExpires(state = null, action) {
  switch (action.type) {
    case 'invalidateToken':
      return null;
    case 'setToken':
      return action.expire;
    default:
      return state;
  }
}

export function errors(state = [], action) {
  switch (action.type) {
    case 'addLoginError':
      return [
        ...state,
        action.error,
      ];
    case 'setToken':
      return state.length > 0 ? [] : state;
    default:
      return state;
  }
}

Feel free to ask me any question or if you need me to explain more on the philosophy.

yuantonito
  • 1,274
  • 8
  • 17
  • This is great. Thanks for posting it. It will take me some time to fully analyze it, but it sounds like I should introduce a state in which the app is initializing, which I do have action creators for already. Much like initializing with `null` and not trusting the state until the value moves to `true` or `false`. – agm1984 Nov 01 '17 at 21:48
  • I definitely need a step to check the expiry on the token. That is where I could probably invalidate it quite efficiently. – agm1984 Nov 01 '17 at 21:49
  • For sake of accuracy, we use Redis to maintain a list of valid tokens with their respective users. The server maintains a whitelist of users rather than a blacklist of banned users. This way we can throttle traffic quite easily. – agm1984 Nov 01 '17 at 21:52
  • Your example code here is architected very strong. I think your root level login component is very well done. – agm1984 Nov 01 '17 at 23:24
  • Did the code work for you @agm1984 ? If so, I'd be happy if you validate / upvote my answer so I can continue giving detailed answer ! – yuantonito Nov 02 '17 at 21:57
  • Yes I took your idea of the auth component and im working with it now. It has produced extremely solid top-level business logic. I haven't finished with the router yet, and I'll harden the JWT after that. I have the hooks in place for `isInvalidated`. – agm1984 Nov 02 '17 at 23:14
  • 1
    I will probably update my question in the form of another answer after I am done because it is fairly complex and it should be shown for others to sample. Essentially, all I need to do is add a couple lines of code to examine the JWT and use the result to update `isInvalidated` in redux, which will be fed into the root auth component. – agm1984 Nov 02 '17 at 23:17
  • Great, I'll be glad to see it then ! – yuantonito Nov 02 '17 at 23:25