Redditアプリケーションの6回目の改良

1概要

この記事では、リンクの改善点をまとめて説明します:/case-study-a-reddit-app-with-spring[Reddit application]。

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 を所有しているかどうかをチェック

postId ** isRssFeedOwner() :現在のユーザーが与えられた MyFeed を所有しているか確認

feedId

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

givenAnotherUserAuth() がユーザー「test」を使用している間、 givenAuth() __実装がユーザー「john」を使用していることに注意してください。これで、2人の異なるユーザーが関与する複雑なシナリオをテストできます。

3その他の再送信オプション

次に、興味深いオプションを追加します。 1日か2日後にRedditに記事を再送信する ということです。

予定されている投稿の再送信オプションを変更することから始めます。これには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))
    ...
}

submissionDate T checkAfterInterval t1 および submitAfterInterval t2 と試行回数> 1のスケジュールされた投稿の場合、次のようになります。

  1. 投稿は T で初めて送信されます

  2. スケジューラは T t1 で投稿スコアをチェックします

  3. 投稿が目標スコアに達していないと仮定すると、投稿は

2回目は T t 1 t 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 呼び出しを使用して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>

アクセストークンが無効な場合、「Connect to 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のケーススタディをまとめてみましょう。私の個人的なニーズに基づいてゼロから構築された、とてもクールなアプリでした。

前の投稿:EnumSetガイド
次の投稿:空手でのREST APIテスト