Sixième série d’améliorations de l’application Reddit

Sixième série d'améliorations à l'application Reddit

1. Vue d'ensemble

Dans cet article, nous allons presque terminer les améliorations apportées auxReddit application.

2. Sécurité de l'API de commande

Tout d'abord, nous allons faire un certain travail pour sécuriser l'API de commande afin d'empêcher la manipulation des ressources par des utilisateurs autres que le propriétaire.

2.1. Configuration

Nous allons commencer par activer l'utilisation de@Preauthorize dans la configuration:

@EnableGlobalMethodSecurity(prePostEnabled = true)

2.2. Autoriser les commandes

Ensuite, autorisons nos commandes dans la couche contrôleur à l'aide de certaines expressions 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) {
    ...
}

Notez que:

  • Nous utilisons "#" pour accéder à l'argument de la méthode - comme nous l'avons fait dans#id

  • Nous utilisons "@" pour accéder à un bean - comme nous l'avons fait dans@resourceSecurityService

2.3. Service de sécurité des ressources

Voici à quoi ressemble le service chargé de vérifier la propriété:

@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();
    }
}

Notez que:

  • isPostOwner(): vérifier si l'utilisateur actuel possède lesPost avec despostId donnés

  • isRssFeedOwner(): vérifier si l'utilisateur actuel possède lesMyFeed avec desfeedId donnés

2.4. Gestion des exceptions

Ensuite, nous allons simplement gérer lesAccessDeniedException - comme suit:

@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. Test d'autorisation

Enfin, nous allons tester notre autorisation de commande:

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);
    }
}

Notez comment l'implémentation degivenAuth() utilise l'utilisateur «john», tandis quegivenAnotherUserAuth() utilise l'utilisateur «test» - afin que nous puissions ensuite tester ces scénarios complexes impliquant deux utilisateurs différents.

3. Plus d'options de resoumettre

Ensuite, nous ajouterons une option intéressante -resubmitting an article to Reddit after a day or two, au lieu de right awa.

Nous commencerons par modifier les options de réenvoi des articles planifiés et nous partagerons lestimeInterval. Auparavant, cela comportait deux responsabilités distinctes. c'était:

  • le temps entre le post-soumission et le score check time et

  • le temps entre la vérification du score et la prochaine heure de soumission

Nous ne séparerons pas ces deux responsabilités:checkAfterInterval etsubmitAfterInterval.

3.1. L'entité de poste

Nous allons modifier les entités Postes et Préférences en supprimant:

private int timeInterval;

Et en ajoutant:

private int checkAfterInterval;

private int submitAfterInterval;

Notez que nous ferons de même pour les DTO associés.

3.2. Le planificateur

Ensuite, nous allons modifier notre planificateur pour utiliser les nouveaux intervalles de temps - comme suit:

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))
    ...
}

Notez que, pour une publication programmée avecsubmissionDateT etcheckAfterIntervalt1 etsubmitAfterIntervalt2 et le nombre de tentatives> 1, nous avoir:

  1. Le message est soumis pour la première fois àT

  2. Le planificateur vérifie le score de publication àT+t1

  3. En supposant que le message n'a pas atteint le score de l'objectif, le message est soumis pour la deuxième fois àT+t1+t2

4. Vérifications supplémentaires pour le jeton d'accès OAuth2

Ensuite, nous ajouterons des vérifications supplémentaires concernant l'utilisation du jeton d'accès.

Parfois, le jeton d'accès utilisateur peut être rompu, ce qui entraîne un comportement inattendu dans l'application. Nous allons résoudre ce problème en permettant à l'utilisateur de reconnecter son compte à Reddit - recevant ainsi un nouveau jeton d'accès - si cela se produit.

4.1. Contrôleur Reddit

Voici la vérification simple du niveau du contrôleur -isAccessTokenValid():

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

4.2. Service Reddit

Et voici la mise en œuvre du niveau de service:

@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;
}

Ce qui se passe ici est assez simple. Si l'utilisateur dispose déjà d'un jeton d'accès, nous essaierons d'accéder à l'API Reddit en utilisant le simple appelneedsCaptcha.

Si l'appel échoue, le jeton actuel n'est pas valide. Nous allons donc le réinitialiser. Et bien sûr, cela conduit l'utilisateur à être invité à reconnecter son compte à Reddit.

4.3. L'extrémité avant

Enfin, nous montrerons ceci sur la page d'accueil:



Notez comment, si le jeton d'accès est invalide, le lien «Se connecter à Reddit» sera affiché à l'utilisateur.

5. Séparation en plusieurs modules

Ensuite, nous divisons l'application en modules. Nous allons utiliser 4 modules:reddit-common,reddit-rest,reddit-ui etreddit-web.

5.1. Parent

Commençons par notre module parent qui encapsule tous les sous-modules.

Le module parentreddit-scheduler contient des sous-modules et un simplepom.xml - comme suit:


    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
    

    
        
    

Toutes les propriétés et versions de dépendance seront déclarées ici, dans le parentpom.xml - à utiliser par tous les sous-modules.

5.2. Module commun

Maintenant, parlons de notre modulereddit-common. Ce module contiendra des ressources relatives à la persistance, au service et à la reddit. Il contient également des tests de persistance et d'intégration.

Les classes de configuration incluses dans ce module sontCommonConfig,PersistenceJpaConfig, RedditConfig,ServiceConfig,WebGeneralConfig.

Voici lespom.xml simples:


    4.0.0
    reddit-common
    reddit-common
    jar

    
        org.example
        reddit-scheduler
        0.2.0-SNAPSHOT
    

5.3. Module REST

Notre modulereddit-rest contient les contrôleurs REST et les DTO.

La seule classe de configuration de ce module estWebApiConfig.

Voici lespom.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
        
    
    ...

Ce module contient également toute la logique de traitement des exceptions.

5.4. Module d'interface utilisateur

Le modulereddit-ui contient les contrôleurs frontaux et MVC.

Les classes de configuration incluses sontWebFrontendConfig etThymeleafConfig.

Nous devrons modifier la configuration de Thymeleaf pour charger les modèles à partir du chemin de classe des ressources au lieu du contexte du serveur:

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

Voici lespom.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
        
    
...

Nous avons maintenant un gestionnaire d'exceptions plus simple ici, pour gérer les exceptions frontales:

@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. Module Web

Enfin, voici notre module reddit-web.

Ce module contient les ressources, la configuration de la sécurité et la configuration deSpringBootApplication - comme suit:

@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);
    }
}

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

Notez qu’il s’agit du seul module déployable en temps de guerre. L’application est donc bien modularisée à l’heure actuelle, mais toujours déployée sous forme de monolithe.

6. Conclusion

Nous sommes sur le point de conclure l'étude de cas Reddit. C’est une application très cool construite à partir de zéro autour d’un de mes besoins personnels, et cela a très bien fonctionné.