- Введение и выбор стека технологий
- Начальная настройка проекта 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
, сердцу всех вкусняшек режима реального времени. А пока не забудьте взглянуть на живое демо и исходный код конечного результата.
Автор: heathen