12.3 OAuth 2

OAuth (Open Authorization,开放授权)是一个关于授权的开放网络标准,它为用户资源的授权定义了一个安全、开放及简单的标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而不需要将用户名和密码提供给第三方应用。

OAuth 允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站在特定的时段内访问特定的资源。这样,OAuth 让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。

目前 OAuth 最新版本是 2.0,在全世界得到了广泛地应用,在国内主流的 QQ,微信等第三方授权登录方式都是基于 OAuth 2 实现的。

OAuth 2 在“客户端”与“服务提供商”之间,设置了一个授权层(authorization layer)。“客户端”不能直接登录“服务提供商”,只能登录授权层,以此将用户与客户端分离。“客户端”登录需要 OAuth 提供的令牌,否则将提示认证失败而导致客户端无法访问服务。

为了方便理解,可以想象 OAuth2.0 就是在用户资源和第三方应用之间的一个中间层(授权层 authorization layer),它把资源和第三方应用隔开,使得第三方应用无法直接访问资源,从而起到保护资源的作用。

本小节我们学习 Spring Boot 项目中是如何配置使用 OAuth 2 服务器端,并让 OAuth 2 整合 Spring Security 来保护我们的 REST 服务接口。

12.3.1 基本概念

OAuth 2 标准中定义了以下几种角色:

  • 资源所有者(Resource Owner):代表授权客户端访问本身资源信息的用户(User),如微信用户其具有头像、照片、朋友圈等资源。
  • 资源服务器(Resource Server):资源服务器托管了受保护的用户账号信息,可以向第三方提供资源,如头像、照片、朋友圈等资源。
  • 授权服务器(Authorization Server):授权服务器用来验证用户身份然后为客户端派发资源访问令牌。资源服务器接受此令牌并验证您的身份。
  • 客户端(Client):代表意图访问受限资源的第三方应用。在访问实现之前,它必须先经过用户者授权,并且获得的授权凭证将进一步由授权服务器进行验证。

为了便于理解,现在假设有一个名叫“脸盆网”的社交网站,用户在首次登陆时会要求导入用户在 Facebook 的好友列表,以便于快速建立社交关系。具体的授权流程如下:

  1. 用户登陆脸盆网,脸盆网试图访问 Facebook 上的好友列表;
  2. 脸盆网发现该资源是 Facebook 的受保护资源,于是返回 302 将用户重定向至 Facebook 登陆页面;
  3. 用户完成认证后,Facebook 提示用户是否将好友列表资源授权给脸盆网使用(如果本来就是已登陆 Facebook 状态则直接显示是否授权的页面);
  4. 用户确认后,脸盆网通过授权码模式获取了 Facebook 颁发的 access_token;
  5. 脸盆网携带该 token 访问 Facebook 的获取用户接口https://api.facebook.com/user,facebook 验证 token 无误后返回了与该token绑定 的用户信息;
  6. 脸盆网的 Spring Security 安全框架根据返回的用户信息构造出了 principal 对象并保存在 session 中;
  7. 脸盆网再次携带该 token 访问好友列表,Facebook 根据该token对应的用户 返回该用户的好友列表信息;
  8. 该用户后续在脸盆网发起的访问 Facebook 上的资源,只要在 token 有效期及权限范围内均可以正常获取(比如想访问一下保存在 Facebook 里的相册)。

不难看出,这个假设的场景中,脸盆网就是第三方应用(客户端 Client),而 Facebook 既充当了认证服务器,又充当了资源服务器,资源所有者就是用户(用户持有的资源,就是用户再 Facebook 上的好友列表)。

OAuht 2 解决问题的关键在于使用授权服务器 提供一个访问凭据 给到第三方应用 ,让第三方应用 可以在不知道资源所有者在资源服务器上的账号和密码的情况下,能获取到资源所有者在资源服务器上的受保护资源(这里的受保护资源就是用户在 Facebook 上的好友列表 )。

  1. 客户端向用户(资源所有者)请求其授权;
  2. 用户单击客户端所呈现的服务授权页面上的同意授权按钮后,客户端收到用户的授权许可,这个授权许可是一个代表资源所有者授权的凭据;
  3. 客户端向授权服务器请求访问令牌,并出示授权许可;
  4. 授权服务器对客户端身份进行认证,并校验授权许可,如果都是有效的,则发放访问令牌;
  5. 客户端向资源服务器请求受保护的资源,并出示访问令牌;
  6. 资源服务器校验访问令牌,如果令牌有效,则提供服务。

OAuth 2.0 提供了4种授权模式:

  • 授权码 Authorization Code:是最常使用的一种授权许可类型,它适用于第三方应用类型为server-side型应用的场景。Authorization Code授权流程基于重定向跳转,客户端必须能够与User-agent(即用户的 Web 浏览器)交互并接收通过User-agent路由发送的实际authorization code值。适合普通服务器端应用使用;
  • 简化(隐式)许可 Implicit:和Authorization Code基于重定向跳转的授权流程十分相似,但它适用于移动应用和 Web App,这些应用与普通服务器端应用相比有个特点,即client secret不能有效保存和信任;
  • 资源所有者密码凭证 Resource Owner Password Credentials:适用于用户与客户端具有信任关系的情况,例如设备操作系统或同一组织的内部及外部应用。用户与应用交互表现形式往往体现为客户端能够直接获取用户凭据(用户名和密码,通常使用交互表单);
  • 客户端凭证 Client Credentials:是最简单的一种授权流程。客户端可以直接使用它的client credentials或其他有效认证信息向授权服务器发起获取access token的请求。

上面这 4 种授权模式,各有各的适用场景,我们在实际项目种需要根据项目类型选择使用。

  1. 授权码模式:第三方 Web 服务器端应用与第三方原生 App;
  2. 简化模式:第三方单页面应用;
  3. 密码模式:第一方单页应用与第一方原生 App;
  4. 客户端模式:没有用户参与的,完全信任的服务器端服务。

12.3.2 集成示例

Spring Boot 对 OAuth 2.0 提供了良好的集成,官方文档提供了简要的说明,官方示例提供了详细的指导。

12.3.2.1 官方示例

下面我们以官方示例来介绍如何在 Spring Boot 中使用 OAuth 2。到https://github.com/spring-guides/tut-spring-boot-oauth2 clone 版本库到本地。

git clone https://github.com/spring-guides/tut-spring-boot-oauth2.git

这些示例在后端使用 Spring Boot 和 Spring Security 的单页应用程序,在前端使用 jQuery 和 bootstrap。

所有示例都是使用 Spring Boot 中的原生 OAuth 2.0 支持来实现的。

官方示例版本库里面包含的 5 个示例,后一个示例都比前一个示例添加了新功能。

  • simple:一个非常基本的静态应用程序,只有一个主页,通过 Spring Boot 的 OAuth 2.0 配置属性无条件登录(如果您访问主页,您将被自动重定向到GitHub)。
  • click:添加用户必须单击才能登录的显式链接。
  • logout:为经过身份验证的用户添加注销链接。
  • two-providers:添加第二个登录提供程序,以便用户可以在主页上选择要使用哪一个。
  • custom-error:为未经身份验证的用户添加错误消息,并基于 GitHub 的 API 添加自定义身份验证。
12.3.2.1.1 simple 示例项目

我们先用 simple 项目来讲解如何在 Spring Boot 应用来介绍如何将 GitHub 作为一个认证服务器,完成单点登录(Single Sign On),然后再介绍 logout 示例项目。在 GitHub 上注册一个新的 OAuth 应用。打开https://github.com/settings/developers, 注册一个新的应用:

OAuth 新应用注册页面,填写对应的信息,需要注意两个地方:

其他信息按照需要填写。

填写完成后,单击“Register application”按钮,完成注册后,GitHub 生成两个ID:Client ID 和 Client Secret,保存这两个字符串值,供后续在 Spring Boot 中配置 OAuth 认证用。

打开 simple 应用的 application.yml 配置文件,使用上面生成的两个 ID 值填充 client-id 和 client-secret 两个属性。

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: your-github-client-id
            client-secret: your-github-client-secret

启动应用,打开浏览器,输入http://localhost:8080/, 如果你没有登录过 GitHub 则会跳转到 GitHub 的登录页。

如果你本机浏览器登录过 GitHub 则直接授权当前 GitHub 用户(下图中的用户就是 gyzhang)登录应用。

完成授权后,返回系统的首页。

主页 index.html 文件中引用了 jQuery 和 bootstrap 脚本和样式。

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>Demo</title>
    <meta name="description" content=""/>
    <meta name="viewport" content="width=device-width"/>
    <base href="/"/>
    <link rel="stylesheet" type="text/css" href="/webjars/bootstrap/css/bootstrap.min.css"/>
    <script type="text/javascript" src="/webjars/jquery/jquery.min.js"></script>
    <script type="text/javascript" src="/webjars/bootstrap/js/bootstrap.min.js"></script>
</head>
<body>
	<h1>Demo</h1>
	<div class="container"></div>
</body>
</html>

前端页面能够引用这些 js 和 CSS 是因为项目 pom 文件中的如下依赖:

<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>2.1.1</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>bootstrap</artifactId>
    <version>3.2.0</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>webjars-locator-core</artifactId>
</dependency>

项目中几乎没有自己写的代码,是因为使用了spring-boot-starter-oauth2-client这个启动器依赖。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

这里,使用 Spring Boot 使用的是“授权码”认证方式。

     +----------+
     | Resource |
     |   Owner  |
     |          |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier      +---------------+
     |         -+----(A)-- & Redirection URI ---->|               |
     |  User-   |                                 | Authorization |
     |  Agent  -+----(B)-- User authenticates --->|     Server    |
     |          |                                 |               |
     |         -+----(C)-- Authorization Code ---<|               |
     +-|----|---+                                 +---------------+
       |    |                                         ^      v
      (A)  (C)                                        |      |
       |    |                                         |      |
       ^    v                                         |      |
     +---------+                                      |      |
     |         |>---(D)-- Authorization Code ---------'      |
     |  Client |          & Redirection URI                  |
     |         |                                             |
     |         |<---(E)----- Access Token -------------------'
     +---------+       (w/ Optional Refresh Token)

上面 simple 示例(运行中,如截图所示)中的4个 OAuth 2.0 角色如下:

  1. 资源所有者(Resource Owner):GitHub 用户 gyzhang;
  2. 资源服务器(Resource Server):GitHub;
  3. 授权服务器(Authorization Server):GitHub;
  4. 客户端(Client):simple 项目。

虽然在 simple 项目中,并没有在页面上引用 GitHub 的资源,但是下面的 logout 项目会演示如何引用资源服务器上的用户资源。

12.3.2.1.2 logout 示例项目

在 simple 的基础上,进一步增加了登录和注销链接和用户全名等信息的 logout 项目,更加清晰的描述了如何使用资源服务器的资源。

在 SocialApplication 启动类上添加 @RestController 注解,并提供 user 方法,向页面返回登录 GitHub 用户的全名。

@SpringBootApplication
@RestController
public class SocialApplication extends WebSecurityConfigurerAdapter {
	@RequestMapping("/user")
	public Map<String, Object> user(@AuthenticationPrincipal OAuth2User principal) {
		return Collections.singletonMap("name", principal.getAttribute("name"));
	}
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		// @formatter:off
		http
			.authorizeRequests(a -> a
				.antMatchers("/", "/error", "/webjars/**").permitAll()
				.anyRequest().authenticated()
			)
			.exceptionHandling(e -> e
				.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
			)
			.csrf(c -> c
				.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
			)
			.logout(l -> l
				.logoutSuccessUrl("/").permitAll()
			)
			.oauth2Login();
		// @formatter:on
	}
	public static void main(String[] args) {
		SpringApplication.run(SocialApplication.class, args);
	}
}

当然,上述方法中OAuth2User principal参数封装了用户(GitHub 用户 gyzhang)在 GitHub 上的资源,比如说用户的全名“Kevin Zhang”和自我介绍“喜欢凑热闹的高龄程序猿,攻城狮。”等信息,可供第三方应用客户端取用。下图是用 GitHub 用户 gyzhang 登录后的信息:

启动类上的 configure 方法,提供了 Spring Security 相关配置。

页面 index.html 中添加了登录和注销的,并在登录后显示用户名称(Kevin Zhang,这就是资源服务器提供的用户资源)。

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Demo</title>
<meta name="description" content="" />
<meta name="viewport" content="width=device-width" />
<base href="/" />
<link rel="stylesheet" type="text/css"
    href="/webjars/bootstrap/css/bootstrap.min.css" />
<script type="text/javascript" src="/webjars/jquery/jquery.min.js"></script>
<script type="text/javascript"
    src="/webjars/bootstrap/js/bootstrap.min.js"></script>
</head>
<body>
    <h1>Login</h1>
    <div class="container unauthenticated">
        With GitHub: <a href="/oauth2/authorization/github">click here</a>
    </div>
    <div class="container authenticated" style="display: none">
        Logged in as: <span id="user"></span>
        <div>
            <button onClick="logout()" class="btn btn-primary">Logout</button>
        </div>
    </div>
    <script type="text/javascript"
    src="/webjars/js-cookie/js.cookie.js"></script>
    <script type="text/javascript">
          $
              .ajaxSetup({
                beforeSend : function(xhr, settings) {
                  if (settings.type == 'POST' || settings.type == 'PUT'
                      || settings.type == 'DELETE') {
                    if (!(/^http:.*/.test(settings.url) || /^https:.*/
                        .test(settings.url))) {
                      // Only send the token to relative URLs i.e. locally.
                      xhr.setRequestHeader("X-XSRF-TOKEN",
                          Cookies.get('XSRF-TOKEN'));
                    }
                  }
                }
              });
          $.get("/user", function(data) {
            $("#user").html(data.name);
            $(".unauthenticated").hide();
            $(".authenticated").show();
          });
          var logout = function() {
            $.post("/logout", function() {
              $("#user").html('');
              $(".unauthenticated").show();
              $(".authenticated").hide();
            })
            return true;
          }
        </script>
</body>
</html>

运行应用,访问http://localhost:8080/, 效果如下:

单击“click here”链接,通过 GitHub 认证返回后的页面上使用用户的资源,显示用户名称(Kevin Zhang)。

12.3.2.2 用户名密码认证

在这一小节,我们学习如何自己搭建“授权服务器”来以“资源所有者密码凭证 Resource Owner Password Credentials”方式完成 OAuth 2 的认证,并访问受权限管理的资源。

Spring Security OAuth 2 主要包含认证服务器(@EnableAuthorizationServer)和资源服务器( @EnableResourceServer)这两大块的实现:

认证服务器主要包含了四种授权模式的实现和 Token 的生成与存储,我们也可以在认证服务器中自定义获取 Token 的方式;资源服务器主要是在 Spring Security 的过滤器链上加了 OAuth2AuthenticationProcessingFilter 过滤器,即使用 OAuth 2 协议发放令牌认证的方式来保护我们的资源。

创建一个新的 Spring Boot 应用。

选择 Spring Security + Spring Web + Thymeleaf 启动器依赖。

在项目的 pom 文件中,手工添加spring-security-oauth2-autoconfigure依赖。

<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.2.4.RELEASE</version>
</dependency>

为了更好地了解 OAuth 2 的配置过程,我们先将项目配置为一个带授权功能的可以运行的应用,然后再一步一步地添加 OAuth 2 相关功能。

12.3.2.2.1 配置 Spring Security 功能

参照“12.2.4 登录表单/注销配置”小节,创建可访问的页面文件 login.html、index.html 和 hello.html 文件。为登录页面提供 LoginController 控制器:

@Controller
public class LoginController {
	@RequestMapping(value = "/login", method = RequestMethod.POST)
	public String login() {
		return "login";
	}
}

为页面导航提供 PagesController 控制器:

@Controller
public class PagesController {
	@RequestMapping("/index")
	public ModelAndView index(Authentication authentication) {
		ModelAndView mv = new ModelAndView();
		mv.addObject("authInfo", authentication);
		mv.setViewName("/index");
		return mv;
	}
	@RequestMapping(value = "/login", method = RequestMethod.GET)
	public String login() {
		return "/login";
	}
	@RequestMapping("/admin/hello")
	public ModelAndView adminHello() {
		ModelAndView mv = new ModelAndView();
		mv.addObject("user", "admin");
		mv.setViewName("/admin/hello");
		return mv;
	}
	@RequestMapping("/user/hello")
	public ModelAndView userHello() {
		ModelAndView mv = new ModelAndView();
		mv.addObject("user", "kevin");
		mv.setViewName("/user/hello");
		return mv;
	}
	@RequestMapping("/other/hello")
	public ModelAndView otherHello() {
		ModelAndView mv = new ModelAndView();
		mv.addObject("user", "other");
		mv.setViewName("/other/hello");
		return mv;
	}
}

创建登录用户实体类:

public class SysUser implements Serializable {
	private static final long serialVersionUID = -7518056168386655741L;
	private String username;
    private String password;
    private boolean accountNonExpired = true;
    private boolean accountNonLocked= true;
    private boolean credentialsNonExpired= true;
    private boolean enabled= true;
  
...getter & setter

创建用户服务类 UserDetailService,实现动态获取用户。这里的逻辑是,可以输入任何用户名,密码都是 123456

在实际工作中,通过这个类中去数据库表中获取用户及权限,并对用户做更多的控制(或修饰)。

需要注意的是,这个的角色 ROLE 要记得添加前缀,例如 ROLE_ADMIN 后面就用做 ADMIN 角色。

@Service
public class UserDetailService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = new SysUser();
        user.setUsername(username);
        user.setPassword(this.passwordEncoder.encode("123456"));
        return new User(username, user.getPassword(), user.isEnabled(),
                user.isAccountNonExpired(), user.isCredentialsNonExpired(),
                user.isAccountNonLocked(), AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_ADMIN,ROLE_USER,ROLE_OTHER"));
    }
}

创建 WebSecurityConfig 类配置

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
	PasswordEncoder passwordEncoder;
	@Autowired
	private UserDetailsService userDetailsService;
	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	@Bean
	@Override
	public AuthenticationManager authenticationManagerBean() throws Exception {
		return super.authenticationManagerBean();
	}
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
	}
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.csrf().disable()
		.formLogin()
		.loginPage("/login")
		.failureUrl("/login?error")
		.defaultSuccessUrl("/index")
		.permitAll()
		.and().logout()
			.logoutUrl("/logout")
			.logoutSuccessUrl("/login?logout")
			.clearAuthentication(true)
			.invalidateHttpSession(true)
			.permitAll()
		.and().authorizeRequests()
			.antMatchers("/oauth/**").permitAll()
			.antMatchers("/admin/**").hasRole("ADMIN")
			.antMatchers("/user/**").hasRole("USER")
			.antMatchers("/other/**").hasRole("OTHER")
			.anyRequest()
			.authenticated();
	}
}

运行项目,应该和“12.2.4 登录表单/注销配置”小节效果基本一致。

12.3.2.2.2 配置授权服务器

创建 AuthorizationServerConfig 授权服务器配置类。

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
	@Autowired
	private AuthenticationManager authenticationManager;
	@Autowired
	private UserDetailService userDetailService;
	@Autowired
	private PasswordEncoder passwordEncoder;
	@Autowired
	TokenStore tokenStore;
	// 使用 jwt
	@Bean
	public TokenStore tokenStore() {
		return new JwtTokenStore(jwtTokenEnhancer());
	}
	// 配置 jwt 生成 策略
	@Bean
	public JwtAccessTokenConverter jwtTokenEnhancer() {
		JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
		converter.setSigningKey("123456"); // 密钥
		return converter;
	}
	// 配置客户端信息
	@Override
	public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
		clients.inMemory().withClient("client_123456")// 这里直接把配置信息保存在内存中
				.secret(passwordEncoder.encode("secret_123456"))// 这里必须使用加密
				.authorizedGrantTypes("authorization_code", "refresh_token", "password", "implicit").scopes("all");
	}
	// 配置 Token 的节点 和 Token 服务
	@Override
	public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
		endpoints.tokenStore(tokenStore()).authenticationManager(authenticationManager)
				.userDetailsService(userDetailService).accessTokenConverter(jwtTokenEnhancer());
	}
	// 配置 Token 节点的安全策略
	@Override
	public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
		security
			.tokenKeyAccess("permitAll()")
			.checkTokenAccess("isAuthenticated()")
			.allowFormAuthenticationForClients();
	}
}
  • public void configure(ClientDetailsServiceConfigurer clients) throws Exception方法中配置 Client ID 和 Secret ID,使得客户端可以与授权服务器正常通讯;
  • public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception方法中配置用户认证并产生 token,完成用户身份认证;
  • public void configure(AuthorizationServerSecurityConfigurer security) throws Exception方法中,配置认证服务器的安全策略。

完成这一步,就可以向认证服务器请求 token 了,比如,在 Postman 中以 Post 方式发起对http://localhost:8080/oauth/token?username=kevin&password=123456&grant_type=password&client_id=client_123456&client_secret=secret_123456 的请求,可以获取到 token 令牌。

12.3.2.2.3 创建资源服务器

创建 ResourceServerConfig 资源服务器配置类。

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
	@Override
	public void configure(HttpSecurity http) throws Exception {
		http.csrf().disable()
		.formLogin()
		.loginPage("/login")
		.failureUrl("/login?error")
		.defaultSuccessUrl("/index")
		.permitAll()
		.and().logout()
			.logoutUrl("/logout")
			.logoutSuccessUrl("/login?logout")
			.clearAuthentication(true)
			.invalidateHttpSession(true)
			.permitAll()
		.and().authorizeRequests()
			.antMatchers("/oauth/**").permitAll()
			.antMatchers("/admin/**").hasRole("ADMIN")
			.antMatchers("/user/**").hasRole("USER")
			.antMatchers("/other/**").hasRole("OTHER")
			.anyRequest()
			.authenticated();
	}
}

在资源服务器中的 http 安全配置,默认情况下会被 WebSecurityConfigurerAdapter 的配置覆盖。

12.3.2.2.4 测试验证

启动项目,打开 Postman,在其中输入如下 url,以 Post 方式提交

http://localhost:8080/oauth/token?username=kevin&password=123456&grant_type=password&client_id=client_123456&client_secret=secret_123456

可以正确地看到授权服务器返回的 token,如下图:

使用 Post 或 Get 方式访问 http://localhost:8080/admin/hello 地址,注意为该请求携带上一步获取到的“access_token”,认证类型需要正确选定。

发送请求后,可以看到服务器端正确返回受 ADMIN 角色限制的 Admin Page 页面。

我们访问 index 首页,可以看到其输出的认证方式为 OAuth2Authentication,并且拥有 ROLE_ADMIN, ROLE_USER, ROLE_OTHER 这三种角色。

12.3.2.3 资源服务器分离

在很多情况下,我们是将认证服务器和资源服务器分离开部署的。

这个示例里面,我们将把上个项目拆分成两个新的应用:

  • spring-boot-oauth2-auth:认证服务器,
  • spring-boot-oauth2-res:资源服务器。
12.3.2.3.1 认证服务器

在认证服务器 spring-boot-oauth2-auth 项目 application.properties 中配置 “server.port=8000” 服务器端口。

删除多余的类及页面文件,只保留如下五个类:

  • SysUser:存放用户名密码等信息;
  • UserDetailService:获取用户信息;
  • WebSecurityConfig:Spring Security配置类;
  • AuthorizationServerConfig:认证服务器配置;
  • SpringBootOauth2AuthApplication:Spring Boot 应用启动类。
12.3.2.3.2 资源服务器

在资源服务器 spring-boot-oauth2-res 项目中删除认证服务相关文件,保留资源服务相关文件:保留如下三个类:

  • ResourceServerConfig:资源服务器配置;
  • PagesController:页面控制器;
  • SpringBootOauth2ResApplication:Spring Boot 应用启动类。

资源服务器和认证服务器是分离开的,所以,资源服务器会使用获取到的 token 到认证服务器上校验 token 是否正确,代码如下:

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
	@Bean
	public RemoteTokenServices tokenServices() {
		final RemoteTokenServices tokenService = new RemoteTokenServices();
		tokenService.setCheckTokenEndpointUrl("http://localhost:8000/oauth/check_token");
		tokenService.setClientId("client_123456");
		tokenService.setClientSecret("secret_123456");
		return tokenService;
	}
	@Override
	public void configure(HttpSecurity http) throws Exception {
		http.csrf().disable()
		.formLogin()
		.loginPage("/login")
		.failureUrl("/login?error")
		.defaultSuccessUrl("/index")
		.permitAll()
		.and().logout()
			.logoutUrl("/logout")
			.logoutSuccessUrl("/login?logout")
			.clearAuthentication(true)
			.invalidateHttpSession(true)
			.permitAll()
		.and().authorizeRequests()
			.antMatchers("/oauth/**").permitAll()
			.antMatchers("/admin/**").hasRole("ADMIN")
			.antMatchers("/user/**").hasRole("USER")
			.antMatchers("/other/**").hasRole("OTHER")
			.anyRequest()
			.authenticated();
	}
}

方法 public RemoteTokenServices tokenServices() 中设定了 RemoteTokenServices 需要持有 ClientId 和 ClientSecret 去http://localhost:8000/oauth/check_token 认证服务器上校验 token 是否合法。其他内容和前面的示例保持不变。

12.3.2.3.3 测试验证

启动认证服务器和资源服务器,认证服务器在 8000 端口提供服务,资源服务器在 8080 端口提供服务。

我们先去认证服务器上获取 token,在 Postman 中以 Post 方式访问 http://localhost:8000/oauth/token?username=kevin&password=123456&grant_type=password&client_id=client_123456&client_secret=secret_123456 这个地址。

可以看到认证服务器正确完成了认证,并返回 token 等信息,复制获得的“access_token”令牌。

使用在认证服务器上获取的 token 访问资源服务器上的 http://localhost:8080/index 这个网址,可以正常访问。

也能正常访问 http://localhost:8080/admin/hello ADMIN 角色的页面资源。

12.3.2.4 授权码认证

如前面介绍的,授权码模式是最安全的一种模式。

  • 用户访问客户端,后者将前者导向认证服务器,认证服务器返回认证页面(账号密码或者其他认证方式)
  • 用户选择是否给予客户端授权。
  • 假设用户给予授权,认证服务器将用户导向客户端事先指定的"重定向 URI"(redirection URI),同时附上一个授权码。
  • 客户端收到授权码,附上早先的"重定向 URI",向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。
  • 认证服务器核对了授权码和重定向 URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。

下面,我们对上小节的认证服务器进行改造,来学习授权码认证模式。

12.3.2.4.1 访问百度

对授权服务器配置类 AuthorizationServerConfig 进行修改,在 public void configure(ClientDetailsServiceConfigurer clients) throws Exception 方法中添加重定向 url 到百度。

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory().withClient("client_123456")// 这里直接把配置信息保存在内存中
        .secret(passwordEncoder.encode("secret_123456"))// 这里必须使用加密
        .authorizedGrantTypes("authorization_code", "refresh_token", "password", "implicit").scopes("all")
        .redirectUris("http://www.baidu.com")
        .accessTokenValiditySeconds(30*60)
        .refreshTokenValiditySeconds(60*60);
}

然后运行授权服务器项目。

打开浏览器,访问 http://localhost:8000/oauth/authorize?client_id=client_123456&response_type=code&scope=all&redirect_uri=http://www.baidu.com 这个地址,输入任何用户名,密码固定为“123456”,登录。

授权服务器完成认证后,在授权页面,点击“授权”按钮。

然后,就正确跳转到跳转到百度。

我们可以看到地址栏的 url 地址 https://www.baidu.com/?code=7wH47G 携带了 code=7wH47G 这样一个 code 字符串。

可以通过 Postman 使用 http://localhost:8000/oauth/token?client_id=client_123456&client_secret=secret_123456&grant_type=authorization_code&redirect_uri=http://www.baidu.com&code=7wH47G 地址,以 Post 方式获取 Token 令牌。

12.3.2.4.2 访问资源服务器

对授权服务器配置类 AuthorizationServerConfig 进行修改,在 public void configure(ClientDetailsServiceConfigurer clients) throws Exception 方法中添加重定向资源服务器上的 “http://localhost:8080/callback” 这个 url 地址。

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory().withClient("client_123456")// 这里直接把配置信息保存在内存中
        .secret(passwordEncoder.encode("secret_123456"))// 这里必须使用加密
        .authorizedGrantTypes("authorization_code", "refresh_token", "password", "implicit").scopes("all")
        .redirectUris("http://localhost:8080/callback")
        .accessTokenValiditySeconds(30*60)
        .refreshTokenValiditySeconds(60*60);
}

对资源服务器配置类 ResourceServerConfig 进行修改,在 public void configure(HttpSecurity http) throws Exception 方法中添加无权限访问 “/callback” 地址。

@Override
public void configure(HttpSecurity http) throws Exception {
    http.csrf().disable()
        .authorizeRequests()
        .antMatchers("/callback").permitAll() //允许无权限访问 callback
        .antMatchers("/admin/**").hasRole("ADMIN")
        .antMatchers("/user/**").hasRole("USER")
        .antMatchers("/other/**").hasRole("OTHER")
        .anyRequest()
        .authenticated();
}

新添加 CallbackController 控制器,对 “/callback” 提供处理逻辑:使用 code 去授权服务器上获取 “access_token” 并转到 index 首页。

// 用code到授权服务器获取access_token,然后访问index主页
@RequestMapping(value = "/callback", method = RequestMethod.GET)
public ResponseEntity<String> callback(@RequestParam("code") String code) throws JsonProcessingException, IOException {
    ResponseEntity<String> response = null;
    System.out.println("Authorization Code: " + code);
    RestTemplate restTemplate = new RestTemplate();
    String access_token_url = "http://localhost:8000/oauth/token";
    access_token_url += "?client_id=client_123456&code=" + code;
    access_token_url += "&client_secret=secret_123456";
    access_token_url += "&grant_type=authorization_code";
    access_token_url += "&redirect_uri=http://localhost:8080/callback";
    System.out.println("access_token_url: " + access_token_url);
    response = restTemplate.exchange(access_token_url, HttpMethod.POST, null, String.class);
    ObjectMapper mapper = new ObjectMapper();
    JsonNode node = mapper.readTree(response.getBody());
    String token = node.path("access_token").asText();
    System.out.println("access_token: " + token);
    String url = "http://localhost:8080/index";
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "Bearer " + token);
    HttpEntity<String> entity = new HttpEntity<>(headers);
    ResponseEntity<String> result = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
    return result;
}

启动授权服务器和资源服务器,通过浏览器访问 http://localhost:8000/oauth/authorize?client_id=client_123456&response_type=code&scope=all&redirect_uri=http://localhost:8080/callback 在授权服务器上登录完成授权。

授权服务器完成授权后重定向到 http://localhost:8080/callback?code=RxLewi, 后台 CallbackController 拿到 “RxLewi” 这个 code 到授权服务器上请求 access_token 令牌,然后 get 资源服务器的 index 首页。效果如下:

当然,callback 这段代码,在实际项目中一般会做成公用的过滤器。

下一节:CAS(Central Authentication Service) 是 Yale 大学发起的一个开源项目,是单点登录的一种实现方式,在全球有着广泛的使用,也和众多的开源软件做了集成。