1. 概述
本教程中,我们将使用OAuth2来保护我们的后端REST API,并使用Angular作为我们的前端
我要构建的应用包含3个独立的模块:
- Authorization Server(授权服务器)
- Resource Server(资源服务器)
- UI authorization code:我们的前端应用,使用授权码模式认证流程。
我们将使用新的Spring Security 5 OAuth技术栈实现。 如果你想使用老版本Spring Security OAuth,可以看下我们之前文章:Spring REST API + OAuth2 + Angular (使用过时的Spring Security OAuth):
下面进入正题。
2. OAuth2 授权服务器 Authorization Server (AS)
简单来说,Authorization Server是用于认证,并颁发授权token的应用程序
在老版本的 Spring Security OAuth中,我们可以自己搭建一个Authorization Server的Spring应用。但现在已不建议这么做,主要是因为OAuth是一个标准开放的协议,已经有很多现成的服务可以直接拿来使用,例如Okta,Keycloak和ForgeRock等。
其中,我们将使用Keycloak。Keycloak是一款开源的身份认证和控制访问服务器。由Red Hat主导,使用Java和JBoss开发。它不仅支持OAuth2,还支持其他标准协议,例如OpenID Connect和SAML。
对于本教程,**我们将在Spring Boot中搭建一个嵌入式的Keycloak服务器**。
3. 资源服务器 Resource Server (RS)
现在让我们讨论Resource Server
。 所谓的资源,实际上它就是我们的REST API。
3.1. Maven 配置
Resource Serverpom
配置和Authorization Server几乎相同,只是没有Keycloak部分,并添加了额外的spring-boot-starter-oauth2-resource-server
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
3.2. Security 配置
因为我们使用Spring Boot,所以只需在application.yml
中定义最低配置就行:
server:
port: 8081
servlet:
context-path: /resource-server
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8083/auth/realms/baeldung
jwk-set-uri: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/certs
这里,我们指明了将使用JWT token进行授权认证。
jwk-set-uri属性指向包含公钥的URI,以便我们的Resource Server可以验证token的完整性。
issuer-uri
属性表示一种用于验证token发行者(即Authorization Server)的附加安全措施。
但是,添加此属性要求必须先运行Authorization Server,然后才能启动资Resource Server。
下一步,配置Security以保护我们的API接口:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors()
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
.hasAuthority("SCOPE_read")
.antMatchers(HttpMethod.POST, "/api/foos")
.hasAuthority("SCOPE_write")
.anyRequest()
.authenticated()
.and()
.oauth2ResourceServer()
.jwt();
return http.build();
}
}
可以看到,对于GET
请求,我们只有具有read
读权限用户才能访问。对于POST
请求,除了read
还需要write
写权限。对于任何接口,用户都需要认证。
同样,oauth2ResourceServer()
方法指明了这是一个Resource Server
,使用jwt
格式的token。
另外需要注意的是,使用了cors()
方法,以允许Access-Control请求头。这一点特别重要,因为我们正在使用Angular客户端,而我们的请求将来自另一个源URL。
3.4. Model(模型) 与 Repository(数据层)
下面,为我们的模型Foo
定义javax.persistence.Entity
实体类,
@Entity
public class Foo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// constructor, getters and setters
}
然后需要添加Foo
对应的Repository
。我们将使用Spring的 PagingAndSortingRepository
:
public interface IFooRepository extends PagingAndSortingRepository<Foo, Long> {
}
3.4. Service实现
之后,我们需要定义Service层:
public interface IFooService {
Optional<Foo> findById(Long id);
Foo save(Foo foo);
Iterable<Foo> findAll();
}
@Service
public class FooServiceImpl implements IFooService {
private IFooRepository fooRepository;
public FooServiceImpl(IFooRepository fooRepository) {
this.fooRepository = fooRepository;
}
@Override
public Optional<Foo> findById(Long id) {
return fooRepository.findById(id);
}
@Override
public Foo save(Foo foo) {
return fooRepository.save(foo);
}
@Override
public Iterable<Foo> findAll() {
return fooRepository.findAll();
}
}
3.5. 一个简单的Controller
现在定义一个简单的controller, 通过DTO暴露我们的Foo
资源:
@RestController
@RequestMapping(value = "/api/foos")
public class FooController {
private IFooService fooService;
public FooController(IFooService fooService) {
this.fooService = fooService;
}
@CrossOrigin(origins = "http://localhost:8089")
@GetMapping(value = "/{id}")
public FooDto findOne(@PathVariable Long id) {
Foo entity = fooService.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return convertToDto(entity);
}
@GetMapping
public Collection<FooDto> findAll() {
Iterable<Foo> foos = this.fooService.findAll();
List<FooDto> fooDtos = new ArrayList<>();
foos.forEach(p -> fooDtos.add(convertToDto(p)));
return fooDtos;
}
protected FooDto convertToDto(Foo entity) {
FooDto dto = new FooDto(entity.getId(), entity.getName());
return dto;
}
}
注意上面的@CrossOrigin注解。这是Controller级的配置,我们需要允许来自Angular App的跨域请求。
下面是我们的FooDto
:
public class FooDto {
private long id;
private String name;
}
4. 前端 — 配置
现在我们看下前端客户端Angular的实现,它将调用我们的REST API。
我们首先使用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
下面章节,我们将讨论Angular应用逻辑
5. 使用Angular完成授权码模式(Authorization Code)认证流程
我们准备使用OAuth2授权码模式。
整个认证逻辑:客户端向Authorization Server请求授权码,并显示登录页面。 一旦用户提交了有效的凭据(账号密码),Authorization Server就会向我们返回授权码。 然后,前端客户端使用授权码来获取access token。
5.1. Home 组件
先从我们最主要的组件 - HomeComponent
开始,所有操作都是从它开始:
@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:8083/auth/realms/baeldung/protocol/openid-connect/auth?
response_type=code&scope=openid%20write%20read&client_id=' +
this._service.clientId + '&redirect_uri='+ this._service.redirectUri;
}
logout() {
this._service.logout();
}
}
首先,当用户未登录时,仅显示登录按钮。 用户单击登录按钮后,跳转到Authorization Server授权页面,在其中输入用户名和密码。成功登录后,重定向回去并附带授权码,然后我们使用授权码获取access token。
5.2. App Service
现在让我们看一下AppService(位于app.service.ts),里面包含了与服务器交互的登录逻辑:
-
retrieveToken()
: 使用授权码获取access token -
saveToken()
: 使用ng2-cookies保存access token -
getResource()
: 通过ID从服务器查询Foo对象 -
checkCredentials()
: 判断用户是否登录 -
logout()
: 用户登出后删除access token cookie
export class Foo {
constructor(public id: number, public name: string) { }
}
@Injectable()
export class AppService {
public clientId = 'newClient';
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('client_secret', 'newClientSecret');
params.append('redirect_uri', this.redirectUri);
params.append('code',code);
let headers =
new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});
this._http.post('http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/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
方法中, 我们使用用户凭据(账号密码)以Basic认证方式向/openid-connect/token
发送POST
请求,以获取access token。 参数以URL编码方式发送。 得到access token后, 我们把它存储在cookie中。
cookie存储在这里特别重要,因为我们仅将cookie用于存储目的,而不是直接驱动身份认证过程。 这有助于防止跨站点请求伪造(CSRF)攻击和漏洞。
5.3. Foo 组件
最后, 定义FooComponent
以显示Foo
详情:
@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:8081/resource-server/api/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: `<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 { }
而AppModule
,封装了我们所有的组件,服务及路由:
@NgModule({
declarations: [
AppComponent,
HomeComponent,
FooComponent
],
imports: [
BrowserModule,
HttpClientModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' }], {onSameUrlNavigation: 'reload'})
],
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"
修改为,比如8089:
"start": "ng serve --port 8089"
8. 总结
在本文中,我们学习了如何使用OAuth2对应用程序进行授权认证。
惯例,本文完整源代码可从GitHub上获取。