构建一个带有弹簧靴和角度的美丽的Crud应用程序
#angular #java #springboot #angularmaterial

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

JUG Tours homepage

单击登录,您将提示您使用Auth0登录。还会要求您同意。这是因为该应用要求访问您的个人资料和电子邮件地址。单击接受继续。

Auth0 consent

经过身份验证后,您将看到一个链接来管理水罐游览。

Manage JUG Tours

您应该能够添加新的组和事件,并编辑并删除它们。

List of JUG Tours

验证柏树端到端测试通过

您可以通过执行项目中包含的柏树测试来验证一切有效。首先,将您的凭据的环境变量添加到您之前创建的.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

Cypress tests running in Chrome

漂亮,你不觉得吗? ðρ

请继续阅读,如果您想查看我如何创建此应用!

使用Spring Boot创建Java REST API

创建新的Spring Boot应用程序的最简单方法是导航到start.spring.io并进行以下选择:

  • 项目: Maven Project
  • 组: com.okta.developer
  • 文物: jugtours
  • 依赖项JPAH2WebValidation22

单击生成项目,下载后展开jugtours.zip,然后在您喜欢的IDE中打开项目。

您也可以使用this linkHTTPie从命令行创建项目:

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/cling 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中看到一个组列表!

JUG Tours list

构建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>&nbsp;
        <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的路线和GroupListComponentapp-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以具有breadcrumbalert类规则:

/* 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,以查看所有外观。单击“管理水罐之旅”,您应该查看默认组的列表。

JUG Tours list

要挤压 Action 右侧的列,请将以下内容添加到group-list.component.css

.mat-column-actions {
  flex: 0 0 120px;
}

您的Angular应用程序应在进行更改时自行更新。

Group list with squished actions column

很高兴在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

Edit a group and add events

要使最高的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中进行一些调整以使工具栏看起来更好。

  1. .toolbar规则中,删除background-colorcolor属性。
  2. #twitter-logo#youtube-logo的利润更改为10px 16px 0 0
  3. .content规则更改为具有65px auto 32pxalign-items: stretch的空白。

现在,该应用程序更多地填充了屏幕,工具栏具有匹配的颜色。

Toolbar colors match

使用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 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'],
    ...
  }
]

在所有这些更改之后,您应该能够重新启动春季靴和角度,并见证了安全计划自己的水罐之旅的荣耀!

Angular app with Login

Angular app with Logout

配置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

Cypress tests running in Electron

修复单元测试

如果您运行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');
  });
});

然后,将两个组组件测试更新为导入HttpClientTestingModuleRouterTestingModule

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 repository secrets

将更改推向GitHub并观看CI工作流程。

用春季靴和角度建造神话般的东西!

我希望这篇文章能帮助您学习如何构建安全的Angular和Spring Boot应用程序。使用OpenID Connect是一种建议使用此类全堆栈应用程序的建议练习,Auth0使其易于执行。添加CSRF保护和包装您的Spring Boot + Angular应用程序,因为单个伪像也非常酷!

我们写了其他一些有趣的春季靴子,Angular和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。在TwitterYouTube上关注我们以获取更多内容。