Перейти к основному содержимому

Шестой раунд улучшений приложения Reddit

· 8 мин. чтения

1. Обзор

В этой статье мы почти подведем итоги улучшения приложения Reddit .

2. Безопасность командного API

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

2.1. Конфигурация

Начнем с включения использования @Preauthorize в конфигурации:

@EnableGlobalMethodSecurity(prePostEnabled = true)

2.2. Авторизовать команды

Далее давайте авторизуем наши команды на уровне контроллера с помощью некоторых выражений Spring Security:

@PreAuthorize("@resourceSecurityService.isPostOwner(#postDto.id)")
@RequestMapping(value = "/{id}", method = RequestMethod.PUT)
@ResponseStatus(HttpStatus.OK)
public void updatePost(@RequestBody ScheduledPostUpdateCommandDto postDto) {
...
}

@PreAuthorize("@resourceSecurityService.isPostOwner(#id)")
@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deletePost(@PathVariable("id") Long id) {
...
}
@PreAuthorize("@resourceSecurityService.isRssFeedOwner(#feedDto.id)")
@RequestMapping(value = "/{id}", method = RequestMethod.PUT)
@ResponseStatus(HttpStatus.OK)
public void updateFeed(@RequestBody FeedUpdateCommandDto feedDto) {
..
}

@PreAuthorize("@resourceSecurityService.isRssFeedOwner(#id)")
@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteFeed(@PathVariable("id") Long id) {
...
}

Обратите внимание, что:

  • Мы используем «#» для доступа к аргументу метода — как мы это делали в #id
  • Мы используем «@» для доступа к bean-компоненту — как мы это делали в @resourceSecurityService.

2.3. Служба безопасности ресурсов

Вот как выглядит сервис, отвечающий за проверку права собственности:

@Service
public class ResourceSecurityService {

@Autowired
private PostRepository postRepository;

@Autowired
private MyFeedRepository feedRepository;

public boolean isPostOwner(Long postId) {
UserPrincipal userPrincipal = (UserPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User user = userPrincipal.getUser();
Post post = postRepository.findOne(postId);
return post.getUser().getId() == user.getId();
}

public boolean isRssFeedOwner(Long feedId) {
UserPrincipal userPrincipal = (UserPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User user = userPrincipal.getUser();
MyFeed feed = feedRepository.findOne(feedId);
return feed.getUser().getId() == user.getId();
}
}

Обратите внимание, что:

  • isPostOwner() : проверяет, владеет ли текущий пользователь публикацией с заданным postId.
  • isRssFeedOwner() : проверяет, владеет ли текущий пользователь MyFeed с заданным идентификатором канала.

2.4. Обработка исключений

Далее мы просто обработаем исключение AccessDeniedException следующим образом:

@ExceptionHandler({ AuthenticationCredentialsNotFoundException.class, AccessDeniedException.class })
public ResponseEntity<Object> handleAccessDeniedException(final Exception ex, final WebRequest request) {
logger.error("403 Status Code", ex);
ApiError apiError = new ApiError(HttpStatus.FORBIDDEN, ex);
return new ResponseEntity<Object>(apiError, new HttpHeaders(), HttpStatus.FORBIDDEN);
}

2.5. Проверка авторизации

Наконец, мы проверим авторизацию нашей команды:

public class CommandAuthorizationLiveTest extends ScheduledPostLiveTest {

@Test
public void givenPostOwner_whenUpdatingScheduledPost_thenUpdated() throws ParseException, IOException {
ScheduledPostDto post = newDto();
post.setTitle("new title");
Response response = withRequestBody(givenAuth(), post).put(urlPrefix + "/api/scheduledPosts/" + post.getId());

assertEquals(200, response.statusCode());
}

@Test
public void givenUserOtherThanOwner_whenUpdatingScheduledPost_thenForbidden() throws ParseException, IOException {
ScheduledPostDto post = newDto();
post.setTitle("new title");
Response response = withRequestBody(givenAnotherUserAuth(), post).put(urlPrefix + "/api/scheduledPosts/" + post.getId());

assertEquals(403, response.statusCode());
}

private RequestSpecification givenAnotherUserAuth() {
FormAuthConfig formConfig = new FormAuthConfig(
urlPrefix + "/j_spring_security_check", "username", "password");
return RestAssured.given().auth().form("test", "test", formConfig);
}
}

Обратите внимание, как реализация GivenAuth() использует пользователя «john», в то время как данная реализация « AnotherUserAuth() » использует пользователя «test», чтобы затем мы могли протестировать эти сложные сценарии с участием двух разных пользователей.

3. Дополнительные параметры повторной отправки

Далее мы добавим интересную опцию — повторная отправка статьи на Reddit через день или два , а не сразу.

Мы начнем с изменения запланированных параметров повторной отправки сообщений и разделим timeInterval . Раньше это имело две отдельные обязанности; это было:

  • время между отправкой сообщения и временем проверки результатов и
  • время между проверкой результатов и временем следующей отправки

Мы не будем разделять эти две обязанности: checkAfterInterval и submitAfterInterval .

3.1. Почтовая сущность

Мы изменим объекты Post и Preference, удалив:

private int timeInterval;

И добавление:

private int checkAfterInterval;

private int submitAfterInterval;

Обратите внимание, что мы сделаем то же самое для связанных DTO.

3.2. Планировщик

Далее мы изменим наш планировщик для использования новых временных интервалов — следующим образом:

private void checkAndReSubmitInternal(Post post) {
if (didIntervalPass(post.getSubmissionDate(), post.getCheckAfterInterval())) {
PostScores postScores = getPostScores(post);
...
}

private void checkAndDeleteInternal(Post post) {
if (didIntervalPass(post.getSubmissionDate(), post.getCheckAfterInterval())) {
PostScores postScores = getPostScores(post);
...
}

private void resetPost(Post post, String failReason) {
long time = new Date().getTime();
time += TimeUnit.MILLISECONDS.convert(post.getSubmitAfterInterval(), TimeUnit.MINUTES);
post.setSubmissionDate(new Date(time))
...
}

Обратите внимание, что для запланированной публикации с submitDate T и checkAfterInterval t1 и submitAfterInterval t2 и количеством попыток > 1 мы будем иметь:

  1. Сообщение отправлено в первый раз на T
  2. Планировщик проверяет почтовую оценку в T+t1
  3. Предполагая, что сообщение не достигло цели, сообщение отправляется во второй раз в T+t1+t2.

4. Дополнительные проверки токена доступа OAuth2

Далее мы добавим несколько дополнительных проверок при работе с токеном доступа.

Иногда токен доступа пользователя может быть поврежден, что приводит к непредвиденному поведению в приложении. Мы собираемся исправить это, позволив пользователю повторно подключить свою учетную запись к Reddit — таким образом, получив новый токен доступа — если это произойдет.

4.1. Реддит Контроллер

Вот простая проверка уровня контроллера — isAccessTokenValid() :

@RequestMapping(value = "/isAccessTokenValid")
@ResponseBody
public boolean isAccessTokenValid() {
return redditService.isCurrentUserAccessTokenValid();
}

4.2. Реддит Сервис

А вот реализация уровня обслуживания:

@Override
public boolean isCurrentUserAccessTokenValid() {
UserPrincipal userPrincipal = (UserPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User currentUser = userPrincipal.getUser();
if (currentUser.getAccessToken() == null) {
return false;
}
try {
redditTemplate.needsCaptcha();
} catch (Exception e) {
redditTemplate.setAccessToken(null);
currentUser.setAccessToken(null);
currentUser.setRefreshToken(null);
currentUser.setTokenExpiration(null);
userRepository.save(currentUser);
return false;
}
return true;
}

То, что здесь происходит, довольно просто. Если у пользователя уже есть токен доступа, мы попытаемся получить доступ к Reddit API, используя простой вызов needCaptcha .

Если вызов не удался, то текущий токен недействителен, поэтому мы его сбросим. И, конечно же, это приводит к тому, что пользователю предлагается повторно подключить свою учетную запись к Reddit.

4.3. Внешний интерфейс

Наконец, мы покажем это на главной странице:

<div id="connect" style="display:none">
<a href="redditLogin">Connect your Account to Reddit</a>
</div>

<script>
$.get("api/isAccessTokenValid", function(data){
if(!data){
$("#connect").show();
}
});
</script>

Обратите внимание, что если токен доступа недействителен, пользователю будет показана ссылка «Подключиться к Reddit».

5. Разделение на несколько модулей

Далее мы разбиваем приложение на модули. Мы возьмем 4 модуля: reddit-common , reddit-rest , reddit-ui и reddit-web .

5.1. Родитель

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

Родительский модуль reddit-scheduler содержит подмодули и простой pom.xml — вот так:

<project>
<modelVersion>4.0.0</modelVersion>
<groupId>org.foreach</groupId>
<artifactId>reddit-scheduler</artifactId>
<version>0.2.0-SNAPSHOT</version>
<name>reddit-scheduler</name>
<packaging>pom</packaging>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.7.RELEASE</version>
</parent>

<modules>
<module>reddit-common</module>
<module>reddit-rest</module>
<module>reddit-ui</module>
<module>reddit-web</module>
</modules>

<properties>
<!-- dependency versions and properties -->
</properties>

</project>

Все свойства и версии зависимостей будут объявлены здесь, в родительском файле pom.xml , для использования всеми подмодулями.

5.2. Общий модуль

Теперь давайте поговорим о нашем модуле reddit-common . Этот модуль будет содержать постоянство, сервис и ресурсы, связанные с Reddit. Он также содержит тесты на устойчивость и интеграцию.

Классы конфигурации, включенные в этот модуль: CommonConfig , PersistenceJpaConfig, RedditConfig , ServiceConfig , WebGeneralConfig .

Вот простой pom.xml :

<project>
<modelVersion>4.0.0</modelVersion>
<artifactId>reddit-common</artifactId>
<name>reddit-common</name>
<packaging>jar</packaging>

<parent>
<groupId>org.foreach</groupId>
<artifactId>reddit-scheduler</artifactId>
<version>0.2.0-SNAPSHOT</version>
</parent>

</project>

5.3. REST-модуль

Наш модуль reddit-rest содержит контроллеры REST и DTO.

Единственный класс конфигурации в этом модуле — WebApiConfig .

Вот pom.xml :

<project>
<modelVersion>4.0.0</modelVersion>
<artifactId>reddit-rest</artifactId>
<name>reddit-rest</name>
<packaging>jar</packaging>

<parent>
<groupId>org.foreach</groupId>
<artifactId>reddit-scheduler</artifactId>
<version>0.2.0-SNAPSHOT</version>
</parent>

<dependencies>
<dependency>
<groupId>org.foreach</groupId>
<artifactId>reddit-common</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>
</dependencies>
...

Этот модуль также содержит всю логику обработки исключений.

5.4. Модуль пользовательского интерфейса

Модуль reddit-ui содержит внешний интерфейс и контроллеры MVC.

Включены классы конфигурации WebFrontendConfig и ThymeleafConfig .

Нам нужно изменить конфигурацию Thymeleaf, чтобы загружать шаблоны из пути к классам ресурсов, а не из контекста сервера:

@Bean
public TemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/");
templateResolver.setSuffix(".html");
templateResolver.setCacheable(false);
return templateResolver;
}

Вот простой pom.xml :

<project>
<modelVersion>4.0.0</modelVersion>
<artifactId>reddit-ui</artifactId>
<name>reddit-ui</name>
<packaging>jar</packaging>

<parent>
<groupId>org.foreach</groupId>
<artifactId>reddit-scheduler</artifactId>
<version>0.2.0-SNAPSHOT</version>
</parent>

<dependencies>
<dependency>
<groupId>org.foreach</groupId>
<artifactId>reddit-common</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>
</dependencies>
...

Теперь у нас также есть более простой обработчик исключений для обработки внешних исключений:

@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler implements Serializable {

private static final long serialVersionUID = -3365045939814599316L;

@ExceptionHandler({ UserApprovalRequiredException.class, UserRedirectRequiredException.class })
public String handleRedirect(RuntimeException ex, WebRequest request) {
logger.info(ex.getLocalizedMessage());
throw ex;
}

@ExceptionHandler({ Exception.class })
public String handleInternal(RuntimeException ex, WebRequest request) {
logger.error(ex);
String response = "Error Occurred: " + ex.getMessage();
return "redirect:/submissionResponse?msg=" + response;
}
}

5.5. Веб-модуль

Наконец, вот наш модуль reddit-web.

Этот модуль содержит ресурсы, конфигурацию безопасности и конфигурацию SpringBootApplication — а именно:

@SpringBootApplication
public class Application extends SpringBootServletInitializer {
@Bean
public ServletRegistrationBean frontendServlet() {
AnnotationConfigWebApplicationContext dispatcherContext =
new AnnotationConfigWebApplicationContext();
dispatcherContext.register(WebFrontendConfig.class, ThymeleafConfig.class);
ServletRegistrationBean registration = new ServletRegistrationBean(
new DispatcherServlet(dispatcherContext), "/*");
registration.setName("FrontendServlet");
registration.setLoadOnStartup(1);
return registration;
}

@Bean
public ServletRegistrationBean apiServlet() {
AnnotationConfigWebApplicationContext dispatcherContext =
new AnnotationConfigWebApplicationContext();
dispatcherContext.register(WebApiConfig.class);
ServletRegistrationBean registration = new ServletRegistrationBean(
new DispatcherServlet(dispatcherContext), "/api/*");
registration.setName("ApiServlet");
registration.setLoadOnStartup(2);
return registration;
}

@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
application.sources(Application.class, CommonConfig.class,
PersistenceJpaConfig.class, RedditConfig.class,
ServiceConfig.class, WebGeneralConfig.class);
return application;
}

@Override
public void onStartup(ServletContext servletContext) throws ServletException {
super.onStartup(servletContext);
servletContext.addListener(new SessionListener());
servletContext.addListener(new RequestContextListener());
servletContext.addListener(new HttpSessionEventPublisher());
}

public static void main(String... args) {
SpringApplication.run(Application.class, args);
}
}

Вот pom.xml :

<project>
<modelVersion>4.0.0</modelVersion>
<artifactId>reddit-web</artifactId>
<name>reddit-web</name>
<packaging>war</packaging>

<parent>
<groupId>org.foreach</groupId>
<artifactId>reddit-scheduler</artifactId>
<version>0.2.0-SNAPSHOT</version>
</parent>

<dependencies>
<dependency>
<groupId>org.foreach</groupId>
<artifactId>reddit-common</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>

<dependency>
<groupId>org.foreach</groupId>
<artifactId>reddit-rest</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>

<dependency>
<groupId>org.foreach</groupId>
<artifactId>reddit-ui</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>
...

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

6. Заключение

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