I'm looking for a way to enable token-based authentication in Jersey. I am trying not to use any particular framework. Is that possible?
My plan is: A user signs up for my web service, my web service generates a token, sends it to the client, and the client will retain it. Then the client, for each request, will send the token instead of username and password.
I was thinking of using a custom filter for each request and @PreAuthorize("hasRole('ROLE')")
, but I just thought that this causes a lot of requests to the database to check if the token is valid.
Or not create filter and in each request put a param token? So that each API first checks the token and after executes something to retrieve resource.
ベストアンサー1
How token-based authentication works
In token-based authentication, the client exchanges hard credentials (such as username and password) for a piece of data called token. For each request, instead of sending the hard credentials, the client will send the token to the server to perform authentication and then authorization.
In a few words, an authentication scheme based on tokens follow these steps:
- The client sends their credentials (username and password) to the server.
- The server authenticates the credentials and, if they are valid, generate a token for the user.
- The server stores the previously generated token in some storage along with the user identifier and an expiration date.
- The server sends the generated token to the client.
- The client sends the token to the server in each request.
- The server, in each request, extracts the token from the incoming request. With the token, the server looks up the user details to perform authentication.
- If the token is valid, the server accepts the request.
- If the token is invalid, the server refuses the request.
- Once the authentication has been performed, the server performs authorization.
- The server can provide an endpoint to refresh tokens.
What you can do with JAX-RS 2.0 (Jersey, RESTEasy and Apache CXF)
This solution uses only the JAX-RS 2.0 API, avoiding any vendor specific solution. So, it should work with JAX-RS 2.0 implementations, such as Jersey, RESTEasy and Apache CXF.
It is worthwhile to mention that if you are using token-based authentication, you are not relying on the standard Java EE web application security mechanisms offered by the servlet container and configurable via application's web.xml
descriptor. It's a custom authentication.
Authenticating a user with their username and password and issuing a token
Create a JAX-RS resource method which receives and validates the credentials (username and password) and issue a token for the user:
@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
}
}
If any exceptions are thrown when validating the credentials, a response with the status 403
(Forbidden) will be returned.
資格情報が正常に検証されると、ステータス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 (コンテキストと依存性の注入) の使用
何らかの理由で、SecurityContext
, you can use CDI (Context and Dependency Injection), which provides useful features such as events and producers.
Create a CDI qualifier:
@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }
In your AuthenticationFilter
created above, inject an Event
annotated with @AuthenticatedUser
:
@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;
If the authentication succeeds, fire the event passing the username as parameter (remember, the token is issued for a user and the token will be used to look up the user identifier):
userAuthenticatedEvent.fire(username);
It's very likely that there's a class that represents a user in your application. Let's call this class User
.
Create a CDI bean to handle the authentication event, find a User
instance with the correspondent username and assign it to the authenticatedUser
producer field:
@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
}
}
The authenticatedUser
field produces a User
instance that can be injected into container managed beans, such as JAX-RS services, CDI beans, servlets and EJBs. Use the following piece of code to inject a User
instance (in fact, it's a CDI proxy):
@Inject
@AuthenticatedUser
User authenticatedUser;
Note that the CDI @Produces
annotation is different from the JAX-RS @Produces
annotation:
- CDI:
javax.enterprise.inject.Produces
- JAX-RS:
javax.ws.rs.Produces
Be sure you use the CDI @Produces
annotation in your AuthenticatedUserProducer
bean.
The key here is the bean annotated with @RequestScoped
, allowing you to share data between filters and your beans. If you don't wan't to use events, you can modify the filter to store the authenticated user in a request scoped bean and then read it from your JAX-RS resource classes.
Compared to the approach that overrides the SecurityContext
, the CDI approach allows you to get the authenticated user from beans other than JAX-RS resources and providers.
Supporting role-based authorization
Please refer to my other answer for details on how to support role-based authorization.
Issuing tokens
A token can be:
- Opaque: Reveals no details other than the value itself (like a random string)
- Self-contained: Contains details about the token itself (like JWT).
See details below:
Random string as token
A token can be issued by generating a random string and persisting it to a database along with the user identifier and an expiration date. A good example of how to generate a random string in Java can be seen here. You also could use:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT (JSON Web Token)
JWT (JSON Web Token) is a standard method for representing claims securely between two parties and is defined by the RFC 7519.
It's a self-contained token and it enables you to store details in claims. These claims are stored in the token payload which is a JSON encoded as Base64. Here are some claims registered in the RFC 7519 and what they mean (read the full RFC for further details):
iss
: Principal that issued the token.sub
: Principal that is the subject of the JWT.exp
: Expiration date for the token.nbf
: Time on which the token will start to be accepted for processing.iat
: Time on which the token was issued.jti
: Unique identifier for the token.
Be aware that you must not store sensitive data, such as passwords, in the token.
ペイロードはクライアントによって読み取られ、サーバー上で署名を検証することでトークンの整合性を簡単に確認できます。署名により、トークンの改ざんが防止されます。
JWTトークンを追跡する必要がなければ、それを保持する必要はありません。ただし、トークンを保持することで、トークンを無効化したり、アクセスを取り消したりする可能性が出てきます。JWTトークンを追跡するには、トークン全体をサーバー上に保持するのではなく、トークン識別子(jti
トークンを発行したユーザー、有効期限などのその他の詳細とともに、クレームも送信されます。
トークンを永続化する場合は、データベースが無制限に拡大するのを防ぐために、常に古いトークンを削除することを検討してください。
JWTの使用
次のような JWT トークンを発行および検証するための Java ライブラリがいくつかあります。
JWTを扱うための他の優れたリソースを見つけるには、以下をご覧ください。翻訳元:。
JWT によるトークン失効の処理
トークンを取り消したい場合は、トークンの追跡をしなければなりません。サーバー側にトークン全体を保存する必要はありません。トークン識別子(一意である必要があります)と必要なメタデータのみを保存します。トークン識別子には、次のものを使用できます。言語。
のjti
トークン識別子をトークンに保存するには、クレームを使用する必要があります。トークンを検証するときは、jti
サーバー側にあるトークン識別子に対してクレームします。
セキュリティ上の理由から、ユーザーがパスワードを変更する場合は、そのユーザーのすべてのトークンを取り消します。