Шестой раунд улучшений в приложении Reddit
1. обзор
В этой статье мы почти завершим улучшения вReddit application.
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(): проверьте, владеет ли текущий пользовательPost с заданнымpostId
-
isRssFeedOwner(): проверьте, владеет ли текущий пользовательMyFeed с заданнымfeedId
2.4. Обработка исключений
Далее мы просто обработаемAccessDeniedException следующим образом:
@ExceptionHandler({ AuthenticationCredentialsNotFoundException.class, AccessDeniedException.class })
public ResponseEntity
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», в то время какgivenAnotherUserAuth() использует пользовательский «test», чтобы затем мы могли протестировать эти сложные сценарии с участием двух разных пользователей.
3. Дополнительные параметры повторной отправки
Далее мы добавим интересную опцию -resubmitting an article to Reddit after a day or two вместо правильного awa.
Мы начнем с изменения параметров запланированной повторной отправки публикации и разделимtimeInterval. Раньше у этого были две отдельные обязанности; это было:
-
время между отправкой и проверкой счета и
-
время между проверкой счета и временем следующего представления
Мы не будем разделять эти две обязанности:checkAfterInterval иsubmitAfterInterval.
3.1. Сообщение Entity
Мы изменим сущности 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))
...
}
Обратите внимание, что для запланированной публикации сsubmissionDateT иcheckAfterIntervalt1 иsubmitAfterIntervalt2 и количеством попыток> 1 мы имеют:
-
Сообщение отправлено впервые вT
-
Планировщик проверяет оценку публикации наT+t1
-
Предполагая, что сообщение не достигло цели, сообщение будет отправлено во второй раз вT+t1+t2
4. Дополнительные проверки токена доступа OAuth2
Затем мы добавим несколько дополнительных проверок работы с токеном доступа.
Иногда маркер доступа пользователя может быть поврежден, что приводит к неожиданному поведению в приложении. Мы собираемся исправить это, позволив пользователю повторно подключить свою учетную запись к Reddit, получив таким образом новый токен доступа, если это произойдет.
4.1. Reddit Controller
Вот простая проверка уровня контроллера -isAccessTokenValid():
@RequestMapping(value = "/isAccessTokenValid")
@ResponseBody
public boolean isAccessTokenValid() {
return redditService.isCurrentUserAccessTokenValid();
}
4.2. Сервис Reddit
А вот реализация уровня обслуживания:
@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 с помощью простого вызоваneedsCaptcha.
Если вызов завершился неудачно, значит текущий токен недействителен, поэтому мы его сбросим. И, конечно, это приводит к тому, что пользователю предлагается заново подключить свою учетную запись к Reddit.
4.3. Внешний интерфейс
Наконец, мы покажем это на главной странице:
Обратите внимание, что, если токен доступа недействителен, пользователю будет показана ссылка «Подключиться к Reddit».
5. Разделение на несколько модулей
Далее мы разбиваем приложение на модули. Мы будем использовать 4 модуля:reddit-common,reddit-rest,reddit-ui иreddit-web.
5.1. родитель
Во-первых, давайте начнем с нашего родительского модуля, в который заключены все подмодули.
Родительский модульreddit-scheduler содержит подмодули и простойpom.xml - следующим образом:
4.0.0
org.example
reddit-scheduler
0.2.0-SNAPSHOT
reddit-scheduler
pom
org.springframework.boot
spring-boot-starter-parent
1.2.7.RELEASE
reddit-common
reddit-rest
reddit-ui
reddit-web
Все свойства и версии зависимостей будут объявлены здесь, в родительскомpom.xml - для использования всеми подмодулями.
5.2. Общий модуль
Теперь поговорим о нашем модулеreddit-common. Этот модуль будет содержать ресурсы, связанные с сохранением, обслуживанием и Reddit. Он также содержит тесты на устойчивость и интеграцию.
Классы конфигурации, включенные в этот модуль:CommonConfig,PersistenceJpaConfig, RedditConfig,ServiceConfig,WebGeneralConfig.
Вот простойpom.xml:
4.0.0
reddit-common
reddit-common
jar
org.example
reddit-scheduler
0.2.0-SNAPSHOT
5.3. Модуль REST
Наш модульreddit-rest содержит контроллеры REST и DTO.
Единственный класс конфигурации в этом модуле -WebApiConfig.
Вотpom.xml:
4.0.0
reddit-rest
reddit-rest
jar
org.example
reddit-scheduler
0.2.0-SNAPSHOT
org.example
reddit-common
0.2.0-SNAPSHOT
...
Этот модуль также содержит всю логику обработки исключений.
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:
4.0.0
reddit-ui
reddit-ui
jar
org.example
reddit-scheduler
0.2.0-SNAPSHOT
org.example
reddit-common
0.2.0-SNAPSHOT
...
Теперь у нас есть более простой обработчик исключений для обработки исключений внешнего интерфейса:
@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:
4.0.0
reddit-web
reddit-web
war
org.example
reddit-scheduler
0.2.0-SNAPSHOT
org.example
reddit-common
0.2.0-SNAPSHOT
org.example
reddit-rest
0.2.0-SNAPSHOT
org.example
reddit-ui
0.2.0-SNAPSHOT
...
Обратите внимание, что это единственный развертываемый военный модуль - поэтому приложение хорошо модульно, но все еще развернуто как монолит.
6. Заключение
Мы близки к завершению тематического исследования Reddit. Это было очень крутое приложение, созданное с нуля для моих личных нужд, и оно сработало довольно хорошо.