Sexta Rodada de Melhorias no Aplicativo Reddit

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 handleAccessDeniedException(final Exception ex, final WebRequest request) {
    logger.error("403 Status Code", ex);
    ApiError apiError = new ApiError(HttpStatus.FORBIDDEN, ex);
    return new ResponseEntity(apiError, new HttpHeaders(), HttpStatus.FORBIDDEN);
}



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:

  1. A postagem é enviada pela primeira vez emT

  2. O agendador verifica a pontuação da postagem emT+t1

  3. 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.