1. Обзор
В этой статье мы собираемся быть почти упаковка улучшений в приложении Reddit .
2. Командная безопасность API
Во-первых, мы собираемся сделать некоторую работу по обеспечению безопасности команды API, чтобы предотвратить манипулирование ресурсами пользователями, кроме владельца.
2.1. Конфигурация
Начнем с того, что мы будем использовать @Preauthorize в конфигурации:
@EnableGlobalMethodSecurity(prePostEnabled = true)
2.2. Авторизовать команды
Далее, давайте авторизовать наши команды в слое контроллера с помощью некоторых выражений весенней безопасности:
@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
- Мы используем “Я” для доступа к фасоли – как мы это делали в @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
- isRssFeedВладелец () : проверьте, является ли текущий пользователь владельцем 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 () реализация использует пользователя “джон”, в то время как даноAnotherUserAuth() использует пользователь “тест” – так что мы можем проверить эти сложные сценарии с участием двух разных пользователей.
3. Больше вариантов повторного висята
Далее мы добавим интересный вариант – повторное удаление статьи на Reddit через день или два , вместо правой авы.
Начнем с изменения запланированных вариантов повторного размещения и разделим времяИнтервал . Раньше это было две отдельные обязанности; Это было:
- время между представлением поста и время проверки оценки и
- время между проверкой баллов и следующим временем представления
Мы не будем отделять эти две обязанности: checkAfterInterval и представитьПосежденоинтервал .
3.1. Почтовое образование
Мы изменим как сущности Почты, так и Предпочтения, удалив:
private int timeInterval;
И добавив:
private int checkAfterInterval; private int submitAfterInterval;
Обратите внимание, что мы сделаем то же самое для связанных DTOs.
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)) ... }
Обратите внимание, что для запланированного поста с представлениеDate T и checkAfterInterval t1 и представитьПосежденоинтервал t2 и количество попыток > 1, мы будем иметь:
- Почта представлена в первый раз на T
- Планировщик проверяет почтовый балл в ТЗТ1
- Предполагая, что должность не достигла цели оценка, должность представлена во второй раз на Тзт1’т2
4. Дополнительные проверки токена доступа OAuth2
Далее мы добавим некоторые дополнительные проверки, работающие с токеном доступа.
Иногда токен доступа пользователя может быть нарушен, что приводит к неожиданному поведению в приложении. Мы собираемся исправить это, позволяя пользователю повторно подключить свою учетную запись к Reddit – таким образом, получая новый токен доступа – если это произойдет.
4.1. Контроллер Reddit
Вот простая проверка уровня контроллера – 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; }
То, что здесь происходит, довольно просто. Если у пользователя уже есть токен доступа, мы постараемся связаться с API Reddit с помощью простого нуждаетсяCaptcha звать.
Если вызов не удается, то текущий маркер является недействительным – так что мы сбросим его. И, конечно, это приводит к тому, что пользователю будет предложено подключить свою учетную запись к Reddit.
4.3. Фронт-энд
Наконец, мы покажем это на главной странице:
Обратите внимание, как, если токен доступа недействителен, пользователю будет показана ссылка “Подключение к Reddit”.
5. Разделение на несколько модулей
Далее мы разделим приложение на модули. Мы пойдем с 4 модулями: Reddit-общий , Reddit-отдых , Reddit-ui и Reddit-веб- .
5.1. Родитель
Во-первых, давайте начнем с нашего родительского модуля, который обернуть все подмодули.
Родительский модуль Reddit-планировщик содержит подмодули и простую пом.xml – следующим образом:
4.0.0 org.baeldung 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
Все свойства и версии зависимостей будут объявлены здесь, в родительском пом.xml – для использования всеми подмодуми.
5.2. Общий модуль
Теперь давайте поговорим о наших Reddit-общий модуль. Этот модуль будет содержать ресурсы, связанные с сохранением, обслуживанием и Reddit. Он также содержит тесты на настойчивость и интеграцию.
Классы конфигурации, включенные в этот модуль, ОбщиеКонфиг , PersistenceJpaConfig, RedditConfig , СервисКонфиг , WebGeneralConfig .
Вот простая пом.xml :
4.0.0 reddit-common reddit-common jar org.baeldung reddit-scheduler 0.2.0-SNAPSHOT
5.3. Модуль REST
Наша Reddit-отдых модуль содержит контроллеры REST и DTOs.
Единственным классом конфигурации в этом модуле является WebApiConfig .
Вот пом.xml :
4.0.0 reddit-rest reddit-rest jar org.baeldung reddit-scheduler 0.2.0-SNAPSHOT ... org.baeldung reddit-common 0.2.0-SNAPSHOT
Этот модуль также содержит логику обработки исключений.
5.4. Модуль пользовательского интерфейса
Reddit-ui модуль содержит передние и MVC контроллеры.
Включенные классы конфигурации WebFrontendConfig и ТимелифКонфиг .
Нам нужно изменить конфигурацию Thymeleaf, чтобы загрузить шаблоны из классов ресурсов вместо контекста сервера:
@Bean public TemplateResolver templateResolver() { SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver(); templateResolver.setPrefix("classpath:/"); templateResolver.setSuffix(".html"); templateResolver.setCacheable(false); return templateResolver; }
Вот простая пом.xml :
4.0.0 reddit-ui reddit-ui jar org.baeldung reddit-scheduler 0.2.0-SNAPSHOT ... org.baeldung 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-веб-модуль.
Этот модуль содержит ресурсы, конфигурацию безопасности и СпрингБутАппликация конфигурация — следующим образом:
@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); } }
Вот пом.xml :
4.0.0 reddit-web reddit-web war org.baeldung reddit-scheduler 0.2.0-SNAPSHOT org.baeldung reddit-common 0.2.0-SNAPSHOT org.baeldung reddit-rest 0.2.0-SNAPSHOT ... org.baeldung reddit-ui 0.2.0-SNAPSHOT
Обратите внимание, что это единственная война, развертываемый модуль – так что приложение хорошо модульный сейчас, но по-прежнему развернуты в качестве монолита.
6. Заключение
Мы близки к тому, чтобы подытовить пример Reddit. Это было очень прохладно приложение построено с нуля вокруг личной необходимости разминирования, и это сработало довольно хорошо.