1. 概述

本教程中,我们将使用OAuth2来保护我们的后端REST API,并使用Angular作为我们的前端。

我们要构建的应用包含4个独立的模块:

  • Authorization Server(授权服务器)
  • Resource Server(资源服务器)
  • UI implicit – 一个使用隐式授权模式(Implicit Flow)的前端应用
  • UI password – 一个使用密码授权模式(Password Flow)的前端应用

注意:本文使用的是旧版Spring Security OAuth项目。新的Spring Security 5的实现版本,可以查看我们这篇文章 Spring REST API + OAuth2 + Angular

下面进入正题。

2. 授权服务器(Authorization Server)

首先,让我们先搭建一个基于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>

注意,我们将使用JDBC实现token存储,所以添加了spring-jdbc和MySQL依赖。

2.2. @EnableAuthorizationServer

现在,让我们开始配置授权服务器,它将负责管理access token:

@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());
    }
}

注意:

  • 为了持久化token, 我们用到了JdbcTokenStore
  • 我们注册了一个使用隐式(implicit)授权模式的客户端
  • 我们还注册了一个支持密码(password),授权码(authorization_code),刷新令牌(refresh_token)3种授权模式的客户端
  • 为了使用密码授权模式,我们需要注入并使用AuthenticationManager bean

2.3. DataSource 数据源配置

下一步,我们要配置JdbcTokenStore将要用到的DataSource

@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 schema:

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 Security 配置

最后, 保护我们的授权服务器。

当客户端应用需要获取Access Token时,它需要先完成表单登录认证:

@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. 资源服务器(Resource Server)

现在,让我们讨论资源服务器。所谓的资源,本质上指的就是我们最终调用的REST API。

3.1 Maven 依赖

资源服务器依赖与上面授权服务器依赖相同。

3.2 Token Store 配置

接下来,我们将配置TokenStore来访问授权服务器用来存储Access Token的同一数据库:

@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实现的Token存储,即便授权服务器和资源服务器是2个独立的应用。

当然,原因是资源服务器需要能够检查授权服务器颁发的Access token的有效性。

3.3 远程 Token 服务

不使用资源服务器中的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将使用授权服务器上的CheckTokenEndPoint来验证 AccessToken并从中获取Authentication对象。
  • 接口地址为 AuthorizationServerBaseURL +”/oauth/check/token“
  • 授权服务器可以使用任何实现方法的TokenStore(如JdbcTokenStore、JwtTokenStore),这对资源服务器来说是透明的

3.4 一个简单的Controller

下一步,我们将实现一个简单的controller,来暴露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"权限访问该资源。

我们还需要开启全局的method security并配置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设置一个很基础的配置:

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

4. 前端 – 搭建

来看看一个简单的前端 AngularJS 客户端实现。

我们首先使用Angular CLI工具生成和管理我们前端模块。

首先,我们要安装node 和 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>

最后, 用Angular CLI生成新模块:

ng new oauthApp

注意我们有2个前端模块,一个使用密码授权模式,一个使用隐式授权模式。

下面章节,我们将讨论这2个Angular应用模块的实现逻辑。

5. Angular —— 密码模式授权流程

本节中我们准备使用OAuth2密码授权流程(Password flow) —— 这只是为了理论验证,不可用于生产环境。你应该注意到了,客户端凭据(账号密码)直接暴露给了前端,这样是不安全的,这将在以后的文章中讨论。

我们的例子很简单:用户提供凭据(账号密码)后,前端客户端将使用它们从授权服务器获取Access token

5.1. App Service

先从AppService开始(位于app.service.ts文件中),里面包含了与服务器交互的逻辑:

  • obtainAccessToken(): 获取Access token
  • saveToken(): 使用ng2-cookies保存access token到cookie中
  • getResource(): 通过ID从服务器查询Foo对象
  • checkCredentials(): 判断用户是否登录
  • logout(): 删除access token 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,我们将发送POST请求到“/oauth/token”接口
  • 我们使用客户端凭据(clientId+私钥)进行Basic认证
  • 然后,我们发送经过URL编码的用户凭证(账号密码)、客户端ID、授权类型参数
  • 得到Access Token后,将其保存在cookie中

cookie存储在这里特别重要,因为我们仅将cookie用于存储目的,而不是直接驱动身份认证过程。 这有助于防止跨站点请求伪造(CSRF)攻击和漏洞。

5.2. Login 组件

下面,看下我们的登录表单 —— 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. Home 组件

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. AppComponent

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. App Service

类似的, 我们从service开始,但这次我们将通过 angular-oauth2-oidc库获取access token,而不是自己实现:

    @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添加到HTTP请求的Authorization header中。

6.2. Home 组件

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 Module

最后, 我们的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

3. 执行启动命令:

npm start

默认情况下,服务监听在4200端口上。要更改,在package.json中将

"start": "ng serve"

修改为,比如8086:

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

8. 总结

本文中,我们学到了如何使用OAuth2授权我们的应用。

本教程完整实现源代码,可从GitHub上获取。