Spring REST API + OAuth2 + AngularJS
http://www.baeldung.com/rest-api-spring-oauth2-angularjs
作者:Eugen Paraschiv
译者:http://oopsguy.com
1、概述
在本教程中,我们将使用 OAuth 来保护 REST API,并以一个简单的 AngularJS 客户端进行示范。
我们要建立的应用程序将包含了四个独立模块:
- 授权服务器
- 资源服务器
- UI implicit - 一个使用 Implicit Flow 的前端应用
- UI password - 一个使用 Password Flow 的前端应用
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>
<version>${oauth.version}</version>
</dependency>
请注意,我们使用了 spring-jdbc 和 MySQL,因为我们将使用 JDBC 来实现 token 存储。
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
等授权类型 - 为了使用
password
授权类型,我们需要装配并使用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
,需要初始化数据库 schema(模式),因此我们使用了 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、安全配置
最后,让我们将授权服务器变得更加安全。
当客户端应用程序需要获取一个 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();
}
}
这里的需要提及的是,Password flow 不需要表单登录配置 - 仅限于 Implicit flow,因此您可以根据您使用的 OAuth2 flow 跳过它。
3、资源服务器
现在,我们来讨论一下资源服务器;本质上就是我们想要消费的 REST API。
3.1、Maven 配置
我们的资源服务器配置与之前的授权服务器应用程序配置相同。
3.2、Token 存储配置
接下来,我们将配置我们的 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());
}
请注意,针对这个简单的实现,即使授权服务器与资源服务器是单独的应用,我们也共享着 token 存储的 SQL。
原因当然是资源服务器需要能够验证授权服务器发出的 Access Token 的有效性。
3.3、远程 Token 服务
我们可以使用 RemoteTokeServices
,而不是在资源服务器中使用一个 TokenStore
:
@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
、……] - 这不会影响到RemoteTokenService
或者资源服务器。
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
scope(范围、作用域或权限)访问此资源。
我们还需要开启全局方法安全性并配置 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 extends WebMvcConfigurerAdapter {}
4、前端 - Password Flow
我们现在来看看一个简单的前端 AngularJS 客户端实现。
我们将在这里使用 OAuth2 Password flow - 这就是为什么这只是一个示例,而不是一个可用于生产的应用。您会注意到,客户端凭据被暴露在前端 - 这也是我们将来在以后的文章中要讨论的。
我们从两个简单的页面开始 - “index” 和 “login”;一旦用户提供凭据,前端 JS 客户端将使用它们从授权服务器获取的一个 Access Token。
4.1、登录页面
以下是一个简单的登录页面:
<body ng-app="myApp" ng-controller="mainCtrl">
<h1>Login</h1>
<label>Username</label><input ng-model="data.username"/>
<label>Password</label><input type="password" ng-model="data.password"/>
<a href="#" ng-click="login()">Login</a>
</body>
4.2、获取 Access Token
现在,让我们来看看如何获取 Access Token:
var app = angular.module('myApp', ["ngResource","ngRoute","ngCookies"]);
app.controller('mainCtrl',
function($scope, $resource, $http, $httpParamSerializer, $cookies) {
$scope.data = {
grant_type:"password",
username: "",
password: "",
client_id: "clientIdPassword"
};
$scope.encoded = btoa("clientIdPassword:secret");
$scope.login = function() {
var req = {
method: 'POST',
url: "http://localhost:8080/spring-security-oauth-server/oauth/token",
headers: {
"Authorization": "Basic " + $scope.encoded,
"Content-type": "application/x-www-form-urlencoded; charset=utf-8"
},
data: $httpParamSerializer($scope.data)
}
$http(req).then(function(data){
$http.defaults.headers.common.Authorization =
'Bearer ' + data.data.access_token;
$cookies.put("access_token", data.data.access_token);
window.location.href="index";
});
}
});
注意:
- 我们发送一个 POST 到
/oauth/token
端点以获取一个 Access Token - 我们使用客户端凭据和 Basic Auth 验证来访问此端点
- 之后我们发送用户凭证以及客户端 id 和授权类型参数的 URL 编码
- 获取 Access Token 后,我们将其存储在一个 cookie 中
cookie 存储在这里特别重要,因为我们只使用 cookie 作为存储目标,而不是直接发动身份验证过程。这有助于防止跨站点请求伪造(CSRF)类型的攻击和漏洞。
4.3、索引(index)页面
以下是一个简单的索引页面:
<body ng-app="myApp" ng-controller="mainCtrl">
<h1>Foo Details</h1>
<label>ID</label><span>{{foo.id}}</span>
<label>Name</label><span>{{foo.name}}</span>
<a href="#" ng-click="getFoo()">New Foo</a>
</body>
4.4、授权客户端请求
由于我们需要 Access Token 为对资源的请求进行授权,我们将追加一个带有 Access Token 的简单授权头:
var isLoginPage = window.location.href.indexOf("login") != -1;
if(isLoginPage){
if($cookies.get("access_token")){
window.location.href = "index";
}
} else{
if($cookies.get("access_token")){
$http.defaults.headers.common.Authorization =
'Bearer ' + $cookies.get("access_token");
} else{
window.location.href = "login";
}
}
没有没有找到 cookie,用户将跳转到登录页面。
5.前端 - 隐式授权(Implicit Grant)
现在,我们来看看使用了隐式授权的客户端应用。
我们的客户端应用是一个独立的模块,尝试使用隐式授权流程从授权服务器获取 Access Token 后访问资源服务器。
5.1、Maven 配置
这里是 pom.xml
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
注意:我们不需要 OAuth 依赖,因为我们将使用 AngularJS 的 OAuth-ng 指令来处理,其可以使用隐式授权流程连接到 OAuth2 服务器。
5.2、Web 配置
以下是我们的一个简单的 web 配置:
@Configuration
@EnableWebMvc
public class UiWebConfig extends WebMvcConfigurerAdapter {
@Bean
public static PropertySourcesPlaceholderConfigurer
propertySourcesPlaceholderConfigurer() {
return new PropertySourcesPlaceholderConfigurer();
}
@Override
public void configureDefaultServletHandling(
DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
super.addViewControllers(registry);
registry.addViewController("/index");
registry.addViewController("/oauthTemplate");
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**")
.addResourceLocations("/resources/");
}
}
5.3、主页
接下来,这里是我们的主页:
OAuth-ng 指令需要:
site
:授权服务器 URLclient-id
:应用程序客户端 idredirect-uri
:从授权服务器获 Access Token 后,要重定向到的 URIscope
:从授权服务器请求的权限template
:渲染自定义 HTML 模板
<body ng-app="myApp" ng-controller="mainCtrl">
<oauth
site="http://localhost:8080/spring-security-oauth-server"
client-id="clientId"
redirect-uri="http://localhost:8080/spring-security-oauth-ui-implicit/index"
scope="read"
template="oauthTemplate">
</oauth>
<h1>Foo Details</h1>
<label >ID</label><span>{{foo.id}}</span>
<label>Name</label><span>{{foo.name}}</span>
</div>
<a href="#" ng-click="getFoo()">New Foo</a>
<script
src="http://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular.min.js">
</script>
<script
src="http://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular-resource.min.js">
</script>
<script
src="http://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular-route.min.js">
</script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/ngStorage/0.3.9/ngStorage.min.js">
</script>
<script th:src="@{/resources/oauth-ng.js}"></script>
</body>
请注意我们如何使用 OAuth-ng 指令来获取 Access Token。
另外,以下是一个简单的 oauthTemplate.html
:
<div>
<a href="#" ng-show="show=='logged-out'" ng-click="login()">Login</a>
<a href="#" ng-show="show=='denied'" ng-click="login()">Access denied. Try again.</a>
</div>
5.4、AngularJS App
这是我们的 AngularJS app:
var app = angular.module('myApp', ["ngResource","ngRoute","oauth"]);
app.config(function($locationProvider) {
$locationProvider.html5Mode({
enabled: true,
requireBase: false
}).hashPrefix('!');
});
app.controller('mainCtrl', function($scope,$resource,$http) {
$scope.$on('oauth:login', function(event, token) {
$http.defaults.headers.common.Authorization= 'Bearer ' + token.access_token;
});
$scope.foo = {id:0 , name:"sample foo"};
$scope.foos = $resource(
"http://localhost:8080/spring-security-oauth-resource/foos/:fooId",
{fooId:'@id'});
$scope.getFoo = function(){
$scope.foo = $scope.foos.get({fooId:$scope.foo.id});
}
});
请注意,在获取 Access Token 后,如果在资源服务器中使用到了受保护的资源,我们将通过 Authorization
头来使用它。
结论
我们已经学习了如何使用 OAuth2 授权我们的应用程序。
本教程的完整实现可以在此 GitHub 项目中找到 - 这是一个基于 Eclipse 的项目,所以应该很容易导入运行。