私はJerseyでトークンベースの認証を有効にする方法を探しています。私は特定のフレームワークを使用しないようにしています。それは可能ですか?
私の計画は次のとおりです。ユーザーが私のWebサービスにサインアップし、私のWebサービスがトークンを生成し、それをクライアントに送信すると、クライアントはそれを保持します。それからクライアントは、リクエストごとに、ユーザー名とパスワードの代わりにトークンを送信します。
各リクエストと@PreAuthorize("hasRole('ROLE')")
にカスタムフィルタを使用することを考えていましたが、これはトークンが有効であるかどうかをチェックするためにデータベースへのリクエストを大量に発生させると思いました。
それとも、フィルタを作成しないで、それぞれのリクエストにparamトークンを入れますか?各APIは最初にトークンをチェックし、その後リソースを取得するために何かを実行します。
トークンベースの認証では、クライアントはtokenと呼ばれるデータとhardクレデンシャル(ユーザー名やパスワードなど)を交換します。クライアントは、要求ごとに、ハード資格情報を送信する代わりに、トークンをサーバーに送信して認証と承認を実行します。
一言で言えば、トークンに基づく認証スキームは次の手順に従います。
注:サーバーが署名済みトークン(JWTなど、ステートレスを実行できる場合)を発行した場合、ステップ3は不要です。 認証)。
このソリューションはJAX-RS 2.0 APIのみを使用します。ベンダー固有のソリューションを回避します。したがって、 Jersey 、 RESTEasy 、および Apache CXF などのJAX-RS 2.0実装で動作するはずです。
トークンベースの認証を使用している場合、サーブレットコンテナによって提供され、アプリケーションのweb.xml
記述子で設定可能な標準のJava EE Webアプリケーションセキュリティメカニズムに依存していないことに言及する価値があります。これはカスタム認証です。
資格情報(ユーザー名とパスワード)を受け取って検証する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
(禁止)の応答が返されます。
資格情報が正常に検証されると、ステータス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ヘッダーの名前は、authorizationではなく、authentication情報を運ぶため、残念です。ただし、資格情報をサーバーに送信するための標準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
(未承認)の応答が返されます。それ以外の場合、要求はリソースメソッドに進みます。
認証フィルターをリソースメソッドまたはリソースクラスにバインドするには、上記で作成した@Secured
アノテーションでアノテーションを付けます。注釈が付けられているメソッドまたはクラス、あるいはその両方に対して、フィルターが実行されます。これは、有効なトークンを使用して要求が実行された場合に、そのようなエンドポイントにonlyだけが到達することを意味します。
一部のメソッドまたはクラスが認証を必要としない場合、それらに注釈を付けないでください:
@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
...
}
}
上記の例では、@Secured
アノテーションが付けられているため、mySecuredMethod(Long)
メソッドに対してフィルターがonly実行されます。
リクエストを実行しているユーザーを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
の名前になります。
JAX-RSリソースクラスに SecurityContext
を挿入します。
@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();
何らかの理由で 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
producerフィールドに割り当てます。
@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
フィールドは、JAX-RSサービス、CDI Bean、サーブレット、EJBなどのコンテナー管理Beanに注入できるUser
インスタンスを生成します。次のコードを使用して、User
インスタンスを挿入します(実際、CDIプロキシです)。
@Inject
@AuthenticatedUser
User authenticatedUser;
CDI @Produces
注釈は、JAX-RS @Produces
注釈とはdifferentであることに注意してください。
javax.enterprise.inject.Produces
javax.ws.rs.Produces
AuthenticatedUserProducer
BeanでCDI @Produces
アノテーションを必ず使用してください。
ここで重要なのは、 @RequestScoped
アノテーションが付けられたBeanです。これにより、フィルターとBeanの間でデータを共有できます。イベントを使用したくない場合は、フィルターを変更して、認証されたユーザーを要求スコープBeanに格納し、JAX-RSリソースクラスから読み取ることができます。
SecurityContext
をオーバーライドするアプローチと比較して、CDIアプローチでは、JAX-RSリソースおよびプロバイダー以外のBeanから認証済みユーザーを取得できます。
役割ベースの承認をサポートする方法の詳細については、他の answer を参照してください。
トークンには次のものがあります。
以下の詳細を参照してください。
トークンは、ランダムな文字列を生成し、ユーザー識別子と有効期限とともにデータベースに保持することで発行できます。 Javaでランダムな文字列を生成する方法の良い例は、 here にあります。また、使用することができます:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT(JSON Web Token)は、2者間でクレームを安全に表すための標準的な方法であり、 RFC 7519 で定義されています。
これは自己完結型のトークンであり、claimsに詳細を保存できます。これらのクレームは、 Base64 としてエンコードされたJSONであるトークンペイロードに保存されます。 RFC 7519 に登録されているいくつかのクレームとその意味(詳細については完全なRFCを読んでください):
iss
:トークンを発行したプリンシパル。sub
:JWTのサブジェクトであるプリンシパル。exp
:トークンの有効期限。nbf
:トークンが処理のために受け入れられ始める時間。iat
:トークンが発行された時刻。jti
:トークンの一意の識別子。パスワードなどの機密データをトークンに保存しないでください。
クライアントはペイロードを読み取ることができ、サーバー上の署名を検証することでトークンの整合性を簡単に確認できます。署名は、トークンが改ざんされるのを防ぎます。
追跡する必要がない場合は、JWTトークンを永続化する必要はありません。ただし、トークンを永続化することにより、トークンのアクセスを無効化および取り消すことができます。サーバー上でトークン全体を永続化する代わりに、JWTトークンを追跡するために、トークン識別子( jti
クレーム)を、トークンを発行したユーザーなどの他の詳細とともに永続化できます。有効期限など.
トークンを永続化するときは、データベースが無期限に成長するのを防ぐために、常に古いトークンを削除することを検討してください。
次のようなJWTトークンを発行および検証するいくつかのJavaライブラリがあります。
JWTで動作する他の優れたリソースを見つけるには、 http://jwt.io をご覧ください。
リフレッシュのためにonly有効な(および期限切れでない)トークンを受け入れます。 exp
クレームで示される有効期限の前にトークンを更新するのは、クライアントの責任です。
トークンが無期限に更新されるのを防ぐ必要があります。以下で検討できるいくつかのアプローチを参照してください。
トークンに2つのクレームを追加することで、トークンの更新を追跡できます(クレーム名はあなた次第です)。
refreshLimit
:トークンを更新できる回数を示します。refreshCount
:トークンが更新された回数を示します。したがって、次の条件に該当する場合にのみトークンを更新してください。
exp >= now
)。refreshCount < refreshLimit
)よりも少ないです。そして、トークンを更新するとき:
exp = now + some-amount-of-time
)を更新します。refreshCount++
)。リフレッシュメントの数を追跡する代わりに、絶対有効期限を示すクレームを作成することもできます(これは上記のrefreshLimit
クレームとほぼ同様に機能します)。 絶対有効期限の前に、任意の数のリフレッシュメントが受け入れられます。
別のアプローチでは、短命のJWTトークンを発行するために使用される別個の長命の更新トークンを発行します。
最適なアプローチは要件によって異なります。
トークンを取り消す場合は、それらを追跡する必要があります。トークン全体をサーバー側に保存する必要はなく、必要に応じてトークン識別子(一意である必要があります)とメタデータのみを保存する必要があります。トークン識別子には、 UUID を使用できます。
jti
クレームを使用して、トークン識別子をトークンに保存する必要があります。トークンを検証するときは、サーバー側にあるトークン識別子に対して jti
クレームの値をチェックして、トークンが取り消されていないことを確認してください。
セキュリティ上の理由から、ユーザーがパスワードを変更した場合、ユーザーのすべてのトークンを取り消します。
この答えはすべてauthorizationについてのものであり、 私の前の答え about authenticationを補足するものです。
なぜanother答えるのですか?私は、JSR-250アノテーションをサポートする方法についての詳細を追加することによって、以前の答えを拡張しようとしました。しかし元の答えは長すぎるとなり、 最大長30,000文字を超えました 。そのため、認証の詳細とトークンの発行に焦点を当てながら、認証の詳細全体をこの回答に移動しました。
@Secured
アノテーションによるロールベースの承認のサポート他の answer に示されている認証フローの他に、役割ベースの承認はRESTエンドポイントでサポートできます。
列挙を作成し、ニーズに応じて役割を定義します。
public enum Role {
ROLE_1,
ROLE_2,
ROLE_3
}
ロールをサポートするために、以前に作成した@Secured
ネームバインディングアノテーションを変更します。
@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured {
Role[] value() default {};
}
次に、リソースクラスとメソッドに@Secured
というアノテーションを付けて承認を行います。メソッド注釈はクラス注釈をオーバーライドします。
@Path("/example")
@Secured({Role.ROLE_1})
public class ExampleResource {
@GET
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myMethod(@PathParam("id") Long id) {
// This method is not annotated with @Secured
// But it's declared within a class annotated with @Secured({Role.ROLE_1})
// So it only can be executed by the users who have the ROLE_1 role
...
}
@DELETE
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
@Secured({Role.ROLE_1, Role.ROLE_2})
public Response myOtherMethod(@PathParam("id") Long id) {
// This method is annotated with @Secured({Role.ROLE_1, Role.ROLE_2})
// The method annotation overrides the class annotation
// So it only can be executed by the users who have the ROLE_1 or ROLE_2 roles
...
}
}
AUTHORIZATION
priorityを指定してフィルターを作成します。これは、前に定義した AUTHENTICATION
priorityフィルターの後に実行されます。
ResourceInfo
を使用して、リクエストを処理し、それらから@Secured
アノテーションを抽出するリソース Method
およびリソース Class
を取得できます。
@Secured
@Provider
@Priority(Priorities.AUTHORIZATION)
public class AuthorizationFilter implements ContainerRequestFilter {
@Context
private ResourceInfo resourceInfo;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
// Get the resource class which matches with the requested URL
// Extract the roles declared by it
Class<?> resourceClass = resourceInfo.getResourceClass();
List<Role> classRoles = extractRoles(resourceClass);
// Get the resource method which matches with the requested URL
// Extract the roles declared by it
Method resourceMethod = resourceInfo.getResourceMethod();
List<Role> methodRoles = extractRoles(resourceMethod);
try {
// Check if the user is allowed to execute the method
// The method annotations override the class annotations
if (methodRoles.isEmpty()) {
checkPermissions(classRoles);
} else {
checkPermissions(methodRoles);
}
} catch (Exception e) {
requestContext.abortWith(
Response.status(Response.Status.FORBIDDEN).build());
}
}
// Extract the roles from the annotated element
private List<Role> extractRoles(AnnotatedElement annotatedElement) {
if (annotatedElement == null) {
return new ArrayList<Role>();
} else {
Secured secured = annotatedElement.getAnnotation(Secured.class);
if (secured == null) {
return new ArrayList<Role>();
} else {
Role[] allowedRoles = secured.value();
return Arrays.asList(allowedRoles);
}
}
}
private void checkPermissions(List<Role> allowedRoles) throws Exception {
// Check if the user contains one of the allowed roles
// Throw an Exception if the user has not permission to execute the method
}
}
ユーザーが操作を実行する権限を持っていない場合、要求は403
(中止)で中止されます。
要求を実行しているユーザーを知るには、 私の以前の回答 を参照してください。あなたはそれを SecurityContext
(すでに ContainerRequestContext
に設定されているはずです)から入手するか、CDIを使って注入することができます。
@Secured
アノテーションにロールが宣言されていない場合は、認証されたすべてのユーザーがそのエンドポイントにアクセスできると想定できます。ただし、ユーザーのロールは無視されます。
上記のように@Secured
アノテーションでロールを定義する代わりに、 @RolesAllowed
、 @PermitAll
、 @DenyAll
などのJSR-250アノテーションを検討することもできます。
JAX-RSは、そのようなアノテーションをそのままではサポートしませんが、フィルタを使用して実現することができます。これらすべてをサポートしたい場合に留意する必要があるいくつかの考慮事項があります。
@DenyAll
は、クラスの @RolesAllowed
および @PermitAll
よりも優先されます。@RolesAllowed
はクラスの @PermitAll
より優先されます。@PermitAll
はクラスの @RolesAllowed
より優先されます。@DenyAll
はクラスにアタッチできません。@RolesAllowed
はクラスの @PermitAll
より優先されます。そのため、JSR-250アノテーションをチェックする認証フィルターは以下のようになります。
@Provider
@Priority(Priorities.AUTHORIZATION)
public class AuthorizationFilter implements ContainerRequestFilter {
@Context
private ResourceInfo resourceInfo;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
Method method = resourceInfo.getResourceMethod();
// @DenyAll on the method takes precedence over @RolesAllowed and @PermitAll
if (method.isAnnotationPresent(DenyAll.class)) {
refuseRequest();
}
// @RolesAllowed on the method takes precedence over @PermitAll
RolesAllowed rolesAllowed = method.getAnnotation(RolesAllowed.class);
if (rolesAllowed != null) {
performAuthorization(rolesAllowed.value(), requestContext);
return;
}
// @PermitAll on the method takes precedence over @RolesAllowed on the class
if (method.isAnnotationPresent(PermitAll.class)) {
// Do nothing
return;
}
// @DenyAll can't be attached to classes
// @RolesAllowed on the class takes precedence over @PermitAll on the class
rolesAllowed =
resourceInfo.getResourceClass().getAnnotation(RolesAllowed.class);
if (rolesAllowed != null) {
performAuthorization(rolesAllowed.value(), requestContext);
}
// @PermitAll on the class
if (resourceInfo.getResourceClass().isAnnotationPresent(PermitAll.class)) {
// Do nothing
return;
}
// Authentication is required for non-annotated methods
if (!isAuthenticated(requestContext)) {
refuseRequest();
}
}
/**
* Perform authorization based on roles.
*
* @param rolesAllowed
* @param requestContext
*/
private void performAuthorization(String[] rolesAllowed,
ContainerRequestContext requestContext) {
if (rolesAllowed.length > 0 && !isAuthenticated(requestContext)) {
refuseRequest();
}
for (final String role : rolesAllowed) {
if (requestContext.getSecurityContext().isUserInRole(role)) {
return;
}
}
refuseRequest();
}
/**
* Check if the user is authenticated.
*
* @param requestContext
* @return
*/
private boolean isAuthenticated(final ContainerRequestContext requestContext) {
// Return true if the user is authenticated or false otherwise
// An implementation could be like:
// return requestContext.getSecurityContext().getUserPrincipal() != null;
}
/**
* Refuse the request.
*/
private void refuseRequest() {
throw new AccessDeniedException(
"You don't have permissions to perform this action.");
}
}
注:上記の実装はJersey RolesAllowedDynamicFeature
に基づいています。 Jerseyを使用している場合は、独自のフィルタを作成する必要はありません。既存の実装を使用するだけです。