
- Введение и выбор стека технологий
- Начальная настройка проекта Phoenix Framework
- Модель User и JWT-аутентификация
- Front-end для регистрации на React и Redux
- Начальное заполнение базы данных и контроллер для входа в приложение
- Аутентификация на front-end на React и Redux
- Настраиваем сокеты и каналы
- Выводим список и создаём новые доски
- Добавляем новых пользователей досок
- Отслеживаем подключённых пользователей досок
- Добавляем списки и карточки
- Выкладываем проект на Heroku
Front-end для регистрации на React и Redux
Предыдущую публикацию мы закончили созданием модели User с проверкой корректности и необходимыми для генерации зашифрованного пароля трансформациями набора изменений (changeset); так же мы обновили файл маршрутизатора и создали контроллер RegistrationController, который обрабатывает запрос на создание нового пользователя и возвращает данные пользователя и его jwt-токен для аутентификации будущих запросов в формате JSON. Теперь двинемся дальше — к front-end.
Подготовка маршрутизатора React
Основная цель — иметь два публичных маршрута, /sign_in и /sign_up, по которым сможет пройти любой посетитель, чтобы, соответственно, войти в приложение или зарегистрировать новый аккаунт.
Помимо этого нам понадобится / как корневой маршрут, чтобы показать все доски, относящиеся к пользователю, и, наконец, маршрут /board/:id для вывода содержимого выбранной пользователем доски. Для доступа к последним двум маршрутам пользователь должен быть аутентифицирован, в противном случае мы перенаправим его на экран регистрации.
Обновим файл routes для react-router, чтобы отразить то, что мы хотим сделать:
// web/static/js/routes/index.js import { IndexRoute, Route } from 'react-router'; import React from 'react'; import MainLayout from '../layouts/main'; import AuthenticatedContainer from '../containers/authenticated'; import HomeIndexView from '../views/home'; import RegistrationsNew from '../views/registrations/new'; import SessionsNew from '../views/sessions/new'; import BoardsShowView from '../views/boards/show'; export default ( <Route component={MainLayout}> <Route path="/sign_up" component={RegistrationsNew} /> <Route path="/sign_in" component={SessionsNew} /> <Route path="/" component={AuthenticatedContainer}> <IndexRoute component={HomeIndexView} /> <Route path="/boards/:id" component={BoardsShowView} /> </Route> </Route> );
Хитрый момент — AuthenticatedContainer, давайте взглянем на него:
// web/static/js/containers/authenticated.js import React from 'react'; import { connect } from 'react-redux'; import { routeActions } from 'redux-simple-router'; class AuthenticatedContainer extends React.Component { componentDidMount() { const { dispatch, currentUser } = this.props; if (localStorage.getItem('phoenixAuthToken')) { dispatch(Actions.currentUser()); } else { dispatch(routeActions.push('/sign_up')); } } render() { // ... } } const mapStateToProps = (state) => ({ currentUser: state.session.currentUser, }); export default connect(mapStateToProps)(AuthenticatedContainer);
Вкратце, что мы тут делаем: проверяем при подключении компонента, присутствует ли jwt-токен в локальном хранилище браузера. Позже мы разберёмся, как этот токен сохранить, но пока давайте представим, что токен не существует; в результате благодаря библиотеке redux-simple-route перенаправим пользователя на страницу регистрации.
Компонент представления (view component) для регистрации
Это то, что мы будем показывать пользователю, если обнаружим, что он не аутентифицирован:
// web/static/js/views/registrations/new.js import React, {PropTypes} from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router'; import { setDocumentTitle, renderErrorsFor } from '../../utils'; import Actions from '../../actions/registrations'; class RegistrationsNew extends React.Component { componentDidMount() { setDocumentTitle('Sign up'); } _handleSubmit(e) { e.preventDefault(); const { dispatch } = this.props; const data = { first_name: this.refs.firstName.value, last_name: this.refs.lastName.value, email: this.refs.email.value, password: this.refs.password.value, password_confirmation: this.refs.passwordConfirmation.value, }; dispatch(Actions.signUp(data)); } render() { const { errors } = this.props; return ( <div className="view-container registrations new"> <main> <header> <div className="logo" /> </header> <form onSubmit={::this._handleSubmit}> <div className="field"> <input ref="firstName" type="text" placeholder="First name" required={true} /> {renderErrorsFor(errors, 'first_name')} </div> <div className="field"> <input ref="lastName" type="text" placeholder="Last name" required={true} /> {renderErrorsFor(errors, 'last_name')} </div> <div className="field"> <input ref="email" type="email" placeholder="Email" required={true} /> {renderErrorsFor(errors, 'email')} </div> <div className="field"> <input ref="password" type="password" placeholder="Password" required={true} /> {renderErrorsFor(errors, 'password')} </div> <div className="field"> <input ref="passwordConfirmation" type="password" placeholder="Confirm password" required={true} /> {renderErrorsFor(errors, 'password_confirmation')} </div> <button type="submit">Sign up</button> </form> <Link to="/sign_in">Sign in</Link> </main> </div> ); } } const mapStateToProps = (state) => ({ errors: state.registration.errors, }); export default connect(mapStateToProps)(RegistrationsNew);
Не особо много можно рассказать об этом компоненте… он изменяет заголовок документа при подключении, выводит форму регистрации и перенаправляет результат конструктора действия (action creator) регистрации singUp.
Конструктор действия (action creator)
Когда предыдущая форма отправлена, нам нужно переслать данные на сервер, где они будут обработаны:
// web/static/js/actions/registrations.js import { pushPath } from 'redux-simple-router'; import Constants from '../constants'; import { httpPost } from '../utils'; const Actions = {}; Actions.signUp = (data) => { return dispatch => { httpPost('/api/v1/registrations', {user: data}) .then((data) => { localStorage.setItem('phoenixAuthToken', data.jwt); dispatch({ type: Constants.CURRENT_USER, currentUser: data.user, }); dispatch(pushPath('/')); }) .catch((error) => { error.response.json() .then((errorJSON) => { dispatch({ type: Constants.REGISTRATIONS_ERROR, errors: errorJSON.errors, }); }); }); }; }; export default Actions;
Когда компонент RegistrationsNew вызывает конструктор действия, передавая ему данные формы, на сервер отправляется новый POST-запрос. Запрос фильтруется маршрутизатором Phoenix и обрабатывается контроллером RegistrationController, который мы создали в предыдущей публикации. В случае успеха полученный с сервера jwt-токен сохраняется в localStorage, данные созданного пользователя передаются действию CURRENT_USER и, наконец, пользователь переадресуется на корневой путь. Наоборот, если присутствуют любые ошибки, связанные с регистрационными данными, будет вызвано действие REGISTRATIONS_ERROR с ошибками в параметрах, так что мы сможем показать их пользователю в форме.
Для работы с http-запросами мы собираемся положиться на пакет isomorphic-fetch, вызываемый из вспомогательного файла, который для этих целей включает несколько методов:
// web/static/js/utils/index.js import React from 'react'; import fetch from 'isomorphic-fetch'; import { polyfill } from 'es6-promise'; export function checkStatus(response) { if (response.status >= 200 && response.status < 300) { return response; } else { var error = new Error(response.statusText); error.response = response; throw error; } } export function parseJSON(response) { return response.json(); } export function httpPost(url, data) { const headers = { Authorization: localStorage.getItem('phoenixAuthToken'), Accept: 'application/json', 'Content-Type': 'application/json', } const body = JSON.stringify(data); return fetch(url, { method: 'post', headers: headers, body: body, }) .then(checkStatus) .then(parseJSON); } // ...
Преобразователи (reducers)
Последний шаг — обработка этих результатов действий с помощью преобразователей, в результате чего мы сможем создать новое дерево состояния, требуемое нашему приложению. Во-первых, взглянем на преобразователь session, в котором будет сохраняться currentUser:
// web/static/js/reducers/session.js import Constants from '../constants'; const initialState = { currentUser: null, }; export default function reducer(state = initialState, action = {}) { switch (action.type) { case Constants.CURRENT_USER: return { ...state, currentUser: action.currentUser }; default: return state; } }
В случае наличия ошибок регистрации любого типа необходимо добавить их к новому состоянию, чтобы мы могли показать их пользователю. Добавим их к преобразователю registration:
// web/static/js/reducers/registration.js import Constants from '../constants'; const initialState = { errors: null, }; export default function reducer(state = initialState, action = {}) { switch (action.type) { case Constants.REGISTRATIONS_ERROR: return {...state, errors: action.errors}; default: return state; } }
Обратите внимание, что для вывода ошибок мы обращаемся к фунцкии renderErrorsFor из этого вспомогательного файла:
// web/static/js/utils/index.js // ... export function renderErrorsFor(errors, ref) { if (!errors) return false; return errors.map((error, i) => { if (error[ref]) { return ( <div key={i} className="error"> {error[ref]} </div> ); } }); }
В целом это всё, что нужно для процесса регистрации. Далее мы увидим, как существующий пользователь может аутентифицироваться в приложении и получить доступ к собственному содержимому.
Начальное заполнение базы данных и контроллер для входа в приложение
Вход пользователя в приложение
Ранее мы подготовили всё для того, чтобы посетители могли регистрироваться и создавать новые пользовательские аккаунты. В этой части мы собираемся реализовать функциональность, необходимую, чтобы позволить посетителям аутентифицироваться в приложение, используя e-mail и пароль. В конце мы создадим механизм для получения пользовательских данных с помощью их токенов аутентификации.
Начальное заполнение базы данных
Если у вас есть опыт работы с Rails, вы увидите, что первоначальное заполнение базы данных в Phoenix выглядит очень похоже. Всё, что нам нужно для этого — наличие файла seeds.exs:
# priv/repo/seeds.exs alias PhoenixTrello.{Repo, User} [ %{ first_name: "John", last_name: "Doe", email: "john@phoenix-trello.com", password: "12345678" }, ] |> Enum.map(&User.changeset(%User{}, &1)) |> Enum.each(&Repo.insert!(&1))
По сути, в этом файле мы просто добавляем в базу данных все данные, которые хотели бы предоставить нашему приложению в качестве начальных. Если вы хотите зарегистрировать любого другого пользователя — просто добавьте его в список и запустите заполнение базы:
$ mix run priv/repo/seeds.exs
Контроллер для входа в приложение
До того, как создать контроллер, необходимо внести некоторые изменения в файл router.ex:
# web/router.ex defmodule PhoenixTrello.Router do use PhoenixTrello.Web, :router #... pipeline :api do # ... plug Guardian.Plug.VerifyHeader plug Guardian.Plug.LoadResource end scope "/api", PhoenixTrello do pipe_through :api scope "/v1" do # ... post "/sessions", SessionController, :create delete "/sessions", SessionController, :delete # ... end end #... end
Первая добавка, которую нужно произвести — добавить в цепочку :api две вставки (plugs, далее будет оригинальный термин использоваться — plug, — поскольку слово "вставка" хоть и отражает букву сути, но не передаёт, как мне кажется, полного смысла; но если я не прав, буду рад нормальному русскому термину. Также имеет смысл для понимания почитать переводной материал о plug и plug pipeline — прим. переводчика):
- VerifyHeader: этот plug просто проверяет наличие токена в заголовке
Authorization(на самом деле, он помимо этого пытается расшифровать его, попутно проверяя на корректность, и создаёт структуру с содержимым токена — прим. переводчика) - LoadResource: если токен присутствует, то делает текущий ресурс (в данном случае — конретную запись из модели
User— прим. переводчика) доступным как результат вызоваGuardian.Plug.current_resource(conn)
Также нужно добавить в область /api/v1 ещё два маршрута для создания и удаления сессии пользователя, оба обрабатываемые контроллером SessionController. Начнём с обработчика :create:
# web/controllers/api/v1/session_controller.ex defmodule PhoenixTrello.SessionController do use PhoenixTrello.Web, :controller plug :scrub_params, "session" when action in [:create] def create(conn, %{"session" => session_params}) do case PhoenixTrello.Session.authenticate(session_params) do {:ok, user} -> {:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token) conn |> put_status(:created) |> render("show.json", jwt: jwt, user: user) :error -> conn |> put_status(:unprocessable_entity) |> render("error.json") end end # ... end
Чтобы аутентифицировать пользователя с полученными параметрами, мы воспользуемся вспомогательным модулем PhoenixTrello.Session. Если всё :ok, то мы зашифруем идентификатор пользователя и впустим его (encode and sign in — несколько вольный, но более понятный перевод — прим. переводчика). Это даст нам jwt-токен, который мы сможем вернуть вместе с записью user в виде JSON. Прежде, чем продолжить, давайте взглянем на вспомогательный модуль Session:
# web/helpers/session.ex defmodule PhoenixTrello.Session do alias PhoenixTrello.{Repo, User} def authenticate(%{"email" => email, "password" => password}) do user = Repo.get_by(User, email: String.downcase(email)) case check_password(user, password) do true -> {:ok, user} _ -> :error end end defp check_password(user, password) do case user do nil -> false _ -> Comeonin.Bcrypt.checkpw(password, user.encrypted_password) end end end
Он пытается найти пользователя по e-mail и проверяет, соответствует ли пришедший пароль зашифрованному паролю пользователя. Если пользователь существует и пароль правильный, возвращается кортеж, содержащий {:ok, user}. В противном случае, если пользователь не найден или пароль неверен, возвращается атом :error.
Возвращаясь к контроллеру SessionController обратите внимание, что он интерпретирует шаблон error.json, если результат аутентификации пользователя — упомянутый ранее атом :error. Наконец, необходимо создать модуль SessionView для отображения обоих результатов:
# web/views/session_view.ex defmodule PhoenixTrello.SessionView do use PhoenixTrello.Web, :view def render("show.json", %{jwt: jwt, user: user}) do %{ jwt: jwt, user: user } end def render("error.json", _) do %{error: "Invalid email or password"} end end
Пользователи, уже авторизовавшиеся в приложении
Другая причина возвращать представление пользователя в JSON при аутентификации в приложении заключается в том, что эти данные могут нам понадобиться для разных целей; к примеру, чтобы показать имя пользователя в шапке приложения. Это соответствует тому, что мы уже сделали. Но что, если пользователь обновит страницу браузера, находясь на первом экране? Всё просто: состояние приложение, управляемое Redux, будет обнулено, а полученная ранее информация исчезнет, что может привести к нежелательным ошибкам. А это не то, чего мы хотим, так что для предотвращения такой ситуации мы можем создать новый контроллер, отвечающий за возврат при необходимости данных аутентифицированного пользователя.
Добавим в файл router.ex новый маршрут:
# web/router.ex defmodule PhoenixTrello.Router do use PhoenixTrello.Web, :router #... scope "/api", PhoenixTrello do pipe_through :api scope "/v1" do # ... get "/current_user", CurrentUserController, :show # ... end end #... end
Теперь нам нужен контроллер CurrentUserController, который выглядит так:
# web/controllers/api/v1/current_user_controller.ex defmodule PhoenixTrello.CurrentUserController do use PhoenixTrello.Web, :controller plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController def show(conn, _) do user = Guardian.Plug.current_resource(conn) conn |> put_status(:ok) |> render("show.json", user: user) end end
Guardian.Plug.EnsureAuthenticated проверяет наличие ранее проверенного токена, и при его отсутствии перенаправляет запрос на функцию :unauthenticated контроллера SessionController. Таким способом мы защитим приватные контроллеры, так что если появится желание определённые маршруты сделать доступными только аутентифицированным пользователям, всё, что понадобится — добавить этот plug в соответствующие контроллеры. Прочая функциональность довольно проста: после подтверждения наличия аутентифицированного токена будет транслирован current_resource, которым в нашем случае являются данные пользователя.
Наконец, нужно в контроллер SessionController добавить обработчик unauthenticated:
# web/controllers/api/v1/session_controller.ex defmodule PhoenixTrello.SessionController do use PhoenixTrello.Web, :controller # ... def unauthenticated(conn, _params) do conn |> put_status(:forbidden) |> render(PhoenixTrello.SessionView, "forbidden.json", error: "Not Authenticated") end end
Он вернёт код 403 — Forbidden вместе с простым текстовым описанием ошибки в JSON. На этом мы закончили с функциональность back-end, относящейся ко входу в приложение и последующей аутентификации. В следующей публикации мы раскроем, как справиться с этим во front-end и как подключиться к UserSocket, сердцу всех вкусняшек режима реального времени. А пока не забудьте взглянуть на живое демо и исходный код конечного результата.