В веб-приложении есть два варианта защиты экрана аутентификации:

  1. Если пользователь не аутентифицирован, перенаправить его по пути /sign-in:

  2. Если пользователь не аутентифицирован, показать ему форму входа по URL страницы, которую он пытался открыть, без перенаправления и отдельного пути:


Первый подход использовался на ранних этапах веба, потому что тогда страницы были неизменяемыми, и казалось логичным иметь отдельный URL просто потому, что в нём была форма и больше никакого другого контента.

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

Проблемы пути /sign-in


1. Страница без семантики


Как правило, страница с URL нужна тогда, когда пользователи могут захотеть сохранить её в закладки, вернуться к ней или с кем-нибудь ею поделиться. Кто захочет делиться URL с /sign-in?

2. Необходимость обратного перенаправления


После аутентификации пользователя его нужно перенаправить на страницу, которая ему была нужна. Это значит, что такой URL необходимо передать странице входа в виде ?back=/profile.

Это добавляет необходимость дополнительного управления и особые случаи ошибочного или отсутствующего URL возврата.

И это просто выглядит некрасиво в адресной строке.

3. Запись в истории браузера


Если пользователь нажал на ссылку /profile, его перенаправило на /sign-in?…, а потом обратно на /profile, то это создаст три записи в истории браузера.

При нажатии «назад» пользователь ожидает, что он попадёт на страницу, где нажимал ссылку на профиль. Но вместо этого он попадает на /sign-in?…, которая снова перенаправляет его на /profile просто потому, что он аутентифицирован.

Пользователи из начала 2000-х уже привыкли дважды нажимать на кнопку «назад», чтобы избежать мешающих перенаправлений на страницу входа, но это не та полезная привычка, которую мы хотим вырабатывать.

4. По-прежнему существует ситуация анонимного просмотра пути


В современных веб-приложениях в памяти находится стек страниц во фронтенде. Например, стек может быть таким:

//profile/profile/edit

Теперь представьте, что у пользователя автоматически произошёл выход из /profile/edit по таймауту.

Вы можете перенаправить его на /sign-in, однако в стеке под ней всё ещё находится /profile. Эта страница неактивна и не перестраивается, однако всё равно имеет какое-то состояние, может содержать отложенный код для исполнения и отобразится, если из стека будет извлечена верхняя страница. А виджеты всё равно должны проектироваться так, чтобы перестраивание могло происходить в любой момент и с произвольной частотой.

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

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

Создание формы входа на странице


Итак, у вас не должно быть отдельного URL для входа. Вместо этого нужно отображать форму входа в любом URL, требующем аутентификации.

Её структура зависит от фреймворка.

В бэкенд-приложениях обычно существуют серверные перенаправления, не вызывающие HTTP-перенаправлений, а просто отправляющие запрос другому обработчику.

В Flutter-приложениях я обычно делаю следующее:

  1. Создаю блок или любой другой объект бизнес-логики, являющийся источником истины для состояния аутентификации в приложении.
  2. Создаю AuthenticatedOrNotWidget с двумя билдерами: один для случая с аутентификацией, другой для случая без аутентификации. Этот билдер слушает блок аутентификации и выполняет перестройку в случае изменения данных аутентификации. Если был выполнен выход пользователя из системы, то на смену приходят билдеры каждого экрана для случая без аутентификации. Если пользователь снова выполняет вход, то снова вызываются билдеры для случая с аутентификацией. В качестве бонуса виджет автоматически выполняет перестройку, если что-то изменилось в профиле пользователя.
  3. Если экраны имеют блоки или уведомители об изменениях, то сделайте так, чтобы они наследовали от какого-то суперкласса, знающего об аутентификации и вызывающего их бизнес-методы только если с аутентификацией всё в порядке. Эта архитектура зависит от конкретного приложения.

Вот виджет одного из моих проектов:

class AuthenticatedOrNotWidget extends StatefulWidget {
  final ValueWidgetBuilder<AuthenticationState> authenticatedBuilder;
  final ValueWidgetBuilder<AuthenticationState> notAuthenticatedBuilder;
  final ValueWidgetBuilder<AuthenticationState>? progressBuilder;
  final ValueWidgetBuilder<AuthenticationState>? failedBuilder;

  AuthenticatedOrNotWidget({
    required this.authenticatedBuilder,
    required this.notAuthenticatedBuilder,
    this.progressBuilder,
    this.failedBuilder,
  });

  @override
  State<AuthenticatedOrNotWidget> createState() =>
      _AuthenticatedOrNotWidgetState();
}

class _AuthenticatedOrNotWidgetState extends State<AuthenticatedOrNotWidget> {
  final _authenticationCubit = GetIt.instance.get<AuthenticationBloc>();

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<AuthenticationState>(
      stream: _authenticationCubit.outState,
      builder: (context, snapshot) => _buildWithState(
          context, snapshot.data ?? _authenticationCubit.initialState),
    );
  }

  Widget _buildWithState(BuildContext context, AuthenticationState state) {
    switch (state.status) {
      case AuthenticationStatus.authenticated:
        return _buildAuthenticated(context, state);

      case AuthenticationStatus.notAuthenticated:
        return _buildNotAuthenticated(context, state);

      case AuthenticationStatus.unknown:
        return _buildProgress(context, state);

      case AuthenticationStatus.failed:
        return _buildFailed(context, state);
    }
  }

  Widget _buildAuthenticated(BuildContext context, AuthenticationState state) {
    return widget.authenticatedBuilder(context, state, null);
  }

  Widget _buildNotAuthenticated(BuildContext context, AuthenticationState state) {
    return widget.notAuthenticatedBuilder(context, state, null);
  }

  Widget _buildProgress(BuildContext context, AuthenticationState state) {
    return (widget.progressBuilder ?? widget.notAuthenticatedBuilder)(
      context,
      state,
      null,
    );
  }

  Widget _buildFailed(BuildContext context, AuthenticationState state) {
    return (widget.failedBuilder ?? widget.notAuthenticatedBuilder)(
      context,
      state,
      null,
    );
  }
}

А вот ещё более удобный виджет поверх первого, с одним только билдером для целевой страницы:

class SignInIfNotWidget extends StatelessWidget {
  final ValueWidgetBuilder<AuthenticationState> signedInBuilder;

  SignInIfNotWidget({
    required this.signedInBuilder,
  });

  @override
  Widget build(BuildContext context) {
    return AuthenticatedOrNotWidget(
      authenticatedBuilder: signedInBuilder,
      notAuthenticatedBuilder: (_, __, ___) => MySignInWidget(),
      progressBuilder: (_, __, ___) => MyCircularProgressIndicator(),
    );
  }
}

Комментарии (7)


  1. andreymal
    03.02.2023 15:03
    +41

    Пожалуйста, обязательно создавайте отдельные пути для sign-in! Это позволяет, например, улучшить работу с менеджерами паролей. Меня раздражает, когда Bitwarden предлагает подставить пароль на всех страницах сайта, просто потому что разработчики поленились сделать отдельную страницу входа.

    А описанные в статье проблемы по-моему вообще не являются проблемами, ничего из перечисленного лично мне не мешает.

    А ещё статья полностью игнорирует штуки вроде Single Sign-On, когда вход может выполняться вообще на другом домене через какой-нибудь OAuth или типа того


    1. Alexandroppolus
      03.02.2023 15:43

      На вкус и цвет, как говорится... Мне, наоборот, нравится "бесшовная" авторизация, как например на Викимапии или pastvu. Проблем с подстановкой пароля там не видел.

      А насчет OAuth - вроде бы это всё можно завернуть в iframe внутри попапчика.


  1. identw
    03.02.2023 15:23
    +19

    Кто захочет делиться URL с /sign-in

    Я всегда сохраняю именно эту страницу в keepassx чтобы зайти и получить форму для логина и залогиниться. Не люблю сайты где для этого используется лишний popup надо его найти, потом нажать.

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


  1. selivanov_pavel
    03.02.2023 16:12
    +17

    Кто захочет делиться URL с /sign-in?

    Я захочу сохранить её в менеджер паролей.


  1. gandjustas
    05.02.2023 14:31

    Обычно на сайтах есть кнопка "войти", на какую страницу она должна вести?


    1. vikarti
      06.02.2023 08:24

      Почему не /sign-in ?


      1. gandjustas
        06.02.2023 08:30

        Автор поста утверждает что не надо создавать /sign-in