Ответ 1
Как работает аутентификация на токенах
При аутентификации на токенах клиент обменивает жесткие учетные данные (например, имя пользователя и пароль) для части данных, называемой токеном. Для каждого запроса вместо отправки жестких учетных данных клиент отправляет токен на сервер для выполнения проверки подлинности и авторизации.
В нескольких словах схема аутентификации, основанная на токенах, выполняет следующие шаги:
- Клиент отправляет свои учетные данные (имя пользователя и пароль) на сервер.
- Сервер аутентифицирует учетные данные и, если они действительны, генерирует токен для пользователя.
- Сервер хранит ранее сгенерированный токен в некотором хранилище вместе с идентификатором пользователя и датой истечения срока действия.
- Сервер отправляет сгенерированный токен клиенту.
- Клиент отправляет маркер на сервер в каждом запросе.
- Сервер в каждом запросе извлекает токен из входящего запроса. С помощью маркера сервер просматривает детали пользователя для выполнения проверки подлинности.
- Если токен действителен, сервер принимает запрос.
- Если токен недействителен, сервер отказывается от запроса.
- После проверки подлинности сервер выполняет авторизацию.
- Сервер может предоставить конечную точку для обновления токенов.
Примечание. Шаг 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
приложения 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
(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 not expired
// Throw an Exception if the token is invalid
}
}
Если во время проверки маркера возникают какие-либо проблемы, возвращается ответ со статусом 401
(Несанкционированный). В противном случае запрос перейдет к методу ресурса.
Защита конечных точек REST
Чтобы связать фильтр проверки подлинности с методами ресурсов или классами ресурсов, аннотируйте их с @Secured
аннотации @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 (Context и Dependency Injection), который предоставляет полезные функции, такие как события и производители.
Создайте квалификатор CDI:
@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }
В созданном выше AuthenticationFilter
@AuthenticatedUser
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, сервлеты и EJB. Используйте следующий фрагмент кода для ввода экземпляра User
(на самом деле это прокси-сервер CDI):
@Inject
@AuthenticatedUser
User authenticatedUser;
Обратите внимание, что аннотация CDI @Produces
отличается от аннотации JAX-RS @Produces
:
- CDI:
javax.enterprise.inject.Produces
- JAX-RS:
javax.ws.rs.Produces
Убедитесь, что вы используете аннотацию CDI @Produces
в компоненте AuthenticatedUserProducer
.
Ключевым моментом здесь является bean, аннотированный с помощью @RequestScoped
, позволяющий обмениваться данными между фильтрами и вашими бобами. Если вы не хотите использовать события, вы можете изменить фильтр для хранения аутентифицированного пользователя в компоненте с включенным запросом, а затем прочитать его из классов ресурсов JAX-RS.
По сравнению с подходом, который переопределяет SecurityContext
, подход CDI позволяет получить аутентифицированного пользователя из компонентов, отличных от ресурсов и поставщиков JAX-RS.
Поддержка ролевой авторизации
Дополнительную информацию о том, как поддерживать авторизацию на основе ролей, см. В моем другом ответе.
Выдача токенов
Токен может быть:
- Opaque: не показывает никаких деталей, кроме самого значения (например, случайной строки)
- Автономный: содержит информацию о самом марке (например, JWT).
См. Подробности ниже:
Случайная строка как токен
Маркер может быть выпущен путем создания случайной строки и сохранения ее в базе данных вместе с идентификатором пользователя и датой истечения срока действия. Хороший пример того, как создать случайную строку в Java, можно увидеть здесь. Вы также можете использовать:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT (JSON Web Token)
JWT (JSON Web Token) является стандартным методом для надежного представления претензий между двумя сторонами и определяется RFC 7519.
Это автономный токен, который позволяет хранить детали в претензиях. Эти претензии сохраняются в полезной нагрузке маркера, которая является JSON, закодированной как Base64. Вот некоторые заявки, зарегистрированные в RFC 7519 и что они означают (прочитайте полный RFC для получения дополнительной информации):
-
iss
: Принципал, выдавший токен. -
sub
: Principal, который является предметом JWT. -
exp
: дата окончания токена. -
nbf
: время, когда токен начнет принимать для обработки. -
iat
: Время, на которое был выдан токен. -
jti
: уникальный идентификатор для токена.
Имейте в виду, что вы не должны хранить конфиденциальные данные, такие как пароли, в токене.
Полезная нагрузка может быть прочитана клиентом, а целостность токена может быть легко проверена путем проверки его подписи на сервере. Подпись - это то, что мешает подделке токена.
Вам не нужно будет сохранять токены JWT, если вам не нужно их отслеживать. Хотя, сохраняя токены, у вас будет возможность аннулировать и отменить доступ к ним. Чтобы сохранить следы токенов JWT, вместо того, чтобы сохранять весь токен на сервере, вы можете сохранить идентификатор маркера (требование jti
) вместе с некоторыми другими данными, такими как пользователь, которому вы выдали токен, дату истечения срока и т.д.
При сохранении токенов всегда рекомендуется удалять старые, чтобы ваша база данных не увеличивалась бесконечно.
Использование JWT
Существует несколько библиотек Java для выдачи и проверки токенов JWT, таких как:
Чтобы найти другие полезные ресурсы для работы с JWT, посмотрите на http://jwt.io.
Обработка обновления токена с помощью JWT
Принять только действительные (и не истекшие) токены для обновления. Ответственность клиента за обновление токенов до даты истечения срока действия, указанной в заявке на exp
.
Вы должны помешать токенам обновляться на неопределенный срок. Ниже вы найдете несколько подходов, которые вы могли бы рассмотреть.
Вы можете сохранить трек обновления токена, добавив две претензии к вашему токену (имена претензий зависят от вас):
-
refreshLimit
: указывает, сколько раз токен может быть обновлен. -
refreshCount
: указывает, сколько раз токен был обновлен.
Так что только обновите токен, если выполняются следующие условия:
- Токен не истек (
exp >= now
). - Количество
refreshCount < refreshLimit
обновления токена меньше количества раз, когда токен может быть обновлен (refreshCount < refreshLimit
).
И при обновлении токена:
- Обновите дату истечения срока действия (
exp = now + some-amount-of-time
). -
refreshCount++
обновления токена (refreshCount++
).
В качестве альтернативы для отслеживания количества напитков вам может потребоваться заявка, указывающая абсолютную дату истечения срока действия (которая очень похожа на refreshLimit
описанную выше). До абсолютной даты истечения срока допустимо любое количество прохладительных напитков.
Другой подход включает в себя выпуск отдельного долгоживущего токена обновления, который используется для выдачи недолговечных токенов JWT.
Наилучший подход зависит от ваших требований.
Обработка аннулирования токена с помощью JWT
Если вы хотите отменить токены, вы должны следить за ними. Вам не нужно хранить весь токен на стороне сервера, хранить только идентификатор маркера (который должен быть уникальным) и некоторые метаданные, если вам нужно. Для идентификатора маркера вы можете использовать UUID.
Требование jti
должно использоваться для хранения идентификатора маркера на токене. При проверке маркера убедитесь, что он не был отозван, проверяя значение требования jti
против идентификаторов маркера, которые у вас есть на стороне сервера.
В целях безопасности отмените все токены для пользователя, когда они меняют свой пароль.
Дополнительная информация
- Неважно, какой тип аутентификации вы решите использовать. Всегда делайте это на верхней части HTTPS-соединения, чтобы предотвратить атаку " человек-в-середине".
- Взгляните на этот вопрос из Information Security за дополнительной информацией о токенах.
- В этой статье вы найдете полезную информацию об аутентификации на токенах.