文章目录
关于 SpringSecurity OAuth2 的 4 种模式的简要介绍性文章:
SpringSecurtiy OAuth2 (2) Authorization Code Grant - 授权码模式
SpringSecurtiy OAuth2 (3) Implicit Grant - 隐式授权模式
SpringSecurtiy OAuth2 (4) Resource Owner Password Grant - 密码模式
SpringSecurtiy OAuth2 (5) Client Credentials Grant - 客户端模式
更多细节和底层原理, 稍后会有专门的文章介绍.
关于
授权码模式 (Authorization Code Grant), 指的是第三方应用先申请一个授权码, 再用授权码换取访问令牌, 随后再用令牌访问资源服务器的资源. 适用于那些有后端的 Web 应用, 授权码由授权服务器返回给前端, 而用授权码换取的 access_token 储存在后端. 第三方应用用 access_token 访问资源服务器的操作也可以放在后端, 这种模式的 access_token 不用经过浏览器或者移动端 App, 是直接从我们的后台发送到授权服务器上, 这样就很大程度减少了 access_token 泄漏的风险, 安全性也最高的一种方式.
获取授权码的流程一般是由客户端使用自己的 client-id 和 client-secret 加上 response_type=code 拼接 url 让浏览器跳转完成的. 用户的登陆与授权过程都需要在浏览器中完成.
在授权码模式中有授权服务器和资源服务器, 授权服务器用于颁发令牌, 拿着令牌就可以去资源服务器获取资源, 这两个服务器可以分开也可以合并.
简要流程
☀ 第三方应用首先有一个按钮, 按钮请求己方应用的如下地址: http://localhost:18910/authorization-server/oauth/authorize?client_id=client-id-caplike&client_secret=client-secret-caplike&response_type=code&scope=access
- response_type=code, 表示授权码模式, 要求返回授权码, 将来用这个授权码获取 access-token.
- client_id 表示客户端 id, 也就是应用的 id. 如果想让我们的系统介入微信登陆, 肯定得去微信官方平台注册, 去填入我自己应用的基本信息等等. 弄完之后, 微信会给我一个 APPID, 也就是我这里的 client_id. 所以, 从这里可以看出, 授权服务器在校验的时候会做两件事: 1. 校验客户端的身份; 2. 校验用户身份.
- redirect_uri 表示用户在登陆成功/失败后, 跳转的地址 (成功登陆微信后, 跳转到我们系统的哪个页面). 跳转的时候还会携带上一个授权码参数.
- scope 表示授权范围, 即我们网站拿到令牌后能干嘛 (一般来说就是获取用户非敏感的基本信息)
☀ 用户 点击按钮后, 提示登录, 默认会被己方应用重定向到 /login 端点. 登录后跳转到授权页面: 浏览器 URL 保持在: http://localhost:18910/authorization-server/oauth/authorize?client_id=client-id-caplike&client_secret=client-secret-caplike&response_type=code&scope=access
☀ 用户 同意后, 授权服务器将授权码返回给客户端. 会调用在授权服务器中配置的回调地址: http://localhost:18910/authorization-server/access?code=pDipXD
☀ 第三方应用 再用授权码访问如下地址获取 access-token: http://localhost:18910/authorization-server/oauth/token?code=pDipXD&grant_type=authorization_code&client_id=client-id-caplike&client_secret=client-secret-caplike&scope=access
POST 请求结果:
{
"access_token": "2aa1b587-08fa-4609-88dd-c92f5f640e48",
"token_type": "bearer",
"refresh_token": "9272a053-91a9-418b-ae1a-70a55ca9eaaa",
"expires_in": 43199,
"scope": "access"
}
☀ 第三方应用 即可用这个 access_token 访问己方范围内的资源: http://localhost:18711/authorization-code-resource-server/resource-server/access, Authorization: Bearer <access_token>
客户端 (Client) 的职责
- 去授权服务器注册, 获取唯一标识客户端的 client_id 和 client_secret;
- 提供到授权服务器 /oauth/authorize 的 URL 让用户登陆授权, 获取授权码;
- 不收集用户凭证, 用授权码去授权服务器获取 access-token;
- 用 access-token 访问资源服务器
授权服务器 (Authorization Server) 的职责
- 颁发 access-token
- 确认用户是否授权客户端的行为
- 认证用户 /authorize
- 认证客户端 (通过 authorization-code)
资源服务器 (Resource Server) 的职责
- 解析 access-token
- 访问控制: scope, audience, user account info (id, roles etc.), client info (id, roles etc.)
- 如果 access-token 不正确或是不存在等情况, 返回 403
实现
所有 Spring Security OAuth2 的 Demo 均采用授权服务器和资源服务器分开的形式. 代码结构:
├─authorization-code-authorization-server
│ │ pom.xml
│ │ README.md
│ │
│ └─src
│ └─main
│ ├─java
│ │ └─c
│ │ └─c
│ │ └─d
│ │ └─s
│ │ └─s
│ │ └─o
│ │ └─a
│ │ └─c
│ │ └─authorization
│ │ └─server
│ │ │ AuthorizationCodeAuthorizationServer.java
│ │ │
│ │ ├─configuration
│ │ │ AuthorizationServerConfiguration.java
│ │ │ SecurityConfiguration.java
│ │ │
│ │ ├─domain
│ │ │ └─entity
│ │ │ User.java
│ │ │
│ │ ├─mapper
│ │ │ UserMapper.java
│ │ │
│ │ └─service
│ │ CustomUserDetailsService.java
│ │
│ └─resources
│ application.yml
│
│
└─authorization-code-resource-server
│ pom.xml
│
└─src
└─main
├─java
│ └─c
│ └─c
│ └─d
│ └─s
│ └─s
│ └─o
│ └─a
│ └─c
│ └─resource
│ └─server
│ │ AuthorizationCodeResourceServer.java
│ │
│ ├─configuration
│ │ ResourceServerConfiguration.java
│ │
│ └─controller
│ ResourceController.java
│
└─resources
application.yml
我们先来看授权服务器.
授权服务器 (Authorization Server)
授权服务器的职能是进行必要的第三方应用以及用户的身份认证和权限校验, 最终颁发令牌给客户端 (第三方应用).
AuthorizationServerConfiguration
要实现一个授权服务器, 需要提供一个继承了 AuthorizationServerConfigurerAdapter
的配置类, 并标注上 @EnableAuthorizationServer
注解:
/**
* <b>授权服务器</b> 配置
*
* @author LiKe
* @version 1.0.0
* @date 2020-05-28 15:22
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
private PasswordEncoder passwordEncoder;
/**
* Description: "授权服务器" 安全配置, 实际上就是 /oauth/token 端点
*
* @see AuthorizationServerConfigurer#configure(AuthorizationServerSecurityConfigurer)
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
// ~ 允许客户端表单验证
// 只对 /oauth/token 端点有响应
// 该设定会开启一个在 BasicAuthenticationFilter 前执行的过滤器, 名为 ClientCredentialsTokenEndpointFilter. 这个过滤器
// 会尝试从请求参数中获取 client_id 和 client_secret 以完成认证.
security.allowFormAuthenticationForClients();
security
// For endpoint /oauth/check_token, 用于资源服务访问的令牌解析端点
// 只有内部应用才能访问这个端点
.checkTokenAccess("hasAuthority('INNER_CLIENT')")
;
}
/**
* Description: 能够使用内存或者 JDBC 来配置 <b>客户端详情服务 (ClientDetailsService)</b><br>
* Details: 密码模式默认不启动,
* 除非在 {@link AuthorizationServerConfigurer#configure(AuthorizationServerEndpointsConfigurer)} 中
* 提供了 {@link org.springframework.security.authentication.AuthenticationManager}.
* 需要至少一个客户端或者声明完整的自定义 {@link org.springframework.security.oauth2.provider.ClientDetailsService}
*
* @see AuthorizationServerConfigurer#configure(ClientDetailsServiceConfigurer)
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
.inMemory()
// ~ 配置一个客户端详情 (ClientDetails)
// -----------------------------------------------------------------------------------------------------
.withClient("client-id-caplike").secret(passwordEncoder.encode("client-secret-caplike"))
// 在客户端授权的时候, 可以设置这个客户端可以访问哪些资源服务 (如果没有设置就是对所有资源都有访问权限)
.resourceIds("resource-server").scopes("access")
// ※ 配置 ClientDetails 的 redirect_uri
// 如果配置了一个 redirect_uri, 并且 URL 上没有参数 redirect_uri, 则会直接重定向至这个唯一的地址;
// 如果配置了多个 redirect_uri, 则会用请求地址用的 redirect_uri 与下列可选 uri 比较, 没有匹配值就会抛出异常.
// ref: DefaultRedirectResolver#resolveRedirect
.redirectUris("/authorization-server/access"/*, "/authorization-server/access-a", "/authorization-server/access-b"*/)
// 授权码模式
.authorizedGrantTypes("authorization_code", "refresh_token")
.and()
// ~ 内部客户端 (Resource Server in this case)
// -----------------------------------------------------------------------------------------------------
.withClient("resource-server-id").secret(passwordEncoder.encode("resource-server-secret"))
.authorities("INNER_CLIENT")
;
}
/**
* Description: 授权服务器的非安全特性配置. 如 token 存储, token 自定义. 默认情况不需要做任何改动, 除非是密码模式 (同时需要提供
* {@link org.springframework.security.authentication.AuthenticationManager}).<br>
* Details: 默认情况 tokenStore 为 InMemoryTokenStore (ref AuthorizationServerEndpointsConfigurer#tokenStore)
*
* @see AuthorizationServerConfigurer#configure(AuthorizationServerEndpointsConfigurer)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
super.configure(endpoints);
}
// ~ autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setPasswordEncoder(@Qualifier("bCryptPasswordEncoder") PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
// ~ bean
// -----------------------------------------------------------------------------------------------------------------
@Bean
public PasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
值得一提的是, 我们注册了两个客户端, 一个是模拟的第三方应用 (client_id: client-id-caplike), 另一个是资源服务器本身, 由于授权服务器和资源服务器分开的, 在第三方应用拿到授权服务器颁发的令牌后, 用此令牌访问资源服务器, 而资源服务器此时的职责是解析这个令牌: 这里的做法是请求授权服务器的 /oauth/check_token 端点验证令牌, 如果合法才予以放行. 而资源服务器访问授权服务器的凭证, 就是我们在这里多注册的那个 Client 信息 (并给它授了 INNER_CLIENT 的权限).
并且对于 /oauth/check_token 端点, 也只允许有 INNER_CLIENT 权限的客户端访问.
SecurityConfiguration
由于授权码模式需要用户在前端先行登陆授权, 所以这里我们也需要配置 UserDetailsService
.
/**
* Spring Security 配置<br>
* 授权码模式需要用户登陆授权.
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-02 13:06
*/
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private UserDetailsService userDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.and()
.authorizeRequests().anyRequest().authenticated()
// ~ 不能禁用 Session, 否则在用户登陆后无法重新跳转回 /oauth/authorize
// -----------------------------------------------------------------------------------------------------
// .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// ~ exception handling
// -----------------------------------------------------------------------------------------------------
// .and()
// .exceptionHandling()
// ~ 被 ExceptionTranslationFilter 调用用于异常处理
// .authenticationEntryPoint((request, response, authException) -> {
// final RequestCache requestCache = new HttpSessionRequestCache();
// // http://localhost:18910/authorization-server/oauth/authorize?client_id=client-id-caplike&client_secret=client-secret-caplike&response_type=code&scope=all
// final String redirectUrl = requestCache.getRequest(request, response).getRedirectUrl();
// final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
// System.out.println(":::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::");
// System.out.println(request.getRequestURI());
// if (!StringUtils.equals(request.getRequestURI(), "/authorization-server/login")) {
// redirectStrategy.sendRedirect(request, response, "/login");
// // ResponseUtils.redirectResponse(response, "/authorization-server/login");
// }
// })
// .accessDeniedHandler((request, response, accessDeniedException) -> ResponseUtils.forbiddenResponse(response, accessDeniedException.getMessage()))
;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
// ~ autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setUserDetailsService(@Qualifier("customUserDetailsService") UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
UserDetailsService
和 SpringSecurity 系列文章中一致, 在此不再赘述:
@Service
public class CustomUserDetailsService implements UserDetailsService {
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
final User user = userMapper.get(username);
if (Objects.isNull(user)) {
throw new UsernameNotFoundException("用户不存在!");
}
return new org.springframework.security.core.userdetails.User(
user.getName(),
user.getPassword(),
Collections.singletonList(new SimpleGrantedAuthority(user.getRole()))
);
}
// ~ autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setUserMapper(UserMapper userMapper) {
this.userMapper = userMapper;
}
}
UserMapper & User
@Repository
@Mapper
public interface UserMapper {
@Select("SELECT USERNAME AS NAME, PASSWORD, 'USER' AS ROLE FROM USER WHERE USERNAME = #{username}")
User get(String username);
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {
private String name;
private String password;
private String role;
}
资源服务器 (Resource Server)
资源服务器的职责是解析令牌, 验证令牌 (请求授权服务器) 并提供资源.
同样的, 需要提供继承于 ResourceServerConfigurerAdapter
的配置类, 并标注上 @EnableResourceServer
注解
/**
* <b>资源服务器</b> 配置
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-02 10:42
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
private static final String RESOURCE_ID = "resource-server";
/**
* Description: 为资源服务器配置特定属性, 如 resource-id.<br>
* Details: 查看 {@link ResourceServerSecurityConfigurer} 的源代码可以知道, 默认情况下,
* 已经为 {@link ResourceServerSecurityConfigurer} 注入了 {@link OAuth2WebSecurityExpressionHandler}
*
* @see org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurer#configure(ResourceServerSecurityConfigurer)
*/
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(RESOURCE_ID).stateless(true).tokenServices(remoteTokenServices());
}
/**
* Description: 配置资源的访问规则. 默认情况下, 除了 /oauth/** 之外的所有资源都被保护<br>
* Details: 默认情况下, {@link OAuth2WebSecurityExpressionHandler} 已经被注入, 形如 {@code http.authorizeRequests().expressionHandler(new OAuth2WebSecurityExpressionHandler())}
*
* <pre>
* protected void configure(HttpSecurity http) throws Exception {
* http
* .authorizeRequests()
* .expressionHandler(new OAuth2WebSecurityExpressionHandler())
* .antMatchers("/photos").access("#oauth2.denyOAuthClient() and hasRole('ROLE_USER') or #oauth2.hasScope('read')")
* .antMatchers("/photos/trusted/**").access("#oauth2.denyOAuthClient() and hasRole('ROLE_USER') or #oauth2.hasScope('trust')")
* .antMatchers("/photos/user/**").access("#oauth2.denyOAuthClient() and hasRole('ROLE_USER') or #oauth2.hasScope('trust')")
* .antMatchers("/photos/**").access("#oauth2.denyOAuthClient() and hasRole('ROLE_USER') or #oauth2.hasScope('read')")
* .regexMatchers(HttpMethod.DELETE, "/oauth/users/([^/].*?)/tokens/.*").access("#oauth2.clientHasRole('ROLE_CLIENT') and (hasRole('ROLE_USER') or #oauth2.isClient()) and #oauth2.hasScope('write')")
* .regexMatchers(HttpMethod.GET, "/oauth/users/.*").access("#oauth2.clientHasRole('ROLE_CLIENT') and (hasRole('ROLE_USER') or #oauth2.isClient()) and #oauth2.hasScope('read')")
* .regexMatchers(HttpMethod.GET, "/oauth/clients/.*").access("#oauth2.clientHasRole('ROLE_CLIENT') and #oauth2.isClient() and #oauth2.hasScope('read')")
*
* .and().requestMatchers().antMatchers("/photos/**", "/oauth/users/**", "/oauth/clients/**")
* .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER)
* .and().exceptionHandling().accessDeniedHandler(new OAuth2AccessDeniedHandler())
*
* // CSRF protection is awkward for machine clients
* .and().csrf().requireCsrfProtectionMatcher(new AntPathRequestMatcher("/oauth/**")).disable()
* .apply(new OAuth2ResourceServerConfigurer()).tokenStore(tokenStore).resourceId(SPARK_RESOURCE_ID);
* }
* </pre>
*
* @see org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurer#configure(HttpSecurity)
*/
@Override
public void configure(HttpSecurity http) throws Exception {
http
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests().antMatchers("/resource-server/access").access("#oauth2.hasScope('access')");
}
// ~ bean
// -----------------------------------------------------------------------------------------------------------------
/**
* Description: 查询 /check_token 端点获取 access-token 的内容
*/
private RemoteTokenServices remoteTokenServices() {
final RemoteTokenServices remoteTokenServices = new RemoteTokenServices();
remoteTokenServices.setCheckTokenEndpointUrl("http://localhost:18910/authorization-server/oauth/check_token");
remoteTokenServices.setClientId("resource-server-id");
remoteTokenServices.setClientSecret("resource-server-secret");
return remoteTokenServices;
}
}
提供了 RemoteTokenService
用以供资源服务器访问授权服务器的 /oauth/check_token 端点完成令牌校验.
测试
分别启动授权服务器和资源服务器, 首先访问授权服务器:
http://localhost:18910/authorization-server/oauth/authorize?client_id=client-id-caplike&client_secret=client-secret-caplike&response_type=code&scope=access
浏览器跳转到 /login 让用户登陆, 用户成功登陆后进行授权, 接下来授权服务器会给回调地址返回授权码, URL 形如:
http://localhost:18910/authorization-server/access?code=z8smWT
最后第三方应用用这个授权码换取令牌:
总结
本篇简要介绍了 Spring Security OAuth2 Authorization Code Grant 的实现, 更多细节会在后续文章中详细阐述.