1. 概述
在本教程中,我們將使用OAuth安全REST API並從簡單的Angular客户端進行消費。
我們將構建的應用程序將由四個單獨模塊組成:
- 授權服務器
- 資源服務器
- UI implicit – 一個使用隱式流的客户端應用程序
- UI password – 一個使用密碼流的客户端應用程序
注意: 本文使用 Spring OAuth 遺留項目。對於使用 Spring Security 5 堆棧版本的本文,請查看我們的文章 Spring REST API + OAuth2 + Angular。
現在,讓我們直接開始吧。
2. The Authorization Server
首先,讓我們設置一個簡單的 Spring Boot 應用程序作為授權服務器。
2.1. Maven Configuration
我們將設置以下一組依賴項:
<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 實現的 token 存儲。
2.2.
現在,讓我們配置負責管理訪問令牌的授權服務器:
@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 注入並使用它
2.3. Data Source Configuration
接下來,讓我們配置用於 AuthorizationServer 存儲的 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 – 以及 Spring Boot 默認使用的以下 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 – Spring Boot 默認會使用 schema.sql。
2.4. Security Configuration
最後,讓我們安全地配置授權服務器。
當客户端應用程序需要獲取訪問令牌時,它將在一個簡單的表單登錄驅動的身份驗證流程中進行:
@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();
}
}
這裏需要注意的是,表單登錄配置對於 Password 流程並非必需 – 僅對於 Implicit 流程才需要,因此您可能能夠跳過它,具體取決於您使用的 OAuth2 流程。
3. 資源服務器
現在,讓我們討論資源服務器;它基本上是 REST API,我們最終希望能夠消耗它。
3.1. Maven 配置
我們的資源服務器配置與先前授權服務器應用程序的配置相同。
3.2. 令牌存儲配置
接下來,我們將配置我們的
@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());
}
請注意,對於這個簡單的實現,
原因當然是資源服務器需要能夠
3.3. 遠程令牌服務
而不是在資源服務器中使用
@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 對象。 - 可以在授權服務器 +”/oauth/check_token“上找到它。
- 授權服務器可以使用任何 TokenStore 類型 [
JdbcTokenStore 、JwtTokenStore 、… ] – 這不會影響RemoteTokenService 或資源服務器。
3.4. 示例控制器
接下來,讓我們實現一個公開
@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));
}
}
請注意,客户端需要
我們還需要啓用全局方法安全並配置
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OAuth2ResourceServerConfig
extends GlobalMethodSecurityConfiguration {
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
return new OAuth2MethodSecurityExpressionHandler();
}
}
這裏是基本的
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 和 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>安裝 node 和 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
請注意,我們將有兩個前端模塊——一個用於密碼流程,另一個用於隱式流程。
在後面的部分,我們將討論每個模塊的 Angular 應用邏輯。
5. Password Flow Using Angular
We’re going to be using the OAuth2 Password flow here – which is why this is just a proof of concept, not a production-ready application. You’ll notice that the client credentials are exposed to the front end – which is something we’ll address in a future article.
Our use case is simple: once a user provides their credentials, the front-end client uses them to acquire an Access Token from the Authorization Server.
5.1. App Service
Let’s start with our AppService – located at app.service.ts – which contains the logic for server interactions:
- obtainAccessToken() : to obtain Access token given user credentials
- saveToken() : to save our access token in a cookie using ng2-cookies library
- getResource() : to get a Foo object from server using its ID
- checkCredentials() : to check if user is logged in or not
- logout() : to delete access token cookie and log the user out
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']);
}
}
Note that:
- To get an Access Token we send a POST to the “/oauth/token” endpoint
- We’re using the client credentials and Basic Auth to hit this endpoint
- We’re then sending the user credentials along with the client id and grant type parameters URL encoded
- After we obtain the Access Token – we store it in a cookie
The cookie storage is especially important here, because we’re only using the cookie for storage purposes and not to drive the authentication process directly. This helps protect against cross-site request forgery (CSRF) type of attacks and vulnerabilities.
5.2. Login Component
Next, let’s take a look at our LoginComponent which is responsible for the login form:
@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 Component
Next, our HomeComponent which is responsible for displaying and manipulating our Home Page:
@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 Component
Finally, our FooComponent to display our Foo details:
@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 Component
Our simple AppComponent to act as the root component:
@Component({
selector: 'app-root',
template: `<router-outlet></router-outlet>`
})
export class AppComponent {}
And the AppModule where we wrap all our components, services and routes:
@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();
}
}
注意,在獲得訪問令牌後,我們使用 Authorization 標頭來消費資源服務器內部的受保護資源。
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. 應用模塊
最後,我們的 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 上啓動,要更改任何模塊的端口,請更改
"start": "ng serve"
在 package.json中,例如將其設置為運行在端口 8086 上:
"start": "ng serve --port 8086"
8. 結論
在本文中,我們學習瞭如何使用 OAuth2 授權我們的應用程序。