“我喜欢编写身份验证和授权代码。” 〜从来没有Java开发人员。 厌倦了一次又一次地建立相同的登录屏幕? 尝试使用Okta API进行托管身份验证,授权和多因素身份验证。
React的设计使创建交互式UI变得轻松自如。 它的状态管理非常有效,并且仅在数据更改时才更新组件。 组件逻辑是用JavaScript编写的,这意味着您可以将状态保持在DOM之外,并创建封装的组件。
开发人员喜欢CRUD(创建,读取,更新和删除)应用程序,因为它们显示了创建应用程序时需要的许多基本功能。 一旦在应用程序中完成了CRUD的基础知识,大多数客户端-服务器管道就完成了,您可以继续实施必要的业务逻辑。
今天,我将向您展示如何在React中使用Spring Boot创建一个基本的CRUD应用。 您可能还记得我去年为Angular撰写的一篇类似文章: 使用Angular 5.0和Spring Boot 2.0构建Basic CRUD应用程序 。 该教程使用OAuth 2.0的隐式流程和我们的Okta Angular SDK 。 在本教程中,我将使用OAuth 2.0授权代码流,并将React应用打包在Spring Boot应用中进行生产。 同时,我将向您展示如何保持React高效的工作流以进行本地开发。
您将需要安装Java 8 , Node.js 8和Yarn才能完成本教程。 您可以使用npm代替Yarn,但是您需要将Yarn语法转换为npm。
使用Spring Boot 2.0创建API应用
我经常在世界各地的会议和用户组中演讲。 我最喜欢发言的用户组是Java用户组(JUG)。 我从事Java开发人员已有近20年的时间,而且我喜欢Java社区。 我的一个好朋友詹姆斯·沃德(James Ward)表示,进行水罐巡游是他当时最喜欢的开发商倡导者活动之一。 我最近接受了他的建议,并在海外会议上进行了JUG聚会在美国的聚会。
我为什么要告诉你呢? 因为我认为今天创建一个“ JUG Tours”应用很有趣,它允许您创建/编辑/删除JUG,以及查看即将发生的事件。
首先,导航至start.spring.io并进行以下选择:
- 组:
com.okta.developer
- 神器:
jugtours
- 依赖项 :
JPA
,H2
,Web
,Lombok
单击生成项目 ,下载后展开jugtours.zip
,然后在您喜欢的IDE中打开该项目。
提示:如果您使用的是IntelliJ IDEA或Spring Tool Suite,则在创建新项目时也可以使用Spring Initializr。
添加一个JPA域模型
您需要做的第一件事是创建一个保存数据的域模型。 在高层次上,有一个Group
表示酒壶,一个Event
有一个多到一的关系Group
,以及User
具有与一个一对多的关系Group
。
创建一个src/main/java/com/okta/developer/jugtours/model
目录和其中的Group.java
类。
package com.okta.developer.jugtours.model;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import javax.persistence.*;
import java.util.Set;
@Data
@NoArgsConstructor
@RequiredArgsConstructor
@Entity
@Table(name = "user_group")
public class Group {
@Id
@GeneratedValue
private Long id;
@NonNull
private String name;
private String address;
private String city;
private String stateOrProvince;
private String country;
private String postalCode;
@ManyToOne(cascade=CascadeType.PERSIST)
private User user;
@OneToMany(fetch = FetchType.EAGER, cascade=CascadeType.ALL)
private Set<Event> events;
}
在同一包中创建一个Event.java
类。
package com.okta.developer.jugtours.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import java.time.Instant;
import java.util.Set;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class Event {
@Id
@GeneratedValue
private Long id;
private Instant date;
private String title;
private String description;
@ManyToMany
private Set<User> attendees;
}
还有一个User.java
类。
package com.okta.developer.jugtours.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.Id;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class User {
@Id
private String id;
private String name;
private String email;
}
创建一个GroupRepository.java
来管理组实体。
package com.okta.developer.jugtours.model;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface GroupRepository extends JpaRepository<Group, Long> {
Group findByName(String name);
}
要加载一些默认数据,请在com.okta.developer.jugtours
包中创建一个Initializer.java
类。
package com.okta.developer.jugtours;
import com.okta.developer.jugtours.model.Event;
import com.okta.developer.jugtours.model.Group;
import com.okta.developer.jugtours.model.GroupRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Collections;
import java.util.stream.Stream;
@Component
class Initializer implements CommandLineRunner {
private final GroupRepository repository;
public Initializer(GroupRepository repository) {
this.repository = repository;
}
@Override
public void run(String... strings) {
Stream.of("Denver JUG", "Utah JUG", "Seattle JUG",
"Richmond JUG").forEach(name ->
repository.save(new Group(name))
);
Group djug = repository.findByName("Denver JUG");
Event e = Event.builder().title("Full Stack Reactive")
.description("Reactive with Spring Boot + React")
.date(Instant.parse("2018-12-12T18:00:00.000Z"))
.build();
djug.setEvents(Collections.singleton(e));
repository.save(djug);
repository.findAll().forEach(System.out::println);
}
}
提示:如果您的IDE Event.builder()
问题,则意味着您需要打开注释处理和/或安装Lombok插件。 我必须在IntelliJ IDEA中卸载/重新安装Lombok插件才能正常工作。
如果在添加此代码后启动应用程序(使用./mvnw spring-boot:run
),您将看到控制台中显示的组和事件列表。
Group(id=1, name=Denver JUG, address=null, city=null, stateOrProvince=null, country=null, postalCode=null, user=null, events=[Event(id=5, date=2018-12-12T18:00:00Z, title=Full Stack Reactive, description=Reactive with Spring Boot + React, attendees=[])])
Group(id=2, name=Utah JUG, address=null, city=null, stateOrProvince=null, country=null, postalCode=null, user=null, events=[])
Group(id=3, name=Seattle JUG, address=null, city=null, stateOrProvince=null, country=null, postalCode=null, user=null, events=[])
Group(id=4, name=Richmond JUG, address=null, city=null, stateOrProvince=null, country=null, postalCode=null, user=null, events=[])
添加一个GroupController.java
类(在src/main/java/.../jugtours/web/GroupController.java
), src/main/java/.../jugtours/web/GroupController.java
可用于CRUD组。
package com.okta.developer.jugtours.web;
import com.okta.developer.jugtours.model.Group;
import com.okta.developer.jugtours.model.GroupRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collection;
import java.util.Optional;
@RestController
@RequestMapping("/api")
class GroupController {
private final Logger log = LoggerFactory.getLogger(GroupController.class);
private GroupRepository groupRepository;
public GroupController(GroupRepository groupRepository) {
this.groupRepository = groupRepository;
}
@GetMapping("/groups")
Collection<Group> groups() {
return groupRepository.findAll();
}
@GetMapping("/group/{id}")
ResponseEntity<?> getGroup(@PathVariable Long id) {
Optional<Group> group = groupRepository.findById(id);
return group.map(response -> ResponseEntity.ok().body(response))
.orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
}
@PostMapping("/group")
ResponseEntity<Group> createGroup(@Valid @RequestBody Group group) throws URISyntaxException {
log.info("Request to create group: {}", group);
Group result = groupRepository.save(group);
return ResponseEntity.created(new URI("/api/group/" + result.getId()))
.body(result);
}
@PutMapping("/group/{id}")
ResponseEntity<Group> updateGroup(@PathVariable Long id, @Valid @RequestBody Group group) {
group.setId(id);
log.info("Request to update group: {}", group);
Group result = groupRepository.save(group);
return ResponseEntity.ok().body(result);
}
@DeleteMapping("/group/{id}")
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
log.info("Request to delete group: {}", id);
groupRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}
如果重新启动服务器应用程序,并使用浏览器或命令行客户端访问http://localhost:8080/api/groups
,则应看到组列表。
您可以使用以下HTTPie命令创建,读取,更新和删除组。
http POST :8080/api/group name='Dublin JUG' city=Dublin country=Ireland
http :8080/api/group/6
http PUT :8080/api/group/6 name='Dublin JUG' city=Dublin country=Ireland address=Downtown
http DELETE :8080/api/group/6
使用Create React App创建一个React UI
Create React App是一个命令行实用程序,可为您生成React项目。 这是一个方便的工具,因为它还提供了一些命令,这些命令将生成和优化您的项目以进行生产。 它使用webpack在后台进行构建。 如果您想了解更多关于webpack的信息,我建议使用webpack.academy 。
使用Yarn在jugtours
目录中创建一个新项目。
yarn create react-app app
应用程序创建过程完成后,导航至app
目录并安装Bootstrap ,对React的cookie支持,React Router和Reactstrap 。
cd app
yarn add bootstrap@4.1.2 react-cookie@2.2.0 react-router-dom@4.3.1 reactstrap@6.3.0
您将使用BootstrapCSS和Reactstrap的组件来使UI看起来更好,尤其是在手机上。 如果您想了解有关Reactstrap的更多信息,请参见https://reactstrap.github.io 。 它具有有关其各种组件以及如何使用它们的大量文档。
将BootstrapCSS文件添加为app/src/index.js
的导入文件。
import 'bootstrap/dist/css/bootstrap.min.css';
调用您的Spring Boot API并显示结果
修改app/src/App.js
以使用以下代码调用/api/groups
并在UI中显示列表。
import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
class App extends Component {
state = {
isLoading: true,
groups: []
};
async componentDidMount() {
const response = await fetch('/api/groups');
const body = await response.json();
this.setState({ groups: body, isLoading: false });
}
render() {
const {groups, isLoading} = this.state;
if (isLoading) {
return <p>Loading...</p>;
}
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h1 className="App-title">Welcome to React</h1>
</header>
<div className="App-intro">
<h2>JUG List</h2>
{groups.map(group =>
<div key={group.id}>
{group.name}
</div>
)}
</div>
</div>
);
}
}
export default App;
要将代理从/api
代理到http://localhost:8080/api
,请将代理设置添加到app/package.json
。
"scripts": {...},
"proxy": "http://localhost:8080"
要了解有关此功能的更多信息,请在app/README.md
搜索“ proxy”。 Create React App随该文件附带了各种文档,这有多酷?
确保Spring Boot正在运行,然后在您的app
目录中运行yarn start
。 您应该看到默认组的列表。
构建一个React GroupList组件
React完全是关于组件的,您不想在主App
呈现所有内容,因此请创建app/src/GroupList.js
并使用以下JavaScript进行填充。
import React, { Component } from 'react';
import { Button, ButtonGroup, Container, Table } from 'reactstrap';
import AppNavbar from './AppNavbar';
import { Link } from 'react-router-dom';
class GroupList extends Component {
constructor(props) {
super(props);
this.state = {groups: [], isLoading: true};
this.remove = this.remove.bind(this);
}
componentDidMount() {
this.setState({isLoading: true});
fetch('api/groups')
.then(response => response.json())
.then(data => this.setState({groups: data, isLoading: false}));
}
async remove(id) {
await fetch(`/api/group/${id}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(() => {
let updatedGroups = [...this.state.groups].filter(i => i.id !== id);
this.setState({groups: updatedGroups});
});
}
render() {
const {groups, isLoading} = this.state;
if (isLoading) {
return <p>Loading...</p>;
}
const groupList = groups.map(group => {
const address = `${group.address || ''} ${group.city || ''} ${group.stateOrProvince || ''}`;
return <tr key={group.id}>
<td style={{whiteSpace: 'nowrap'}}>{group.name}</td>
<td>{address}</td>
<td>{group.events.map(event => {
return <div key={event.id}>{new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: '2-digit'
}).format(new Date(event.date))}: {event.title}</div>
})}</td>
<td>
<ButtonGroup>
<Button size="sm" color="primary" tag={Link} to={"/groups/" + group.id}>Edit</Button>
<Button size="sm" color="danger" onClick={() => this.remove(group.id)}>Delete</Button>
</ButtonGroup>
</td>
</tr>
});
return (
<div>
<AppNavbar/>
<Container fluid>
<div className="float-right">
<Button color="success" tag={Link} to="/groups/new">Add Group</Button>
</div>
<h3>My JUG Tour</h3>
<Table className="mt-4">
<thead>
<tr>
<th width="20%">Name</th>
<th width="20%">Location</th>
<th>Events</th>
<th width="10%">Actions</th>
</tr>
</thead>
<tbody>
{groupList}
</tbody>
</Table>
</Container>
</div>
);
}
}
export default GroupList;
在同一目录中创建AppNavbar.js
,以在组件之间建立通用的UI功能。
import React, { Component } from 'react';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import { Link } from 'react-router-dom';
export default class AppNavbar extends Component {
constructor(props) {
super(props);
this.state = {isOpen: false};
this.toggle = this.toggle.bind(this);
}
toggle() {
this.setState({
isOpen: !this.state.isOpen
});
}
render() {
return <Navbar color="dark" dark expand="md">
<NavbarBrand tag={Link} to="/">Home</NavbarBrand>
<NavbarToggler onClick={this.toggle}/>
<Collapse isOpen={this.state.isOpen} navbar>
<Nav className="ml-auto" navbar>
<NavItem>
<NavLink
href="https://twitter.com/oktadev">@oktadev</NavLink>
</NavItem>
<NavItem>
<NavLink href="https://github.com/oktadeveloper/okta-spring-boot-react-crud-example">GitHub</NavLink>
</NavItem>
</Nav>
</Collapse>
</Navbar>;
}
}
创建app/src/Home.js
作为应用程序的登录页面。
import React, { Component } from 'react';
import './App.css';
import AppNavbar from './AppNavbar';
import { Link } from 'react-router-dom';
import { Button, Container } from 'reactstrap';
class Home extends Component {
render() {
return (
<div>
<AppNavbar/>
<Container fluid>
<Button color="link"><Link to="/groups">Manage JUG Tour</Link></Button>
</Container>
</div>
);
}
}
export default Home;
另外,更改app/src/App.js
以使用React Router在组件之间导航。
import React, { Component } from 'react';
import './App.css';
import Home from './Home';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import GroupList from './GroupList';
class App extends Component {
render() {
return (
<Router>
<Switch>
<Route path='/' exact={true} component={Home}/>
<Route path='/groups' exact={true} component={GroupList}/>
</Switch>
</Router>
)
}
}
export default App;
为了使您的UI更加宽敞,请在app/src/App.css
容器类中添加一个上边距。
.container, .container-fluid {
margin-top: 20px
}
当您进行更改时,您的React应用程序应该会自我更新,并且您应该在http://localhost:3000
看到如下屏幕。 点击Manage JUG Tour ,您将看到默认组的列表。 可以在React应用程序中查看Spring Boot API的数据真是太好了,但是如果您不能编辑它就不好玩了!
添加一个React GroupEdit组件
创建app/src/GroupEdit.js
并使用其componentDidMount()
从URL中获取具有ID的组资源。
import React, { Component } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { Button, Container, Form, FormGroup, Input, Label } from 'reactstrap';
import AppNavbar from './AppNavbar';
class GroupEdit extends Component {
emptyItem = {
name: '',
address: '',
city: '',
stateOrProvince: '',
country: '',
postalCode: ''
};
constructor(props) {
super(props);
this.state = {
item: this.emptyItem
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
async componentDidMount() {
if (this.props.match.params.id !== 'new') {
const group = await (await fetch(`/api/group/${this.props.match.params.id}`)).json();
this.setState({item: group});
}
}
handleChange(event) {
const target = event.target;
const value = target.value;
const name = target.name;
let item = {...this.state.item};
item[name] = value;
this.setState({item});
}
async handleSubmit(event) {
event.preventDefault();
const {item} = this.state;
await fetch('/api/group', {
method: (item.id) ? 'PUT' : 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(item),
});
this.props.history.push('/groups');
}
render() {
const {item} = this.state;
const title = <h2>{item.id ? 'Edit Group' : 'Add Group'}</h2>;
return <div>
<AppNavbar/>
<Container>
{title}
<Form onSubmit={this.handleSubmit}>
<FormGroup>
<Label for="name">Name</Label>
<Input type="text" name="name" id="name" value={item.name || ''}
onChange={this.handleChange} autoComplete="name"/>
</FormGroup>
<FormGroup>
<Label for="address">Address</Label>
<Input type="text" name="address" id="address" value={item.address || ''}
onChange={this.handleChange} autoComplete="address-level1"/>
</FormGroup>
<FormGroup>
<Label for="city">City</Label>
<Input type="text" name="city" id="city" value={item.city || ''}
onChange={this.handleChange} autoComplete="address-level1"/>
</FormGroup>
<div className="row">
<FormGroup className="col-md-4 mb-3">
<Label for="stateOrProvince">State/Province</Label>
<Input type="text" name="stateOrProvince" id="stateOrProvince" value={item.stateOrProvince || ''}
onChange={this.handleChange} autoComplete="address-level1"/>
</FormGroup>
<FormGroup className="col-md-5 mb-3">
<Label for="country">Country</Label>
<Input type="text" name="country" id="country" value={item.country || ''}
onChange={this.handleChange} autoComplete="address-level1"/>
</FormGroup>
<FormGroup className="col-md-3 mb-3">
<Label for="country">Postal Code</Label>
<Input type="text" name="postalCode" id="postalCode" value={item.postalCode || ''}
onChange={this.handleChange} autoComplete="address-level1"/>
</FormGroup>
</div>
<FormGroup>
<Button color="primary" type="submit">Save</Button>{' '}
<Button color="secondary" tag={Link} to="/groups">Cancel</Button>
</FormGroup>
</Form>
</Container>
</div>
}
}
export default withRouter(GroupEdit);
底部需要使用withRouter()
高阶组件来显示this.props.history
因此您可以在添加或保存GroupList
后导航回this.props.history
。
修改app/src/App.js
以导入GroupEdit
并指定其路径。
import GroupEdit from './GroupEdit';
class App extends Component {
render() {
return (
<Router>
<Switch>
...
<Route path='/groups/:id' component={GroupEdit}/>
</Switch>
</Router>
)
}
}
现在,您应该可以添加和编辑组了!
使用Okta添加身份验证
构建CRUD应用程序非常酷,但是构建安全的应用程序甚至更酷。 为此,您需要添加身份验证,以便用户必须先登录才能查看/修改组。 为简化起见,您可以使用Okta的OIDC API。 在Okta,我们的目标是使身份管理比您以往更加轻松,安全和可扩展。 Okta是一项云服务,允许开发人员创建,编辑和安全地存储用户帐户和用户帐户数据,并将它们与一个或多个应用程序连接。 我们的API使您能够:
你卖了吗 注册一个永久免费的开发者帐户 ,完成后再回来,这样您就可以了解有关使用Spring Boot构建安全应用程序的更多信息!
Spring Security + OIDC
Spring Security在其5.0版本中增加了OIDC支持 。 从那时起,他们进行了许多改进并简化了所需的配置。 我认为探索最新和最有趣的东西很有趣,所以我首先使用Spring的快照存储库更新pom.xml
,将Spring Boot和Spring Security升级到夜间构建,并添加必要的Spring Security依赖项来进行OIDC身份验证。
<?xml version="1.0" encoding="UTF-8"?>
<project>
...
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.0.BUILD-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
...
<spring-security.version>5.1.0.BUILD-SNAPSHOT</spring-security.version>
</properties>
<dependencies>
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</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-jose</artifactId>
</dependency>
</dependencies>
<build...>
<pluginRepositories>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshot</name>
<url>http://repo.spring.io/snapshot</url>
</repository>
</repositories>
</project>
在Okta中创建OIDC应用
登录到您的1563开发者帐户(或者注册 ,如果你没有一个帐户)并导航到应用程序 > 添加应用程序 。 单击“ Web” ,然后单击“ 下一步” 。 给应用程序起一个您会记住的名称,并指定http://localhost:8080/login/oauth2/code/okta
作为登录重定向URI。 点击完成 ,然后点击编辑以编辑常规设置。 添加http://localhost:3000
和http://localhost:8080
作为注销重定向URI,然后点击保存 。
将默认授权服务器的URI,客户端ID和客户端密钥复制并粘贴到src/main/resources/application.yml
。 创建此文件,然后可以删除同一目录中的application.properties
文件。
spring:
security:
oauth2:
client:
registration:
okta:
client-id: {clientId}
client-secret: {clientSecret}
scope: openid email profile
provider:
okta:
issuer-uri: https://{yourOktaDomain}/oauth2/default
为React和用户身份配置Spring Security
为了使Spring Security React友好,请在src/main/java/.../jugtours/config
创建一个SecurityConfiguration.java
文件。 创建config
目录并将该类放入其中。
package com.okta.developer.jugtours.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final Logger log = LoggerFactory.getLogger(SecurityConfiguration.class);
@Override
protected void configure(HttpSecurity http) throws Exception {
RequestCache requestCache = refererRequestCache();
SavedRequestAwareAuthenticationSuccessHandler handler = new SavedRequestAwareAuthenticationSuccessHandler();
handler.setRequestCache(requestCache);
http
.exceptionHandling()
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/okta"))
.and()
.oauth2Login()
.successHandler(handler)
.and()
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
.requestCache()
.requestCache(requestCache)
.and()
.authorizeRequests()
.antMatchers("/**/*.{js,html,css}").permitAll()
.antMatchers("/", "/api/user").permitAll()
.anyRequest().authenticated();
}
@Bean
public RequestCache refererRequestCache() {
return new RequestCache() {
private String savedAttrName = getClass().getName().concat(".SAVED");
@Override
public void saveRequest(HttpServletRequest request, HttpServletResponse response) {
String referrer = request.getHeader("referer");
if (referrer != null) {
request.getSession().setAttribute(this.savedAttrName, referrerRequest(referrer));
}
}
@Override
public SavedRequest getRequest(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session != null) {
return (SavedRequest) session.getAttribute(this.savedAttrName);
}
return null;
}
@Override
public HttpServletRequest getMatchingRequest(HttpServletRequest request, HttpServletResponse response) {
return request;
}
@Override
public void removeRequest(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session != null) {
log.debug("Removing SavedRequest from session if present");
session.removeAttribute(this.savedAttrName);
}
}
};
}
private SavedRequest referrerRequest(final String referrer) {
return new SavedRequest() {
@Override
public String getRedirectUrl() {
return referrer;
}
@Override
public List<Cookie> getCookies() {
return null;
}
@Override
public String getMethod() {
return null;
}
@Override
public List<String> getHeaderValues(String name) {
return null;
}
@Override
public Collection<String> getHeaderNames() {
return null;
}
@Override
public List<Locale> getLocales() {
return null;
}
@Override
public String[] getParameterValues(String name) {
return new String[0];
}
@Override
public Map<String, String[]> getParameterMap() {
return null;
}
};
}
}
这堂课正在进行很多,所以让我解释一些事情。 在年初configure()
方法,你建立一个新类型,缓存网址标头(拼错请求缓存的referer
在现实生活中),所以Spring Security可以验证后回重定向到它。 当您在http://localhost:3000
上开发React并希望在登录后重定向到那里时,基于引用者的请求缓存会派上用场。
@Override
protected void configure(HttpSecurity http) throws Exception {
RequestCache requestCache = refererRequestCache();
SavedRequestAwareAuthenticationSuccessHandler handler = new SavedRequestAwareAuthenticationSuccessHandler();
handler.setRequestCache(requestCache);
http
.exceptionHandling()
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/okta"))
.and()
.oauth2Login()
.successHandler(handler)
.and()
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
.requestCache()
.requestCache(requestCache)
.and()
.authorizeRequests()
.antMatchers("/**/*.{js,html,css}").permitAll()
.antMatchers("/", "/api/user").permitAll()
.anyRequest().authenticated();
}
authenticationEntryPoint()
行使Spring Security自动重定向到Okta。 在Spring Security 5.1.0.RELEASE中,当您仅配置一个OIDC提供程序时,将不需要此行。 它会自动重定向。
使用CookieCsrfTokenRepository.withHttpOnlyFalse()
配置CSRF(跨站点请求伪造)保护意味着XSRF-TOKEN
cookie将不会被标记为仅HTTP,因此React可以读取它并在尝试操作数据时将其发送回去。
antMatchers
行定义了匿名用户可以使用哪些URL。 您将很快进行配置,以便由Spring Boot应用程序服务您的React应用程序,因此允许使用Web文件和“ /”的原因。 您可能会注意到也有一个公开的/api/user
路径。 创建src/main/java/.../jugtours/web/UserController.java
并使用以下代码填充它。 React将使用此API来1)找出用户是否已通过身份验证,以及2)执行全局注销。
package com.okta.developer.jugtours.web;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@RestController
public class UserController {
@Value("${spring.security.oauth2.client.provider.okta.issuer-uri}")
String issuerUri;
@GetMapping("/api/user")
public ResponseEntity<?> getUser(@AuthenticationPrincipal OAuth2User user) {
if (user == null) {
return new ResponseEntity<>("", HttpStatus.OK);
} else {
return ResponseEntity.ok().body(user.getAttributes());
}
}
@PostMapping("/api/logout")
public ResponseEntity<?> logout(HttpServletRequest request,
@AuthenticationPrincipal(expression = "idToken") OidcIdToken idToken) {
// send logout URL to client so they can initiate logout - doesn't work from the server side
// Make it easier: https://github.com/spring-projects/spring-security/issues/5540
String logoutUrl = issuerUri + "/v1/logout";
Map<String, String> logoutDetails = new HashMap<>();
logoutDetails.put("logoutUrl", logoutUrl);
logoutDetails.put("idToken", idToken.getTokenValue());
request.getSession(false).invalidate();
return ResponseEntity.ok().body(logoutDetails);
}
}
您还需要创建组时,这样就可以通过你的壶之旅筛选添加用户信息。 在与GroupRepository.java
相同的目录中添加UserRepository.java
。
package com.okta.developer.jugtours.model;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, String> {
}
将新的findAllByUserId(String id)
方法添加到GroupRepository.java
。
List<Group> findAllByUserId(String id);
然后将UserRepository
注入GroupController.java
并在添加新组时使用它来创建(或获取现有用户)。 在那里,请修改groups()
方法以按用户过滤。
package com.okta.developer.jugtours.web;
import com.okta.developer.jugtours.model.Group;
import com.okta.developer.jugtours.model.GroupRepository;
import com.okta.developer.jugtours.model.User;
import com.okta.developer.jugtours.model.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.Principal;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api")
class GroupController {
private final Logger log = LoggerFactory.getLogger(GroupController.class);
private GroupRepository groupRepository;
private UserRepository userRepository;
public GroupController(GroupRepository groupRepository, UserRepository userRepository) {
this.groupRepository = groupRepository;
this.userRepository = userRepository;
}
@GetMapping("/groups")
Collection<Group> groups(Principal principal) {
return groupRepository.findAllByUserId(principal.getName());
}
@GetMapping("/group/{id}")
ResponseEntity<?> getGroup(@PathVariable Long id) {
Optional<Group> group = groupRepository.findById(id);
return group.map(response -> ResponseEntity.ok().body(response))
.orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
}
@PostMapping("/group")
ResponseEntity<Group> createGroup(@Valid @RequestBody Group group,
@AuthenticationPrincipal OAuth2User principal) throws URISyntaxException {
log.info("Request to create group: {}", group);
Map<String, Object> details = principal.getAttributes();
String userId = details.get("sub").toString();
// check to see if user already exists
Optional<User> user = userRepository.findById(userId);
group.setUser(user.orElse(new User(userId,
details.get("name").toString(), details.get("email").toString())));
Group result = groupRepository.save(group);
return ResponseEntity.created(new URI("/api/group/" + result.getId()))
.body(result);
}
@PutMapping("/group")
ResponseEntity<Group> updateGroup(@Valid @RequestBody Group group) {
log.info("Request to update group: {}", group);
Group result = groupRepository.save(group);
return ResponseEntity.ok().body(result);
}
@DeleteMapping("/group/{id}")
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
log.info("Request to delete group: {}", id);
groupRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}
为了放大更改,它们在groups()
和createGroup()
方法中。 Spring JPA会为您创建findAllByUserId()
方法/查询,并且userRepository.findById()
使用Java 8的Optional ,这是一个很好的选择 。
@GetMapping("/groups")
Collection<Group> groups(Principal principal) {
return groupRepository.findAllByUserId(principal.getName());
}
@PostMapping("/group")
ResponseEntity<Group> createGroup(@Valid @RequestBody Group group,
@AuthenticationPrincipal OAuth2User principal) throws URISyntaxException {
log.info("Request to create group: {}", group);
Map<String, Object> details = principal.getAttributes();
String userId = details.get("sub").toString();
// check to see if user already exists
Optional<User> user = userRepository.findById(userId);
group.setUser(user.orElse(new User(userId,
details.get("name").toString(), details.get("email").toString())));
Group result = groupRepository.save(group);
return ResponseEntity.created(new URI("/api/group/" + result.getId()))
.body(result);
}
修改React Handle CSRF并识别身份
您需要对React组件进行一些更改,以使它们能够识别身份。 您要做的第一件事是修改App.js
以将所有内容包装在CookieProvider
。 该组件允许您读取CSRF cookie并将其作为标题发送回。
import { CookiesProvider } from 'react-cookie';
class App extends Component {
render() {
return (
<CookiesProvider>
<Router...>
</CookiesProvider>
)
}
}
修改app/src/Home.js
以调用/api/user
来查看用户是否已登录。如果没有Login
,请显示“ Login
按钮。
import React, { Component } from 'react';
import './App.css';
import AppNavbar from './AppNavbar';
import { Link } from 'react-router-dom';
import { Button, Container } from 'reactstrap';
import { withCookies } from 'react-cookie';
class Home extends Component {
state = {
isLoading: true,
isAuthenticated: false,
user: undefined
};
constructor(props) {
super(props);
const {cookies} = props;
this.state.csrfToken = cookies.get('XSRF-TOKEN');
this.login = this.login.bind(this);
this.logout = this.logout.bind(this);
}
async componentDidMount() {
const response = await fetch('/api/user', {credentials: 'include'});
const body = await response.text();
if (body === '') {
this.setState(({isAuthenticated: false}))
} else {
this.setState({isAuthenticated: true, user: JSON.parse(body)})
}
}
login() {
let port = (window.location.port ? ':' + window.location.port : '');
if (port === ':3000') {
port = ':8080';
}
window.location.href = '//' + window.location.hostname + port + '/private';
}
logout() {
console.log('logging out...');
fetch('/api/logout', {method: 'POST', credentials: 'include',
headers: {'X-XSRF-TOKEN': this.state.csrfToken}}).then(res => res.json())
.then(response => {
window.location.href = response.logoutUrl + "?id_token_hint=" +
response.idToken + "&post_logout_redirect_uri=" + window.location.origin;
});
}
render() {
const message = this.state.user ?
<h2>Welcome, {this.state.user.name}!</h2> :
<p>Please log in to manage your JUG Tour.</p>;
const button = this.state.isAuthenticated ?
<div>
<Button color="link"><Link to="/groups">Manage JUG Tour</Link></Button>
<br/>
<Button color="link" onClick={this.logout}>Logout</Button>
</div> :
<Button color="primary" onClick={this.login}>Login</Button>;
return (
<div>
<AppNavbar/>
<Container fluid>
{message}
{button}
</Container>
</div>
);
}
}
export default withCookies(Home);
您应该在此组件中注意一些事项:
-
withCookies()
将Home
组件包装在底部,以使其可以访问cookie。 然后,您可以在构造const {cookies} = props
中使用const {cookies} = props
,并使用cookies.get('XSRF-TOKEN')
获取cookie。 - 使用
fetch()
,需要包括{credentials: 'include'}
来传输cookie。 如果不包含此选项,则将获得“ 403禁止访问”。 - Spring Security的CSRF cookie的名称与您需要发回的标头的名称不同。 cookie名称是
XSRF-TOKEN
,而标题名称是X-XSRF-TOKEN
。
更新app/src/GroupList.js
以进行类似更改。 好消息是您不需要对render()
方法进行任何更改。
import { Link, withRouter } from 'react-router-dom';
import { instanceOf } from 'prop-types';
import { withCookies, Cookies } from 'react-cookie';
class GroupList extends Component {
static propTypes = {
cookies: instanceOf(Cookies).isRequired
};
constructor(props) {
super(props);
const {cookies} = props;
this.state = {groups: [], csrfToken: cookies.get('XSRF-TOKEN'), isLoading: true};
this.remove = this.remove.bind(this);
}
componentDidMount() {
this.setState({isLoading: true});
fetch('api/groups', {credentials: 'include'})
.then(response => response.json())
.then(data => this.setState({groups: data, isLoading: false}))
.catch(() => this.props.history.push('/'))
}
async remove(id) {
await fetch(`/api/group/${id}`, {
method: 'DELETE',
headers: {
'X-XSRF-TOKEN': this.state.csrfToken,
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include'
}).then(() => {
let updatedGroups = [...this.state.groups].filter(i => i.id !== id);
this.setState({groups: updatedGroups});
});
}
render() {...}
}
export default withCookies(withRouter(GroupList));
也更新GroupEdit.js
。
import { instanceOf } from 'prop-types';
import { Cookies, withCookies } from 'react-cookie';
class GroupEdit extends Component {
static propTypes = {
cookies: instanceOf(Cookies).isRequired
};
emptyItem = {
name: '',
address: '',
city: '',
stateOrProvince: '',
country: '',
postalCode: ''
};
constructor(props) {
super(props);
const {cookies} = props;
this.state = {
item: this.emptyItem,
csrfToken: cookies.get('XSRF-TOKEN')
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
async componentDidMount() {
if (this.props.match.params.id !== 'new') {
try {
const group = await (await fetch(`/api/group/${this.props.match.params.id}`, {credentials: 'include'})).json();
this.setState({item: group});
} catch (error) {
this.props.history.push('/');
}
}
}
handleChange(event) {
const target = event.target;
const value = target.value;
const name = target.name;
let item = {...this.state.item};
item[name] = value;
this.setState({item});
}
async handleSubmit(event) {
event.preventDefault();
const {item, csrfToken} = this.state;
await fetch('/api/group', {
method: (item.id) ? 'PUT' : 'POST',
headers: {
'X-XSRF-TOKEN': csrfToken,
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(item),
credentials: 'include'
});
this.props.history.push('/groups');
}
render() {...}
}
export default withCookies(withRouter(GroupEdit));
完成所有这些更改之后,您应该能够重新启动Spring Boot和React,并见证计划自己的JUG Tour的荣耀!
配置Maven以使用Spring Boot构建和打包React
要使用Maven构建和打包React应用,可以使用frontend-maven-plugin和Maven的配置文件将其激活。 将版本的属性和<profiles>
部分添加到pom.xml
。
<properties>
...
<frontend-maven-plugin.version>1.6</frontend-maven-plugin.version>
<node.version>v10.6.0</node.version>
<yarn.version>v1.8.0</yarn.version>
</properties>
<profiles>
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<spring.profiles.active>dev</spring.profiles.active>
</properties>
</profile>
<profile>
<id>prod</id>
<build>
<plugins>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-resources</id>
<phase>process-classes</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/target/classes/static</outputDirectory>
<resources>
<resource>
<directory>app/build</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>${frontend-maven-plugin.version}</version>
<configuration>
<workingDirectory>app</workingDirectory>
</configuration>
<executions>
<execution>
<id>install node</id>
<goals>
<goal>install-node-and-yarn</goal>
</goals>
<configuration>
<nodeVersion>${node.version}</nodeVersion>
<yarnVersion>${yarn.version}</yarnVersion>
</configuration>
</execution>
<execution>
<id>yarn install</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>generate-resources</phase>
</execution>
<execution>
<id>yarn test</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>test</phase>
<configuration>
<arguments>test</arguments>
</configuration>
</execution>
<execution>
<id>yarn build</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>compile</phase>
<configuration>
<arguments>build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<properties>
<spring.profiles.active>prod</spring.profiles.active>
</properties>
</profile>
</profiles>
在使用时,将活动配置文件设置添加到src/main/resources/application.yml
:
spring:
profiles:
active: @spring.profiles.active@
security:
添加./mvnw spring-boot:run -Pprod
之后,您应该可以运行./mvnw spring-boot:run -Pprod
并且您的应用程序可以看到您的应用程序在http://localhost:8080
。
注意:如果您无法登录,则可以尝试在隐身窗口中打开您的应用程序。
Spring Security的OAuth 2.0与OIDC支持
在撰写这篇文章时,我与Rob Winch (Spring Security Lead)合作,以确保我有效地使用了Spring Security。 我开始使用Spring Security的OAuth 2.0支持及其@EnableOAuth2Sso
批注。 Rob鼓励我改用Spring Security的OIDC支持,这对使一切正常发挥了作用。
随着Spring Boot 2.1和Spring Security 5.1的里程碑和发行版的发布,我将更新此帖子以删除不再需要的代码。
了解有关Spring Boot和React的更多信息
我希望您喜欢本教程,了解如何使用React,Spring Boot和Spring Security进行CRUD。 您可以看到Spring Security的OIDC支持非常强大,并且不需要大量配置。 添加CSRF保护并将Spring Boot + React应用打包为单个工件也很酷!
您可以在GitHub上的https://github.com/oktadeveloper/okta-spring-boot-react-crud-example上找到本教程中创建的示例。
我们还编写了其他一些很棒的Spring Boot和React教程,如果您有兴趣的话可以查看它们。
- 使用Spring Boot和React进行Bootiful开发
- 构建一个React Native应用程序并使用OAuth 2.0进行身份验证
- 使用Jenkins X和Kubernetes将CI / CD添加到您的Spring Boot应用程序
- 15分钟内通过用户身份验证构建React应用程序
如有任何疑问,请随时在下面发表评论,或在我们的Okta开发者论坛上向我们提问。 如果您想查看更多类似的教程,请在Twitter上关注我们!
“我喜欢编写身份验证和授权代码。” 〜从来没有Java开发人员。 厌倦了一次又一次地建立相同的登录屏幕? 尝试使用Okta API进行托管身份验证,授权和多因素身份验证。
``使用React和Spring Boot构建简单的CRUD应用程序''最初于2018年7月19日发布在Okta开发者博客上。
翻译自: https://www.javacodegeeks.com/2018/07/react-spring-boot-build-crud-app.html