“我喜欢编写身份验证和授权代码。” 〜从来没有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@GeneratedValueprivate Long id;@NonNullprivate 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@GeneratedValueprivate Long id;private Instant date;private String title;private String description;@ManyToManyprivate 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 {@Idprivate 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;}@Overridepublic 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><NavLinkhref="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 profileprovider: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);@Overrideprotected 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();}@Beanpublic RequestCache refererRequestCache() {return new RequestCache() {private String savedAttrName = getClass().getName().concat(".SAVED");@Overridepublic void saveRequest(HttpServletRequest request, HttpServletResponse response) {String referrer = request.getHeader("referer");if (referrer != null) {request.getSession().setAttribute(this.savedAttrName, referrerRequest(referrer));}}@Overridepublic SavedRequest getRequest(HttpServletRequest request, HttpServletResponse response) {HttpSession session = request.getSession(false);if (session != null) {return (SavedRequest) session.getAttribute(this.savedAttrName);}return null;}@Overridepublic HttpServletRequest getMatchingRequest(HttpServletRequest request, HttpServletResponse response) {return request;}@Overridepublic 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() {@Overridepublic String getRedirectUrl() {return referrer;}@Overridepublic List<Cookie> getCookies() {return null;}@Overridepublic String getMethod() {return null;}@Overridepublic List<String> getHeaderValues(String name) {return null;}@Overridepublic Collection<String> getHeaderNames() {return null;}@Overridepublic List<Locale> getLocales() {return null;}@Overridepublic String[] getParameterValues(String name) {return new String[0];}@Overridepublic 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/5540String 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 existsOptional<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 existsOptional<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