Перейти к основному содержимому

Внедрение платформы авторизации OAuth 2.0 с использованием Jakarta EE

· 18 мин. чтения

1. Обзор

В этом руководстве мы собираемся предоставить реализацию платформы авторизации OAuth 2.0 с использованием Jakarta EE и MicroProfile. Самое главное, мы собираемся реализовать взаимодействие ролей OAuth 2.0 через тип гранта Authorization Code . Мотивация написания этой статьи — оказать поддержку проектам, реализованным с использованием Jakarta EE, так как это еще не обеспечивает поддержку OAuth.

Для наиболее важной роли, сервера авторизации, мы собираемся реализовать конечную точку авторизации, конечную точку маркера и, кроме того, конечную точку ключа JWK , которая полезна для сервера ресурсов для получения открытого ключа.

Поскольку мы хотим, чтобы реализация была простой и легкой для быстрой настройки, мы собираемся использовать предварительно зарегистрированное хранилище клиентов и пользователей и, очевидно, хранилище JWT для токенов доступа.

Прежде чем перейти непосредственно к теме, важно отметить, что пример в этом руководстве предназначен для образовательных целей. Для производственных систем настоятельно рекомендуется использовать зрелое, хорошо протестированное решение, такое как Keycloak .

2. Обзор OAuth 2.0

В этом разделе мы собираемся дать краткий обзор ролей OAuth 2.0 и потока предоставления кода авторизации.

2.1. Роли

Платформа OAuth 2.0 подразумевает сотрудничество между четырьмя следующими ролями:

  • Владелец ресурса : обычно это конечный пользователь — это организация, у которой есть некоторые ресурсы, которые стоит защищать.
  • Сервер ресурсов : служба, которая защищает данные владельца ресурса, обычно публикуя их через REST API.
  • Клиент : приложение, использующее данные владельца ресурса.
  • Сервер авторизации : приложение, которое предоставляет разрешение или полномочия клиентам в виде токенов с истекающим сроком действия.

2.2. Типы предоставления авторизации

Тип гранта — это то, как клиент получает разрешение на использование данных владельца ресурса, в конечном счете, в форме токена доступа.

Естественно, разные типы клиентов предпочитают разные виды грантов :

  • Код авторизации : чаще всего предпочтительнее `` будь то веб-приложение, собственное приложение или одностраничное приложение , хотя собственные и одностраничные приложения требуют дополнительной защиты, называемой PKCE.
  • Обновить токен : специальный грант на продление, подходящий для веб-приложений , чтобы обновить свой существующий токен .
  • Учетные данные клиента : предпочтительнее для связи между службами , например, когда владелец ресурса не является конечным пользователем.
  • ` Пароль владельца ресурса : предпочтителен для **первой стороны аутентификации собственных приложений ,`** например, когда мобильному приложению требуется собственная страница входа.

Кроме того, клиент может использовать неявный тип предоставления. Однако обычно более безопасно использовать предоставление кода авторизации с PKCE.

2.3. Поток предоставления кода авторизации

Поскольку поток предоставления кода авторизации является наиболее распространенным, давайте также рассмотрим, как он работает, и это то, что мы создадим в этом руководстве.

Приложение — клиент — запрашивает разрешение, перенаправляя его на конечную точку / authorize сервера авторизации . `` Этой конечной точке приложение предоставляет конечную точку обратного вызова .

Сервер авторизации обычно запрашивает разрешение у конечного пользователя — владельца ресурса. Если конечный пользователь дает разрешение, то сервер авторизации перенаправляет обратно на обратный вызов с кодом .

Приложение получает этот код, а затем выполняет аутентифицированный вызов конечной точки /token сервера авторизации . `` Под «аутентифицированным» мы подразумеваем, что приложение доказывает, кто оно есть, в рамках этого звонка. Если все в порядке, сервер авторизации отвечает токеном.

Имея токен на руках, приложение делает запрос к API — серверу ресурсов — и этот API проверяет токен. Он может попросить сервер авторизации проверить токен, используя его конечную точку /introspect . Или, если токен автономный, сервер ресурсов может оптимизировать его, локально проверив подпись токена, как в случае с JWT.

2.4. Что поддерживает Jakarta EE?

Пока не так много. В этом уроке мы создадим большинство вещей с нуля.

3. Сервер авторизации OAuth 2.0

В этой реализации мы сосредоточимся на наиболее часто используемом типе гранта : коде авторизации.

3.1. Регистрация клиентов и пользователей

Сервер авторизации, конечно, должен знать о клиентах и пользователях, прежде чем он сможет авторизовать их запросы. И сервер авторизации обычно имеет для этого пользовательский интерфейс.

Однако для простоты мы будем использовать предварительно настроенный клиент:

INSERT INTO clients (client_id, client_secret, redirect_uri, scope, authorized_grant_types) 
VALUES ('webappclient', 'webappclientsecret', 'http://localhost:9180/callback',
'resource.read resource.write', 'authorization_code refresh_token');
@Entity
@Table(name = "clients")
public class Client {
@Id
@Column(name = "client_id")
private String clientId;
@Column(name = "client_secret")
private String clientSecret;

@Column(name = "redirect_uri")
private String redirectUri;

@Column(name = "scope")
private String scope;

// ...
}

И предварительно настроенный пользователь:

INSERT INTO users (user_id, password, roles, scopes)
VALUES ('appuser', 'appusersecret', 'USER', 'resource.read resource.write');
@Entity
@Table(name = "users")
public class User implements Principal {
@Id
@Column(name = "user_id")
private String userId;

@Column(name = "password")
private String password;

@Column(name = "roles")
private String roles;

@Column(name = "scopes")
private String scopes;

// ...
}

Обратите внимание, что в этом руководстве мы использовали пароли в виде простого текста, но в производственной среде их следует хэшировать .

В оставшейся части этого руководства мы покажем, как appuser — владелец ресурса — может предоставить доступ к webappclient — приложению — путем реализации кода авторизации.

3.2. Конечная точка авторизации

Основная роль конечной точки авторизации заключается в том, чтобы сначала аутентифицировать пользователя, а затем запрашивать разрешения или области действия, которые нужны приложению.

В соответствии со спецификациями OAuth2 эта конечная точка должна поддерживать метод HTTP GET, хотя она также может поддерживать метод HTTP POST. В этой реализации мы будем поддерживать только метод HTTP GET.

Во- первых, конечная точка авторизации требует, чтобы пользователь прошел аутентификацию . Спецификация не требует здесь определенного способа, поэтому воспользуемся Form Authentication из Jakarta EE 8 Security API :

@FormAuthenticationMechanismDefinition(
loginToContinue = @LoginToContinue(loginPage = "/login.jsp", errorPage = "/login.jsp")
)

Пользователь будет перенаправлен на /login.jsp для аутентификации, а затем станет доступен как CallerPrincipal через API SecurityContext :

Principal principal = securityContext.getCallerPrincipal();

Мы можем собрать их вместе, используя JAX-RS:

@FormAuthenticationMechanismDefinition(
loginToContinue = @LoginToContinue(loginPage = "/login.jsp", errorPage = "/login.jsp")
)
@Path("authorize")
public class AuthorizationEndpoint {
//...
@GET
@Produces(MediaType.TEXT_HTML)
public Response doGet(@Context HttpServletRequest request,
@Context HttpServletResponse response,
@Context UriInfo uriInfo) throws ServletException, IOException {

MultivaluedMap<String, String> params = uriInfo.getQueryParameters();
Principal principal = securityContext.getCallerPrincipal();
// ...
}
}

В этот момент конечная точка авторизации может начать обработку запроса приложения, который должен содержать параметры response_type и client_id и — необязательно, но рекомендуется — параметры redirect_uri, scope и state .

client_id должен быть действительным клиентом, в нашем случае из таблицы базы данных клиентов .

redirect_uri , если он указан, также должен соответствовать тому, что мы находим в таблице базы данных клиентов .

И, поскольку мы делаем код авторизации, response_type — это код.

Поскольку авторизация — это многоэтапный процесс, мы можем временно хранить эти значения в сессии:

request.getSession().setAttribute("ORIGINAL_PARAMS", params);

А затем приготовьтесь спросить пользователя, какие разрешения может использовать приложение, перенаправив его на эту страницу:

String allowedScopes = checkUserScopes(user.getScopes(), requestedScope);
request.setAttribute("scopes", allowedScopes);
request.getRequestDispatcher("/authorize.jsp").forward(request, response);

3.3. Утверждение пользовательских областей

В этот момент браузер отображает пользовательский интерфейс авторизации для пользователя, и пользователь делает выбор. Затем браузер отправляет выбор пользователя в HTTP POST :

@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_HTML)
public Response doPost(@Context HttpServletRequest request, @Context HttpServletResponse response,
MultivaluedMap<String, String> params) throws Exception {
MultivaluedMap<String, String> originalParams =
(MultivaluedMap<String, String>) request.getSession().getAttribute("ORIGINAL_PARAMS");

// ...

String approvalStatus = params.getFirst("approval_status"); // YES OR NO

// ... if YES

List<String> approvedScopes = params.get("scope");

// ...
}

Затем мы создаем временный код, который ссылается на user_id, client_id и redirect_uri, которые приложение будет использовать позже, когда достигнет конечной точки токена.

Итак, давайте создадим сущность JPA AuthorizationCode с автоматически сгенерированным идентификатором :

@Entity
@Table(name ="authorization_code")
public class AuthorizationCode {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
@Column(name = "code")
private String code;

//...

}

А затем заполните его:

AuthorizationCode authorizationCode = new AuthorizationCode();
authorizationCode.setClientId(clientId);
authorizationCode.setUserId(userId);
authorizationCode.setApprovedScopes(String.join(" ", authorizedScopes));
authorizationCode.setExpirationDate(LocalDateTime.now().plusMinutes(2));
authorizationCode.setRedirectUri(redirectUri);

Когда мы сохраняем bean-компонент, атрибут кода заполняется автоматически, поэтому мы можем получить его и отправить обратно клиенту:

appDataRepository.save(authorizationCode);
String code = authorizationCode.getCode();

Обратите внимание, что срок действия нашего кода авторизации истекает через две минуты — мы должны быть максимально консервативны в отношении этого срока действия. Он может быть коротким, так как клиент сразу его обменяет на токен доступа.

Затем мы перенаправляем обратно на redirect_uri приложения, передавая ему код, а также любой параметр состояния , который приложение указало в своем запросе /authorize :

StringBuilder sb = new StringBuilder(redirectUri);
// ...

sb.append("?code=").append(code);
String state = params.getFirst("state");
if (state != null) {
sb.append("&state=").append(state);
}
URI location = UriBuilder.fromUri(sb.toString()).build();
return Response.seeOther(location).build();

Еще раз обратите внимание, что redirectUri — это то, что существует в таблице клиентов , а не параметр запроса redirect_uri .

Итак, наш следующий шаг — клиент должен получить этот код и обменять его на токен доступа, используя конечную точку токена.

3.4. Конечная точка токена

В отличие от конечной точки авторизации, конечной точке токена не нужен браузер для связи с клиентом , поэтому мы реализуем ее как конечную точку JAX-RS:

@Path("token")
public class TokenEndpoint {

List<String> supportedGrantTypes = Collections.singletonList("authorization_code");

@Inject
private AppDataRepository appDataRepository;

@Inject
Instance<AuthorizationGrantTypeHandler> authorizationGrantTypeHandlers;

@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response token(MultivaluedMap<String, String> params,
@HeaderParam(HttpHeaders.AUTHORIZATION) String authHeader) throws JOSEException {
//...
}
}

Для конечной точки токена требуется POST, а также кодирование параметров с использованием типа носителя application/x-www-form-urlencoded .

Как мы уже говорили, мы будем поддерживать только тип предоставления кода авторизации :

List<String> supportedGrantTypes = Collections.singletonList("authorization_code");

Итак, полученный grant_type в качестве обязательного параметра должен поддерживаться:

String grantType = params.getFirst("grant_type");
Objects.requireNonNull(grantType, "grant_type params is required");
if (!supportedGrantTypes.contains(grantType)) {
JsonObject error = Json.createObjectBuilder()
.add("error", "unsupported_grant_type")
.add("error_description", "grant type should be one of :" + supportedGrantTypes)
.build();
return Response.status(Response.Status.BAD_REQUEST)
.entity(error).build();
}

Далее мы проверяем аутентификацию клиента через базовую аутентификацию HTTP. То есть проверяем , соответствуют ли полученные client_id и client_secret через заголовок Authorization зарегистрированному клиенту:

String[] clientCredentials = extract(authHeader);
String clientId = clientCredentials[0];
String clientSecret = clientCredentials[1];
Client client = appDataRepository.getClient(clientId);
if (client == null || clientSecret == null || !clientSecret.equals(client.getClientSecret())) {
JsonObject error = Json.createObjectBuilder()
.add("error", "invalid_client")
.build();
return Response.status(Response.Status.UNAUTHORIZED)
.entity(error).build();
}

Наконец, мы делегируем создание TokenResponse соответствующему обработчику типа гранта:

public interface AuthorizationGrantTypeHandler {
TokenResponse createAccessToken(String clientId, MultivaluedMap<String, String> params) throws Exception;
}

Поскольку нас больше интересует тип предоставления кода авторизации, мы предоставили адекватную реализацию в виде компонента CDI и украсили его аннотацией Named :

@Named("authorization_code")

Во время выполнения и по полученному значению grant_type активируется соответствующая реализация через механизм CDI Instance :

String grantType = params.getFirst("grant_type");
//...
AuthorizationGrantTypeHandler authorizationGrantTypeHandler =
authorizationGrantTypeHandlers.select(NamedLiteral.of(grantType)).get();

Пришло время создать ответ /token .

3.5. Закрытые и открытые ключи RSA

Перед созданием токена нам нужен закрытый ключ RSA для подписи токенов.

Для этой цели мы будем использовать OpenSSL:

# PRIVATE KEY
openssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:2048

Частный ключ.pem предоставляется серверу через свойство signingKey конфигурации MicroProfile с использованием файла META-INF/microprofile-config.properties:

signingkey=/META-INF/private-key.pem

Сервер может прочитать свойство, используя внедренный объект Config :

String signingkey = config.getValue("signingkey", String.class);

Точно так же мы можем сгенерировать соответствующий открытый ключ:

# PUBLIC KEY
openssl rsa -pubout -in private-key.pem -out public-key.pem

И используйте ключ проверки MicroProfile Config , чтобы прочитать его:

verificationkey=/META-INF/public-key.pem

Сервер должен сделать его доступным для сервера ресурсов для проверки. Это делается через конечную точку JWK.

Nimbus JOSE+JWT — это библиотека, которая может здесь очень помочь. Давайте сначала добавим [ зависимость nimbus-jose- jwt](https://search.maven.org/search?q=g:com.nimbusds AND a:nimbus-jose-jwt&core=gav) :

<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>7.7</version>
</dependency>

И теперь мы можем использовать поддержку Nimbus JWK, чтобы упростить нашу конечную точку:

@Path("jwk")
@ApplicationScoped
public class JWKEndpoint {

@GET
public Response getKey(@QueryParam("format") String format) throws Exception {
//...

String verificationkey = config.getValue("verificationkey", String.class);
String pemEncodedRSAPublicKey = PEMKeyUtils.readKeyAsString(verificationkey);
if (format == null || format.equals("jwk")) {
JWK jwk = JWK.parseFromPEMEncodedObjects(pemEncodedRSAPublicKey);
return Response.ok(jwk.toJSONString()).type(MediaType.APPLICATION_JSON).build();
} else if (format.equals("pem")) {
return Response.ok(pemEncodedRSAPublicKey).build();
}

//...
}
}

Мы использовали параметр формата для переключения между форматами PEM и JWK. MicroProfile JWT, который мы будем использовать для реализации сервера ресурсов, поддерживает оба этих формата.

3.6. Ответ конечной точки токена

Пришло время для данного AuthorizationGrantTypeHandler создать ответ токена. В этой реализации мы будем поддерживать только структурированные токены JWT.

Для создания токена в этом формате мы снова воспользуемся библиотекой Nimbus JOSE+JWT , но есть и множество других библиотек JWT .

Итак, чтобы создать подписанный JWT, нам сначала нужно создать заголовок JWT:

JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).build();

Затем мы создаем полезную нагрузку , которая представляет собой набор стандартизированных и настраиваемых утверждений:

Instant now = Instant.now();
Long expiresInMin = 30L;
Date in30Min = Date.from(now.plus(expiresInMin, ChronoUnit.MINUTES));

JWTClaimsSet jwtClaims = new JWTClaimsSet.Builder()
.issuer("http://localhost:9080")
.subject(authorizationCode.getUserId())
.claim("upn", authorizationCode.getUserId())
.audience("http://localhost:9280")
.claim("scope", authorizationCode.getApprovedScopes())
.claim("groups", Arrays.asList(authorizationCode.getApprovedScopes().split(" ")))
.expirationTime(in30Min)
.notBeforeTime(Date.from(now))
.issueTime(Date.from(now))
.jwtID(UUID.randomUUID().toString())
.build();
SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaims);

В дополнение к стандартным утверждениям JWT мы добавили еще два утверждения — upn и groups — поскольку они необходимы JWT MicroProfile. Upn будет сопоставлен с Jakarta EE Security CallerPrincipal , а группы будут сопоставлены с ролями Jakarta EE.

Теперь, когда у нас есть заголовок и полезная нагрузка, нам нужно подписать токен доступа закрытым ключом RSA . Соответствующий открытый ключ RSA будет предоставлен через конечную точку JWK или доступен другими способами, чтобы сервер ресурсов мог использовать его для проверки токена доступа.

Поскольку мы предоставили закрытый ключ в формате PEM, мы должны получить его и преобразовать в RSAPrivateKey:

SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaims);
//...
String signingkey = config.getValue("signingkey", String.class);
String pemEncodedRSAPrivateKey = PEMKeyUtils.readKeyAsString(signingkey);
RSAKey rsaKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPrivateKey);

Затем мы подписываем и сериализуем JWT:

signedJWT.sign(new RSASSASigner(rsaKey.toRSAPrivateKey()));
String accessToken = signedJWT.serialize();

И, наконец , мы создаем токен-ответ:

return Json.createObjectBuilder()
.add("token_type", "Bearer")
.add("access_token", accessToken)
.add("expires_in", expiresInMin * 60)
.add("scope", authorizationCode.getApprovedScopes())
.build();

который, благодаря JSON-P, сериализуется в формат JSON и отправляется клиенту:

{
"access_token": "acb6803a48114d9fb4761e403c17f812",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "resource.read resource.write"
}

4. Клиент OAuth 2.0

В этом разделе мы создадим веб-клиент OAuth 2.0 с использованием API-интерфейсов Servlet, MicroProfile Config и JAX RS Client.

Точнее, мы будем реализовывать два основных сервлета: один для запроса конечной точки авторизации сервера авторизации и получения кода с использованием типа гранта кода авторизации, а другой сервлет для использования полученного кода и запроса токена доступа из конечной точки токена сервера авторизации. .

Кроме того, мы реализуем еще два сервлета: один для получения нового токена доступа с использованием типа предоставления токена обновления, а другой — для доступа к API-интерфейсам сервера ресурсов.

4.1. Сведения о клиенте OAuth 2.0

Поскольку клиент уже зарегистрирован на сервере авторизации, нам сначала нужно предоставить регистрационную информацию клиента:

  • client_id: идентификатор клиента, который обычно выдается сервером авторизации в процессе регистрации.
  • client_secret: секрет клиента.
  • redirect_uri: место, где можно получить код авторизации.
  • область действия: запрошенные клиентом разрешения.

Кроме того, клиент должен знать конечные точки авторизации и токена сервера авторизации:

  • авторизация_uri: расположение конечной точки авторизации сервера авторизации, которую мы можем использовать для получения кода.
  • token_uri: расположение конечной точки токена сервера авторизации, которую мы можем использовать для получения токена.

Вся эта информация предоставляется через файл конфигурации MicroProfile META-INF/microprofile-config.properties:

# Client registration
client.clientId=webappclient
client.clientSecret=webappclientsecret
client.redirectUri=http://localhost:9180/callback
client.scope=resource.read resource.write

# Provider
provider.authorizationUri=http://127.0.0.1:9080/authorize
provider.tokenUri=http://127.0.0.1:9080/token

4.2. Запрос кода авторизации

Процесс получения кода авторизации начинается с клиента, перенаправляющего браузер на конечную точку авторизации сервера авторизации.

Как правило, это происходит, когда пользователь пытается получить доступ к API защищенного ресурса без авторизации или явным образом, вызвав путь client /authorize :

@WebServlet(urlPatterns = "/authorize")
public class AuthorizationCodeServlet extends HttpServlet {

@Inject
private Config config;

@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
//...
}
}

В методе doGet() мы начинаем с создания и сохранения значения состояния безопасности:

String state = UUID.randomUUID().toString();
request.getSession().setAttribute("CLIENT_LOCAL_STATE", state);

Затем мы получаем информацию о конфигурации клиента:

String authorizationUri = config.getValue("provider.authorizationUri", String.class);
String clientId = config.getValue("client.clientId", String.class);
String redirectUri = config.getValue("client.redirectUri", String.class);
String scope = config.getValue("client.scope", String.class);

Затем мы добавим эти фрагменты информации в качестве параметров запроса к конечной точке авторизации сервера авторизации:

String authorizationLocation = authorizationUri + "?response_type=code"
+ "&client_id=" + clientId
+ "&redirect_uri=" + redirectUri
+ "&scope=" + scope
+ "&state=" + state;

И, наконец, мы перенаправим браузер на этот URL:

response.sendRedirect(authorizationLocation);

После обработки запроса конечная точка авторизации сервера авторизации сгенерирует и добавит код в дополнение к полученному параметру состояния в redirect_uri и перенаправит обратно браузер http://localhost:9081/callback?code=A123&state=Y .

4.3. Запрос токена доступа

Сервлет обратного вызова клиента, /callback, начинается с проверки полученного состояния:

String localState = (String) request.getSession().getAttribute("CLIENT_LOCAL_STATE");
if (!localState.equals(request.getParameter("state"))) {
request.setAttribute("error", "The state attribute doesn't match!");
dispatch("/", request, response);
return;
}

Далее мы будем использовать ранее полученный код для запроса токена доступа через конечную точку токена сервера авторизации:

String code = request.getParameter("code");
Client client = ClientBuilder.newClient();
WebTarget target = client.target(config.getValue("provider.tokenUri", String.class));

Form form = new Form();
form.param("grant_type", "authorization_code");
form.param("code", code);
form.param("redirect_uri", config.getValue("client.redirectUri", String.class));

TokenResponse tokenResponse = target.request(MediaType.APPLICATION_JSON_TYPE)
.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeaderValue())
.post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED_TYPE), TokenResponse.class);

Как мы видим, для этого вызова нет взаимодействия с браузером, и запрос выполняется напрямую с использованием клиентского API JAX-RS в виде HTTP POST.

Поскольку конечная точка токена требует аутентификации клиента, мы включили учетные данные клиента client_id и client_secret в заголовок авторизации .

Клиент может использовать этот токен доступа для вызова API-интерфейсов сервера ресурсов, которые рассматриваются в следующем подразделе.

4.4. Защищенный доступ к ресурсам

На данный момент у нас есть действующий токен доступа, и мы можем вызывать API-интерфейсы / read и / write сервера ресурсов .

Для этого мы должны предоставить заголовок авторизации . Используя клиентский API JAX-RS, это просто делается с помощью метода Invocation.Builder header() :

resourceWebTarget = webTarget.path("resource/read");
Invocation.Builder invocationBuilder = resourceWebTarget.request();
response = invocationBuilder
.header("authorization", tokenResponse.getString("access_token"))
.get(String.class);

5. Сервер ресурсов OAuth 2.0

В этом разделе мы создадим защищенное веб-приложение на основе JAX-RS, MicroProfile JWT и MicroProfile Config. MicroProfile JWT обеспечивает проверку полученного JWT и сопоставление областей JWT с ролями Jakarta EE .

5.1. Зависимости Maven

В дополнение к зависимости [Java EE Web API](https://search.maven.org/search?q=g:javax AND a:javaee-web-api&core=gav) нам также нужны [MicroProfile Config](https://search.maven.org/search?q=g:org.eclipse.microprofile.config AND a:microprofile-config-api&core=gav) и [MicroProfile JWT](https://search.maven.org/search?q=g:org.eclipse.microprofile.jwt AND a:microprofile-jwt-auth-api&core=gav) API:

<dependency>
<groupId>javax</groupId>
<artifactId>javaee-web-api</artifactId>
<version>8.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.config</groupId>
<artifactId>microprofile-config-api</artifactId>
<version>1.3</version>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.jwt</groupId>
<artifactId>microprofile-jwt-auth-api</artifactId>
<version>1.1</version>
</dependency>

5.2. Механизм аутентификации JWT

MicroProfile JWT обеспечивает реализацию механизма аутентификации Bearer Token Authentication. Это обеспечивает обработку JWT, присутствующего в заголовке авторизации , делает доступным субъект безопасности Jakarta EE в виде JsonWebToken , который содержит утверждения JWT, и сопоставляет области с ролями Jakarta EE. Дополнительные сведения см. в Jakarta EE Security API .

Чтобы включить механизм аутентификации JWT на сервере, нам нужно добавить аннотацию LoginConfig в приложение JAX-RS:

@ApplicationPath("/api")
@DeclareRoles({"resource.read", "resource.write"})
@LoginConfig(authMethod = "MP-JWT")
public class OAuth2ResourceServerApplication extends Application {
}

Кроме того, MicroProfile JWT требуется открытый ключ RSA для проверки подписи JWT . Мы можем обеспечить это либо самоанализом, либо, для простоты, вручную скопировав ключ с сервера авторизации. В любом случае нам нужно указать местоположение открытого ключа:

mp.jwt.verify.publickey.location=/META-INF/public-key.pem

Наконец, MicroProfile JWT необходимо проверить утверждение iss входящего JWT, которое должно присутствовать и соответствовать значению свойства MicroProfile Config:

mp.jwt.verify.issuer=http://127.0.0.1:9080

Как правило, это расположение сервера авторизации.

5.3. Защищенные конечные точки

For demonstration purposes, we'll add a resource API with two endpoints. One is a read endpoint that's accessible by users having the resource.read scope and another write endpoint for users with resource.write scope.

The restriction on the scopes is done through the @RolesAllowed annotation:

@Path("/resource")
@RequestScoped
public class ProtectedResource {

@Inject
private JsonWebToken principal;

@GET
@RolesAllowed("resource.read")
@Path("/read")
public String read() {
return "Protected Resource accessed by : " + principal.getName();
}

@POST
@RolesAllowed("resource.write")
@Path("/write")
public String write() {
return "Protected Resource accessed by : " + principal.getName();
}
}

6. Running All Servers

To run one server, we just need to invoke the Maven command in the corresponding directory:

mvn package liberty:run-server

The authorization server, the client and the resource server will be running and available respectively at the following locations:

# Authorization Server
http://localhost:9080/

# Client
http://localhost:9180/

# Resource Server
http://localhost:9280/

So, we can access the client home page and then we click on “Get Access Token” to start the authorization flow. After receiving the access token, we can access the resource server's read and write APIs.

Depending on the granted scopes, the resource server will respond either by a successful message or we'll get an HTTP 403 forbidden status.

7. Conclusion

In this article, we've provided an implementation of an OAuth 2.0 Authorization Server that can be used with any compatible OAuth 2.0 Client and Resource Server.

To explain the overall framework, we have also provided an implementation for the client and the resource server. To implement all these components, we've used using Jakarta EE 8 APIs, especially, CDI, Servlet, JAX RS, Jakarta EE Security. Additionally, we have used the pseudo-Jakarta EE APIs of the MicroProfile: MicroProfile Config and MicroProfile JWT.

The full source code for the examples is available over on GitHub . Note that the code includes an example of both the authorization code and refresh token grant types.

Finally, it's important to be aware of the educational nature of this article and that the example given shouldn't be used in production systems.