零、前言
本文基于《基于SpringBoot搭建应用开发框架(一)——基础架构》,通过该文,熟悉了SpringBoot的用法,完成了应用框架底层的搭建。
在开始本文之前,底层这块已经有了很大的调整,主要是SpringBoot由之前的 1.5.9.RELEASE 升级至 2.1.0.RELEASE 版本,其它依赖的三方包基本也都升级到目前最新版了。
其次是整体架构上也做了调整:
sunny-parent:sunny 项目的顶级父类,sunny-parent 又继承自 spring-boot-starter-parent ,为所有项目统一 spring 及 springboot 版本。同时,管理项目中将用到的大部分的第三方包,统一管理版本号。
sunny-starter:项目中开发的组件以 starter 的方式进行集成,按需引入 starter 即可。sunny-starter 下以 module 的形式组织,便于管理、批量打包部署。
sunny-starter-core:核心包,定义基础的操作类、异常封装、工具类等,集成了 mybatis-mapper、druid 数据源、redis 等。
sunny-starter-captcha:验证码封装。
sunny-cloud:spring-cloud 系列服务,微服务基础框架,本篇文章主要集中在 sunny-cloud-security上,其它的以后再说。
sunny-cloud-security:认证服务和授权服务。
sunny-admin:管理端服务,业务中心。
本篇将会一步步完成系统的登录认证,包括常规的用户名+密码登录、以及社交方式登录,如QQ、微信授权登录等,一步步分析 spring-security 及 oauth 相关的源码。
一、SpringSecurity 简介
SpringSecurity 是专门针对基于Spring项目的安全框架,充分利用了AOP和Filter来实现安全功能。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。他提供了强大的企业安全服务,如:认证授权机制、Web资源访问控制、业务方法调用访问控制、领域对象访问控制Access Control List(ACL)、单点登录(SSO)等等。
核心功能:认证(你是谁)、授权(你能干什么)、攻击防护(防止伪造身份)。
基本原理:SpringSecurity的核心实质是一个过滤器链,即一组Filter,所有的请求都会经过这些过滤器,然后响应返回。每个过滤器都有特定的职责,可通过配置添加、删除过滤器。过滤器的排序很重要,因为它们之间有依赖关系。有些过滤器也不能删除,如处在过滤器链最后几环的ExceptionTranslationFilter(处理后者抛出的异常),FilterSecurityInterceptor(最后一环,根据配置决定请求能不能访问服务)。
二、标准登录
使用 用户名+密码 的方式来登录,用户名、密码存储在数据库,并且支持密码输入错误三次后开启验证码,通过这样一个过程来熟悉 spring security 的认证流程,掌握 spring security 的原理。
1、基础环境
① 创建 sunny-cloud-security 模块,端口号设置为 8010,在sunny-cloud-security模块引入security支持以及sunny-starter-core:
② 开发一个TestController
③ 不做任何配置,启动系统,然后访问 localhost:8010/test 时,会自动跳转到SpringSecurity默认的登录页面去进行认证。那这登录的用户名和密码从哪来呢?
启动项目时,从控制台输出中可以找到生成的 security 密码,从 UserDetailsServiceAutoConfiguration 可以得知,使用的是基于内存的用户管理器,默认的用户名为 user,密码是随机生成的UUID。
我们也可以修改默认的用户名和密码。
④ 使用 user 和生成的UUID密码登录成功后即可访问 /test 资源,最简单的一个认证就完成了。
在不做任何配置的情况下,security会把服务内所有资源的访问都保护起来,需要先进行身份证认证才可访问, 使用默认的表单登录或http basic认证方式。
不过这种默认方式肯定无法满足我们的需求,我们的用户名和密码都是存在数据库的。下面我们就来看看在 spring boot 中我们如何去配置自己的登录页面以及从数据库获取用户数据来完成用户登录。
2、自定义登录页面
① 首先开发一个登录页面,由于页面中会使用到一些动态数据,决定使用 thymeleaf 模板引擎,只需在 pom 中引入如下依赖,使用默认配置即可,具体有哪些配置可从 ThymeleafProperties 中了解到。
② 同时,在 resources 目录下,建 static 和 templates 两个目录,static 目录用于存放静态资源,templates 用于存放 thymeleaf 模板页面,同时配置MVC的静态资源映射。
③ 开发后台首页、登录页面的跳转地址,/login 接口用于向登录页面传递登录相关的数据,如用户名、是否启用验证码、错误消息等。
1 package com.lyyzoo.sunny.security.controller; 2 3 import javax.servlet.http.HttpServletResponse; 4 import javax.servlet.http.HttpSession; 5 6 import org.apache.commons.lang3.StringUtils; 7 import org.springframework.beans.factory.annotation.Autowired; 8 import org.springframework.security.web.WebAttributes; 9 import org.springframework.stereotype.Controller; 10 import org.springframework.ui.Model; 11 import org.springframework.web.bind.annotation.GetMapping; 12 import org.springframework.web.bind.annotation.RequestMapping; 13 import org.springframework.web.bind.annotation.ResponseBody; 14 15 import com.lyyzoo.sunny.captcha.CaptchaImageHelper; 16 import com.lyyzoo.sunny.core.base.Result; 17 import com.lyyzoo.sunny.core.message.MessageAccessor; 18 import com.lyyzoo.sunny.core.userdetails.CustomUserDetails; 19 import com.lyyzoo.sunny.core.userdetails.DetailsHelper; 20 import com.lyyzoo.sunny.core.util.Results; 21 import com.lyyzoo.sunny.security.constant.SecurityConstants; 22 import com.lyyzoo.sunny.security.domain.entity.User; 23 import com.lyyzoo.sunny.security.domain.service.ConfigService; 24 import com.lyyzoo.sunny.security.domain.service.UserService; 25 26 /** 27 * 28 * @author bojiangzhou 2018/03/28 29 */ 30 @Controller 31 public class SecurityController { 32 33 private static final String LOGIN_PAGE = "login"; 34 35 private static final String INDEX_PAGE = "index"; 36 37 private static final String FIELD_ERROR_MSG = "errorMsg"; 38 private static final String FIELD_ENABLE_CAPTCHA = "enableCaptcha"; 39 40 @Autowired 41 private CaptchaImageHelper captchaImageHelper; 42 @Autowired 43 private UserService userService; 44 @Autowired 45 private ConfigService configService; 46 47 @RequestMapping("/index") 48 public String index() { 49 return INDEX_PAGE; 50 } 51 52 @GetMapping("/login") 53 public String login(HttpSession session, Model model) { 54 String errorMsg = (String) session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); 55 String username = (String) session.getAttribute(User.FIELD_USERNAME); 56 if (StringUtils.isNotBlank(errorMsg)) { 57 model.addAttribute(FIELD_ERROR_MSG, errorMsg); 58 } 59 if (StringUtils.isNotBlank(username)) { 60 model.addAttribute(User.FIELD_USERNAME, username); 61 User user = userService.getUserByUsername(username); 62 if (user == null) { 63 model.addAttribute(FIELD_ERROR_MSG, MessageAccessor.getMessage("login.username-or-password.error")); 64 } else { 65 if (configService.isEnableCaptcha(user.getPasswordErrorTime())) { 66 model.addAttribute(FIELD_ENABLE_CAPTCHA, true); 67 } 68 } 69 } 70 session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); 71 72 return LOGIN_PAGE; 73 } 74 75 @GetMapping("/public/captcha.jpg") 76 public void captcha(HttpServletResponse response) { 77 captchaImageHelper.generateAndWriteCaptchaImage(response, SecurityConstants.SECURITY_KEY); 78 } 79 80 @GetMapping("/user/self") 81 @ResponseBody 82 public Result test() { 83 CustomUserDetails details = DetailsHelper.getUserDetails(); 84 85 return Results.successWithData(details); 86 } 87 88 }
④ 从 spring boot 官方文档可以得知,spring security 的核心配置都在 WebSecurityConfigurerAdapter 里,我们只需继承该适配器覆盖默认配置即可。首先来看看默认的登录页面以及如何配置登录页面。
通过 HttpSecurity 配置安全策略,首先开放了允许匿名访问的地址,除此之外都需要认证,通过 formLogin() 来启用表单登录,并配置了默认的登录页面,以及登录成功后的首页地址。
启动系统,访问资源跳转到自定义的登录页面了:
⑤ 那么默认的登录页面是怎么来的呢,以及做了哪些默认配置?
从 formLogin() 可以看出,启用表单登录即启用了表单登录的配置 FormLoginConfigurer:
从 FormLoginConfigurer 的构造函数中可以看出,表单登录用户名和密码的参数默认配置为 username 和 password,所以,我们的登录页面中需和这两个参数配置成一样,当然了,我们也可以在 formLogin() 后自定义这两个参数。
同时,可以看出开启了 UsernamePasswordAuthenticationFilter 过滤器,用于 用户名+密码 登录方式的认证,这个之后再说明。
从初始化配置中可以看出,默认创建了 DefaultLoginPageGeneratingFilter 过滤器用于生成默认的登录页面,从该过滤器的初始化方法中我们也可以了解到一些默认的配置。这个过滤器只有在未配置自定义登录页面时才会生效。
3、SpringSecurity基本原理
在进行后面的开发前,先来了解下 spring security 的基本原理。
spring security 的核心是过滤器链,即一组 Filter。所有服务资源的请求都会经过 spring security 的过滤器链,并响应返回。
我们从控制台中可以找到输出过滤器链的类 DefaultSecurityFilterChain,在现有的配置上,可以看到当前过滤器链共有13个过滤器。
每个过滤器主要做什么可以参考:Spring Security 核心过滤器链分析
过滤器链的创建是通过 HttpSecurity 的配置而来,实际上,每个 HttpSecurity 的配置都会创建相应的过滤器链来处理对应的请求,每个请求都会进入 FilterChainProxy 过滤器,根据请求选择一个合适的过滤器链来处理该请求。
过滤器的顺序我们可以从 FilterComparator 中得知,并且可以看出 spring security 默认有25个过滤器(自行查看):
不难发现,几乎所有的过滤器都直接或间接继承自 GenericFilterBean,通过这个基础过滤器可以看到都有哪些过滤器,通过每个过滤器的名称我们能大概了解到 spring security 为我们提供了哪些功能,要启用这些功能,只需通过配置加入相应的过滤器即可,比如 oauth 认证。
过滤器链中,绿色框出的这类过滤器主要用于用户认证,这些过滤器会根据当前的请求检查是否有这个过滤器所需的信息,如果有则进入该过滤器,没有则不会进入下一个过滤器。
比如这里,如果是表单登录,要求必须是[POST /login],则进入 UsernamePasswordAuthenticationFilter 过滤器,使用用户名和密码进行认证,不会再进入BasicAuthenticationFilter;
如果使用 http basic 的方式进行认证,要求请求头必须包含 Authorization,且值以 basic 打头,则进入 BasicAuthenticationFilter 进行认证。
经过前面的过滤器后,最后会进入到 FilterSecurityInterceptor,这是整个 spring security 过滤器链的最后一环,在它身后就是服务的API。
这个过滤器会去根据配置决定当前的请求能不能访问真正的资源,主要一些实现功能在其父类AbstractSecurityInterceptor中。
[1] 拿到的是权限配置,会根据这些配置决定访问的API能否通过。
[2] 当前上下文必须有用户认证信息 Authentication,就算是匿名访问也会有相应的过滤器来生成 Authentication。不难发现,不同类型的认证过滤器对应了不同的 Authentication。使用用户名和密码登录时,就会生成 UsernamePasswordAuthenticationToken。
[3] 用户认证,首先判断用户是否已认证通过,认证通过则直接返回 Authentication,否则调用认证器进行认证。认证通过之后将 Authentication 放到 Security 的上下文,这就是为何我们能从 SecurityContextHolder 中取到 Authentication 的源头。
认证管理器是默认配置的 ProviderManager,ProviderManager 则管理者多个 AuthenticationProvider 认证器 ,认证的时候,只要其中一个认证器认证通过,则标识认证通过。
认证器:表单登录默认使用 DaoAuthenticationProvider,我们想要实现从数据库获取用户名和密码就得从这里入手。
[4] 认证通过后,使用权限决定管理器 AccessDecisionManager 判断是否有权限,管理器则管理者多个 权限投票器 AccessDecisionVoter,通过投票器来决定是否有权限访问资源。因此,我们也可以自定义投票器来判断用户是否有权限访问某个API。
最后,如果未认证通过或没有权限,FilterSecurityInterceptor 则抛出相应的异常,异常会被 ExceptionTranslationFilter 捕捉到,进行统一的异常处理分流,比如未登录时,重定向到登录页面;没有权限的时候抛出403异常等。
4、用户认证流程
从 spring security 基本原理的分析中不难发现,用户的认证过程涉及到三个主要的组件:
AbstractAuthenticationProcessingFilter:它在基于web的认证请求中用于处理包含认证信息的请求,创建一个部分完整的Authentication对象以在链中传递凭证信息。
AuthenticationManager:它用来校验用户的凭证信息,或者会抛出一个特定的异常(校验失败的情况)或者完整填充Authentication对象,将会包含了权限信息。
AuthenticationProvider:它为AuthenticationManager提供凭证校验。一些AuthenticationProvider的实现基于凭证信息的存储,如数据库,来判定凭证信息是否可以被认可。
我们从核心的 AbstractAuthenticationProcessingFilter 入手,来分析下用户认证的流程。
[1] 可以看到,首先会调用 attemptAuthentication 来获取认证后的 Authentication。attemptAuthentication 是一个抽象方法,在其子类中实现。
前面提到过,启用表单登录时,就会创建 UsernamePasswordAuthenticationFilter 用于处理表单登录。后面开发 oauth2 认证的时候则会用到 OAuth2 相关的过滤器。
从 attemptAuthentication 的实现中可以看出,主要是将 username 和 password 封装到 UsernamePasswordAuthenticationToken。
从当前 UsernamePasswordAuthenticationToken 的构造方法中可以看出,此时的 Authentication 设置了未认证状态。
【#】通过 setDetails 可以向 UsernamePasswordAuthenticationToken 中加入 Details 用于后续流程的处理,稍后我会实现AuthenticationDetailsSource 将验证码放进去用于后面的认证。
之后,通过 AuthenticationManager 进行认证,实际是 ProviderManager 管理着一些认证器,这些配置都可以通过 setter 方法找到相应配置的位置,这里就不赘述了。
不难发现,用户认证器使用的是 AbstractUserDetailsAuthenticationProvider,流程主要涉及到 retrieveUser 和 additionalAuthenticationChecks 两个抽象方法。
【#】AbstractUserDetailsAuthenticationProvider 默认只有一个实现类 DaoAuthenticationProvider,获取用户信息、用户密码校验都是在这个实现类里,因此我们也可以实现自己的 AbstractUserDetailsAuthenticationProvider 来处理相关业务。
【#】从 retrieveUser 中可以发现,主要使用 UserDetailsService 来获取用户信息,该接口只有一个方法 loadUserByUsername,我们也会实现该接口来从数据库获取用户信息。如果有复杂的业务逻辑,比如锁定用户等,还可以覆盖 retrieveUser 方法。
用户返回成功后,就会通过 PasswordEncoder 来校验用户输入的密码和数据库密码是否匹配。注意数据库存入的密码是加密后的密码,且不可逆。
用户、密码都校验通过后,就会创建已认证的 Authentication,从此时 UsernamePasswordAuthenticationToken 的构造方法可以看出,构造的是一个已认证的 Authentication。
[2] 如果用户认证失败,会调用 AuthenticationFailureHandler 的 onAuthenticationFailure 方法进行认证失败后的处理,我们也会实现这个接口来做一些失败后逻辑处理。
[3] 用户认证成功,将 Authentication 放入 security 上下文,调用 AuthenticationSuccessHandler 做认证成功的一些后续逻辑处理,我们也会实现这个接口。
5、用户认证代码实现
通过 spring security 基本原理分析和用户认证流程分析,我们已经能够梳理出完成认证需要做哪些工作了。
① 首先设计并创建系统用户表:
② CustomUserDetails
自定义 UserDetails,根据自己的需求将一些常用的用户信息封装到 UserDetails 中,便于快速获取用户信息,比如用户ID、昵称等。
1 package com.lyyzoo.sunny.core.userdetails; 2 3 import java.util.Collection; 4 import java.util.Objects; 5 6 import org.springframework.security.core.GrantedAuthority; 7 import org.springframework.security.core.userdetails.User; 8 9 10 /** 11 * 定制的UserDetail对象 12 * 13 * @author bojiangzhou 2018/09/02 14 */ 15 public class CustomUserDetails extends User { 16 private static final long serialVersionUID = -4461471539260584625L; 17 18 private Long userId; 19 20 private String nickname; 21 22 private String language; 23 24 public CustomUserDetails(String username, String password, Long userId, String nickname, String language, 25 Collection<? extends GrantedAuthority> authorities) { 26 super(username, password, authorities); 27 this.userId = userId; 28 this.nickname = nickname; 29 this.language = language; 30 } 31 32 public Long getUserId() { 33 return userId; 34 } 35 36 public void setUserId(Long userId) { 37 this.userId = userId; 38 } 39 40 public String getNickname() { 41 return nickname; 42 } 43 44 public void setNickname(String nickname) { 45 this.nickname = nickname; 46 } 47 48 public String getLanguage() { 49 return language; 50 } 51 52 public void setLanguage(String language) { 53 this.language = language; 54 } 55 56 @Override 57 public boolean equals(Object o) { 58 if (this == o) { 59 return true; 60 } 61 if (!(o instanceof CustomUserDetails)) { 62 return false; 63 } 64 if (!super.equals(o)) { 65 return false; 66 } 67 68 CustomUserDetails that = (CustomUserDetails) o; 69 70 if (!Objects.equals(userId, that.userId)) { 71 return false; 72 } 73 return false; 74 } 75 76 @Override 77 public int hashCode() { 78 int result = super.hashCode(); 79 result = 31 * result + userId.hashCode(); 80 result = 31 * result + nickname.hashCode(); 81 result = 31 * result + language.hashCode(); 82 return result; 83 } 84 85 }
③ CustomUserDetailsService
自定义 UserDetailsService 来从数据库获取用户信息,并将用户信息封装到 CustomUserDetails
1 package com.lyyzoo.sunny.security.core; 2 3 import java.util.ArrayList; 4 import java.util.Collection; 5 6 import org.springframework.beans.factory.annotation.Autowired; 7 import org.springframework.security.core.GrantedAuthority; 8 import org.springframework.security.core.authority.SimpleGrantedAuthority; 9 import org.springframework.security.core.userdetails.UserDetails; 10 import org.springframework.security.core.userdetails.UserDetailsService; 11 import org.springframework.security.core.userdetails.UsernameNotFoundException; 12 import org.springframework.stereotype.Component; 13 14 import com.lyyzoo.sunny.core.message.MessageAccessor; 15 import com.lyyzoo.sunny.core.userdetails.CustomUserDetails; 16 import com.lyyzoo.sunny.security.domain.entity.User; 17 import com.lyyzoo.sunny.security.domain.service.UserService; 18 19 /** 20 * 加载用户信息实现类 21 * 22 * @author bojiangzhou 2018/03/25 23 */ 24 @Component 25 public class CustomUserDetailsService implements UserDetailsService { 26 27 @Autowired 28 private UserService userService; 29 30 @Override 31 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 32 User user = userService.getUserByUsername(username); 33 if (user == null) { 34 throw new UsernameNotFoundException(MessageAccessor.getMessage("login.username-or-password.error")); 35 } 36 37 Collection<GrantedAuthority> authorities = new ArrayList<>(); 38 authorities.add(new SimpleGrantedAuthority("ROLE_USER")); 39 40 return new CustomUserDetails(username, user.getPassword(), user.getId(), 41 user.getNickname(), user.getLanguage(), authorities); 42 } 43 44 }
④ CustomWebAuthenticationDetails
自定义 WebAuthenticationDetails 用于封装传入的验证码以及缓存的验证码,用于后续校验。
1 package com.lyyzoo.sunny.security.core; 2 3 import javax.servlet.http.HttpServletRequest; 4 5 import com.lyyzoo.sunny.captcha.CaptchaResult; 6 import org.springframework.security.web.authentication.WebAuthenticationDetails; 7 8 /** 9 * 封装验证码 10 * 11 * @author bojiangzhou 2018/09/18 12 */ 13 public class CustomWebAuthenticationDetails extends WebAuthenticationDetails { 14 15 public static final String FIELD_CACHE_CAPTCHA = "cacheCaptcha"; 16 17 private String inputCaptcha; 18 private String cacheCaptcha; 19 20 public CustomWebAuthenticationDetails(HttpServletRequest request) { 21 super(request); 22 cacheCaptcha = (String) request.getAttribute(FIELD_CACHE_CAPTCHA); 23 inputCaptcha = request.getParameter(CaptchaResult.FIELD_CAPTCHA); 24 } 25 26 public String getInputCaptcha() { 27 return inputCaptcha; 28 } 29 30 public String getCacheCaptcha() { 31 return cacheCaptcha; 32 } 33 34 @Override 35 public boolean equals(Object object) { 36 if (this == object) { 37 return true; 38 } 39 if (object == null || getClass() != object.getClass()) { 40 return false; 41 } 42 if (!super.equals(object)) { 43 return false; 44 } 45 46 CustomWebAuthenticationDetails that = (CustomWebAuthenticationDetails) object; 47 48 return inputCaptcha != null ? inputCaptcha.equals(that.inputCaptcha) : that.inputCaptcha == null; 49 } 50 51 @Override 52 public int hashCode() { 53 int result = super.hashCode(); 54 result = 31 * result + (inputCaptcha != null ? inputCaptcha.hashCode() : 0); 55 return result; 56 } 57 } 58 package com.lyyzoo.sunny.security.core; 59 60 import javax.servlet.http.HttpServletRequest; 61 62 import com.lyyzoo.sunny.captcha.CaptchaResult; 63 import org.springframework.security.web.authentication.WebAuthenticationDetails; 64 65 /** 66 * 封装验证码 67 * 68 * @author bojiangzhou 2018/09/18 69 */ 70 public class CustomWebAuthenticationDetails extends WebAuthenticationDetails { 71 72 public static final String FIELD_CACHE_CAPTCHA = "cacheCaptcha"; 73 74 private String inputCaptcha; 75 private String cacheCaptcha; 76 77 public CustomWebAuthenticationDetails(HttpServletRequest request) { 78 super(request); 79 cacheCaptcha = (String) request.getAttribute(FIELD_CACHE_CAPTCHA); 80 inputCaptcha = request.getParameter(CaptchaResult.FIELD_CAPTCHA); 81 } 82 83 public String getInputCaptcha() { 84 return inputCaptcha; 85 } 86 87 public String getCacheCaptcha() { 88 return cacheCaptcha; 89 } 90 91 @Override 92 public boolean equals(Object object) { 93 if (this == object) { 94 return true; 95 } 96 if (object == null || getClass() != object.getClass()) { 97 return false; 98 } 99 if (!super.equals(object)) { 100 return false; 101 } 102 103 CustomWebAuthenticationDetails that = (CustomWebAuthenticationDetails) object; 104 105 return inputCaptcha != null ? inputCaptcha.equals(that.inputCaptcha) : that.inputCaptcha == null; 106 } 107 108 @Override 109 public int hashCode() { 110 int result = super.hashCode(); 111 result = 31 * result + (inputCaptcha != null ? inputCaptcha.hashCode() : 0); 112 return result; 113 } 114 }
⑤ CustomAuthenticationDetailsSource
当然了,还需要一个构造验证码的 AuthenticationDetailsSource
1 package com.lyyzoo.sunny.security.core; 2 3 import javax.servlet.http.HttpServletRequest; 4 5 import org.springframework.beans.factory.annotation.Autowired; 6 import org.springframework.security.authentication.AuthenticationDetailsSource; 7 import org.springframework.security.web.authentication.WebAuthenticationDetails; 8 import org.springframework.stereotype.Component; 9 10 import com.lyyzoo.sunny.captcha.CaptchaImageHelper; 11 import com.lyyzoo.sunny.security.constant.SecurityConstants; 12 13 /** 14 * 自定义获取AuthenticationDetails 用于封装传进来的验证码 15 * 16 * @author bojiangzhou 2018/09/18 17 */ 18 @Component 19 public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> { 20 21 @Autowired 22 private CaptchaImageHelper captchaImageHelper; 23 24 @Override 25 public WebAuthenticationDetails buildDetails(HttpServletRequest request) { 26 String cacheCaptcha = captchaImageHelper.getCaptcha(request, SecurityConstants.SECURITY_KEY); 27 request.setAttribute(CustomWebAuthenticationDetails.FIELD_CACHE_CAPTCHA, cacheCaptcha); 28 return new CustomWebAuthenticationDetails(request); 29 } 30 31 }
⑥ CustomAuthenticationProvider
自定义认证处理器,主要加入了验证码的检查,如果用户密码输入错误三次以上,则需要验证码。
1 package com.lyyzoo.sunny.security.core; 2 3 import org.apache.commons.lang3.StringUtils; 4 import org.springframework.beans.factory.annotation.Autowired; 5 import org.springframework.security.authentication.AuthenticationServiceException; 6 import org.springframework.security.authentication.BadCredentialsException; 7 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 8 import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider; 9 import org.springframework.security.core.AuthenticationException; 10 import org.springframework.security.core.userdetails.UserDetails; 11 import org.springframework.security.crypto.password.PasswordEncoder; 12 import org.springframework.stereotype.Component; 13 14 import com.lyyzoo.sunny.security.domain.entity.User; 15 import com.lyyzoo.sunny.security.domain.service.ConfigService; 16 import com.lyyzoo.sunny.security.domain.service.UserService; 17 18 /** 19 * 自定义认证器 20 * 21 * @author bojiangzhou 2018/09/09 22 */ 23 @Component 24 public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { 25 26 @Autowired 27 private UserService userService; 28 @Autowired 29 private CustomUserDetailsService detailsService; 30 @Autowired 31 private PasswordEncoder passwordEncoder; 32 @Autowired 33 private ConfigService configService; 34 35 36 @Override 37 protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { 38 // 如有其它逻辑处理,可在此处进行逻辑处理... 39 return detailsService.loadUserByUsername(username); 40 } 41 42 @Override 43 protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { 44 String username = userDetails.getUsername(); 45 User user = userService.getUserByUsername(username); 46 47 // 检查验证码 48 if (authentication.getDetails() instanceof CustomWebAuthenticationDetails) { 49 if (configService.isEnableCaptcha(user.getPasswordErrorTime())) { 50 CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails(); 51 String inputCaptcha = details.getInputCaptcha(); 52 String cacheCaptcha = details.getCacheCaptcha(); 53 if (StringUtils.isEmpty(inputCaptcha) || !StringUtils.equalsIgnoreCase(inputCaptcha, cacheCaptcha)) { 54 throw new AuthenticationServiceException("login.captcha.error"); 55 } 56 authentication.setDetails(null); 57 } 58 } 59 60 // 检查密码是否正确 61 String password = userDetails.getPassword(); 62 String rawPassword = authentication.getCredentials().toString(); 63 64 boolean match = passwordEncoder.matches(rawPassword, password); 65 if (!match) { 66 throw new BadCredentialsException("login.username-or-password.error"); 67 } 68 } 69 }
⑦ CustomAuthenticationSuccessHandler
自定义认证成功处理器,用户认证成功,将密码错误次数置零。
1 package com.lyyzoo.sunny.security.core; 2 3 import java.io.IOException; 4 5 import javax.servlet.ServletException; 6 import javax.servlet.http.HttpServletRequest; 7 import javax.servlet.http.HttpServletResponse; 8 9 import org.springframework.beans.factory.annotation.Autowired; 10 import org.springframework.security.core.Authentication; 11 import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; 12 import org.springframework.stereotype.Component; 13 14 import com.lyyzoo.sunny.security.domain.entity.User; 15 import com.lyyzoo.sunny.security.domain.service.UserService; 16 17 /** 18 * 登录认证成功处理器 19 * 20 * @author bojiangzhou 2018/03/29 21 */ 22 @Component 23 public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { 24 25 @Autowired 26 private UserService userService; 27 28 @Override 29 public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, 30 Authentication authentication) throws IOException, ServletException { 31 String username = request.getParameter("username"); 32 User user = userService.getUserByUsername(username); 33 userService.loginSuccess(user.getId()); 34 super.onAuthenticationSuccess(request, response, authentication); 35 } 36 }
⑧ CustomAuthenticationFailureHandler
用户认证失败,记录密码错误次数,并重定向到登录页面。
1 package com.lyyzoo.sunny.security.core; 2 3 import java.io.IOException; 4 5 import javax.servlet.ServletException; 6 import javax.servlet.http.HttpServletRequest; 7 import javax.servlet.http.HttpServletResponse; 8 import javax.servlet.http.HttpSession; 9 10 import org.springframework.beans.factory.annotation.Autowired; 11 import org.springframework.security.authentication.BadCredentialsException; 12 import org.springframework.security.core.AuthenticationException; 13 import org.springframework.security.web.DefaultRedirectStrategy; 14 import org.springframework.security.web.RedirectStrategy; 15 import org.springframework.security.web.WebAttributes; 16 import org.springframework.security.web.authentication.AuthenticationFailureHandler; 17 import org.springframework.stereotype.Component; 18 19 import com.lyyzoo.sunny.core.message.MessageAccessor; 20 import com.lyyzoo.sunny.security.config.SecurityProperties; 21 import com.lyyzoo.sunny.security.domain.entity.User; 22 import com.lyyzoo.sunny.security.domain.service.UserService; 23 24 /** 25 * 登录失败处理器 26 * 27 * @author bojiangzhou 2018/03/29 28 */ 29 @Component 30 public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { 31 32 @Autowired 33 private SecurityProperties securityProperties; 34 @Autowired 35 private UserService userService; 36 37 private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); 38 39 @Override 40 public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, 41 AuthenticationException exception) throws IOException, ServletException { 42 String username = request.getParameter("username"); 43 HttpSession session = request.getSession(false); 44 45 if (session != null) { 46 session.setAttribute("username", username); 47 session.setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, 48 MessageAccessor.getMessage(exception.getMessage(), exception.getMessage())); 49 } 50 if (exception instanceof BadCredentialsException) { 51 User user = userService.getUserByUsername(username); 52 userService.loginFail(user.getId()); 53 } 54 55 redirectStrategy.sendRedirect(request, response, securityProperties.getLoginPage() + "?username=" + username); 56 } 57 }
⑨ 配置
前面的开发完成当然还需做配置,通过 formLogin() 来配置认证成功/失败处理器等。
通过 AuthenticationManagerBuilder 配置自定义的认证器。
SpringSecurity提供了一个 PasswordEncoder 接口用于处理加密解密。该接口有两个方法 encode 和 matches 。encode 对密码加密,matches 判断用户输入的密码和加密的密码(数据库密码)是否匹配。
1 package com.lyyzoo.sunny.security.config; 2 3 import com.lyyzoo.sunny.security.core.*; 4 import org.springframework.beans.factory.annotation.Autowired; 5 import org.springframework.boot.context.properties.EnableConfigurationProperties; 6 import org.springframework.context.annotation.Bean; 7 import org.springframework.context.annotation.Configuration; 8 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 9 import org.springframework.security.config.annotation.web.builders.HttpSecurity; 10 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 11 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 12 import org.springframework.security.crypto.password.PasswordEncoder; 13 14 /** 15 * Security 主配置器 16 * 17 * @author bojiangzhou 18 */ 19 @Configuration 20 @EnableConfigurationProperties(SecurityProperties.class) 21 public class SecurityConfiguration extends WebSecurityConfigurerAdapter { 22 23 @Autowired 24 private SecurityProperties properties; 25 @Autowired 26 private CustomAuthenticationDetailsSource authenticationDetailsSource; 27 @Autowired 28 private CustomAuthenticationProvider authenticationProvider; 29 @Autowired 30 private CustomAuthenticationSuccessHandler authenticationSuccessHandler; 31 @Autowired 32 private CustomAuthenticationFailureHandler authenticationFailureHandler; 33 34 @Override 35 protected void configure(HttpSecurity http) throws Exception { 36 http 37 .authorizeRequests() 38 .antMatchers("/static/**", "/webjars/**", "/public/**", "/login", "/favicon.ico") 39 .permitAll() // 允许匿名访问的地址 40 .and() // 使用and()方法相当于XML标签的关闭,这样允许我们继续配置父类节点。 41 .authorizeRequests() 42 .anyRequest() 43 .authenticated() // 其它地址都需进行认证 44 .and() 45 .formLogin() // 启用表单登录 46 .loginPage(properties.getLoginPage()) // 登录页面 47 .defaultSuccessUrl("/index") // 默认的登录成功后的跳转地址 48 .authenticationDetailsSource(authenticationDetailsSource) 49 .successHandler(authenticationSuccessHandler) 50 .failureHandler(authenticationFailureHandler) 51 .and() 52 .csrf() 53 .disable() 54 ; 55 56 } 57 58 /** 59 * 设置认证处理器 60 */ 61 @Override 62 protected void configure(AuthenticationManagerBuilder auth) throws Exception { 63 auth.authenticationProvider(authenticationProvider); 64 super.configure(auth); 65 } 66 67 /** 68 * 密码处理器 69 */ 70 @Bean 71 public PasswordEncoder passwordEncoder() { 72 return new BCryptPasswordEncoder(); 73 } 74 75 }
⑩ 登录页面
三、手机短信登录
经过前面用户名+密码的登录流程分析后,现在再来开发手机号+短信验证码的方式登录。手机短信登录无法直接使用标准登录的流程,所以需要模拟标准登录流程开发。
1、流程分析
类比标准登录流程:
① 登录请求 [POST /login] 在 UsernamePasswordAuthenticationFilter 过滤器中封装未认证的 UsernamePasswordAuthenticationToken;
短信登录时,请求 [POST /authentication/mobile] 进行登录认证,自定义 SmsAuthenticationFilter 短信认证过滤器,生成未认证的 SmsAuthenticationToken;
② 调用 AuthenticationManager 进行认证;
③ 认证时,使用自定义的 CustomAuthenticationProvider 进行用户信息认证;短信登录则自定义短信认证器 SmsAuthenticationProvider ;
④ 认证器使用自定义的 CustomUserDetailsService 来获取用户信息;
⑤ 认证成功后,生成已认证的 UsernamePasswordAuthenticationToken;短信登录时则生成已认证的 SmsAuthenticationToken;
2、代码实现
① 短信登录专用 Authentication
参照 UsernamePasswordAuthenticationToken,两个构造方法,认证前,放入手机号;认证成功之后,放入用户信息。
1 package com.lyyzoo.sunny.security.sms; 2 3 import java.util.Collection; 4 5 import org.springframework.security.authentication.AbstractAuthenticationToken; 6 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 7 import org.springframework.security.core.GrantedAuthority; 8 9 /** 10 * 短信认证用到的 Authentication,封装登录信息。 认证前,放入手机号;认证成功之后,放入用户信息。 11 * <p> 12 * 参考 {@link UsernamePasswordAuthenticationToken} 13 * 14 * @author bojiangzhou 2018/09/22 15 */ 16 public class SmsAuthenticationToken extends AbstractAuthenticationToken { 17 18 // 手机号 19 private final Object principal; 20 21 public SmsAuthenticationToken(Object principal) { 22 super(null); 23 this.principal = principal; 24 setAuthenticated(false); 25 } 26 27 public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) { 28 super(authorities); 29 this.principal = principal; 30 super.setAuthenticated(true); 31 } 32 33 @Override 34 public Object getCredentials() { 35 return null; 36 } 37 38 public Object getPrincipal() { 39 return this.principal; 40 } 41 42 public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { 43 if (isAuthenticated) { 44 throw new IllegalArgumentException( 45 "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); 46 } 47 super.setAuthenticated(false); 48 } 49 50 @Override 51 public void eraseCredentials() { 52 super.eraseCredentials(); 53 } 54 }
② 短信登录认证过滤器
参照 UsernamePasswordAuthenticationFilter,注意在构造方法中配置短信登录的地址 [POST /authentication/mobile],只有与这个地址匹配的才会进入这个过滤器。
同时,定义 SmsAuthenticationDetails 封装用户输入的手机验证码,在认证器里校验验证码正确性。
1 package com.lyyzoo.sunny.security.sms; 2 3 import javax.servlet.http.HttpServletRequest; 4 import javax.servlet.http.HttpServletResponse; 5 6 import org.springframework.security.authentication.AuthenticationServiceException; 7 import org.springframework.security.core.Authentication; 8 import org.springframework.security.core.AuthenticationException; 9 import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; 10 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 11 import org.springframework.security.web.util.matcher.AntPathRequestMatcher; 12 import org.springframework.util.Assert; 13 14 /** 15 * 短信登录认证过滤器 16 * <p> 17 * 参考 {@link UsernamePasswordAuthenticationFilter} 18 * 19 * @author bojiangzhou 2018/09/22 20 */ 21 public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter { 22 23 public static final String SUNNY_SMS_MOBILE_KEY = "mobile"; 24 25 private String mobileParameter = SUNNY_SMS_MOBILE_KEY; 26 private boolean postOnly = true; 27 28 /** 29 * 仅匹配 [POST /authentication/mobile] 30 */ 31 public SmsAuthenticationFilter() { 32 super(new AntPathRequestMatcher("/authentication/mobile", "POST")); 33 } 34 35 public Authentication attemptAuthentication(HttpServletRequest request, 36 HttpServletResponse response) throws AuthenticationException { 37 if (postOnly && !request.getMethod().equals("POST")) { 38 throw new AuthenticationServiceException( 39 "Authentication method not supported: " + request.getMethod()); 40 } 41 String mobile = obtainMobile(request); 42 43 if (mobile == null) { 44 mobile = ""; 45 } 46 47 mobile = mobile.trim(); 48 49 SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile); 50 51 // Allow subclasses to set the "details" property 52 setDetails(request, authRequest); 53 54 return this.getAuthenticationManager().authenticate(authRequest); 55 } 56 57 protected String obtainMobile(HttpServletRequest request) { 58 return request.getParameter(mobileParameter); 59 } 60 61 protected void setDetails(HttpServletRequest request, SmsAuthenticationToken authRequest) { 62 authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); 63 } 64 65 public void setMobileParameter(String mobileParameter) { 66 Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null"); 67 this.mobileParameter = mobileParameter; 68 } 69 70 public void setPostOnly(boolean postOnly) { 71 this.postOnly = postOnly; 72 } 73 74 public final String getMobileParameter() { 75 return mobileParameter; 76 } 77 78 }
③ 短信登录认证器
参考 DaoAuthenticationProvider,覆盖父类的 authenticate 方法,根据手机号获取用户信息,校验用户输入的验证码是否正确。
覆盖 supports 方法,只有 {@link SmsAuthenticationToken} 类型才使用该认证器,ProviderManager 里将会调用该方法寻找合适的认证器来认证。
1 package com.lyyzoo.sunny.security.sms; 2 3 import com.lyyzoo.sunny.captcha.CaptchaMessageHelper; 4 import com.lyyzoo.sunny.captcha.CaptchaResult; 5 import com.lyyzoo.sunny.security.constant.SecurityConstants; 6 import com.lyyzoo.sunny.security.exception.CaptchaException; 7 import org.apache.commons.lang3.StringUtils; 8 import org.slf4j.Logger; 9 import org.slf4j.LoggerFactory; 10 import org.springframework.security.authentication.AuthenticationProvider; 11 import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider; 12 import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 13 import org.springframework.security.core.Authentication; 14 import org.springframework.security.core.AuthenticationException; 15 import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; 16 import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; 17 import org.springframework.security.core.userdetails.UserDetails; 18 import org.springframework.security.core.userdetails.UserDetailsService; 19 import org.springframework.util.Assert; 20 21 /** 22 * 短信登录认证器 23 * <p> 24 * 参考 {@link AbstractUserDetailsAuthenticationProvider},{@link DaoAuthenticationProvider} 25 * 26 * @author bojiangzhou 2018/09/22 27 */ 28 public class SmsAuthenticationProvider implements AuthenticationProvider { 29 private static final Logger LOGGER = LoggerFactory.getLogger(SmsAuthenticationProvider.class); 30 31 private UserDetailsService userDetailsService; 32 33 private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); 34 35 private CaptchaMessageHelper captchaMessageHelper; 36 37 public SmsAuthenticationProvider(UserDetailsService userDetailsService, CaptchaMessageHelper captchaMessageHelper) { 38 this.userDetailsService = userDetailsService; 39 this.captchaMessageHelper = captchaMessageHelper; 40 } 41 42 @Override 43 public Authentication authenticate(Authentication authentication) throws AuthenticationException { 44 Assert.isInstanceOf(SmsAuthenticationToken.class, authentication, 45 "Only SmsAuthenticationToken is supported"); 46 47 String mobile = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); 48 49 UserDetails user = retrieveUser(mobile, (SmsAuthenticationToken) authentication); 50 Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); 51 52 additionalAuthenticationChecks(user, (SmsAuthenticationToken) authentication); 53 54 return createSuccessAuthentication(user, authentication, user); 55 } 56 57 protected UserDetails retrieveUser(String mobile, SmsAuthenticationToken authentication) 58 throws AuthenticationException { 59 60 return getUserDetailsService().loadUserByUsername(mobile); 61 } 62 63 protected void additionalAuthenticationChecks(UserDetails userDetails, SmsAuthenticationToken authentication) 64 throws AuthenticationException { 65 Assert.isInstanceOf(SmsAuthenticationDetails.class, authentication.getDetails()); 66 SmsAuthenticationDetails details = (SmsAuthenticationDetails) authentication.getDetails(); 67 String mobile = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); 68 // 检查验证码 69 String inputCaptcha = details.getInputCaptcha(); 70 String captchaKey = details.getCaptchaKey(); 71 if (StringUtils.isAnyEmpty(inputCaptcha, captchaKey)) { 72 throw new CaptchaException("login.mobile-captcha.null"); 73 } 74 CaptchaResult captchaResult = captchaMessageHelper.checkCaptcha(captchaKey, inputCaptcha, mobile, 75 SecurityConstants.SECURITY_KEY, false); 76 authentication.setDetails(null); 77 78 if (!captchaResult.isSuccess()) { 79 throw new CaptchaException(captchaResult.getMessage()); 80 } 81 } 82 83 protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, 84 UserDetails user) { 85 SmsAuthenticationToken result = 86 new SmsAuthenticationToken(principal, authoritiesMapper.mapAuthorities(user.getAuthorities())); 87 result.setDetails(authentication.getDetails()); 88 89 return result; 90 } 91 92 /** 93 * 只有 {@link SmsAuthenticationToken} 类型才使用该认证器 94 */ 95 @Override 96 public boolean supports(Class<?> authentication) { 97 return (SmsAuthenticationToken.class.isAssignableFrom(authentication)); 98 } 99 100 public UserDetailsService getUserDetailsService() { 101 return userDetailsService; 102 } 103 104 public void setUserDetailsService(UserDetailsService userDetailsService) { 105 this.userDetailsService = userDetailsService; 106 } 107 108 public CaptchaMessageHelper getCaptchaMessageHelper() { 109 return captchaMessageHelper; 110 } 111 112 public void setCaptchaMessageHelper(CaptchaMessageHelper captchaMessageHelper) { 113 this.captchaMessageHelper = captchaMessageHelper; 114 } 115 116 }
3、短信登录配置
短信登录的配置可以参考表单登录的配置 FormLoginConfigurer,在使用 formLogin() 时就会启用该配置。
定义 SmsLoginConfigurer,创建短信登录配置时,创建短信认证过滤器,在 configure 中配置该过滤器的认证成功/失败处理器。最重要的一点,将短信认证过滤器加到 UsernamePasswordAuthenticationFilter 之后。
1 package com.lyyzoo.sunny.security.sms; 2 3 import javax.servlet.http.HttpServletRequest; 4 5 import org.springframework.security.authentication.AuthenticationDetailsSource; 6 import org.springframework.security.authentication.AuthenticationManager; 7 import org.springframework.security.config.annotation.SecurityConfigurerAdapter; 8 import org.springframework.security.config.annotation.web.builders.HttpSecurity; 9 import org.springframework.security.web.DefaultSecurityFilterChain; 10 import org.springframework.security.web.authentication.AuthenticationFailureHandler; 11 import org.springframework.security.web.authentication.AuthenticationSuccessHandler; 12 import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; 13 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 14 import org.springframework.security.web.util.matcher.AntPathRequestMatcher; 15 import org.springframework.security.web.util.matcher.RequestMatcher; 16 import org.springframework.util.Assert; 17 18 /** 19 * 短信登录配置 20 * 21 * @author bojiangzhou 2018/09/23 22 */ 23 public class SmsLoginConfigurer 24 extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { 25 26 private static final String SMS_DEFAULT_LOGIN_PROCESS_URL = "/authentication/mobile"; 27 28 private SmsAuthenticationFilter authFilter; 29 30 private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource; 31 32 private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler(); 33 34 private AuthenticationFailureHandler failureHandler; 35 36 /** 37 * 默认手机+短信验证码 登录处理地址 [POST "/authentication/mobile"]. 默认手机参数 - mobile 38 */ 39 public SmsLoginConfigurer() { 40 authFilter = new SmsAuthenticationFilter(); 41 loginProcessingUrl(SMS_DEFAULT_LOGIN_PROCESS_URL); 42 mobileParameter("mobile"); 43 } 44 45 public SmsLoginConfigurer mobileParameter(String mobileParameter) { 46 authFilter.setMobileParameter(mobileParameter); 47 return this; 48 } 49 50 public SmsLoginConfigurer loginProcessingUrl(String loginProcessingUrl) { 51 authFilter.setRequiresAuthenticationRequestMatcher(createLoginProcessingUrlMatcher(loginProcessingUrl)); 52 return this; 53 } 54 55 public SmsLoginConfigurer authenticationDetailsSource( 56 AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource) { 57 this.authenticationDetailsSource = authenticationDetailsSource; 58 return this; 59 } 60 61 public SmsLoginConfigurer successHandler(AuthenticationSuccessHandler successHandler) { 62 this.successHandler = successHandler; 63 return this; 64 } 65 66 public SmsLoginConfigurer failureHandler(AuthenticationFailureHandler failureHandler) { 67 this.failureHandler = failureHandler; 68 return this; 69 } 70 71 protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { 72 return new AntPathRequestMatcher(loginProcessingUrl, "POST"); 73 } 74 75 @Override 76 public void configure(HttpSecurity http) throws Exception { 77 Assert.notNull(successHandler, "successHandler should not be null."); 78 Assert.notNull(failureHandler, "failureHandler should not be null."); 79 authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); 80 authFilter.setAuthenticationSuccessHandler(successHandler); 81 authFilter.setAuthenticationFailureHandler(failureHandler); 82 if (authenticationDetailsSource != null) { 83 authFilter.setAuthenticationDetailsSource(authenticationDetailsSource); 84 } 85 // 将短信认证过滤器加到 UsernamePasswordAuthenticationFilter 之后 86 http.addFilterAfter(authFilter, UsernamePasswordAuthenticationFilter.class); 87 } 88 89 }
之后,需要在 WebSecurityConfigurerAdapter 中调用 HttpSecurity.apply() 应用该配置。
1 package com.lyyzoo.sunny.security.config; 2 3 import org.springframework.beans.factory.annotation.Autowired; 4 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 5 import org.springframework.boot.context.properties.EnableConfigurationProperties; 6 import org.springframework.context.annotation.Bean; 7 import org.springframework.context.annotation.Configuration; 8 import org.springframework.security.config.annotation.web.builders.HttpSecurity; 9 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 10 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 11 import org.springframework.security.crypto.password.PasswordEncoder; 12 13 import com.lyyzoo.sunny.captcha.CaptchaMessageHelper; 14 import com.lyyzoo.sunny.security.core.*; 15 import com.lyyzoo.sunny.security.sms.SmsAuthenticationDetailsSource; 16 import com.lyyzoo.sunny.security.sms.SmsAuthenticationFailureHandler; 17 import com.lyyzoo.sunny.security.sms.SmsAuthenticationProvider; 18 import com.lyyzoo.sunny.security.sms.SmsLoginConfigurer; 19 20 /** 21 * Security 主配置器 22 * 23 * @author bojiangzhou 24 */ 25 @Configuration 26 @EnableConfigurationProperties(SecurityProperties.class) 27 public class SecurityConfiguration extends WebSecurityConfigurerAdapter { 28 29 @Autowired 30 private SecurityProperties properties; 31 @Autowired 32 private CustomAuthenticationDetailsSource authenticationDetailsSource; 33 @Autowired 34 private CustomAuthenticationProvider authenticationProvider; 35 @Autowired 36 private CustomAuthenticationSuccessHandler authenticationSuccessHandler; 37 @Autowired 38 private CustomAuthenticationFailureHandler authenticationFailureHandler; 39 @Autowired 40 private CustomUserDetailsService userDetailsService; 41 @Autowired 42 private CaptchaMessageHelper captchaMessageHelper; 43 44 @Override 45 @SuppressWarnings("unchecked") 46 protected void configure(HttpSecurity http) throws Exception { 47 http 48 .authorizeRequests() 49 .antMatchers("/static/**", "/webjars/**", "/public/**", "/favicon.ico", "/login", "/authentication/**", "/*.html") 50 .permitAll() // 允许匿名访问的地址 51 .and() // 使用and()方法相当于XML标签的关闭,这样允许我们继续配置父类节点。 52 .authorizeRequests() 53 .anyRequest() 54 .authenticated() // 其它地址都需进行认证 55 .and() 56 .formLogin() // 启用表单登录 57 .loginPage(properties.getLoginPage()) // 登录页面 58 .defaultSuccessUrl("/index") // 默认的登录成功后的跳转地址 59 .authenticationDetailsSource(authenticationDetailsSource) 60 .successHandler(authenticationSuccessHandler) 61 .failureHandler(authenticationFailureHandler) 62 .and() 63 .authenticationProvider(authenticationProvider) 64 .csrf() 65 .disable() 66 ; 67 68 if (properties.isEnableSmsLogin()) { 69 // 配置短信登录 70 SmsLoginConfigurer smsLoginConfigurer = new SmsLoginConfigurer(); 71 smsLoginConfigurer 72 .authenticationDetailsSource(smsAuthenticationDetailsSource()) 73 .successHandler(authenticationSuccessHandler) 74 .failureHandler(smsAuthenticationFailureHandler()) 75 ; 76 http.apply(smsLoginConfigurer); 77 http.authenticationProvider(smsAuthenticationProvider()); 78 } 79 } 80 81 /** 82 * 密码处理器 83 */ 84 @Bean 85 public PasswordEncoder passwordEncoder() { 86 return new BCryptPasswordEncoder(); 87 } 88 89 @Bean 90 @ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true", 91 matchIfMissing = true) 92 public SmsAuthenticationFailureHandler smsAuthenticationFailureHandler() { 93 return new SmsAuthenticationFailureHandler(); 94 } 95 96 @Bean 97 @ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true", 98 matchIfMissing = true) 99 public SmsAuthenticationDetailsSource smsAuthenticationDetailsSource() { 100 return new SmsAuthenticationDetailsSource(); 101 } 102 103 @Bean 104 @ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true", 105 matchIfMissing = true) 106 public SmsAuthenticationProvider smsAuthenticationProvider() { 107 return new SmsAuthenticationProvider(userDetailsService, captchaMessageHelper); 108 } 109 110 }
短信登录页面:
四、三方QQ登录
1、OAuth协议
OAuth 是一个授权协议,它的目的是让用户不用给客户端应用提供服务提供商(如QQ、微信)的账号和密码的情况下,让客户端应用可以有权限去访问用户在服务提供商的资源。
关于 OAuth 介绍建议直接看《阮一峰 - 理解OAuth 2.0》,深入浅出,容易理解,这里就不赘述了。我这里主要看下源码及流程实现。
OAuth协议中的各种角色:
服务提供商(Provider):谁提供令牌谁就是服务提供商,比如微信、QQ。
资源所有者(Resource Owner):即用户,我们要获取的即用户的资源。
第三方应用(Client):指获取授权的应用,一般就是我们自己开发的应用。
认证服务器(Authorization Server):即服务提供商专门用来处理认证的服务器,认证用户的身份并产生令牌。
资源服务器(Resource Server):即服务提供商存放用户生成的资源的服务器。认证服务器和资源服务器虽然是两个角色,但他们一般也可以在同一个应用,同一台机器上。
各种角色联系在一起构成 OAuth 的认证流程(授权码模式):
2、Spring Social
spring social 将 OAuth 认证的整个流程封装并实现,它已经提供了对主流社交网站的支持,只需要简单配置即可。针对上面的流程,来看下spring social 相关源码。
在 pom 中引入 spring-social 的依赖,版本使用 2.0.0.M4:
1 <dependency> 2 <groupId>org.springframework.social</groupId> 3 <artifactId>spring-social-core</artifactId> 4 </dependency> 5 <dependency> 6 <groupId>org.springframework.social</groupId> 7 <artifactId>spring-social-config</artifactId> 8 </dependency> 9 <dependency> 10 <groupId>org.springframework.social</groupId> 11 <artifactId>spring-social-security</artifactId> 12 </dependency> 13 <dependency> 14 <groupId>org.springframework.social</groupId> 15 <artifactId>spring-social-web</artifactId> 16 </dependency>
① 首先是服务提供商,对应 ServiceProvider ,这是一个顶层的接口定义。默认使用 AbstractOAuth2ServiceProvider。
② 从 AbstractOAuth2ServiceProvider 不难看出,需要提供 OAuth2Operations,OAuth2Operations 接口封装了 OAuth2 认证的整个标准流程,默认实现为 OAuth2Template。
③ AbstractOAuth2ServiceProvider 还需要提供一个 Api 接口,因为每个服务提供商返回的用户信息都是有差别的,这需要我们自己定义相关接口来获取用户信息。
spring social 提供了一个默认的抽象类 AbstractOAuth2ApiBinding,从其定义可以看出我们可以使用第6步中获取的服务提供商的令牌,使用 RestTemplate 发送请求来获取数据。
④ 使用 Api 获取到用户信息后,就需要使用 Connection 来封装用户信息,默认实现为 OAuth2Connection。
⑤ Connection 又是由 ConnectionFactory 创建出来的,默认使用 OAuth2ConnectionFactory。
⑥ ConnectionFactory 又需要 ServiceProvider 和 ApiAdapter:ServiceProvider 用来走认证流程,获取用户信息;ApiAdapter 则用来适配不同服务提供商返回来的用户数据,将其转换成标准的 Connection。最终,ConnectionFactory 就可以构建出 Connection。
⑦ 获取到三方应用的用户信息后,就需要和客户端应用的用户进行关联,获取客户端应用中用户的接口即为 UsersConnectionRepository。
3、流程分析
Social 认证是通过向 spring security 过滤器链加入 SocialAuthenticationFilter 过滤器来完成的,通过这个过滤器来了解下 spring-social 的认证流程。
① 通过判断是否需要认证的方法 requiresAuthentication 可以看出,认证的地址必须是 **/{filterProcessesUrl}/{providerId} 的形式,比如 www.lyyzoo.com/auth/qq。这里的 qq 即为 providerId,auth 为过滤器处理地址 filterProcessesUrl,这个值默认为 auth。
② 再看看认证的方法 attemptAuthentication,首先会检测用户是否拒绝授权,如果用户拒绝授权则直接抛出异常。然后获取 providerId 及对应的认证服务类,用于处理认证。认证失败,则重定向到一个地址去。
通过 detectRejection 可以看出,我们在请求登录时,不要随意设置参数,否则会被错误认为是用户拒绝授权的。
③ 认证方法中,从注释也可以了解到,第一次请求时,会抛出 AuthenticationRedirectException 异常,重定向到服务提供商的认证地址去。用户确认授权后,重定向回来时,就是第二次请求,就会拿着授权码去服务提供商那获取令牌。
在获取 SocialAuthenticationToken 的方法中可以看到,如果请求的参数中没有 code(授权码),则重定向到服务提供商那。通过 buildReturnToUrl 和 buildAuthenticateUrl 可以看出,会自动帮我们构造回调地址以及重定向到认证服务器的地址。
buildReturnToUrl 会构造回调地址,所以本地测试要使用域名访问,可以在 hosts 中配置域名映射。否则你访问 localhost 是重定向不回来的,而且域名必须与QQ互联上配置的域名保持一致。
buildAuthenticateUrl 会构造服务提供商的认证地址,会自动帮我们把 redirect_uri、state 等参数拼接上,在创建 OAuth2Template 时我们提供一个基础地址即可。
④ 第二次请求时,有了授权码,则会用授权码去获取令牌 AccessGrant 用于构造 Connection,最终构造 SocialAuthenticationToken(注意此时的 SocialAuthenticationToken 是未认证的) 。
通过 exchangeForAccess 方法,可以发现,会自动帮我们带上获取令牌的参数,如果要带上 client_id、client_secret 需配置 useParametersForClientAuthentication=true。
获取到令牌后会自动帮我们将令牌封装到 AccessGrant 里,默认返回的数据结构为 Map,所以如果服务提供商返回令牌信息时不是 Map 结构的还需定制化处理。
⑤ 创建好 AccessGrant 后,通过 OAuth2ConnectionFactory 创建 Connection,实际是创建 OAuth2Connection 对象。initApi() 方法会获取 ServiceProvider 中配置的Api。
initKey() 用于生成服务提供商用户唯一的 key,根据 providerId 和 providerUserId(服务提供商的用户ID,即openId) 创建。而 providerUserId 则是通过 ApiAdapter 适配器来获取,这需要我们自行设置。
⑥ 获取到 SocialAuthenticationToken 后,相当于服务提供商那边认证完成,接着就会调用 doAuthentication 进行客户端用户认证。
与标准登录流程类似,同样可以自定义 AuthenticationDetailsSource;接着调用认证器进行认证,spring social 的认证器默认使用 SocialAuthenticationProvider 。
从其认证方法可以看出,将通过之前得到的 providerId 和 providerUserId 来获取 userId (客户端用户ID),这里 spring social 默认有一张表来存储 userId、providerId、providerUserId 之间的关系,可配置 JdbcUsersConnectionRepository 来维护对应的关系。
如果没有获取到对应的 userId,将抛出 BadCredentialsException,在 doAuthentication 里拦截到这个异常后,默认将重定向到 signupUrl 这个注册页面的地址,让用户先注册或绑定三方账号。signupUrl 默认为 "/signup"。
获取到对应的 userId后,就根据 userId 查询用户信息,这需要我们自定义 SocialUserDetailsService 及 SocialUserDetails。获取到用户后,就会创建已认证的 SocialAuthenticationToken。
⑦ 通过 toUserId() 可以发现,根据 Connection 查找系统 userId 时,JdbcUsersConnectionRepository 默认的处理方式是:如果未查询到关联的 userId,可以自定义一个 ConnectionSignUp 用于注册用户并返回一个 userId,并且会调用 addConnection 添加关联。所以对于用户如果未注册,使用三方账号扫码自动注册用户的需求,就可以使用这种方式实现。
⑧ 客户端这边认证成功后,就会通过 updateConnections 或 addConnection 将用户的 access_token、refresh_token、secret、用户和服务商的关联 等更新到数据库。
4、QQ登录准备工作
① 社交登录必须要有一个外网能访问的域名,所以首先需要自己申请一个域名,然后备案,再将域名指向一台可访问的服务器,将服务部署到这台服务器上。推荐在阿里云上完成这一整套的配置,就不在这里细说了。
② 到 [QQ互联] 上申请成为开发者,然后通过创建应用获取QQ的appId和appKey。
在创建应用时,网站地址 填写公网可访问的域名即可;网站回调域 即请求QQ后回调的地址,这个后面再做详细说明。
③ 获取授权码地址
参考QQ互联 使用Authorization_Code获取Access_Token 可以得知获取授权码的地址:[https://graph.qq.com/oauth2.0/authorize],注意请求的参数有 response_type、client_id、redirect_uri、state 等。
client_id 即你申请的 appId,redirect_uri 即网站回调域。
认证的时候,用户成功授权,则会跳转到指定的回调地址,即参数 <redirect_uri>,也即创建应用时填写的 <网站回调域>,这二者必须保持一致,否则会提示重定向地址非法。
④ 获取令牌地址
可以得到授权码地址 [https://graph.qq.com/oauth2.0/token] ,注意 grant_type、client_id、client_secret、code、redirect_uri 这些必须参数。
client_id 即 appId,client_secret 即 appKey,code 为获取的授权码。
⑤ QQ访问用户资料API
QQ互联上提供了如下的一些API,其中访问用户资料的API是不需要申请的。[QQ互联API列表]
从文档中可以得到访问用户资料的地址:[ https://graph.qq.com/user/get_user_info ]
而要调用这个接口则必须带上获取的令牌(access_token),客户端应用申请的 appId,以及 openId,即用户的QQ号,可以使用 [ https://graph.qq.com/oauth2.0/me?access_token=YOUR_ACCESS_TOKEN ] 地址来获取QQ号。
使用 [ https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID ] 地址来获取用户资料。
返回参数,这些参数将封装到特定的 UserInfo 中。
最后,通过返回码来判断是成功还是失败。
5、QQ登录实现
从 SpringSocial 的源码分析中可以得知,我们主要目的就是获取服务提供商的用户信息,用户信息则封装到 Connection 中,想要获得 Connection 就需要 ConnectionFactory,想要构造一个 ConnectionFactory 就需要 ServiceProvider 和 ApiAdapter,ServiceProvider 又需要 OAuth2Operations 和 Api。下面来一步步实现获取QQ用户资料从而登录的流程。
① 构建 Api
首先根据获取QQ用户信息的接口封装QQ用户信息以及QQApi接口。
1 package com.lyyzoo.sunny.security.social.qq.api; 2 3 import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 5 /** 6 * QQ 用户信息 7 * 8 * @author bojiangzhou 2018/10/16 9 */ 10 @JsonIgnoreProperties(ignoreUnknown = true) 11 public class QQUser { 12 13 private String ret; 14 15 private String msg; 16 17 private String openId; 18 19 private String nickname; 20 21 private String figureurl; 22 23 private String gender; 24 25 //getter setter 26 }
1 package com.lyyzoo.sunny.security.social.qq.api; 2 3 /** 4 * QQ API 5 * 6 * @author bojiangzhou 2018/10/16 7 */ 8 public interface QQApi { 9 10 /** 11 * 获取QQ用户信息 12 */ 13 QQUser getQQUser(); 14 15 }
提供 Api 默认实现,继承 AbstractOAuth2ApiBinding,用户信息api需要参数 appId 及 openId,而想要获取 openId 就要使用 access_token 获取用户 openId。
1 package com.lyyzoo.sunny.security.social.qq.api; 2 3 import java.io.IOException; 4 5 import org.apache.commons.lang3.StringUtils; 6 import org.slf4j.Logger; 7 import org.slf4j.LoggerFactory; 8 import org.springframework.social.oauth2.AbstractOAuth2ApiBinding; 9 import org.springframework.social.oauth2.TokenStrategy; 10 11 import com.fasterxml.jackson.databind.ObjectMapper; 12 import com.lyyzoo.sunny.core.exception.CommonException; 13 import com.lyyzoo.sunny.security.social.exception.ProviderUserNotFoundException; 14 15 /** 16 * QQ API 默认实现,继承 {@link AbstractOAuth2ApiBinding}。 17 * 由于 Api 会使用得到的令牌来获取信息,每个用户的令牌是不同的,所以该类不是一个单例对象,每次访问 Api 都需要新建实例。 18 * 19 * @author bojiangzhou 2018/10/16 20 */ 21 public class DefaultQQApi extends AbstractOAuth2ApiBinding implements QQApi { 22 23 private static final Logger LOGGER = LoggerFactory.getLogger(DefaultQQApi.class); 24 25 /** 26 * QQ 获取 openId 的地址 27 */ 28 private static final String URL_GET_OPEN_ID = "https://graph.qq.com/oauth2.0/me?access_token={accessToken}"; 29 /** 30 * QQ 获取用户信息的地址 31 */ 32 private static final String URL_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key={appId}&openid={openId}"; 33 34 /** 35 * 客户端 appId 36 */ 37 private String appId; 38 /** 39 * openId 40 */ 41 private String openId; 42 43 private ObjectMapper mapper = new ObjectMapper(); 44 45 public DefaultQQApi(String accessToken, String appId) { 46 super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER); 47 this.appId = appId; 48 this.openId = getOpenId(accessToken); 49 } 50 51 @Override 52 public QQUser getQQUser() { 53 String result = getRestTemplate().getForObject(URL_GET_USER_INFO, String.class, appId, openId); 54 55 QQUser user = null; 56 try { 57 user = mapper.readValue(result, QQUser.class); 58 } catch (IOException e) { 59 LOGGER.error("parse qq UserInfo error."); 60 } 61 if (user == null) { 62 throw new ProviderUserNotFoundException("login.provider.user.not-found"); 63 } 64 user.setOpenId(openId); 65 return user; 66 } 67 68 /** 69 * 获取用户 OpenId 70 */ 71 private String getOpenId(String accessToken) { 72 // 返回结构:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} ); 73 String openIdResult = getRestTemplate().getForObject(URL_GET_OPEN_ID, String.class, accessToken); 74 if (StringUtils.isBlank(openIdResult) || openIdResult.contains("code")) { 75 throw new CommonException("获取QQ账号错误"); 76 } 77 // 解析 openId 78 String[] arr = StringUtils.substringBetween(openIdResult, "{", "}").replace("\"", "").split(","); 79 String openid = null; 80 for (String s : arr) { 81 if (s.contains("openid")) { 82 openid = s.split(":")[1]; 83 } 84 } 85 return openid; 86 } 87 }
② 构建QQApiAdapter 适配器,在QQApi 与 Connection之间做适配。
1 package com.lyyzoo.sunny.security.social.qq.connection; 2 3 import com.lyyzoo.sunny.security.social.qq.api.QQApi; 4 import com.lyyzoo.sunny.security.social.qq.api.QQUser; 5 import org.springframework.social.connect.ApiAdapter; 6 import org.springframework.social.connect.ConnectionValues; 7 import org.springframework.social.connect.UserProfile; 8 9 /** 10 * QQApi 适配器 11 * 12 * @author bojiangzhou 2018/10/17 13 */ 14 public class QQApiAdapter implements ApiAdapter<QQApi> { 15 16 /** 17 * 测试Api连接是否可用 18 * 19 * @param api QQApi 20 */ 21 @Override 22 public boolean test(QQApi api) { 23 return true; 24 } 25 26 /** 27 * QQApi 与 Connection 做适配 28 * @param api QQApi 29 * @param values Connection 30 */ 31 @Override 32 public void setConnectionValues(QQApi api, ConnectionValues values) { 33 QQUser user = api.getQQUser(); 34 35 values.setDisplayName(user.getNickname()); 36 values.setImageUrl(user.getFigureurl()); 37 values.setProviderUserId(user.getOpenId()); 38 } 39 40 @Override 41 public UserProfile fetchUserProfile(QQApi api) { 42 return null; 43 } 44 45 @Override 46 public void updateStatus(QQApi api, String message) { 47 48 } 49 }
③ 定制化 QQOAuth2Template,因为标准的 OAuth2Template 处理令牌时,要求返回的数据结构为 Map,而QQ返回的令牌是一个字符串,因此需要定制处理。
1 package com.lyyzoo.sunny.security.social.qq.connection; 2 3 import org.apache.commons.lang3.StringUtils; 4 import org.slf4j.Logger; 5 import org.slf4j.LoggerFactory; 6 import org.springframework.http.converter.StringHttpMessageConverter; 7 import org.springframework.social.oauth2.AccessGrant; 8 import org.springframework.social.oauth2.OAuth2Template; 9 import org.springframework.util.MultiValueMap; 10 import org.springframework.web.client.RestClientException; 11 import org.springframework.web.client.RestTemplate; 12 13 import com.google.common.base.Charsets; 14 15 /** 16 * 定制 OAuth2Template 17 * 18 * @author bojiangzhou 2018/10/26 19 */ 20 public class QQOauth2Template extends OAuth2Template { 21 22 private static final Logger LOGGER = LoggerFactory.getLogger(QQOauth2Template.class); 23 24 public QQOauth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) { 25 super(clientId, clientSecret, authorizeUrl, accessTokenUrl); 26 // 设置带上 client_id、client_secret 27 setUseParametersForClientAuthentication(true); 28 } 29 30 /** 31 * 解析 QQ 返回的令牌 32 */ 33 @Override 34 protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) { 35 // 返回格式:access_token=FE04********CCE2&expires_in=7776000&refresh_token=88E4***********BE14 36 String result = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class); 37 if (StringUtils.isBlank(result)) { 38 throw new RestClientException("access token endpoint returned empty result"); 39 } 40 LOGGER.debug("==> get qq access_token: " + result); 41 String[] arr = StringUtils.split(result, "&"); 42 String accessToken = "", expireIn = "", refreshToken = ""; 43 for (String s : arr) { 44 if (s.contains("access_token")) { 45 accessToken = s.split("=")[1]; 46 } else if (s.contains("expires_in")) { 47 expireIn = s.split("=")[1]; 48 } else if (s.contains("refresh_token")) { 49 refreshToken = s.split("=")[1]; 50 } 51 } 52 return createAccessGrant(accessToken, null, refreshToken, Long.valueOf(expireIn), null); 53 } 54 55 /** 56 * QQ 响应 ContentType=text/html;因此需要加入 text/html; 的处理器 57 */ 58 @Override 59 protected RestTemplate createRestTemplate() { 60 RestTemplate restTemplate = super.createRestTemplate(); 61 restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charsets.UTF_8)); 62 return restTemplate; 63 } 64 }
④ 通过 QQOAuth2Template 和 QQApi 构造 QQServiceProvider,创建 OAuth2Template 时,需传入获取授权码的地址和获取令牌的地址。
1 package com.lyyzoo.sunny.security.social.qq.connection; 2 3 import com.lyyzoo.sunny.security.social.qq.api.DefaultQQApi; 4 import com.lyyzoo.sunny.security.social.qq.api.QQApi; 5 import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider; 6 7 /** 8 * QQ 服务提供商 9 * 10 * @author bojiangzhou 2018/10/17 11 */ 12 public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApi> { 13 /** 14 * 获取授权码地址(引导用户跳转到这个地址上去授权) 15 */ 16 private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize"; 17 /** 18 * 获取令牌地址 19 */ 20 private static final String URL_GET_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token"; 21 22 private String appId; 23 24 public QQServiceProvider(String appId, String appSecret) { 25 super(new QQOauth2Template(appId, appSecret, URL_AUTHORIZE, URL_GET_ACCESS_TOKEN)); 26 this.appId = appId; 27 } 28 29 @Override 30 public QQApi getApi(String accessToken) { 31 return new DefaultQQApi(accessToken, appId); 32 } 33 }
⑤ 通过QQServiceProvider和QQApiAdapter构造 QQConnectionFactory。
1 package com.lyyzoo.sunny.security.social.qq.connection; 2 3 import com.lyyzoo.sunny.security.social.qq.api.QQApi; 4 import org.springframework.social.connect.support.OAuth2ConnectionFactory; 5 6 /** 7 * QQ Connection 工厂 8 * 9 * @author bojiangzhou 2018/10/17 10 */ 11 public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApi> { 12 13 14 public QQConnectionFactory(String providerId, String appId, String appSecret) { 15 super(providerId, new QQServiceProvider(appId, appSecret), new QQApiAdapter()); 16 } 17 }
⑥ 自定义 CustomSocialUserDetails 及 CustomSocialUserDetailsService,封装 Social 专用的 UserDetails 对象。与 CustomUserDetails 和 CustomUserDetailsService 类似。
1 package com.lyyzoo.sunny.security.social.common; 2 3 import java.util.Collection; 4 5 import org.springframework.security.core.GrantedAuthority; 6 import org.springframework.security.core.userdetails.User; 7 import org.springframework.social.security.SocialUserDetails; 8 9 /** 10 * 定制 SocialUserDetails 封装 Social 登录用户信息 11 * 12 * @author bojiangzhou 2018/10/17 13 */ 14 public class CustomSocialUserDetails extends User implements SocialUserDetails { 15 16 private String userId; 17 18 private String nickname; 19 20 private String language; 21 22 public CustomSocialUserDetails(String username, String password, String userId, String nickname, String language, 23 Collection<? extends GrantedAuthority> authorities) { 24 super(username, password, authorities); 25 this.userId = userId; 26 this.nickname = nickname; 27 this.language = language; 28 } 29 30 @Override 31 public String getUserId() { 32 return userId; 33 } 34 35 public String getNickname() { 36 return nickname; 37 } 38 39 public String getLanguage() { 40 return language; 41 } 42 }
1 package com.lyyzoo.sunny.security.social.common; 2 3 import java.util.ArrayList; 4 import java.util.Collection; 5 6 import org.springframework.beans.factory.annotation.Autowired; 7 import org.springframework.security.core.GrantedAuthority; 8 import org.springframework.security.core.authority.SimpleGrantedAuthority; 9 import org.springfr