Reddit應用第六次改進

REST,Spring
Remote
1
05:29 PM · Dec 01 ,2025

1. 概述

在本文中,我們將幾乎完成對 Reddit 應用程序的改進。

2. Command API Security

首先,我們將對命令 API 進行安全加固,以防止用户(除了所有者)篡改資源。

2.1. Configuration

我們將通過啓用使用 @Preauthorize 在配置中,來開始:

@EnableGlobalMethodSecurity(prePostEnabled = true)

2.2. Authorize Commands

接下來,我們將使用 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) {
    ...
}

注意:

  • 我們使用 “#” 來訪問方法參數——就像我們在 #id 中一樣
  • 我們使用 “@” 來訪問 Bean——就像我們在 @resourceSecurityService 中一樣

2.3. Resource Security Service

以下是負責檢查所有權的服務:

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

注意:

  • isPostOwner(): 檢查當前用户是否擁有 ID 為 postIdPost
  • isRssFeedOwner(): 檢查當前用户是否擁有 ID 為 feedIdMyFeed

2.4. Exception Handling

接下來,我們將處理 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

最後,我們將測試我們的命令授權:

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

注意,givenAuth() 實現使用用户 “john”,而 givenAnotherUserAuth() 使用用户 “test”——以便我們可以測試涉及兩個不同用户的複雜場景。

3. 更多重提交選項

接下來,我們將添加一個有趣的選項——在一天或兩天後,將文章重提交到Reddit,而不是立即提交。

我們將首先修改已安排發佈的重提交選項,並分割 timeInterval。 此選項之前承擔着兩個單獨的職責,即:

  • 帖子提交時間和評分檢查時間之間的時間,以及
  • 評分檢查時間和下次提交時間之間的時間

我們不會將這兩個職責分開: checkAfterIntervalsubmitAfterInterval

3.1. 帖子實體

我們將通過刪除以下內容修改帖子和偏好實體:

private int timeInterval;

並添加:

private int checkAfterInterval;

private int submitAfterInterval;

請注意,我們也會對相關的DTO進行相同的操作。

3.2. 調度器

接下來,我們將修改我們的調度器,使其使用新的時間間隔——如下所示:

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

請注意,對於具有提交日期 submissionDate T 和評分檢查時間間隔 checkAfterInterval t1 和提交時間間隔 submitAfterInterval t2 以及嘗試次數 > 1 的已安排帖子,我們將有:

  1. 帖子首次提交於 T
  2. 調度器在 T+t1 時檢查帖子的評分
  3. 假設帖子未達到目標評分,帖子將在 T+t1+t2 時再次提交

4. Extra Checks for the OAuth2 Access Token

接下來,我們將對使用訪問令牌進行額外的檢查。

有時,用户訪問令牌可能已失效,導致應用程序出現意外行為。我們將通過允許用户重新連接其帳户到 Reddit – 從而獲得新的訪問令牌 – 來解決這個問題。

4.1. Reddit Controller

以下是控制器級別的簡單檢查 – isAccessTokenValid():

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

4.2. Reddit 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;
}

這裏發生的事情非常簡單。如果用户已經擁有訪問令牌,我們將嘗試使用簡單的 needsCaptcha 調用來訪問 Reddit API。

如果調用失敗,則當前令牌無效 – 因此我們將重置它。當然,這會導致用户被提示重新連接其帳户到 Reddit。

4.3. Front-end

最後,我們將將其顯示在主頁上:

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

請注意,如果訪問令牌無效,則“Connect to Reddit” 鏈接將顯示給用户。

5. Separation into Multiple Modules

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

5.1. Parent

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:

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

5.2. Common Module

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.

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

Here’s the simple 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>

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

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

The only configuration class in this module is WebApiConfig.

Here’s the 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

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

The configuration classes included are WebFrontendConfig and ThymeleafConfig.

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

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

Here’s the 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

Finally, here is our reddit-web module.

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

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

<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. 結論我們已經接近完成Reddit案例研究。這是一個從零開始構建的非常酷的應用,基於我個人的需求,並且效果相當不錯。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.