Redditアプリケーションの第6ラウンドの改善
1. 概要
この記事では、Redditアプリケーションの改善点をほぼまとめます。
2. コマンドAPIセキュリティ
まず、所有者以外のユーザーによるリソースの操作を防ぐために、コマンドAPIを保護するための作業を行います。
2.1. 構成
構成で@Preauthorizeの使用を有効にすることから始めます。
@EnableGlobalMethodSecurity(prePostEnabled = true)
2.2. コマンドを承認する
次に、いくつかのSpringセキュリティ式を使用して、コントローラーレイヤーでコマンドを承認しましょう。
@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 で行ったように、メソッド引数にアクセスするために「#」を使用しています
- @resourceSecurityService で行ったように、beanにアクセスするために「@」を使用しています
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():現在のユーザーが指定されたpostIdで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);
}
}
giveAuth()実装がユーザー「john」を使用しているのに対し、 giveAnotherUserAuth()はユーザー「test」を使用していることに注意してください。これにより、以下を含むこれらの複雑なシナリオをテストできます。 2人の異なるユーザー。
3. その他の再送信オプション
次に、興味深いオプションを追加します–1日か2日後にRedditに記事を再送信します。
スケジュールされた再送信後のオプションを変更することから始め、timeIntervalを分割します。 これには、2つの別々の責任がありました。 そうだった:
- 投稿後の提出からスコアチェックまでの時間と
- スコアチェックから次の提出時間までの時間
checkAfterIntervalとsubmitAfterIntervalの2つの責任を分離しません。
3.1. 投稿エンティティ
以下を削除して、PostエンティティとPreferenceエンティティの両方を変更します。
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))
...
}
subjectDate TおよびcheckAfterInterval t1およびsubmitAfterInterval t2および試行回数>1で、次のようになります。
- 投稿はTで初めて送信されます
- スケジューラーはT+t1で投稿スコアをチェックします
- 投稿が目標スコアに到達しなかったとすると、投稿は T + t1 +t2で2回目に送信されます。
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呼び出しを使用してRedditAPIに到達しようとします。
呼び出しが失敗した場合、現在のトークンは無効であるため、リセットします。 そしてもちろん、これにより、ユーザーは自分のアカウントを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. 複数のモジュールへの分離
次に、アプリケーションをモジュールに分割します。 reddit-common 、 reddit-rest 、 reddit-ui 、reddit-webの4つのモジュールを使用します。
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です。
サーバーコンテキストではなくリソースクラスパスからテンプレートをロードするようにThymeleaf構成を変更する必要があります。
@Bean
public TemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/");
templateResolver.setSuffix(".html");
templateResolver.setCacheable(false);
return templateResolver;
}
単純な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>
...
フロントエンドの例外を処理するための、より単純な例外ハンドラーもここにあります。
@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のケーススタディの締めくくりに近づいています。 それは私の個人的なニーズを中心にゼロから構築された非常にクールなアプリであり、非常にうまく機能しました。