Как работает аутентификация на основе токенов
При аутентификации на основе токенов клиент обменивает жесткие учетные данные (например, имя пользователя и пароль) на часть данных, называемую токеном . Для каждого запроса вместо отправки жестких учетных данных клиент отправляет токен на сервер для выполнения аутентификации, а затем авторизации.
В нескольких словах схема аутентификации на основе токенов состоит из следующих шагов:
- Клиент отправляет свои учетные данные (имя пользователя и пароль) на сервер.
- Сервер аутентифицирует учетные данные и, если они действительны, генерирует токен для пользователя.
- Сервер сохраняет ранее сгенерированный токен в некотором хранилище вместе с идентификатором пользователя и датой истечения срока действия.
- Сервер отправляет сгенерированный токен клиенту.
- Клиент отправляет токен серверу в каждом запросе.
- Сервер в каждом запросе извлекает токен из входящего запроса. С помощью токена сервер ищет данные пользователя для выполнения аутентификации.
- Если токен действителен, сервер принимает запрос.
- Если токен недействителен, сервер отклоняет запрос.
- Как только аутентификация была выполнена, сервер выполняет авторизацию.
- Сервер может предоставить конечную точку для обновления токенов.
Примечание . Шаг 3 не требуется, если сервер выпустил подписанный токен (например, JWT, который позволяет выполнять аутентификацию без сохранения состояния ).
Что вы можете сделать с JAX-RS 2.0 (Джерси, RESTEasy и Apache CXF)
В этом решении используется только API-интерфейс JAX-RS 2.0, что исключает любое конкретное решение поставщика . Таким образом, он должен работать с реализациями JAX-RS 2.0, такими как Jersey , RESTEasy и Apache 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
будет возвращен ответ со статусом (Запрещено).
Если учетные данные успешно подтверждены, 200
будет возвращен ответ со статусом (ОК), а выданный токен будет отправлен клиенту в полезной нагрузке ответа. Клиент должен отправлять токен на сервер при каждом запросе.
При использовании 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
будет возвращен ответ со статусом (Не авторизован). В противном случае запрос перейдет к методу ресурса.
Защита ваших конечных точек 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
, вы можете использовать CDI (внедрение контекста и зависимости), которое предоставляет полезные функции, такие как события и производители.
Создайте классификатор CDI:
@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }
В AuthenticationFilter
созданный выше, введите Event
аннотацию с @AuthenticatedUser
:
@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;
Если аутентификация прошла успешно, инициируйте событие, передавая имя пользователя в качестве параметра (помните, токен выдается для пользователя, и токен будет использоваться для поиска идентификатора пользователя):
userAuthenticatedEvent.fire(username);
Весьма вероятно, что в вашем приложении есть класс, представляющий пользователя. Давайте назовем этот классUser
.
Создайте компонент CDI для обработки события аутентификации, найдите 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 бобов, сервлетов и EJBs. Используйте следующий фрагмент кода для внедрения User
экземпляра (на самом деле это прокси-сервер CDI):
@Inject
@AuthenticatedUser
User authenticatedUser;
Обратите внимание, что @Produces
аннотация CDI отличается от @Produces
аннотации JAX-RS :
Убедитесь, что вы используете @Produces
аннотацию CDI в своем AuthenticatedUserProducer
бине.
Ключ здесь - это боб, помеченный @RequestScoped
, позволяющий вам обмениваться данными между фильтрами и вашими компонентами. Если вы не хотите использовать события, вы можете изменить фильтр, чтобы сохранить прошедшего аутентификацию пользователя в bean-объекте в области запроса, а затем прочитать его из ваших классов ресурсов JAX-RS.
По сравнению с подходом, который переопределяет SecurityContext
, подход CDI позволяет получить аутентифицированного пользователя от бинов, отличных от ресурсов и поставщиков JAX-RS.
Поддержка авторизации на основе ролей
Пожалуйста, обратитесь к моему другому ответу для получения подробной информации о том, как поддерживать авторизацию на основе ролей.
Выдача токенов
Токен может быть:
- Непрозрачный: не раскрывает никаких деталей, кроме самого значения (например, случайной строки)
- Автономный: Содержит подробную информацию о самом токене (например, JWT).
Подробности см. Ниже:
Случайная строка как токен
Токен можно получить, сгенерировав случайную строку и сохранив ее в базе данных вместе с идентификатором пользователя и датой истечения срока действия. Хороший пример того, как генерировать случайную строку в Java, можно увидеть здесь . Вы также можете использовать:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT (веб-токен JSON)
JWT (JSON Web Token) - это стандартный метод для безопасного представления заявок между двумя сторонами, который определен в RFC 7519 .
Это автономный токен, который позволяет хранить детали в претензиях . Эти утверждения хранятся в полезной нагрузке токена, которая представляет собой JSON, закодированный как Base64 . Вот некоторые претензии, зарегистрированные в RFC 7519 и их значение (для получения более подробной информации прочитайте полный RFC):
iss
: Принципал, который выдал токен.
sub
: Принципал, который является предметом JWT.
exp
: Срок действия токена.
nbf
: Время, когда токен начнет приниматься к обработке.
iat
: Время выдачи токена.
jti
: Уникальный идентификатор токена.
Помните, что вы не должны хранить конфиденциальные данные, такие как пароли, в токене.
Клиент может прочитать полезные данные, а целостность токена легко проверить, проверив его подпись на сервере. Подпись - это то, что предотвращает подделку токена.
Вам не нужно будет сохранять токены JWT, если вам не нужно их отслеживать. Хотя при сохранении токенов у вас будет возможность аннулировать и отозвать доступ к ним. Чтобы отслеживать токены JWT, вместо того, чтобы сохранять весь токен на сервере, вы можете сохранить идентификатор ( jti
заявку) токена вместе с некоторыми другими сведениями, такими как пользователь, для которого выдан токен, дата истечения срока действия и т. Д.
При сохранении токенов всегда рассматривайте возможность удаления старых, чтобы предотвратить бесконечный рост вашей базы данных.
Использование JWT
Существует несколько библиотек Java для выпуска и проверки токенов JWT, таких как:
Чтобы найти другие полезные ресурсы для работы с JWT, взгляните на http://jwt.io .
Обработка отзыва токенов с помощью JWT
Если вы хотите отозвать токены, вы должны следить за ними. Вам не нужно хранить весь токен на стороне сервера, храните только идентификатор токена (который должен быть уникальным) и некоторые метаданные, если вам нужно. Для идентификатора токена вы можете использовать UUID .
jti
Требование должно быть использовано для хранения идентификатора маркера на маркер. При проверке токена убедитесь, что он не был отозван, проверив значение jti
претензии по идентификаторам токена, имеющимся на стороне сервера.
В целях безопасности отмените все токены для пользователя при смене пароля.
Дополнительная информация
- Неважно, какой тип аутентификации вы решите использовать. Всегда делайте это в верхней части HTTPS-соединения, чтобы предотвратить атаку «человек посередине» .
- Посмотрите на этот вопрос в Информационной безопасности для получения дополнительной информации о токенах.
- В этой статье вы найдете полезную информацию об аутентификации на основе токенов.
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.
Как это RESTful?