知識庫 / Spring / Spring Security RSS 訂閱

Spring REST API + OAuth2 + Angular (使用 Spring Security OAuth 遺留棧)

Spring Security
HongKong
6
01:02 PM · Dec 06 ,2025

1. 概述

在本教程中,我們將使用 OAuth 保護 REST API,並從一個簡單的 Angular 客户端進行消費。

我們將構建的應用程序將由四個獨立的模塊組成:

  • 授權服務器
  • 資源服務器
  • UI implicit – 一個使用 Implicit Flow 的前端應用程序
  • UI password – 一個使用 Password Flow 的前端應用程序

 

注意:本文檔使用了 Spring OAuth 遺留項目。對於使用 Spring Security 5 堆棧版本的本文檔,請查看我們的文章 Spring REST API + OAuth2 + Angular。

 

現在,讓我們直接開始吧。

2. 授權服務器

首先,讓我們以一個簡單的 Spring Boot 應用程序的方式設置一個授權服務器。

2.1. Maven 配置

我們將設置以下依賴項:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>    
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
</dependency>  
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
</dependency>

請注意,我們使用了 spring-jdbc 和 MySQL,因為我們將使用基於 JDBC 的令牌存儲實現。

2.2. @EnableAuthorizationServer

現在,讓我們開始配置負責管理訪問令牌的授權服務器:

@Configuration
@EnableAuthorizationServer
public class AuthServerOAuth2Config
  extends AuthorizationServerConfigurerAdapter {
 
    @Autowired
    @Qualifier("authenticationManagerBean")
    private AuthenticationManager authenticationManager;

    @Override
    public void configure(
      AuthorizationServerSecurityConfigurer oauthServer) 
      throws Exception {
        oauthServer
          .tokenKeyAccess("permitAll()")
          .checkTokenAccess("isAuthenticated()");
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) 
      throws Exception {
        clients.jdbc(dataSource())
          .withClient("sampleClientId")
          .authorizedGrantTypes("implicit")
          .scopes("read")
          .autoApprove(true)
          .and()
          .withClient("clientIdPassword")
          .secret("secret")
          .authorizedGrantTypes(
            "password","authorization_code", "refresh_token")
          .scopes("read");
    }

    @Override
    public void configure(
      AuthorizationServerEndpointsConfigurer endpoints) 
      throws Exception {
 
        endpoints
          .tokenStore(tokenStore())
          .authenticationManager(authenticationManager);
    }

    @Bean
    public TokenStore tokenStore() {
        return new JdbcTokenStore(dataSource());
    }
}

請注意:

  • 為了持久化令牌,我們使用了 JdbcTokenStore
  • 我們註冊了一個用於“implicit” grant 類型的客户端
  • 我們註冊了另一個客户端並授權了 “password“、 “authorization_code” 和 “refresh_token” grant 類型
  • 為了使用 “password” grant 類型,我們需要注入並使用 AuthenticationManager bean

2.3. 數據源配置

接下來,讓我們配置用於 JdbcTokenStore 的數據源:

@Value("classpath:schema.sql")
private Resource schemaScript;

@Bean
public DataSourceInitializer dataSourceInitializer(DataSource dataSource) {
    DataSourceInitializer initializer = new DataSourceInitializer();
    initializer.setDataSource(dataSource);
    initializer.setDatabasePopulator(databasePopulator());
    return initializer;
}

private DatabasePopulator databasePopulator() {
    ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
    populator.addScript(schemaScript);
    return populator;
}

@Bean
public DataSource dataSource() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName(env.getProperty("jdbc.driverClassName"));
    dataSource.setUrl(env.getProperty("jdbc.url"));
    dataSource.setUsername(env.getProperty("jdbc.user"));
    dataSource.setPassword(env.getProperty("jdbc.pass"));
    return dataSource;
}

請注意,由於我們使用了 JdbcTokenStore,因此我們需要初始化數據庫模式,因此我們使用了 DataSourceInitializer,以及以下 SQL 模式:

drop table if exists oauth_client_details;
create table oauth_client_details (
  client_id VARCHAR(255) PRIMARY KEY,
  resource_ids VARCHAR(255),
  client_secret VARCHAR(255),
  scope VARCHAR(255),
  authorized_grant_types VARCHAR(255),
  web_server_redirect_uri VARCHAR(255),
  authorities VARCHAR(255),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additional_information VARCHAR(4096),
  autoapprove VARCHAR(255)
);

drop table if exists oauth_client_token;
create table oauth_client_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication_id VARCHAR(255) PRIMARY KEY,
  user_name VARCHAR(255),
  client_id VARCHAR(255)
);

drop table if exists oauth_access_token;
create table oauth_access_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication_id VARCHAR(255) PRIMARY KEY,
  user_name VARCHAR(255),
  client_id VARCHAR(255),
  authentication LONG VARBINARY,
  refresh_token VARCHAR(255)
);

drop table if exists oauth_refresh_token;
create table oauth_refresh_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication LONG VARBINARY
);

drop table if exists oauth_code;
create table oauth_code (
  code VARCHAR(255), authentication LONG VARBINARY
);

drop table if exists oauth_approvals;
create table oauth_approvals (
	userId VARCHAR(255),
	clientId VARCHAR(255),
	scope VARCHAR(255),
	status VARCHAR(10),
	expiresAt TIMESTAMP,
	lastModifiedAt TIMESTAMP
);

drop table if exists ClientDetails;
create table ClientDetails (
  appId VARCHAR(255) PRIMARY KEY,
  resourceIds VARCHAR(255),
  appSecret VARCHAR(255),
  scope VARCHAR(255),
  grantTypes VARCHAR(255),
  redirectUrl VARCHAR(255),
  authorities VARCHAR(255),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additionalInformation VARCHAR(4096),
  autoApproveScopes VARCHAR(255)
);

請注意,我們並不一定需要顯式的 DatabasePopulator Bean —— 我們可以簡單地使用 schema.sql ,Spring Boot 默認情況下就利用它

2.4. 授權服務器安全配置

最後,讓我們安全地配置授權服務器。

當客户端應用程序需要獲取訪問令牌時,它將通過簡單的基於表單登錄驅動的身份驗證流程進行獲取:

@Configuration
public class ServerSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) 
      throws Exception {
        auth.inMemoryAuthentication()
          .withUser("john").password("123").roles("USER");
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() 
      throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/login").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin().permitAll();
    }
}

這裏提供一個簡要説明:密碼流程不要求登錄配置——僅適用於隱式流程——因此,您可能可以根據您使用的 OAuth2 流程來決定是否跳過該配置。

3. 資源服務器

現在,我們來討論一下資源服務器;它本質上是我們要最終能夠消費的 REST API。

3.1. Maven 配置

我們的資源服務器配置與之前的授權服務器應用程序配置相同。

3.2. 令牌存儲配置

接下來,我們將配置我們的 TokenStore 以訪問授權服務器使用的相同的數據庫,該數據庫用於存儲訪問令牌:

@Autowired
private Environment env;

@Bean
public DataSource dataSource() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName(env.getProperty("jdbc.driverClassName"));
    dataSource.setUrl(env.getProperty("jdbc.url"));
    dataSource.setUsername(env.getProperty("jdbc.user"));
    dataSource.setPassword(env.getProperty("jdbc.pass"));
    return dataSource;
}

@Bean
public TokenStore tokenStore() {
    return new JdbcTokenStore(dataSource());
}

請注意,對於這個簡單的實現,我們共享了基於 SQL 的令牌存儲,即使授權服務器和資源服務器是獨立的應用程序。

原因當然是,資源服務器需要能夠驗證授權服務器頒發的訪問令牌的有效性

3.3. 遠程令牌服務

為了避免在我們的資源服務器中使用 TokenStore,我們可以使用 RemoteTokeServices

@Primary
@Bean
public RemoteTokenServices tokenService() {
    RemoteTokenServices tokenService = new RemoteTokenServices();
    tokenService.setCheckTokenEndpointUrl(
      "http://localhost:8080/spring-security-oauth-server/oauth/check_token");
    tokenService.setClientId("fooClientIdPassword");
    tokenService.setClientSecret("secret");
    return tokenService;
}

請注意:

  • RemoteTokenService 將使用 Authorization Server 的 CheckTokenEndPoint 來驗證 AccessToken 並從中獲取 Authentication 對象。
  • 該地址位於 AuthorizationServerBaseURL + “//oauth/check_token
  • Authorization Server 可以使用任何 TokenStore 類型 [JdbcTokenStore, JwtTokenStore, …] – 這不會影響 RemoteTokenService 或 Resource server。

3.4. 一個示例控制器

接下來,讓我們實現一個簡單的控制器,它會暴露一個 Foo 資源:

@Controller
public class FooController {

    @PreAuthorize("#oauth2.hasScope('read')")
    @RequestMapping(method = RequestMethod.GET, value = "/foos/{id}")
    @ResponseBody
    public Foo findById(@PathVariable long id) {
        return 
          new Foo(Long.parseLong(randomNumeric(2)), randomAlphabetic(4));
    }
}

請注意客户端需要 “read” 權限才能訪問該資源。

此外,還需要啓用全局方法安全性並配置 MethodSecurityExpressionHandler

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OAuth2ResourceServerConfig 
  extends GlobalMethodSecurityConfiguration {

    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        return new OAuth2MethodSecurityExpressionHandler();
    }
}

以下是我們的基本 Foo 資源:

public class Foo {
    private long id;
    private String name;
}

3.5. Web 配置

最後,讓我們為 API 設置一個非常基本的 Web 配置:

@Configuration
@EnableWebMvc
@ComponentScan({ "org.baeldung.web.controller" })
public class ResourceWebConfig implements WebMvcConfigurer {}

4. 前端 – 設置

現在我們將探討一個簡單的 Angular 前端實現,用於客户端。

首先,我們將使用 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. 應用服務

以下內容包含應用服務的邏輯,位於 <em >app.service.ts</em> 中:

  • <em >obtainAccessToken()</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 {
  constructor(
    private _router: Router, private _http: Http){}
 
  obtainAccessToken(loginData){
    let params = new URLSearchParams();
    params.append('username',loginData.username);
    params.append('password',loginData.password);    
    params.append('grant_type','password');
    params.append('client_id','fooClientIdPassword');
    let headers = 
      new Headers({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8',
      'Authorization': 'Basic '+btoa("fooClientIdPassword:secret")});
    let options = new RequestOptions({ headers: headers });
    
    this._http.post('http://localhost:8081/spring-security-oauth-server/oauth/token', 
      params.toString(), options)
      .map(res => res.json())
      .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);
    this._router.navigate(['/']);
  }

  getResource(resourceUrl) : Observable<Foo>{
    var headers = 
      new Headers({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8',
      'Authorization': 'Bearer '+Cookie.get('access_token')});
    var options = new RequestOptions({ headers: headers });
    return this._http.get(resourceUrl, options)
                   .map((res:Response) => res.json())
                   .catch((error:any) => 
                     Observable.throw(error.json().error || 'Server error'));
  }

  checkCredentials(){
    if (!Cookie.check('access_token')){
        this._router.navigate(['/login']);
    }
  } 

  logout() {
    Cookie.delete('access_token');
    this._router.navigate(['/login']);
  }
}

請注意:

  • 為了獲取 Access Token,我們向 “/oauth/token” 端點發送 POST 請求
  • 我們使用客户端憑據和基本身份驗證來訪問該端點
  • 然後,我們將用户憑據以及 client id 和 grant type 參數 URL 編碼的方式發送
  • 獲取 Access Token 後,我們會將其存儲在 cookie 中

cookie 存儲在這裏尤其重要,因為我們僅將 cookie 用於存儲目的,而不會直接驅動身份驗證過程。 這有助於防止跨站請求偽造 (CSRF) 類型攻擊和漏洞。

5.2. 登錄組件

接下來,讓我們看看我們的 LoginComponent,它負責登錄表單:

@Component({
  selector: 'login-form',
  providers: [AppService],  
  template: `<h1>Login</h1>
    <input type="text" [(ngModel)]="loginData.username" />
    <input type="password"  [(ngModel)]="loginData.password"/>
    <button (click)="login()" type="submit">Login</button>`
})
export class LoginComponent {
    public loginData = {username: "", password: ""};

    constructor(private _service:AppService) {}
 
    login() {
        this._service.obtainAccessToken(this.loginData);
    }

5.3. 主頁組件

接下來是我們的 HomeComponent,負責顯示和操作我們的主頁:

@Component({
    selector: 'home-header',
    providers: [AppService],
  template: `<span>Welcome !!</span>
    <a (click)="logout()" href="#">Logout</a>
    <foo-details></foo-details>`
})
 
export class HomeComponent {
    constructor(
        private _service:AppService){}
 
    ngOnInit(){
        this._service.checkCredentials();
    }
 
    logout() {
        this._service.logout();
    }
}

5.4. Foo 組件

最後,我們使用 FooComponent 來顯示 Foo 的詳細信息:

@Component({
  selector: 'foo-details',
  providers: [AppService],  
  template: `<h1>Foo Details</h1>
    <label>ID</label> <span>{{foo.id}}</span>
    <label>Name</label> <span>{{foo.name}}</span>
    <button (click)="getFoo()" type="submit">New Foo</button>`
})

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');
    }
}

5.5. App 組件

我們的簡單 AppComponent 作為根組件:

@Component({
    selector: 'app-root',
    template: `<router-outlet></router-outlet>`
})

export class AppComponent {}

以及 AppModule,其中我們封裝了所有組件、服務和路由:

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    LoginComponent,
    FooComponent    
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    RouterModule.forRoot([
     { path: '', component: HomeComponent },
    { path: 'login', component: LoginComponent }])
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

6. 隱式流

接下來,我們將重點關注隱式流模塊。

6.1. 應用服務

同樣,我們首先將從應用服務開始,但這次我們將使用庫 angular-oauth2-oidc,而不是自行獲取訪問令牌:

@Injectable()
export class AppService {
 
  constructor(
    private _router: Router, private _http: Http, private oauthService: OAuthService){
        this.oauthService.loginUrl = 
          'http://localhost:8081/spring-security-oauth-server/oauth/authorize'; 
        this.oauthService.redirectUri = 'http://localhost:8086/';
        this.oauthService.clientId = "sampleClientId";
        this.oauthService.scope = "read write foo bar";    
        this.oauthService.setStorage(sessionStorage);
        this.oauthService.tryLogin({});      
    }
 
  obtainAccessToken(){
      this.oauthService.initImplicitFlow();
  }

  getResource(resourceUrl) : Observable<Foo>{
    var headers = 
      new Headers({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8',
     'Authorization': 'Bearer '+this.oauthService.getAccessToken()});
    var options = new RequestOptions({ headers: headers });
    return this._http.get(resourceUrl, options)
      .map((res:Response) => res.json())
      .catch((error:any) => Observable.throw(error.json().error || 'Server error'));
  }

  isLoggedIn(){
    if (this.oauthService.getAccessToken() === null){
       return false;
    }
    return true;
  } 

  logout() {
      this.oauthService.logOut();
      location.reload();
  }
}

請注意,在獲取到 Access Token 後,我們使用它通過 Authorization 標頭來消費來自 Resource Server 的受保護資源。

6.2. 主頁組件

我們的 HomeComponent 用於處理我們的簡單主頁。

@Component({
    selector: 'home-header',
    providers: [AppService],
  template: `
    <button *ngIf="!isLoggedIn" (click)="login()" type="submit">Login</button>
    <div *ngIf="isLoggedIn">
        <span>Welcome !!</span>
        <a (click)="logout()" href="#">Logout</a>
        <br/>
        <foo-details></foo-details>
    </div>`
})
 
export class HomeComponent {
    public isLoggedIn = false;

    constructor(
        private _service:AppService){}
    
    ngOnInit(){
        this.isLoggedIn = this._service.isLoggedIn();
    }

    login() {
        this._service.obtainAccessToken();
    }

    logout() {
        this._service.logout();
    }
}

6.3. Foo 組件

我們的 FooComponent 與密碼流程模塊中的完全相同。

6.4. App 模塊

最後,我們的 AppModule

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    FooComponent    
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    OAuthModule.forRoot(),    
    RouterModule.forRoot([
     { path: '', component: HomeComponent }])
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

7. 運行前端

1. 要運行我們任何前端模塊,首先需要構建應用程序:

mvn clean install

2. 然後,我們需要導航到我們的 Angular 應用目錄。

cd src/main/resources

最後,我們將啓動我們的應用程序。

npm start

服務器默認啓動端口為 4200,要更改任何模塊的端口,請修改...

"start": "ng serve"

package.json 中進行配置,使其在 8086 端口上運行,例如:

"start": "ng serve --port 8086"

8. 結論

在本文中,我們學習瞭如何使用 OAuth2 授權我們的應用程序。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.