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上获取。