JavaとSpringセキュリティを使用したJWTによるRESTセキュリティ

公開: 2022-03-11

安全

セキュリティは利便性の敵であり、その逆も同様です。 この声明は、物理的な家の入り口からWebバンキングプラットフォームまで、仮想または現実のすべてのシステムに当てはまります。 エンジニアは常に、どちらかの側に寄りかかって、特定のユースケースに適したバランスを見つけようとしています。 通常、新しい脅威が発生すると、セキュリティに移行し、利便性から離れます。 次に、セキュリティを大幅に低下させることなく、失われた利便性を回復できるかどうかを確認します。 さらに、この悪循環は永遠に続きます。

春のセキュリティチュートリアル:セキュリティと利便性の図

セキュリティは利便性の敵であり、その逆も同様です。
つぶやき

簡単なSpringセキュリティチュートリアルを使用してRESTセキュリティの実際の動作を示すことにより、今日のRESTセキュリティの状態を調べてみましょう。

REST(Representational State Transferの略)サービスは、サービスを記述するためのWSDLやメッセージ形式を指定するためのSOAPなど、膨大な仕様と面倒な形式を持つWebサービスへの非常に単純化されたアプローチとして始まりました。 RESTには、それらはありません。 RESTサービスをプレーンテキストファイルで記述し、JSON、XML、さらにはプレーンテキストなどの任意のメッセージ形式を使用できます。 簡略化されたアプローチは、RESTサービスのセキュリティにも適用されました。 定義された標準は、ユーザーを認証するための特定の方法を課していません。

RESTサービスにはあまり指定されていませんが、重要なのは状態の欠如です。 これは、サーバーがクライアントの状態を保持しないことを意味します。良い例としてセッションがあります。 したがって、サーバーは、クライアントが最初に行ったものであるかのように、各要求に応答します。 ただし、現在でも、多くの実装では、標準のWebサイトアーキテクチャ設計から継承されたCookieベースの認証が使用されています。 RESTのステートレスアプローチは、セキュリティの観点からセッションCookieを不適切にしますが、それでもなお広く使用されています。 必要なステートレス性を無視することに加えて、単純化されたアプローチは、予想されるセキュリティのトレードオフとしてもたらされました。 Webサービスに使用されるWS-Security標準と比較すると、RESTサービスの作成と利用がはるかに簡単であるため、利便性が大幅に向上しました。 トレードオフはかなりスリムなセキュリティです。 セッションハイジャックとクロスサイトリクエストフォージェリ(XSRF)は、最も一般的なセキュリティの問題です。

サーバーからクライアントセッションを削除しようとする際に、基本またはダイジェストHTTP認証など、他のいくつかの方法が使用されることがあります。 どちらもAuthorizationヘッダーを使用して、エンコード(HTTP Basic)または暗号化(HTTP Digest)が追加されたユーザー資格情報を送信します。 もちろん、Webサイトに見られるのと同じ欠陥がありました。ユーザー名とパスワードは簡単に元に戻せるbase64エンコーディングで送信されるため、HTTPS BasicをHTTPS経由で使用する必要があり、HTTP Digestは、安全でないことが証明されている廃止されたMD5ハッシュの使用を強制しました。

最後に、一部の実装では、任意のトークンを使用してクライアントを認証しました。 このオプションは、今のところ、私たちが持っている最高のもののようです。 適切に実装すると、HTTP Basic、HTTP Digest、またはセッションCookieのすべてのセキュリティ問題が修正され、使いやすく、ステートレスパターンに従います。

ただし、このような任意のトークンでは、関連する標準はほとんどありません。 すべてのサービスプロバイダーは、トークンに何を入れるか、トークンをエンコードまたは暗号化する方法について自分の考えを持っていました。 使用する特定のトークン形式に適応するためだけに、さまざまなプロバイダーのサービスを利用するには、追加のセットアップ時間が必要でした。 一方、他の方法(セッションCookie、HTTP Basic、HTTP Digest)は開発者によく知られており、すべてのデバイスのほとんどすべてのブラウザーがそのまま使用できます。 フレームワークと言語はこれらのメソッドに対応しており、それぞれをシームレスに処理するための組み込み関数を備えています。

JWT認証

JWT(JSON Web Tokenから短縮)は、RESTサービスだけでなく、一般的にWeb上で認証するためにトークンを使用するための欠落している標準化です。 現在、RFC 7519としてドラフト状態になっています。堅牢で多くの情報を運ぶことができますが、サイズが比較的小さい場合でも簡単に使用できます。 他のトークンと同様に、JWTを使用して、認証されたユーザーのIDをIDプロバイダーとサービスプロバイダー(必ずしも同じシステムである必要はありません)の間で渡すことができます。 また、認証データなど、すべてのユーザーの要求を伝達できるため、サービスプロバイダーは、データベースや外部システムにアクセスして、各要求のユーザーロールとアクセス許可を確認する必要がありません。 そのデータはトークンから抽出されます。

JWTセキュリティが機能するように設計されている方法は次のとおりです。

JWTjavaフローの図

  • クライアントは、IDプロバイダーに資格情報を送信してログインします。
  • IDプロバイダーは資格情報を確認します。 すべてOKの場合、ユーザーデータを取得し、サービスへのアクセスに使用されるユーザーの詳細とアクセス許可を含むJWTを生成し、JWTの有効期限を設定します(無制限の場合があります)。
  • IDプロバイダーは署名し、必要に応じてJWTを暗号化し、資格情報を使用した最初のリクエストへの応答としてクライアントに送信します。
  • クライアントは、IDプロバイダーによって設定された有効期限に応じて、限られた時間または無制限の期間JWTを保存します。
  • クライアントは、サービスプロバイダーへのすべてのリクエストに対してAuthorizationヘッダーで保存されたJWTを送信します。
  • リクエストごとに、サービスプロバイダーはAuthorizationヘッダーからJWTを取得し、必要に応じて復号化し、署名を検証し、すべてがOKの場合は、ユーザーデータとアクセス許可を抽出します。 このデータのみに基づいて、データベースで詳細を検索したり、IDプロバイダーに連絡したりすることなく、クライアントの要求を受け入れるか拒否することができます。 唯一の要件は、IDとサービスプロバイダーが暗号化について合意していることです。これにより、サービスは署名を検証したり、暗号化されたIDを復号化したりすることができます。

このフローにより、物事を安全に保ち、開発を容易にしながら、優れた柔軟性が得られます。 このアプローチを使用することにより、サービスプロバイダークラスターに新しいサーバーノードを簡単に追加し、共有秘密キーを提供することで署名を検証し、トークンを復号化する機能のみでノードを初期化できます。 セッションレプリケーション、データベース同期、またはノード間通信は必要ありません。 その栄光の中でREST。

JWTと他の任意のトークンの主な違いは、トークンのコンテンツの標準化です。 もう1つの推奨されるアプローチは、Bearerスキームを使用してAuthorizationヘッダーでJWTトークンを送信することです。 ヘッダーの内容は次のようになります。

 Authorization: Bearer <token>

RESTセキュリティの実装

RESTサービスが期待どおりに機能するには、従来の複数ページのWebサイトとは少し異なる認証アプローチが必要です。

クライアントがセキュリティで保護されたリソースをリクエストしたときにログインページにリダイレクトして認証プロセスをトリガーする代わりに、RESTサーバーはリクエスト自体で利用可能なデータ(この場合はJWTトークン)を使用してすべてのリクエストを認証します。 そのような認証が失敗した場合、リダイレクトは意味がありません。 REST APIはHTTPコード401(無許可)応答を送信するだけであり、クライアントは何をすべきかを知っている必要があります。 たとえば、ブラウザは動的なdivを表示して、ユーザーがユーザー名とパスワードを指定できるようにします。

一方、従来の複数ページのWebサイトで認証が成功すると、ユーザーはHTTPコード301(永続的に移動)を使用してリダイレクトされます。通常はホームページにリダイレクトされます。さらに、ユーザーが最初に要求したページにリダイレクトされます。認証プロセス。 RESTの場合も、これは意味がありません。 代わりに、リソースがまったく保護されていないかのようにリクエストの実行を続行し、HTTPコード200(OK)と期待される応答本文を返します。

春のセキュリティの例

JWTとJavaを使用したSpringRESTセキュリティ

それでは、JavaとSpringを使用してJWTトークンベースのREST APIを実装し、可能な場合はSpringSecurityのデフォルトの動作を再利用する方法を見てみましょう。

予想どおり、Spring Securityフレームワークには、セッションCookie、HTTP Basic、HTTPダイジェストなどの「古い」承認メカニズムを処理するプラグインクラスが多数付属しています。 ただし、JWTのネイティブサポートが不足しているため、JWTを機能させるには手を汚す必要があります。 詳細な概要については、SpringSecurityの公式ドキュメントを参照してください。

それでは、 web.xmlの通常のSpringSecurityフィルター定義から始めましょう。

 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

残りのSpring構成をそのまま使用するには、SpringSecurityフィルターの名前が正確にspringSecurityFilterChainである必要があることに注意してください。

次は、セキュリティに関連するSpringBeanのXML宣言です。 XMLを単純化するために、ルートXML要素にxmlns="http://www.springframework.org/schema/security"を追加して、デフォルトの名前空間をsecurityに設定します。 XMLの残りの部分は次のようになります。

 <global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
  • (1)この行では、コンテキスト内の任意の@PreAuthorize @PreFilter @PostFilter@PostAuthorizeアノテーションをアクティブにします。
  • (2)セキュリティをスキップするようにログインエンドポイントとサインアップエンドポイントを定義します。 「匿名」でさえ、これら2つの操作を実行できるはずです。
  • (3)次に、エントリポイント参照とセッション作成をstatelessに設定する2つの重要な構成を追加しながら、すべてのリクエストに適用されるフィルターチェーンを定義します(各リクエストにトークンを使用しているため、セキュリティ目的でセッションを作成する必要はありません) 。
  • (4)トークンはcsrf保護の影響を受けないため、 csrf保護は必要ありません。
  • (5)次に、フォームログインフィルターの直前に、Springの事前定義されたフィルターチェーン内に特別な認証フィルターをプラグインします。
  • (6)このBeanは、認証フィルターの宣言です。 SpringのAbstractAuthenticationProcessingFilterを拡張しているため、そのプロパティをワイヤリングするためにXMLで宣言する必要があります(自動ワイヤリングはここでは機能しません)。 フィルタの機能については後で説明します。
  • (7) AbstractAuthenticationProcessingFilterのデフォルトの成功ハンドラーは、ユーザーを成功ページにリダイレクトするため、RESTの目的には十分ではありません。 それが私たちがここに私たち自身を設定する理由です。
  • (8) authenticationManagerによって作成されたプロバイダーの宣言は、ユーザーを認証するためにフィルターによって使用されます。

次に、上記のXMLで宣言された特定のクラスを実装する方法を見てみましょう。 Springがそれらを配線することに注意してください。 最も単純なものから始めます。

RestAuthenticationEntryPoint.java

 public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }

上で説明したように、このクラスは、認証が失敗したときにHTTPコード401(Unauthorized)を返すだけで、デフォルトのSpringのリダイレクトをオーバーライドします。

JwtAuthenticationSuccessHandler.java

 public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }

この単純なオーバーライドにより、認証が成功した場合のデフォルトの動作が削除されます(ユーザーが要求したホームまたはその他のページにリダイレクトされます)。 なぜAuthenticationFailureHandlerをオーバーライドする必要がないのか疑問に思われる場合は、リダイレクトURLが設定されていない場合、デフォルトの実装はどこにもリダイレクトされないため、URLの設定は避けます。これで十分です。

JwtAuthenticationFilter.java

 public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }

このクラスは、JWT認証プロセスのエントリポイントです。 フィルタはリクエストヘッダーからJWTトークンを抽出し、挿入されたAuthenticationManagerに認証を委任します。 トークンが見つからない場合は、リクエストの処理を停止する例外がスローされます。 デフォルトのSpringフローはフィルターチェーンを停止し、リダイレクトを続行するため、認証を成功させるにはオーバーライドも必要です。 上で説明したように、応答の生成を含め、チェーンを完全に実行する必要があることに注意してください。

JwtAuthenticationProvider.java

 public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }

このクラスでは、SpringのデフォルトのAuthenticationManagerを使用していますが、実際の認証プロセスを実行する独自のAuthenticationProviderを注入しています。 これを実装するために、 AbstractUserDetailsAuthenticationProviderを拡張します。これにより、認証要求(この場合はJwtAuthenticationTokenクラスにラップされたJWTトークン)に基づいてUserDetailsを返すだけで済みます。 トークンが有効でない場合、例外をスローします。 ただし、それが有効で、 JwtUtilによる復号化が成功した場合は、データベースにまったくアクセスせずに、ユーザーの詳細を抽出します( JwtUtilクラスで正確に確認できます)。 ユーザーの役割を含むユーザーに関するすべての情報は、トークン自体に含まれています。

JwtUtil.java

 public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }

最後に、 JwtUtilクラスは、トークンをUserオブジェクトに解析し、 Userオブジェクトからトークンを生成する役割を果たします。 jjwtライブラリを使用してすべてのJWT作業を実行するため、簡単です。 この例では、ユーザー名、ユーザーID、およびユーザーロールをトークンに格納するだけです。 さらに任意のものを保存し、トークンの有効期限などのセキュリティ機能を追加することもできます。 トークンの解析は、上記のようにAuthenticationProviderで使用されます。 generateToken()メソッドはログインおよびサインアップRESTサービスから呼び出されます。これらのサービスはセキュリティで保護されておらず、セキュリティチェックをトリガーしたり、リクエストにトークンが存在する必要はありません。 最後に、ユーザーに基づいてクライアントに返されるトークンを生成します。

結論

古い標準化されたセキュリティアプローチ(セッションCookie、HTTP Basic、およびHTTPダイジェスト)はRESTサービスでも機能しますが、より良い標準を使用することで回避できる問題があります。 JWTは、その日を節約するためにちょうど間に合うように到着します。そして最も重要なことは、JWTがIETF標準になるのに非常に近いことです。

JWTの主な強みは、ステートレスでスケーラブルな方法でユーザー認証を処理すると同時に、最新の暗号化標準ですべてを安全に保つことです。 クレーム(ユーザーの役割とアクセス許可)をトークン自体に保存すると、要求を発行するサーバーが認証データソースにアクセスできない分散システムアーキテクチャに大きなメリットがあります。