使用Java EE和OIDC构建Java REST API

“我喜欢编写身份验证和授权代码。” 〜从来没有Java开发人员。 厌倦了一次又一次地建立相同的登录屏幕? 尝试使用Okta API进行托管身份验证,授权和多因素身份验证。

Java EE允许您使用JAX-RS和JPA快速轻松地构建Java REST API。 Java EE是保护伞标准规范,它描述了许多Java技术,包括EJB,JPA,JAX-RS和许多其他技术。 它最初旨在允许Java应用程序服务器之间的可移植性,并在2000年代初期蓬勃发展。 那时,应用服务器非常流行,并且由许多知名公司(例如IBM,BEA和Sun)提供。 JBoss是一家新兴公司,它破坏了现状,并表明可以将Java EE应用程序服务器开发为一个开源项目,并免费提供它。 JBoss在2006年被RedHat收购。

在2000年代初期,Java开发人员使用servlet和EJB来开发其服务器应用程序。 Hibernate和Spring分别于2002年和2004年问世。 两种技术都对各地的Java开发人员产生了巨大的影响,这表明他们有可能在没有EJB的情况下编写分布式,健壮的应用程序。 Hibernate的POJO模型最终被用作JPA标准,并且对EJB的影响也很大。

快进到2018年,Java EE肯定不像以前那样! 现在,它主要是POJO和注释,并且使用起来更简单。

为什么要使用Java EE而不是Spring Boot构建Java REST API?

Spring Boot是Java生态系统中我最喜欢的技术之一。 它极大地减少了Spring应用程序中必需的配置,并使得仅用几行代码即可生成REST API。 但是,最近有一些不使用Spring Boot的开发人员提出了很多API安全性问题。 其中一些甚至没有使用Spring!

基于这个原因,我认为构建一个Java REST API(使用Java EE)很有趣,该API与我过去开发的Spring Boot REST API相同。 即,我的Bootiful AngularBootiful React帖子中的“啤酒” API。

使用Java EE构建Java REST API

首先,我在Twitter上询问了我的网络,是否存在诸如start.spring.io之类的Java EE快速入门。 我收到了一些建议,并开始进行一些研究。 David Blevins建议我看一下tomee-jaxrs-starter-project ,所以我从那里开始。 我还研究了Roberto Cortez推荐的TomEE Maven原型

我喜欢jaxrs-starter项目,因为它展示了如何使用JAX-RS创建REST API。 TomEE Maven原型也很有用,特别是因为它展示了如何使用JPA,H2和JSF。 我将两者结合起来,创建了自己的最小启动器,可用于在TomEE上实现安全的Java EE API。 您不必在这些示例中使用TomEE,但我尚未在其他实现上对其进行测试。

如果您在其他应用服务器上使用了这些示例,请告诉我,我将更新此博客文章。

在这些示例中,我将使用Java 8和Java EE 7.0以及TomEE 7.1.0。 TomEE 7.x是EE 7兼容版本; 有一个TomEE 8.x分支用于EE8兼容性工作,但尚无发行版本。 我希望您也安装了Apache Maven

首先,将我们的Java EE REST API存储库克隆到您的硬盘驱动器,然后运行它:

git clone https://github.com/oktadeveloper/okta-java-ee-rest-api-example.git javaee-rest-api
cd javaee-rest-api
mvn package tomee:run

导航到http:// localhost:8080并添加新啤酒。

Java REST API

单击添加 ,您应该看到成功消息。

Java REST API

单击查看存在的啤酒查看啤酒的完整列表。

Java REST API
您还可以在http://localhost:8080/good-beers查看系统中的优质啤酒列表。 以下是使用HTTPie时的输出。

$ http :8080/good-beers
HTTP/1.1 200
Content-Type: application/json
Date: Wed, 29 Aug 2018 21:58:23 GMT
Server: Apache TomEE
Transfer-Encoding: chunked
[
    {
        "id": 101,
        "name": "Kentucky Brunch Brand Stout"
    },
    {
        "id": 102,
        "name": "Marshmallow Handjee"
    },
    {
        "id": 103,
        "name": "Barrel-Aged Abraxas"
    },
    {
        "id": 104,
        "name": "Heady Topper"
    },
    {
        "id": 108,
        "name": "White Rascal"
    }
]

使用Java EE构建REST API

我向您展示了该应用程序可以做什么,但是我还没有谈论它是如何构建的。 它有一些XML配置文件,但我将跳过其中的大多数。 目录结构如下所示:

$ tree .
.
├── LICENSE
├── README.md
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── okta
    │   │           └── developer
    │   │               ├── Beer.java
    │   │               ├── BeerBean.java
    │   │               ├── BeerResource.java
    │   │               ├── BeerService.java
    │   │               └── StartupBean.java
    │   ├── resources
    │   │   └── META-INF
    │   │       └── persistence.xml
    │   └── webapp
    │       ├── WEB-INF
    │       │   ├── beans.xml
    │       │   └── faces-config.xml
    │       ├── beer.xhtml
    │       ├── index.jsp
    │       └── result.xhtml
    └── test
        └── resources
            └── arquillian.xml

12 directories, 16 files

最重要的XML文件是pom.xml ,它定义了依赖关系,并允许您运行TomEE Maven插件。 它非常简短,可爱,只有一个依赖项和一个插件。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.okta.developer</groupId>
    <artifactId>java-ee-rest-api</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>
    <name>Java EE Webapp with JAX-RS API</name>
    <url>http://developer.okta.com</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.target>1.8</maven.compiler.target>
        <maven.compiler.source>1.8</maven.compiler.source>
        <failOnMissingWebXml>false</failOnMissingWebXml>
        <javaee-api.version>7.0</javaee-api.version>
        <tomee.version>7.1.0</tomee.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>javax</groupId>
            <artifactId>javaee-api</artifactId>
            <version>${javaee-api.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.tomee.maven</groupId>
                <artifactId>tomee-maven-plugin</artifactId>
                <version>${tomee.version}</version>
                <configuration>
                    <context>ROOT</context>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

主要实体是Beer.java

package com.okta.developer;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Beer {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private int id;
    private String name;

    public Beer() {}

    public Beer(String name) {
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String beerName) {
        this.name = beerName;
    }

    @Override
    public String toString() {
        return "Beer{" +
                "id=" + id +
                ", name='" + name + '\'' +
                '}';
    }
}

数据库(aka,数据源)在src/main/resources/META-INF/persistence.xml

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.0" xmlns="http://java.sun.com/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
    <persistence-unit name="beer-pu" transaction-type="JTA">
        <jta-data-source>beerDatabase</jta-data-source>
        <class>com.okta.developer.Beer</class>
        <properties>
            <property name="openjpa.jdbc.SynchronizeMappings" value="buildSchema(ForeignKeys=true)"/>
        </properties>
    </persistence-unit>
</persistence>

BeerService.java类使用JPA的EntityManager处理该实体的读取并将其保存到数据库中。

package com.okta.developer;

import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.Query;
import javax.persistence.criteria.CriteriaQuery;
import java.util.List;

@Stateless
public class BeerService {

    @PersistenceContext(unitName = "beer-pu")
    private EntityManager entityManager;

    public void addBeer(Beer beer) {
        entityManager.persist(beer);
    }

    public List<Beer> getAllBeers() {
        CriteriaQuery<Beer> cq = entityManager.getCriteriaBuilder().createQuery(Beer.class);
        cq.select(cq.from(Beer.class));
        return entityManager.createQuery(cq).getResultList();
    }

    public void clear() {
        Query removeAll = entityManager.createQuery("delete from Beer");
        removeAll.executeUpdate();
    }
}

有一个StartupBean.java ,用于在启动时填充数据库,并在关闭时清除数据库。

package com.okta.developer;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.ejb.Singleton;
import javax.ejb.Startup;
import javax.inject.Inject;
import java.util.stream.Stream;

@Singleton
@Startup
public class StartupBean {
    private final BeerService beerService;

    @Inject
    public StartupBean(BeerService beerService) {
        this.beerService = beerService;
    }

    @PostConstruct
    private void startup() {
        // Top beers from https://www.beeradvocate.com/lists/top/
        Stream.of("Kentucky Brunch Brand Stout", "Marshmallow Handjee", 
                "Barrel-Aged Abraxas", "Heady Topper",
                "Budweiser", "Coors Light", "PBR").forEach(name ->
                beerService.addBeer(new Beer(name))
        );
        beerService.getAllBeers().forEach(System.out::println);
    }

    @PreDestroy
    private void shutdown() {
        beerService.clear();
    }
}

这三个类构成了应用程序的基础,此外还有一个BeerResource.java类,它使用JAX-RS公开/good-beers端点。

package com.okta.developer;

import javax.ejb.Lock;
import javax.ejb.Singleton;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import java.util.List;
import java.util.stream.Collectors;

import static javax.ejb.LockType.READ;
import static javax.ws.rs.core.MediaType.APPLICATION_JSON;

@Lock(READ)
@Singleton
@Path("/good-beers")
public class BeerResource {
    private final BeerService beerService;

    @Inject
    public BeerResource(BeerService beerService) {
        this.beerService = beerService;
    }

    @GET
    @Produces({APPLICATION_JSON})
    public List<Beer> getGoodBeers() {
        return beerService.getAllBeers().stream()
                .filter(this::isGreat)
                .collect(Collectors.toList());
    }

    private boolean isGreat(Beer beer) {
        return !beer.getName().equals("Budweiser") &&
                !beer.getName().equals("Coors Light") &&
                !beer.getName().equals("PBR");
    }
}

最后,有一个BeerBean.java类用作JSF的托管bean。

package com.okta.developer;

import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.inject.Named;
import java.util.List;

@Named
@RequestScoped
public class BeerBean {

    @Inject
    private BeerService beerService;
    private List<Beer> beersAvailable;
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Beer> getBeersAvailable() {
        return beersAvailable;
    }

    public void setBeersAvailable(List<Beer> beersAvailable) {
        this.beersAvailable = beersAvailable;
    }

    public String fetchBeers() {
        beersAvailable = beerService.getAllBeers();
        return "success";
    }

    public String add() {
        Beer beer = new Beer();
        beer.setName(name);
        beerService.addBeer(beer);
        return "success";
    }
}

您现在拥有了使用Java EE构建的REST API! 但是,这并不安全。 在以下各节中,我将向您展示如何使用Okta的Java JWT验证程序,Spring Security和Pac4j对其进行保护。

使用Okta将OIDC安全性添加到Java REST API

您将需要在Okta中创建OIDC应用程序,以验证将要实施的安全配置。 要使此操作毫不费力,您可以使用Okta的OIDC API。 在Okta,我们的目标是使身份管理比您以往更加轻松,安全和可扩展。 Okta是一项云服务,允许开发人员创建,编辑和安全地存储用户帐户和用户帐户数据,并将它们与一个或多个应用程序连接。 我们的API使您能够:

你卖了吗 立即注册一个永久免费的开发者帐户 ! 完成后,请完成以下步骤以创建OIDC应用程序。

  1. 登录到您在developer.okta.com上的开发者帐户。
  2. 导航到应用程序 ,然后单击添加应用程序
  3. 选择“ Web” ,然后单击“ 下一步”
  4. 为应用程序命名(例如Java EE Secure API ),然后添加以下内容作为登录重定向URI:
    • http://localhost:3000/implicit/callback
    • http://localhost:8080/login/oauth2/code/okta
    • http://localhost:8080/callback?client_name=OidcClient
  5. 单击完成 ,然后编辑项目并启用“隐式(混合)”作为授予类型(允许ID和访问令牌),然后单击保存

使用JWT Verifier保护Java REST API

要从Okta验证JWT,您需要将Okta Java JWT Verifier添加到pom.xml

<properties>
    ...
    <okta-jwt.version>0.3.0</okta-jwt.version>
</properties>

<dependencies>
    ...
    <dependency>
        <groupId>com.okta.jwt</groupId>
        <artifactId>okta-jwt-verifier</artifactId>
        <version>${okta-jwt.version}</version>
    </dependency>
</dependencies>

然后创建一个JwtFilter.java (在src/main/java/com/okta/developer目录中)。 该过滤器查找其中包含访问令牌的authorization标头。 如果存在,它将对其进行验证并打印出用户的sub ,也就是他们的电子邮件地址。 如果不存在或有效,则返回拒绝访问状态。

确保使用您创建的应用中的设置替换{yourOktaDomain}{clientId}

package com.okta.developer;

import com.nimbusds.oauth2.sdk.ParseException;
import com.okta.jwt.JoseException;
import com.okta.jwt.Jwt;
import com.okta.jwt.JwtHelper;
import com.okta.jwt.JwtVerifier;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebFilter(filterName = "jwtFilter", urlPatterns = "/*")
public class JwtFilter implements Filter {
    private JwtVerifier jwtVerifier;

    @Override
    public void init(FilterConfig filterConfig) {
        try {
            jwtVerifier = new JwtHelper()
                    .setIssuerUrl("https://{yourOktaDomain}/oauth2/default")
                    .setClientId("{yourClientId}")
                    .build();
        } catch (IOException | ParseException e) {
            System.err.print("Configuring JWT Verifier failed!");
            e.printStackTrace();
        }
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
                         FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        System.out.println("In JwtFilter, path: " + request.getRequestURI());

        // Get access token from authorization header
        String authHeader = request.getHeader("authorization");
        if (authHeader == null) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Access denied.");
            return;
        } else {
            String accessToken = authHeader.substring(authHeader.indexOf("Bearer ") + 7);
            try {
                Jwt jwt = jwtVerifier.decodeAccessToken(accessToken);
                System.out.println("Hello, " + jwt.getClaims().get("sub"));
            } catch (JoseException e) {
                e.printStackTrace();
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Access denied.");
                return;
            }
        }

        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
    }
}

为确保此过滤器正常工作,请重新启动您的应用并运行:

mvn package tomee:run

如果在浏览器中导航到http://localhost:8080/good-beers ,则会看到拒绝访问错误。

Java REST API

为了证明它可以与有效的JWT一起使用,您可以克隆我的Bootiful React项目,并运行其UI:

git clone -b okta https://github.com/oktadeveloper/spring-boot-react-example.git bootiful-react
cd bootiful-react/client
npm install

编辑此项目的client/src/App.tsx文件,并更改issuerclientId以匹配您的应用程序。

const config = {
  issuer: 'https://{yourOktaDomain}/oauth2/default',
  redirectUri: window.location.origin + '/implicit/callback',
  clientId: '{yourClientId}'
};

然后启动它:

npm start

然后,您应该能够使用创建帐户所用的凭据登录http://localhost:3000 。 但是,由于CORS错误(在浏览器的开发人员控制台中),您将无法从API加载任何啤酒。

Failed to load http://localhost:8080/good-beers: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:3000' is therefore not allowed access.

提示:如果看到401并且没有CORS错误,则可能意味着您的客户ID不匹配。

要解决此CORS错误,请在JwtFilter.java类旁边添加一个CorsFilter.java 。 下面的过滤器将允许OPTIONS请求,并向后发送访问控制标头,以允许任何起源,GET方法和任何标头。 我建议您在生产中使这些设置更加具体。

package com.okta.developer;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebFilter(filterName = "corsFilter")
public class CorsFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        System.out.println("In CorsFilter, method: " + request.getMethod());

        // Authorize (allow) all domains to consume the content
        response.addHeader("Access-Control-Allow-Origin", "http://localhost:3000");
        response.addHeader("Access-Control-Allow-Methods", "GET");
        response.addHeader("Access-Control-Allow-Headers", "*");

        // For HTTP OPTIONS verb/method reply with ACCEPTED status code -- per CORS handshake
        if (request.getMethod().equals("OPTIONS")) {
            response.setStatus(HttpServletResponse.SC_ACCEPTED);
            return;
        }

        // pass the request along the filter chain
        chain.doFilter(request, response);
    }

    @Override
    public void init(FilterConfig config) {
    }

    @Override
    public void destroy() {
    }
}

您添加的两个过滤器都使用@WebFilter进行注册。 这是一个方便的注释,但不提供任何过滤器排序功能。 要解决此丢失的功能,请修改JwtFilter ,使其@WebFilter中没有urlPattern

@WebFilter(filterName = "jwtFilter")

然后创建一个src/main/webapp/WEB-INF/web.xml文件,并使用以下XML进行填充。 这些过滤器映射可确保CorsFilter处理CorsFilter

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.1"
         xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd">

    <filter-mapping>
        <filter-name>corsFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter-mapping>
        <filter-name>jwtFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
</web-app>

重新启动Java API,现在一切正常!

Java REST API

在控制台中,您应该看到类似于我的消息:

In CorsFilter, method: OPTIONS
In CorsFilter, method: GET
In JwtFilter, path: /good-beers
Hello, demo@okta.com

通过Okta的JWT验证程序使用过滤器是实现资源服务器的一种简单方法(采用OAuth 2.0命名法)。 但是,它不向您提供有关该用户的任何信息。 JwtVerifier接口的确有一个decodeIdToken(String idToken, String nonce)方法,但是您必须从客户端传递ID令牌才能使用它。

在接下来的两节中,我将向您展示如何使用Spring Security和Pac4j来实现类似的安全性。 作为奖励,我将向您展示如何提示用户登录(当他们尝试直接访问API时)并获取用户的信息。

通过Spring Security保护Java REST API

Spring Security是我在Javaland中最喜欢的框架之一。 在显示如何使用Spring Security时,此博客上的大多数示例都使用Spring Boot。 我将使用最新版本– 5.1.0.RC2 –因此本教程将保持最新状态。

还原更改以添加JWT Verifier,或直接删除web.xml继续。

修改您的pom.xml使其具有Spring Security所需的依赖关系。 您还需要添加Spring的快照存储库以获取候选版本。

<properties>
    ...
    <spring-security.version>5.1.0.RC2</spring-security.version>
    <spring.version>5.1.0.RC3</spring.version>
    <jackson.version>2.9.6</jackson.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-framework-bom</artifactId>
            <version>${spring.version}</version>
            <scope>import</scope>
            <type>pom</type>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-bom</artifactId>
            <version>${spring-security.version}</version>
            <scope>import</scope>
            <type>pom</type>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    ...
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-config</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-client</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-jose</artifactId>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-core</artifactId>
        <version>${jackson.version}</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>${jackson.version}</version>
    </dependency>
</dependencies>

<pluginRepositories>
    <pluginRepository>
        <id>spring-snapshots</id>
        <name>Spring Snapshots</name>
        <url>https://repo.spring.io/libs-snapshot</url>
        <snapshots>
            <enabled>true</enabled>
        </snapshots>
    </pluginRepository>
</pluginRepositories>
<repositories>
    <repository>
        <id>spring-snapshots</id>
        <name>Spring Snapshot</name>
        <url>https://repo.spring.io/libs-snapshot</url>
    </repository>
</repositories>

src/main/java/com/okta/developer创建一个SecurityWebApplicationInitializer.java类:

package com.okta.developer;

import org.springframework.security.web.context.*;

public class SecurityWebApplicationInitializer
   extends AbstractSecurityWebApplicationInitializer {

   public SecurityWebApplicationInitializer() {
       super(SecurityConfiguration.class);
   }
}

在同一目录中创建一个SecurityConfiguration.java类。 此类使用Spring Security 5的oauth2Login()并向Spring Security注册您的Okta应用程序。

package com.okta.developer;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.ClientRegistrations;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

@Configuration
@EnableWebSecurity
@PropertySource("classpath:application.properties")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    private final String clientSecret;
    private final String clientId;
    private final String issuerUri;

    @Autowired
    public SecurityConfiguration(@Value("${okta.issuer-uri}") String issuerUri,
            @Value("${okta.client-id}") String clientId,
            @Value("${okta.client-secret}") String clientSecret) {
        this.issuerUri = issuerUri;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
    }

@Override
   protected void configure(HttpSecurity http) throws Exception {
      http
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
                .and()
           .csrf()
               .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
               .and()
           .authorizeRequests()
               .anyRequest().authenticated()
               .and()
           .oauth2Login();
   }

   @Bean
   public OAuth2AuthorizedClientService authorizedClientService() {
       return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository());
   }

   @Bean
   public ClientRegistrationRepository clientRegistrationRepository() {
       List<ClientRegistration> registrations = clients.stream()
               .map(this::getRegistration)
               .filter(Objects::nonNull)
               .collect(Collectors.toList());

       return new InMemoryClientRegistrationRepository(registrations);
   }

   @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        ClientRegistration okta = getRegistration();
        return new InMemoryClientRegistrationRepository(okta);
    }

    ClientRegistrations.fromOidcIssuerLocation(Objects.requireNonNull(issuerUri))
            .registrationId("okta")
            .clientId(clientId)
            .clientSecret(clientSecret)
            .build();
}

创建src/main/resources/application.properties并用Okta OIDC应用设置进行填充。

okta.client-id={clientId}
okta.client-secret={clientSecret}
okta.issuer-uri=https://{yourOktaDomain}/oauth2/default

感谢Baeldung提供有关Spring Security 5 OAuth出色文档

因为启用了CSRF,所以必须在任何<h:form>标记内添加以下隐藏字段以保护CSRF。 我将以下内容添加到src/main/webapp/beer.xhtmlresult.xhtml

<input type="hidden" value="${_csrf.token}" name="${_csrf.parameterName}"/>

重新启动您的API( mvn clean package tomee:run )并导航到http://localhost:8080/good-beers 。 您应该重定向到Okta进行登录。

Java REST API

输入有效的凭证,您应该在浏览器中看到JSON。 JSON Viewer Chrome插件提供了美观的JSON。

Java REST API

要求用户登录以查看您的API数据很方便,但是最好将其作为React UI示例的资源服务器。 OAuth 2.0资源服务器支持是Spring Security 5.1.0 RC1中的新增功能,因此我将向您展示如何使用它。

用以下代码替换SecurityConfiguration.javaconfigure()方法,该代码启用CORS并设置资源服务器。

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
            .and()
        .csrf()
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .and()
        .cors()
            .and()
        .authorizeRequests()
            .anyRequest().authenticated()
            .and()
        .oauth2Login()
            .and()
        .oauth2ResourceServer()
            .jwt();
}

@Bean
JwtDecoder jwtDecoder() {
    return JwtDecoders.fromOidcIssuerLocation(this.issuerUri);
}

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowCredentials(true);
    configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
    configuration.setAllowedMethods(Collections.singletonList("GET"));
    configuration.setAllowedHeaders(Collections.singletonList("*"));
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

进行这些更改之后,重新启动您的API并确认您的React UI可以与之对话。 很漂亮吧?

Spring Security的用户信息

Spring Security与Servlet API集成在一起,因此您可以使用以下方法来获取当前用户的信息。

  • HttpServletRequest.getRemoteUser()
  • HttpServletRequest.getUserPrincipal()

拥有Principal ,您可以获取有关用户的详细信息,包括其角色(又名,权限)。

OAuth2Authentication authentication = (OAuth2Authentication) principal;
Map<String, Object> user = (Map<String, Object>) authentication.getUserAuthentication().getDetails();

请参阅Spring Security的Servlet API集成文档以获取更多信息。

使用Pac4j锁定Java REST API

我想向您展示的确保Java REST API安全的最后一种技术是使用Pac4j,特别是j2e-pac4j

恢复您的更改以添加Spring Security。

git reset --hard HEAD

编辑pom.xml以添加完成本节所需的Pac4j库。

<properties>
    ...
    <pac4j-j2e.version>4.0.0</pac4j-j2e.version>
    <pac4j.version>3.0.0</pac4j.version>
</properties>

<dependencies>
    ...
    <dependency>
        <groupId>org.pac4j</groupId>
        <artifactId>j2e-pac4j</artifactId>
        <version>${pac4j-j2e.version}</version>
    </dependency>
    <dependency>
        <groupId>org.pac4j</groupId>
        <artifactId>pac4j-oidc</artifactId>
        <version>${pac4j.version}</version>
    </dependency>
    <dependency>
        <groupId>org.pac4j</groupId>
        <artifactId>pac4j-http</artifactId>
        <version>${pac4j.version}</version>
    </dependency>
    <dependency>
        <groupId>org.pac4j</groupId>
        <artifactId>pac4j-jwt</artifactId>
        <version>${pac4j.version}</version>
    </dependency>
</dependencies>

就像创建JWT Verifier一样,创建一个src/main/java/com/okta/developer/CorsFilter.java

package com.okta.developer;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebFilter(filterName = "corsFilter")
public class CorsFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        System.out.println("In CorsFilter, method: " + request.getMethod());

        // Authorize (allow) all domains to consume the content
        response.addHeader("Access-Control-Allow-Origin", "http://localhost:3000");
        response.addHeader("Access-Control-Allow-Methods", "GET");
        response.addHeader("Access-Control-Allow-Headers", "*");

        // For HTTP OPTIONS verb/method reply with ACCEPTED status code -- per CORS handshake
        if (request.getMethod().equals("OPTIONS")) {
            response.setStatus(HttpServletResponse.SC_ACCEPTED);
            return;
        }

        // pass the request along the filter chain
        chain.doFilter(request, response);
    }

    @Override
    public void init(FilterConfig config) {
    }

    @Override
    public void destroy() {
    }
}

在同一程序包中创建一个SecurityConfigFactory.java 。 将客户端ID,密钥和域占位符替换为与OIDC应用程序匹配的占位符。

package com.okta.developer;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.pac4j.core.client.Clients;
import org.pac4j.core.client.direct.AnonymousClient;
import org.pac4j.core.config.Config;
import org.pac4j.core.config.ConfigFactory;
import org.pac4j.core.credentials.TokenCredentials;
import org.pac4j.core.profile.CommonProfile;
import org.pac4j.http.client.direct.HeaderClient;
import org.pac4j.jwt.config.signature.RSASignatureConfiguration;
import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator;
import org.pac4j.jwt.util.JWKHelper;
import org.pac4j.oidc.client.OidcClient;
import org.pac4j.oidc.config.OidcConfiguration;
import org.pac4j.oidc.profile.OidcProfile;

import java.io.IOException;
import java.net.URL;
import java.security.KeyPair;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class SecurityConfigFactory implements ConfigFactory {
    private final JwtAuthenticator jwtAuthenticator = new JwtAuthenticator();
    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    public Config build(final Object... parameters) {
        System.out.print("Building Security configuration...\n");

        final OidcConfiguration oidcConfiguration = new OidcConfiguration();
        oidcConfiguration.setClientId("{yourClientId}");
        oidcConfiguration.setSecret("{yourClientSecret}");
        oidcConfiguration.setDiscoveryURI("https://{yourOktaDomain}/oauth2/default/.well-known/openid-configuration");
        oidcConfiguration.setUseNonce(true);
        final OidcClient<OidcProfile, OidcConfiguration> oidcClient = new OidcClient<>(oidcConfiguration);
        oidcClient.setAuthorizationGenerator((ctx, profile) -> {
            profile.addRole("ROLE_USER");
            return profile;
        });

        HeaderClient headerClient = new HeaderClient("Authorization", "Bearer ", (credentials, ctx) -> {
            String token = ((TokenCredentials) credentials).getToken();
            if (token != null) {
                try {
                    // Get JWK
                    URL keysUrl = new URL("https://{yourOktaDomain}/oauth2/default/v1/keys");
                    Map map = mapper.readValue(keysUrl, Map.class);
                    List keys = (ArrayList) map.get("keys");
                    String json = mapper.writeValueAsString(keys.get(0));

                    // Build key pair and validate token
                    KeyPair rsaKeyPair = JWKHelper.buildRSAKeyPairFromJwk(json);
                    jwtAuthenticator.addSignatureConfiguration(new RSASignatureConfiguration(rsaKeyPair));
                    CommonProfile profile = jwtAuthenticator.validateToken(token);
                    credentials.setUserProfile(profile);
                    System.out.println("Hello, " + profile.getId());
                } catch (IOException e) {
                    System.err.println("Failed to validate Bearer token: " + e.getMessage());
                    e.printStackTrace();
                }
            }
        });

        final Clients clients = new Clients("http://localhost:8080/callback",
                oidcClient, headerClient, new AnonymousClient());
        return new Config(clients);
    }
}

如果oidcClient的代码中的oidcClient尝试直接访问您的API,将使用户登录Okta。 headerClient设置了资源服务器,该资源服务器根据用户的访问令牌对用户进行授权。

创建src/main/webapp/WEB-INF/web.xml来映射CorsFilter以及Pac4j的CallbackFilterSecurityFilter 。 您可以看到SecurityFilter通过其configFactory init-param链接到SecurityConfigFactory类。

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">
    <display-name>javaee-pac4j-demo</display-name>

    <absolute-ordering/>

    <filter-mapping>
        <filter-name>corsFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    <filter>
        <filter-name>callbackFilter</filter-name>
        <filter-class>org.pac4j.j2e.filter.CallbackFilter</filter-class>
        <init-param>
            <param-name>defaultUrl</param-name>
            <param-value>/</param-value>
        </init-param>
        <init-param>
            <param-name>renewSession</param-name>
            <param-value>true</param-value>
        </init-param>
        <init-param>
            <param-name>multiProfile</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>callbackFilter</filter-name>
        <url-pattern>/callback</url-pattern>
        <dispatcher>REQUEST</dispatcher>
    </filter-mapping>

    <filter>
        <filter-name>OidcFilter</filter-name>
        <filter-class>org.pac4j.j2e.filter.SecurityFilter</filter-class>
        <init-param>
            <param-name>configFactory</param-name>
            <param-value>com.okta.developer.SecurityConfigFactory</param-value>
        </init-param>
        <init-param>
            <param-name>clients</param-name>
            <param-value>oidcClient,headerClient</param-value>
        </init-param>
        <init-param>
            <param-name>authorizers</param-name>
            <param-value>securityHeaders</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>OidcFilter</filter-name>
        <url-pattern>/*</url-pattern>
        <dispatcher>REQUEST</dispatcher>
        <dispatcher>FORWARD</dispatcher>
    </filter-mapping>
</web-app>

为了更好地可视化用户信息,您需要创建更多文件。 这些与JSF相关的文件是从j2e-pac4j-cdi-demo复制的。

注意:我试图在TomEE上运行j2e-pac4j-cdi-demo (没有web.xml ),但是失败并出现错误: Filters cannot be added to context [] as the context has been initialised ,因此无法将Filters cannot be added to context [] as the context has been initialised 。 当使用Payara Maven插件时,它确实起作用。

创建src/main/java/com/okta/developer/ProfileView.java ,这是一个JSF托管的bean,用于收集用户的信息。

package com.okta.developer;

import org.pac4j.core.context.WebContext;
import org.pac4j.core.profile.ProfileManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.PostConstruct;
import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.inject.Named;
import java.util.List;

/**
 * Managed bean which exposes the pac4j profile manager.
 *
 * JSF views such as facelets can reference this to view the contents of profiles.
 *
 * @author Phillip Ross
 */
@Named
@RequestScoped
public class ProfileView {

    /** The static logger instance. */
    private static final Logger logger = LoggerFactory.getLogger(ProfileView.class);

    /** The pac4j web context. */
    @Inject
    private WebContext webContext;

    /** The pac4j profile manager. */
    @Inject
    private ProfileManager profileManager;

    /** Simple no-args constructor. */
    public ProfileView() {
    }

    /**
     * Gets the first profile (if it exists) contained in the profile manager.
     *
     * @return a list of pac4j profiles
     */
    public Object getProfile() {
        return profileManager.get(true).orElse(null); // It's fine to return a null reference if there is no value present.
    }

    /**
     * Gets the profiles contained in the profile manager.
     *
     * @return a list of pac4j profiles
     */
    public List getProfiles() {
        return profileManager.getAll(true);
    }

    /** Simply prints some debugging information post-construction. */
    @PostConstruct
    public void init() {
        logger.debug("webContext is null? {}", (webContext == null));
        logger.debug("profileManager is null? {}", (profileManager == null));
    }
}

src/main/webapp/oidc/index.xhtml为JSF模板。

<ui:composition xmlns="http://www.w3.org/1999/xhtml"
                xmlns:h="http://java.sun.com/jsf/html"
                xmlns:f="http://java.sun.com/jsf/core"
                xmlns:ui="http://java.sun.com/jsf/facelets"
                template="/WEB-INF/template.xhtml">
    <ui:define name="title">Pac4J Java EE Demo - Protected Area</ui:define>
    <ui:define name="content">
        <div class="ui-g">
            <div class="ui-g-12">
                <div class="ui-container">
                    <h1>Protected Area</h1>
                    <p><h:link value="Back" outcome="/index"/></p>
                </div>
                <ui:include src="/WEB-INF/facelets/includes/pac4j-profiles-list.xhtml"/>
            </div>
        </div>
    </ui:define>
</ui:composition>

创建pac4j-profiles-list.xhtml文件,该文件包含在WEB-INF/facelets/includes

<ui:composition xmlns="http://www.w3.org/1999/xhtml"
                xmlns:h="http://java.sun.com/jsf/html"
                xmlns:f="http://java.sun.com/jsf/core"
                xmlns:ui="http://java.sun.com/jsf/facelets">
    <div class="ui-container">
        <p>Found  <h:outputText value="#{profileView.profiles.size()}"/> profiles.</p>
        <h:panelGroup layout="block" rendered="#{profileView.profiles.size() > 0}">
            <p>First profile:  <h:outputText value="#{profileView.profile}"/></p>
        </h:panelGroup>
    </div>

    <h:panelGroup layout="block" rendered="#{not empty profileView.profile}">
        <h2>Profile Details</h2>
        <p><h:outputText value="Id: #{profileView.profile.id}"/></p>
        <p><h:outputText value="Type Id: #{profileView.profile.typedId}"/></p>
        <p><h:outputText value="Remembered: #{profileView.profile.remembered}"/></p>
        <h3>Attributes (<h:outputText value="#{profileView.profile.attributes.size()}"/>)</h3>
        <h:panelGroup layout="block" rendered="#{profileView.profile.attributes.size() > 0}">
            <ul>
                <ui:repeat value="#{profileView.profile.attributes.keySet().toArray()}" var="attributeName">
                    <li><h:outputText value="#{attributeName}"/>: <h:outputText value="#{profileView.profile.attributes.get(attributeName)}"/> </li>
                </ui:repeat>
            </ul>
        </h:panelGroup>
        <h3>Roles (<h:outputText value="#{profileView.profile.roles.size()}"/>)</h3>
        <h:panelGroup layout="block" rendered="#{profileView.profile.roles.size() > 0}">
            <ul>
                <ui:repeat value="#{profileView.profile.roles.toArray()}" var="role">
                    <li><h:outputText value="#{role}"/></li>
                </ui:repeat>
            </ul>
        </h:panelGroup>
        <h3>Permissions (<h:outputText value="#{profileView.profile.permissions.size()}"/>)</h3>
        <h:panelGroup layout="block" rendered="#{profileView.profile.permissions.size() > 0}">
            <ul>
                <ui:repeat value="#{profileView.profile.permissions.toArray()}" var="permission">
                    <li><h:outputText value="#{permission}"/></li>
                </ui:repeat>
            </ul>
        </h:panelGroup>
    </h:panelGroup>
</ui:composition>

oidc/index.xhtml模板使用WEB-INF/template.xhtml ,因此您也需要创建它。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="http://java.sun.com/jsf/html"
      xmlns:f="http://java.sun.com/jsf/core"
      xmlns:ui="http://java.sun.com/jsf/facelets">

    <h:head>
        <f:facet name="first">
            <meta http-equiv="X-UA-Compatible" content="IE=edge" />
            <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
            <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"/>
            <meta name="apple-mobile-web-app-capable" content="yes" />
        </f:facet>
        <title><ui:insert name="title">Pac4J Java EE Demo</ui:insert></title>
        <ui:insert name="head"/>
    </h:head>

    <h:body styleClass="main-body">
        <div class="layout-wrapper">
            <div class="layout-main">
                <ui:insert name="content"/>
            </div>
        </div>
    </h:body>
</html>

添加这些文件后,重建项目并重新启动TomEE。

mvn clean package tomee:run

导航到http://localhost:8080/oidc/index.jsf ,您将被重定向到Okta进行登录。 如果您初次尝试无法解决问题,请重新启动浏览器并使用隐身窗口。 您应该看到用户的个人资料信息。

Java REST API

http://localhost:3000尝试您的React客户端; 它也应该工作!

Java REST API

如果您想知道为什么不堆叠图像,那是因为我将React应用程序的BeerList.tsx的啤酒清单的JSX更改为内联。

<h2>Beer List</h2>
{beers.map((beer: Beer) =>
  <span key={beer.id} style={{float: 'left', marginRight: '10px', marginLeft: '10px'}}>
    {beer.name}<br/>
    <GiphyImage name={beer.name}/>
  </span>
)}

雅加达EE呢?

您可能已经听说Java EE已经成为开源的(类似于Java SE的OpenJDK ),其新名称为Jakarta EE 。 David Blevins是一个很好的朋友,并且积极参与Java EE / Jakarta EE。 有关证明,请参阅他的Twitter传记:Apache TomEE,OpenEJB和Geronimo项目的创始人。 Apache,JCP EC,EE4J PMC,Jakarta EE WG,MicroProfile和Eclipse Board的成员。 首席执行官@Tomitribe

我问戴维何时会发布可用的Jakarta EE。

David:目前的主要重点是创建与Java EE 8兼容的Jakarta EE版本。我们希望在今年年底之前将其发布。 发布之后,我们将开始开发Jakarta EE 9并根据需要进行迭代。

Jakarta EE有一个工作组来决定平台的方向。

了解有关安全REST API,Java EE,Jakarta EE和OIDC的更多信息

我希望您喜欢这个游览,向您展示了如何使用JWT和OIDC构建和保护Java EE REST API。 如果您想查看每个完成部分的源代码,我将它们放在GitHub repo的分支中。 您可以使用以下命令克隆不同的实现:

git clone -b jwt-verifier https://github.com/oktadeveloper/okta-java-ee-rest-api-example.git
git clone -b spring-security https://github.com/oktadeveloper/okta-java-ee-rest-api-example.git
git clone -b pac4j https://github.com/oktadeveloper/okta-java-ee-rest-api-example.git

如前所述,我们在此博客上获得的大多数Java教程都展示了如何使用Spring Boot。 如果您有兴趣学习Spring Boot,这里有一些我写的教程将向您展示要点。

如果您是OIDC的新手,建议您查看以下文章:

有关Java REST API和TomEE的更多信息,我建议以下来源:

如果您到目前为止已经做到了,我怀疑您可能对以后的博客文章感兴趣。 在Twitter上关注我和我的整个团队在Facebook上关注我们,或者查看我们的YouTube频道 。 如有疑问,请在下面发表评论,或将其发布到我们的开发者论坛

“我喜欢编写身份验证和授权代码。” 〜从来没有Java开发人员。 厌倦了一次又一次地建立相同的登录屏幕? 尝试使用Okta API进行托管身份验证,授权和多因素身份验证。

使用Java EE和OIDC构建Java REST API最初于2018年9月12日发布在Okta开发人员博客上。

翻译自: https://www.javacodegeeks.com/2018/10/build-java-rest-api-java-ee-oidc.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值