1. 概述

本文将使用 Jakarta EEMicroProfile 技术栈,从零实现一个完整的 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. 授权码流程详解

这是最经典、最安全的流程,也是本文实现的核心:

  1. 重定向授权:客户端将用户重定向到授权服务器的 /authorize 接口
  2. 用户授权:用户登录并同意授权
  3. 返回授权码:授权服务器重定向回客户端 redirect_uri,携带一次性 code
  4. 换取 Token:客户端用 code + 自身凭证(client_id/secret)向 /token 接口换取 Access Token
  5. 访问资源:客户端携带 Token 访问资源服务器 API
  6. 验证 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

测试流程:

  1. 访问客户端首页
  2. 点击“获取 Token”触发授权流程
  3. 登录并授权
  4. 客户端获取 Token 后,可调用资源服务器的 /read/write 接口
  5. 根据授权范围,资源服务器返回 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 等成熟方案,避免安全漏洞。


原始标题:Implementing the Oauth 2.0 Authorization Framework Using Jakarta EE | Baeldung