Angular是我最喜欢的构建单页应用程序(SPA)的框架之一。作为Java开发人员,其组件,服务和管道的分离对我来说很有意义。这是一个网络框架,可让您通过创建可重复使用的小型组件来声明地描述您的UI。我相信这是打字稿受欢迎的巨大影响者。它也得到了Google的支持,这意味着它可能会持续很长时间。
我喜欢构建CRUD(创建,阅读,更新和删除)应用程序以了解框架。我认为它们在创建应用程序时显示了许多基本功能。一旦完成CRUD的基础知识,大多数集成工作就完成了,您可以继续实施必要的业务逻辑。
在本教程中,您将学习如何使用Spring Boot和Angular构建安全的CRUD应用程序。最终结果将使用OAuth 2.0授权代码流,并在Spring Boot应用程序中包装Angular App作为单个工件进行分发。同时,我将向您展示如何在本地开发时保持Angular的生产力。
您需要安装多种工具与本教程一起遵循。您也可以watch it as a screencast。
先决条件:
配置并运行春季靴和Angular App
我是世界各地的会议和Java用户组(水罐)的经常发言人。我一直是Java开发人员已有20多年的历史,并且是Java社区。我发现在水罐上讲话是与社区互动并获得有关演示文稿的原始反馈的好方法。
我为什么告诉你这个?因为我认为创建一个“水罐旅行”应用程序会很有趣,该应用程序允许您创建/编辑/删除水罐并查看即将到来的事件。
我意识到,花20分钟来构建此应用可能会很麻烦,因此我已经在@oktadev/auth0-spring-boot-angular-crud-example中构建了它。该项目使用Spring Boot 3.1.0和Angular 16.我希望这可以帮助您克隆,配置和运行!看到跑步的东西真是一种快乐的经历。 ðübr>
git clone https://github.com/oktadev/auth0-spring-boot-angular-crud-example jugtours
cd jugtours
打开一个终端窗口并运行auth0 login
以配置Auth0 CLI,以获取租户的API键。然后,运行auth0 apps create
向适当的URL注册此应用:
auth0 apps create \
--name "Bootiful Angular" \
--description "Spring Boot + Angular = ❤️" \
--type regular \
--callbacks http://localhost:8080/login/oauth2/code/okta,http://localhost:4200/login/oauth2/code/okta \
--logout-urls http://localhost:8080,http://localhost:4200 \
--reveal-secrets
将输出值从此命令复制到新的.okta.env
文件:
export OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
export OKTA_OAUTH2_CLIENT_ID=<your-client-id>
export OKTA_OAUTH2_CLIENT_SECRET=<your-client-secret>
如果您在Windows上,请使用set
而不是export
设置这些环境变量,并将文件命名为.okta.env.bat
:
set OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
set OKTA_OAUTH2_CLIENT_ID=<your-client-id>
set OKTA_OAUTH2_CLIENT_SECRET=<your-client-secret>
然后,运行source .okta.env
(或Windows上的.okta.env.bat
)在当前的外壳中设置这些环境变量。
最后,运行./mvnw
(或Windows上的mvnw
)启动应用程序。
source .okta.env # run .okta.env.bat on Windows
./mvnw -Pprod # use mvnw -Pprod on Windows
要查看该应用程序,您可以在您喜欢的浏览器中打开http://localhost:8080
。
单击登录,您将提示您使用Auth0登录。还会要求您同意。这是因为该应用要求访问您的个人资料和电子邮件地址。单击接受继续。
经过身份验证后,您将看到一个链接来管理水罐游览。
您应该能够添加新的组和事件,并编辑并删除它们。
验证柏树端到端测试通过
您可以通过执行项目中包含的柏树测试来验证一切有效。首先,将您的凭据的环境变量添加到您之前创建的.okta.env
(或.okta.env.bat
)文件中。
export CYPRESS_E2E_DOMAIN=<your-auth0-domain> # use the raw value, no https prefix
export CYPRESS_E2E_USERNAME=<your-email>
export CYPRESS_E2E_PASSWORD=<your-password>
然后,运行source .okta.env
(或Windows上的.okta.env.bat
)设置这些环境变量。
最后,运行npm run e2e
启动应用程序并运行柏树测试。
cd app
npm run e2e
漂亮,你不觉得吗? ðρ
请继续阅读,如果您想查看我如何创建此应用!
使用Spring Boot创建Java REST API
创建新的Spring Boot应用程序的最简单方法是导航到start.spring.io并进行以下选择:
-
项目:
Maven Project
-
组:
com.okta.developer
-
文物:
jugtours
-
依赖项:
JPA
,H2
,Web
,Validation
22
单击生成项目,下载后展开jugtours.zip
,然后在您喜欢的IDE中打开项目。
您也可以使用this link或HTTPie从命令行创建项目:
https start.spring.io/starter.zip type==maven-project bootVersion==3.1.0 \
dependencies==data-jpa,h2,web,validation \
language==java platformVersion==17 \
name==jugtours artifactId==jugtours \
groupId==com.okta.developer packageName==com.okta.developer.jugtours \
baseDir==jugtours | tar -xzvf -
添加JPA域模型
在您喜欢的IDE中打开Jugtours项目。在其中创建一个src/main/java/com/okta/developer/jugtours/model
目录和一个Group.java
类。
package com.okta.developer.jugtours.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import java.util.Set;
@Entity
@Table(name = "user_group")
public class Group {
@Id
@GeneratedValue
private Long id;
@NotNull
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;
// JPA requires a default constructor
public Group() {}
public Group(String name) {
this.name = name;
}
// getters and setters, equals, hashcode, and toString omitted for brevity
// Why not Lombok? See https://twitter.com/mariofusco/status/1650439733212766208
// Want Lombok anyway? See https://bit.ly/3HkaYMm and revert
}
在同一软件包中创建一个Event.java
类。
package com.okta.developer.jugtours.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToMany;
import java.time.Instant;
import java.util.Set;
@Entity
public class Event {
@Id
@GeneratedValue
private Long id;
private Instant date;
private String title;
private String description;
@ManyToMany
private Set<User> attendees;
public Event() {}
public Event(Instant date, String title, String description) {
this.date = date;
this.title = title;
this.description = description;
}
// you can generate the getters and setters using your IDE!
}
和User.java
类。
package com.okta.developer.jugtours.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Objects;
@Entity
@Table(name = "users")
public class User {
@Id
private String id;
private String name;
private String email;
public User() {}
public User(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// getters and setters omitted for brevity
}
创建一个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("Omaha JUG", "Kansas City JUG", "Chicago JUG",
"Dallas JUG", "Philly JUG", "Garden State JUG", "NY Java SIG")
.forEach(name -> repository.save(new Group(name)));
Group jug = repository.findByName("Garden State JUG");
Event e = new Event(Instant.parse("2023-10-18T18:00:00.000Z"),
"OAuth for Java Developers", "Learn all about OAuth and OIDC + Java!");
jug.setEvents(Collections.singleton(e));
repository.save(jug);
repository.findAll().forEach(System.out::println);
}
}
使用mvn spring-boot:run
启动您的应用程序,您应该看到正在创建的组和事件。
package com.okta.developer.jugtours.web;
import com.okta.developer.jugtours.model.Group;
import com.okta.developer.jugtours.model.GroupRepository;
import jakarta.validation.Valid;
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 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 final 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(@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();
}
}
重新启动应用程序,使用httpie击中http://localhost:8080/api/groups
,您应该看到组列表。
http :8080/api/groups
您可以使用以下命令创建,读取,更新和删除组。
http POST :8080/api/group name='SF JUG' city='San Francisco' country=USA
http :8080/api/group/8
http PUT :8080/api/group/8 id=8 name='SF JUG' address='By the Bay'
http DELETE :8080/api/group/8
使用角CLI创建一个角应用
Angular CLI在2016年发布时是革命性的工具。现在,它是创建新的Angular项目的标准方法,也是最简单的Angular开始的方法。许多网络框架已经采用了类似的工具来改善其开发人员的体验。
您不必在全球安装Angular CLI。 npx
命令可以为您安装并运行。
npx @angular/cli@16 new app --routing --style css
当然,如果愿意,您可以使用久经考验的npm i -g @angular/cli
和ng new app --routing --style css
。如果要居住在边缘,您甚至可以删除版本号。
应用程序创建过程完成后,导航到app
目录并安装Angular Material以使UI看起来很漂亮,尤其是在移动设备上。
cd app
ng add @angular/material
您将提示您选择主题,设置排版样式并包括动画。选择默认值。
修改app/src/app/app.component.html
并将CSS移动到app.component.css
:
<div class="toolbar" role="banner">
...
</div>
<div class="content" role="main">
<router-outlet></router-outlet>
</div>
致电您的Spring Boot API并显示结果
更新app.component.ts
加载时列表。
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Group } from './model/group';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
title = 'JUG Tours';
loading = true;
groups: Group[] = [];
constructor(private http: HttpClient) {
}
ngOnInit() {
this.loading = true;
this.http.get<Group[]>('api/groups').subscribe((data: Group[]) => {
this.groups = data;
this.loading = false;
});
}
}
在此编译之前,您需要创建一个带有以下内容的app/src/app/model/group.ts
文件:
export class Group {
id: number | null;
name: string;
constructor(group: Partial<Group> = {}) {
this.id = group?.id || null;
this.name = group?.name || '';
}
}
并将HttpClientModule
添加到app.module.ts
:
import { HttpClientModule } from '@angular/common/http';
@NgModule({
...
imports: [
...
HttpClientModule
],
...
})
export class AppModule { }
然后,修改app.component.html
文件以显示组列表。
<div class="content" role="main">
<h2>{{title}}</h2>
<div *ngIf="loading">
<p>Loading...</p>
</div>
<div *ngFor="let group of groups">
<div>{{group.name}}</div>
</div>
<router-outlet></router-outlet>
</div>
在您的Angular Project的src
文件夹中创建一个名为proxy.conf.js
的文件,并使用它来定义您的代理:
const PROXY_CONFIG = [
{
context: ['/api'],
target: 'http://localhost:8080',
secure: true,
logLevel: 'debug'
}
]
module.exports = PROXY_CONFIG;
更新angular.json
及其使用代理的serve
命令。
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "app:build:production"
},
"development": {
"browserTarget": "app:build:development",
"proxyConfig": "src/proxy.conf.js"
}
},
"defaultConfiguration": "development"
},
使用Ctrl+C
停止您的应用程序,然后使用npm start
重新启动。现在,您应该在Angular App中看到一个组列表!
构建Angular GroupList
组件
Angular是一个组件框架,可让您轻松地分离。您不想在主AppComponent
中渲染所有内容,因此创建一个新组件以显示组列表。
ng g c group-list --standalone
此命令将使用打字稿文件,HTML模板,CSS文件和测试文件创建src/app/group-list
中的新组件。 --standalone
标志在Angular 15中是新的,可允许您隔离组件具有独立式,因此更易于分发。如果您遵循,则不需要使用它,但是最终代码使用它。
用以下内容替换group-list.component.ts
中的代码:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Group } from '../model/group';
import { HttpClient } from '@angular/common/http';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-group-list',
standalone: true,
imports: [CommonModule, RouterLink, MatButtonModule, MatTableModule, MatIconModule],
templateUrl: './group-list.component.html',
styleUrls: ['./group-list.component.css']
})
export class GroupListComponent {
title = 'Group List';
loading = true;
groups: Group[] = [];
displayedColumns = ['id','name','events','actions'];
feedback: any = {};
constructor(private http: HttpClient) {
}
ngOnInit() {
this.loading = true;
this.http.get<Group[]>('api/groups').subscribe((data: Group[]) => {
this.groups = data;
this.loading = false;
this.feedback = {};
});
}
delete(group: Group): void {
if (confirm(`Are you sure you want to delete ${group.name}?`)) {
this.http.delete(`api/group/${group.id}`).subscribe({
next: () => {
this.feedback = {type: 'success', message: 'Delete was successful!'};
setTimeout(() => {
this.ngOnInit();
}, 1000);
},
error: () => {
this.feedback = {type: 'warning', message: 'Error deleting.'};
}
});
}
}
protected readonly event = event;
}
更新其HTML模板以使用Angular Material的表组件。
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a routerLink="/">Home</a></li>
<li class="breadcrumb-item active">Groups</li>
</ol>
</nav>
<a [routerLink]="['/group/new']" mat-raised-button color="primary" style="float: right" id="add">Add Group</a>
<h2>{{title}}</h2>
<div *ngIf="loading; else list">
<p>Loading...</p>
</div>
<ng-template #list>
<div *ngIf="feedback.message" class="alert alert-{{feedback.type}}">{{ feedback.message }}</div>
<table mat-table [dataSource]="groups">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef> ID </mat-header-cell>
<mat-cell *matCellDef="let item"> {{ item.id }} </mat-cell>
</ng-container>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef> Name </mat-header-cell>
<mat-cell *matCellDef="let item"> {{ item.name }} </mat-cell>
</ng-container>
<ng-container matColumnDef="events">
<mat-header-cell *matHeaderCellDef> Events </mat-header-cell>
<mat-cell *matCellDef="let item">
<ng-container *ngFor="let event of item.events">
{{event.date | date }}: {{ event.title }}
<br/>
</ng-container>
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef> Actions </mat-header-cell>
<mat-cell *matCellDef="let item">
<a [routerLink]="['../group', item.id ]" mat-raised-button color="accent">Edit</a>
<button (click)="delete(item)" mat-button color="warn"><mat-icon>delete</mat-icon></button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</table>
</ng-template>
创建一个HomeComponent
以显示欢迎消息和指向“组”页面的链接。此组件将是应用程序的默认路由。
ng g c home --standalone
更新home.component.html
的内容:
<a mat-button color="primary" href="/groups">Manage JUG Tour</a>
更改app.component.html
以删除<router-outlet>
上方的组列表:
<div class="content" role="main">
<router-outlet></router-outlet>
</div>
并删除从app.component.ts
获取逻辑的组:
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'JUG Tours';
}
添加HomeComponent
的路线和GroupListComponent
到app-routing.module.ts
:
import { HomeComponent } from './home/home.component';
import { GroupListComponent } from './group-list/group-list.component';
export const routes: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{
path: 'home',
component: HomeComponent
},
{
path: 'groups',
component: GroupListComponent
}
];
更新styles.css
中的CSS以具有breadcrumb
和alert
类规则:
/* https://careydevelopment.us/blog/angular-how-to-add-breadcrumbs-to-your-ui */
ol.breadcrumb {
padding: 0;
list-style-type: none;
}
.breadcrumb-item + .active {
color: inherit;
font-weight: 500;
}
.breadcrumb-item {
color: #3F51B5;
font-size: 1rem;
text-decoration: underline;
cursor: pointer;
}
.breadcrumb-item + .breadcrumb-item {
padding-left: 0.5rem;
}
.breadcrumb-item + .breadcrumb-item::before {
display: inline-block;
padding-right: 0.5rem;
color: rgb(108, 117, 125);
content: "/";
}
ol.breadcrumb li {
list-style-type: none;
}
ol.breadcrumb li {
list-style-type: none;
display: inline
}
.alert {
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid transparent;
}
.alert-success {
color: #155724;
background-color: #d4edda;
border-color: #c3e6cb;
}
.alert-error {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
在您的app
目录中运行npm start
,以查看所有外观。单击“管理水罐之旅”,您应该查看默认组的列表。
要挤压 Action 右侧的列,请将以下内容添加到group-list.component.css
:
.mat-column-actions {
flex: 0 0 120px;
}
您的Angular应用程序应在进行更改时自行更新。
很高兴在Angular应用中看到您的Spring Boot API的数据,但是如果您无法修改它,这没什么好玩的!
构建Angular GroupEdit
组件
创建一个group-edit
组件并使用Angular's HttpClient
用URL的ID获取组资源。
ng g c group-edit --standalone
将此组件的路线添加到app-routing.module.ts
:
import { GroupEditComponent } from './group-edit/group-edit.component';
export const routes: Routes = [
...
{
path: 'group/:id',
component: GroupEditComponent
}
];
用以下内容替换group-edit.component.ts
中的代码:
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { map, of, switchMap } from 'rxjs';
import { Group } from '../model/group';
import { Event } from '../model/event';
import { HttpClient } from '@angular/common/http';
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatIconModule } from '@angular/material/icon';
import { MatNativeDateModule } from '@angular/material/core';
import { MatTooltipModule } from '@angular/material/tooltip';
@Component({
selector: 'app-group-edit',
standalone: true,
imports: [
CommonModule, MatInputModule, FormsModule, MatButtonModule, RouterLink,
MatDatepickerModule, MatIconModule, MatNativeDateModule, MatTooltipModule
],
templateUrl: './group-edit.component.html',
styleUrls: ['./group-edit.component.css']
})
export class GroupEditComponent implements OnInit {
group!: Group;
feedback: any = {};
constructor(private route: ActivatedRoute, private router: Router,
private http: HttpClient) {
}
ngOnInit() {
this.route.params.pipe(
map(p => p['id']),
switchMap(id => {
if (id === 'new') {
return of(new Group());
}
return this.http.get<Group>(`api/group/${id}`);
})
).subscribe({
next: group => {
this.group = group;
this.feedback = {};
},
error: () => {
this.feedback = {type: 'warning', message: 'Error loading'};
}
});
}
save() {
const id = this.group.id;
const method = id ? 'put' : 'post';
this.http[method]<Group>(`/api/group${id ? '/' + id : ''}`, this.group).subscribe({
next: () => {
this.feedback = {type: 'success', message: 'Save was successful!'};
setTimeout(async () => {
await this.router.navigate(['/groups']);
}, 1000);
},
error: () => {
this.feedback = {type: 'error', message: 'Error saving'};
}
});
}
async cancel() {
await this.router.navigate(['/groups']);
}
addEvent() {
this.group.events.push(new Event());
}
removeEvent(index: number) {
this.group.events.splice(index, 1);
}
}
创建一个model/event.ts
文件,以便此组件会编译。
export class Event {
id: number | null;
date: Date | null;
title: string;
constructor(event: Partial<Event> = {}) {
this.id = event?.id || null;
this.date = event?.date || null;
this.title = event?.title || '';
}
}
更新model/group.ts
以包括Event
类。
import { Event } from './event';
export class Group {
id: number | null;
name: string;
events: Event[];
constructor(group: Partial<Group> = {}) {
this.id = group?.id || null;
this.name = group?.name || '';
this.events = group?.events || [];
}
}
GroupEditComponent
需要呈现表单,因此请使用以下内容更新group-edit.component.html
:
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a routerLink="/">Home</a></li>
<li class="breadcrumb-item"><a routerLink="/groups">Groups</a></li>
<li class="breadcrumb-item active">Edit Group</li>
</ol>
</nav>
<h2>Group Information</h2>
<div *ngIf="feedback.message" class="alert alert-{{feedback.type}}">{{ feedback.message }}</div>
<form *ngIf="group" #editForm="ngForm" (ngSubmit)="save()">
<mat-form-field class="full-width" *ngIf="group.id">
<mat-label>ID</mat-label>
<input matInput [(ngModel)]="group.id" id="id" name="id" placeholder="ID" readonly>
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>Name</mat-label>
<input matInput [(ngModel)]="group.name" id="name" name="name" placeholder="Name" required>
</mat-form-field>
<h3 *ngIf="group.events?.length">Events</h3>
<div *ngFor="let event of group.events; index as i" class="full-width">
<mat-form-field style="width: 35%">
<mat-label>Date</mat-label>
<input matInput [matDatepicker]="picker"
[(ngModel)]="group.events[i].date" name="group.events[{{i}}].date" placeholder="Date">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
<mat-form-field style="width: 65%">
<mat-label>Title</mat-label>
<input matInput [(ngModel)]="group.events[i].title" name="group.events[{{i}}].title" placeholder="Title">
</mat-form-field>
<button mat-icon-button (click)="removeEvent(i)" aria-label="Remove Event"
style="float: right; margin: -70px -5px 0 0">
<mat-icon>delete</mat-icon>
</button>
</div>
<div class="button-row" role="group">
<button type="button" mat-mini-fab color="accent" (click)="addEvent()"
aria-label="Add Event" *ngIf="group.id" matTooltip="Add Event"
style="float: right; margin-top: -4px"><mat-icon>add</mat-icon></button>
<button type="submit" mat-raised-button color="primary" [disabled]="!editForm.form.valid" id="save">Save</button>
<button type="button" mat-button color="accent" (click)="cancel()" id="cancel">Cancel</button>
</div>
</form>
如果仔细观察,您会注意到此组件允许您为组编辑事件。该组件是如何处理角中嵌套对象的一个很好的例子。
更新group-edit.component.css
,以使所有设备上的情况看起来更好:
form, h2 {
min-width: 150px;
max-width: 700px;
width: 100%;
margin: 10px auto;
}
.alert {
max-width: 660px;
margin: 0 auto;
}
.full-width {
width: 100%;
}
现在,随着您的Angular应用程序运行,您应该能够添加和编辑组! yaasss! ð7
要使最高的Navbar使用Angular材料颜色,请使用以下内容更新app.component.html
:
<mat-toolbar role="banner" color="primary" class="toolbar">
<img
width="40"
alt="Angular Logo"
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg=="
/>
<span>{{ title }}</span>
<div class="spacer"></div>
<a aria-label="OktaDev on Twitter" target="_blank" rel="noopener" href="https://twitter.com/oktadev" title="Twitter">
<svg id="twitter-logo" height="24" data-name="Logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<rect width="400" height="400" fill="none"/>
<path
d="M153.62,301.59c94.34,0,145.94-78.16,145.94-145.94,0-2.22,0-4.43-.15-6.63A104.36,104.36,0,0,0,325,122.47a102.38,102.38,0,0,1-29.46,8.07,51.47,51.47,0,0,0,22.55-28.37,102.79,102.79,0,0,1-32.57,12.45,51.34,51.34,0,0,0-87.41,46.78A145.62,145.62,0,0,1,92.4,107.81a51.33,51.33,0,0,0,15.88,68.47A50.91,50.91,0,0,1,85,169.86c0,.21,0,.43,0,.65a51.31,51.31,0,0,0,41.15,50.28,51.21,51.21,0,0,1-23.16.88,51.35,51.35,0,0,0,47.92,35.62,102.92,102.92,0,0,1-63.7,22A104.41,104.41,0,0,1,75,278.55a145.21,145.21,0,0,0,78.62,23"
fill="#fff"/>
</svg>
</a>
<a aria-label="OktaDev on YouTube" target="_blank" rel="noopener" href="https://youtube.com/oktadev" title="YouTube">
<svg id="youtube-logo" height="24" width="24" data-name="Logo" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="#fff">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path
d="M21.58 7.19c-.23-.86-.91-1.54-1.77-1.77C18.25 5 12 5 12 5s-6.25 0-7.81.42c-.86.23-1.54.91-1.77 1.77C2 8.75 2 12 2 12s0 3.25.42 4.81c.23.86.91 1.54 1.77 1.77C5.75 19 12 19 12 19s6.25 0 7.81-.42c.86-.23 1.54-.91 1.77-1.77C22 15.25 22 12 22 12s0-3.25-.42-4.81zM10 15V9l5.2 3-5.2 3z"/>
</svg>
</a>
</mat-toolbar>
由于这不是独立组件,因此您必须在app.module.ts
中导入MatToolbarModule
。
import { MatToolbarModule } from '@angular/material/toolbar';
@NgModule({
...
imports: [
...
MatToolbarModule
],
...
})
export class AppModule { }
在app.component.css
中进行一些调整以使工具栏看起来更好。
- 在
.toolbar
规则中,删除background-color
和color
属性。 - 将
#twitter-logo
和#youtube-logo
的利润更改为10px 16px 0 0
。 - 将
.content
规则更改为具有65px auto 32px
和align-items: stretch
的空白。
现在,该应用程序更多地填充了屏幕,工具栏具有匹配的颜色。
使用OpenID Connect和Oauth固定弹簧靴
我喜欢构建简单的CRUD应用程序来学习一个新的技术堆栈,但是我认为构建A Secure One更酷。因此,让我们这样做!
Spring Security在2017年大约5.0版中添加了对OpenID Connect(OIDC)的支持。这很棒,因为这意味着您可以使用Spring Security使用第三方身份提供商(IDP)(例如Auth0)来保护您的应用程序。这比尝试构建自己的身份验证系统并存储用户凭证要好得多。
添加Okta Spring Boot启动器以在pom.xml
中进行OIDC身份验证。这也将为您的应用程序添加春季安全性。
<dependency>
<groupId>com.okta.spring</groupId>
<artifactId>okta-spring-boot-starter</artifactId>
<version>3.0.4</version>
</dependency>
安装Auth0 CLI(如果还没有)并在外壳中运行auth0 login
。
接下来,运行auth0 apps create
以注册具有适当回调的新的OIDC应用程序:
auth0 apps create \
--name "Bootiful Angular" \
--description "Spring Boot + Angular = ❤️" \
--type regular \
--callbacks http://localhost:8080/login/oauth2/code/okta,http://localhost:4200/login/oauth2/code/okta \
--logout-urls http://localhost:8080,http://localhost:4200 \
--reveal-secrets
将返回的值从此命令复制到.okta.env
文件:
export OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
export OKTA_OAUTH2_CLIENT_ID=<your-client-id>
export OKTA_OAUTH2_CLIENT_SECRET=<your-client-secret>
如果您在Windows上,请使用set
而不是export
设置这些环境变量并将文件命名为.okta.env.bat
:
set OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
set OKTA_OAUTH2_CLIENT_ID=<your-client-id>
set OKTA_OAUTH2_CLIENT_SECRET=<your-client-secret>
将*.env
添加到您的.gitignore
文件中,这样您就不会意外地揭露客户的秘密。
然后,运行source .okta.env
(或Windows上的.okta.env.bat
)在当前的外壳中设置这些环境变量。
最后,运行./mvnw
(或Windows上的mvnw
)启动应用程序。
source .okta.env
./mvnw spring-boot:run
提示:您可能必须运行chmod +x mvnw
才能执行Maven包装脚本。
然后,您可以在您喜欢的浏览器中打开http://localhost:8080
。您将重定向以进行身份验证并之后返回。您将在Spring Boot中看到404个错误,因为您没有映射到默认的/
路线。
配置弹簧安全性以最大程度保护
要使Spring Security-Angular-Frignly友好,请在src/main/java/.../jugtours/config
中创建一个SecurityConfiguration.java
文件。创建config
目录并将此类放入其中。
package com.okta.developer.jugtours.config;
import com.okta.developer.jugtours.web.CookieCsrfFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authz) -> authz
.requestMatchers("/", "/index.html", "*.ico", "*.css", "*.js", "/api/user").permitAll()
.anyRequest().authenticated())
.oauth2Login(withDefaults())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults()))
.csrf((csrf) -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
.addFilterAfter(new CookieCsrfFilter(), BasicAuthenticationFilter.class);
return http.build();
}
}
这堂课有很多事情要做,所以让我解释一下一些事情。在以前的Spring Security版本中,有一个authorizeRequests()
lambda可以用来保护路径。自Spring Security 3.1以来,它已被弃用,您应该使用authorizeHttpRequests()
。默认情况下,authorizeRequests()
lambda是允许的,这意味着您未指定的任何路径将被允许。建议使用authorizeHttpRequests()
显示的推荐方法默认情况下拒绝。这意味着您必须指定要允许Spring Security服务的资源以及Angular App具有的资源。
requestMatchers
行定义了匿名用户允许的URL。您很快就会配置内容,因此您的Spring Boot应用程序为您的Angular应用提供服务,因此允许/
,/index.html
和Web文件的原因。您可能还会注意到裸露的/api/user
路径。
使用CookieCsrfTokenRepository.withHttpOnlyFalse()
配置CSRF(跨站点请求伪造)保护意味着XSRF-TOKEN
Cookie不会仅标记HTTP,因此Angular可以读取并在尝试操纵数据时将其发送回去。 CsrfTokenRequestAttributeHandler
不再是默认值,因此您必须将其配置为请求处理程序。要了解更多信息,您可以阅读this Stack Overflow answer。基本上,由于我们没有将CSRF令牌发送到HTML页面,因此我们不必担心违规攻击。这意味着我们可以恢复为Spring Security 5的先前默认值。
您需要创建添加的CookieCsrfFilter
类,因为Spring Security 6不再为您设置Cookie。在web
软件包中创建它。
package com.okta.developer.jugtours.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* Spring Security 6 doesn't set an XSRF-TOKEN cookie by default.
* This solution is
* <a href="https://github.com/spring-projects/spring-security/issues/12141#issuecomment-1321345077">
* recommended by Spring Security.</a>
*/
public class CookieCsrfFilter extends OncePerRequestFilter {
/**
* {@inheritDoc}
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
filterChain.doFilter(request, response);
}
}
创建src/main/java/.../jugtours/web/UserController.java
并使用以下代码填充它。 Angular将使用此API到1)找出是否对用户进行身份验证并执行全局注销。
package com.okta.developer.jugtours.web;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
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 java.text.MessageFormat;
import static java.util.Map.of;
@RestController
public class UserController {
private final ClientRegistration registration;
public UserController(ClientRegistrationRepository registrations) {
this.registration = registrations.findByRegistrationId("okta");
}
@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) {
// send logout URL to client so they can initiate logout
var issuerUri = registration.getProviderDetails().getIssuerUri();
var originUrl = request.getHeader(HttpHeaders.ORIGIN);
Object[] params = {issuerUri, registration.getClientId(), originUrl};
// Yes! We @ Auth0 should have an end_session_endpoint in our OIDC metadata.
// It's not included at the time of this writing, but will be coming soon!
var logoutUrl = MessageFormat.format("{0}v2/logout?client_id={1}&returnTo={2}", params);
request.getSession().invalidate();
return ResponseEntity.ok().body(of("logoutUrl", logoutUrl));
}
}
创建组时,您还需要添加用户信息,以便您可以通过水罐游览过滤。与GroupRepository.java
相同的目录中添加UserRepository.java
。
package com.okta.developer.jugtours.model;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, String> {
}
向GroupRepository.java
添加新的findAllByUserId(String id)
方法。
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 jakarta.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 final GroupRepository groupRepository;
private final 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/{id}")
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()
方法/查询很光滑。
更新Angular来处理CSRF并成为身份认识
我喜欢Angular,因为它是一个安全的第一框架。它具有对CSRF的内置支持,并且很容易使其具有身份感知。让我们同时做!
Angular的HttpClient
支持CSRF保护的客户端一半。它将读取Spring Boot发送的Cookie,然后在X-XSRF-TOKEN
标头中返回。您可以在Angular's Security docs上阅读有关此信息的更多信息。
更新Angular应用的身份验证机制
创建一个新的AuthService
类,以与您的Spring Boot API通信以获取身份验证信息。将以下代码添加到app/src/app/auth.service.ts
的新文件。
import { Injectable } from '@angular/core';
import { Location } from '@angular/common';
import { BehaviorSubject, lastValueFrom, Observable } from 'rxjs';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { map } from 'rxjs/operators';
import { User } from './model/user';
const headers = new HttpHeaders().set('Accept', 'application/json');
@Injectable({
providedIn: 'root'
})
export class AuthService {
$authenticationState = new BehaviorSubject<boolean>(false);
constructor(private http: HttpClient, private location: Location) {
}
getUser(): Observable<User> {
return this.http.get<User>('/api/user', {headers}, )
.pipe(map((response: User) => {
if (response !== null) {
this.$authenticationState.next(true);
}
return response;
})
);
}
async isAuthenticated(): Promise<boolean> {
const user = await lastValueFrom(this.getUser());
return user !== null;
}
login(): void {
location.href = `${location.origin}${this.location.prepareExternalUrl('oauth2/authorization/okta')}`;
}
logout(): void {
this.http.post('/api/logout', {}, { withCredentials: true }).subscribe((response: any) => {
location.href = response.logoutUrl;
});
}
}
将引用的User
类添加到app/src/app/model/user.ts
。
export class User {
email!: number;
name!: string;
}
修改home.component.ts
用于使用AuthService
查看用户是否登录。
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { AuthService } from '../auth.service';
import { User } from '../model/user';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, MatButtonModule, RouterLink],
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {
isAuthenticated!: boolean;
user!: User;
constructor(public auth: AuthService) {
}
async ngOnInit() {
this.isAuthenticated = await this.auth.isAuthenticated();
await this.auth.getUser().subscribe(data => this.user = data);
}
}
修改home.component.html
如果未登录用户,请显示登录按钮。否则,显示登录按钮。
<div *ngIf="user; else login">
<h2>Welcome, {{ user.name }}!</h2>
<a mat-button color="primary" routerLink="/groups">Manage JUG Tour</a>
<br/><br/>
<button mat-raised-button color="primary" (click)="auth.logout()" id="logout">Logout</button>
</div>
<ng-template #login>
<p>Please log in to manage your JUG Tour.</p>
<button mat-raised-button color="primary" (click)="auth.login()" id="login">Login</button>
</ng-template>
更新app/src/proxy.conf.js
要为/oauth2
和/login
提供其他代理路径。
const PROXY_CONFIG = [
{
context: ['/api', '/oauth2', '/login'],
...
}
]
在所有这些更改之后,您应该能够重新启动春季靴和角度,并见证了安全计划自己的水罐之旅的荣耀!
配置Maven和带有弹簧靴的角包装
要使用Maven构建和包装您的React应用程序,您可以使用frontend-maven-plugin和Maven的配置文件来激活它。将版本的属性和<profiles>
部分添加到您的pom.xml
。
<properties>
...
<frontend-maven-plugin.version>1.12.1</frontend-maven-plugin.version>
<node.version>v18.16.0</node.version>
<npm.version>9.6.5</npm.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/dist/app</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-npm</goal>
</goals>
<configuration>
<nodeVersion>${node.version}</nodeVersion>
<npmVersion>${npm.version}</npmVersion>
</configuration>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<phase>generate-resources</phase>
</execution>
<execution>
<id>npm test</id>
<goals>
<goal>npm</goal>
</goals>
<phase>test</phase>
<configuration>
<arguments>test -- --watch=false</arguments>
</configuration>
</execution>
<execution>
<id>npm build</id>
<goals>
<goal>npm</goal>
</goals>
<phase>compile</phase>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<properties>
<spring.profiles.active>prod</spring.profiles.active>
</properties>
</profile>
</profiles>
当您使用时,将活动配置文件设置添加到src/main/resources/application.properties
:
spring.profiles.active=@spring.profiles.active@
添加此之后,您应该可以运行mvn spring-boot:run -Pprod
并在http://localhost:8080
上查看您的应用程序。
如果您以根开始,则一切都可以正常工作,因为Angular将处理路由。但是,如果您在http://localhost:8080/groups
处刷新页面,则会遇到404误差,因为Spring Boot没有/groups
的路线。要解决此问题,请添加一个有条件地转发到Angular App的SpaWebFilter
。
package com.okta.developer.jugtours.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class SpaWebFilter extends OncePerRequestFilter {
/**
* Forwards any unmapped paths (except those containing a period) to the client {@code index.html}.
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
if (!path.startsWith("/api") &&
!path.startsWith("/login") &&
!path.startsWith("/oauth2") &&
!path.contains(".") &&
path.matches("/(.*)")) {
request.getRequestDispatcher("/index.html").forward(request, response);
return;
}
filterChain.doFilter(request, response);
}
}
并添加到您的SecurityConfiguration.java
类:
.addFilterAfter(new SpaWebFilter(), BasicAuthenticationFilter.class);
现在,如果您重新启动并重新加载页面,所有内容都将按预期工作。 ðÖ©
验证一切与柏树一起使用
在本节中,您将学习如何将柏树集成到该项目中以支持端到端测试。添加Cypress Angular Schematic:
ng add @cypress/schematic
提示时选择默认更新。然后,更新app/cypress/support/commands.ts
添加login(username, password)
方法:
/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-use-before-define */
// eslint-disable-next-line spaced-comment
/// <reference types="cypress" />
Cypress.Commands.add('login', (username: string, password: string) => {
Cypress.log({
message: [`🔐 Authenticating: ${username}`],
autoEnd: false,
})
cy.origin(Cypress.env('E2E_DOMAIN'), {args: {username, password}},
({username, password}) => {
cy.get('input[name=username]').type(username);
cy.get('input[name=password]').type(`${password}{enter}`, {log: false});
}
);
});
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Cypress.Chainable;
}
}
}
// Convert this to a module instead of script (allows import/export)
export {};
更新app/cypress/support/e2e.ts
要在每个测试之前登录并在后登录。
import './commands';
beforeEach(() => {
if (Cypress.env('E2E_USERNAME') === undefined) {
console.error('E2E_USERNAME is not defined');
alert('E2E_USERNAME is not defined');
return;
}
cy.visit('/')
cy.get('#login').click()
cy.login(
Cypress.env('E2E_USERNAME'),
Cypress.env('E2E_PASSWORD')
)
})
afterEach(() => {
cy.visit('/')
cy.get('#logout').click()
})
添加app/cypress/e2e/home.cy.ts
测试以验证主页加载是否加载。
describe('Home', () => {
beforeEach(() => {
cy.visit('/')
});
it('Visits the initial app page', () => {
cy.contains('JUG Tours')
cy.contains('Logout')
})
})
在同一目录中创建一个groups.cy.ts
,以测试组上的crud。
describe('Groups', () => {
beforeEach(() => {
cy.visit('/groups')
});
it('add button should exist', () => {
cy.get('#add').should('exist');
});
it('should add a new group', () => {
cy.get('#add').click();
cy.get('#name').type('Test Group');
cy.get('#save').click();
cy.get('.alert-success').should('exist');
});
it('should edit a group', () => {
cy.get('a').last().click();
cy.get('#name').should('have.value', 'Test Group');
cy.get('#cancel').click();
});
it('should delete a group', () => {
cy.get('button').last().click();
cy.on('window:confirm', () => true);
cy.get('.alert-success').should('exist');
});
});
将带有凭据的环境变量添加到您之前创建的.okta.env
(或.okta.env.bat
)文件中。
export CYPRESS_E2E_DOMAIN=<your-auth0-domain> # use the raw value, no https prefix
export CYPRESS_E2E_USERNAME=<your-email>
export CYPRESS_E2E_PASSWORD=<your-password>
然后,运行source .okta.env
(或Windows上的.okta.env.bat
)设置这些环境变量并启动应用程序。
mvn spring-boot:run -Pprod
在另一个终端窗口中,用电子运行柏树测试。
source .okta.env
cd app
npx cypress run --browser electron --config baseUrl=http://localhost:8080
修复单元测试
如果您运行npm test
,您会看到几个失败。那是因为组件具有在测试中未导入的依赖项。将home.component.spec.ts
更新到导入HttpClientTestingModule
:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HomeComponent } from './home.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HomeComponent, HttpClientTestingModule],
});
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
在app.component.spec.ts
中,导入MatToolBarModule
并在页面中查找JUG Tours
。
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { MatToolbarModule } from '@angular/material/toolbar';
describe('AppComponent', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [RouterTestingModule, MatToolbarModule],
declarations: [AppComponent]
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'app'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('JUG Tours');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('mat-toolbar > span')?.textContent).toContain('JUG Tours');
});
});
然后,将两个组组件测试更新为导入HttpClientTestingModule
和RouterTestingModule
。
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterTestingModule } from '@angular/router/testing';
describe('...', () => {
...
beforeEach(() => {
TestBed.configureTestingModule({
imports: [..., HttpClientTestingModule, RouterTestingModule]
});
...
});
...
});
现在,npm test
应该通过。
如果您在没有设置环境变量的情况下运行mvn test
,则Java测试也会失败。要解决此问题,请添加一个src/test/java/com/okta/developer/jugtours/TestSecurityConfiguration.java
类以模拟OAuth提供商。
package com.okta.developer.jugtours;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
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.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.Mockito.mock;
/**
* This class allows you to run unit and integration tests without an IdP.
*/
@TestConfiguration
public class TestSecurityConfiguration {
@Bean
ClientRegistration clientRegistration() {
return clientRegistrationBuilder().build();
}
@Bean
ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
return new InMemoryClientRegistrationRepository(clientRegistration);
}
private ClientRegistration.Builder clientRegistrationBuilder() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("end_session_endpoint", "https://example.org/logout");
return ClientRegistration.withRegistrationId("oidc")
.issuerUri("{baseUrl}")
.redirectUri("{baseUrl}/{action}/oauth2/code/{registrationId}")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.scope("read:user")
.authorizationUri("https://example.org/login/oauth/authorize")
.tokenUri("https://example.org/login/oauth/access_token")
.jwkSetUri("https://example.org/oauth/jwk")
.userInfoUri("https://api.example.org/user")
.providerConfigurationMetadata(metadata)
.userNameAttributeName("id")
.clientName("Client Name")
.clientId("client-id")
.clientSecret("client-secret");
}
@Bean
JwtDecoder jwtDecoder() {
return mock(JwtDecoder.class);
}
@Bean
OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) {
return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
}
}
然后,在同一目录中更新JugToursApplicationTests.java
以使用新配置。
@SpringBootTest(classes = {JugtoursApplication.class, TestSecurityConfiguration.class})
再次运行mvn test
,您的测试将通过。 ð
使用GitHub操作来构建和测试您的应用
在.github/workflows/main.yml
上添加github工作流程,以证明您的测试在CI中进行。
name: JUG Tours CI
on: [push, pull_request]
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Java 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 17
cache: 'maven'
- name: Run tests
run: xvfb-run mvn verify -ntp -Pprod
- name: Run e2e tests
uses: cypress-io/github-action@v5
with:
browser: chrome
start: mvn spring-boot:run -Pprod -ntp -f ../pom.xml
install: false
wait-on: http://[::1]:8080
wait-on-timeout: 120
config: baseUrl=http://localhost:8080
working-directory: app
env:
OKTA_OAUTH2_ISSUER: ${{ secrets.OKTA_OAUTH2_ISSUER }}
OKTA_OAUTH2_CLIENT_ID: ${{ secrets.OKTA_OAUTH2_CLIENT_ID }}
OKTA_OAUTH2_CLIENT_SECRET: ${{ secrets.OKTA_OAUTH2_CLIENT_SECRET }}
CYPRESS_E2E_DOMAIN: ${{ secrets.CYPRESS_E2E_DOMAIN }}
CYPRESS_E2E_USERNAME: ${{ secrets.CYPRESS_E2E_USERNAME }}
CYPRESS_E2E_PASSWORD: ${{ secrets.CYPRESS_E2E_PASSWORD }}
- name: Upload screenshots
uses: actions/upload-artifact@v3
if: failure()
with:
name: cypress-screenshots
path: app/cypress/screenshots
您可以在Action on GitHub中看到此工作流程,也可以通过创建新的GitHub repo并将代码推向它来尝试一下。
在设置> 秘密和变量> Action >> 新存储库中。
将更改推向GitHub并观看CI工作流程。
用春季靴和角度建造神话般的东西!
我希望这篇文章能帮助您学习如何构建安全的Angular和Spring Boot应用程序。使用OpenID Connect是一种建议使用此类全堆栈应用程序的建议练习,Auth0使其易于执行。添加CSRF保护和包装您的Spring Boot + Angular应用程序,因为单个伪像也非常酷!
我们写了其他一些有趣的春季靴子,Angular和Jhipster教程。检查它们!
- Build a Simple CRUD App with Spring Boot and Vue.js
- Use React and Spring Boot to Build a Simple CRUD App
- Add OpenID Connect to Angular Apps Quickly
- Full Stack Java with React, Spring Boot, and JHipster
我还写了几本Infoq mini-Books,您可能会发现有用:
- The JHipster Mini-Book:展示了我如何使用Jhipster(Angular,Spring Boot,Bootstrap等)构建21-Points Health。它包括有关带有Spring Boot,React和Auth0的微服务的一章。
- The Angular Mini-Book:Angular,Bootstrap和Spring Boot的实用指南。它使用Kotlin和Gradle,推荐安全实践,并包含几个云部署指南。
如果您有任何疑问,请在下面发表评论。如果您想查看本教程的完整代码,请查看其GitHub repo。在Twitter和YouTube上关注我们以获取更多内容。