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 项目

  1. 确保本地已安装 Node.js
  2. 使用 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. 启动与运行

要验证整个流程,需依次启动三个服务:

  1. 授权服务器:运行在 8081 端口
  2. 资源服务器:运行在 8082 端口
  3. 前端应用:运行在 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 请求提升安全性。


原始标题:Front-End App with Spring Security OAuth - Authorization Code Flow