全コードはGithubに載せてあります。
コードだけ見たいよという方はこちらをご参照ください。
1. はじめに
1-1. 背景
社内の新人研修の一環で、フロントエンド(Vue)とバックエンド(Spring)をWebAPIで連携するアプリケーションを作成する機会がありました。その際、「どうせならバックエンドAPIに認証機能もつけちゃおう!」ということで、AWSのユーザー認証機能を提供するサービスであるAmazon Cognitoを用いてバックエンドAPIに認証機能を実装することになりました。
しかし、いざ実装してみようと「Cognito SpringSecurity 認証」などで調べてみたものの、出てくるのは以下のようなものがほとんど。
- フロントエンドとバックエンドが一緒になったアプリが前提で、フロントエンド・バックエンド間のIDトークンの連携方法が明記されていない
- ログイン処理のみの解説記事が多く、トークンからユーザ情報を取得する方法が明記されていない
現在主流となっている フロントエンドとバックエンドをAPIで接続して動作するアプリケーションに適用したい場合はどうするんだ? となり、いろいろ調べて実装してみたのでその内容について書きたいと思います。
1-2. この記事で書くこと
今回作成するのは、アプリケーションの業務処理で利用するDB(以降業務DB)に登録されているユーザ情報を取得するバックエンドAPIです。このAPIをCognitoとSpringSecurityの認証機能で保護する方法とその過程で認証初心者である自分がつまずいた部分について書きます。
認証フローは以下のようなものを想定しています。
認証・IDトークン取得までのフロー
認証・IDトークン取得後、再リクエストするまでのフロー
OAuth2.0を拡張した認証プロトコルであるOpenID Connectの公式ドキュメントでは3つの認証手法が定義されていますが、今回はその中でも広く用いられている認可コードフローを用いた手法を採用しています。大まかな流れは以下のようになっています。
- 未認証のフロントエンドからAPIリクエストを送信。未認証のため401エラー&ログインページにリダイレクト(1~8)
- ログインページに入力された情報をもとにCognitoでユーザー認証(9~11)
- 生成した認可コードをクエリパラメータに含めてバックエンドにリクエスト(12)
- バックエンドで認可コードからIDトークンを取得(13~17)
- IDトークンからユーザ情報を取得し、業務DBに登録(18, 19)
- IDトークンと業務処理で利用するユーザID(以降ユーザID)をボディに含めてフロントエンドにレスポンス(20~22)
- 認証済みのフロントエンドからのAPIリクエストに含まれるIDトークンを検証してレスポンス(23~31)
この流れを実現するにあたり、バックエンドで実装すべき処理は上記の4~7の4点です。
- 認可コードからIDトークンを取得する処理
- IDトークンからCognito側で保持されているユーザ情報を取得し、業務DBに格納する処理
- IDトークンとユーザIDをボディに含めてフロントエンドにレスポンスとして返却する処理
- APIリクエストに含まれるIDトークンを検証する処理
またこの実装において、自分がつまずいた部分は以下の3点です。
- なぜIDトークンをバックエンドで取得するのか
- なぜアクセストークンではなく、IDトークンを検証に用いるのか
- IDトークンをどのようにフロントエンドにレスポンスとして返却するのか
本記事では上記4つの処理を中心に認証機能を実現する方法について解説したのちに、3つのつまずき箇所に関して調べたことをまとめたいと思います。
この記事が、フロントエンドとバックエンドをWebAPIで連携するアプリにおけるバックエンドAPIの保護を実装したいと考える人の一助となればうれしいです。
1-3. 想定読者
- フロントエンドとバックエンドをWebAPIで連携するアプリケーションに認証機能を実装する方法をコードレベルで知りたい人
- Cognitoを用いて認証機能を1から実装したい人
- IDトークンからユーザ情報を取得し、業務DBに登録する一連の処理を実装するサンプルが欲しい人
- 初心者が認証機能を実装する上でつまずきやすいポイントを知りたい人
1-4. 前提条件
- SpringBootでAPIサーバを作成したことがある
- SpringJDBCやSpringJPAを用いてDBを操作することができる
- OAuth2やOpenID connectなど認証認可に関する基本的な知識を持つ
2. 事前準備
2-1. 環境・ツール
- Windows11
- IntelliJ IDEA Community Edition: 2024-3
- Java SDK: Amazon Correto 21.0.5
- SpringBoot: 3.4.0
- Maven: 3.9.9
- MySQL: 8.0.40
- Amazon Cognito
- Postman
※今回はフロントエンドを実装しないためPostmanというツールを用いてフロントエンドからのリクエストを再現します
2-2. 依存パッケージ
- Spring Web
- Spring Data JPA
- MySQL Driver
- Spring Boot Dev Tools
- Validation
- OAuth2 Resource Server
- OAuth2 Client
- Spring Security
2-3. プロジェクトの構成
SpringBootプロジェクトの作成方法やMySQLのインストール方法などは割愛します。
以下の記事などを参考に各自作成してください。
プロジェクトの構成は以下のようになっています。
UserControllerには、業務DBに登録されている全ユーザの情報を取得するエンドポイントとユーザIDで指定したユーザの情報のみを返却するエンドポイントが定義されており、Cognitoにてこれらのエンドポイントを保護します。
C:.
├─java
│ └─com
│ └─example
│ └─cognito_qiita
│ │ CognitoQiitaApplication.java
│ │
│ ├─config
│ │ SecurityConfig.java // ここで認証に関するフィルターを設定
│ │
│ ├─controller
│ │ AuthController.java // 検証のために、認証機能で保護していない誰でもアクセスできるエンドポイントを定義
│ │ UserController.java // 業務DBからユーザ情報を取得するエンドポイントを定義。今回認証機能で保護する対象。
│ │
│ ├─dto
│ │ OAuth2LoginResponseDTO.java
│ │ UserDTO.java
│ │
│ ├─entity
│ │ User.java // ユーザ情報(userId, name, email, role)の定義
│ │
│ ├─exception
│ │ UserNotFoundException.java
│ │ UserNotFoundExceptionHandler.java
│ │
│ ├─repository
│ │ UserRepository.java // UserServiceから受け取ったユーザ情報をDBに登録する処理を定義
│ │
│ └─service
│ UserService.java // 取得したユーザ情報をもとに新規ユーザを作成する処理を定義
│
└─resources
│ application-develop.properties // 業務DBのパスやユーザ情報、初期化設定、デバックレベルの定義
│ application.properties // プロファイルの指定
│ application.yaml // Cognitoの設定情報の参照先を記載(値自体はauth-config-develop.properties)
│ data.sql // 初期データ登録用のSQL
│ schema.sql // テーブル作成用のSQL
│
├─static
└─templates
appllication-develop.properties
に設定してあるMySQLに関する以下の環境変数は各自で作成したものを設定してください。
spring.datasource.url=${SPRING_DATASOURCE_URL}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
githubのコードをそのまま用いる場合は、auth-config-develop.properties
をresourceディレクトリ配下に配置する必要があります。
記載する内容に関しては3-2-1をご覧ください。
2-4. 用語の定義
実装に入る前に、出てくる用語についても整理しておきます。
(これがけっこうややこしい、、、けど大事)
用語 | 意味 | 本プロジェクトでは |
---|---|---|
リソース | リソースオーナの情報資源。ユーザに関する情報やLambdaなどのAWS上のサービスを指す。 | Cognitoで新規登録する際に入力した情報(名前、メールアドレス)。ただしCognitoを用いた場合、これらの情報はIDトークンにも含まれている。 |
リソースオーナ | リソースの保有者。リソースオーナの認証はメールアドレス(選択可能)とパスワードで行う。 | Cognitoで新規登録を行った人(=アプリユーザ) |
クライアント(RP:Relying Party) | リソースの使用を依頼する側。クライアントの認証はクライアントIDとクライアントシークレットで行う。 | 自作のアプリケーション全体(フロントエンド+バックエンド) |
認可サーバ | クライアントからリソース利用の依頼が来たときにその可否をリソースオーナに確認する役割を持つサーバ。アクセストークンを発行するサーバ。 | Cognitoサーバ |
認証サーバ(OP:OpenID Provider) | IDトークンを発行するサーバ | Cognitoサーバ |
リソースサーバ | リソースを提供するサーバ | Cognitoサーバ |
認可コード | リソース使用許可が下りたことを示す証。authorizeエンドポイントで発行される。これを用いてアクセストークンやIDトークンを発行できる。 | ログイン認証完了後のページにてクエリパラメータcodeに格納されている値 |
アクセストークン | クライアントからリソースに対してアクセスを行うために必要なトークン。 tokenエンドポイントで発行される。認可コードフローでは、取得のためにクライアントIDとクライアントシークレットが必要。アクセストークンを用いてuserInfoエンドポイントからユーザに関する情報を取得できる。 | 今回取得したいユーザ情報(name, email)はIDトークンから取得できるため、今回は使用しない。 |
IDトークン | ユーザが認証されたことを証明するトークン。 アクセストークンと同じくtokenエンドポイントで発行される。認可コードフローでは、取得のためにクライアントIDとクライアントシークレットが必要。IDトークンにはユーザ情報などを含めることができる。 | フロントエンドにてAuthorizationヘッダに格納され、APIリクエスト毎に検証するトークン |
3. 実装
3-1. Cognitoの設定
Amazon Cognito > ユーザープールに移動し、右上の「ユーザープールを作成」をクリックします。
各項目を以下のように設定して「作成」をクリックします。
- アプリケーション:従来のウェブアプリケーション
- アプリケーションに名前を付ける:{任意の名前}
- サインイン識別子オプション:{任意の項目}(これとパスワードで認証を行うことになります。今回はメールドレスを選択します)
- サインアップのための必須属性:{任意の項目}(今回は業務DBに名前を登録したいのでnameを選択します)
- リターンURLを追加:
http://localhost:8080/login/oauth2/code/cognito
以下のようなページに遷移します。一番下の「概要に移動」をクリックします。
左のタブにある「アプリケーションクライアント」をクリックします。
先ほど付けた名前でアプリケーションクライアントが作成されていることを確認します。
続けてアプリケーションクライアントの名前をクリックします。
このページで後々必要になるクライアントIDやクライアントシークレットの情報が確認できます。
また「ログインページ」タブをクリックするとコールバック(リダイレクト)URLを確認することができます。
たったこれだけで完了です。
3-2. バックエンドアプリの実装
3-2-1. Cognitoのパラメータの設定
先ほど作成したCognitoのパラメータの設定をapplication.yaml
に記載します。
実際の値はauth-config-develop.properties
に記載しています。
(Githubにはないので以下の例を参考に各自で作成してください)
spring:
config:
#実際のパラメータの値はauth-config-develop.propertiesに記載
import: classpath:auth-config-${spring.profiles.active}.properties
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${auth.issuer-uri} // Authorizationヘッダに含まれるJWTの検証に使用
client:
registration:
cognito:
client-id: ${auth.clientId}
client-secret: ${auth.clientSecret}
scope:
- openid
redirect-uri: ${auth.redirect-uri}
provider:
cognito:
issuerUri: ${auth.issuer-uri}
user-name-attribute: username
auth.clientId=${CLIENT_ID} // クライアントID
auth.clientSecret=${CLIENT_SECRET} // クライアントシークレット
auth.jwk-set-uri=${JWK_SET_URI} // トークン署名キーURL
auth.issuer-uri=${ISSUER_URI} // トークン署名キーURLの/.well-known/jwk.jsonを削除したもの
auth.redirect-uri=${REDIRECT_URI} // 許可されているコールバックURL(Cognitoで自分が設定したもの)
application.yamlに記載するこれらの設定値の詳細については、AWSコンソール内のAmazon Cognito>ユーザープール>{ユーザープール名}>アプリケーションクライアント>{アプリケーションクライアント名}のQuickSetupガイドの④やSpringSecurityの公式ドキュメントを参照ください。
3-2-2. SecurityConfigの実装
続いてSecurityConfigに認証に関するコードを記述します。
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private UserService userService;
@Bean
// CORSの設定を行うメソッド
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 許可するオリジン(フロントエンドのURL)を指定
configuration.setAllowedOrigins(Arrays.asList("http://localhost:8080"));
// 許可するHTTPメソッドを指定(GET, POST, PUT, DELETE, OPTIONSを許可)
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// 許可するヘッダ情報を指定(authorizationとcontent-typeを許可)
configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type"));
// CORSの設定情報をURLベースで登録するためのオブジェクトを作成
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 全てのパスに対してCORSの設定を登録
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF対策の設定
http.csrf(Customizer.withDefaults())
// CORSの設定を適用
.cors(c -> c.configurationSource(corsConfigurationSource()))
// セッションをサーバ側で保持しない(ステートレス)ように設定
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// HTTPリクエストの認可設定
.authorizeHttpRequests((authorize) -> authorize
// /auth/で始まるURLへのアクセスを認証なしで許可
.requestMatchers("/auth/**").permitAll()
// OPTIONSメソッド(事前リクエスト)の全てのパスへのアクセスを認証なしで許可
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 上記以外のリクエストは認証を必要とする設定
.anyRequest().authenticated()
)
// OAuth2ログインの設定で、ログイン成功時の処理を指定
.oauth2Login(oauth2 -> oauth2.successHandler(this::handleOAuth2LoginSuccess))
// AuthorizationヘッダのJWTを検証する設定
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
private void handleOAuth2LoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
try {
// 認証に成功したユーザ情報を取得
DefaultOidcUser oidcUser = (DefaultOidcUser) authentication.getPrincipal();
// ユーザのIDトークンを取得
String idToken = oidcUser.getIdToken().getTokenValue();
// クレーム(ユーザ属性)情報を取得
// 基本的にはIDトークンから取得される
// 利用可能な場合、userInfoエンドポイントにもアクセスして追加の情報を取得する
Map<String, Object> attributes = oidcUser.getClaims();
// クレーム情報から名前とメールアドレスを取得
String username = (String) attributes.get("name");
String email = (String) attributes.get("email");
// ユーザ情報を業務DBに保存し、ユーザIDを取得
Long userId = userService.createUser(username, email);
// レスポンスをJSON形式で書き込む
writeJsonResponse(response, idToken, userId);
// HTTPステータスコードを200 OKに設定
response.setStatus(HttpServletResponse.SC_OK);
} catch (Exception e) {
// エラーハンドリング:認証処理が失敗した場合、エラーメッセージをレスポンスとして返却する
// ※本来は例外発生箇所に応じてエラー処理を分けるべきですが、今回は本質ではないため、簡易的なエラーハンドリングを実装しています
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.getWriter().write("{\"error\": \"Authentication failed\"}");
}
}
private void writeJsonResponse(HttpServletResponse response, String idToken, Long userId) throws IOException {
// レスポンスのContent-TypeをJSONに設定
response.setContentType("application/json");
// レスポンスの文字エンコーディングをUTF-8に設定
response.setCharacterEncoding("UTF-8");
// IDトークンとユーザIDをJSON形式の文字列に変換して書き込む
String jsonResponse = String.format("{\"idToken\": \"%s\", \"userId\": %d}", idToken, userId);
response.getWriter().write(jsonResponse);
}
}
これだけで、1-2.で述べた以下4つのバックエンドで実装すべき処理を実現できます。
- 認可コードからIDトークンを取得する処理
- IDトークンからCognito側で保持されているユーザ情報を取得し、業務DBに格納する処理
- IDトークンとユーザIDをボディに含めてフロントエンドにレスポンスとして返却する処理
- APIリクエストに含まれるIDトークンを検証する処理
3-2-2-1. 認可コードからIDトークンを取得する処理
この処理は、http.oauth2Login()
で実装されています。
認証に成功すると、Cognitoはapplication.yaml
のredirect-uri
に認可コードを返却します。
SpringSecurityは受け取った認可コードとクライアントIDやシークレットの情報を用いてtokenエンドポイントにIDトークンを要求します。
(tokenエンドポイントはissuer-uri
からメタデータを取得することによって、自動的に設定されます。)
要求を受け取ったCognitoはこれらの情報を検証してIDトークンを返却します。
SpringSecurityは返却されたIDトークンを検証するためにissuer-uri
に公開鍵を要求します。
そして、取得した公開鍵を用いてIDトークンを検証します。
成功したらIDトークンを含むユーザ情報をauthentication
に格納します。
3-2-2-2. IDトークンからCognito側で保持されているユーザ情報を取得し、業務DBに格納する処理
この処理は、ログイン成功時に呼び出されるように指定したhandleOAuth2LoginSuccessメソッド内の以下の部分で実装しています。
// 認証に成功したユーザ情報を取得
DefaultOidcUser oidcUser = (DefaultOidcUser) authentication.getPrincipal();
// ユーザのIDトークンを取得
String idToken = oidcUser.getIdToken().getTokenValue();
// クレーム(ユーザ属性)情報を取得
// 基本的にはIDトークンから取得される
// 利用可能な場合、userInfoエンドポイントにもアクセスして追加の情報を取得する
Map<String, Object> attributes = oidcUser.getClaims();
// クレーム情報から名前とメールアドレスを取得
String username = (String) attributes.get("name");
String email = (String) attributes.get("email");
// ユーザ情報を業務DBに保存し、ユーザIDを取得
Long userId = userService.createUser(username, email);
oicdUser.getClaims()
でIDトークンに含まれるユーザ情報を取得することができます。
IDトークンは以下のような情報が含まれています。
{
"at_hash": "JzgR8tK8RpcHOKzSoG7gqw",
"sub": "d4f804d8-8091-7032-e46a-d221b9eb7124",
"email_verified": true,
"iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_NDYCvGAQw",
"cognito:username": "d4f804d8-8091-7032-e46a-d221b9eb7124",
"nonce": "WDMfcXXV-p2GEj1SRzFSg_oiqtm4jp6G_XLYxy8p4UM",
"origin_jti": "6430ab3b-d64b-4fef-aa0c-d174c2fa0543",
"aud": "4qjqt92suc24qoqm14u4g9emlo",
"event_id": "87785056-1773-4463-9522-133ad0c5ecd4",
"token_use": "id",
"auth_time": 1733957322,
"name": "k-tsurumaki",
"exp": 1733960922,
"iat": 1733957323,
"jti": "664c8c97-9d86-446b-854e-0d9a48ed15d2",
"email": "{メールアドレス}"
}
この中から、名前とメールアドレスの情報をattributes.get()
で取得します。
そして、これらの情報をもとにuserService.createUser(username, email)
で業務DBにユーザ情報を登録し、登録時に作成されたユーザIDを取得します。
3-2-2-3. IDトークンとユーザIDをボディに含めてフロントエンドにレスポンスとして返却する処理
ブラウザを介してフロントエンドへ返却するレスポンスにIDトークンとユーザ情報(今回はユーザID)を設定する処理は、writeJsonResponse(response, idToken, userId);
の部分で実装されています。
今回はレスポンスボディにIDトークンとユーザIDを格納することで、ブラウザを介してフロントエンドに返却しています。
3-2-2-4. APIリクエストに含まれるIDトークンを検証する処理
この処理は、oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
で実装されています。
リスエストを受け取ったSpringSecurityは、Authorization: Bearer ヘッダからJWTを取り出します。そしてissure-uriにアクセスして公開鍵を取得し、IDトークンを検証します。
検証に成功した場合、リクエスト内容に従って、フロントエンドにデータを返却します。
4. ブラウザとAPI開発ツールを用いた挙動確認
実際にサーバを起動して挙動確認を行っていきます。
起動手順はGithubのREADMEをご覧ください。
確認することは以下4点です。
- ログイン認証ページへの自動リダイレクト
- ブラウザから、認証されていない状態でバックエンドAPIのエンドポイントにアクセスすると、自動でログイン認証ページにリダイレクトする
- IDトークンとユーザ情報の取得
- ログイン認証ページに認証情報を入力してログインすると、指定のURIにリダイレクトされ、IDトークンとユーザ情報(今回はユーザID)が取得できる
- 業務DBへのユーザ情報の登録
- 1-2. 大まかな流れの5.の検証
- Cognito側で登録したユーザ情報をもとに、業務DBにユーザが登録されている
- IDトークンの検証
- 1-2. 大まかな流れの7.の検証
- Postmanを用いて、取得したIDトークンをAuthorizationヘッダに格納してバックエンドAPIのエンドポイントをたたくと、認証に成功して正しいレスポンスが返却される
4-1.ログイン認証ページへの自動リダイレクト
こちらは1-2. 大まかな流れの1.の検証にあたります。
Postmanで、http://localhost:8080/users
にGETリクエストを投げます。
すると、以下のように401 Unauthorizedと表示されます。現状認証されていないためアクセスできません。
ブラウザでも、同様のリクエストに対する挙動を確認します。
http://localhost:8080/users
にアクセスします。
すると、以下のようなCognitoが用意するデフォルトのログイン認証ページにリダイレクトしました。
開発者ツールでステータスコードを確認してみると302となっており、バックエンドからのリダイレクト指示に従ってリダイレクトされていることが確認できます。
4-2. IDトークンとユーザ情報の取得
こちらは1-2. 大まかな流れの4.の検証にあたります。
4_1.のログイン認証画面にてメールアドレスとパスワードを入力します。
ログイン認証に成功すると、以下のようにIDトークンとログインしたユーザのユーザIDを含んだレスポンスが返却されます。
これによりフロントエンド側でユーザIDを取得できるため、ログインしたユーザに応じた個別のページに遷移させることができます。
まだユーザ登録を行っていない場合はCreate an Accountから新規登録を行ってください。
4-3. 業務DBへのユーザ情報の登録
こちらは1-2. 大まかな流れの5.の検証にあたります。
業務DBにも正しい情報が登録されているか確認します。
以下がリクエストを投げる前の業務DBのユーザテーブルです。
そして以下がリクエスト後の業務DBのユーザテーブルです。
このようにCognitoに登録した名前やメールアドレスを取得して業務DBにユーザ登録ができていることがわかります。
4-4. IDトークンの検証
こちらは1-2. 大まかな流れの7.の検証にあたります。
取得したIDトークンを用いて、再度http://localhost:8080/users
がたたけるか確認します。
Postmanの「ヘッダー」タブから以下のような設定を追加して再度リクエストを投げます。
- キー:Authorization
- 値:Bearer {取得したIDトークン}
すると、今度は正しいレスポンスを取得することができます。
SpringSecurityでIDトークンの検証に成功していることがわかります。
また、http://localhost:8080/users/{userId}
をたたくことで、ログインしたユーザの情報のみを取り出すことができます。
これにより、ログインしたユーザに対して個別のサービスを提供することができます。
実際のアプリケーションではこの処理をフロントエンドにて行います。
5. つまずき箇所
最後に、実装にあたってつまずいた箇所とそれについて調べたことを記載します。
5-1. なぜIDトークンをバックエンドで取得するのか
認可コードフローを採用している場合、フロントエンドでクライアントシークレットの情報を保持する必要があり、これはセキュリティ的によくないとされているからです。フロントエンドのソースコードはバックエンドと比較して第三者の解析に弱く、クライアントシークレット漏洩の可能性が高いとされています。また、そもそもクライアントシークレットを用いない手法(インプリシットフロー)も存在します。しかし、この場合アクセストークンやIDトークンがブラウザに渡されるため、これはこれで危険。
詳しくは「コンフィデンシャルクライアントとパブリッククライアント」などで検索いただけるとたくさんヒットするかと思います。自分も参考にさせていただいた記事をいくつか載せておきます。
5-2. なぜアクセストークンではなく、IDトークンを検証に用いるのか
これには本当に悩まされました。
結論、自作アプリのバックエンドAPIの保護ならIDトークンで行うべきです。
これを理解するためには以下3つの前提が重要になります。
- IDトークンは認証を行うもの、アクセストークンは認可を行うもの
- バックエンドサービスはOP(今回でいうCognito)側が提供するリソースサーバではなく、あくまでRPの一部である
- 今回やりたいことは認証であり、バックエンドサービスで認可は行っていない
リソースサーバと聞けばバックエンドサービス、クライアントと聞けばフロントエンドを指していると考えがちですが、用語の定義でも述べた通り、今回実装したようなアプリケーションの場合だとアプリケーション全体がクライアントになります。 また今回の場合、OP(Cognito)側が提供するリソースを用いていない(ユーザ情報はIDトークンからでも取得できる)ためそもそもアクセストークンは必要ありません。あくまでユーザの本人確認を行い、その情報からリクエストを行った本人と業務DBの情報の紐づけを行っているだけであり、Cognitoを用いた認可は今回行っていません。 IDトークンは認証を行うためのトークンなので認証機能を持たないアクセストークンではなくIDトークンを用いるのが自然です。もしユーザごとにたたけるエンドポイントを制限したいとすれば、それはSpringSecurity側でやることです。
一方で、Cognitoの公式サイトには以下のように記載されてます。
The ID token can also be used to authenticate users to your resource servers or server applications.
ここでいうresource serverはバックエンドサービスのことを指しています。 これをCognito側のリソースに対するリソースサーバと誤解してしまい、理解にすごく時間がかかりました。
こちらの記事が非常に参考になります。
(たまたまヒットした記事が自分の会社の先輩の記事でした(笑))
他にも参考にさせていただいた記事を載せておきます。
5-3. IDトークンをどのようにフロントエンドにレスポンスとして返却するのか
これもとても難しい問題だと思います。セキュリティ要件によるというのが答えになるのかなと思います。
バックエンド側で取得したIDトークンをブラウザを介してフロントエンドに返却する方法としては以下3つの方法が考えられます。
- レスポンスボディに含める
- Cookieに含める
- カスタムヘッダに含める
それぞれのメリットデメリットを比較すると以下のようになるかと思います。
返却方法 | メリット | デメリット |
---|---|---|
レスポンスボディに含める | バックエンド側で特別な操作が必要なくシンプル。 JSONで送信できるのでフロント側での処理が簡単。今回のようにユーザ情報などの他の情報と一緒に送信することができる。取得したIDトークンをAuthorizationヘッダに格納することでRFC6750で推奨されている標準的な認証が実現できる。 | 中継時にIDトークンが漏洩する可能性がある。結局取得したIDトークンをどこに保存するのか問題が発生する。(XSSのリスクが大きい) |
HTTPOnly Cookieに含める | XSSのリスクが最も低い。取得したIDトークンの保持も容易。セッションベースの認証を行う場合、フロントエンド側の処理がなくなる。 | JavaScriptからCookieにアクセスできないので、Authorizationヘッダを用いた認証ができない。 完全にステートレスとはいえず、RESTfulAPIを実現する場合は不向き。Cookieの情報は自動送信されるためCSRFの危険性が高まる。(SameSite属性やCSRFトークンの設定が必要) |
カスタムヘッダに含める | ヘッダ名を自由にカスタマイズできるため、他の情報と混同しずらい。HTTPSを前提とすれば途中で盗まれるリスクは低い。取得したIDトークンをAuthorizationヘッダに格納することでRFC6750で推奨されている標準的な認証が実現できる。 | レスポンスボディ同様、結局取得したIDトークンをどこに保存するのか問題が発生する。(XSSのリスクが大きい) |
レスポンスボディorカスタムヘッダで返却する方法とHTTPSOnly Cookieで返却する方法で大きく違うのはAuthorizationヘッダにトークンを格納することを推奨するOAuth2.0やJWTの仕様に準拠できるかということです。前者の場合、JSでIDトークンを操作できるため取得したIDトークンをAuthorizationヘッダに格納することができます。標準的な仕様に準拠しているため実装は簡単ですが、これはXSSのリスクが高いことも意味します。一方、後者の場合だと、JSでIDトークンを操作できない分XSSのリスクは低いですが、標準的な手法に準拠していない分実装が大変だったりします。またCookieは自動で送信されるため、CSRF対策も必要です。
なぜこのような問題が起きるのでしょうか。それはOAuth2.0の仕様(RFC6749, RFC6750)やJWTの仕様(RFC7519)でクライアント側でのトークンの保存方法について言及されていないからです。トークンの保存方法は開発者に委ねられています。XSS対策を重視するならHTTPOnly Cookieを用いる、実装の容易さを重視するならレスポンスボディやカスタムヘッダを用いるといったように、各自が要件に合わせて検討する必要があります。
(今回は一番シンプルなリクエストボディで返却する方法を採用しました。)
6. あとがき
本記事ではCognitoとSpringSecurityを用いて、自作アプリのバックエンドAPIを保護する方法と、その過程で自分がつまずいた点についてアウトプットしてみました。
認証の「に」も知らない状態からスタートしたため、仕組みの理解や実装に時間がかかってしまいましたが、とても勉強になりました!
今後はDocker環境やAWS環境でも実装してみたいと思います!
まだまだ理解が浅く、間違ったことを書いている可能性もあると思います。
その際はぜひコメントいただけますと幸いです!
最後まで見ていただきましてありがとうございました!