Spring BootでOAuthログインとAPIへのアクセス制限を実現する


Spring BootでOAuthログインを実現するには、Spring Security OAuthを利用するわけですが、
あまりドキュメントがなかったので備忘録を兼ねてメモっておきます。

とはいえ、大体は以下のページに書いてあるとおりですが。

https://spring.io/guides/tutorials/spring-boot-oauth2/

この記事では、まずOAuthでのログインを実現した後、AngularJSなどのアプリケーションから利用される想定のAPIを作成し、そのAPIにユーザー権限によるアクセス制限を掛けるまでの手順を記載します。

要するに、動的画面遷移を中心に組み立てられたSPAっぽいアプリケーションで、ログインと認可を実現する方法をメモっておきます。

## Spring Bootアプリケーションの作成

Spring Bootアプリケーションの作成には幾つか選択肢がありますが、ここではある程度スクラッチで作ることにします。
実際は、Webページ上から雛形プロジェクトをサクッと作成してダウンロードすることも可能です。

https://start.spring.io/

まずは、Bootアプリケーションのビルドや実行に利用するビルドスクリプトです。

`build.gradle`をプロジェクトディレクトリ直下に用意します。

```groovy:build.gradle
buildscript {
    ext {
        springBootVersion = '1.3.2.RELEASE' // Spring Bootのバージョン
    }
    repositories {
        mavenCentral() // Gradle Spring Bootプラグインを取得するリポジトリ(ここではMavenセントラルリポジトリ)
    }
    dependencies {
        // Gradle Spring Bootプラグイン
        classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}"
    }
}

// ビルドに必要なライブラリの読み込み
apply plugin: 'java'
apply plugin: 'spring-boot'

sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
    mavenCentral() // アプリケーションを実行するのに必要なライブラリの取得先リポジトリ
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('org.springframework.security.oauth:spring-security-oauth2')
    compile("org.springframework.boot:spring-boot-devtools") //コード更新時にアプリケーションをリロードする
}

bootRun {
    jvmArgs = ['-Dspring.output.ansi.enabled=always'] //コンソールに色を付ける
}
```

次に、Bootアプリケーションのメインクラスを作成します。

```java:src/main/java/com/example/SsoSampleApplication.java
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SsoSampleApplication {
    public static void main(String[] args) {
        SpringApplication.run(SsoSampleApplication.class, args);
    }
}
```

最後に、画面表示用のhtmlを用意します。
BootではJSPやThymeleafなどのテンプレートを利用することも簡単に出来ますが、
今回の主題と関係ないのでペラのHTMLを表示するだけにします。

Spring Bootでは、クラスパス上の`static`や`public`ディレクトリ以下のファイルをホストできるので、
`src/main/resources/public`以下に`index.html`を配置し、クラスパス上に`index.html`がコピーされるようにします。

```html:src/main/resources/public/index.html
<!DOCTYPE html>
<html>
  <head>
    <title>SSO Sample</title>
  </head>
  <body>
    <p>SSO Sample</p>
  </body>
</html>
```

以上三つのファイルを作成したら、以下のコマンドを実行してアプリケーションを起動します。

```
gradle bootRun
```

gradleコマンドをインストールする[^1]必要があるので、まだ入っていない場合は[Gradleのページ](http://gradle.org/download)からダウンロードしてインストールしてください。

[^1]: MacやLinuxなら [sdkman](http://sdkman.io/) を利用するのが便利です。

http://localhost:8080/ で画面が表示されることを確認します。

bootアプリケーションはSpringSecurityがクラスパスに入っているとデフォルトでアプリケーションにBASIC認証をかけます。
なので、 http://localhost:8080/ に行くとユーザー認証ダイアログが表示されます。

デフォルトのユーザー名は`user`、パスワードはgradleコマンドを実行したコンソールに表示されているので、そのとおり入力します。

次の節から、このBASIC認証をログインリンクからのOAuth2認証に変えていきます。

## OAuth2でログインする設定

まず、BASIC認証をOAuth2でのログインに変更します。OAuth2プロバイダーとして今回は[Slack](https://slack.com/)を利用します。Facebookとかでも基本的にやることはあまり変わりません。

### OAuthアプリケーションの登録
どのプロバイダーを利用するにしても、まずクライアントアプリケーションを登録する必要がありますね。
Slackの場合は下記ページの「Create a new application」からアプリケーションを登録できます。

https://api.slack.com/applications

AppNameやらは適当に良い感じの名前を入力してもらうとして、
Redirect URI(s)に http://localhost:8080 を入れておいてください。

登録すると発行される「Client ID」や「Client Secret」は後ほど使用します。

### Bootアプリケーションの設定
BootアプリケーションにSpring Security OAuthでOAuthコンシューマー[^2]の設定を追加します。
それには、まず設定用のJavaクラスを`@EnableOAuth2Sso`([JavaDoc](http://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/autoconfigure/security/oauth2/client/EnableOAuth2Sso.html))で注釈します。

[^2]: 今回は利用しませんが、「Spring Security OAuth」では上記のようなOAuthコンシューマーだけでなく、本記事におけるSlackのようなOAuthプロバイダーを実装することもできます。

```java:src/main/java/com/example/SsoSampleApplication.java
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;

@SpringBootApplication
@EnableOAuth2Sso // これを追加する
public class SsoSampleApplication {
    public static void main(String[] args) {
        SpringApplication.run(SsoSampleApplication.class, args);
    }
}
```

このアノテーションにより、OAuth2クライアントの設定(`@EnableOAuth2Client`([JavaDoc](https://github.com/spring-projects/spring-security-oauth/blob/master/spring-security-oauth2/src/main/java/org/springframework/security/oauth2/config/annotation/web/configuration/EnableOAuth2Client.java)))とそのクライアントを利用した認証処理(`OAuth2SsoDefaultConfiguration`([JavaDoc](http://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/autoconfigure/security/oauth2/client/OAuth2SsoDefaultConfiguration.html)))がBootアプリケーションに組み込まれます。

`@EnableOAuth2Sso`がやっていることを自分で実装することで認証処理をカスタマイズする方法が以下に紹介されています。

https://spring.io/guides/tutorials/spring-boot-oauth2/#_social_login_manual

`@EnableOAuth2Sso`は、Springのアプリケーション設定からOAuth2の設定を読み込んで利用します[^3]。
クラスパス上に`application.properties`か`application.yml`を作成して、利用するOAuthプロバイダー(今回はSlack)の設定を記載します。

[^3]: OAuth2SsoProperties([JavaDoc](http://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/autoconfigure/security/oauth2/client/OAuth2SsoProperties.html)) 参照

プロパティファイルよりYAMLのほうが読みやすいと思うので、今回は`application.yml`で。

```yaml:src/main/resources/application.yml
security:
  oauth2:
    client:
      clientId: 'xxxx.xxxx' # Slackのアプリケーション登録で表示された「Client ID」
      clientSecret: 'xxxxx' # Slackのアプリケーション登録で表示された「Client Secret」
      accessTokenUri: 'https://slack.com/api/oauth.access' # Slackを利用する場合の設定値
      userAuthorizationUri: 'https://slack.com/oauth/authorize' # Slackを利用する場合の設定値
      authenticationScheme: 'query' # Slackを利用する場合の設定値
      scope: 'identify' # Slackを利用する場合の設定値
      tokenName: 'token' # Slackを利用する場合の設定値
    resource:
      userInfoUri: 'https://slack.com/api/auth.test' # Slackを利用する場合の設定値
```

Bootアプリケーションを再起動してトップページ( http://localhost:8080/ )にアクセスすると
Slackのページに飛ばされ、OAuthログインが可能になっているはずです。

## ログインリンクの作成

今の状態だと、トップページを表示したとたんにログインが要求されますが、これをログインボタンを押すことでログイン要求されるように変更してみます。

まず、html上にログインリンクを作成します。

```html:src/main/resources/public/index.html
<!DOCTYPE html>
<html>
  <head>
    <title>SSO Sample</title>
  </head>
  <body>
    <p>SSO Sample <a href="/login">ログイン</a></p>
  </body>
</html>
```

`/login`はSpring Bootが用意している特別なパスで、特に何も設定しなくてもここにアクセスすればログイン処理が開始されます[^4]。

[^4]: このパスは、`application.yml`で`security.oauth.sso.login-path`に`/sessions/new`などと設定することで変更できます。SpringSecurityの`form-login`などでは設定できないようです。

`/login`に対応する画面を作成する必要はありません。
`/login`に移動するとOAuthプロバイダーのログイン画面へリダイレクトされ、ログイン後はSlackに設定したRedirect URI(s)に従い`/login`に遷移する前のページにリダイレクトされます。

とはいえ、このままだと`/login`リンクを踏む前のトップページに行っただけでログインが要求されてしまうので、Spring Securityの設定を変更して、ログインなしでトップページにアクセスできるようにします。

```java:src/main/java/com/example/SsoSampleApplication.java
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@SpringBootApplication
@EnableOAuth2Sso
public class SsoSampleApplication  extends WebSecurityConfigurerAdapter {
    public static void main(String[] args) {
        SpringApplication.run(SsoSampleApplication.class, args);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated();
    }
}
```

これでトップページがログインなしで表示され、「ログイン」リンクを踏むとSlackの認証ページに飛んでログインできるようになります。
また、ログイン情報はセッションに保存されるので、一度ログインすれば再度ログインボタンを押してもすぐにトップページに返ってきます。


## ログアウト機能の作成

ログインはできても、今のままだとログアウトができませんね。ログアウト機能も作っておきます。

ログアウト機能を追加するには、SpringSecurityの設定でセッションのログイン情報を破棄するURLパスを設定します。


```java:src/main/java/com/example/SsoSampleApplication.java
@SpringBootApplication
@EnableOAuth2Sso
public class SsoSampleApplication  extends WebSecurityConfigurerAdapter {
    public static void main(String[] args) {
        SpringApplication.run(SsoSampleApplication.class, args);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated()
                .and().logout().logoutSuccessUrl("/").permitAll(); // ログアウト機能の設定
    }
}
```

ログアウト機能は、SpringSecurityのHttpSecurity設定で`logout()`を呼び出すことで設定できます。デフォルトでは、`/logout`にPOSTのリクエストを送ることでログイン情報が破棄され、ログアウトできるようになります。

また、SpringSecurityではデフォルトでCSRF対策が有効になっており、POSTリクエストを正常に送るにはCSRFトークンを送信する必要があります。CSRF対策は実際には必須ですが、この記事では少し趣旨から外れるので無効化してしまいます。

```java:src/main/java/com/example/SsoSampleApplication.java
@SpringBootApplication
@EnableOAuth2Sso
public class SsoSampleApplication  extends WebSecurityConfigurerAdapter {
    public static void main(String[] args) {
        SpringApplication.run(SsoSampleApplication.class, args);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable() // CSRF対策を無効化
                .authorizeRequests()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated()
                .and().logout().logoutSuccessUrl("/").permitAll();
    }
}
```

さらに、画面のHTMLにログアウトボタンを追加します。

```html:src/main/resources/public/index.html
<!DOCTYPE html>
<html>
  <head>
    <title>SSO Sample</title>
  </head>
  <body>
    <p>SSO Sample <a href="/login">ログイン</a></p>
    <form id="logoutForm" action="/logout" method="POST">
      <input type="submit" value="ログアウト" />
    </form>
  </body>
</html>
```

トップページのログアウトボタンを押すことでログアウトされ、再度「ログイン」リンクを踏んだときにSlackへリダイレクトされるようになることを確認します。

## APIの実装
さて、これで認証機能が実装されたので、簡単なAPIを実装して、現在のログイン状態に応じてレスポンスを変更できるようにしてみます。

まず、APIの実装です。以下のように、RestAPI用のコントローラーを追加します。

```java:src/main/java/com/example/HelloController.java
package com.example;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
public class HelloController {

    @RequestMapping("/api/hello")
    public Map<String, String> getMessage() {
        Map<String, String> response = new HashMap<>();
        response.put("message", "Hello!");
        return response;
    }

}
```


APIは`/api`以下のURLに実装することにします。このURLは認証なしで利用できるようにします。

```java:src/main/java/com/example/SsoSampleApplication.java
@SpringBootApplication
@EnableOAuth2Sso
public class SsoSampleApplication  extends WebSecurityConfigurerAdapter {
    public static void main(String[] args) {
        SpringApplication.run(SsoSampleApplication.class, args);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/", "/api/**").permitAll() // /api以下のパスを認証なしで利用できるようにする
	            .anyRequest().authenticated()
                .and().logout().logoutSuccessUrl("/").permitAll();
    }
}
```

http://localhost:8080/api/hello にアクセスして、JSONが表示されることを確認します。

```json
{"message": "Hello"}
```

また、画面にこのメッセージが表示されるようにしておきましょう。

```html:src/main/resources/public/index.html
<!DOCTYPE html>
<html>
  <head>
    <title>SSO Sample</title>
  </head>
  <body>
    <p>SSO Sample <a href="/login">ログイン</a></p>
    <form id="logoutForm" action="/logout" method="POST">
      <input type="submit" value="ログアウト" />
    </form>
    <p id="message"></p>
  </body>
  <!-- 以下を追加 -->
  <script src="//code.jquery.com/jquery-2.2.0.js"></script>
  <script>
    $.get('/api/hello').done(function(data){
      $("#message").html(data.message);
    }).fail(function(data){
      $("#message").html(data.responseJSON.message);
    });
  </script>
</html>
```

画面に「Hello!」というメッセージが表示されることを確認します。


## APIのアクセス制限

さて、作成した`/api/hello`にアクセス制限を掛けてみます。[Spring SecurityはサーブレットAPIと統合されている](http://docs.spring.io/spring-security/site/docs/current/reference/html/servletapi.html)ので、サーブレットAPIを通して現在の認証状態を取得できます。

`HelloController`を以下のように修正して、現在のログイン状態に応じて異なるレスポンスを返すようにします。

```java:src/main/java/com/example/HelloController.java
package com.example;

import org.springframework.http.HttpStatus;
import org.springframework.security.oauth2.common.exceptions.UnauthorizedUserException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@RestController
public class HelloController {

    @RequestMapping("/api/hello")
    public Map<String, String> getMessage(HttpServletRequest request) {
        if (request.isUserInRole("ROLE_USER")) {
            Map<String, String> response = new HashMap<>();
            response.put("message", "Hello, " + request.getRemoteUser() + "!!!");
            return response;
        }
        throw new UnauthorizedUserException("You don't have a required role. ");
    }

    @ExceptionHandler(UnauthorizedUserException.class)
    public void unauthorized(HttpServletResponse response) throws IOException {
        response.sendError(HttpStatus.UNAUTHORIZED.value());
    }

}
```

このように、`HttpServletRequest`から現在ログイン中のユーザー名や割り当てられたロールを取得することができます。

トップページ( http://localhost:8080/ )にアクセスして、ログイン前後で画面の表示が変わることを確認します。

## まとめ

一般的なログインフローなら、足回りの機能をほとんどSpring Bootがやってくれるのでかなり楽ですね。

もちろん、ログインユーザーに付加的な情報を追加したり、ロールを調整する場合は追加の実装が必要になりますが、それほど手間でもなさそうです。

以下のリンクを参照してください。

https://spring.io/guides/tutorials/spring-boot-oauth2/#_how_to_add_a_local_user_database
https://spring.io/guides/tutorials/spring-boot-oauth2/#_generating_a_401_in_the_server