1. Overview
1.概述
In this tutorial, we’re going to provide an implementation for the OAuth 2.0 Authorization Framework using Jakarta EE And MicroProfile. Most importantly, we’re going to implement the interaction of the OAuth 2.0 roles through the Authorization Code grant type. The motivation behind this writing is to give support for projects that are implemented using Jakarta EE as this doesn’t yet provide support for OAuth.
在本教程中,我们将使用Jakarta EE和MicroProfile为OAuth 2.0授权框架提供一个实现。最重要的是,我们将通过OAuth 2.0角色的授权代码授予类型来实现其交互。写这篇文章的动机是为了给那些使用Jakarta EE实现的项目提供支持,因为这还没有提供对OAuth的支持。
For the most important role, the Authorization Server, we’re going to implement the Authorization Endpoint, the Token Endpoint and additionally, the JWK Key Endpoint, which is useful for the Resource Server to retrieve the public key.
对于最重要的角色–授权服务器,我们将实现授权端点、令牌端点,此外还有JWK密钥端点,这对资源服务器检索公钥很有用。
As we want the implementation to be simple and easy for a quick setup, we’re going to use a pre-registered store of clients and users, and obviously a JWT store for access tokens.
由于我们希望实现简单易行,便于快速设置,我们将使用一个预注册的客户和用户存储,显然还有一个JWT存储用于访问令牌。
Before jumping right into the topic, it’s important to note that the example in this tutorial is for educational purposes. For production systems, it’s highly recommended to use a mature, well-tested solution such as Keycloak.
在直接进入主题之前,需要注意的是,本教程中的例子是出于教育目的。对于生产系统,我们强烈建议使用成熟的、经过测试的解决方案,如Keycloak。
2. OAuth 2.0 Overview
2.OAuth 2.0概述
In this section, we’re going to give a brief overview of the OAuth 2.0 roles and the Authorization Code grant flow.
在本节中,我们将简要介绍OAuth 2.0的角色和授权码授予流程。
2.1. Roles
2.1 角色
The OAuth 2.0 framework implies the collaboration between the four following roles:
OAuth 2.0框架意味着以下四个角色之间的合作。
- Resource Owner: Usually, this is the end-user – it’s the entity that has some resources worth protecting
- Resource Server: An service that protects the resource owner’s data, usually publishing it through a REST API
- Client: An application that uses the resource owner’s data
- Authorization Server: An application that grants permission – or authority – to clients in the form of expiring tokens
2.2. Authorization Grant Types
2.2.授权许可类型
A grant type is how a client gets permission to use the resource owner’s data, ultimately in the form of an access token.
授予类型是客户如何获得使用资源所有者数据的许可,最终以访问令牌的形式出现。
Naturally, different types of clients prefer different types of grants:
当然,不同类型的客户更喜欢不同类型的补助金。
- Authorization Code: Preferred most often – whether it is a web application, a native application, or a single-page application, though native and single-page apps require additional protection called PKCE
- Refresh Token: A special renewal grant, suitable for web applications to renew their existing token
- Client Credentials: Preferred for service-to-service communication, say when the resource owner isn’t an end-user
- Resource Owner Password: Preferred for the first-party authentication of native applications, say when the mobile app needs its own login page
In addition, the client can use the implicit grant type. However, it’s usually more secure to use the authorization code grant with PKCE.
此外,客户端可以使用implicit授予类型。然而,通常使用PKCE的授权码授予会更安全。
2.3. Authorization Code Grant Flow
2.3.授权码授予流程
Since the authorization code grant flow is the most common, let’s also review how that works, and that’s actually what we’ll build in this tutorial.
由于授权代码授予流程是最常见的,我们也来回顾一下它是如何工作的,这实际上是我们将在本教程中构建的。。
An application – a client – requests permission by redirecting to the authorization server’s /authorize endpoint. To this endpoint, the application gives a callback endpoint.
一个应用程序–客户端–通过重定向到授权服务器的/authorize端点来请求权限。对于这个端点,应用程序给出一个callback端点。
The authorization server will usually ask the end-user – the resource owner – for permission. If the end-user grants permission, then the authorization server redirects back to the callback with a code.
授权服务器通常会向终端用户–资源所有者–征求许可。如果最终用户给予许可,那么授权服务器就会带着一个代码重定向回调。
The application receives this code and then makes an authenticated call to the authorization server’s /token endpoint. By “authenticated”, we mean that the application proves who it is as part of this call. If all appears in order, the authorization server responds with the token.
应用程序收到该代码,然后对授权服务器的/token 端点进行认证调用。通过 “认证”,我们的意思是,应用程序在这个调用中证明了它是谁。如果一切正常,授权服务器会以令牌作为回应。
With the token in hand, the application makes its request to the API – the resource server – and that API will verify the token. It can ask the authorization server to verify the token using its /introspect endpoint. Or, if the token is self-contained, the resource server can optimize by locally verifying the token’s signature, as is the case with JWT.
有了令牌,应用程序向API–资源服务器–发出请求,该API将验证令牌。它可以要求授权服务器使用其/introspect端点来验证该令牌。或者,如果令牌是独立的,资源服务器可以通过本地验证令牌的签名来进行优化,正如JWT的情况一样。
2.4. What Does Jakarta EE Support?
2.4.Jakarta EE支持什么?
Not much, yet. In this tutorial, we’ll build most things from the ground up.
不多,还没有。在本教程中,我们将从头开始构建大多数东西。
3. OAuth 2.0 Authorization Server
3.OAuth 2.0授权服务器
In this implementation, we’ll focus on the most commonly used grant type: Authorization Code.
在这个实现中,我们将专注于最常用的授予类型。授权代码。
3.1. Client and User Registration
3.1.客户和用户注册
An authorization server would, of course, need to know about the clients and users before it can authorize their requests. And it’s common for an authorization server to have a UI for this.
当然,授权服务器需要了解客户和用户的情况,然后才能授权他们的请求。而授权服务器通常会有一个用户界面来实现这一点。
For simplicity, though, we’ll use a pre-configured client:
不过,为了简单起见,我们将使用一个预配置的客户端。
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;
// ...
}
And a pre-configured user:
还有一个预先配置的用户。
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;
// ...
}
Note that for the sake of this tutorial, we’ve used passwords in plain text, but in a production environment, they should be hashed.
请注意,在本教程中,我们使用了纯文本的密码,但在生产环境中,它们应该是散列的。
For the rest of this tutorial, we’ll show how appuser – the resource owner – can grant access to webappclient – the application – by implementing the Authorization Code.
在本教程的其余部分,我们将展示appuser–资源所有者–如何通过实现授权代码授予webappclient–应用程序的访问权。
3.2. Authorization Endpoint
3.2.授权端点
The main role of the authorization endpoint is to first authenticate the user and then ask for the permissions – or scopes – that the application wants.
授权端点的主要作用是首先认证用户,然后要求获得应用程序想要的权限–或范围。
As instructed by the OAuth2 specs, this endpoint should support the HTTP GET method, although it can also support the HTTP POST method. In this implementation, we’ll support only the HTTP GET method.
正如OAuth2规范所指示的那样,这个端点应该支持HTTP GET方法,尽管它也可以支持HTTP POST方法。在这个实现中,我们将只支持HTTP GET方法。
First, the authorization endpoint requires that the user be authenticated. The spec doesn’t require a certain way here, so let’s use Form Authentication from the Jakarta EE 8 Security API:
首先,授权端点要求用户经过认证。规范在这里并没有要求采用某种方式,因此让我们使用Jakarta EE 8 Security API中的表单验证。
@FormAuthenticationMechanismDefinition(
loginToContinue = @LoginToContinue(loginPage = "/login.jsp", errorPage = "/login.jsp")
)
The user will be redirected to /login.jsp for authentication and then will be available as a CallerPrincipal through the SecurityContext API:
用户将被重定向到/login.jsp进行认证,然后通过SecurityContext API作为CallerPrincipal可用。
Principal principal = securityContext.getCallerPrincipal();
We can put these together using JAX-RS:
我们可以使用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();
// ...
}
}
At this point, the authorization endpoint can start processing the application’s request, which must contain response_type and client_id parameters and – optionally, but recommended – the redirect_uri, scope, and state parameters.
在这一点上,授权端点可以开始处理应用程序的请求,其中必须包含response_type和client_id参数,以及–可以选择,但建议–redirect_uri、范围、和state参数。
The client_id should be a valid client, in our case from the clients database table.
client_id应该是一个有效的客户,在我们的例子中是来自clients数据库表。
The redirect_uri, if specified, should also match what we find in the clients database table.
如果指定了redirect_uri,也应该与我们在clients数据库表中发现的相匹配。
And, because we’re doing Authorization Code, response_type is code.
而且,因为我们正在做授权代码,响应_类型是代码。回应类型是代码。
Since authorization is a multi-step process, we can temporarily store these values in the session:
由于授权是一个多步骤的过程,我们可以在会话中临时存储这些值。
request.getSession().setAttribute("ORIGINAL_PARAMS", params);
And then prepare to ask the user which permissions the application may use, redirecting to that page:
然后准备询问用户该应用程序可以使用哪些权限,重定向到该页面。
String allowedScopes = checkUserScopes(user.getScopes(), requestedScope);
request.setAttribute("scopes", allowedScopes);
request.getRequestDispatcher("/authorize.jsp").forward(request, response);
3.3. User Scopes Approval
3.3.用户范围批准
At this point, the browser renders an authorization UI for the user, and the user makes a selection. Then, the browser submits the user’s selection in an HTTP POST:
这时,浏览器为用户渲染一个授权用户界面,用户做出选择。然后,浏览器将用户的选择提交给一个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");
// ...
}
Next, we generate a temporary code that refers to the user_id, client_id, and redirect_uri, all of which the application will use later when it hits the token endpoint.
接下来,我们生成一个临时代码,引用user_id、client_id、和redirect_uri,所有这些代码将在稍后的应用程序点击token端点时使用。
So let’s create an AuthorizationCode JPA Entity with an auto-generated id:
因此,让我们创建一个AuthorizationCode JPA实体,它有一个自动生成的id:。
@Entity
@Table(name ="authorization_code")
public class AuthorizationCode {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
@Column(name = "code")
private String code;
//...
}
And then populate it:
然后填充它。
AuthorizationCode authorizationCode = new AuthorizationCode();
authorizationCode.setClientId(clientId);
authorizationCode.setUserId(userId);
authorizationCode.setApprovedScopes(String.join(" ", authorizedScopes));
authorizationCode.setExpirationDate(LocalDateTime.now().plusMinutes(2));
authorizationCode.setRedirectUri(redirectUri);
When we save the bean, the code attribute is auto-populated, and so we can get it and send it back to the client:
当我们保存Bean时,代码属性是自动填充的,因此我们可以得到它并将它送回给客户端。
appDataRepository.save(authorizationCode);
String code = authorizationCode.getCode();
Note that our authorization code will expire in two minutes – we should be as conservative as we can with this expiration. It can be short since the client is going to exchange it right away for an access token.
请注意,我们的授权码将在两分钟后过期–我们应该尽可能地保守这个过期时间。它可以很短,因为客户要马上把它换成一个访问令牌。
We then redirect back to the application’s redirect_uri, giving it the code as well as any state parameter that the application specified in its /authorize request:
然后我们重定向到应用程序的redirect_uri,给它代码以及应用程序在其/authorize请求中指定的任何state参数。
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();
Note again that redirectUri is whatever exists in the clients table, not the redirect_uri request parameter.
再次注意,redirectUri是存在于clients表中的任何东西,而不是redirect_urirequest参数。
So, our next step is for the client to receive this code and exchange it for an access token using the token endpoint.
因此,我们的下一步是让客户接收这个代码,并使用令牌端点将其交换为访问令牌。
3.4. Token Endpoint
3.4.令牌端点
As opposed to the authorization endpoint, the token endpoint doesn’t need a browser to communicate with the client, and we’ll, therefore, implement it as a JAX-RS endpoint:
与授权端点相比,令牌端点不需要浏览器来与客户端通信,因此我们将把它实现为一个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 {
//...
}
}
The token endpoint requires a POST, as well as encoding the parameters using the application/x-www-form-urlencoded media type.
令牌端点需要一个POST,以及使用application/x-www-form-urlencoded媒体类型对参数进行编码。
As we discussed, we’ll be supporting only the authorization code grant type:
正如我们所讨论的,我们将只支持授权码授予类型。
List<String> supportedGrantTypes = Collections.singletonList("authorization_code");
So, the received grant_type as a required parameter should be supported:
因此,应该支持将收到的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();
}
Next, we check the client authentication through via HTTP Basic authentication. That is, we check if the received client_id and client_secret, through the Authorization header, matches a registered client:
接下来,我们通过HTTP基本认证来检查客户端的认证。也就是说,我们检查如果收到的client_id和client_secret,通过Authorizationheader,匹配一个注册的客户端:。
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();
}
Finally, we delegate the production of the TokenResponse to a corresponding grant type handler:
最后,我们将TokenResponse的生产委托给一个相应的授予类型处理程序。
public interface AuthorizationGrantTypeHandler {
TokenResponse createAccessToken(String clientId, MultivaluedMap<String, String> params) throws Exception;
}
As we’re more interested in the authorization code grant type, we’ve provided an adequate implementation as a CDI bean and decorated it with the Named annotation:
由于我们对授权码授予类型更感兴趣,我们提供了一个足够的实现作为CDI bean,并用Named注解来装饰它。
@Named("authorization_code")
At runtime, and according to the received grant_type value, the corresponding implementation is activated through the CDI Instance mechanism:
在运行时,根据收到的grant_type值,通过CDI Instance机制激活相应的实现。
String grantType = params.getFirst("grant_type");
//...
AuthorizationGrantTypeHandler authorizationGrantTypeHandler =
authorizationGrantTypeHandlers.select(NamedLiteral.of(grantType)).get();
It’s now time to produce /token‘s response.
现在是产生/token的响应的时候了。
3.5. RSA Private and Public Keys
3.5.RSA 私钥和公钥
Before generating the token, we need an RSA private key for signing tokens.
在生成令牌之前,我们需要一个用于签署令牌的RSA私钥。。
For this purpose, we’ll be using OpenSSL:
为了这个目的,我们将使用OpenSSL。
# PRIVATE KEY
openssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:2048
The private-key.pem is provided to the server through the MicroProfile Config signingKey property using the file META-INF/microprofile-config.properties:
private-key.pem通过MicroProfile配置signingKey属性提供给服务器,使用文件META-INF/Microprofile-config.properties:。
signingkey=/META-INF/private-key.pem
The server can read the property using the injected Config object:
服务器可以使用注入的Config对象读取该属性。
String signingkey = config.getValue("signingkey", String.class);
Similarly, we can generate the corresponding public key:
同样地,我们可以生成相应的公钥。
# PUBLIC KEY
openssl rsa -pubout -in private-key.pem -out public-key.pem
And use the MicroProfile Config verificationKey to read it:
并使用MicroProfile配置verificationKey来读取它。
verificationkey=/META-INF/public-key.pem
The server should make it available for the resource server for the purpose of verification. This is done through a JWK endpoint.
服务器应将其提供给资源服务器用于验证的目的。这是通过JWK端点来完成的。
Nimbus JOSE+JWT is a library that can be a big help here. Let’s first add the nimbus-jose-jwt dependency:
Nimbus JOSE+JWT是一个库,在这里可以起到很大的帮助。让我们首先添加的nimbus-jose-jwt依赖。
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>7.7</version>
</dependency>
And now, we can leverage Nimbus’s JWK support to simplify our endpoint:
而现在,我们可以利用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();
}
//...
}
}
We’ve used the format parameter to switch between the PEM and JWK formats. The MicroProfile JWT which we’ll use for implementing the resource server supports both these formats.
我们使用格式parameter在PEM和JWK格式之间切换。我们将用于实现资源服务器的MicroProfile JWT支持这两种格式。
3.6. Token Endpoint Response
3.6.令牌端点响应
It’s now time for a given AuthorizationGrantTypeHandler to create the token response. In this implementation, we’ll support only the structured JWT Tokens.
现在是给定的AuthorizationGrantTypeHandler创建令牌响应的时候了。在这个实现中,我们将只支持结构化的JWT令牌。
For creating a token in this format, we’ll again use the Nimbus JOSE+JWT library, but there are numerous other JWT libraries, too.
对于创建这种格式的令牌,我们将再次使用Nimbus JOSE+JWT库,但也有无数其他JWT库。
So, to create a signed JWT, we first have to construct the JWT header:
因此,要创建一个签名的JWT,我们首先要构建JWT头:。
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).build();
Then, we build the payload which is a Set of standardized and custom claims:
然后,我们建立有效载荷,它是一个标准化和自定义索赔的集。
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);
In addition to the standard JWT claims, we’ve added two more claims – upn and groups – as they’re needed by the MicroProfile JWT. The upn will be mapped to the Jakarta EE Security CallerPrincipal and the groups will be mapped to Jakarta EE Roles.
除了标准的JWT请求外,我们还增加了两个请求–upn和groups,因为MicroProfile JWT需要它们。upn将被映射到Jakarta EE安全CallerPrincipal,groups将被映射到Jakarta EE Roles。
Now that we have the header and the payload, we need to sign the access token with an RSA private key. The corresponding RSA public key will be exposed through the JWK endpoint or made available by other means so that the resource server can use it to verify the access token.
现在我们有了头和有效载荷,我们需要用RSA私钥签署访问令牌。相应的RSA公钥将通过JWK端点公开或通过其他方式提供,以便资源服务器可以使用它来验证访问令牌。
As we’ve provided the private key as a PEM format, we should retrieve it and transform it into an RSAPrivateKey:
由于我们已经提供了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);
Next, we sign and serialize the JWT:
接下来,我们签署并序列化JWT:。
signedJWT.sign(new RSASSASigner(rsaKey.toRSAPrivateKey()));
String accessToken = signedJWT.serialize();
And finally we construct a token response:
最后,我们构建了一个标记性的响应:。
return Json.createObjectBuilder()
.add("token_type", "Bearer")
.add("access_token", accessToken)
.add("expires_in", expiresInMin * 60)
.add("scope", authorizationCode.getApprovedScopes())
.build();
which is, thanks to JSON-P, serialized to JSON format and sent to the client:
由于JSON-P的存在,它被序列化为JSON格式并被发送到客户端。
{
"access_token": "acb6803a48114d9fb4761e403c17f812",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "resource.read resource.write"
}
4. OAuth 2.0 Client
4.OAuth 2.0客户端
In this section, we’ll be building a web-based OAuth 2.0 Client using the Servlet, MicroProfile Config, and JAX RS Client APIs.
在本节中,我们将使用Servlet、MicroProfile配置和JAX RS客户端API,构建一个基于Web的OAuth 2.0客户端。
More precisely, we’ll be implementing two main servlets: one for requesting the authorization server’s authorization endpoint and getting a code using the authorization code grant type, and another servlet for using the received code and requesting an access token from the authorization server’s token endpoint.
更确切地说,我们将实现两个主要的Servlet:一个用于请求授权服务器的授权端点,并使用授权码授予类型获得一个代码,另一个Servlet用于使用收到的代码,并从授权服务器的token端点请求一个访问令牌。
Additionally, we’ll be implementing two more servlets: One for getting a new access token using the refresh token grant type, and another for accessing the resource server’s APIs.
此外,我们还将实现另外两个Servlet。一个用于使用刷新令牌授予类型获取新的访问令牌,另一个用于访问资源服务器的API。
4.1. OAuth 2.0 Client Details
4.1.OAuth 2.0客户端细节
As the client is already registered within the authorization server, we first need to provide the client registration information:
由于客户端已经在授权服务器内注册,我们首先需要提供客户端的注册信息。
- client_id: Client Identifier and it’s usually issued by the authorization server during the registration process.
- client_secret: Client Secret.
- redirect_uri: Location where to receive the authorization code.
- scope: Client requested permissions.
Additionally, the client should know the authorization server’s authorization and token endpoints:
此外,客户端应该知道授权服务器的授权和令牌端点。
- authorization_uri: Location of the authorization server authorization endpoint that we can use to get a code.
- token_uri: Location of the authorization server token endpoint that we can use to get a token.
All this information is provided through the MicroProfile Config file, META-INF/microprofile-config.properties:
所有这些信息都通过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. Authorization Code Request
4.2.授权码请求
The flow of getting an authorization code starts with the client by redirecting the browser to the authorization server’s authorization endpoint.
获取授权代码的流程从客户端开始,将浏览器重定向到授权服务器的授权端点。
Typically, this happens when the user tries to access a protected resource API without authorization, or by explicitly by invoking the client /authorize path:
通常情况下,当用户试图在没有授权的情况下访问受保护的资源API,或者通过调用客户端/authorize路径明确地访问受保护的资源时,就会发生这种情况。
@WebServlet(urlPatterns = "/authorize")
public class AuthorizationCodeServlet extends HttpServlet {
@Inject
private Config config;
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
//...
}
}
In the doGet() method, we start by generating and storing a security state value:
在doGet()方法中,我们首先生成并存储了一个安全状态值。
String state = UUID.randomUUID().toString();
request.getSession().setAttribute("CLIENT_LOCAL_STATE", state);
Then, we retrieve the client configuration information:
然后,我们检索客户的配置信息。
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);
We’ll then append these pieces of information as query parameters to the authorization server’s authorization endpoint:
然后,我们将把这些信息作为查询参数附加到授权服务器的授权端点。
String authorizationLocation = authorizationUri + "?response_type=code"
+ "&client_id=" + clientId
+ "&redirect_uri=" + redirectUri
+ "&scope=" + scope
+ "&state=" + state;
And finally, we’ll redirect the browser to this URL:
最后,我们将把浏览器重定向到这个URL。
response.sendRedirect(authorizationLocation);
After processing the request, the authorization server’s authorization endpoint will generate and append a code, in addition to the received state parameter, to the redirect_uri and will redirect back the browser http://localhost:9081/callback?code=A123&state=Y.
在处理完请求后,授权服务器的授权端点将生成并附加一个代码,除了收到的状态参数外,还有redirect_uri,并将重定向回浏览器http://localhost:9081/callback?code=A123&state=Y。
4.3. Access Token Request
4.3.访问令牌请求
The client callback servlet, /callback, begins by validating the received state:
客户端回调Servlet,/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;
}
Next, we’ll use the code we previously received to request an access token through the authorization server’s token endpoint:
接下来,我们将使用之前收到的代码,通过授权服务器的令牌端点请求一个访问令牌。
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);
As we can see, there’s no browser interaction for this call, and the request is made directly using the JAX-RS client API as an HTTP POST.
我们可以看到,这个调用没有浏览器的交互,请求是直接使用JAX-RS客户端API作为HTTP POST发出的。
As the token endpoint requires the client authentication, we have included the client credentials client_id and client_secret in the Authorization header.
由于令牌端点需要客户端认证,我们在Authorization头中包含了客户端凭证client_id和client_secret。
The client can use this access token to invoke the resource server APIs which is the subject of the next subsection.
客户端可以使用这个访问令牌来调用资源服务器的API,这是下一小节的主题。
4.4. Protected Resource Access
4.4.受保护资源的获取
At this point, we have a valid access token and we can call the resource server’s /read and /write APIs.
在这一点上,我们有一个有效的访问令牌,我们可以调用资源服务器的/读和/写 API。
To do that, we have to provide the Authorization header. Using the JAX-RS Client API, this is simply done through the Invocation.Builder header() method:
要做到这一点,我们必须提供Authorization头。使用JAX-RS客户端API,这可以通过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 Resource Server
5.OAuth 2.0资源服务器
In this section, we’ll be building a secured web application based on JAX-RS, MicroProfile JWT, and MicroProfile Config. The MicroProfile JWT takes care of validating the received JWT and mapping the JWT scopes to Jakarta EE roles.
在本节中,我们将在JAX-RS、MicroProfile JWT和MicroProfile配置的基础上构建一个安全的Web应用。MicroProfile JWT负责验证收到的JWT,并将JWT作用域映射到Jakarta EE角色。
5.1. Maven Dependencies
5.1.Maven的依赖性
In addition to the Java EE Web API dependency, we need also the MicroProfile Config and MicroProfile JWT APIs:
除了Java EE Web API依赖性之外,我们还需要MicroProfile配置和MicroProfile JWT 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 Authentication Mechanism
5.2.JWT认证机制
The MicroProfile JWT provides an implementation of the Bearer Token Authentication mechanism. This takes care of processing the JWT present in the Authorization header, makes available a Jakarta EE Security Principal as a JsonWebToken which holds the JWT claims, and maps the scopes to Jakarta EE roles. Take a look at the Jakarta EE Security API for more background.
MicroProfile JWT提供了承载令牌认证机制的实现。它负责处理Authorization头中的JWT,将Jakarta EE安全委托人作为JsonWebToken提供,该委托人持有JWT请求,并将作用域映射到Jakarta EE角色。请看Jakarta EE Security API以了解更多背景。
To enable the JWT authentication mechanism in the server, we need to add the LoginConfig annotation in the JAX-RS application:
为了在服务器中启用JWT认证机制,我们需要在JAX-RS应用程序中添加LoginConfig注解。
@ApplicationPath("/api")
@DeclareRoles({"resource.read", "resource.write"})
@LoginConfig(authMethod = "MP-JWT")
public class OAuth2ResourceServerApplication extends Application {
}
Additionally, MicroProfile JWT needs the RSA public key in order to verify the JWT signature. We can provide this either by introspection or, for simplicity, by manually copying the key from the authorization server. In either case, we need to provide the location of the public key:
此外,MicroProfile JWT需要RSA公钥,以验证JWT签名。我们可以通过自省来提供,或者为了简单起见,从授权服务器上手动复制密钥。在这两种情况下,我们都需要提供公钥的位置。
mp.jwt.verify.publickey.location=/META-INF/public-key.pem
Finally, the MicroProfile JWT needs to verify the iss claim of the incoming JWT, which should be present and match the value of the MicroProfile Config property:
最后,MicroProfile JWT需要验证传入的JWT的iss要求,它应该存在并与MicroProfile配置属性的值相匹配。
mp.jwt.verify.issuer=http://127.0.0.1:9080
Typically, this is the location of the Authorization Server.
通常情况下,这是授权服务器的位置。
5.3. The Secured Endpoints
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.
出于示范目的,我们将添加一个有两个端点的资源API。一个是读端点,供具有resource.read范围的用户访问,另一个写端点供具有resource.write范围的用户访问。
The restriction on the scopes is done through the @RolesAllowed annotation:
对作用域的限制是通过@RolesAllowed注解完成的。
@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
6.运行所有服务器
To run one server, we just need to invoke the Maven command in the corresponding directory:
要运行一台服务器,我们只需在相应目录下调用Maven命令。
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.
因此,我们可以访问客户端主页,然后我们点击 “获取访问令牌 “来启动授权流程。收到访问令牌后,我们可以访问资源服务器的读和写 API。
Depending on the granted scopes, the resource server will respond either by a successful message or we’ll get an HTTP 403 forbidden status.
根据授予的作用域,资源服务器将回应一个成功的消息或我们将得到一个HTTP 403禁止的状态。
7. Conclusion
7.结语
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.
在这篇文章中,我们提供了一个OAuth 2.0授权服务器的实现,可以与任何兼容的OAuth 2.0客户端和资源服务器一起使用。
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.
为了解释整个框架,我们还提供了一个客户端和资源服务器的实现。为了实现所有这些组件,我们使用了Jakarta EE 8的API,特别是CDI、Servlet、JAX RS、Jakarta EE Security。此外,我们还使用了MicroProfile的伪Jakarta EE APIs。MicroProfile配置和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.
这些示例的完整源代码可在GitHub上获取。请注意,该代码包括授权代码和刷新令牌授予类型的示例。
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.
最后,重要的是要意识到这篇文章的教育性质,所举的例子不应该用于生产系统。