知識庫 / Spring RSS 訂閱

Reddit應用第六次改進

REST,Spring
HongKong
4
03:59 AM · Dec 06 ,2025

1. 概述

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

2. 命令 API 安全

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

2.1. 配置

我們將首先啓用在配置中使用 @Preauthorize 的功能:

@EnableGlobalMethodSecurity(prePostEnabled = true)

2.2. 授權命令

接下來,我們將使用 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 資源安全服務

以下是負責檢查所有權驗證的服務的工作方式:

@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(): 檢查當前用户是否擁有指定 Post ID 的 Post
  • isRssFeedOwner(): 檢查當前用户是否擁有指定 feedIdMyFeed

2.4. 異常處理

接下來,我們將簡單地處理 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. 授權測試

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

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,以及 checkAfterIntervalt1submitAfterIntervalt2,並且嘗試次數大於 1,則會發生以下情況:

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

4. OAuth2 訪問令牌的額外檢查

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

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

4.1. Reddit 控制器

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

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

4.2. Reddit 服務

以下是服務級別實現:

@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. 前端

最後,我們將展示它在主頁上:

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

如果訪問令牌無效,則“連接到 Reddit”鏈接將顯示給用户。

5. 分解為多個模塊

接下來,我們將應用程序分解為多個模塊。我們將採用 4 個模塊:reddit-commonreddit-restreddit-uireddit-web

5.1. 父模塊

首先,我們來介紹父模塊,它包含了所有子模塊。

父模塊 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>

所有屬性和依賴版本將在此處聲明,在父級 pom.xml 中 – 用於所有子模塊。

5.2. 常用模塊

現在,我們來談談我們的 reddit-common 模塊。該模塊將包含持久化、服務和與 Reddit 相關的資源。它還包含持久化和集成測試。

該模塊中的配置類包括 CommonConfigPersistenceJpaConfigRedditConfigServiceConfigWebGeneralConfig

以下是簡單的 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 模塊

我們的 reddit-rest 模塊包含 REST 控制器和 DTO。

該模塊中唯一的配置類是 WebApiConfig

以下是 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>
    ...

本模塊包含所有異常處理邏輯。

5.4. UI 模塊

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

包含的配置類包括 WebFrontendConfigThymeleafConfig

我們需要修改 ThymeleafConfig 配置,以便從資源類路徑加載模板,而不是從 Server 根目錄。

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

這是簡單的

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

我們現在也添加了一個更簡單的異常處理程序,用於處理前端異常:

@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 模塊

以下是我們提供的 reddit-web 模塊。

該模塊包含資源、安全配置和 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);
    }
}

以下是 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>
...

請注意,這只是唯一的戰爭模塊——因此應用程序現在已經很好地模塊化了,但仍然作為單體應用進行部署。

6. 結論

我們距離完成 Reddit 案例研究已經很接近。這是一個從零開始構建的非常酷的應用,基於我個人的需求,並且效果相當不錯。

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

發佈 評論

Some HTML is okay.