1. 概述
本文将使用 Jakarta EE 与 MicroProfile 技术栈,从零实现一个完整的 OAuth 2.0 授权框架。核心目标是实现 授权码模式(Authorization Code Grant) 下各角色的交互流程。
重点实现授权服务器(Authorization Server)的三大核心接口:
- ✅ 授权接口(/authorize)
- ✅ 令牌接口(/token)
- ✅ JWK 公钥接口(/jwk),供资源服务器验证 JWT 签名
为简化实现,采用预注册客户端与用户,Token 使用 JWT 格式存储。
⚠️ 重要提醒:本文示例仅用于学习理解 OAuth 2.0 机制。生产环境请使用 Keycloak、Auth0 等成熟方案,避免自己造轮子踩坑。
2. OAuth 2.0 核心概念回顾
2.1. 四大角色
OAuth 2.0 涉及四个核心参与者:
- 资源拥有者(Resource Owner):通常是最终用户,拥有受保护资源
- 资源服务器(Resource Server):托管资源的服务,如 REST API
- 客户端(Client):代表用户访问资源的应用,如 Web 应用、移动端
- 授权服务器(Authorization Server):颁发访问令牌(Access Token)的服务
2.2. 授权模式(Grant Types)
不同客户端类型适用不同授权模式:
模式 | 适用场景 | 安全性 |
---|---|---|
✅ Authorization Code | Web 应用、原生应用、单页应用(SPA) | 高(推荐) |
🔁 Refresh Token | Web 应用刷新过期 Token | 高 |
🤝 Client Credentials | 服务间通信(无用户参与) | 中 |
🔑 Resource Owner Password | 第一方应用(如自家 App 登录) | 低(慎用) |
💡 本文聚焦最常用的 Authorization Code 模式。SPA 或原生应用应配合 PKCE 增强安全性。
2.3. 授权码流程详解
这是最经典、最安全的流程,也是本文实现的核心:
- 重定向授权:客户端将用户重定向到授权服务器的
/authorize
接口 - 用户授权:用户登录并同意授权
- 返回授权码:授权服务器重定向回客户端
redirect_uri
,携带一次性code
- 换取 Token:客户端用
code
+ 自身凭证(client_id/secret)向/token
接口换取 Access Token - 访问资源:客户端携带 Token 访问资源服务器 API
- 验证 Token:资源服务器通过 JWT 签名本地验证,或调用授权服务器的
/introspect
接口验证
2.4. Jakarta EE 的现状
目前 Jakarta EE 原生并未提供完整的 OAuth 2.0 实现,需要结合 MicroProfile 或第三方库补全。本文正是填补这一空白的实践。
3. 授权服务器实现
3.1. 客户端与用户注册
授权服务器需预先知晓客户端和用户信息。为简化,使用 SQL 预置数据:
-- 客户端:webappclient
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;
// getter/setter 省略
}
-- 用户:appuser
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;
// getter/setter 省略
}
⚠️ 示例中密码为明文,生产环境务必使用 BCrypt 等哈希算法加密存储。
3.2. 授权接口(/authorize)
该接口负责用户认证和授权确认。
认证机制
使用 Jakarta EE 8 Security 的表单认证:
@FormAuthenticationMechanismDefinition(
loginToContinue = @LoginToContinue(loginPage = "/login.jsp", errorPage = "/login.jsp")
)
用户登录后可通过 SecurityContext
获取:
Principal principal = securityContext.getCallerPrincipal();
JAX-RS 实现
@FormAuthenticationMechanismDefinition(
loginToContinue = @LoginToContinue(loginPage = "/login.jsp", errorPage = "/login.jsp")
)
@Path("authorize")
public class AuthorizationEndpoint {
@Inject
private SecurityContext securityContext;
@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();
// 必须参数
String responseType = params.getFirst("response_type");
String clientId = params.getFirst("client_id");
if (!"code".equals(responseType) || clientId == null) {
return Response.status(400).entity("Invalid parameters").build();
}
// 验证客户端是否存在
Client client = appDataRepository.getClient(clientId);
if (client == null) {
return Response.status(400).entity("Invalid client").build();
}
// 验证 redirect_uri(可选但推荐)
String redirectUri = params.getFirst("redirect_uri");
if (redirectUri != null && !redirectUri.equals(client.getRedirectUri())) {
return Response.status(400).entity("Invalid redirect_uri").build();
}
// 保存原始请求参数到 Session
request.getSession().setAttribute("ORIGINAL_PARAMS", params);
// 获取用户可授权的 scopes
Principal principal = securityContext.getCallerPrincipal();
User user = appDataRepository.getUser(principal.getName());
String requestedScope = params.getFirst("scope");
String allowedScopes = checkUserScopes(user.getScopes(), requestedScope);
// 跳转到授权确认页面
request.setAttribute("scopes", allowedScopes.split(" "));
request.getRequestDispatcher("/authorize.jsp").forward(request, response);
return Response.ok().build();
}
}
3.3. 用户授权确认
用户在 UI 上选择是否授权及具体权限范围。
处理 POST 请求
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_HTML)
public Response doPost(@Context HttpServletRequest request,
@Context HttpServletResponse response,
MultivaluedMap<String, String> formParams) throws Exception {
MultivaluedMap<String, String> originalParams =
(MultivaluedMap<String, String>) request.getSession().getAttribute("ORIGINAL_PARAMS");
String approvalStatus = formParams.getFirst("approval_status"); // YES/NO
if ("YES".equals(approvalStatus)) {
List<String> approvedScopes = formParams.get("scope");
String clientId = originalParams.getFirst("client_id");
String redirectUri = originalParams.getFirst("redirect_uri");
String state = originalParams.getFirst("state");
// 生成授权码
AuthorizationCode authCode = new AuthorizationCode();
authCode.setClientId(clientId);
authCode.setUserId(securityContext.getCallerPrincipal().getName());
authCode.setApprovedScopes(String.join(" ", approvedScopes));
authCode.setRedirectUri(redirectUri);
authCode.setExpirationDate(LocalDateTime.now().plusMinutes(2)); // 2分钟过期
appDataRepository.save(authCode);
String code = authCode.getCode();
// 重定向回客户端,携带 code 和 state
StringBuilder sb = new StringBuilder(redirectUri);
sb.append("?code=").append(code);
if (state != null) {
sb.append("&state=").append(state);
}
URI location = UriBuilder.fromUri(sb.toString()).build();
return Response.seeOther(location).build();
} else {
// 用户拒绝授权
return Response.seeOther(URI.create(redirectUri + "?error=access_denied")).build();
}
}
@Entity
@Table(name = "authorization_code")
public class AuthorizationCode {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "code")
private String code;
@Column(name = "client_id")
private String clientId;
@Column(name = "user_id")
private String userId;
@Column(name = "approved_scopes")
private String approvedScopes;
@Column(name = "redirect_uri")
private String redirectUri;
@Column(name = "expiration_date")
private LocalDateTime expirationDate;
// getter/setter 省略
}
✅ 授权码有效期极短(2分钟),防止被滥用。
3.4. 令牌接口(/token)
该接口通过授权码换取 Access Token,无需浏览器参与,纯 API 交互。
@Path("token")
public class TokenEndpoint {
private final List<String> supportedGrantTypes = Collections.singletonList("authorization_code");
@Inject
private AppDataRepository appDataRepository;
@Inject
private Instance<AuthorizationGrantTypeHandler> authorizationGrantTypeHandlers;
@Inject
private Config config;
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response token(MultivaluedMap<String, String> params,
@HeaderParam(HttpHeaders.AUTHORIZATION) String authHeader) throws JOSEException {
// 1. 验证 grant_type
String grantType = params.getFirst("grant_type");
if (!supportedGrantTypes.contains(grantType)) {
return errorResponse("unsupported_grant_type", "Supported: " + supportedGrantTypes);
}
// 2. 客户端认证(HTTP Basic)
String[] credentials = extractCredentials(authHeader);
String clientId = credentials[0];
String clientSecret = credentials[1];
Client client = appDataRepository.getClient(clientId);
if (client == null || !client.getClientSecret().equals(clientSecret)) {
return Response.status(401).entity(errorJson("invalid_client")).build();
}
// 3. 委托具体处理器(CDI 动态选择)
AuthorizationGrantTypeHandler handler =
authorizationGrantTypeHandlers.select(NamedLiteral.of(grantType)).get();
try {
TokenResponse tokenResponse = handler.createAccessToken(clientId, params);
return Response.ok(tokenResponse.toJson()).build();
} catch (Exception e) {
return errorResponse("server_error", e.getMessage());
}
}
private String[] extractCredentials(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Basic ")) {
throw new IllegalArgumentException("Invalid Authorization header");
}
String decoded = new String(Base64.getDecoder().decode(authHeader.substring(6)));
return decoded.split(":", 2);
}
}
处理器接口
public interface AuthorizationGrantTypeHandler {
TokenResponse createAccessToken(String clientId, MultivaluedMap<String, String> params) throws Exception;
}
CDI 实现选择
@Named("authorization_code")
@ApplicationScoped
public class AuthorizationCodeGrantHandler implements AuthorizationGrantTypeHandler {
// 实现 createAccessToken
}
3.5. RSA 密钥对生成与 JWK 接口
生成密钥对
# 生成私钥
openssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:2048
# 提取公钥
openssl rsa -pubout -in private-key.pem -out public-key.pem
MicroProfile 配置
# META-INF/microprofile-config.properties
signingkey=/META-INF/private-key.pem
verificationkey=/META-INF/public-key.pem
JWK 公钥接口
资源服务器需获取公钥以验证 JWT 签名。
@Path("jwk")
@ApplicationScoped
public class JWKEndpoint {
@Inject
private Config config;
@GET
public Response getKey(@QueryParam("format") String format) throws Exception {
String verificationKeyPath = config.getValue("verificationkey", String.class);
String pemPublicKey = PEMKeyUtils.readKeyAsString(verificationKeyPath);
if (format == null || "jwk".equals(format)) {
JWK jwk = JWK.parseFromPEMEncodedObjects(pemPublicKey);
return Response.ok(jwk.toJSONString()).type(MediaType.APPLICATION_JSON).build();
} else if ("pem".equals(format)) {
return Response.ok(pemPublicKey).build();
} else {
return Response.status(400).entity("Unsupported format").build();
}
}
}
Maven 依赖
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>7.7</version>
</dependency>
3.6. 生成 JWT Token 响应
使用 Nimbus JOSE+JWT 库生成签名 JWT。
构建 JWT Header
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.RS256)
.type(JOSEObjectType.JWT)
.build();
构建 JWT Payload
Instant now = Instant.now();
Date expiresAt = Date.from(now.plus(30, ChronoUnit.MINUTES));
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer("http://localhost:9080") // iss
.subject("appuser") // sub
.claim("upn", "appuser") // MicroProfile 映射 Principal
.audience("http://localhost:9280") // aud
.claim("scope", "resource.read resource.write") // scope
.claim("groups", Arrays.asList("resource.read", "resource.write")) // MicroProfile 映射 Roles
.expirationTime(expiresAt)
.notBeforeTime(Date.from(now))
.issueTime(Date.from(now))
.jwtID(UUID.randomUUID().toString())
.build();
签名 JWT
SignedJWT signedJWT = new SignedJWT(jwsHeader, claimsSet);
// 加载私钥
String signingKeyPath = config.getValue("signingkey", String.class);
String pemPrivateKey = PEMKeyUtils.readKeyAsString(signingKeyPath);
RSAKey rsaKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemPrivateKey);
// 签名
signedJWT.sign(new RSASSASigner(rsaKey.toRSAPrivateKey()));
String accessToken = signedJWT.serialize();
返回 Token 响应
JsonObject responseJson = Json.createObjectBuilder()
.add("token_type", "Bearer")
.add("access_token", accessToken)
.add("expires_in", 1800) // 30分钟
.add("scope", "resource.read resource.write")
.build();
return Response.ok(responseJson).build();
最终响应示例:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "resource.read resource.write"
}
4. OAuth 2.0 客户端实现
使用 Servlet + MicroProfile Config + JAX-RS Client 实现 Web 客户端。
4.1. 客户端配置
# META-INF/microprofile-config.properties
client.clientId=webappclient
client.clientSecret=webappclientsecret
client.redirectUri=http://localhost:9180/callback
client.scope=resource.read resource.write
provider.authorizationUri=http://localhost:9080/authorize
provider.tokenUri=http://localhost:9080/token
4.2. 请求授权码
@WebServlet("/authorize")
public class AuthorizationCodeServlet extends HttpServlet {
@Inject
private Config config;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// 生成 state 防 CSRF
String state = UUID.randomUUID().toString();
req.getSession().setAttribute("CLIENT_LOCAL_STATE", state);
// 构造授权请求 URL
String authUrl = config.getValue("provider.authorizationUri", String.class);
String url = authUrl +
"?response_type=code" +
"&client_id=" + config.getValue("client.clientId", String.class) +
"&redirect_uri=" + config.getValue("client.redirectUri", String.class) +
"&scope=" + config.getValue("client.scope", String.class) +
"&state=" + state;
resp.sendRedirect(url);
}
}
4.3. 获取 Access Token
@WebServlet("/callback")
public class CallbackServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// 1. 验证 state
String localState = (String) req.getSession().getAttribute("CLIENT_LOCAL_STATE");
String remoteState = req.getParameter("state");
if (!localState.equals(remoteState)) {
resp.sendError(400, "CSRF detected");
return;
}
// 2. 用 code 换 token
String code = req.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));
// HTTP Basic 认证
String authHeader = "Basic " + Base64.getEncoder().encodeToString(
(config.getValue("client.clientId", String.class) + ":" +
config.getValue("client.clientSecret", String.class)).getBytes()
);
TokenResponse tokenResponse = target.request(MediaType.APPLICATION_JSON)
.header(HttpHeaders.AUTHORIZATION, authHeader)
.post(Entity.form(form), TokenResponse.class);
// 存储 token 供后续使用
req.getSession().setAttribute("ACCESS_TOKEN", tokenResponse.getAccessToken());
resp.sendRedirect("/");
}
}
4.4. 访问受保护资源
// 使用 JAX-RS Client 调用资源服务器
String accessToken = (String) req.getSession().getAttribute("ACCESS_TOKEN");
WebTarget resourceTarget = ClientBuilder.newClient().target("http://localhost:9280/api");
String response = resourceTarget.path("resource/read")
.request()
.header("Authorization", "Bearer " + accessToken)
.get(String.class);
5. 资源服务器实现
5.1. Maven 依赖
<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 认证
@ApplicationPath("/api")
@DeclareRoles({"resource.read", "resource.write"})
@LoginConfig(authMethod = "MP-JWT")
public class OAuth2ResourceServerApplication extends Application {
}
5.3. MicroProfile 配置
# 公钥位置(与授权服务器一致)
mp.jwt.verify.publickey.location=/META-INF/public-key.pem
# 发行者验证
mp.jwt.verify.issuer=http://localhost:9080
5.4. 受保护接口
@Path("/resource")
@RequestScoped
public class ProtectedResource {
@Inject
private JsonWebToken principal;
@GET
@Path("/read")
@RolesAllowed("resource.read")
public String read() {
return "Hello " + principal.getName() + ", you can read!";
}
@POST
@Path("/write")
@RolesAllowed("resource.write")
public String write() {
return "Hello " + principal.getName() + ", you can write!";
}
}
✅ MicroProfile JWT 自动将 JWT 中的
groups
claim 映射为 Jakarta EE 的 Roles,完美对接@RolesAllowed
。
6. 启动与测试
分别在三个模块执行:
mvn package liberty:run-server
服务地址:
- 授权服务器:
http://localhost:9080
- 客户端:
http://localhost:9180
- 资源服务器:
http://localhost:9280
测试流程:
- 访问客户端首页
- 点击“获取 Token”触发授权流程
- 登录并授权
- 客户端获取 Token 后,可调用资源服务器的
/read
和/write
接口 - 根据授权范围,资源服务器返回 200 或 403
7. 总结
本文完整实现了基于 Jakarta EE 和 MicroProfile 的 OAuth 2.0 授权码模式:
- ✅ 授权服务器:
/authorize
、/token
、/jwk
- ✅ 客户端:完整授权码流程
- ✅ 资源服务器:JWT 验证与角色映射
技术栈涵盖:CDI、JAX-RS、Jakarta Security、MicroProfile Config/JWT、Nimbus JOSE+JWT。
⚠️ 再次强调:本文为教学演示,切勿直接用于生产环境。生产系统应使用 Keycloak 等成熟方案,避免安全漏洞。