1. 概述
在本教程中,我們將使用 OAuth2 對 REST API 進行安全保護,並從一個簡單的 Angular 客户端進行消費。
我們將構建的應用程序將由三個獨立的模塊組成:
- 授權服務器
- 資源服務器
- UI 授權碼:使用授權碼流程的客户端應用程序
我們將使用 Spring Security 5 中的 OAuth 棧。 如果您想使用 Spring Security OAuth 遺留棧,請查看這篇以前的文章:Spring REST API + OAuth2 + Angular (使用 Spring Security OAuth 遺留棧)。
現在,讓我們開始吧。
2. OAuth2 授權服務器 (AS)
簡單來説,授權服務器是一個頒發授權令牌的應用程序。在之前的版本中,Spring Security OAuth 棧提供了將授權服務器設置為 Spring Application 的可能性。但由於 OAuth 是一個開放標準,並且擁有眾多成熟的提供商(例如 Okta、Keycloak 和 ForgeRock 等),因此該項目已被棄用。
其中,我們將使用 Keycloak。它是由 Red Hat 維護的開源身份和訪問管理服務器,使用 Java 開發,由 JBoss 開發。它不僅支持 OAuth2,還支持 OpenID Connect 和 SAML 等其他標準協議。
對於本教程,我們將在一個 Spring Boot 應用中設置一個嵌入式 Keycloak 服務器。
3. 資源服務器 (RS)
現在我們來討論資源服務器;這本質上是 REST API,是我們最終想要能夠消費的。
3.1. Maven 配置
我們的資源服務器的 pom 文件與之前的授權服務器 pom 文件非常相似,除了缺少 Keycloak 相關部分,並且包含一個額外的 <a href="https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-oauth2-resource-server"><em>spring-boot-starter-oauth2-resource-server</em></a> 依賴項:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>3.2. 安全配置
由於我們使用了 Spring Boot,我們可以使用 Boot 屬性定義最小所需配置。
我們將這在 application.yml 文件中完成:
server:
port: 8081
servlet:
context-path: /resource-server
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8083/auth/realms/baeldung
jwk-set-uri: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/certs在這裏,我們指定將使用 JWT 令牌進行授權。
jwk-set-uri 屬性指向包含公鑰的 URI,以便我們的資源服務器可以驗證令牌的完整性。
issuer-uri 屬性代表一項額外的安全措施,用於驗證令牌的頒發者(即授權服務器)。但是,添加此屬性也要求授權服務器在啓動資源服務器應用程序之前必須運行。
接下來,讓我們為 API 設置一個 安全配置以保護 API 端點:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors()
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
.hasAuthority("SCOPE_read")
.antMatchers(HttpMethod.POST, "/api/foos")
.hasAuthority("SCOPE_write")
.anyRequest()
.authenticated()
.and()
.oauth2ResourceServer()
.jwt();
return http.build();
}
}如我們所見,對於我們的 GET 方法,我們僅允許具有 讀取 權限的請求。對於 POST 方法,請求者需要除了 讀取 權限外,還需要擁有 寫入 權限。但是,對於任何其他端點,請求只需要進行任何用户的身份驗證。
此外,oauth2ResourceServer() 方法指定了這是一個資源服務器,並使用 jwt()- 格式的令牌。
另一個需要注意的點是使用方法 cors() 以允許請求上的 Access-Control 標頭。這在與 Angular 客户端一起處理時尤其重要,因為我們的請求將來自另一個源 URL。
3.4. 模型與倉庫
接下來,我們為我們的模型定義一個 javax.persistence.Entity,名為 Foo:
@Entity
public class Foo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// constructor, getters and setters
}然後我們需要一個 Foo 存儲庫。 我們將使用 Spring 的 PagingAndSortingRepository:
public interface IFooRepository extends PagingAndSortingRepository<Foo, Long> {
}
3.4. 服務與實現
隨後,我們將為我們的 API 定義並實現一個簡單的服務:
public interface IFooService {
Optional<Foo> findById(Long id);
Foo save(Foo foo);
Iterable<Foo> findAll();
}
@Service
public class FooServiceImpl implements IFooService {
private IFooRepository fooRepository;
public FooServiceImpl(IFooRepository fooRepository) {
this.fooRepository = fooRepository;
}
@Override
public Optional<Foo> findById(Long id) {
return fooRepository.findById(id);
}
@Override
public Foo save(Foo foo) {
return fooRepository.save(foo);
}
@Override
public Iterable<Foo> findAll() {
return fooRepository.findAll();
}
}
3.5. 示例控制器
現在,讓我們實現一個簡單的控制器,通過 DTO 暴露我們的 Foo 資源:
@RestController
@RequestMapping(value = "/api/foos")
public class FooController {
private IFooService fooService;
public FooController(IFooService fooService) {
this.fooService = fooService;
}
@CrossOrigin(origins = "http://localhost:8089")
@GetMapping(value = "/{id}")
public FooDto findOne(@PathVariable Long id) {
Foo entity = fooService.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return convertToDto(entity);
}
@GetMapping
public Collection<FooDto> findAll() {
Iterable<Foo> foos = this.fooService.findAll();
List<FooDto> fooDtos = new ArrayList<>();
foos.forEach(p -> fooDtos.add(convertToDto(p)));
return fooDtos;
}
protected FooDto convertToDto(Foo entity) {
FooDto dto = new FooDto(entity.getId(), entity.getName());
return dto;
}
}請注意使用上述的 @CrossOrigin;這是我們需要的控制器級別的配置,用於允許從我們的 Angular App 從指定 URL 處進行 CORS。
以下是我們的 FooDto:
public class FooDto {
private long id;
private String name;
}4. 前端 — 設置
我們現在將探討一個簡單的 Angular 前端實現,用於客户端訪問我們的 REST API。
我們將首先使用 Angular CLI 生成和管理我們的前端模塊。
首先,我們需要安裝 Node.js 和 npm,因為 Angular CLI 是一個基於 npm 的工具。
然後,我們需要使用 frontend-maven-plugin 使用 Maven 構建我們的 Angular 項目:
<build>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.3</version>
<configuration>
<nodeVersion>v6.10.2</nodeVersion>
<npmVersion>3.10.10</npmVersion>
<workingDirectory>src/main/resources</workingDirectory>
</configuration>
<executions>
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
</execution>
<execution>
<id>npm run build</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build><p>最後,使用 Angular CLI <strong >生成一個新的模塊</strong>:</p>
ng new oauthApp在下面的部分,我們將討論 Angular 應用的邏輯。
5. 使用 Angular 的授權碼流程
我們這裏將使用 OAuth2 授權碼流程。
我們的用例:客户端應用請求從授權服務器獲取授權碼,並向用户展示登錄頁面。 一旦用户提供有效的憑據並提交,授權服務器將向我們提供該授權碼。 然後,前端客户端使用它來獲取訪問令牌。
5.1. 主組件
讓我們從我們的主組件,HomeComponent 開始,這是所有操作的起點:
@Component({
selector: 'home-header',
providers: [AppService],
template: `<div class="container" >
<button *ngIf="!isLoggedIn" class="btn btn-primary" (click)="login()" type="submit">
Login</button>
<div *ngIf="isLoggedIn" class="content">
<span>Welcome !!</span>
<a class="btn btn-default pull-right"(click)="logout()" href="#">Logout</a>
<br/>
<foo-details></foo-details>
</div>
</div>`
})
export class HomeComponent {
public isLoggedIn = false;
constructor(private _service: AppService) { }
ngOnInit() {
this.isLoggedIn = this._service.checkCredentials();
let i = window.location.href.indexOf('code');
if(!this.isLoggedIn && i != -1) {
this._service.retrieveToken(window.location.href.substring(i + 5));
}
}
login() {
window.location.href =
'http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/auth?
response_type=code&scope=openid%20write%20read&client_id=' +
this._service.clientId + '&redirect_uri='+ this._service.redirectUri;
}
logout() {
this._service.logout();
}
}
在開始時,當用户未登錄時,只會顯示登錄按鈕。點擊該按鈕後,用户將被引導至 AS 的授權 URL,並輸入用户名和密碼。登錄成功後,用户將被重定向回頁面,並帶有一個授權碼,然後我們使用該授權碼來檢索訪問令牌。
5.2. 應用服務
以下內容包含應用服務的邏輯,位於 app.service.ts:
<em>retrieveToken()</em>: 使用授權碼獲取訪問令牌。<em>saveToken()</em>: 使用 ng2-cookies 庫將訪問令牌保存到 Cookie 中。<em>getResource()</em>: 通過其 ID 從服務器獲取 Foo 對象。<em>checkCredentials()</em>: 檢查用户是否已登錄。<em>logout()</em>: 刪除訪問令牌 Cookie 並註銷用户。
export class Foo {
constructor(public id: number, public name: string) { }
}
@Injectable()
export class AppService {
public clientId = 'newClient';
public redirectUri = 'http://localhost:8089/';
constructor(private _http: HttpClient) { }
retrieveToken(code) {
let params = new URLSearchParams();
params.append('grant_type','authorization_code');
params.append('client_id', this.clientId);
params.append('redirect_uri', this.redirectUri);
params.append('code',code);
let headers =
new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});
this._http.post('http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token',
params.toString(), { headers: headers })
.subscribe(
data => this.saveToken(data),
err => alert('Invalid Credentials'));
}
saveToken(token) {
var expireDate = new Date().getTime() + (1000 * token.expires_in);
Cookie.set("access_token", token.access_token, expireDate);
console.log('Obtained Access token');
window.location.href = 'http://localhost:8089';
}
getResource(resourceUrl) : Observable<any> {
var headers = new HttpHeaders({
'Content-type': 'application/x-www-form-urlencoded; charset=utf-8',
'Authorization': 'Bearer '+Cookie.get('access_token')});
return this._http.get(resourceUrl, { headers: headers })
.catch((error:any) => Observable.throw(error.json().error || 'Server error'));
}
checkCredentials() {
return Cookie.check('access_token');
}
logout() {
Cookie.delete('access_token');
window.location.reload();
}
}在 retrieveToken 方法中,我們使用客户端憑據和 Basic Auth 向 /openid-connect/token 端點發送 POST 請求,以獲取訪問令牌。參數以 URL 編碼格式發送。獲取訪問令牌後,我們將其存儲在 Cookie 中。
Cookie 存儲在這裏尤其重要,因為我們僅將 Cookie 用於存儲目的,而不直接驅動身份驗證流程。 這有助於防止跨站請求偽造 (CSRF) 攻擊和漏洞。
5.3. Foo 組件
最後,我們使用 FooComponent 來顯示 Foo 的詳細信息:
@Component({
selector: 'foo-details',
providers: [AppService],
template: `<div class="container">
<h1 class="col-sm-12">Foo Details</h1>
<div class="col-sm-12">
<label class="col-sm-3">ID</label> <span>{{foo.id}}</span>
</div>
<div class="col-sm-12">
<label class="col-sm-3">Name</label> <span>{{foo.name}}</span>
</div>
<div class="col-sm-12">
<button class="btn btn-primary" (click)="getFoo()" type="submit">New Foo</button>
</div>
</div>`
})
export class FooComponent {
public foo = new Foo(1,'sample foo');
private foosUrl = 'http://localhost:8081/resource-server/api/foos/';
constructor(private _service:AppService) {}
getFoo() {
this._service.getResource(this.foosUrl+this.foo.id)
.subscribe(
data => this.foo = data,
error => this.foo.name = 'Error');
}
}5.5. App 組件
我們的簡單 AppComponent 作為根組件:
@Component({
selector: 'app-root',
template: `<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="/">Spring Security Oauth - Authorization Code</a>
</div>
</div>
</nav>
<router-outlet></router-outlet>`
})
export class AppComponent { }
以及 AppModule,其中我們封裝了所有組件、服務和路由:
@NgModule({
declarations: [
AppComponent,
HomeComponent,
FooComponent
],
imports: [
BrowserModule,
HttpClientModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' }], {onSameUrlNavigation: 'reload'})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
7. 運行前端
1. 要運行我們任何前端模塊,首先需要構建應用程序:
mvn clean install2. 然後,我們需要導航到我們的 Angular 應用目錄。
cd src/main/resources最後,我們將啓動我們的應用程序。
npm start服務器默認啓動端口為 4200;要更改任何模塊的端口,請修改:
"start": "ng serve"在 <em package.json;</em> 中,例如,要使其在 8089 端口上運行,請添加:
"start": "ng serve --port 8089"8. 結論
在本文中,我們學習瞭如何使用 OAuth2 授權我們的應用程序。