Sechste Verbesserungsrunde der Reddit-Anwendung

Sechste Runde der Verbesserungen an der Reddit-Anwendung

1. Überblick

In diesem Artikel werden wir die Verbesserungen anReddit application fast abschließen.

2. Befehls-API-Sicherheit

Zunächst werden wir einige Arbeiten durchführen, um die Befehls-API zu sichern und zu verhindern, dass andere Benutzer als der Eigentümer Ressourcen manipulieren.

2.1. Aufbau

Zunächst aktivieren wir die Verwendung von@Preauthorize in der Konfiguration:

@EnableGlobalMethodSecurity(prePostEnabled = true)

2.2. Befehle autorisieren

Als Nächstes autorisieren wir unsere Befehle in der Controller-Ebene mithilfe einiger Spring Security-Ausdrücke:

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

Beachten Sie, dass:

  • Wir verwenden "#", um auf das Methodenargument zuzugreifen - wie in#id

  • Wir verwenden "@", um auf eine Bean zuzugreifen - wie in@resourceSecurityService

2.3. Ressourcensicherheitsdienst

So sieht der Dienst aus, der für die Überprüfung des Eigentums verantwortlich ist:

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

Beachten Sie, dass:

  • isPostOwner(): Überprüfen Sie, ob der aktuelle BenutzerPost mit den angegebenenpostId besitzt

  • isRssFeedOwner(): Überprüfen Sie, ob der aktuelle BenutzerMyFeed mit den angegebenenfeedId besitzt

2.4. Ausnahmebehandlung

Als nächstes werden wir einfach dieAccessDeniedException behandeln - wie folgt:

@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. Autorisierungstest

Zuletzt testen wir unsere Befehlsberechtigung:

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

Beachten Sie, wie die Implementierung vongivenAuth()den Benutzer "john" verwendet, währendgivenAnotherUserAuth() den Benutzer "test" verwendet, damit wir diese komplexen Szenarien mit zwei verschiedenen Benutzern testen können.

3. Weitere Optionen zum erneuten Senden

Als nächstes fügen wir eine interessante Option hinzu -resubmitting an article to Reddit after a day or two anstelle von right awa.

Wir beginnen mit der Änderung der geplanten Optionen für die erneute Übermittlung und teilentimeInterval auf. Dies hatte früher zwei getrennte Verantwortlichkeiten; es war:

  • die Zeit zwischen der Einreichung der Ergebnisse und der Überprüfung der Ergebnisse und

  • Die Zeit zwischen der Überprüfung der Punktzahl und der nächsten Einreichung

Wir werden diese beiden Verantwortlichkeiten nicht trennen:checkAfterInterval undsubmitAfterInterval.

3.1. Die Post-Entität

Wir werden sowohl die Entitäten "Post" als auch "Preference" ändern, indem wir Folgendes entfernen:

private int timeInterval;

Und fügte hinzu:

private int checkAfterInterval;

private int submitAfterInterval;

Beachten Sie, dass wir dasselbe für die zugehörigen DTOs tun.

3.2. Der Scheduler

Als nächstes werden wir unseren Scheduler modifizieren, um die neuen Zeitintervalle zu verwenden - wie folgt:

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

Beachten Sie, dass für einen geplanten Beitrag mitsubmissionDateT undcheckAfterIntervalt1 undsubmitAfterIntervalt2 und Anzahl der Versuche> 1 wir werden haben:

  1. Der Beitrag wird zum ersten Mal umT gesendet

  2. Der Scheduler überprüft die Post-Punktzahl beiT+t1

  3. Angenommen, der Beitrag hat die Torpunktzahl nicht erreicht, wird der Beitrag zum zweiten Mal beiT+t1+t2 eingereicht

4. Zusätzliche Überprüfungen für das OAuth2-Zugriffstoken

Als Nächstes werden wir einige zusätzliche Überprüfungen für die Arbeit mit dem Zugriffstoken hinzufügen.

Manchmal kann das Benutzerzugriffstoken beschädigt werden, was zu unerwartetem Verhalten in der Anwendung führt. Wir werden das beheben, indem wir dem Benutzer erlauben, sein Konto erneut mit Reddit zu verbinden und in diesem Fall ein neues Zugriffstoken zu erhalten.

4.1. Reddit Controller

Hier ist die einfache Überprüfung des Controller-Pegels -isAccessTokenValid():

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

4.2. Reddit Service

Und hier ist die Implementierung des Service Levels:

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

Was hier passiert, ist ganz einfach. Wenn der Benutzer bereits über ein Zugriffstoken verfügt, versuchen wir, die Reddit-API mit dem einfachen Aufruf vonneedsCaptchazu erreichen.

Wenn der Anruf fehlschlägt, ist das aktuelle Token ungültig. Wir setzen es daher zurück. Und dies führt natürlich dazu, dass der Benutzer aufgefordert wird, sein Konto erneut mit Reddit zu verbinden.

4.3. Vorderes Ende

Zum Schluss zeigen wir dies auf der Homepage:



Beachten Sie, dass dem Benutzer der Link „Mit Reddit verbinden“ angezeigt wird, wenn das Zugriffstoken ungültig ist.

5. Trennung in mehrere Module

Als Nächstes teilen wir die Anwendung in Module auf. Wir werden mit 4 Modulen arbeiten:reddit-common,reddit-rest,reddit-ui undreddit-web.

5.1. Elternteil

Beginnen wir zunächst mit unserem übergeordneten Modul, das alle Untermodule umschließt.

Das übergeordnete Modulreddit-scheduler enthält Untermodule und ein einfachespom.xml - wie folgt:


    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
    

    
        
    

Alle Eigenschaften und Abhängigkeitsversionen werden hier im übergeordnetenpom.xml deklariert, das von allen Untermodulen verwendet wird.

5.2. Gemeinsames Modul

Lassen Sie uns nun über unserreddit-common-Modul sprechen. Dieses Modul enthält Ressourcen zu Persistenz, Service und Reddit. Es enthält auch Persistenz- und Integrationstests.

Die in diesem Modul enthaltenen Konfigurationsklassen sindCommonConfig,PersistenceJpaConfig, RedditConfig,ServiceConfig,WebGeneralConfig.

Hier sind die einfachenpom.xml:


    4.0.0
    reddit-common
    reddit-common
    jar

    
        org.example
        reddit-scheduler
        0.2.0-SNAPSHOT
    

5.3. REST-Modul

Unserreddit-rest-Modul enthält die REST-Controller und die DTOs.

Die einzige Konfigurationsklasse in diesem Modul istWebApiConfig.

Hier sind diepom.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
        
    
    ...

Dieses Modul enthält auch die gesamte Ausnahmebehandlungslogik.

5.4. UI-Modul

Dasreddit-ui-Modul enthält die Front-End- und MVC-Controller.

Die enthaltenen Konfigurationsklassen sindWebFrontendConfig undThymeleafConfig.

Wir müssen die Thymeleaf-Konfiguration ändern, um Vorlagen aus dem Ressourcenklassenpfad anstelle des Serverkontexts zu laden:

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

Hier sind die einfachenpom.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
        
    
...

Wir haben jetzt auch hier einen einfacheren Ausnahmebehandler für die Behandlung von Front-End-Ausnahmen:

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

Zum Schluss noch unser reddit-web Modul.

Dieses Modul enthält Ressourcen, Sicherheitskonfiguration undSpringBootApplication-Konfiguration - wie folgt:

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

Hier istpom.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
        
...

Beachten Sie, dass dies das einzige Modul ist, das im Krieg eingesetzt werden kann. Daher ist die Anwendung jetzt gut modularisiert, wird jedoch weiterhin als Monolith eingesetzt.

6. Fazit

Wir stehen kurz vor dem Abschluss der Reddit-Fallstudie. Es war eine sehr coole App, die von Grund auf auf meine persönlichen Bedürfnisse zugeschnitten war, und sie hat ganz gut funktioniert.