基于SpringBoot搭建应用开发框架(二) —— 登录认证

时间:2024-01-03 12:06:20

零、前言

本文基于《基于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:管理端服务,业务中心。

  基于SpringBoot搭建应用开发框架(二) —— 登录认证

本篇将会一步步完成系统的登录认证,包括常规的用户名+密码登录、以及社交方式登录,如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:

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 开发一个TestController

基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ 不做任何配置,启动系统,然后访问 localhost:8010/test 时,会自动跳转到SpringSecurity默认的登录页面去进行认证。那这登录的用户名和密码从哪来呢?

基于SpringBoot搭建应用开发框架(二) —— 登录认证

启动项目时,从控制台输出中可以找到生成的 security 密码,从 UserDetailsServiceAutoConfiguration 可以得知,使用的是基于内存的用户管理器,默认的用户名为 user,密码是随机生成的UUID。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

我们也可以修改默认的用户名和密码。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

④ 使用 user 和生成的UUID密码登录成功后即可访问 /test 资源,最简单的一个认证就完成了。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

在不做任何配置的情况下,security会把服务内所有资源的访问都保护起来,需要先进行身份证认证才可访问, 使用默认的表单登录或http basic认证方式。

不过这种默认方式肯定无法满足我们的需求,我们的用户名和密码都是存在数据库的。下面我们就来看看在 spring boot 中我们如何去配置自己的登录页面以及从数据库获取用户数据来完成用户登录。

2、自定义登录页面

① 首先开发一个登录页面,由于页面中会使用到一些动态数据,决定使用 thymeleaf 模板引擎,只需在 pom 中引入如下依赖,使用默认配置即可,具体有哪些配置可从 ThymeleafProperties 中了解到。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 同时,在 resources 目录下,建 static 和 templates 两个目录,static 目录用于存放静态资源,templates 用于存放 thymeleaf 模板页面,同时配置MVC的静态资源映射。

基于SpringBoot搭建应用开发框架(二) —— 登录认证   基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ 开发后台首页、登录页面的跳转地址,/login 接口用于向登录页面传递登录相关的数据,如用户名、是否启用验证码、错误消息等。

 package com.lyyzoo.sunny.security.controller;

 import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.WebAttributes;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody; import com.lyyzoo.sunny.captcha.CaptchaImageHelper;
import com.lyyzoo.sunny.core.base.Result;
import com.lyyzoo.sunny.core.message.MessageAccessor;
import com.lyyzoo.sunny.core.userdetails.CustomUserDetails;
import com.lyyzoo.sunny.core.userdetails.DetailsHelper;
import com.lyyzoo.sunny.core.util.Results;
import com.lyyzoo.sunny.security.constant.SecurityConstants;
import com.lyyzoo.sunny.security.domain.entity.User;
import com.lyyzoo.sunny.security.domain.service.ConfigService;
import com.lyyzoo.sunny.security.domain.service.UserService; /**
*
* @author bojiangzhou 2018/03/28
*/
@Controller
public class SecurityController { private static final String LOGIN_PAGE = "login"; private static final String INDEX_PAGE = "index"; private static final String FIELD_ERROR_MSG = "errorMsg";
private static final String FIELD_ENABLE_CAPTCHA = "enableCaptcha"; @Autowired
private CaptchaImageHelper captchaImageHelper;
@Autowired
private UserService userService;
@Autowired
private ConfigService configService; @RequestMapping("/index")
public String index() {
return INDEX_PAGE;
} @GetMapping("/login")
public String login(HttpSession session, Model model) {
String errorMsg = (String) session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
String username = (String) session.getAttribute(User.FIELD_USERNAME);
if (StringUtils.isNotBlank(errorMsg)) {
model.addAttribute(FIELD_ERROR_MSG, errorMsg);
}
if (StringUtils.isNotBlank(username)) {
model.addAttribute(User.FIELD_USERNAME, username);
User user = userService.getUserByUsername(username);
if (user == null) {
model.addAttribute(FIELD_ERROR_MSG, MessageAccessor.getMessage("login.username-or-password.error"));
} else {
if (configService.isEnableCaptcha(user.getPasswordErrorTime())) {
model.addAttribute(FIELD_ENABLE_CAPTCHA, true);
}
}
}
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); return LOGIN_PAGE;
} @GetMapping("/public/captcha.jpg")
public void captcha(HttpServletResponse response) {
captchaImageHelper.generateAndWriteCaptchaImage(response, SecurityConstants.SECURITY_KEY);
} @GetMapping("/user/self")
@ResponseBody
public Result test() {
CustomUserDetails details = DetailsHelper.getUserDetails(); return Results.successWithData(details);
} }

④  从 spring boot 官方文档可以得知,spring security 的核心配置都在 WebSecurityConfigurerAdapter 里,我们只需继承该适配器覆盖默认配置即可。首先来看看默认的登录页面以及如何配置登录页面。

通过 HttpSecurity 配置安全策略,首先开放了允许匿名访问的地址,除此之外都需要认证,通过 formLogin() 来启用表单登录,并配置了默认的登录页面,以及登录成功后的首页地址。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

启动系统,访问资源跳转到自定义的登录页面了:

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑤ 那么默认的登录页面是怎么来的呢,以及做了哪些默认配置?

从 formLogin() 可以看出,启用表单登录即启用了表单登录的配置 FormLoginConfigurer:

基于SpringBoot搭建应用开发框架(二) —— 登录认证

从 FormLoginConfigurer 的构造函数中可以看出,表单登录用户名和密码的参数默认配置为 username 和 password,所以,我们的登录页面中需和这两个参数配置成一样,当然了,我们也可以在 formLogin() 后自定义这两个参数。

同时,可以看出开启了 UsernamePasswordAuthenticationFilter 过滤器,用于 用户名+密码 登录方式的认证,这个之后再说明。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

从初始化配置中可以看出,默认创建了 DefaultLoginPageGeneratingFilter 过滤器用于生成默认的登录页面,从该过滤器的初始化方法中我们也可以了解到一些默认的配置。这个过滤器只有在未配置自定义登录页面时才会生效。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

3、SpringSecurity基本原理

在进行后面的开发前,先来了解下 spring security 的基本原理。

spring security 的核心是过滤器链,即一组 Filter。所有服务资源的请求都会经过 spring security 的过滤器链,并响应返回。

我们从控制台中可以找到输出过滤器链的类 DefaultSecurityFilterChain,在现有的配置上,可以看到当前过滤器链共有13个过滤器。

每个过滤器主要做什么可以参考:Spring Security 核心过滤器链分析

基于SpringBoot搭建应用开发框架(二) —— 登录认证

过滤器链的创建是通过 HttpSecurity 的配置而来,实际上,每个 HttpSecurity 的配置都会创建相应的过滤器链来处理对应的请求,每个请求都会进入 FilterChainProxy 过滤器,根据请求选择一个合适的过滤器链来处理该请求。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

过滤器的顺序我们可以从 FilterComparator 中得知,并且可以看出 spring security 默认有25个过滤器(自行查看):

基于SpringBoot搭建应用开发框架(二) —— 登录认证

不难发现,几乎所有的过滤器都直接或间接继承自 GenericFilterBean,通过这个基础过滤器可以看到都有哪些过滤器,通过每个过滤器的名称我们能大概了解到 spring security 为我们提供了哪些功能,要启用这些功能,只需通过配置加入相应的过滤器即可,比如 oauth 认证。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

过滤器链中,绿色框出的这类过滤器主要用于用户认证,这些过滤器会根据当前的请求检查是否有这个过滤器所需的信息,如果有则进入该过滤器,没有则不会进入下一个过滤器。

比如这里,如果是表单登录,要求必须是[POST /login],则进入 UsernamePasswordAuthenticationFilter 过滤器,使用用户名和密码进行认证,不会再进入BasicAuthenticationFilter;

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

如果使用 http basic 的方式进行认证,要求请求头必须包含 Authorization,且值以 basic 打头,则进入 BasicAuthenticationFilter 进行认证。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

经过前面的过滤器后,最后会进入到 FilterSecurityInterceptor,这是整个 spring security 过滤器链的最后一环,在它身后就是服务的API。

这个过滤器会去根据配置决定当前的请求能不能访问真正的资源,主要一些实现功能在其父类AbstractSecurityInterceptor中。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

[1] 拿到的是权限配置,会根据这些配置决定访问的API能否通过。

[2] 当前上下文必须有用户认证信息 Authentication,就算是匿名访问也会有相应的过滤器来生成 Authentication。不难发现,不同类型的认证过滤器对应了不同的 Authentication。使用用户名和密码登录时,就会生成 UsernamePasswordAuthenticationToken。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

[3] 用户认证,首先判断用户是否已认证通过,认证通过则直接返回 Authentication,否则调用认证器进行认证。认证通过之后将 Authentication 放到 Security 的上下文,这就是为何我们能从 SecurityContextHolder 中取到 Authentication 的源头。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

认证管理器是默认配置的 ProviderManager,ProviderManager 则管理者多个 AuthenticationProvider 认证器 ,认证的时候,只要其中一个认证器认证通过,则标识认证通过。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

认证器:表单登录默认使用 DaoAuthenticationProvider,我们想要实现从数据库获取用户名和密码就得从这里入手。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

[4] 认证通过后,使用权限决定管理器 AccessDecisionManager 判断是否有权限,管理器则管理者多个 权限投票器 AccessDecisionVoter,通过投票器来决定是否有权限访问资源。因此,我们也可以自定义投票器来判断用户是否有权限访问某个API。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

最后,如果未认证通过或没有权限,FilterSecurityInterceptor 则抛出相应的异常,异常会被 ExceptionTranslationFilter 捕捉到,进行统一的异常处理分流,比如未登录时,重定向到登录页面;没有权限的时候抛出403异常等。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

4、用户认证流程

从 spring security 基本原理的分析中不难发现,用户的认证过程涉及到三个主要的组件:

AbstractAuthenticationProcessingFilter:它在基于web的认证请求中用于处理包含认证信息的请求,创建一个部分完整的Authentication对象以在链中传递凭证信息。

AuthenticationManager:它用来校验用户的凭证信息,或者会抛出一个特定的异常(校验失败的情况)或者完整填充Authentication对象,将会包含了权限信息。

AuthenticationProvider:它为AuthenticationManager提供凭证校验。一些AuthenticationProvider的实现基于凭证信息的存储,如数据库,来判定凭证信息是否可以被认可。

我们从核心的 AbstractAuthenticationProcessingFilter 入手,来分析下用户认证的流程。

[1] 可以看到,首先会调用 attemptAuthentication 来获取认证后的 Authentication。attemptAuthentication 是一个抽象方法,在其子类中实现。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

前面提到过,启用表单登录时,就会创建 UsernamePasswordAuthenticationFilter 用于处理表单登录。后面开发 oauth2 认证的时候则会用到 OAuth2 相关的过滤器。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

从 attemptAuthentication 的实现中可以看出,主要是将 username 和 password 封装到 UsernamePasswordAuthenticationToken。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

从当前 UsernamePasswordAuthenticationToken 的构造方法中可以看出,此时的 Authentication 设置了未认证状态。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

【#】通过 setDetails 可以向 UsernamePasswordAuthenticationToken  中加入 Details 用于后续流程的处理,稍后我会实现AuthenticationDetailsSource 将验证码放进去用于后面的认证。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

之后,通过 AuthenticationManager 进行认证,实际是 ProviderManager 管理着一些认证器,这些配置都可以通过 setter 方法找到相应配置的位置,这里就不赘述了。

不难发现,用户认证器使用的是 AbstractUserDetailsAuthenticationProvider,流程主要涉及到 retrieveUser  和 additionalAuthenticationChecks 两个抽象方法。

【#】AbstractUserDetailsAuthenticationProvider 默认只有一个实现类 DaoAuthenticationProvider,获取用户信息、用户密码校验都是在这个实现类里,因此我们也可以实现自己的 AbstractUserDetailsAuthenticationProvider 来处理相关业务。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

【#】从 retrieveUser 中可以发现,主要使用 UserDetailsService 来获取用户信息,该接口只有一个方法 loadUserByUsername,我们也会实现该接口来从数据库获取用户信息。如果有复杂的业务逻辑,比如锁定用户等,还可以覆盖 retrieveUser 方法。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

用户返回成功后,就会通过 PasswordEncoder 来校验用户输入的密码和数据库密码是否匹配。注意数据库存入的密码是加密后的密码,且不可逆。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

用户、密码都校验通过后,就会创建已认证的 Authentication,从此时 UsernamePasswordAuthenticationToken 的构造方法可以看出,构造的是一个已认证的 Authentication。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

[2] 如果用户认证失败,会调用 AuthenticationFailureHandler 的 onAuthenticationFailure 方法进行认证失败后的处理,我们也会实现这个接口来做一些失败后逻辑处理。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

[3] 用户认证成功,将 Authentication 放入 security 上下文,调用 AuthenticationSuccessHandler 做认证成功的一些后续逻辑处理,我们也会实现这个接口。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

5、用户认证代码实现

通过 spring security 基本原理分析和用户认证流程分析,我们已经能够梳理出完成认证需要做哪些工作了。

① 首先设计并创建系统用户表:

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② CustomUserDetails

自定义 UserDetails,根据自己的需求将一些常用的用户信息封装到 UserDetails 中,便于快速获取用户信息,比如用户ID、昵称等。

 package com.lyyzoo.sunny.core.userdetails;

 import java.util.Collection;
import java.util.Objects; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User; /**
* 定制的UserDetail对象
*
* @author bojiangzhou 2018/09/02
*/
public class CustomUserDetails extends User {
private static final long serialVersionUID = -4461471539260584625L; private Long userId; private String nickname; private String language; public CustomUserDetails(String username, String password, Long userId, String nickname, String language,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
this.userId = userId;
this.nickname = nickname;
this.language = language;
} public Long getUserId() {
return userId;
} public void setUserId(Long userId) {
this.userId = userId;
} public String getNickname() {
return nickname;
} public void setNickname(String nickname) {
this.nickname = nickname;
} public String getLanguage() {
return language;
} public void setLanguage(String language) {
this.language = language;
} @Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof CustomUserDetails)) {
return false;
}
if (!super.equals(o)) {
return false;
} CustomUserDetails that = (CustomUserDetails) o; if (!Objects.equals(userId, that.userId)) {
return false;
}
return false;
} @Override
public int hashCode() {
int result = super.hashCode();
result = 31 * result + userId.hashCode();
result = 31 * result + nickname.hashCode();
result = 31 * result + language.hashCode();
return result;
} }

③ CustomUserDetailsService

自定义 UserDetailsService 来从数据库获取用户信息,并将用户信息封装到 CustomUserDetails

 package com.lyyzoo.sunny.security.core;

 import java.util.ArrayList;
import java.util.Collection; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component; import com.lyyzoo.sunny.core.message.MessageAccessor;
import com.lyyzoo.sunny.core.userdetails.CustomUserDetails;
import com.lyyzoo.sunny.security.domain.entity.User;
import com.lyyzoo.sunny.security.domain.service.UserService; /**
* 加载用户信息实现类
*
* @author bojiangzhou 2018/03/25
*/
@Component
public class CustomUserDetailsService implements UserDetailsService { @Autowired
private UserService userService; @Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.getUserByUsername(username);
if (user == null) {
throw new UsernameNotFoundException(MessageAccessor.getMessage("login.username-or-password.error"));
} Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_USER")); return new CustomUserDetails(username, user.getPassword(), user.getId(),
user.getNickname(), user.getLanguage(), authorities);
} }

④ CustomWebAuthenticationDetails

自定义 WebAuthenticationDetails 用于封装传入的验证码以及缓存的验证码,用于后续校验。

 package com.lyyzoo.sunny.security.core;

 import javax.servlet.http.HttpServletRequest;

 import com.lyyzoo.sunny.captcha.CaptchaResult;
import org.springframework.security.web.authentication.WebAuthenticationDetails; /**
* 封装验证码
*
* @author bojiangzhou 2018/09/18
*/
public class CustomWebAuthenticationDetails extends WebAuthenticationDetails { public static final String FIELD_CACHE_CAPTCHA = "cacheCaptcha"; private String inputCaptcha;
private String cacheCaptcha; public CustomWebAuthenticationDetails(HttpServletRequest request) {
super(request);
cacheCaptcha = (String) request.getAttribute(FIELD_CACHE_CAPTCHA);
inputCaptcha = request.getParameter(CaptchaResult.FIELD_CAPTCHA);
} public String getInputCaptcha() {
return inputCaptcha;
} public String getCacheCaptcha() {
return cacheCaptcha;
} @Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (object == null || getClass() != object.getClass()) {
return false;
}
if (!super.equals(object)) {
return false;
} CustomWebAuthenticationDetails that = (CustomWebAuthenticationDetails) object; return inputCaptcha != null ? inputCaptcha.equals(that.inputCaptcha) : that.inputCaptcha == null;
} @Override
public int hashCode() {
int result = super.hashCode();
result = 31 * result + (inputCaptcha != null ? inputCaptcha.hashCode() : 0);
return result;
}
}
package com.lyyzoo.sunny.security.core; import javax.servlet.http.HttpServletRequest; import com.lyyzoo.sunny.captcha.CaptchaResult;
import org.springframework.security.web.authentication.WebAuthenticationDetails; /**
* 封装验证码
*
* @author bojiangzhou 2018/09/18
*/
public class CustomWebAuthenticationDetails extends WebAuthenticationDetails { public static final String FIELD_CACHE_CAPTCHA = "cacheCaptcha"; private String inputCaptcha;
private String cacheCaptcha; public CustomWebAuthenticationDetails(HttpServletRequest request) {
super(request);
cacheCaptcha = (String) request.getAttribute(FIELD_CACHE_CAPTCHA);
inputCaptcha = request.getParameter(CaptchaResult.FIELD_CAPTCHA);
} public String getInputCaptcha() {
return inputCaptcha;
} public String getCacheCaptcha() {
return cacheCaptcha;
} @Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (object == null || getClass() != object.getClass()) {
return false;
}
if (!super.equals(object)) {
return false;
} CustomWebAuthenticationDetails that = (CustomWebAuthenticationDetails) object; return inputCaptcha != null ? inputCaptcha.equals(that.inputCaptcha) : that.inputCaptcha == null;
} @Override
public int hashCode() {
int result = super.hashCode();
result = 31 * result + (inputCaptcha != null ? inputCaptcha.hashCode() : 0);
return result;
}
}

⑤ CustomAuthenticationDetailsSource

当然了,还需要一个构造验证码的 AuthenticationDetailsSource

 package com.lyyzoo.sunny.security.core;

 import javax.servlet.http.HttpServletRequest;

 import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.stereotype.Component; import com.lyyzoo.sunny.captcha.CaptchaImageHelper;
import com.lyyzoo.sunny.security.constant.SecurityConstants; /**
* 自定义获取AuthenticationDetails 用于封装传进来的验证码
*
* @author bojiangzhou 2018/09/18
*/
@Component
public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> { @Autowired
private CaptchaImageHelper captchaImageHelper; @Override
public WebAuthenticationDetails buildDetails(HttpServletRequest request) {
String cacheCaptcha = captchaImageHelper.getCaptcha(request, SecurityConstants.SECURITY_KEY);
request.setAttribute(CustomWebAuthenticationDetails.FIELD_CACHE_CAPTCHA, cacheCaptcha);
return new CustomWebAuthenticationDetails(request);
} }

⑥ CustomAuthenticationProvider

自定义认证处理器,主要加入了验证码的检查,如果用户密码输入错误三次以上,则需要验证码。

 package com.lyyzoo.sunny.security.core;

 import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import com.lyyzoo.sunny.security.domain.entity.User;
import com.lyyzoo.sunny.security.domain.service.ConfigService;
import com.lyyzoo.sunny.security.domain.service.UserService; /**
* 自定义认证器
*
* @author bojiangzhou 2018/09/09
*/
@Component
public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired
private UserService userService;
@Autowired
private CustomUserDetailsService detailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private ConfigService configService; @Override
protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 如有其它逻辑处理,可在此处进行逻辑处理...
return detailsService.loadUserByUsername(username);
} @Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
String username = userDetails.getUsername();
User user = userService.getUserByUsername(username); // 检查验证码
if (authentication.getDetails() instanceof CustomWebAuthenticationDetails) {
if (configService.isEnableCaptcha(user.getPasswordErrorTime())) {
CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
String inputCaptcha = details.getInputCaptcha();
String cacheCaptcha = details.getCacheCaptcha();
if (StringUtils.isEmpty(inputCaptcha) || !StringUtils.equalsIgnoreCase(inputCaptcha, cacheCaptcha)) {
throw new AuthenticationServiceException("login.captcha.error");
}
authentication.setDetails(null);
}
} // 检查密码是否正确
String password = userDetails.getPassword();
String rawPassword = authentication.getCredentials().toString(); boolean match = passwordEncoder.matches(rawPassword, password);
if (!match) {
throw new BadCredentialsException("login.username-or-password.error");
}
}
}

⑦ CustomAuthenticationSuccessHandler

自定义认证成功处理器,用户认证成功,将密码错误次数置零。

 package com.lyyzoo.sunny.security.core;

 import java.io.IOException;

 import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.stereotype.Component; import com.lyyzoo.sunny.security.domain.entity.User;
import com.lyyzoo.sunny.security.domain.service.UserService; /**
* 登录认证成功处理器
*
* @author bojiangzhou 2018/03/29
*/
@Component
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @Autowired
private UserService userService; @Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String username = request.getParameter("username");
User user = userService.getUserByUsername(username);
userService.loginSuccess(user.getId());
super.onAuthenticationSuccess(request, response, authentication);
}
}

⑧ CustomAuthenticationFailureHandler

用户认证失败,记录密码错误次数,并重定向到登录页面。

 package com.lyyzoo.sunny.security.core;

 import java.io.IOException;

 import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component; import com.lyyzoo.sunny.core.message.MessageAccessor;
import com.lyyzoo.sunny.security.config.SecurityProperties;
import com.lyyzoo.sunny.security.domain.entity.User;
import com.lyyzoo.sunny.security.domain.service.UserService; /**
* 登录失败处理器
*
* @author bojiangzhou 2018/03/29
*/
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { @Autowired
private SecurityProperties securityProperties;
@Autowired
private UserService userService; private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); @Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String username = request.getParameter("username");
HttpSession session = request.getSession(false); if (session != null) {
session.setAttribute("username", username);
session.setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION,
MessageAccessor.getMessage(exception.getMessage(), exception.getMessage()));
}
if (exception instanceof BadCredentialsException) {
User user = userService.getUserByUsername(username);
userService.loginFail(user.getId());
} redirectStrategy.sendRedirect(request, response, securityProperties.getLoginPage() + "?username=" + username);
}
}

⑨ 配置

前面的开发完成当然还需做配置,通过 formLogin() 来配置认证成功/失败处理器等。

通过 AuthenticationManagerBuilder 配置自定义的认证器。

SpringSecurity提供了一个 PasswordEncoder 接口用于处理加密解密。该接口有两个方法 encode 和 matches 。encode 对密码加密,matches 判断用户输入的密码和加密的密码(数据库密码)是否匹配。

 package com.lyyzoo.sunny.security.config;

 import com.lyyzoo.sunny.security.core.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; /**
* Security 主配置器
*
* @author bojiangzhou
*/
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired
private SecurityProperties properties;
@Autowired
private CustomAuthenticationDetailsSource authenticationDetailsSource;
@Autowired
private CustomAuthenticationProvider authenticationProvider;
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler; @Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/static/**", "/webjars/**", "/public/**", "/login", "/favicon.ico")
.permitAll() // 允许匿名访问的地址
.and() // 使用and()方法相当于XML标签的关闭,这样允许我们继续配置父类节点。
.authorizeRequests()
.anyRequest()
.authenticated() // 其它地址都需进行认证
.and()
.formLogin() // 启用表单登录
.loginPage(properties.getLoginPage()) // 登录页面
.defaultSuccessUrl("/index") // 默认的登录成功后的跳转地址
.authenticationDetailsSource(authenticationDetailsSource)
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.csrf()
.disable()
; } /**
* 设置认证处理器
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider);
super.configure(auth);
} /**
* 密码处理器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
} }

⑩ 登录页面

基于SpringBoot搭建应用开发框架(二) —— 登录认证

三、手机短信登录

经过前面用户名+密码的登录流程分析后,现在再来开发手机号+短信验证码的方式登录。手机短信登录无法直接使用标准登录的流程,所以需要模拟标准登录流程开发。

1、流程分析

类比标准登录流程:

① 登录请求 [POST /login] 在 UsernamePasswordAuthenticationFilter 过滤器中封装未认证的 UsernamePasswordAuthenticationToken;

  短信登录时,请求 [POST /authentication/mobile] 进行登录认证,自定义 SmsAuthenticationFilter 短信认证过滤器,生成未认证的 SmsAuthenticationToken;

② 调用 AuthenticationManager 进行认证;

③ 认证时,使用自定义的 CustomAuthenticationProvider 进行用户信息认证;短信登录则自定义短信认证器 SmsAuthenticationProvider ;

④ 认证器使用自定义的 CustomUserDetailsService 来获取用户信息;

⑤ 认证成功后,生成已认证的 UsernamePasswordAuthenticationToken;短信登录时则生成已认证的 SmsAuthenticationToken;

基于SpringBoot搭建应用开发框架(二) —— 登录认证

2、代码实现

① 短信登录专用 Authentication

参照 UsernamePasswordAuthenticationToken,两个构造方法,认证前,放入手机号;认证成功之后,放入用户信息。

 package com.lyyzoo.sunny.security.sms;

 import java.util.Collection;

 import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority; /**
* 短信认证用到的 Authentication,封装登录信息。 认证前,放入手机号;认证成功之后,放入用户信息。
* <p>
* 参考 {@link UsernamePasswordAuthenticationToken}
*
* @author bojiangzhou 2018/09/22
*/
public class SmsAuthenticationToken extends AbstractAuthenticationToken { // 手机号
private final Object principal; public SmsAuthenticationToken(Object principal) {
super(null);
this.principal = principal;
setAuthenticated(false);
} public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true);
} @Override
public Object getCredentials() {
return null;
} public Object getPrincipal() {
return this.principal;
} public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException(
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}
super.setAuthenticated(false);
} @Override
public void eraseCredentials() {
super.eraseCredentials();
}
}

② 短信登录认证过滤器

参照 UsernamePasswordAuthenticationFilter,注意在构造方法中配置短信登录的地址 [POST /authentication/mobile],只有与这个地址匹配的才会进入这个过滤器。

同时,定义 SmsAuthenticationDetails 封装用户输入的手机验证码,在认证器里校验验证码正确性。

 package com.lyyzoo.sunny.security.sms;

 import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert; /**
* 短信登录认证过滤器
* <p>
* 参考 {@link UsernamePasswordAuthenticationFilter}
*
* @author bojiangzhou 2018/09/22
*/
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SUNNY_SMS_MOBILE_KEY = "mobile"; private String mobileParameter = SUNNY_SMS_MOBILE_KEY;
private boolean postOnly = true; /**
* 仅匹配 [POST /authentication/mobile]
*/
public SmsAuthenticationFilter() {
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
} public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String mobile = obtainMobile(request); if (mobile == null) {
mobile = "";
} mobile = mobile.trim(); SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile); // Allow subclasses to set the "details" property
setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest);
} protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
} protected void setDetails(HttpServletRequest request, SmsAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
} public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
this.mobileParameter = mobileParameter;
} public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
} public final String getMobileParameter() {
return mobileParameter;
} }

③ 短信登录认证器

参考 DaoAuthenticationProvider,覆盖父类的 authenticate 方法,根据手机号获取用户信息,校验用户输入的验证码是否正确。

覆盖 supports 方法,只有 {@link SmsAuthenticationToken} 类型才使用该认证器,ProviderManager 里将会调用该方法寻找合适的认证器来认证。

 package com.lyyzoo.sunny.security.sms;

 import com.lyyzoo.sunny.captcha.CaptchaMessageHelper;
import com.lyyzoo.sunny.captcha.CaptchaResult;
import com.lyyzoo.sunny.security.constant.SecurityConstants;
import com.lyyzoo.sunny.security.exception.CaptchaException;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert; /**
* 短信登录认证器
* <p>
* 参考 {@link AbstractUserDetailsAuthenticationProvider},{@link DaoAuthenticationProvider}
*
* @author bojiangzhou 2018/09/22
*/
public class SmsAuthenticationProvider implements AuthenticationProvider {
private static final Logger LOGGER = LoggerFactory.getLogger(SmsAuthenticationProvider.class); private UserDetailsService userDetailsService; private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); private CaptchaMessageHelper captchaMessageHelper; public SmsAuthenticationProvider(UserDetailsService userDetailsService, CaptchaMessageHelper captchaMessageHelper) {
this.userDetailsService = userDetailsService;
this.captchaMessageHelper = captchaMessageHelper;
} @Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(SmsAuthenticationToken.class, authentication,
"Only SmsAuthenticationToken is supported"); String mobile = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); UserDetails user = retrieveUser(mobile, (SmsAuthenticationToken) authentication);
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); additionalAuthenticationChecks(user, (SmsAuthenticationToken) authentication); return createSuccessAuthentication(user, authentication, user);
} protected UserDetails retrieveUser(String mobile, SmsAuthenticationToken authentication)
throws AuthenticationException { return getUserDetailsService().loadUserByUsername(mobile);
} protected void additionalAuthenticationChecks(UserDetails userDetails, SmsAuthenticationToken authentication)
throws AuthenticationException {
Assert.isInstanceOf(SmsAuthenticationDetails.class, authentication.getDetails());
SmsAuthenticationDetails details = (SmsAuthenticationDetails) authentication.getDetails();
String mobile = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
// 检查验证码
String inputCaptcha = details.getInputCaptcha();
String captchaKey = details.getCaptchaKey();
if (StringUtils.isAnyEmpty(inputCaptcha, captchaKey)) {
throw new CaptchaException("login.mobile-captcha.null");
}
CaptchaResult captchaResult = captchaMessageHelper.checkCaptcha(captchaKey, inputCaptcha, mobile,
SecurityConstants.SECURITY_KEY, false);
authentication.setDetails(null); if (!captchaResult.isSuccess()) {
throw new CaptchaException(captchaResult.getMessage());
}
} protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
SmsAuthenticationToken result =
new SmsAuthenticationToken(principal, authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails()); return result;
} /**
* 只有 {@link SmsAuthenticationToken} 类型才使用该认证器
*/
@Override
public boolean supports(Class<?> authentication) {
return (SmsAuthenticationToken.class.isAssignableFrom(authentication));
} public UserDetailsService getUserDetailsService() {
return userDetailsService;
} public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
} public CaptchaMessageHelper getCaptchaMessageHelper() {
return captchaMessageHelper;
} public void setCaptchaMessageHelper(CaptchaMessageHelper captchaMessageHelper) {
this.captchaMessageHelper = captchaMessageHelper;
} }

3、短信登录配置

短信登录的配置可以参考表单登录的配置 FormLoginConfigurer,在使用 formLogin() 时就会启用该配置。

定义 SmsLoginConfigurer,创建短信登录配置时,创建短信认证过滤器,在 configure 中配置该过滤器的认证成功/失败处理器。最重要的一点,将短信认证过滤器加到 UsernamePasswordAuthenticationFilter 之后。

 package com.lyyzoo.sunny.security.sms;

 import javax.servlet.http.HttpServletRequest;

 import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert; /**
* 短信登录配置
*
* @author bojiangzhou 2018/09/23
*/
public class SmsLoginConfigurer
extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { private static final String SMS_DEFAULT_LOGIN_PROCESS_URL = "/authentication/mobile"; private SmsAuthenticationFilter authFilter; private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource; private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler(); private AuthenticationFailureHandler failureHandler; /**
* 默认手机+短信验证码 登录处理地址 [POST "/authentication/mobile"]. 默认手机参数 - mobile
*/
public SmsLoginConfigurer() {
authFilter = new SmsAuthenticationFilter();
loginProcessingUrl(SMS_DEFAULT_LOGIN_PROCESS_URL);
mobileParameter("mobile");
} public SmsLoginConfigurer mobileParameter(String mobileParameter) {
authFilter.setMobileParameter(mobileParameter);
return this;
} public SmsLoginConfigurer loginProcessingUrl(String loginProcessingUrl) {
authFilter.setRequiresAuthenticationRequestMatcher(createLoginProcessingUrlMatcher(loginProcessingUrl));
return this;
} public SmsLoginConfigurer authenticationDetailsSource(
AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource) {
this.authenticationDetailsSource = authenticationDetailsSource;
return this;
} public SmsLoginConfigurer successHandler(AuthenticationSuccessHandler successHandler) {
this.successHandler = successHandler;
return this;
} public SmsLoginConfigurer failureHandler(AuthenticationFailureHandler failureHandler) {
this.failureHandler = failureHandler;
return this;
} protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
return new AntPathRequestMatcher(loginProcessingUrl, "POST");
} @Override
public void configure(HttpSecurity http) throws Exception {
Assert.notNull(successHandler, "successHandler should not be null.");
Assert.notNull(failureHandler, "failureHandler should not be null.");
authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
authFilter.setAuthenticationSuccessHandler(successHandler);
authFilter.setAuthenticationFailureHandler(failureHandler);
if (authenticationDetailsSource != null) {
authFilter.setAuthenticationDetailsSource(authenticationDetailsSource);
}
// 将短信认证过滤器加到 UsernamePasswordAuthenticationFilter 之后
http.addFilterAfter(authFilter, UsernamePasswordAuthenticationFilter.class);
} }

之后,需要在 WebSecurityConfigurerAdapter 中调用 HttpSecurity.apply() 应用该配置。

 package com.lyyzoo.sunny.security.config;

 import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import com.lyyzoo.sunny.captcha.CaptchaMessageHelper;
import com.lyyzoo.sunny.security.core.*;
import com.lyyzoo.sunny.security.sms.SmsAuthenticationDetailsSource;
import com.lyyzoo.sunny.security.sms.SmsAuthenticationFailureHandler;
import com.lyyzoo.sunny.security.sms.SmsAuthenticationProvider;
import com.lyyzoo.sunny.security.sms.SmsLoginConfigurer; /**
* Security 主配置器
*
* @author bojiangzhou
*/
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired
private SecurityProperties properties;
@Autowired
private CustomAuthenticationDetailsSource authenticationDetailsSource;
@Autowired
private CustomAuthenticationProvider authenticationProvider;
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private CaptchaMessageHelper captchaMessageHelper; @Override
@SuppressWarnings("unchecked")
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/static/**", "/webjars/**", "/public/**", "/favicon.ico", "/login", "/authentication/**", "/*.html")
.permitAll() // 允许匿名访问的地址
.and() // 使用and()方法相当于XML标签的关闭,这样允许我们继续配置父类节点。
.authorizeRequests()
.anyRequest()
.authenticated() // 其它地址都需进行认证
.and()
.formLogin() // 启用表单登录
.loginPage(properties.getLoginPage()) // 登录页面
.defaultSuccessUrl("/index") // 默认的登录成功后的跳转地址
.authenticationDetailsSource(authenticationDetailsSource)
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.authenticationProvider(authenticationProvider)
.csrf()
.disable()
; if (properties.isEnableSmsLogin()) {
// 配置短信登录
SmsLoginConfigurer smsLoginConfigurer = new SmsLoginConfigurer();
smsLoginConfigurer
.authenticationDetailsSource(smsAuthenticationDetailsSource())
.successHandler(authenticationSuccessHandler)
.failureHandler(smsAuthenticationFailureHandler())
;
http.apply(smsLoginConfigurer);
http.authenticationProvider(smsAuthenticationProvider());
}
} /**
* 密码处理器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
} @Bean
@ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true",
matchIfMissing = true)
public SmsAuthenticationFailureHandler smsAuthenticationFailureHandler() {
return new SmsAuthenticationFailureHandler();
} @Bean
@ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true",
matchIfMissing = true)
public SmsAuthenticationDetailsSource smsAuthenticationDetailsSource() {
return new SmsAuthenticationDetailsSource();
} @Bean
@ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true",
matchIfMissing = true)
public SmsAuthenticationProvider smsAuthenticationProvider() {
return new SmsAuthenticationProvider(userDetailsService, captchaMessageHelper);
} }

短信登录页面:

基于SpringBoot搭建应用开发框架(二) —— 登录认证

四、三方QQ登录

1、OAuth协议

OAuth 是一个授权协议,它的目的是让用户不用给客户端应用提供服务提供商(如QQ、微信)的账号和密码的情况下,让客户端应用可以有权限去访问用户在服务提供商的资源。

关于 OAuth 介绍建议直接看《阮一峰 -理解OAuth 2.0》,深入浅出,容易理解,这里就不赘述了。我这里主要看下源码及流程实现。

OAuth协议中的各种角色:

服务提供商(Provider):谁提供令牌谁就是服务提供商,比如微信、QQ。

资源所有者(Resource Owner):即用户,我们要获取的即用户的资源。

第三方应用(Client):指获取授权的应用,一般就是我们自己开发的应用。

认证服务器(Authorization Server):即服务提供商专门用来处理认证的服务器,认证用户的身份并产生令牌。

资源服务器(Resource Server):即服务提供商存放用户生成的资源的服务器。认证服务器和资源服务器虽然是两个角色,但他们一般也可以在同一个应用,同一台机器上。

各种角色联系在一起构成 OAuth 的认证流程(授权码模式):

基于SpringBoot搭建应用开发框架(二) —— 登录认证

2、Spring Social

spring social 将 OAuth 认证的整个流程封装并实现,它已经提供了对主流社交网站的支持,只需要简单配置即可。针对上面的流程,来看下spring social 相关源码。

在 pom 中引入 spring-social 的依赖,版本使用 2.0.0.M4:

 <dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-web</artifactId>
</dependency>

① 首先是服务提供商,对应 ServiceProvider ,这是一个顶层的接口定义。默认使用 AbstractOAuth2ServiceProvider。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 从 AbstractOAuth2ServiceProvider 不难看出,需要提供 OAuth2Operations,OAuth2Operations 接口封装了 OAuth2 认证的整个标准流程,默认实现为 OAuth2Template。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ AbstractOAuth2ServiceProvider 还需要提供一个 Api 接口,因为每个服务提供商返回的用户信息都是有差别的,这需要我们自己定义相关接口来获取用户信息。

spring social 提供了一个默认的抽象类 AbstractOAuth2ApiBinding,从其定义可以看出我们可以使用第6步中获取的服务提供商的令牌,使用 RestTemplate 发送请求来获取数据。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

④ 使用 Api 获取到用户信息后,就需要使用 Connection 来封装用户信息,默认实现为 OAuth2Connection。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑤ Connection 又是由 ConnectionFactory 创建出来的,默认使用 OAuth2ConnectionFactory。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑥ ConnectionFactory 又需要 ServiceProvider 和 ApiAdapter:ServiceProvider 用来走认证流程,获取用户信息;ApiAdapter 则用来适配不同服务提供商返回来的用户数据,将其转换成标准的 Connection。最终,ConnectionFactory 就可以构建出 Connection。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑦ 获取到三方应用的用户信息后,就需要和客户端应用的用户进行关联,获取客户端应用中用户的接口即为 UsersConnectionRepository。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

3、流程分析

Social 认证是通过向 spring security 过滤器链加入 SocialAuthenticationFilter 过滤器来完成的,通过这个过滤器来了解下 spring-social 的认证流程。

① 通过判断是否需要认证的方法 requiresAuthentication 可以看出,认证的地址必须是 **/{filterProcessesUrl}/{providerId} 的形式,比如 www.lyyzoo.com/auth/qq。这里的 qq 即为 providerId,auth 为过滤器处理地址 filterProcessesUrl,这个值默认为 auth。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 再看看认证的方法 attemptAuthentication,首先会检测用户是否拒绝授权,如果用户拒绝授权则直接抛出异常。然后获取 providerId 及对应的认证服务类,用于处理认证。认证失败,则重定向到一个地址去。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

通过 detectRejection 可以看出,我们在请求登录时,不要随意设置参数,否则会被错误认为是用户拒绝授权的。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ 认证方法中,从注释也可以了解到,第一次请求时,会抛出 AuthenticationRedirectException 异常,重定向到服务提供商的认证地址去。用户确认授权后,重定向回来时,就是第二次请求,就会拿着授权码去服务提供商那获取令牌。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

在获取 SocialAuthenticationToken 的方法中可以看到,如果请求的参数中没有 code(授权码),则重定向到服务提供商那。通过 buildReturnToUrl 和 buildAuthenticateUrl 可以看出,会自动帮我们构造回调地址以及重定向到认证服务器的地址。

buildReturnToUrl 会构造回调地址,所以本地测试要使用域名访问,可以在 hosts 中配置域名映射。否则你访问 localhost 是重定向不回来的,而且域名必须与QQ互联上配置的域名保持一致。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

buildAuthenticateUrl 会构造服务提供商的认证地址,会自动帮我们把 redirect_uri、state 等参数拼接上,在创建 OAuth2Template 时我们提供一个基础地址即可。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

④ 第二次请求时,有了授权码,则会用授权码去获取令牌 AccessGrant 用于构造 Connection,最终构造 SocialAuthenticationToken(注意此时的 SocialAuthenticationToken 是未认证的) 。

通过 exchangeForAccess 方法,可以发现,会自动帮我们带上获取令牌的参数,如果要带上 client_id、client_secret 需配置 useParametersForClientAuthentication=true。

获取到令牌后会自动帮我们将令牌封装到 AccessGrant 里,默认返回的数据结构为 Map,所以如果服务提供商返回令牌信息时不是 Map 结构的还需定制化处理。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑤ 创建好 AccessGrant 后,通过 OAuth2ConnectionFactory 创建 Connection,实际是创建 OAuth2Connection 对象。initApi() 方法会获取 ServiceProvider 中配置的Api。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

initKey() 用于生成服务提供商用户唯一的 key,根据 providerId 和 providerUserId(服务提供商的用户ID,即openId) 创建。而 providerUserId 则是通过 ApiAdapter 适配器来获取,这需要我们自行设置。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑥ 获取到 SocialAuthenticationToken 后,相当于服务提供商那边认证完成,接着就会调用 doAuthentication 进行客户端用户认证。

与标准登录流程类似,同样可以自定义 AuthenticationDetailsSource;接着调用认证器进行认证,spring social 的认证器默认使用 SocialAuthenticationProvider 。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

从其认证方法可以看出,将通过之前得到的 providerId 和 providerUserId 来获取 userId (客户端用户ID),这里 spring social 默认有一张表来存储 userId、providerId、providerUserId 之间的关系,可配置 JdbcUsersConnectionRepository 来维护对应的关系。

如果没有获取到对应的 userId,将抛出 BadCredentialsException,在 doAuthentication 里拦截到这个异常后,默认将重定向到 signupUrl 这个注册页面的地址,让用户先注册或绑定三方账号。signupUrl 默认为 "/signup"。

获取到对应的 userId后,就根据 userId 查询用户信息,这需要我们自定义 SocialUserDetailsService 及 SocialUserDetails。获取到用户后,就会创建已认证的 SocialAuthenticationToken。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑦ 通过 toUserId() 可以发现,根据 Connection 查找系统 userId 时,JdbcUsersConnectionRepository 默认的处理方式是:如果未查询到关联的 userId,可以自定义一个 ConnectionSignUp 用于注册用户并返回一个 userId,并且会调用 addConnection 添加关联。所以对于用户如果未注册,使用三方账号扫码自动注册用户的需求,就可以使用这种方式实现。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑧ 客户端这边认证成功后,就会通过 updateConnections 或 addConnection 将用户的 access_token、refresh_token、secret、用户和服务商的关联 等更新到数据库。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

4、QQ登录准备工作

① 社交登录必须要有一个外网能访问的域名,所以首先需要自己申请一个域名,然后备案,再将域名指向一台可访问的服务器,将服务部署到这台服务器上。推荐在阿里云上完成这一整套的配置,就不在这里细说了。

② 到 [QQ互联] 上申请成为开发者,然后通过创建应用获取QQ的appId和appKey。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

在创建应用时,网站地址 填写公网可访问的域名即可;网站回调域 即请求QQ后回调的地址,这个后面再做详细说明。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ 获取授权码地址

参考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>,也即创建应用时填写的 <网站回调域>,这二者必须保持一致,否则会提示重定向地址非法。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

④ 获取令牌地址

可以得到授权码地址 [https://graph.qq.com/oauth2.0/token] ,注意 grant_type、client_id、client_secret、code、redirect_uri 这些必须参数。

client_id 即 appId,client_secret 即 appKey,code 为获取的授权码。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑤ QQ访问用户资料API

QQ互联上提供了如下的一些API,其中访问用户资料的API是不需要申请的。[QQ互联API列表]

基于SpringBoot搭建应用开发框架(二) —— 登录认证

从文档中可以得到访问用户资料的地址:[ https://graph.qq.com/user/get_user_info ]

基于SpringBoot搭建应用开发框架(二) —— 登录认证

而要调用这个接口则必须带上获取的令牌(access_token),客户端应用申请的 appId,以及 openId,即用户的QQ号,可以使用 [ https://graph.qq.com/oauth2.0/me?access_token=YOUR_ACCESS_TOKEN ] 地址来获取QQ号。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

使用 [ https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID ] 地址来获取用户资料。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

返回参数,这些参数将封装到特定的 UserInfo 中。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

最后,通过返回码来判断是成功还是失败。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

5、QQ登录实现

从 SpringSocial 的源码分析中可以得知,我们主要目的就是获取服务提供商的用户信息,用户信息则封装到 Connection 中,想要获得 Connection 就需要 ConnectionFactory,想要构造一个 ConnectionFactory 就需要 ServiceProvider 和 ApiAdapter,ServiceProvider 又需要 OAuth2Operations 和 Api。下面来一步步实现获取QQ用户资料从而登录的流程。

① 构建 Api

首先根据获取QQ用户信息的接口封装QQ用户信息以及QQApi接口。

 package com.lyyzoo.sunny.security.social.qq.api;

 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

 /**
* QQ 用户信息
*
* @author bojiangzhou 2018/10/16
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class QQUser { private String ret; private String msg; private String openId; private String nickname; private String figureurl; private String gender; //getter setter
}
 package com.lyyzoo.sunny.security.social.qq.api;

 /**
* QQ API
*
* @author bojiangzhou 2018/10/16
*/
public interface QQApi { /**
* 获取QQ用户信息
*/
QQUser getQQUser(); }

提供 Api 默认实现,继承 AbstractOAuth2ApiBinding,用户信息api需要参数 appId 及 openId,而想要获取 openId 就要使用 access_token 获取用户 openId。

 package com.lyyzoo.sunny.security.social.qq.api;

 import java.io.IOException;

 import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.social.oauth2.AbstractOAuth2ApiBinding;
import org.springframework.social.oauth2.TokenStrategy; import com.fasterxml.jackson.databind.ObjectMapper;
import com.lyyzoo.sunny.core.exception.CommonException;
import com.lyyzoo.sunny.security.social.exception.ProviderUserNotFoundException; /**
* QQ API 默认实现,继承 {@link AbstractOAuth2ApiBinding}。
* 由于 Api 会使用得到的令牌来获取信息,每个用户的令牌是不同的,所以该类不是一个单例对象,每次访问 Api 都需要新建实例。
*
* @author bojiangzhou 2018/10/16
*/
public class DefaultQQApi extends AbstractOAuth2ApiBinding implements QQApi { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultQQApi.class); /**
* QQ 获取 openId 的地址
*/
private static final String URL_GET_OPEN_ID = "https://graph.qq.com/oauth2.0/me?access_token={accessToken}";
/**
* QQ 获取用户信息的地址
*/
private static final String URL_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key={appId}&openid={openId}"; /**
* 客户端 appId
*/
private String appId;
/**
* openId
*/
private String openId; private ObjectMapper mapper = new ObjectMapper(); public DefaultQQApi(String accessToken, String appId) {
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId = appId;
this.openId = getOpenId(accessToken);
} @Override
public QQUser getQQUser() {
String result = getRestTemplate().getForObject(URL_GET_USER_INFO, String.class, appId, openId); QQUser user = null;
try {
user = mapper.readValue(result, QQUser.class);
} catch (IOException e) {
LOGGER.error("parse qq UserInfo error.");
}
if (user == null) {
throw new ProviderUserNotFoundException("login.provider.user.not-found");
}
user.setOpenId(openId);
return user;
} /**
* 获取用户 OpenId
*/
private String getOpenId(String accessToken) {
// 返回结构:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
String openIdResult = getRestTemplate().getForObject(URL_GET_OPEN_ID, String.class, accessToken);
if (StringUtils.isBlank(openIdResult) || openIdResult.contains("code")) {
throw new CommonException("获取QQ账号错误");
}
// 解析 openId
String[] arr = StringUtils.substringBetween(openIdResult, "{", "}").replace("\"", "").split(",");
String openid = null;
for (String s : arr) {
if (s.contains("openid")) {
openid = s.split(":")[1];
}
}
return openid;
}
}

② 构建QQApiAdapter 适配器,在QQApi 与 Connection之间做适配。

 package com.lyyzoo.sunny.security.social.qq.connection;

 import com.lyyzoo.sunny.security.social.qq.api.QQApi;
import com.lyyzoo.sunny.security.social.qq.api.QQUser;
import org.springframework.social.connect.ApiAdapter;
import org.springframework.social.connect.ConnectionValues;
import org.springframework.social.connect.UserProfile; /**
* QQApi 适配器
*
* @author bojiangzhou 2018/10/17
*/
public class QQApiAdapter implements ApiAdapter<QQApi> { /**
* 测试Api连接是否可用
*
* @param api QQApi
*/
@Override
public boolean test(QQApi api) {
return true;
} /**
* QQApi 与 Connection 做适配
* @param api QQApi
* @param values Connection
*/
@Override
public void setConnectionValues(QQApi api, ConnectionValues values) {
QQUser user = api.getQQUser(); values.setDisplayName(user.getNickname());
values.setImageUrl(user.getFigureurl());
values.setProviderUserId(user.getOpenId());
} @Override
public UserProfile fetchUserProfile(QQApi api) {
return null;
} @Override
public void updateStatus(QQApi api, String message) { }
}

③ 定制化 QQOAuth2Template,因为标准的 OAuth2Template 处理令牌时,要求返回的数据结构为 Map,而QQ返回的令牌是一个字符串,因此需要定制处理。

 package com.lyyzoo.sunny.security.social.qq.connection;

 import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.social.oauth2.AccessGrant;
import org.springframework.social.oauth2.OAuth2Template;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate; import com.google.common.base.Charsets; /**
* 定制 OAuth2Template
*
* @author bojiangzhou 2018/10/26
*/
public class QQOauth2Template extends OAuth2Template { private static final Logger LOGGER = LoggerFactory.getLogger(QQOauth2Template.class); public QQOauth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
// 设置带上 client_id、client_secret
setUseParametersForClientAuthentication(true);
} /**
* 解析 QQ 返回的令牌
*/
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
// 返回格式:access_token=FE04********CCE2&expires_in=7776000&refresh_token=88E4***********BE14
String result = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);
if (StringUtils.isBlank(result)) {
throw new RestClientException("access token endpoint returned empty result");
}
LOGGER.debug("==> get qq access_token: " + result);
String[] arr = StringUtils.split(result, "&");
String accessToken = "", expireIn = "", refreshToken = "";
for (String s : arr) {
if (s.contains("access_token")) {
accessToken = s.split("=")[1];
} else if (s.contains("expires_in")) {
expireIn = s.split("=")[1];
} else if (s.contains("refresh_token")) {
refreshToken = s.split("=")[1];
}
}
return createAccessGrant(accessToken, null, refreshToken, Long.valueOf(expireIn), null);
} /**
* QQ 响应 ContentType=text/html;因此需要加入 text/html; 的处理器
*/
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charsets.UTF_8));
return restTemplate;
}
}

④ 通过 QQOAuth2Template 和 QQApi 构造 QQServiceProvider,创建 OAuth2Template 时,需传入获取授权码的地址和获取令牌的地址。

 package com.lyyzoo.sunny.security.social.qq.connection;

 import com.lyyzoo.sunny.security.social.qq.api.DefaultQQApi;
import com.lyyzoo.sunny.security.social.qq.api.QQApi;
import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider; /**
* QQ 服务提供商
*
* @author bojiangzhou 2018/10/17
*/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApi> {
/**
* 获取授权码地址(引导用户跳转到这个地址上去授权)
*/
private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
/**
* 获取令牌地址
*/
private static final String URL_GET_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token"; private String appId; public QQServiceProvider(String appId, String appSecret) {
super(new QQOauth2Template(appId, appSecret, URL_AUTHORIZE, URL_GET_ACCESS_TOKEN));
this.appId = appId;
} @Override
public QQApi getApi(String accessToken) {
return new DefaultQQApi(accessToken, appId);
}
}

⑤ 通过QQServiceProvider和QQApiAdapter构造 QQConnectionFactory。

 package com.lyyzoo.sunny.security.social.qq.connection;

 import com.lyyzoo.sunny.security.social.qq.api.QQApi;
import org.springframework.social.connect.support.OAuth2ConnectionFactory; /**
* QQ Connection 工厂
*
* @author bojiangzhou 2018/10/17
*/
public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApi> { public QQConnectionFactory(String providerId, String appId, String appSecret) {
super(providerId, new QQServiceProvider(appId, appSecret), new QQApiAdapter());
}
}

⑥ 自定义 CustomSocialUserDetails 及 CustomSocialUserDetailsService,封装 Social 专用的 UserDetails 对象。与 CustomUserDetails 和 CustomUserDetailsService 类似。

 package com.lyyzoo.sunny.security.social.common;

 import java.util.Collection;

 import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.social.security.SocialUserDetails; /**
* 定制 SocialUserDetails 封装 Social 登录用户信息
*
* @author bojiangzhou 2018/10/17
*/
public class CustomSocialUserDetails extends User implements SocialUserDetails { private String userId; private String nickname; private String language; public CustomSocialUserDetails(String username, String password, String userId, String nickname, String language,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
this.userId = userId;
this.nickname = nickname;
this.language = language;
} @Override
public String getUserId() {
return userId;
} public String getNickname() {
return nickname;
} public String getLanguage() {
return language;
}
}
 package com.lyyzoo.sunny.security.social.common;

 import java.util.ArrayList;
import java.util.Collection; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.social.security.SocialUserDetails;
import org.springframework.social.security.SocialUserDetailsService; import com.lyyzoo.sunny.security.domain.entity.User;
import com.lyyzoo.sunny.security.domain.service.UserService;
import com.lyyzoo.sunny.security.exception.AccountNotExistsException; /**
* 定制 Social UserDetailsService 用于获取系统用户信息
*
* @author bojiangzhou 2018/10/17
*/
public class CustomSocialUserDetailsService implements SocialUserDetailsService { @Autowired
private UserService userService; @Override
public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
User user = userService.select(Long.valueOf(userId)); if (user == null) {
throw new AccountNotExistsException("login.username-or-password.error");
} Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_USER")); return new CustomSocialUserDetails(user.getUsername(), user.getPassword(), userId, user.getNickname(),
user.getLanguage(), authorities);
}
}

⑥ 自定义 social 配置器,支持设置Social过滤器处理地址

 package com.lyyzoo.sunny.security.social.config;

 import org.springframework.context.annotation.Configuration;
import org.springframework.social.security.SocialAuthenticationFilter;
import org.springframework.social.security.SpringSocialConfigurer;
import org.springframework.util.Assert; /**
* social 配置器,支持设置Social过滤器处理地址.
*
* <pre>
* SpringSocialConfigurer socialConfigurer = new CustomSocialConfigurer();
* http.apply(socialConfigurer);
* </pre>
* @author bojiangzhou 2018/10/19
*/
@Configuration
public class CustomSocialConfigurer extends SpringSocialConfigurer { private static final String DEFAULT_FILTER_PROCESSES_URL = "/openid"; private String filterProcessesUrl = DEFAULT_FILTER_PROCESSES_URL; public CustomSocialConfigurer() { } public CustomSocialConfigurer(String filterProcessesUrl) {
Assert.notNull(filterProcessesUrl, "social filterProcessesUrl should not be null.");
this.filterProcessesUrl = filterProcessesUrl;
} @Override
@SuppressWarnings("unchecked")
protected <T> T postProcess(T object) {
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
filter.setFilterProcessesUrl(filterProcessesUrl);
return (T) filter;
}
}

⑦ social 配置,加入 QQConnectionFactory。

配置增删改查用户三方关系的 UsersConnectionRepository,使用 JdbcUsersConnectionRepository,并设置表前缀,可在源码包里找到初始化脚本,会自动帮我们增删改查用户与第三方账号的关联。

 package com.lyyzoo.sunny.security.social.config;

 import javax.sql.DataSource;

 import com.lyyzoo.sunny.security.social.core.CustomSocialAuthenticationSuccessHandler;
import com.lyyzoo.sunny.security.social.core.CustomSocialUserDetailsService;
import com.lyyzoo.sunny.security.social.qq.connection.QQConnectionFactory;
import com.lyyzoo.sunny.security.social.wechat.connection.WechatConnectionFactory;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.social.UserIdSource;
import org.springframework.social.config.annotation.ConnectionFactoryConfigurer;
import org.springframework.social.config.annotation.EnableSocial;
import org.springframework.social.config.annotation.SocialConfigurerAdapter;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.ConnectionSignUp;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.social.connect.jdbc.JdbcUsersConnectionRepository;
import org.springframework.social.connect.web.ProviderSignInUtils;
import org.springframework.social.security.AuthenticationNameUserIdSource;
import org.springframework.social.security.SocialUserDetailsService; /**
* social 配置
*
* @author bojiangzhou 2018/10/17
*/
@Configuration
@EnableSocial
@EnableConfigurationProperties(SocialProperties.class)
public class SocialConfiguration extends SocialConfigurerAdapter { @Autowired
private SocialProperties properties;
@Autowired
private DataSource dataSource; @Autowired(required = false)
private ConnectionSignUp connectionSignUp; @Override
public void addConnectionFactories(ConnectionFactoryConfigurer connectionFactoryConfigurer, Environment environment) {
// QQ
SocialProperties.Qq qq = properties.getQq();
if (StringUtils.isNoneBlank(qq.getAppId(), qq.getAppSecret())) {
connectionFactoryConfigurer.addConnectionFactory(
new QQConnectionFactory(qq.getProviderId(), qq.getAppId(), qq.getAppSecret()));
}
} @Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
JdbcUsersConnectionRepository usersConnectionRepository =
new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
// 设置表前缀
usersConnectionRepository.setTablePrefix("sys_");
// ConnectionSignUp 需自定义
usersConnectionRepository.setConnectionSignUp(connectionSignUp);
return usersConnectionRepository;
} @Override
public UserIdSource getUserIdSource() {
return new AuthenticationNameUserIdSource();
} @Bean
public SocialUserDetailsService socialUserDetailsService() {
return new CustomSocialUserDetailsService();
} @Bean
public CustomSocialAuthenticationSuccessHandler socialAuthenticationSuccessHandler() {
return new CustomSocialAuthenticationSuccessHandler();
} //@Bean
//public CustomSocialAuthenticationFailureHandler customSocialAuthenticationFailureHandler() {
// return new CustomSocialAuthenticationFailureHandler();
//} @Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator,
UsersConnectionRepository connectionRepository) {
return new ProviderSignInUtils(connectionFactoryLocator, connectionRepository);
} }

⑧ 如果用户未绑定QQ账号,则会默认跳转到 /signup 进行新用户注册或者账号绑定,账号绑定会用到 Social 提供的一个工具类 ProviderSignInUtils,会自动帮我们创建关联关系,并且在绑定后继续认证用户信息。

 @Service
public class UserServiceImpl extends BaseService<User> implements UserService { @Autowired
private PasswordEncoder passwordEncoder; @Autowired
private ProviderSignInUtils providerSignInUtils; @Override
public void bindProvider(String username, String password, HttpServletRequest request) {
// login
User user = select(User.FIELD_USERNAME, username);
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
throw new CommonException("user.error.login.username-or-password.error");
} providerSignInUtils.doPostSignUp(user.getId().toString(), new ServletWebRequest(request));
} }

6、实现效果

① 在登录页面点击QQ登录,实际就是访问 /openid/qq。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 跳转到QQ授权页面进行授权

基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ 用户授权之后,跳转回来,将根据 providerId (qq) 和 providerUserId (openid) 查询系统用户ID,然而 sys_userconnection 表中并没有对应的关系,于是自动跳转到注册页面,用户可以选择注册新用户并绑定,或者直接绑定已有账号。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

④用户绑定系统账号后,sys_userconnection 表中就会新增一条关联数据,代表系统用户和QQ用户已绑定,下次再登录时就不会再要求进行绑定了。还可以在用户个人中心提供绑定第三方账号的功能,这里就不在演示了,原理是类似的。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

五、Session 管理

1、Session 超时处理

可以通过设置 server.servlet.session.timeout 来设置 Session 超时时间,默认为30分钟

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

当你设置超时时间小于60秒的时候,实际默认最小为 1 分钟。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

可以在 HttpSecurity 的配置中设置Session失效后跳转的地址,这里配置直接跳转到登录页。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

2、Session 并发控制

用户登录时,如果只想让用户在一处登录,可设置 Session 并发数量来控制,并且可以设置当后一次登录挤掉前一次登录时的处理策略。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

如果用户已经登录,在其它地方登录时则不允许登录,可设置 maxSessionsPreventsLogin=true 即可。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

注意:如果发现设置不生效,请检查 UserDetails ,要重写 hashCode、equals、toString 方法,因为判断是否属于同一个用户是通过这几个方法来判断的。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

3、集群Session管理

在服务集群中,已经在 serverA 上登录了,登录后的Session是在 serverA 上,再访问 serverB 时,则会要求再次登录,因为没有Session。因此在集群中,可以将Session放到服务之外进行管理,让 Session 在集群中可以共享。

在 SpringBoot 中可以很容易做到这件事,目前可以支持以下几种类型的 Session 存储,我这里使用 Redis 进行 Session 存储。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

只需在 pom 中加入 spring-session 依赖,然后在配置中启用某种类型的 session 存储即可,最终会启用相关配置类。

 <!-- spring-session Session集群共享 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

基于SpringBoot搭建应用开发框架(二) —— 登录认证

再次登录时就会发现 Session 已经存储到 redis 中了。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

4、退出登录

默认退出地址为 /logout,退出后会跳转到登录地址+?logout,这些就不介绍了,看源码很容易发现这些配置。

我们可以通过 HttpSecurity的logout()来自定义登出的配置,实际会启用 LogoutConfigurer 的配置,注意登出成功地址和登出成功处理器不能同时配置,同时配置了则以后一个生效。可以在登出成功处理器返回 JSON,也可以做一些自定义的逻辑处理等。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

六、OAuth登录

前面实现的登录认证方式,登录成功后,登录信息是存储在服务器的 Session 里的,每次浏览器访问服务时,实际是在 Cookie 中带着 JSESSIONID 去访问服务,服务根据 JSESSIONID 来获取用户 Session,这种方式是基于服务器 Session 来保存用户信息。但在前后端分离或开发移动APP的时候,前端是单独部署在一台服务器上,用户实际访问的是 WebServer,所有的服务API请求再间接由 Web Server 发起。用户不再通过浏览器直接访问我们的后端应用,而是通过第三方的应用来访问。这种时候就不便于使用 Cookie + Session 的方式来保存用户信息,Cookie 存在跨域的问题,使用这种开发方式繁琐,安全性差。

于是就有了OAuth,类似于 QQ、微信认证那样,我们自己也可以作为服务提供商,前端应用或APP则作为第三方客户端,通过给客户端发放令牌,客户端在http参数中带着令牌来访问服务,服务端则通过令牌得到用户信息。Spring Social 封装了第三方客户端访问服务提供商时要做的大部分操作,而 Spring Security OAuth 则封装了服务提供商为第三方客户端提供令牌所要做的绝大部分操作,使用 Spring Security OAuth 我们可以快速搭建起一个服务提供商程序。

要实现服务提供商程序,实际就是实现 认证服务器和资源服务器,作为认证服务器,可以使用 OAuth 的四种授权模式,来生成令牌并存储、发放。作为资源服务器,OAuth2 通过向 SpringSecurity 过滤器链上加入 OAuth2AuthenticationProcessingFilter 来对资源进行认证,解析令牌,根据令牌获取用户信息等。

在开始本章之前,建议先熟悉 OAuth2 的认证流程及授权模式等:理解OAuth 2.0

1、OAuth 认证服务器

① 只需在配置中加上 @EnableAuthorizationServer 就可启用简单的 OAuth2 认证服务器功能。

实际上,该注解引入的 AuthorizationServerSecurityConfiguration 做了一个 oauth 的 HttpSecurity 配置,创建了一条专用于处理获取令牌(/oauth)相关请求的过滤器链,这个可自行查看。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 通过其导入的配置可以发现,主要启用了两个端点:授权端点(AuthorizationEndpoint)和令牌端点(TokenEndpoint)。授权端点用于用户授权给第三方客户端,就像我们在QQ授权页面登录授权一样。令牌端点则用于给用户发放令牌。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

2、OAuth 授权流程

下面通过授权码模式来了解OAuth的授权流程。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

① 在程序启动时,已生成默认的 client-id 和 client-secret(基于内存的方式),第三方客户端将用户重定向到认证服务器上(/oauth/authorize?client_id=xxx&response_type=code..... ) 获取用户授权。

此时默认会跳转到我们之前配置的登录页去进行登录,因为该请求匹配标准登录的过滤器链,发现用户没有认证,则跳转到登录页进行登录。用户确认登录即是向客户端授权,登录成功后就会进入 authorize 端点。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 可以看出:response_type 参数必须设置为 token 或者 code,可见该端点只用于 授权码模式(authorization code) 和 简化模式(implicit grant type);且必须传入 client_id,客户端ID一般由服务提供商提供给客户端应用;同时要求用户必须已经登录且已认证通过。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ 之后,通过 client_id 获取 ClientDetails,这里我们就需要做客制化了,我们需要添加自己的客户端应用库,从数据库获取客户端信息。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

之后会从参数中获取重定向回客户端的 redirect_uri,然后处理重定向地址,客户端(client)是可以配置授权类型的,默认就有这五种类型:authorization_code、password、client_credentials、implicit、refresh_token。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

可以看出,能进行重定向回客户端的只支持 授权码模式(authorization code) 和 简化模式(implicit grant type)。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

确认可以重定向之后,就会获取 client 配置的重定向地址,如果 client 的重定向地址不为空,就会跟客户端传入的 redirect_uri 进行比对,如果 redirect_uri 为空,则直接返回 client 配置的重定向地址;如果不为空,则要求二者必须保持一致,这也是需要注意的地方。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

④ 设置完重定向地址后,接着就检查 scope,即客户端申请访问的权限范围,如果检查发现不需要用户授权,则重定向回去,否则会跳转到一个默认的授权页面让用户授权。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

如果 client 中有与请求的 scope 对应的授权范围或者用户允许授权(Approve),则会生成授权码并存储起来,然后重定向到之前设置的地址上去,并返回授权码,以及原样返回 state 参数。之后客户端就可以带着授权码去获取令牌。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

3、发放令牌

基于SpringBoot搭建应用开发框架(二) —— 登录认证

① 客户端得到授权码后,就可以带上授权码去获取令牌(/oauth/token?grant_type=authorization_code&code=xxx&redirect_uri=xxx&client_id=xxx),这里用 Postman 来测试。

注意发起表单请求时,要配置客户端允许表单认证,将向 oauth 过滤器链中加入 ClientCredentialsTokenEndpointFilter 客户端过滤器来拦截用户请求,根据 client_id 和 client_secret 创建 Authentication 。跟标准的用户名密码登录流程一样,只不过这里是校验 client_id 和 client_secret。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② client_id 和 client_secret 认证通过后,就会进入获取令牌的端点,首先根据 client_id 获取 Client ,然后创建 TokenRequest。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

可以看出,获取令牌端点是不支持简化模式的,简化模式是访问 /authorize 端点时直接发放令牌的,这个稍后再说。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ 之后就会调用 TokenGranter 进行授权,授权成功将创建 OAuth2AccessToken,最后返回到客户端。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

授权时,实际就是调用五种授权类型的 TokenGranter,使用匹配的授权器来创建 AccessToken。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

④ 创建 AccessToken 时,首先是根据授权码获取用户信息(创建授权码的时候会把授权的用户信息序列化存储起来)。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

从存储中获取 AccessToken,先判断该用户是否已经存在 AccessToken,如果存在且没有过期,则刷新再返回。tokenStore 我们可以配置成数据库存储、Redis 存储等。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

如果不存在,则创建 refreshToken 和 accessToken,并存储起来。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

⑤ 之后就可以看到返回给客户端的令牌,之后我们就可以带着令牌访问服务的资源了。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

4、资源服务器

获取到令牌后,还无法直接通过令牌获访问资源服务,还需启用资源服务功能才能解析令牌。

① 启用资源服务器,只需在配置类上加上 @EnableResourceServer 即可,同样会创建一条 oauth 过滤器链,并向该过滤器链中加入 OAuth2AuthenticationProcessingFilter 过滤器来处理令牌。

这里配置该过滤器链仅对 [/open/**] 的请求做处理,其它请求还是走标准的过滤器链。你也可以配置所有请求都通过令牌来访问。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 在这个过滤器中,将从请求中根据令牌解析 Authentication ,默认的令牌解析器使用 BearerTokenExtractor。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

解析令牌时,首先检查请求头是否包含 [Authorization: Bearer token.....],没有的话就判断请求的参数是否包含 access_token,因此我们可以使用这两种方式携带 access_token 去访问资源。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

③ 得到 Authentication 后,就对 Authentication 进行认证,在认证过程中,会调用 DefaultTokenServices 获取用户信息,首先读取 AccessToken,并判断令牌是否过期,最后根据令牌得到用户信息。最终放入到 SecurityContextHolder 上下文中表示认证通过。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

5、刷新令牌

令牌是存在过期时间的,一般会设置一个小时或两个小时过期。在用户使用过程中,如果令牌过期,则又需要用户重新登录,用户体验不好。因此可以使用得到的更新令牌去重新获取访问令牌而不需要重新登录。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

6、简化模式

一般来说,我们自己内部的系统并不需要使用两步的授权码模式来获取授权,我们可以使用简化模式(implicit grant type)来获取授权。

只需将response_type改为token即可: host/oauth/authorize?client_id=client&response_type=token&scope=default&state=test。用户确认授权后,就会在地址中将令牌带回。

基于SpringBoot搭建应用开发框架(二) —— 登录认证

基于SpringBoot搭建应用开发框架(二) —— 登录认证

7、代码实现

① 自定义客户端服务类,从数据库获取 Client

 package com.lyyzoo.sunny.security.oauth;

 import java.util.Collections;
import java.util.Map;
import java.util.Optional; import com.fasterxml.jackson.databind.ObjectMapper;
import com.lyyzoo.sunny.security.domain.entity.Client;
import com.lyyzoo.sunny.security.domain.service.ClientService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.ClientRegistrationException;
import org.springframework.security.oauth2.provider.NoSuchClientException;
import org.springframework.util.StringUtils; /**
* 自定义 ClientDetailsService
*
* @author bojiangzhou 2018/11/03
*/
public class CustomClientDetailsService implements ClientDetailsService {
private static final Logger LOGGER = LoggerFactory.getLogger(CustomClientDetailsService.class); private ClientService clientService;
private OAuthProperties properties; public CustomClientDetailsService(ClientService clientService, OAuthProperties properties) {
this.clientService = clientService;
this.properties = properties;
} private ObjectMapper mapper = new ObjectMapper(); @Override
@SuppressWarnings("unchecked")
public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
Client client = clientService.selectByClientId(clientId);
if (client == null) {
throw new NoSuchClientException("No client with requested id: " + clientId);
}
CustomClientDetails clientDetails = new CustomClientDetails();
clientDetails.setClientId(client.getClientId());
clientDetails.setClientSecret(client.getClientSecret());
clientDetails.setAuthorizedGrantTypes(StringUtils.commaDelimitedListToSet(client.getGrantTypes()));
clientDetails.setResourceIds(StringUtils.commaDelimitedListToSet(client.getResourceIds()));
clientDetails.setScope(StringUtils.commaDelimitedListToSet(client.getScope()));
clientDetails.setRegisteredRedirectUri(StringUtils.commaDelimitedListToSet(client.getRedirectUris()));
clientDetails.setAuthorities(Collections.emptyList());
int accessTokenValiditySeconds = Optional
.ofNullable(client.getAccessTokenValidity())
.orElse(properties.getAccessTokenValiditySeconds());
clientDetails.setAccessTokenValiditySeconds(accessTokenValiditySeconds);
int refreshTokenValiditySeconds = Optional
.ofNullable(client.getRefreshTokenValidity())
.orElse(properties.getRefreshTokenValiditySeconds());
clientDetails.setRefreshTokenValiditySeconds(refreshTokenValiditySeconds);
clientDetails.setAutoApproveScopes(StringUtils.commaDelimitedListToSet(client.getAutoApproveScopes()));
String json = client.getAdditionalInformation();
if (org.apache.commons.lang3.StringUtils.isNotBlank(json)) {
try {
Map<String, Object> additionalInformation = mapper.readValue(json, Map.class);
clientDetails.setAdditionalInformation(additionalInformation);
} catch (Exception e) {
LOGGER.warn("parser addition info error: {}", e);
}
}
return clientDetails;
}
}

基于SpringBoot搭建应用开发框架(二) —— 登录认证

② 认证服务器配置,主要是针对授权服务端口的配置,配置使用Redis来存储令牌。

 package com.lyyzoo.sunny.security.config;

 import javax.sql.DataSource;

 import com.lyyzoo.sunny.security.core.CustomUserDetailsService;
import com.lyyzoo.sunny.security.oauth.CustomClientDetailsService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore; /**
* 认证服务器配置
*
* @author bojiangzhou 2018/11/02
*/
@EnableAuthorizationServer
@Configuration
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter { @Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
.passwordEncoder(NoOpPasswordEncoder.getInstance())
.allowFormAuthenticationForClients()
;
} private static final String FIELD_ACCESS_TOKEN = "oauth2:access_token:"; private AuthenticationManager authenticationManager;
private CustomClientDetailsService clientDetailsService;
private CustomUserDetailsService userDetailsService;
private DataSource dataSource;
private RedisConnectionFactory redisConnectionFactory; public AuthorizationServerConfiguration(AuthenticationConfiguration authenticationConfiguration,
CustomClientDetailsService clientDetailsService,
CustomUserDetailsService userDetailsService,
DataSource dataSource,
RedisConnectionFactory redisConnectionFactory) throws Exception {
this.authenticationManager = authenticationConfiguration.getAuthenticationManager();
this.clientDetailsService = clientDetailsService;
this.userDetailsService = userDetailsService;
this.dataSource = dataSource;
this.redisConnectionFactory = redisConnectionFactory;
} @Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.authorizationCodeServices(new JdbcAuthorizationCodeServices(dataSource))
.tokenStore(tokenStore())
.userDetailsService(userDetailsService)
.authenticationManager(authenticationManager)
;
} @Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetailsService);
} @Bean
@ConditionalOnMissingBean(RedisTokenStore.class)
public RedisTokenStore tokenStore() {
RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
redisTokenStore.setPrefix(FIELD_ACCESS_TOKEN);
return redisTokenStore;
} }

七、总结

1、参考文档

Spring Security 参考手册

Spring Security 核心过滤器链分析

Spring boot security

初识 Spring Security

理解OAuth 2.0

JSON Web Token 入门教程

The OAuth 2.0 Authorization Framework

Spring Security 与 OAuth2

2、总结

本篇主要讲述了基于SpringSecurity和OAuth2的几种登录认证方式,主要是分析了整个流程以及相关的源码、原理。前后端分离部分目前只是使用 Postman 简单测试了下,后面有时间考虑使用 Vue 做前端框架,搭建一个前端出来,后面再完善。

本来还要做SSO单点登录和授权相关的内容的,考虑到时间精力有限,就不在这里做介绍了。通过前面对源码的分析梳理,相信这部分内容也不在话下。

下一步计划是做 Spring cloud 这部分的内容,开发微服务中的注册中心(Eureka)、网关(Gateway)等等,通过开发这些服务,去熟悉spring cloud 的使用、熟悉部分核心代码及原理。

3、源码地址

源码仅供参考,很多代码都不完善,尽自己学习使用。

https://gitee.com/bojiangzhou/sunny [都不点赞还要源码  o(一︿一+)o ]

<------------------------------------------------------------------------------------------------------------->