Sixth Round of Improvements to the Reddit Application – Reddit应用程序的第六轮改进

最后修改: 2015年 10月 30日

1. Overview

1.概述

In this article we’re going to be almost wrapping up the improvements to the Reddit application.

在这篇文章中,我们将几乎完成对Reddit应用程序的改进。

2. Command API Security

2.命令API的安全性

First, we’re going to do some work to secure the command API to prevent manipulating of resources by users other than the owner.

首先,我们要做一些工作来保证命令API的安全,以防止所有者以外的用户对资源进行操纵。

2.1. Configuration

2.1.配置

We’re going to start by enabling the use of @Preauthorize in the configuration:

我们将首先在配置中启用@Preauthorize的使用。

@EnableGlobalMethodSecurity(prePostEnabled = true)

2.2. Authorize Commands

2.2.授权命令

Next, let’s authorize our commands in the controller layer with the help of some Spring Security 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) {
    ...
}

Note that:

请注意,。

  • We’re using “#” to access the method argument – as we did in #id
  • We’re using “@” to access a bean – as we did in @resourceSecurityService

2.3. Resource Security Service

2.3.资源安全服务

Here’s how the service responsible with checking the ownership looks like:

下面是负责检查所有权的服务看起来是这样的。

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

Note that:

请注意,。

  • isPostOwner(): check if current user owns the Post with given postId
  • isRssFeedOwner(): check if current user owns the MyFeed with given feedId

2.4. Exception Handling

2.4.异常处理

Next, we will simply handle the AccessDeniedException – as follows:

接下来,我们将简单地处理AccessDeniedException – 如下所示。

@ExceptionHandler({ AuthenticationCredentialsNotFoundException.class, AccessDeniedException.class })
public ResponseEntity<Object> handleAccessDeniedException(final Exception ex, final WebRequest request) {
    logger.error("403 Status Code", ex);
    ApiError apiError = new ApiError(HttpStatus.FORBIDDEN, ex);
    return new ResponseEntity<Object>(apiError, new HttpHeaders(), HttpStatus.FORBIDDEN);
}

2.5. Authorization Test

2.5.授权测试

Finally, we will test our command authorization:

最后,我们将测试我们的命令授权。

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

Note how thegivenAuth() implementation is using the user “john”, while givenAnotherUserAuth() is using the user “test” – so that we can then test out these complex scenarios involving two different users.

请注意givenAuth()实现是如何使用用户 “john “的,而givenAnotherUserAuth()是如何使用用户 “test “的–这样我们就可以测试出这些涉及两个不同用户的复杂场景。

3. More Resubmit Options

3.更多的重新提交选项

Next, we’ll add in an interesting option – resubmitting an article to Reddit after a day or two, instead of right awa.

接下来,我们将添加一个有趣的选项–在一两天后重新向Reddit提交文章,而不是直接等待。

We’ll start by modifying the scheduled post resubmit options and we’ll split timeInterval. This used to have two separate responsibilities; it was:

我们将从修改计划中的帖子重新提交选项开始,我们将拆分timeInterval。这曾经有两个独立的责任,它是。

  • the time between post submission and score check time and
  • the time between score check and next submission time

We’ll not separate these two responsibilities: checkAfterInterval and submitAfterInterval.

我们不把这两个责任分开。checkAfterIntervalsubmitAfterInterval

3.1. The Post Entity

3.1.邮政实体

We will modify both Post and Preference entities by removing:

我们将通过删除来修改Post和Preference两个实体。

private int timeInterval;

And adding:

并补充说。

private int checkAfterInterval;

private int submitAfterInterval;

Note that we’ll do the same for the related DTOs.

请注意,我们对相关的DTO也会这样做。

3.2. The Scheduler

3.2.调度器

Next, we will modify our scheduler to use the new time intervals – as follows:

接下来,我们将修改我们的调度程序以使用新的时间间隔–如下所示。

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

Note that, for a scheduled post with submissionDate T and checkAfterInterval t1 and submitAfterInterval t2 and number of attempts > 1, we’ll have:

请注意,对于一个有submissionDate TcheckAfterInterval t1submitAfterInterval t2以及尝试次数>1的预定帖子,我们会有。

  1. Post is submitted for the first time at T
  2. Scheduler checks the post score at T+t1
  3. Assuming post didn’t reach goal score, the post is the submitted for the second time at T+t1+t2

4. Extra Checks for the OAuth2 Access Token

4.OAuth2访问令牌的额外检查

Next, we’ll add some extra checks around working with the access token.

接下来,我们将围绕访问令牌的工作添加一些额外的检查。

Sometimes, the user access token could be broken which leads to unexpected behavior in the application. We’re going to fix that by allowing the user to re-connect their account to Reddit – thus receiving a new access token – if that happens.

有时,用户的访问令牌可能被破坏,导致应用程序中出现意外行为。我们将通过允许用户重新连接他们的账户到Reddit–从而收到一个新的访问令牌–来解决这个问题,如果发生这种情况。

4.1. Reddit Controller

4.1.Reddit控制器</strong

Here’s the simple controller level check – isAccessTokenValid():

这里是简单的控制器级别的检查 – isAccessTokenValid()

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

4.2. Reddit Service

4.2.Reddit服务</strong

And here’s the service level implementation:

而这里是服务水平的实施。

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

What’s happening here is quite simple. If the user already has an access token, we’ll try to reach the Reddit API using the simple needsCaptcha call.

这里发生的事情是非常简单的。如果用户已经有一个访问令牌,我们将尝试使用简单的needsCaptcha调用到达Reddit API。

If the call fails, then the current token is invalid – so we’ll reset it. And of course this leads to the user being prompted to reconnect their account to Reddit.

如果调用失败,那么当前的令牌是无效的–所以我们将重置它。当然这也会导致用户被提示重新连接他们的账户到Reddit。

4.3. Front-end

4.3.前端</strong

Finally, we’ll show this on the homepage:

最后,我们将在主页上显示这一点。

<div id="connect" style="display:none">
    <a href="redditLogin">Connect your Account to Reddit</a>
</div>

<script>
$.get("api/isAccessTokenValid", function(data){
    if(!data){
        $("#connect").show();
    }
});
</script>

Note how, if the access token is invalid, the “Connect to Reddit” link will be shown to the user.

请注意,如果访问令牌无效,”连接到Reddit “链接将显示给用户。

5. Separation into Multiple Modules

5.分离成多个模块

Next, we’re splitting the application into modules. We’ll go with 4 modules: reddit-common, reddit-rest, reddit-ui and reddit-web.

接下来,我们要把应用程序分割成模块。我们将采用4个模块。reddit-common, reddit-rest, reddit-uireddit-web

5.1. Parent

5.1.家长

First, let’s start with our parent module which wrap all sub-modules.

首先,让我们从我们的父模块开始,它包裹着所有的子模块。

The parent module reddit-scheduler contains sub-modules and a simple pom.xml – as follows:

父模块reddit-scheduler包含子模块和一个简单的pom.xml – 如下所示。

<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.baeldung</groupId>
    <artifactId>reddit-scheduler</artifactId>
    <version>0.2.0-SNAPSHOT</version>
    <name>reddit-scheduler</name>
    <packaging>pom</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.2.7.RELEASE</version>
    </parent>
        
    <modules>
        <module>reddit-common</module>
        <module>reddit-rest</module>
        <module>reddit-ui</module>
        <module>reddit-web</module>
    </modules>

    <properties>
        <!-- dependency versions and properties -->
    </properties>

</project>

All properties and dependency versions will be declared here, in the parent pom.xml – to be used by all sub-modules.

所有的属性和依赖版本都将在这里声明,在父pom.xml中–被所有子模块使用。

5.2. Common Module

5.2.通用模块

Now, let’s talk about our reddit-common module. This module will contain persistence, service and reddit related resources. It also contains persistence and integration tests.

现在,让我们来谈谈我们的reddit-common模块。这个模块将包含持久性、服务和reddit相关资源。它还包含持久性和集成测试。

The configuration classes included in this module are CommonConfig, PersistenceJpaConfig, RedditConfig, ServiceConfig, WebGeneralConfig.

该模块包含的配置类有CommonConfig,PersistenceJpaConfig, RedditConfig,ServiceConfig, WebGeneralConfig

Here’s the simple pom.xml:

这里是简单的pom.xml

<project>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>reddit-common</artifactId>
    <name>reddit-common</name>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.baeldung</groupId>
        <artifactId>reddit-scheduler</artifactId>
        <version>0.2.0-SNAPSHOT</version>
    </parent>

</project>

5.3. REST Module

5.3.REST模块</strong

Our reddit-rest module contains the REST controllers and the DTOs.

我们的reddit-rest模块包含REST控制器和DTOs。

The only configuration class in this module is WebApiConfig.

本模块中唯一的配置类是WebApiConfig

Here’s the pom.xml:

这里是pom.xml

<project>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>reddit-rest</artifactId>
    <name>reddit-rest</name>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.baeldung</groupId>
        <artifactId>reddit-scheduler</artifactId>
        <version>0.2.0-SNAPSHOT</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.baeldung</groupId>
            <artifactId>reddit-common</artifactId>
            <version>0.2.0-SNAPSHOT</version>
        </dependency>
    </dependencies>
    ...

This module contains all exception handling logic as well.

这个模块也包含所有的异常处理逻辑。

5.4. UI Module

5.4.UI模块

The reddit-ui module contains the front-end and MVC controllers.

reddit-ui模块包含前端和MVC控制器。

The configuration classes included are WebFrontendConfig and ThymeleafConfig.

包括的配置类是WebFrontendConfigThymeleafConfig

We’ll need to change the Thymeleaf configuration to load templates from resources classpath instead of Server context:

我们需要改变Thymeleaf的配置,从资源classpath而不是服务器上下文加载模板。

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

Here’s the simple pom.xml:

这里是简单的pom.xml

<project>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>reddit-ui</artifactId>
    <name>reddit-ui</name>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.baeldung</groupId>
        <artifactId>reddit-scheduler</artifactId>
        <version>0.2.0-SNAPSHOT</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.baeldung</groupId>
            <artifactId>reddit-common</artifactId>
            <version>0.2.0-SNAPSHOT</version>
        </dependency>
    </dependencies>
...

We now have a simpler exception handler here as well, for handling front-end exceptions:

我们现在在这里也有一个更简单的异常处理程序,用于处理前端的异常。

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

5.5.网络模块

Finally, here is our reddit-web module.

最后,这里是我们的reddit-web模块。

This module contains resources, security configuration and SpringBootApplication configuration – as follows:

该模块包含资源、安全配置和SpringBootApplication配置–如下。

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

Here is pom.xml:

这里是pom.xml

<project>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>reddit-web</artifactId>
    <name>reddit-web</name>
    <packaging>war</packaging>

    <parent>
        <groupId>org.baeldung</groupId>
        <artifactId>reddit-scheduler</artifactId>
        <version>0.2.0-SNAPSHOT</version>
    </parent>

    <dependencies>
	<dependency>
            <groupId>org.baeldung</groupId>
            <artifactId>reddit-common</artifactId>
            <version>0.2.0-SNAPSHOT</version>
        </dependency>
        
        <dependency>
            <groupId>org.baeldung</groupId>
            <artifactId>reddit-rest</artifactId>
            <version>0.2.0-SNAPSHOT</version>
        </dependency>
        
        <dependency>
            <groupId>org.baeldung</groupId>
            <artifactId>reddit-ui</artifactId>
            <version>0.2.0-SNAPSHOT</version>
        </dependency>
...

Note that this is the only war, deployable module – so the application is well modularized now, but still deployed as a monolith.

请注意,这是唯一的战争、可部署的模块–所以现在的应用程序已经很好地模块化了,但仍然作为一个单体部署。

6. Conclusion

6.结论

We’re close to wrapping up the Reddit case study. It’s been a very cool app built from ground up around a personal need of mine, and it worked out quite well.

我们即将结束Reddit的案例研究。这是一个非常酷的应用,围绕我的个人需求从头开始,而且效果很好。