65.9K
CodeProject 正在变化。 阅读更多。
Home

如何为 Angular 和 Spring Boot 配置 CORS

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2022年10月21日

MIT

16分钟阅读

viewsIcon

30898

downloadIcon

127

本教程将讨论如何为 Spring Boot Web 应用程序配置 CORS。

引言

最近,我重新温习了 Angular Web 应用程序开发,并不得不面对一个我一直回避的问题 - CORS,跨域资源共享。我之前没有遇到这个问题,是因为我参与的项目始终将我的网页相关源代码与后端服务器放在一起。它们在一起。这样我就不必处理 CORS。当我从 AngularJS 切换到 Angular 后,我发现自己处于一个十字路口,要么学习如何处理 CORS,要么回到老路。问题在于。在开发过程中,我的 Angular 代码在一个独立的 Web 服务器上运行。我的后端 API 服务运行在 Spring Boot 托管的 Web 应用程序容器中。因为两者是两个不同的 Web 服务器,所以我必须在我的 API 服务中正确配置 CORS,以便至少在开发期间,这样的设置才能正常工作。

为什么这是一个大问题?想象一下,我的网页托管在 URL:https://:4200/XXXXX。我的后端服务 API 有一个不同的 URL:https://:8080/YYYYY。即使它们使用相同的域名“localhost”,并且使用相同的协议“http”,端口号的不同也会使它们成为两个不同的域,而不是同一个域。CORS 检查的目的是确保只有受信任的站点才能相互通信。这也是一个我们开发者必须记住为我们的项目配置/实现的要求,如果我们希望以某种方式完成某事。

为 Spring Boot 基础 Web 应用程序配置 CORS 非常容易。但我并不知道自己在做什么。它花了我一点额外的时间才让它工作起来。我遇到的特定问题是,CORS 配置在请求未进行身份验证时有效。当后端检查身份验证和授权时,请求会失败。如果你之前处理过这个问题,你可能知道是什么原因。对我来说,当时我没有这方面的知识,所以花了我一些时间才弄清楚。我希望本教程能为面临类似问题的人提供一些有用的信息。

架构概述

我提供的示例应用程序有两个部分。第一部分是后端服务 API 应用程序,使用 Spring Boot 编写。这是需要 CORS 配置的部分。另一部分是前端 Web 应用程序,使用最新的 Angular 框架编写。前端 Web 应用程序有三个基本功能。第一个是用户必须登录。接下来是登录后,用户将看到一个数据列表。这通过 API 调用后端完成,检索数据并在页面上显示。最后一个是登出功能。所有这三个都需要 CORS 检查才能使两端进行通信。

我用 Angular 编写了前端,并且很难使用普通的基于会话的安全机制。我决定以我其他教程为基础。登录安全将由一个模拟的 JWT 令牌控制。在服务器端,我有一个 OncePerRequestFilter,它将检查 JWT 令牌,并对请求进行身份验证/授权。这意味着登录后,用户的所有请求都必须添加 JWT 令牌,否则请求将被拒绝。 这是我之前创建的教程,是此示例应用程序的基础。如果您需要更多关于理解后端 Web 应用程序的帮助,请查看之前的教程。

本教程分为两部分。第一部分将详细讨论前端设计,以及它如何与后端通信。教程的第二部分将讨论服务 API 端点的 CORS 配置。我将详细解释浏览器如何处理 CORS 配置,我遇到的问题以及解决方案的工作原理。

使用 Angular 进行客户端代码设计

客户端是一个简单的应用程序。它有一个登录页面,一个显示一些数据的索引页面,以及一个将用户踢回登录页面的登出功能。由于我决定使用 Angular 而不是普通的 AngularJS,所有这些客户端代码都托管在其自己的小程序项目中。并且它是自托管的。让我从登录页面开始。

登录页面

我将登录页面的功能分解为三个不同的相关组件:HTML 页面标记、登录组件的代码逻辑以及所需服务的代码逻辑。首先,我将展示这三个中最简单的,HTML 标记。

<div class="row login-form justify-content-center">
   <div class="col-xs-12 col-sm-8 col-md-6 col-lg-4">
      <div class="panel-box">
         <form novalidate #loginForm="ngForm" >
            <div class="mb-3">
               <label for="login_userName" class="form-label">User Name</label>
               <input type="text" 
                      class="form-control"
                      id="login_UserName"
                      name="userName"
                      [(ngModel)]="userName"
                      [ngClass]="{'invalid-input': (usrName.dirty || 
                      usrName.touched) && usrName.errors?.['required']}"
                      required
                      #usrName="ngModel" />
               <span class="badge bg-danger"
                     *ngIf="(usrName.dirty || usrName.touched) && 
                     usrName.errors?.['required']">Required</span>
            </div>
            <div class="mb-3">
               <label for="login_userPass" class="form-label">Password</label>
               <input type="password"
                      class="form-control"
                      id="login_userPass"
                      name="userPass"
                      [(ngModel)]="userPass"
                      [ngClass]="{'invalid-input': (usrPass.dirty || 
                      usrPass.touched) && usrPass.errors?.['required']}"
                      required
                      #usrPass="ngModel" />
               <span class="badge bg-danger"
                     *ngIf="(usrPass.dirty || usrPass.touched) && 
                     usrPass.errors?.['required']">Required</span>
            </div>
            <div class="row">
               <div class="col">
		            <button type="submit"
		                    class="btn btn-primary form-control"
		                    (click)="onClickLogin(loginForm)">Login</button>
               </div>
               <div class="col">
                  <button type="clear"
                          class="btn btn-default form-control"
                          (click)="onClickClear(loginForm)">Clear</button>
               </div>
            </div>
         </form>
      </div>
   </div>
</div>

除了 Angular 的基本内容之外,我还为 HTML 表单添加了一些输入验证,这本身就值得一篇教程。所以我不会在这里浪费时间解释它们。您只需要知道的是,此页面有两个输入字段,第一个允许用户输入用户名。第二个输入字段用于输入密码。然后有两个按钮,一个用于登录安全页面。另一个用于清除输入字段。

接下来,将是登录组件的 Angular 代码。

import { Component, OnInit } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';

import { LoginUser } from '../dataModels/loginUser.type';
import { LoginService } from './login.service';
import { FormsService } from '../common/forms.service';

@Component({
  selector: 'app-root',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
   private _userName: String = "";
   private _userPass: String = "";
   private _loginService: LoginService;
   private _formsService: FormsService;
    
   private _router: Router;
   
   constructor(loginService: LoginService, formsService: FormsService, router: Router) {
      this._loginService = loginService;
      this._formsService = formsService;
      this._router = router;
   }
   
   ngOnInit() {
      let userLoggedIn: Boolean
         = this._loginService.checkUserLoggedIn();
      if (userLoggedIn) {
         this._router.navigate(['/index']);
      }
   }

   public get userName() {
	   return this._userName;
   }
   
   public set userName(val: String) {
	   this._userName = val;
   }

   public get userPass() {
	   return this._userPass;
   }
   
   public set userPass(val: String) {
	   this._userPass = val;
   }

   public onClickClear(loginForm: any): void{
      this._userName = "";
      this._userPass = "";
      
      this._formsService.makeFormFieldsClean(loginForm);
   }
   
   public onClickLogin(loginForm: any): void{
      this._formsService.makeFormFieldsDirty(loginForm);
      
      if (loginForm.valid) {
         let userToLogin: LoginUser = new LoginUser(this._userName, this._userPass);
         let self:any = this;

         self._loginService.login(userToLogin)
             .subscribe((resp: any) => {
                if (resp != null &&
                    resp.userId != null &&
                    resp.userId.trim() !== "" &&
                    resp.tokenValue != null &&
      	           resp.tokenValue.trim() !== "") {
                                          
                   self._loginService.setSessionCurrentUser(resp);
                   self._router.navigate(['/index']);
                }
             }, (error: HttpErrorResponse) => {
                if (error != null) {
                   if (error.status === 0) {
                      console.log("Client error.");                     
                   } else if (error.status === 401 || error.status === 403) {
                      self._userName = "";
                      self._userPass = "";
                      self._formsService.makeFormFieldsClean(loginForm);
                      console.log("You are not authorized.");
                   } else if (error.status === 500) {
                      console.log("Server error occurred.");
                   } else {
                      console.log("Unknown error: " + error.status);
                   }
                }
             });
	   }
   }
}

同样,我不会解释此组件的表单验证部分,以免破坏乐趣。此组件源文件最重要的部分是这个。

let userToLogin: LoginUser = new LoginUser(this._userName, this._userPass);
let self:any = this;

self._loginService.login(userToLogin)
    .subscribe((resp: any) => {
      if (resp != null &&
          resp.userId != null &&
          resp.userId.trim() !== "" &&
          resp.tokenValue != null &&
          resp.tokenValue.trim() !== "") {
             self._loginService.setSessionCurrentUser(resp);
             self._router.navigate(['/index']);
      }
    }, (error: HttpErrorResponse) => {
      if (error != null) {
         if (error.status === 0) {
            console.log("Client error.");                     
         } else if (error.status === 401 || error.status === 403) {
            self._userName = "";
            self._userPass = "";
            self._formsService.makeFormFieldsClean(loginForm);
            console.log("You are not authorized.");
         } else if (error.status === 500) {
            console.log("Server error occurred.");
         } else {
            console.log("Unknown error: " + error.status);
         }
      }
});

提取的代码使用服务对象调用后端进行用户身份验证。如果身份验证成功,则需要保存简单的 JWT 令牌供将来使用。我将其保存在浏览器会话存储中。然后浏览器将导航回索引页面。

这是调用后端服务 API 进行身份验证的服务类。

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';

import { LoginUser } from '../dataModels/loginUser.type';
import { environment } from '../../environments/environment';

@Injectable({
   providedIn: 'root',
})
export class LoginService {
   constructor(private http: HttpClient) {
	
   }
   
   public login(userToLogin: LoginUser): Observable<any> {
      return this.http.post<any>(environment.apiBaseUrl + "authenticate", userToLogin);
   }
   
   public signout(): Observable<any> {
      let jwtToken: String = this.getUserSecurityToken(),
          headers: HttpHeaders = new HttpHeaders({
             "authorization": "bearer " + jwtToken,
          }),
          options = { headers: headers };
      return this.http.post<any>(environment.apiBaseUrl + "signOut", null, options);
   }
   
   public setSessionCurrentUser(userToAdd: any): void {
      if (userToAdd != null &&
          userToAdd.userId &&
          userToAdd.userId.trim() !== "" &&
          userToAdd.tokenValue &&
          userToAdd.tokenValue.trim() !== "") {
         if (sessionStorage.getItem("currentUser") != null) {
            sessionStorage.removeItem("currentUser");
         }
      
         sessionStorage.setItem("currentUser", JSON.stringify(userToAdd));
      }
   }
   
   public removeSessionCurrentUser(): void {
      if (sessionStorage.getItem("currentUser") != null) {
         sessionStorage.removeItem("currentUser");
      }
   }
   
   public checkUserLoggedIn(): Boolean {
      let retVal: Boolean = false,
          currUserObj: any = null,
          currUser: any;
      if (sessionStorage.getItem("currentUser") != null) {
         currUserObj = sessionStorage.getItem("currentUser");
         if (currUserObj != null && currUserObj.toString() 
         != null && currUserObj.toString().trim() !== "") {
            currUser = JSON.parse(currUserObj.toString());
            if (currUser &&
                currUser.userId &&
                currUser.userId.trim() !== "") {
               retVal = currUser.tokenValue != null && currUser.tokenValue.trim() !== "";
            }
         }
      }
        
      return retVal;
   }
   
   public getLoggedinUser(): any | null {
      let retVal: any | null = null,
          currUser: any | null = null,
          currUserObj: any = null;
          
      if (sessionStorage.getItem("currentUser") != null) {
         currUserObj = sessionStorage.getItem("currentUser");
         if (currUserObj != null && currUserObj.toString() 
         != null && currUserObj.toString().trim() !== "") {
            currUser = JSON.parse(currUserObj.toString());
            if (currUser &&
                currUser.userId &&
                currUser.userId.trim() !== "") {
               retVal = currUser;
            }
         }
      }

      return retVal;
   }

   public getUserSecurityToken(): String
   {
      let retVal: String = "",
          currUser: any | null = null,
          currUserObj: any = null;
          
      if (sessionStorage.getItem("currentUser") != null) {
         currUserObj = sessionStorage.getItem("currentUser");
         if (currUserObj != null && currUserObj.toString() 
         != null && currUserObj.toString().trim() !== "") {
            currUser = JSON.parse(currUserObj.toString());
            if (currUser &&
                currUser.userId &&
                currUser.userId.trim() !== "") {
               retVal = currUser.tokenValue;
            }
         }
      }

      return retVal;
   }
}

这个服务类有很多内容。有两个方法调用后端 API。还有很多方法用于将安全令牌保存到浏览器会话存储、从浏览器会话存储中取出安全令牌、检查安全令牌以及提取用于服务调用的安全令牌。

您不必担心那些管理安全令牌的方法。调用后端的方法是 login()signout()

...
   public login(userToLogin: LoginUser): Observable<any> {
      return this.http.post<any>(environment.apiBaseUrl + "authenticate", userToLogin);
   }
   
   public signout(): Observable<any> {
      let jwtToken: String = this.getUserSecurityToken(),
          headers: HttpHeaders = new HttpHeaders({
             "authorization": "bearer " + jwtToken,
          }),
          options = { headers: headers };
      return this.http.post<any>(environment.apiBaseUrl + "signOut", null, options);
   }
...

这两种方法都使用 HTTP POST 与后端进行交互。主要区别在于 signout() 方法,我必须添加我的模拟 JWT 令牌。登出功能是安全的。我必须有安全令牌才能访问此类功能。login() 方法没有这样的限制。所以对于那个方法,我不必添加安全令牌。

两者的区别在于我如何遇到问题并开始冒险。现在,您不必担心这一点。让我继续介绍索引页面和索引页面的数据加载。

索引页面

与登录页面不同,索引页面受到保护,只有用户成功登录后才能访问。登录后,索引页面将通过调用后端 API 服务来加载列表数据。这是该页面的组件源代码。

import { PageSecurityService } from '../common/pageSecurity.service';
import { GameTitlesService } from './gameTitles.service'
import { LoginService } from '../loginPage/login.service';
import { GameTitle } from '../dataModels/gameTitle.type';

@Component({
  selector: 'app-root',
  templateUrl: './index.component.html',
  styleUrls: ['./index.component.css']
})
export class IndexComponent implements OnInit {
   private _loginService: LoginService;
   private _pageSecurityService: PageSecurityService;
   private _gameTitlesService: GameTitlesService;
   private _allTitles: Array<GameTitle> = [];
   public testArray: Array<String> = [];
   
   constructor(loginService: LoginService,
               pageSecurityService: PageSecurityService,
               gameTitlesService: GameTitlesService) {
      this._loginService = loginService;
      this._pageSecurityService = pageSecurityService;
      this._gameTitlesService = gameTitlesService;
      
      this.testArray.push("Test1");
      this.testArray.push("Test2");
      this.testArray.push("Test3");
      this.testArray.push("Test4");
      this.testArray.push("Test5");
   }
   
   public get allTitles(): Array<GameTitle> {
      return this._allTitles;
   }
   
   public set allTitles(val: Array<GameTitle>) {
      this._allTitles = val;
   }
   
   ngOnInit(): void {
      let userLoggedIn: Boolean
         = this._loginService.checkUserLoggedIn();
      if (!userLoggedIn) {
         this._pageSecurityService.gotoLoginPage();
      } else {
         this.loadAllGameTitles();
      }
   }
   
   public onClickLogout(): void {
      let self = this;
      self._loginService.signout()
         .subscribe((resp: any) => {
            if (resp != null) {
               if (resp.successful) {
                  alert("Signed out successfully"); /// XXX
                  self._loginService.removeSessionCurrentUser();
                  self._pageSecurityService.gotoLoginPage();
               } else {
                  alert("Signed out failed with error. " + resp.detailedMessage); /// XXX
               }        
            } else {
               alert("Signed out failed with error. Unknown error."); /// XXX
            }
         }, (error: HttpErrorResponse) => {
            if (error != null) {
               if (error.status === 0) {
                  // XXX
                  console.log("Client error.");                     
               } else if (error.status === 401 || error.status === 403) {
                  // XXX
                  alert("You are not authorized.");
                  console.log("You are not authorized.");
                  self._loginService.removeSessionCurrentUser();
                  self._pageSecurityService.gotoLoginPage();
               } else if (error.status === 500) {
                console.log("Server error occurred.");
             } else {
                console.log("Unknown error: " + error.status);
             }
          }
       });
   }
   
   private loadAllGameTitles(): void {
      let self = this;
      self._gameTitlesService.getAllGameTitles()
         .subscribe((resp: any) => {
            if (resp && resp.length > 0) {
               for (var itm of resp) {
                  if (itm) {
                     let titleToAdd: GameTitle = new GameTitle
                     (itm.gameTitle, itm.publisher, 
                      itm.devStudioName, itm.publishingYear, itm.retailPrice);
                     self._allTitles.push(titleToAdd);
                  }
               }
            }
         }, (error: HttpErrorResponse) => {
            if (error != null) {
               if (error.status === 0) {
                  // XXX
                  console.log("Client error.");                     
               } else if (error.status === 401 || error.status === 403) {
                  // XXX
                  alert("You are not authorized.");
                  console.log("You are not authorized.");
                  self._loginService.removeSessionCurrentUser();
                  self._pageSecurityService.gotoLoginPage();
               } else if (error.status === 500) {
                  alert("Server error.");
                  console.log("Server error occurred.");
               } else {
                  alert("Unknown error.");
                  console.log("Unknown error: " + error.status);
               }
            }
         });
   }
}

关于这个名为 IndexComponent 的类,有几点我喜欢讨论。该类继承自 OnInit 接口。这允许我覆盖名为 ngOnInit() 的方法。调用此方法以执行任何初始化。这是我检查用户是否已登录的方法。这是通过检查安全令牌是否存在于会话存储中来完成的。如果不存在,它将(通过 Angular 路由)重定向到登录页面。但如果用户已登录,则会尝试加载游戏列表并在表中显示。这是它。

...
   ngOnInit(): void {
      let userLoggedIn: Boolean
         = this._loginService.checkUserLoggedIn();
      if (!userLoggedIn) {
         this._pageSecurityService.gotoLoginPage();
      } else {
         this.loadAllGameTitles();
      }
   }
...

这是从后端 API 服务加载游戏的方法。

...
   private loadAllGameTitles(): void {
      let self = this;
      self._gameTitlesService.getAllGameTitles()
         .subscribe((resp: any) => {
            if (resp && resp.length > 0) {
               for (var itm of resp) {
                  if (itm) {
                     let titleToAdd: GameTitle = new GameTitle
                     (itm.gameTitle, itm.publisher, 
                      itm.devStudioName, itm.publishingYear, itm.retailPrice);
                     self._allTitles.push(titleToAdd);
                  }
               }
            }
         }, (error: HttpErrorResponse) => {
            if (error != null) {
               if (error.status === 0) {
                  // XXX
                  console.log("Client error.");                     
               } else if (error.status === 401 || error.status === 403) {
                  // XXX
                  alert("You are not authorized.");
                  console.log("You are not authorized.");
                  self._loginService.removeSessionCurrentUser();
                  self._pageSecurityService.gotoLoginPage();
               } else if (error.status === 500) {
                  alert("Server error.");
                  console.log("Server error occurred.");
               } else {
                  alert("Unknown error.");
                  console.log("Unknown error: " + error.status);
               }
            }
         });
   }
...

以上所有代码所做的就是调用名为 _gameTitlesService 的服务对象,并调用名为 getAllGameTitles() 的方法。一旦方法调用成功完成,游戏标题列表就会转换为数据模型 GameTitle,然后在页面上显示。这些 API 调用都很简单明了,看起来和我过去几年在其他教程中所做的很多调用一样。但是,在这种情况下,情况有所不同。原因是 API 调用来自托管在 localhost 上的页面,并且端口不同。

现在我们已经看到了所有的 API 调用,登录身份验证,安全方进行的登出 API 调用,以及加载索引页面的安全调用。在下一节中,我将解释我发现的问题以及如何解决它。我将讨论我的失误以及让我得以救赎的辛勤工作,以及我从中获得的乐趣,促成了这篇教程。

配置 CORS 处理

我曾提到,尽管客户端 API 调用与我过去所做的相同,但这次情况有所不同。客户端和后端在两个不同的服务器上分离,因此必须配置 CORS 才能继续我的开发工作。我还想解释,我曾计划将转换后的脚本复制到后端服务器项目中,以便前端和后端 API 集成在一起。这将消除对 CORS 配置的需求。然而,在开发过程中,前端和后端是分开的,因此需要 CORS 配置。

在基于 Spring Boot 的 Web 应用程序中有两种不同的方法可以配置 CORS。第一种是全局配置,这样配置将应用于所有 API 方法。另一种方法是用 @CORS(...) 注释 API 方法,以单独指定 API 方法的 CORS 配置。我选择全局配置 CORS 的方法。这非常简单。我只需要一个提供配置的 bean。这是代码。

   @Bean
   public WebMvcConfigurer corsConfigurer()
   {
      String[] allowDomains = new String[2];
      allowDomains[0] = "https://:4200";
      allowDomains[1] = "https://:8080";
      
      System.out.println("CORS configuration....");
      return new WebMvcConfigurer() {
         @Override
         public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/**").allowedOrigins(allowDomains);
         }
      };
   }

此方法返回一个 WebMvcConfigurer 类型的 bean。在此方法中,我只是定义了一个匿名类,它是 WebMvcConfigurer 的子类型。并返回它的一个对象。匿名类只有一个方法,用于定义 CORS 映射。它指定所有 API 都可以从 URL 数组调用。我在方法开头定义了数组。其中有两个,一个是它自身:https://:8080;另一个是来自:https://:4200(nodejs 服务器)。抱歉硬编码了这些。我应该把所有这些 URL 放在一个配置文件中然后从那里读取。硬编码只是为了演示。

通过这种方法,我的设置似乎奏效了。我能够让身份验证工作。这是我用于身份验证用户的 API 方法。

   @RequestMapping(value="/authenticate", method = RequestMethod.POST,
                   consumes=MediaType.APPLICATION_JSON_VALUE, 
                   produces=MediaType.APPLICATION_JSON_VALUE)
   public ResponseEntity<AuthUserInfo> login(
         @RequestBody
         LoginRequest loginReq
   )
   {
      System.out.println("User Name: " + loginReq.getUserName());
      System.out.println("User Pass: " + loginReq.getUserPass());
      
      if (StringUtils.hasText(loginReq.getUserName()) && 
          StringUtils.hasText(loginReq.getUserPass()))
      {
         AuthUserInfo userFound = _authService.authenticateUser
                      (loginReq.getUserName(), loginReq.getUserPass());
         if (userFound != null)
         {
            return ResponseEntity.ok(userFound);
         }
         else
         {
            return ResponseEntity.status(403).body((AuthUserInfo)null);
         }
      }
      else
      {
         return ResponseEntity.status(403).body((AuthUserInfo)null);
      }
   }

前端代码能够将身份验证请求发送到上面显示的 API 方法。只要用户名和密码匹配,用户就可以被身份验证和授权。将返回一个 AuthUserInfo 类型的返回值给前端。

接下来,我尝试让登出功能正常工作。这时我遇到了问题。每次前端尝试附加安全令牌并调用该功能时,都会收到 CORS 错误。我不明白的是,登录功能按预期工作。但是受保护的 API 方法却不行。我再次花了很长时间寻找答案。然后我找不到任何相关的内容。真正的问题是我不知道应该问什么问题。我改变了策略。我问自己,我真的知道 CORS 在浏览器中是如何工作的吗?答案是否定的。我问的下一个问题是“CORS 是如何工作的?”答案引导我找到了我所面临问题的答案。

浏览器处理跨域请求的方式,即请求从服务器 https://:4200 到 https://:8080。浏览器可以检测到这种跨域请求。它会首先向后端服务器发送一个 HTTP OPTIONS 请求。返回的响应将告诉浏览器实际的跨域请求是否有效。这由浏览器静默且匿名地完成,我甚至在开发模式下也看不到 HTTP OPTIONS 请求。难怪 CORS 错误如此难以处理。由于它是匿名发送的,因此只能由未受保护的 API 方法处理请求。登出 API 方法看起来是这样的。

   @PreAuthorize("isAuthenticated()")
   @RequestMapping(value="/signOut", method = RequestMethod.POST)
   public ResponseEntity<OpResponse> signOut()
   {
      ResponseEntity<OpResponse> retVal = null;
      OpResponse resp = new OpResponse();
      
      AuthUserInfo currUser = getCurrentUser();
      if (currUser != null)
      {
         String  userId = currUser.getUserId();
         
         boolean signoutSuccess = _authService.userSignOut(userId);
         if (signoutSuccess)
         {
            resp.setSuccessful(true);
            resp.setStatus("Log out successful");
            resp.setDetailMessage("You have successfully log out from this site.");
            retVal = new ResponseEntity<OpResponse>(resp, HttpStatus.OK);
         }
         else
         {
            resp.setSuccessful(false);
            resp.setStatus("Operation Failed");
            resp.setDetailMessage("Unable to sin out user. Unknown error.");
            retVal = new ResponseEntity<OpResponse>
                     (resp, HttpStatus.INTERNAL_SERVER_ERROR);
         }
      }
      else
      {
         resp.setSuccessful(false);
         resp.setStatus("Operation Failed");
         resp.setDetailMessage("You cannot log out if you are not log in first.");
         retVal = new ResponseEntity<OpResponse>(resp, HttpStatus.UNAUTHORIZED);
      }
      
      System.out.println("sign out called!");
      return retVal;
   }

一旦我意识到浏览器正在向后端服务器静默发送 HTTP OPTIONS 请求,并且是匿名发送的。上述方法将永远无法处理此类请求。实际的跨域请求也会失败。我应该问的问题是如何配置请求的安全,以便这些 HTTP OPTIONS 请求可以匿名处理。结果 Baeldung 有一篇教程解释了如何做到这一点。好消息是我不必阅读整篇教程就能找到答案。它就在教程的开头。无论如何,我寻找的答案是忽略所有来自安全 API 方法的 CORS 测试请求(那些看不见的 HTTP OPTIONS 请求)。这是我的安全请求处理配置(额外更改以粗体显示)。

   @Bean
   public SecurityFilterChain filterChain(HttpSecurity http) throws Exception
   {
      System.out.println("Security filter chain initialization...");
      http.cors().and()
          .csrf().disable()
          .authorizeRequests()
	       .antMatchers("/assets/**", "/public/**", 
                        "/authenticate", "/app/**").permitAll()
	       .anyRequest().authenticated().and()
	       .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
	       .accessDeniedHandler(accessDeniedHandler).and().sessionManagement()
	       .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
      
      http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
      
      return http.build();
   }

正如您所见,解决我的问题非常简单。我所要做的就是将代码 .cors().and() 添加到 HttpSecurity 对象的方法调用中。这告诉 Spring Security 任何与 CORS 测试请求(那些 HTTP OPTIONS 请求)相关的内容都应由后端服务器处理,而无需进行任何安全检查。也就是说,检查请求是否具有授权的请求过滤器将不会对这些请求进行。因此,CORS 测试请求将由 Spring Web 通过我所做的 CORS 配置自动处理,然后实际请求将成功处理(只要请求中包含授权数据并且有效)。看,只要我理解了症状和根本原因,就很简单。

如何运行示例应用程序

现在所有秘密都揭开了,是时候让我们看看它的实际运行情况了。这个项目比我以前的教程要复杂一些。与它们不同,这个项目有一个 Spring Boot Web 应用程序和一个基于 nodejs 的客户端应用程序。两者都必须构建和运行。

下载示例项目 zip 文件后,请将其解压缩到您方便的位置。解压缩后,找到包含 pom.xml 的文件夹。那将是项目的根文件夹。请搜索子文件夹 webapp/secure-app,里面有一个 .sj 文件。请将其重命名为 .js,以便 nodejs 构建可以顺利进行。

要构建 Spring Boot Web 应用程序,请使用终端 cd 到项目根文件夹,然后运行以下命令。

mvn clean install

成功构建 Java 项目后,您可以使用以下命令运行 Web 应用程序。

java -jar target/hanbo-angular-secure-app1-1.0.0.jar

对于 nodejs 项目,它需要更多工作。首先,您需要安装所有 node 模块。转到 nodejs 项目文件夹。它应该是项目根文件夹下的子文件夹 webapp/secure-app。cd 到该文件夹。然后运行以下命令。

npm install

请注意,我使用的是 nodejs 版本 v18.11.0。您可能可以使用 16 或更高版本,并且项目仍然可以构建。我没有尝试过,所以我不知道较低版本是否有效。

接下来,您可以使用此命令构建整个客户端应用程序。

ng build

我建议您不要构建此项,因为它将生成最终脚本并集成到 Java 项目中。这还不是我们想要做的。下一个命令应该是您应该使用的。

ng serve

一旦 Java 应用程序和 nodejs 客户端应用程序都运行起来,就可以运行 Web 应用程序了,使用您的浏览器导航到以下 URL。

https://:4200/

如果一切正常,登录页面将显示:

使用凭据“testuser1”(用户名)/“123test321”(密码)。登录后,用户应看到以下页面。

右上角有登出链接。单击它将使用户登出。如果您注释掉所有的 CORS 配置代码,并重新运行应用程序,您将看到与 CORS 相关的错误。所有的 CORS 配置代码都在文件 WebAppSecurityConfig.java 中。如果您注释掉所有这些代码,登录功能将无法正常工作。如果您只注释掉代码 .cors.and(),您仍然可以登录,但索引页面将无法加载汽车模型并显示列表。并且您也无法登出。您可以在浏览器的开发者控制台中看到错误。

如果我注释掉 CORS 配置,这是 Chrome 浏览器开发者控制台中显示 CORS 错误的截图。

摘要

写这篇教程很有趣。在这篇教程中,我讨论了 CORS 的工作原理以及如何将 CORS 配置集成到 Spring Boot 应用程序中。如所示,它们都很容易。一个是全局设置,适用于所有 API 方法,另一个是安全配置。即使它很简单,如果我不知道我在做什么,它仍然会很难。我很高兴我能够深入研究问题并完成这篇教程。

除了 CORS 配置,我还广泛地展示了 Angular 应用程序的工作方式。有大量代码说明了如何在 Angular 客户端应用程序中调用后端 API 服务;它如何将 JWT 令牌放入请求的头部以便能够通过后端 API 服务。关于源代码,还有很多我没有讨论的内容,如果您仔细查看,可能会发现它们很有用。

一如既往,我发现写这篇教程很有趣。更好的是,这个过程帮助我解决了一个问题。我希望这篇教程能为您提供一些帮助,如果您面临类似问题。祝您好运!

供参考

Han 目前正在寻找激动人心且具有挑战性的工作。Han 拥有 19 年的软件工程经验。Han 曾作为开发人员、QA 和开发负责人成功发布了许多软件产品。如果您想为您的下一个伟大产品项目团队添加一位 CodeProject MVA(2022),请通过“sunhanbo (at) duck.com”联系 Han。感谢您的关注。

历史

  • 2022年10月20日 - 初稿
© . All rights reserved.