Продолжение учебника React + GraphQL путем добавления безопасности с помощью JWT

Предупреждение

Реализация, представленная в этом руководстве, не является готовым к эксплуатации кодом и недостаточно безопасна для немедленной интеграции в ваше приложение. Цель этого поста - расширить информацию, приведенную в документации GraphQL. Вы можете прочитать множество дискуссий (здесь или там) об использовании JWT, его преимуществах, недостатках и различных способах их хранения.

Примечание. Если вы используете аутентификацию с помощью токена в производственной среде, вы должны убедиться, что ваш API доступен только в течение https. А также CORS ДОЛЖЕН быть отключен (здесь включен для упрощения примера)

Вступление

В первой части этой серии статей мы создали одностраничное приложение (SPA) с GraphQL API на бэкэнде (используя node JS) и React JS во Frontend.

Во второй части мы улучшили наше приложение, добавив Redux в качестве библиотеки управления состоянием и Redux-Thunk в качестве промежуточного программного обеспечения. Мы также добавили некоторые функции, такие как StorageService для хранения данных для формы поиска и React-Router-Redux для обработки маршрутизации на стороне клиента способом Redux.

В этом руководстве мы будем использовать все эти функции и внести некоторые изменения на стороне Backend, чтобы защитить наш GraphQL API с помощью аутентификации на основе веб-токенов Json, также известных как JWT.

Вы можете увидеть рабочий туториал в репозитории. Чтобы запустить его, запустите npm install как в клиентской, так и в серверной папках, а затем вы можете использовать npm start для запуска либо сервера, либо клиента, либо обоих.

Защита вашего API на сервере

Прежде всего нам нужно установить пакет npm, полезный для токенов JWT.

npm i -s jsonwebtoken

Мы будем использовать его для создания и проверки веб-токенов JSON на стороне Backend. Вы можете увидеть полный файл package.json здесь.

Затем нам нужно создать нашу логику для работы с JWT. Начнем с импорта библиотеки jsonwebtoken, источника данных для пользователей и дополнительной функции lodash.

import jwt from 'jsonwebtoken'
import Users from './data/users'
import find from 'lodash/find'

Следующие константы работают как параметры конфигурации для JWT:

const expiresIn = '3h' // time to live
const secret = 'samplejwtauthgraphql' // secret key
const tokenPrefix = 'JWT' // Prefix for HTTP header

Теперь мы готовы добавить методы, отвечающие за аутентификацию, на стороне Backend нашего SPA. Первая функция будет отвечать за создание токена. Он проверит, существует ли пользователь с данными учетными данными в источнике данных, и вернет токен или вернет false в противном случае. Функция jwt.sign используется для создания нового токена. В этом руководстве мы будем использовать адрес электронной почты пользователя и его фамилию в нижнем регистре в качестве учетных данных.

/**
 * Use email as login, use password as password
 * @param {string} email 
 * @param {string} password
 */
export const createToken = (email, password) => {
    if (!email || !password) { // no credentials = fail
        return false
    }
    const user = find(Users,
        (user) => {
            return user.email === email.toLowerCase()
                && user.last_name.toLowerCase() === password
        }
    );
    if (!user) { // return false if not found
        return false
    }
    const payload = {
        username: user.email,
    }
    const token = jwt.sign(payload, secret, {
        expiresIn
    })
    return token
}

Следующая функция извлечет токен из заголовка аутентификации (по префиксу JWT) и проверит, действителен ли он. Если он действителен, он вернет токен, принадлежащий пользователю, или иным образом выдаст ошибку. Для этого используется метод jwt.verify.

/**
 * @returns {Object} - current user object
 * @param {string} token header
 */
export const verifyToken = (token) => {
    const [prefix, payload] = token.split(' ')
    let user = null
    if (!payload) { //no token in the header
        throw new Error('No token provided')
    }
    if (prefix !== tokenPrefix) { //unexpected prefix or format
        throw new Error('Invalid header format')
    }
    jwt.verify(payload, secret, (err, data) => {
        if (err) { //token is invalid
            throw new Error('Invalid token!')
        } else {
            user = find(Users, { email: data.username })
        }
    })
    if (!user) { //user does not exist in DB
        throw new Error('User doesn not exist')
    }
    return user
}

Чтобы сделать наш API безопасным, нам нужно создать несколько конечных точек HTTP в файле server.js для обработки входа пользователя и проверки токена.

import express from 'express'
import bodyParser from 'body-parser'
import schema from './schema'
import graphqlHTTP from 'express-graphql'
import { createToken, verifyToken } from './auth'
app.use('/login', jsonParser, (req, res) => {
    if (req.method === 'POST') {
        const token = createToken(req.body.email, req.body.password)
        if (token) { //send successful token
            res.status(200).json({ token })
        } else {
            res.status(403).json({ //no token - invalid credentials
                message: 'Login failed! Invalid credentials!'
            })
        }
    }
});
/**
 * Verify token and return either error or valid user profile
 */
app.use('/verifyToken', jsonParser, (req, res) => {
    if (req.method === 'POST') {
        try {
            const token = req.headers['authorization']
            const user = verifyToken(token)
            res.status(200).json({ user })
        } catch (e) {
            console.log(e.message)
            res.status(401).json({ //unauthorized token
                message: e.message
            })
        }
    }
});

Кроме того, нам нужно добавить некоторое промежуточное программное обеспечение для работы с данными до обработки API GraphQL, чтобы сделать наш API защищенным с помощью аутентификации токена JWT.

// auth middleware
app.use('/graphql', (req, res, next) => {
    const token = req.headers['authorization']
    try {
        req.user = verifyToken(token)
        next()
    } catch (e) {
        res.status(401).json({ //unauthorized token
            message: e.message
        })
    }
});

Это промежуточное программное обеспечение проверяет токен и добавляет объект пользователя для запроса объекта, если токен действителен, и в противном случае возвращает заголовок 401 http. Если токен действителен, он вызывает функцию next (), которая приводит к существующему обработчику GraphQL. Мы можем передать этот пользовательский объект в схему GraphQL с помощью ключа «context».

app.use('/graphql', graphqlHTTP((req, res) => ({
    schema,
    graphiql: true,
    context: {
        user: req.user,
    }
}));

Добавление логики аутентификации и проверки токена на Frontend

Нам нужно внести некоторые изменения на стороне Frontend, чтобы он работал с аутентификацией и токенами. Вы можете найти исходный код в папке client репозитория.

Сначала нам нужно добавить несколько методов в ApiService для вызовов новых конечных точек.

Следующий метод - это общая функция для вызова API-интерфейсов, отличных от GraphQL. Он просто добавляет больше синтаксического сахара для функции извлечения Javascript.

/**
     * Generic API call (for non-graphql endpoints)
     * @param {string} url 
     * @param {object} params 
     */
    async apiCall(url, params = {}, method = 'POST', token = false) {
        const res = await fetch(`${this.baseUrl}${url}/`, {
            method,
            mode: 'cors',
            headers: this.buildHeaders(token),
            body: JSON.stringify(params),
        })
        if (!res.ok) {
            throw new Error(res.status)
        }
        return res.json()
    }

Нам также необходимо определить метод buildHeaders, который предполагает, что у нас может быть заголовок Authentication, содержащий наш токен. Если мы передаем токен в качестве параметра, он создает HTTP-заголовок авторизации, который содержит наш токен с префиксом «JWT».

    /**
     * Build  http headers object
     * @param {string|boolean} token 
     */
    buildHeaders(token = false) {
        let headers = new Headers();
        headers.append('Content-type', 'application/json');
        if (token) {
            headers.append('Authorization', `JWT ${token}`);
        }
        return headers;
    }

Затем нам нужны методы для вызова конечных точек входа в систему и verifyToken.

/**
     * Login user and return jwt token or throw error in
     * case of fail
     * @param {string} login
     * @param {string} password
     */
    async login(params) {
        const res = await this.apiCall('/login', params)
        console.log(res)
        return res.token
    }
/**
     * Verify current token and return current user or throw error
     * @param {string} token 
     */
    async verifyToken(token) {
        const res = await this.apiCall(
           '/verifyToken',
           {},
           'POST',
           token
        )
        return res.user
    }

И, наконец, наш метод для GraphQL API должен иметь возможность использовать наш токен аутентификации в своих HTTP-заголовках. Также обратите внимание, что теперь мы передаем токены всем защищенным вызовам API в качестве параметра.

/**
     * Generic function to fetch data from server via graphql API
     * @param {string} query
     * @returns {unresolved}
     */
    async getGraphQlData(resource, params, fields, token = false) {
        const query = `{${resource} ${this.paramsToString(params)} 
             ${fields}}`
        const res = await fetch(this.apiUrl, {
            method: 'POST',
            mode: 'cors',
            headers: this.buildHeaders(token),
            body: JSON.stringify({ query }),
        });
        if (res.ok) {
            const body = await res.json();
            return body.data;
        } else {
            throw new Error(res.status);
        }
    }
/* .... */
   /**
     * 
     * @param {object} params
     * @returns {array} users list or empty list
     */
    async getTodos(params = {}, token) {
        const data = await this.getGraphQlData(
            'todos', params, this.todoFields, token
        );
        //return todos list
        return data.todos;
    }

Теперь пора перейти к логике, связанной с React-Redux. Нам нужно определить редуктор для обработки частей нашего состояния, связанных с аутентификацией.

// shape of bit or state related to authentication
const initialState = { 
    isAuthenticated: false,
    isFailure: false,
    isLoading: true,
    current_user: null,
}

Этот редуктор обрабатывает все случаи успешной и неудачной аутентификации, а также загружает данные профиля пользователя. Мы используем флаг isLoading, чтобы компоненты пользовательского интерфейса знали, что идет загрузка.

Файл actions будет обрабатывать все эти переходы между состояниями. Самая интересная его часть - это на самом деле промежуточное программное обеспечение, которое обрабатывает вызовы API для получения / проверки токенов и отправляет определенные действия в зависимости от результата. Например, следующая функция входа в систему сделает вызов API с заданными параметрами, затем сохранит токен в хранилище и отправит действие loginSuccess () в случае успеха или отобразит ошибку и отправит loginFailure () в случае неудачной аутентификации. Обратите внимание, что мы также используем метод push () из react-redux-router для перенаправления "способом redux".

export const login = (params) => async dispatch => {
    try {
        const token = await ApiService.login(params)
        StorageService.setToken(token)
        dispatch(loginSuccess())
        dispatch(push('/'))
    } catch (e) {
        console.error(e.message)
        dispatch(loginFailure())
    }
}
export const logout = () => dispatch => { //destroy token and logout
    StorageService.removeToken()
    dispatch(logoutAction())
    dispatch(push('/login'))
}

Следующее действие используется для проверки нашего токена из хранилища при загрузке страницы и получения профиля пользователя, если токен действителен, или перенаправления на страницу входа в систему, если нет.

export const verifyToken = () => async dispatch => {
    if (!StorageService.getToken()) { //if no token - logout
        dispatch(logoutAction())
        return
    }
    try {
        dispatch(requestProfile())
        const user =await ApiService.verifyToken(
            StorageService.getToken()
        )
        dispatch(receiveProfile(user))
        dispatch(loginSuccess())
    } catch (e) {
        //remove token and logout if invalid
        console.error(e.message)        
        StorageService.removeToken()
        dispatch(logoutAction())
    }

Обработка частных и публичных маршрутов на стороне клиента

В нашем приложении много разных компонентов, и нам нужно, чтобы некоторые из них (список пользователей, список Todo) были доступны только авторизованным пользователям, но некоторые другие (форма входа) должны быть доступны неавторизованным пользователям. Проверять это внутри каждого компонента не очень эффективно, поэтому мы создадим специальные контейнеры для обработки этого на уровне маршрута. Первый разрешает только гостевой доступ и перенаправляет на главную страницу, если пользователь уже вошел в систему.

/**
 * Router for only guest stuff like Login/Register
 * If not guest - redirects to home
 */
class GuestRoute extends React.Component {
    render() {
        const {
            isAuthenticated,
            component: Component,
            ...props
        } = this.props        
        return (
            <Route
                {...props}
                render={props =>
                    !isAuthenticated
                        ? <Component {...props} />
                        : (
                            <Redirect to={{
                                pathname: '/',
                                state: { from: props.location }
                            }} />
                        )
                }
            />
        )
    }
}
const mapStateToProps = ({ auth }) => {
    const isAuthenticated = auth.isAuthenticated
    return {
        isAuthenticated,
    }
}
export default connect(mapStateToProps)(GuestRoute)

Второй - PrivateRoute, который разрешает доступ только авторизованным пользователям и перенаправляет на страницу входа в систему, если пользователь не вошел в систему.

/**
 * Private route to navigate over private routes
 * If not logged in - goes to login
 * If not admin but required - throws an error!
 */
class PrivateRoute extends React.Component {
componentDidMount() {
        this.props.dispatch(verifyToken())
    }
logoutHandler() {
        this.props.dispatch(logout())
    }
render() {
        const {
            isAuthenticated,
            component: Component,
            current_user,
            ...props
        } = this.props
        if (this.props.isLoading) {
            return <Loading />
        }
        if (isAuthenticated && !current_user) {
            return null
        }
        return (
            <Route
                {...props}
                render={props =>
                    isAuthenticated
                        ?
                        <main>
                            <Header
                              current_user={current_user}
                              logout={this.logoutHandler.bind(this)}                     
                            />
                            <Component {...props} />
                        </main>
                        : (
                            <Redirect to={{
                                pathname: '/login',
                                state: { from: props.location }
                            }} />
                        )
                }
            />
        )
    }
}
const mapStateToProps = ({ auth }) => {
    const current_user = auth.current_user;
    const isAuthenticated = auth.isAuthenticated;
    return {
        isAuthenticated,
        current_user,
        isLoading: auth.isLoading,
    }
}
export default connect(mapStateToProps)(PrivateRoute)

В компоненте Mount мы проверяем статус аутентификации, отправляя действие verifyToken, а затем либо передаем текущий профиль пользователя в состояние нашего приложения, либо перенаправляем на страницу входа. Флаг isLoading используется для состояния перехода.

Для этого контейнера мы также добавляем дополнительные функции, такие как общий заголовок с приветственным сообщением и ссылку для выхода, которая будет отображаться на всех страницах, когда пользователь входит в систему.

/**
 * Header with greetings and links
 * @param {object} props 
 */
const Header = (props) => {
    return <header className="header">
        <p className="header__greeting">
            <span>
                Welcome
                <a className="header__link" href="">
                    {props.current_user.first_name}
                </a>
            </span>
        </p>
        <p className="header__logout">
            <a className="header__link" href="" onClick={(e) => { e.preventDefault(); props.logout(); }}>
                Logout
            </a>
        </p>
    </header>
}
export default Header

Как вы можете видеть, эти контейнеры маршрутов являются компонентами более высокого порядка, которые обертывают общий компонент Route с реактивным маршрутизатором, поэтому мы можем использовать их в маршрутизации нашего приложения в основном файле App. Здесь есть все общественные и гостевые маршруты.

const App = () => {
    return <Switch>
        <GuestRoute exact path='/login'
            component={LoginContainer}                       
        />
        <PrivateRoute exact path='/' component={UserListContainer}/>
        <PrivateRoute path='/todos/:userId'
            component={TodoListContainer}
        />
    </Switch>
};

Подведение итогов

После изучения всех примеров исходного кода давайте посмотрим, как на самом деле работает наше приложение.

Когда мы запускаем наше приложение и открываем страницу в браузере - мы не вошли в систему, и проверка в PrivateRoute перенаправляет нас на форму входа.

Если мы введем действительные учетные данные из источника данных Пользователи (адрес электронной почты и фамилия в нижнем регистре), мы войдем в систему и перенаправимся на страницу со списком пользователей.

Мы отправляем запрос в конечную точку / login, и, если наши учетные данные действительны, она возвращает нам действительный токен аутентификации JWT. Вы можете увидеть это в своем локальном хранилище с помощью инструментов разработчика Chrome.

Когда вы обновляете страницу, токен все еще там, и вы можете видеть, что метод verifyToken был вызван для получения информации о текущем вошедшем в систему пользователе. Токен проходит проверку через эту конечную точку и логику проверки токена, стоящую за ней, и наш сервер отправляет нам обратно профиль текущего пользователя.

Если срок действия нашего токена истек или он удален, или мы изменим его на что-то недействительное, например:

мы увидим наше сообщение об ошибке, и запрос GraphQL API вернет неавторизованный HTTP-ответ 401, потому что проверка токена не удалась.

Пока мы вошли в систему, наше приложение должно работать как обычно, пока мы не выполним выход, после чего токен будет удален из localStorage, и мы снова будем перенаправлены на страницу входа. Чтобы выйти из системы с помощью JWT, вам просто нужно удалить свой токен со стороны клиента.

Вот как мы можем использовать GraphQL API с аутентификацией JWT в Node JS с React + Redux на стороне Frontend. Надеюсь, это будет полезно. Не стесняйтесь клонировать Репозиторий и запускать / тестировать / изменять его.

Это более или менее все для этого урока. :)