Статьи

Интеграция Календаря Google в приложение Wicket

Недавно я интегрировал Календарь Google в приложение Wicket, которое я использую дома. Поскольку некоторые требования нетипичны, я хочу описать их, прежде чем вдаваться в подробности:

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

Технические параметры

Если вы хотите использовать Google API в своем приложении, есть вероятность, что они уже предоставляют клиентскую библиотеку, что на самом деле имеет место для Календаря Google. Я решил пойти другим путем по двум причинам:

  1. Поскольку это еще один REST API, нет необходимости в специальной библиотеке и новом API. Если я уже использую Spring, я могу достичь того же результата, просто используя Spring RestTemplate .
  2. Я просто хотел создавать, обновлять и удалять события на весь день с заголовком. Нет необходимости в полномасштабном API, который поддерживает все параметры, такие как поддержка повторяющихся событий, несколько участников или напоминания.

Прежде чем получить доступ к API Google, приложению требуется авторизация пользователей для доступа к своим данным, что обрабатывается OAuth 2. Поскольку приложение уже использует Spring для различных аспектов, Spring Security OAuth 2 был для меня очевидным выбором, поскольку он прекрасно интегрируется с остальными. приложения.

Однако я столкнулся с двумя проблемами: во-первых, мне нужно было интегрировать Wicket и Spring Security OAuth 2, а во-вторых, Spring Security OAuth 2 потребовалось несколько настроек для совместной работы с моей установкой.

Помимо Spring Security OAuth 2 я использую следующие библиотеки / фреймворки:

  • Калитка как веб-фреймворк
  • Spring как контейнер для инъекций зависимостей
  • Джексона для хранения токенов и сериализации и десериализации сообщений в API Календаря Google
  • Gradle как инструмент для сборки
  • Причал как контейнер

Я создал простое демонстрационное приложение, которое позволяет вам создавать события на весь день в любом из ваших календарей. Это доступно на Github .

OAuth 2 менее чем за 100 слов

В Интернете есть много информации об OAuth 2, поэтому я просто хочу кратко описать, что делает OAuth 2:

Используя OAuth 2, пользователи могут предоставить вашему приложению доступ к своим данным, доступным через сторонние службы, без предоставления учетных данных учетных записей, к которым вы хотите получить доступ. В нем участвуют три стороны: ваши пользователи, ваше приложение (клиент OAuth 2) и поставщик авторизации (сервер OAuth 2). Помимо того, что они являются пользователями вашего приложения, они также известны поставщику авторизации. Когда пользователи получают доступ к вашему приложению (шаг 1), оно запрашивает разрешение, например, на доступ к своим данным из сторонней службы (шаг 2). Поставщик авторизации перенаправляет на страницу, где пользователи могут предоставить доступ к вашему приложению (шаг 3).

Сценарий авторизации OAuth2

Сценарий авторизации OAuth2 (Иконки предоставлены Адамом Уиткрофтом )

Два отличных ресурса для углубленной информации об OAuth 2 — это документация Google по OAuth 2 и RFC 6749 .

OAuth 2 для веб-приложений

Документация Google OAuth 2 описывает различные сценарии, которые можно использовать. Я предполагаю, что вы хотите использовать сценарий веб- сервера и, в частности, вы хотите использовать «онлайн-доступ» (подробнее об этом позже). В сценарии с веб-сервером пользователь перенаправляется на сервер авторизации Google перед первым вызовом приложения в API Календаря Google. После того как пользователь предоставил доступ, сервер авторизации перенаправляет пользователя в ваше приложение вместе с токеном доступа, и приложение может получить доступ к API Календаря Google с этим токеном доступа.

Начиная

Прежде чем мы углубимся в код, мы должны зарегистрировать приложение у поставщика OAuth. Для Google это делается через консоль API . Процесс довольно прост:

1. Создайте новый проект и укажите описательное имя:

Создать новый проект Google API

2. Создайте новый идентификатор клиента. Идентификатор клиента идентифицирует ваше приложение против поставщика OAuth. Не забудьте указать пользовательский URL перенаправления на втором экране.

Создайте новый идентификатор клиента для проекта Google API (часть 1)

Создайте новый идентификатор клиента для проекта API Google (часть 2)

3. После того, как вы нажали «Создать», появится следующий обзор:

Google API проект создан

Наиболее важные элементы для вашего приложения — это идентификатор клиента и секрет клиента. Как я уже описал, идентификатор клиента однозначно идентифицирует ваше приложение с поставщиком OAuth 2. Секрет клиента аутентифицирует ваше приложение у провайдера OAuth 2. Если вы хотите подумать о традиционной аутентификации на основе имени пользователя / пароля, идентификатор клиента слабо соответствует имени пользователя вашего приложения, а секрет клиента — паролю вашего приложения с поставщиком OAuth 2. По-видимому, эти два свойства не должны быть общими.

Скопируйте значения «идентификатор клиента» в свойство «google.calendar.client.id» и «секрет клиента» в свойство «google.calendar.client.secret» в файле application.properties, если вы будете следовать вместе с демонстрационной версией. применение.

4. Затем запросите доступ к API Календаря Google для вашего приложения в меню «Сервисы».

Активировать доступ к Google Calendar API

Доступ к Календарю Google с использованием Spring OAuth 2

Все доступы демонстрационного приложения к API Календаря Google заключены в классе GoogleCalendarRepositoryImpl . Он использует расширение стандартного интерфейса Spring RestOperations, называемое OAuth2RestOperations, которое может дополнительно обрабатывать авторизацию OAuth 2. Аналогично стандартной реализации RestOperations, RestTemplate, Spring Security OAuth 2 предоставляет OAuth2RestTemplate. В демонстрационном приложении OAuth2RestTemplate настраивается в com / github / gcaldemo / calendar / spring-context.xml . Во-первых, давайте посмотрим на элемент «oauth: resource» там:

<oauth:resource id="google"
                type="authorization_code"
                client-id="${google.calendar.client.id}"
                client-secret="${google.calendar.client.secret}"
                access-token-uri="https://accounts.google.com/o/oauth2/token"
                user-authorization-uri="https://accounts.google.com/o/oauth2/auth"
                scope="https://www.googleapis.com/auth/calendar"
                client-authentication-scheme="form"
/>

Это определение описывает ресурсы, к которым мы хотим получить доступ. Помимо идентификатора клиента и секрета клиента, который мы уже обсуждали, он также содержит URL-адрес, по которому пользователи будут перенаправлены, когда приложение получит разрешение на доступ к своим данным (user-authorization-uri). Атрибут «scope» определяет привилегии, которые приложение хочет получить. В этом случае нам нужен доступ для чтения и записи к календарным данным, указанным в URL. Правильный URL можно найти в документации по API Календаря Google .

Всякий раз, когда приложение пытается вызвать API Календаря Google, Spring OAuth 2 проверит, имеет ли приложение действительный токен доступа. Этот токен доступа выдается поставщиком OAuth 2 и предоставляется приложению после того, как пользователь предоставил доступ к его данным. Однако токен доступа действителен только в течение ограниченного периода времени, который различается у разных поставщиков OAuth. Токены доступа Google в настоящее время действительны в течение одного часа. По истечении этого периода времени токен доступа становится недействительным, и пользователи должны снова предоставить приложению доступ к своим данным.

К счастью, существует множество способов предотвратить постоянное раздражение пользователей:

  • RFC OAuth 2 определяет так называемый токен обновления. Токен обновления отправляется поставщиком OAuth 2 вместе с первым токеном доступа. Его можно использовать для получения нового токена доступа по истечении срока действия старого без вмешательства пользователя. Срок действия маркера обновления может быть ограничен и зависит от поставщика OAuth. Токен обновления Google действителен до тех пор, пока приложение явно не предложит пользователю явно авторизоваться. Обратите внимание, что Google отправляет токен обновления только в «автономном» сценарии. Автономный означает, в основном, что приложение может действовать от имени пользователей без присутствия пользователя (например, пакетные процессы). Дополнительную информацию о токене обновления см. В разделе о токенах обновления в документации Google OAuth 2 .
  • В онлайн-сценарии (пользователь присутствует, когда ваше приложение обращается к API Календаря Google), Google не отправляет токен обновления, а сохраняет cookie в браузере клиента. Этот файл cookie будет использоваться вместо токена обновления для предотвращения повторного явного одобрения со стороны пользователя. Как я уже упоминал ранее, этот сценарий применим к демонстрационному приложению.

Демонстрационное приложение предоставляет простое хранилище токенов на основе JSON, которое достаточно для одного пользователя. Он реализован в JsonClientTokenServices , который выполняет следующие задачи:

  • Сохраняет токен доступа в файле JSON.
  • Он корректирует значение expiry_in: срок действия, отправленный поставщиком OAuth 2, обозначается в секундах с момента, когда поставщик OAuth 2 выдал токен доступа. Таким образом, если токен доступа действителен в течение одного часа, начальное значение будет 3600 (60 секунд в минуту * 60 минут в час). Однако это явно не подходит для постоянного хранения. Поэтому хранилище токенов будет корректировать срок действия при загрузке токена доступа.

Хранилище токенов настраивается вместе с поставщиком токенов доступа:

<bean id="accessTokenProviderChain" class="org.springframework.security.oauth2.client.token.AccessTokenProviderChain">
<!-- Redefinition of the default access token providers  -->
  <constructor-arg index="0">
    <list>
      <bean class="org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeAccessTokenProvider"/>
      <bean class="org.springframework.security.oauth2.client.token.grant.implicit.ImplicitAccessTokenProvider"/>
      <bean class="org.springframework.security.oauth2.client.token.grant.password.ResourceOwnerPasswordAccessTokenProvider"/>
      <bean class="org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsAccessTokenProvider"/>
    </list>
  </constructor-arg>
  <property name="clientTokenServices">
    <bean class="com.github.gcaldemo.calendar.repository.impl.token.JsonClientTokenServices"/>
  </property>
</bean>

После определения как ресурса для доступа, так и поставщика маркеров доступа, OAuth2RestTemplate можно настроить:

<oauth:rest-template id="googleCalendarRestTemplate"
                     resource="google"
                     access-token-provider="accessTokenProviderChain"/>

Интеграция Spring OAuth 2 в Wicket

Если приложение имеет действительный токен доступа, OAuth2RestTemplate выполняет вызов API, в противном случае выдается исключение UserRedirectRequiredException. Как правило, OAuth2ClientContextFilter, являющийся частью цепочки безопасности Spring, должен перехватить это исключение и перенаправить пользователя на uri-authorization-uri, указанный ранее. Однако по умолчанию Wicket отлавливает все исключения, возникающие в веб-приложении, и просто выводит их в режиме разработки или предоставляет страницу с ошибкой в ​​рабочем режиме. Следовательно, исключение UserRedirectRequiredException никогда не достигнет цепочки фильтров безопасности Spring. Поэтому нам нужно настроить обработку исключений, внедрив собственный IExceptionMapper:

public class OAuth2ExceptionMapper implements IExceptionMapper {
  private final IExceptionMapper delegateExceptionMapper;
 
  public OAuth2ExceptionMapper(IExceptionMapper delegateExceptionMapper) {
    this.delegateExceptionMapper = delegateExceptionMapper;
  }
 
  @Override
  public IRequestHandler map(Exception e) {
    Throwable rootCause = getRootCause(e);
    if (rootCause instanceof UserRedirectRequiredException) {
      //see DefaultExceptionMapper
      Response response = RequestCycle.get().getResponse();
      if (response instanceof WebResponse) {
        // we don't want to cache an exceptional reply in the browser
        ((WebResponse)response).disableCaching();
      }
      throw ((UserRedirectRequiredException) rootCause);
    } else {
      return delegateExceptionMapper.map(e);
    }
  }
 
  private Throwable getRootCause(Throwable ex) {
    if (ex == null) {
      return null;
    }
    if (ex.getCause() == null) {
      return ex;
    }
    return getRootCause(ex.getCause());
  }
}

Пользовательский сопоставитель исключений должен быть создан в приложении поставщиком сопоставления исключений:

public class CalendarDemoApplication extends WebApplication {
  private IProvider<IExceptionMapper> exceptionMapperProvider;
 
  @Override
  protected void init() {
    super.init();
    this.exceptionMapperProvider = new OAuth2ExceptionMapperProvider();
    //details left out - see original class on Github
  }
 
  @Override
  public IProvider<IExceptionMapper> getExceptionMapperProvider() {
    return exceptionMapperProvider;
  }
 
  /**
   * Custom Exception Mapper provider that integrates the OAuth2ExceptionMapper into the application.
   */
  private static class OAuth2ExceptionMapperProvider implements IProvider<IExceptionMapper> {
    @Override
    public IExceptionMapper get() {
      return new OAuth2ExceptionMapper(new DefaultExceptionMapper());
    }
  }
}

Теперь UserRedirectRequiredException не будет обрабатываться Wicket, а будет распространяться дальше вверх по стеку вызовов, что позволяет OAuth2ClientContextFilter правильно обрабатывать исключение. Мы почти закончили, но один последний кусок все еще отсутствует.

Системная внутренняя аутентификация

Как я уже писал во введении, я не хочу проходить аутентификацию на своем собственном приложении, поскольку я единственный пользователь. Однако Spring Security OAuth 2 ожидает учетные данные пользователя в приложении до аутентификации приложения на сервере OAuth 2. Если мы попытаемся выполнить вызов API Календаря Google как анонимный пользователь, мы получим следующую трассировку:

org.springframework.security.authentication.InsufficientAuthenticationException: Authentication is required to obtain an access token (anonymous not allowed)
at org.springframework.security.oauth2.client.token.AccessTokenProviderChain.obtainAccessToken(AccessTokenProviderChain.java:88)
at org.springframework.security.oauth2.client.OAuth2RestTemplate.acquireAccessToken(OAuth2RestTemplate.java:217)
at org.springframework.security.oauth2.client.OAuth2RestTemplate.getAccessToken(OAuth2RestTemplate.java:169)
at org.springframework.security.oauth2.client.OAuth2RestTemplate.createRequest(OAuth2RestTemplate.java:90)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:479)
at org.springframework.security.oauth2.client.OAuth2RestTemplate.doExecute(OAuth2RestTemplate.java:124)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:446)
at org.springframework.web.client.RestTemplate.getForObject(RestTemplate.java:214)
at com.github.gcaldemo.calendar.repository.impl.GoogleCalendarRepositoryImpl.loadCalendars(GoogleCalendarRepositoryImpl.java:45)
[...]

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

<security:intercept-url pattern="/**" access="ROLE_USER" />

Далее нам нужно обмануть Spring Security OAuth 2, внедрив специальный фильтр обработки аутентификации, который публикует системного пользователя с соответствующими правами в SecurityContext:

//some details omitted - see original class on Github
public class SystemAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
  @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
    // Populate an internal system user in the security context with proper access privileges. These is typically not
    // necessary for multi user systems in production as users typically have to authenticate against your
    // application before using it.
    Authentication authentication = new TestingAuthenticationToken("internal_system_user", "internal_null_credentials", "ROLE_USER");
    authentication.setAuthenticated(true);
    return getAuthenticationManager().authenticate(authentication);
  }
 
  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
    throws IOException, ServletException {
 
    if (SecurityContextHolder.getContext().getAuthentication() == null) {
      SecurityContextHolder.getContext().setAuthentication(attemptAuthentication((HttpServletRequest) req, (HttpServletResponse) res));
    }
    chain.doFilter(req, res);
  }
}

Теперь Spring Security OAuth 2 может правильно выполнять запросы на авторизацию, и мы перенаправлены перед первым доступом к API Календаря Google:

Демо-приложение запрашивает разрешение на доступ от Google

После того, как пользователь предоставил приложению доступ к его данным, мы можем создать событие. Обратите внимание, что в раскрывающемся списке календаря уже есть все календари Google, к которым у пользователя есть как минимум права на запись

Создание нового события календаря Google

Новое событие календаря Google было создано

Резюме

Хотя клиентская библиотека для API Календаря Google доступна, иногда целесообразно использовать библиотеки и технологии, которые уже используются в проекте. С помощью нескольких настроек я смог использовать API Календаря Google в приложении Wicket с использованием Spring Security OAuth 2. Пример приложения на Github демонстрирует интеграцию, но остерегайтесь ограничений, которые были упомянуты выше: эта настройка в первую очередь подходит для однопользовательского режима. Приложения. Если вы хотите повторно использовать пример кода в производственной среде, вы должны использовать реализацию ClientTokenServices, поддерживаемую базой данных, и использовать реальную реализацию AbstractAuthenticationProcessingFilter, такую ​​как UsernamePasswordAuthenticationFilter .

Я надеюсь, что в этом посте достаточно подробно описано, как интегрировать API Календаря Google в приложение Wicket. В противном случае, не стесняйтесь задавать вопросы в разделе комментариев.