最近接手一个需求,在已有的登录系统下,为第三方平台提供一个登录认证功能。这里涉及的协议是OAuth2,关于该协议的具体内容不是本文讲述的主要内容,具体可以参考如下链接:
Oauth2协议相关:
http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html
https://github.com/jeansfish/RFC6749.zh-cn/blob/master/SUMMARY.md
Spring Security Oauth2框架相关:
https://docs.spring.io/spring-security-oauth2-boot/docs/current-SNAPSHOT/reference/htmlsingle/#oauth2-boot-authorization-server-authentication-manager
本系列分为两部分,第一部分介绍如何搭建一个认证授权的服务器,第二部分将从Spring Security Oauth2框架源码出发,简单分析其工作流程及原理,最后分享几个我在完成这个项目过程中踩过的坑。
技术选型:Spring Boot 、Spring Security Oauth2、thymeleaf、Mybatis…
这里简单阐述下为什么选择Spring Security Oauth2框架:Oauth2实际上是一个关于授权(authorization)的开放网络标准,它仅仅是定义了一些列的规范、认证的交互流程,而不涉及任何具体的实现细节。因此若想要基于Oauth2协议实现认证授权功能,你可能会面临这些问题:授权码如何生成?令牌如何存储?令牌时效如何设定等等,诸如这些问题,若手动实现Oauth2协议,你可能会被淹没在大量的细节实现上,而无暇顾及核心的业务需求。Spring Security Oauth2本身已经提供Oauth2协议的实现,且依赖Spring、Spring Security的强大后台,能够更好的兼容整合项目。
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.3.RELEASE</version>
<relativePath/>
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.3.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity4</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、常规Spring Security配置
@Configuration
@EnableWebSecurity
// 启用方法级别的权限认证
@EnableGlobalMethodSecurity(prePostEnabled = true)
// 启用Session共享,部署两个实例,共享信息存储在Redis中
@EnableRedisHttpSession
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//自定义的登录认证Provider,后续会讲到
@Autowired
private SelfAuthenticationProvider authenticationProvider;
//自定义的登出成功处理器,后续会讲到
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login/**", "/healthCheck/**").permitAll()
.anyRequest().authenticated() // 其他地址的访问均需验证权限
.and()
// 定义当需要用户登录时候,转到的登录页面
.formLogin()
.loginPage("/login")
.successHandler(authenticationSuccessHandler())
.failureHandler(authenticationFailureHandler())
.and()
.logout()
.logoutUrl("/oauth/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.and()
.csrf()
.disable();
}
/**
* 登录成功处理器:定义认证通过后的一些操作
* @return
*/
AuthenticationSuccessHandler authenticationSuccessHandler() {
return new SelfAuthenticationSuccessHandler();
}
/**
* 登录失败处理器:定义认证失败后的一些操作
*/
AuthenticationFailureHandler authenticationFailureHandler() {
return new SelfAuthenticationFailureHandler();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/assets/**");
}
/**
* 使用自定义的authenticationProvider
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//使用自定义的AuthenticationProvider
auth.authenticationProvider(authenticationProvider);
}
/**
* 加密器
* 为client_secret加密
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
3、OAuth2的认证服务器配置
@Configuration
//该注解表示启用认证服务器
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
//客户端信息,这里我配置到application.yml文件中,最后将客户端信息存储到内存中
@Value("${client.detail.client_id}")
private String clientId;
@Value("${client.detail.client_secret}")
private String clientSecret;
@Value("${client.detail.scopes}")
private String scopes;
@Value("${client.detail.authorized_grant_types}")
private String authorizedGrantTypes;
@Value("${client.detail.redirect_uris}")
private String redirectUris;
@Resource
private DataSource dataSource;
@Autowired
RedisConnectionFactory redisConnectionFactory;
/**
* 配置授权服务器的安全,意味着实际上是/oauth/token端点。
* /oauth/authorize端点也应该是安全的
* 默认的设置覆盖到了绝大多数需求,所以一般情况下你不需要做任何事情。
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
super.configure(security);
}
/**
* 配置ClientDetailsService
* 注意,除非你在下面的configure(AuthorizationServerEndpointsConfigurer)中指定了一个AuthenticationManager,否则密码授权方式不可用。
* 至少配置一个client,否则服务器将不会启动。
* 两种方式:JDBC、InMemory
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//方式一:客户端信息存储在数据库中
// clients.jdbc(dataSource);
//方式二:客户端信息存储到内存中
// clients.inMemory()
// .withClient(clientId)
// .secret(clientSecret)
// .scopes(scopes)
// .authorizedGrantTypes(authorizedGrantTypes)
// .redirectUris(redirectUris.split(","));
clients.withClientDetails(clientDetailsService);
}
@Bean
//Spring Security Oauth源码中有懒加载一个clientDetailsService,安全起见加上@Primary注解
@Primary
public ClientDetailsService clientDetailsService() throws Exception {
//存储到内存中
InMemoryClientDetailsServiceBuilder inMemoryClientDetailsServiceBuilder = new InMemoryClientDetailsServiceBuilder();
inMemoryClientDetailsServiceBuilder
.withClient(clientId)
.secret(clientSecret)
.scopes(scopes)
.authorizedGrantTypes(authorizedGrantTypes)
.redirectUris(redirectUris.split(","));
return inMemoryClientDetailsServiceBuilder.build();
//存储到数据库中
// return new JdbcClientDetailsService(dataSource);
}
/**
* 该方法是用来配置Authorization Server endpoints的一些非安全特性的,比如token存储、token自定义、授权类型等等的
* 默认情况下,你不需要做任何事情,除非你需要密码授权,那么在这种情况下你需要提供一个AuthenticationManager
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//token使用Redis存储;授权码使用数据库存储
endpoints.tokenStore(redisTokenStore())
.authorizationCodeServices(authorizationCodeServices())
.requestFactory(new DefaultOAuth2RequestFactory(clientDetailsService()));
}
/**
* token存储到Redis中
* @return
*/
@Bean
public RedisTokenStore redisTokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
/***
* 授权码存储到数据库中
* @return
*/
protected AuthorizationCodeServices authorizationCodeServices() {
return new JdbcAuthorizationCodeServices(dataSource);
}
4、OAuth2的资源服务器配置
@Configuration
//该注解表示启用资源服务器
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//这里配置,所有匹配/user/**的请求,都受资源服务器的保护,即必须通过access_token换取资源信息
@Override
public void configure(HttpSecurity http) throws Exception {
http.requestMatchers().antMatchers("/user/**")
.and()
.authorizeRequests()
.anyRequest().authenticated();
}
}
5、自定义用户授权页面和错误提示页面
@Configuration
@Import(AuthorizationServerEndpointsConfiguration.class)
public class EndpointConfiguration {
//authorizationEndpoint是源码提供的一个bean,这里覆盖其两个属性
@Autowired
private AuthorizationEndpoint authorizationEndpoint;
@PostConstruct
public void init() {
//用户授权页面
authorizationEndpoint.setUserApprovalPage("forward:/oauth/user_approval");
//错误页面
authorizationEndpoint.setErrorPage("forward:/oauth/globalError");
}
}
注意,对authorizationEndpoint的操作不要放到AuthorizationServerConfig配置类中,我之前看别人的技术博客,是写在一起的,但通过我的实践发现,写在一起会偶现一些问题,通过源码发现,若AuthorizationServerConfig配置类中注入authorizationEndpoint,则配置类会与框架的配置类AuthorizationServerEndpointsConfiguration存在循环依赖。
6、页面跳转控制器
@Controller
@SessionAttributes("authorizationRequest")
public class PageController {
private static final String DEFAULT_GLOBAL_ERROR = "未知错误,请联系技术客服!";
private static final Integer DEFAULT_ERROR_FLAG = 201;
/**
* 登录页面跳转
* @return
*/
@RequestMapping("/login")
public String login() {
return "login_new";
}
/**
* 用户授权页面跳转
* @return
*/
@RequestMapping("/oauth/user_approval")
public String userApproval() {
return "authorization";
}
/**
* 全局异常提示页面跳转
* @param request
* @param model
* @return
*/
@RequestMapping("/oauth/globalError")
public String handleGlobalError(HttpServletRequest request, Map<String, Object> model) {
Object error = request.getAttribute("error");
String errorSummary = DEFAULT_GLOBAL_ERROR;
if (error instanceof OAuth2Exception) {
OAuth2Exception oauthError = (OAuth2Exception) error;
errorSummary = HtmlUtils.htmlEscape(oauthError.getSummary());
}
model.put("errorSummary", errorSummary);
return "error_original";
}
}
7、受保护的资源控制器
@RestController
@RequestMapping(value = "/user")
public class UserController {
/**
* 受资源服务器保护,即必须通过access_token获取
* @return
*/
@RequestMapping(value = "/loginUser")
public LoginUser getLoginUser() {
//从上下文中获取登录用户的信息
OAuth2Authentication authentication = (OAuth2Authentication) SecurityContextHolder.getContext().getAuthentication();
SelfUserDetail userDetail = (SelfUserDetail) authentication.getUserAuthentication().getPrincipal();
return new LoginUser(userDetail.getUsername(), userDetail.getOrgCode());
}
}
上文资源服务器控制类对/user/**的请求进行保护,即UserController的url必须通过access_token换取。这里受保护的资源为当前登录用户的基本信息
Part Two:流程分析&源码解析