1. 概述
本文是 Spring Security OAuth 系列教程 的延续,重点在于构建一个基于 授权码模式(Authorization Code Flow) 的前端应用。
⚠️ 注意:本文的关注点在 客户端(前端)实现。关于授权服务器(Authorization Server)和资源服务器(Resource Server)的详细配置,请参考前置文章:Spring REST API + OAuth2 + AngularJS。
授权码模式是 OAuth2 中最安全、最常用的流程,适用于有后端的应用。本文将带你从零搭建一个简单的 Angular 前端,完成完整的登录、获取令牌、调用资源和登出流程。
2. 授权服务器配置
在启动前端之前,必须先在授权服务器中注册客户端信息。以下是关键配置:
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("fooClientId")
.secret(passwordEncoder().encode("secret"))
.authorizedGrantTypes("authorization_code")
.scopes("foo", "read", "write")
.redirectUris("http://localhost:8089/");
}
}
关键点解析:
- ✅ client ID:
fooClientId
—— 前端登录时需要使用 - ✅ client secret:
secret
—— 用于换取 access token,注意前端中虽暴露但实际由后端代理更安全(本文为演示直接使用) - ✅ 授权类型:
authorization_code
—— 启用授权码模式 - ✅ 回调地址(redirect URI):
http://localhost:8089/
—— 授权成功后跳转的目标地址,必须完全匹配
⚠️ 踩坑提醒:
redirect_uri
必须与注册时完全一致,包括末尾的/
,否则会报invalid redirect_uri
错误。
3. 前端项目搭建
我们使用 Angular 6 构建前端,并通过 Maven 的 frontend-maven-plugin
集成到 Spring Boot 项目中,实现一键打包。
Maven 插件配置
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.6</version>
<configuration>
<nodeVersion>v8.11.3</nodeVersion>
<npmVersion>6.1.0</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>
初始化 Angular 项目
- 确保本地已安装 Node.js
- 使用 Angular CLI 创建项目:
ng new authCode
项目结构生成在 src/main/resources
下,便于 Maven 打包时包含静态资源。
4. Angular 模块与组件设计
整个前端由一个主模块(AppModule)和多个组件构成,结构清晰。
核心模块:AppModule
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { HomeComponent } from './home.component';
import { FooComponent } from './foo.component';
@NgModule({
declarations: [
AppComponent,
HomeComponent,
FooComponent
],
imports: [
BrowserModule,
HttpClientModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' }], {onSameUrlNavigation: 'reload'})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
✅ 说明:
- 使用
HttpClientModule
发起 HTTP 请求 - 配置路由,根路径
/
映射到HomeComponent
{onSameUrlNavigation: 'reload'}
确保刷新时组件重新加载(避免 token 处理失效)
4.1 App 组件(根组件)
import {Component} from '@angular/core';
@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 {}
简单粗暴,只负责渲染导航栏和路由出口 <router-outlet>
。
4.2 Home 组件(首页)
import {Component} from '@angular/core';
import {AppService} from './app.service'
@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:8081/spring-security-oauth-server/oauth/authorize?response_type=code&client_id=' + this._service.clientId + '&redirect_uri=' + this._service.redirectUri;
}
logout() {
this._service.logout();
}
}
核心逻辑:
- ✅
ngOnInit
中检查是否已登录(通过 Cookie) - ✅ 若 URL 中包含
code
参数(授权服务器重定向回来),立即调用retrieveToken
换取 access token - ✅ 登录按钮跳转到授权服务器的
/oauth/authorize
接口
⚠️ 踩坑提醒:
indexOf('code')
只是简单查找,实际项目建议使用 URL 解析库(如URLSearchParams
)提取参数,避免误判。
4.3 Foo 组件(资源展示)
import { Component } from '@angular/core';
import { AppService, Foo } from './app.service'
@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:8082/spring-security-oauth-resource/foos/';
constructor(private _service: AppService) {}
getFoo(){
this._service.getResource(this.foosUrl + this.foo.id)
.subscribe(
data => this.foo = data,
error => this.foo.name = 'Error');
}
}
功能:
- 展示从资源服务器获取的
Foo
对象 - 点击按钮调用
getResource
接口,自动携带 Bearer Token
4.4 App Service(核心服务)
import {Injectable} from '@angular/core';
import { Cookie } from 'ng2-cookies';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/map';
export class Foo {
constructor(
public id: number,
public name: string) { }
}
@Injectable()
export class AppService {
public clientId = 'fooClientId';
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',
'Authorization': 'Basic ' + btoa(this.clientId + ":secret")
});
this._http.post(
'http://localhost:8081/spring-security-oauth-server/oauth/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(code) |
使用授权码向 /oauth/token 接口换取 access token |
saveToken(token) |
将 token 存入 Cookie 并跳转回首页 |
getResource(url) |
带 Bearer Token 请求资源接口 |
checkCredentials() |
检查 Cookie 中是否存在 token |
logout() |
删除 token Cookie 并刷新页面 |
⚠️ 踩坑提醒:
btoa(clientId:secret)
用于生成 Basic 认证头,注意ng2-cookies
需要手动安装并导入。
5. 启动与运行
要验证整个流程,需依次启动三个服务:
- 授权服务器:运行在
8081
端口 - 资源服务器:运行在
8082
端口 - 前端应用:运行在
8089
端口
构建并启动前端
# 打包后端并构建前端资源
mvn clean install
# 进入前端目录
cd src/main/resources/authCode
# 启动 Angular 开发服务器
npm start
访问 http://localhost:8089
,点击 Login,完成 OAuth2 授权码流程。
6. 总结
本文实现了基于 Spring Security OAuth2 + Angular 6 的授权码模式前端应用,涵盖了:
- 客户端注册与配置
- 前端登录跳转授权
- 授权码换取 access token
- 使用 token 调用受保护资源
- 登出清理
完整源码已托管至 GitHub:
https://github.com/baeldung/spring-security-oauth/tree/master/oauth-legacy
✅ 建议:生产环境应避免在前端暴露
client_secret
,可通过后端代理/oauth/token
请求提升安全性。