1. ホーム
  2. java

[解決済み] JAX-RSとJerseyでRESTトークンベースの認証を実装する方法

2022-03-20 17:03:42

質問

Jerseyでトークンベースの認証を有効にする方法を探しています。私は、特定のフレームワークを使用しないようにしています。それは可能ですか?

私の計画は、ユーザーが私のウェブサービスにサインアップし、私のウェブサービスがトークンを生成してクライアントに送信し、クライアントがそれを保持することです。そして、クライアントは、各リクエストについて、ユーザー名とパスワードの代わりにトークンを送信します。

各リクエストにカスタムフィルタを使用しようと考えていて @PreAuthorize("hasRole('ROLE')") しかし、これではトークンが有効かどうかをチェックするために、データベースへのリクエストが大量に発生してしまうと思っただけです。

または、フィルタを作成せず、各リクエストでパラメータトークンを置く?そうすれば、各APIはまずトークンをチェックし、その後リソースを取得するために何かを実行することになります。

解決方法は?

トークンベースの認証の仕組み

トークンベース認証では、クライアントは ハードクレデンシャル (と呼ばれるデータ(ユーザー名やパスワードなど)と交換します。 トークン . 各リクエストに対して、クライアントはハードクレデンシャルを送信する代わりに、トークンをサーバーに送信し、認証と認可を実行します。

トークンに基づく認証方式は、一言で言えば、次のような手順で行われます。

  1. クライアントがサーバーに認証情報(ユーザー名とパスワード)を送信する。
  2. サーバーは資格情報を認証し、それが有効であれば、ユーザー用のトークンを生成する。
  3. サーバーは、先に生成されたトークンをユーザー識別子と有効期限とともに、何らかのストレージに保存する。
  4. サーバーは、生成したトークンをクライアントに送信する。
  5. クライアントはリクエストごとにトークンをサーバーに送信します。
  6. サーバーは、各リクエストで、受信したリクエストからトークンを抽出します。トークンを使って、サーバーはユーザーの詳細を調べ、認証を実行する。
    • トークンが有効であれば、サーバーはリクエストを受け付けます。
    • トークンが無効な場合、サーバーはリクエストを拒否します。
  7. 認証が行われると、サーバーは認可を実行する。
  8. サーバーはトークンをリフレッシュするためのエンドポイントを提供することができます。

JAX-RS 2.0(Jersey、RESTEasy、Apache CXF)でできること

このソリューションでは、JAX-RS 2.0 API のみを使用します。 ベンダ固有のソリューションを回避 . したがって、次のような JAX-RS 2.0 の実装で動作するはずです。 ジャージー , RESTEasy および アパッチCXF .

トークン・ベースの認証を使用する場合、サーブレットコンテナが提供する標準的な Java EE ウェブアプリケーション・セキュリティ機構に依存しないこと、およびアプリケーションの web.xml 記述子を使用します。これはカスタム認証です。

ユーザー名とパスワードでユーザーを認証し、トークンを発行する

JAX-RSリソースメソッドを作成し、認証情報(ユーザー名とパスワード)を受け取って検証し、ユーザーのためにトークンを発行します。

@Path("/authentication")
public class AuthenticationEndpoint {

    @POST
    @Produces(MediaType.APPLICATION_JSON)
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    public Response authenticateUser(@FormParam("username") String username, 
                                     @FormParam("password") String password) {

        try {

            // Authenticate the user using the credentials provided
            authenticate(username, password);

            // Issue a token for the user
            String token = issueToken(username);

            // Return the token on the response
            return Response.ok(token).build();

        } catch (Exception e) {
            return Response.status(Response.Status.FORBIDDEN).build();
        }      
    }

    private void authenticate(String username, String password) throws Exception {
        // Authenticate against a database, LDAP, file or whatever
        // Throw an Exception if the credentials are invalid
    }

    private String issueToken(String username) {
        // Issue a token (can be a random String persisted to a database or a JWT token)
        // The issued token must be associated to a user
        // Return the issued token
    }
}

資格情報を検証する際に例外が発生した場合は、ステータス 403 (Forbidden)が返されます。

認証情報の検証に成功した場合、レスポンスにステータス 200 (OK)が返され、発行されたトークンがレスポンス・ペイロードとしてクライアントに送信されます。クライアントは、リクエストのたびにトークンをサーバーに送信する必要があります。

を消費する場合 application/x-www-form-urlencoded の場合、クライアントはリクエストペイロードに以下の形式でクレデンシャルを送信する必要があります。

username=admin&password=123456

フォームパラメータの代わりに、ユーザー名とパスワードをクラスにラップすることが可能です。

public class Credentials implements Serializable {

    private String username;
    private String password;
    
    // Getters and setters omitted
}

そして、それをJSONとして消費する。

@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Response authenticateUser(Credentials credentials) {

    String username = credentials.getUsername();
    String password = credentials.getPassword();
    
    // Authenticate the user, issue a token and return a response
}

この方法を用いると、クライアントはリクエストのペイロードに以下の形式でクレデンシャルを送信する必要があります。

{
  "username": "admin",
  "password": "123456"
}

リクエストからトークンを抽出し、バリデートする

クライアントは、トークンを標準的な HTTP Authorization ヘッダを生成します。例えば

Authorization: Bearer <token-goes-here>

標準的なHTTPヘッダーの名前が残念なのは、そのヘッダーに 認証 情報であって オーソライゼーション . しかし、これはサーバーに認証情報を送信するための標準的なHTTPヘッダーです。

JAX-RSでは @NameBinding これは、フィルタやインターセプターをリソースクラスやメソッドにバインドするためのアノテーションを作成するために使用するメタアノテーションです。を定義します。 @Secured アノテーションは、以下のようになります。

@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured { }

上記で定義した名前バインディングのアノテーションを使用して、フィルタクラスを装飾します。 ContainerRequestFilter これにより、リソースメソッドで処理される前に、リクエストをインターセプトすることができます。そのため ContainerRequestContext は、HTTPリクエストヘッダにアクセスし、トークンを抽出するために使用することができます。

@Secured
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {

    private static final String REALM = "example";
    private static final String AUTHENTICATION_SCHEME = "Bearer";

    @Override
    public void filter(ContainerRequestContext requestContext) throws IOException {

        // Get the Authorization header from the request
        String authorizationHeader =
                requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);

        // Validate the Authorization header
        if (!isTokenBasedAuthentication(authorizationHeader)) {
            abortWithUnauthorized(requestContext);
            return;
        }

        // Extract the token from the Authorization header
        String token = authorizationHeader
                            .substring(AUTHENTICATION_SCHEME.length()).trim();

        try {

            // Validate the token
            validateToken(token);

        } catch (Exception e) {
            abortWithUnauthorized(requestContext);
        }
    }

    private boolean isTokenBasedAuthentication(String authorizationHeader) {

        // Check if the Authorization header is valid
        // It must not be null and must be prefixed with "Bearer" plus a whitespace
        // The authentication scheme comparison must be case-insensitive
        return authorizationHeader != null && authorizationHeader.toLowerCase()
                    .startsWith(AUTHENTICATION_SCHEME.toLowerCase() + " ");
    }

    private void abortWithUnauthorized(ContainerRequestContext requestContext) {

        // Abort the filter chain with a 401 status code response
        // The WWW-Authenticate header is sent along with the response
        requestContext.abortWith(
                Response.status(Response.Status.UNAUTHORIZED)
                        .header(HttpHeaders.WWW_AUTHENTICATE, 
                                AUTHENTICATION_SCHEME + " realm=\"" + REALM + "\"")
                        .build());
    }

    private void validateToken(String token) throws Exception {
        // Check if the token was issued by the server and if it's not expired
        // Throw an Exception if the token is invalid
    }
}

トークンの検証中に何らかの問題が発生した場合、ステータスのあるレスポンス 401 (Unauthorized)が返されます。そうでなければ、リクエストはリソースメソッドに進みます。

RESTエンドポイントの安全性確保

リソースメソッドやリソースクラスに認証フィルタをバインドするには、リソースメソッドやリソースクラスに @Secured アノテーションを作成します。アノテーションされたメソッドやクラスに対して、フィルタリングが実行されます。つまり、そのようなエンドポイントでは のみ 有効なトークンでリクエストを実行した場合に到達します。

認証が不要なメソッドやクラスは、アノテーションを付けないようにします。

@Path("/example")
public class ExampleResource {

    @GET
    @Path("{id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response myUnsecuredMethod(@PathParam("id") Long id) {
        // This method is not annotated with @Secured
        // The authentication filter won't be executed before invoking this method
        ...
    }

    @DELETE
    @Secured
    @Path("{id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response mySecuredMethod(@PathParam("id") Long id) {
        // This method is annotated with @Secured
        // The authentication filter will be executed before invoking this method
        // The HTTP request must be performed with a valid token
        ...
    }
}

上に示した例では、フィルターが実行されます。 のみ に対して mySecuredMethod(Long) メソッドでアノテーションされているからです。 @Secured .

現在のユーザーを特定する

REST APIへのリクエストを実行したユーザーを知る必要がある場合が多々あります。そのためには、以下のような方法があります。

現在のリクエストのセキュリティコンテキストをオーバーライドする

の中で ContainerRequestFilter.filter(ContainerRequestContext) メソッドで、新しい SecurityContext のインスタンスを現在のリクエストに設定することができます。そして SecurityContext.getUserPrincipal() を返します。 Principal のインスタンスを作成します。

final SecurityContext currentSecurityContext = requestContext.getSecurityContext();
requestContext.setSecurityContext(new SecurityContext() {

        @Override
        public Principal getUserPrincipal() {
            return () -> username;
        }

    @Override
    public boolean isUserInRole(String role) {
        return true;
    }

    @Override
    public boolean isSecure() {
        return currentSecurityContext.isSecure();
    }

    @Override
    public String getAuthenticationScheme() {
        return AUTHENTICATION_SCHEME;
    }
});

トークンを使ってユーザー識別子 (ユーザー名) を調べ、それを Principal の名前です。

を注入します。 SecurityContext を任意のJAX-RSリソースクラスで使用することができます。

@Context
SecurityContext securityContext;

JAX-RSのリソースメソッドでも同様のことが可能です。

@GET
@Secured
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myMethod(@PathParam("id") Long id, 
                         @Context SecurityContext securityContext) {
    ...
}

そして Principal :

Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();

CDI (Context and Dependency Injection) の使用法

もし、何らかの理由で SecurityContext を使えば、CDI (Context and Dependency Injection) が使えるので、イベントやプロデューサーなどの便利な機能が利用できます。

CDI修飾子を作成します。

@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }

あなたの AuthenticationFilter 上記で作成した Event でアノテーションされた @AuthenticatedUser :

@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;

認証に成功したら、ユーザー名をパラメータとしてイベントを発生させます(トークンはユーザーに対して発行され、トークンはユーザー識別子を調べるために使用されることを覚えておいてください)。

userAuthenticatedEvent.fire(username);

あなたのアプリケーションには、ユーザーを表すクラスが存在する可能性が高いです。このクラスを User .

認証イベントを処理する CDI Bean を作成し、その中で User インスタンスを作成し、そのインスタンスを authenticatedUser プロデューサー・フィールド

@RequestScoped
public class AuthenticatedUserProducer {

    @Produces
    @RequestScoped
    @AuthenticatedUser
    private User authenticatedUser;
    
    public void handleAuthenticationEvent(@Observes @AuthenticatedUser String username) {
        this.authenticatedUser = findUser(username);
    }

    private User findUser(String username) {
        // Hit the the database or a service to find a user by its username and return it
        // Return the User instance
    }
}

authenticatedUser フィールドは User このインスタンスは、JAX-RS サービスや CDI Bean、サーブレット、EJB などのコンテナ管理 Bean に注入することができます。以下のコード片を使用して User インスタンス (実際には CDI プロキシ) を作成します。

@Inject
@AuthenticatedUser
User authenticatedUser;

なお、CDI @Produces アノテーションは 異なる JAX-RSから @Produces アノテーションを使用します。

必ずCDIを使用する @Produces アノテーションを AuthenticatedUserProducer ビーンになります。

でアノテーションされたビーンがキーとなります。 @RequestScoped これにより、フィルタとビーン間でデータを共有することができます。イベントを使いたくない場合は、フィルターを変更して認証済みユーザーをリクエストスコープのBeanに格納し、JAX-RSリソースクラスから読み取ることができます。

をオーバーライドするアプローチと比較すると SecurityContext のように、CDI のアプローチでは、JAX-RS のリソースやプロバイダ以外の Bean から認証ユーザを取得することができます。

ロールベースの認可をサポートする

私の他の 回答 は、ロールベースの認証をサポートする方法の詳細について説明します。

トークンの発行

トークンは、以下のようなものがあります。

  • 不透明である。 値そのもの以外の詳細を明らかにしない(ランダムな文字列のような)。
  • 自己完結している。 トークン自体の詳細が含まれている(JWTのような)。

詳細は下記をご覧ください。

ランダムな文字列をトークンとする

トークンは、ランダムな文字列を生成し、ユーザー識別子と有効期限とともにデータベースに保存することで発行することができる。Javaでランダムな文字列を生成する方法の良い例を以下に示します。 こちら . も使える。

Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);

JWT (JSONウェブトークン)

JWT(JSON Web Token)は、2者間で安全にクレームを表現するための標準的な手法で、以下のように定義されています。 RFC 7519 .

これは自己完結型のトークンであり、これによって詳細を クレーム . これらのクレームは、以下のようにエンコードされたJSONであるトークン・ペイロードに格納されます。 ベース64 . に登録されたクレームを以下に示します。 RFC 7519 とその意味(詳しくはRFCの全文をお読みください)。

  • iss : トークンを発行したプリンシパル。
  • sub : JWTのサブジェクトであるプリンシパル。
  • exp : トークンの有効期限です。
  • nbf : トークンの処理受付開始時刻。
  • iat : トークンが発行された時刻。
  • jti : トークンの一意な識別子。

トークンには、パスワードなどの機密データを保存しないように注意してください。

ペイロードはクライアントが読むことができ、トークンの完全性はサーバー上で署名を検証することで簡単に確認することができます。署名は、トークンが改ざんされるのを防ぐためのものです。

JWTトークンを追跡する必要がなければ、JWTトークンを永続化する必要はないでしょう。しかし、トークンを永続化することで、トークンのアクセスを無効化したり取り消したりできる可能性があります。JWT トークンの追跡を維持するには、トークン全体をサーバーに永続化する代わりに、トークン識別子 ( jti クレーム) に加えて、トークンを発行したユーザや有効期限などの詳細が表示されます。

トークンを永続化する際には、データベースが際限なく大きくなるのを防ぐために、古いトークンを削除することを常に考慮してください。

JWTの使用

JWTトークンの発行や検証を行うためのJavaライブラリは、以下のようなものがあります。

JWTを使用するための他の素晴らしいリソースを見つけるには、以下を参照してください。 http://jwt.io .

JWTでトークン失効を処理する

トークンを失効させたい場合は、トークンを追跡する必要があります。トークン全体をサーバサイドに保存する必要はなく、トークン識別子 (これは一意でなければなりません) と必要なメタデータのみを保存すればよいのです。トークン識別子には、次のようなものを使用します。 UUID .

jti のクレームは、トークンの識別子を保存するために使用されるべきです。トークンを検証する際には、トークンが失効していないことを jti は、サーバー側で持っているトークン識別子に対してクレームしてください。

セキュリティのため、ユーザーがパスワードを変更したら、そのユーザーのすべてのトークンを失効させる。

追加情報

  • どのタイプの認証を使用するかは問題ではありません。 常に を防ぐために、HTTPS接続の先頭で実行します。 マンインザミドルアタック .
  • をご覧ください。 この質問 トークンの詳細については、情報セキュリティのページをご覧ください。
  • この記事で トークン・ベース認証について、いくつかの有用な情報を得ることができます。