Sexta Rodada de Melhorias no Aplicativo Reddit
1. Visão geral
Neste artigo, estaremos quase encerrando as melhorias emReddit application.
2. Segurança API de comando
Primeiro, vamos trabalhar para proteger a API de comando para evitar a manipulação de recursos por usuários que não sejam o proprietário.
2.1. Configuração
Vamos começar habilitando o uso de@Preauthorize na configuração:
@EnableGlobalMethodSecurity(prePostEnabled = true)
2.2. Autorizar Comandos
A seguir, vamos autorizar nossos comandos na camada do controlador com a ajuda de algumas expressões do 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) {
...
}
Observe que:
-
Estamos usando “#” para acessar o argumento do método - como fizemos em#id
-
Estamos usando “@” para acessar um bean - como fizemos em@resourceSecurityService
2.3. Serviço de segurança de recursos
Veja como o serviço responsável por verificar a propriedade se parece com:
@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();
}
}
Observe que:
-
isPostOwner(): verifique se o usuário atual possui oPost com determinadopostId
-
isRssFeedOwner(): verifique se o usuário atual possui oMyFeed com determinadofeedId
2.4. Manipulação de exceção
Em seguida, vamos simplesmente lidar comAccessDeniedException - da seguinte forma:
@ExceptionHandler({ AuthenticationCredentialsNotFoundException.class, AccessDeniedException.class })
public ResponseEntity
2.5. Teste de Autorização
Por fim, testaremos nossa autorização de comando:
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);
}
}
Observe como a implementação degivenAuth() está usando o usuário “john”, enquantogivenAnotherUserAuth() está usando o usuário “teste” - para que possamos testar esses cenários complexos envolvendo dois usuários diferentes.
3. Mais opções de reenvio
A seguir, adicionaremos uma opção interessante -resubmitting an article to Reddit after a day or two, em vez de awa direito.
Começaremos modificando as opções de reenvio de pós-agendamento e dividiremostimeInterval. Isso costumava ter duas responsabilidades separadas; isso foi:
-
o tempo entre o envio da postagem e o tempo de verificação da pontuação e
-
o tempo entre a verificação da pontuação e a próxima hora de envio
Não separaremos essas duas responsabilidades:checkAfterInterval esubmitAfterInterval.
3.1. The Post Entity
Modificaremos as entidades Post e Preference removendo:
private int timeInterval;
E adicionando:
private int checkAfterInterval;
private int submitAfterInterval;
Observe que faremos o mesmo para os DTOs relacionados.
3.2. The Scheduler
Em seguida, modificaremos nosso agendador para usar os novos intervalos de tempo - da seguinte maneira:
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))
...
}
Observe que, para uma postagem agendada comsubmissionDateT echeckAfterIntervalt1 esubmitAfterIntervalt2e número de tentativas> 1, vamos ter:
-
A postagem é enviada pela primeira vez emT
-
O agendador verifica a pontuação da postagem emT+t1
-
Assumindo que a postagem não atingiu a pontuação do gol, a postagem é enviada pela segunda vez emT+t1+t2
4. Verificações extras para token de acesso OAuth2
A seguir, adicionaremos algumas verificações extras sobre como trabalhar com o token de acesso.
Às vezes, o token de acesso do usuário pode ser quebrado, o que leva a um comportamento inesperado no aplicativo. Vamos consertar isso permitindo que o usuário reconecte sua conta ao Reddit - recebendo assim um novo token de acesso - se isso acontecer.
4.1. Reddit Controller
Aqui está a verificação simples do nível do controlador -isAccessTokenValid():
@RequestMapping(value = "/isAccessTokenValid")
@ResponseBody
public boolean isAccessTokenValid() {
return redditService.isCurrentUserAccessTokenValid();
}
4.2. Serviço Reddit
E aqui está a implementação do nível de serviço:
@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;
}
O que está acontecendo aqui é bastante simples. Se o usuário já tiver um token de acesso, tentaremos acessar a API do Reddit usando a chamadaneedsCaptcha simples.
Se a chamada falhar, o token atual é inválido - vamos redefini-lo. E é claro que isso leva o usuário a ser solicitado a reconectar sua conta ao Reddit.
4.3. A parte dianteira
Finalmente, vamos mostrar isso na página inicial:
Observe como, se o token de acesso for inválido, o link "Conectar ao Reddit" será mostrado ao usuário.
5. Separação em vários módulos
A seguir, estamos dividindo o aplicativo em módulos. Iremos com 4 módulos:reddit-common,reddit-rest,reddit-uiereddit-web.
5.1. Pai
Primeiro, vamos começar com nosso módulo pai que envolve todos os submódulos.
O módulo paireddit-scheduler contém submódulos e umpom.xml simples - como segue:
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
Todas as propriedades e versões de dependência serão declaradas aqui, no paipom.xml - para ser usado por todos os submódulos.
5.2. Módulo Comum
Agora, vamos falar sobre nosso móduloreddit-common. Este módulo conterá persistência, serviço e recursos relacionados ao reddit. Ele também contém testes de persistência e integração.
As classes de configuração incluídas neste módulo sãoCommonConfig,PersistenceJpaConfig, RedditConfig,ServiceConfig,WebGeneralConfig.
Aqui está opom.xml simples:
4.0.0
reddit-common
reddit-common
jar
org.example
reddit-scheduler
0.2.0-SNAPSHOT
5.3. Módulo REST
Nosso móduloreddit-rest contém os controladores REST e os DTOs.
A única classe de configuração neste módulo éWebApiConfig.
Aqui está opom.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
...
Este módulo também contém toda a lógica de manipulação de exceções.
5.4. Módulo UI
O móduloreddit-ui contém os controladores front-end e MVC.
As classes de configuração incluídas sãoWebFrontendConfigeThymeleafConfig.
Precisamos alterar a configuração do Thymeleaf para carregar modelos do classpath de recursos em vez do contexto do servidor:
@Bean
public TemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/");
templateResolver.setSuffix(".html");
templateResolver.setCacheable(false);
return templateResolver;
}
Aqui está opom.xml simples:
4.0.0
reddit-ui
reddit-ui
jar
org.example
reddit-scheduler
0.2.0-SNAPSHOT
org.example
reddit-common
0.2.0-SNAPSHOT
...
Agora também temos um manipulador de exceção mais simples aqui, para lidar com exceções de front-end:
@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. Módulo da Web
Finalmente, aqui está o nosso módulo reddit-web.
Este módulo contém recursos, configuração de segurança e configuração deSpringBootApplication - da seguinte forma:
@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);
}
}
Aqui está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
...
Observe que este é o único módulo implementável de guerra - portanto, o aplicativo está bem modularizado agora, mas ainda implantado como um monólito.
6. Conclusão
Estamos perto de encerrar o estudo de caso do Reddit. Tem sido um aplicativo muito legal criado a partir do zero em torno de uma necessidade pessoal minha e funcionou muito bem.