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(): 檢查當前用户是否擁有指定 feedId 的 MyFeed
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 進行分割。 此項原本承擔着兩個單獨的職責,即:
- 帖子提交與評分檢查之間的間隔時間,
- 評分檢查與下次提交之間的間隔時間
我們將不分離這兩個職責:checkAfterInterval 和 submitAfterInterval.
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,則會發生以下情況:
- 帖子首次提交於時間 T
- 調度器在時間 T+t1 檢查帖子得分
- 假設帖子未達到目標得分,則帖子將在時間 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-common、reddit-rest、reddit-ui 和 reddit-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 相關的資源。它還包含持久化和集成測試。
該模塊中的配置類包括 CommonConfig、PersistenceJpaConfig、RedditConfig、ServiceConfig、WebGeneralConfig。
以下是簡單的 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 控制器。
包含的配置類包括 WebFrontendConfig 和 ThymeleafConfig。
我們需要修改 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 案例研究已經很接近。這是一個從零開始構建的非常酷的應用,基於我個人的需求,並且效果相當不錯。