61969 字
169 分钟
SpringSecurity与OAuth2安全框架完全指南

SpringSecurity入门到精通#

SpringSecurity从入门到精通#

课程介绍#

image-20211219121555979

0. 简介#

Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。

​ 一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。

​ 一般Web应用的需要进行认证授权

认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户

授权:经过认证后判断当前用户是否有权限进行某个操作

​ 而认证和授权也是SpringSecurity作为安全框架的核心功能。

1. 快速入门#

1.1 准备工作#

​ 我们先要搭建一个简单的SpringBoot工程

① 设置父工程 添加依赖

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

② 创建启动类

@SpringBootApplication
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class,args);
}
}

③ 创建Controller

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@RequestMapping("/hello")
public String hello(){
return "hello";
}
}

1.2 引入SpringSecurity#

​ 在SpringBoot项目中使用SpringSecurity我们只需要引入依赖即可实现入门案例。

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

​ 引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。

​ 必须登陆之后才能对接口进行访问。

2. 认证#

2.1 登陆校验流程#

image-20211215094003288

2.2 原理初探#

​ 想要知道如何实现自己的登陆流程就必须要先知道入门案例中SpringSecurity的流程。

2.2.1 SpringSecurity完整流程#

​ SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。

image-20211214144425527

​ 图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。

UsernamePasswordAuthenticationFilter<负责处理我们在登陆页面填写了用户名密码后的登陆请求>。入门案例的认证工作主要有它负责。

**ExceptionTranslationFilter:**处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。

**FilterSecurityInterceptor:**负责权限校验的过滤器。

​ 我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。

image-20211214145824903

2.2.2 认证流程详解#

image-20211214151515385

概念速查:

Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。

AuthenticationManager接口:定义了认证Authentication的方法

UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。

UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

2.3 解决问题#

2.3.1 思路分析#

登录

​ ①自定义登录接口

​ 调用ProviderManager的方法进行认证 如果认证通过生成jwt

​ 把用户信息存入redis中

​ ②自定义UserDetailsService

​ 在这个实现类中去查询数据库

校验:

​ ①定义Jwt认证过滤器

​ 获取token

​ 解析token获取其中的userid

​ 从redis中获取用户信息

​ 存入SecurityContextHolder

2.3.2 准备工作#

①添加依赖

<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redis依赖commons-pool 这个依赖一定要添加 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--fastjson依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.33</version>
</dependency>
<!--jwt依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!-- 基于 Java 语言实现的各种 JWT 类库 主要用于验证 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>

② 添加Redis相关配置

FastJsonRedisSerializer#

注意fastjson版本为:1.2.33时生效,其他版本可能不生效

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.util.Assert;
import java.nio.charset.Charset;
/**
* Redis使用FastJson序列化 注意fastjson版本为:1.2.33时生效,其他版本可能不生效
*
* @author zy
*/
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
private Class<T> clazz;
static
{
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
public FastJsonRedisSerializer(Class<T> clazz)
{
super();
this.clazz = clazz;
}
@Override
public byte[] serialize(T t) throws SerializationException
{
if (t == null)
{
return new byte[0];
}
return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
}
@Override
public T deserialize(byte[] bytes) throws SerializationException
{
if (bytes == null || bytes.length <= 0)
{
return null;
}
String str = new String(bytes, DEFAULT_CHARSET);
return JSON.parseObject(str, clazz);
}
protected JavaType getJavaType(Class<?> clazz)
{
return TypeFactory.defaultInstance().constructType(clazz);
}
}
RedisConfig#
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
@SuppressWarnings(value = { "unchecked", "rawtypes" })
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}

③ 响应类

响应类#
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> {
/**
* 状态码
*/
private Integer code;
/**
* 提示信息,如果有错误时,前端可以获取该字段进行提示
*/
private String msg;
/**
* 查询到的结果数据,
*/
private T data;
public ResponseResult(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public ResponseResult(Integer code, T data) {
this.code = code;
this.data = data;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public ResponseResult(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
}

④工具类

jwt工具#
package cn.zyroot.domain.security.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.*;
/**
* JWT工具类
*/
@Data
public class JwtUtil {
/**
* 默认算法
*/
private static final SignatureAlgorithm defaultSignatureAlgorithm = SignatureAlgorithm.HS256;
// 有效期为
public static final Long JWT_TTL = 60 * 60 * 1000L;// 60 * 60 *1000 一个小时
// 设置秘钥明文 默认密钥
public static final String JWT_KEY = "singing";
// 签发者
public static final String JWT_ISSUER = "zy";
// 算法
private SignatureAlgorithm signatureAlgorithm;
public JwtUtil(SignatureAlgorithm signatureAlgorithm) {
this.signatureAlgorithm = signatureAlgorithm;
}
/**
* 获取uuid
*
* @return 唯一标识
*/
public static String getUUID() {
return UUID.randomUUID().toString().replaceAll("-", "");
}
/**
* 生成加密后的秘钥 secretKey
*
* @return secretKey
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
return new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
}
/**
* 获取jwt构造器
*
* @param subject 主题
* @param ttlMillis 过期时间
* @param uuid uuid
* @return JwtBuilder
*/
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid, Map<String, Object> chaim) {
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if (ttlMillis == null) {
ttlMillis = JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setClaims(chaim)
// 唯一的ID
.setId(uuid)
// 主题 可以是JSON数据
.setSubject(subject)
// 签发者
.setIssuer(JwtUtil.JWT_ISSUER)
// 签发时间
.setIssuedAt(now)
// 使用HS256对称加密算法签名, 第二个参数为秘钥
.signWith(defaultSignatureAlgorithm, secretKey)
.setExpiration(expDate);
}
/**
* 生成jtw 无过期时间
*
* @param subject token中要存放的数据(json格式)
* @return jwtStr
*/
public static String createJWT(String subject) {
// 无过期时间
JwtBuilder builder = getJwtBuilder(subject, null, getUUID(),null);
return builder.compact();
}
/**
* 生成jtw 指定过期时间、指定载荷
*
* @param subject token中要存放的数据(json格式)
* @param ttlMillis token超时时间
* @return jwtStr
*/
public static String createJWT(String subject, Long ttlMillis, Map<String, Object> chaim) {
// 设置过期时间
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID(),chaim);
return builder.compact();
}
/**
* 创建token 指定过期时间、id(唯一标识)
*
* @param id 唯一标识
* @param subject 主题
* @param ttlMillis 过期时间
* @return jwtStr
*/
public static String createJWT(String subject, Long ttlMillis, String id) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id,null);
return builder.compact();
}
/**
* 创建token 指定过期时间、id(唯一标识)、指定载荷
*
* @param id 唯一标识
* @param subject 主题
* @param ttlMillis 过期时间
* @return jwtStr
*/
public static String createJWT(String subject, Long ttlMillis, String id, Map<String, Object> chaim) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id, chaim);
return builder.compact();
}
/**
* 解析
*
* @param jwt 字符串
* @return Claims
* @throws Exception 异常
*/
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}
// 判断jwtToken是否合法
public static boolean isVerify(String jwtToken) {
// 这个是官方的校验规则,这里只写了一个”校验算法“,可以自己加
Algorithm algorithm = null;
switch (JwtUtil.defaultSignatureAlgorithm) {
case HS256:
algorithm = Algorithm.HMAC256(JwtUtil.JWT_KEY);
break;
default:
throw new RuntimeException("不支持该算法");
}
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(jwtToken);
// 校验不通过会抛出异常
// 判断合法的标准:1. 头部和荷载部分没有篡改过。2. 没有过期
return true;
}
// 测试
public static void main(String[] args) throws Exception {
String jwt = createJWT("123");
System.out.println(jwt);
Claims claims = parseJWT(jwt);
System.out.println(claims);
}
}
RedisCache#
import java.util.*;
import java.util.concurrent.TimeUnit;
@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache
{
@Autowired
public RedisTemplate redisTemplate;
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
*/
public <T> void setCacheObject(final String key, final T value)
{
redisTemplate.opsForValue().set(key, value);
}
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
{
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout)
{
return expire(key, timeout, TimeUnit.SECONDS);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @param unit 时间单位
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout, final TimeUnit unit)
{
return redisTemplate.expire(key, timeout, unit);
}
/**
* 获得缓存的基本对象。
*
* @param key 缓存键值
* @return 缓存键值对应的数据
*/
public <T> T getCacheObject(final String key)
{
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
/**
* 删除单个对象
*
* @param key
*/
public boolean deleteObject(final String key)
{
return redisTemplate.delete(key);
}
/**
* 删除集合对象
*
* @param collection 多个对象
* @return
*/
public long deleteObject(final Collection collection)
{
return redisTemplate.delete(collection);
}
/**
* 缓存List数据
*
* @param key 缓存的键值
* @param dataList 待缓存的List数据
* @return 缓存的对象
*/
public <T> long setCacheList(final String key, final List<T> dataList)
{
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
return count == null ? 0 : count;
}
/**
* 获得缓存的list对象
*
* @param key 缓存的键值
* @return 缓存键值对应的数据
*/
public <T> List<T> getCacheList(final String key)
{
return redisTemplate.opsForList().range(key, 0, -1);
}
/**
* 缓存Set
*
* @param key 缓存键值
* @param dataSet 缓存的数据
* @return 缓存数据的对象
*/
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
{
BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
Iterator<T> it = dataSet.iterator();
while (it.hasNext())
{
setOperation.add(it.next());
}
return setOperation;
}
/**
* 获得缓存的set
*
* @param key
* @return
*/
public <T> Set<T> getCacheSet(final String key)
{
return redisTemplate.opsForSet().members(key);
}
/**
* 缓存Map
*
* @param key
* @param dataMap
*/
public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
{
if (dataMap != null) {
redisTemplate.opsForHash().putAll(key, dataMap);
}
}
/**
* 获得缓存的Map
*
* @param key
* @return
*/
public <T> Map<String, T> getCacheMap(final String key)
{
return redisTemplate.opsForHash().entries(key);
}
/**
* 往Hash中存入数据
*
* @param key Redis键
* @param hKey Hash键
* @param value
*/
public <T> void setCacheMapValue(final String key, final String hKey, final T value)
{
redisTemplate.opsForHash().put(key, hKey, value);
}
/**
* 获取Hash中的数据
*
* @param key Redis键
* @param hKey Hash键
* @return Hash中的对象
*/
public <T> T getCacheMapValue(final String key, final String hKey)
{
HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
return opsForHash.get(key, hKey);
}
/**
* 删除Hash中的数据
*
* @param key
* @param hkey
*/
public void delCacheMapValue(final String key, final String hkey)
{
HashOperations hashOperations = redisTemplate.opsForHash();
hashOperations.delete(key, hkey);
}
/**
* 获取多个Hash中的数据
*
* @param key Redis键
* @param hKeys Hash键集合
* @return Hash对象集合
*/
public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
{
return redisTemplate.opsForHash().multiGet(key, hKeys);
}
/**
* 获得缓存的基本对象列表
*
* @param pattern 字符串前缀
* @return 对象列表
*/
public Collection<String> keys(final String pattern)
{
return redisTemplate.keys(pattern);
}
}
WebUtils#
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class WebUtils
{
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @return null
*/
public static String renderString(HttpServletResponse response, String string) {
try
{
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
}
catch (IOException e)
{
e.printStackTrace();
}
return null;
}
}
用户实体类#
import java.io.Serializable;
import java.util.Date;
/**
* 用户表(User)实体类
*
* @author 三更
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
private static final long serialVersionUID = -40356785423868312L;
/**
* 主键
*/
private Long id;
/**
* 用户名
*/
private String userName;
/**
* 昵称
*/
private String nickName;
/**
* 密码
*/
private String password;
/**
* 账号状态(0正常 1停用)
*/
private String status;
/**
* 邮箱
*/
private String email;
/**
* 手机号
*/
private String phonenumber;
/**
* 用户性别(0男,1女,2未知)
*/
private String sex;
/**
* 头像
*/
private String avatar;
/**
* 用户类型(0管理员,1普通用户)
*/
private String userType;
/**
* 创建人的用户id
*/
private Long createBy;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新人
*/
private Long updateBy;
/**
* 更新时间
*/
private Date updateTime;
/**
* 删除标志(0代表未删除,1代表已删除)
*/
private Integer delFlag;
}
用户工具类#
public class SecurityUtils
{
/**
* 获取用户
**/
public static LoginUser getLoginUser()
{
return (LoginUser) getAuthentication().getPrincipal();
}
/**
* 获取Authentication
*/
public static Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
public static Boolean isAdmin(){
Long id = getLoginUser().getUser().getId();
return id != null && 1L == id;
}
public static Long getUserId() {
return getLoginUser().getUser().getId();
}
}
2.3.3 实现#
2.3.3.1 数据库校验用户#

​ 从之前的分析我们可以知道,我们可以自定义一个UserDetailsService,让SpringSecurity使用我们的UserDetailsService。我们自己的UserDetailsService可以从数据库中查询用户名和密码。

准备工作#

​ 我们先创建一个用户表, 建表语句如下:

CREATE TABLE `sys_user` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
`password` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
`status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱',
`phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
`sex` CHAR(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像',
`user_type` CHAR(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
`create_by` BIGINT(20) DEFAULT NULL COMMENT '创建人的用户id',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` BIGINT(20) DEFAULT NULL COMMENT '更新人',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`del_flag` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'

​ 引入MybatisPuls和mysql驱动的依赖

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

​ 配置数据库信息

spring:
datasource:
url: jdbc:mysql://localhost:3306/sg_security?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver

​ 定义Mapper接口

public interface UserMapper extends BaseMapper<User> {
}

​ 修改User实体类

类名上加@TableName(value = "sys_user") ,id字段上加 @TableId

​ 配置Mapper扫描

@SpringBootApplication
@MapperScan("com.sangeng.mapper")
public class SimpleSecurityApplication {
public static void main(String[] args) {
ConfigurableApplicationContext run = SpringApplication.run(SimpleSecurityApplication.class);
System.out.println(run);
}
}

​ 添加junit依赖

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

​ 测试MP是否能正常使用

/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@SpringBootTest
public class MapperTest {
@Autowired
private UserMapper userMapper;
@Test
public void testUserMapper(){
List<User> users = userMapper.selectList(null);
System.out.println(users);
}
}
核心代码实现#

创建一个类实现UserDetailsService接口,重写其中的方法。更加用户名从数据库中查询用户信息

/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
//封装成UserDetails对象返回
//往后走,security内部逻辑在做秘密验证
return new LoginUser(user);
}
}

因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。

LoginUser

/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

注意:如果要测试,需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop}。例如

image-20211216123945882

这样登陆的时候就可以用sg作为用户名,1234作为密码来登陆了。

2.3.3.2 密码加密存储#

​ 实际项目中我们不会把密码明文存储在数据库中。

​ 默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。

​ 我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。

​ 我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。

​ 我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapter。

/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
2.3.3.3 登陆接口#

​ 接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。

​ 在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。

​ 认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。

@RestController
public class LoginController {
@Autowired
private LoginServcie loginServcie;
@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user){
return loginServcie.login(user);
}
}
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问 必须以斜杠开头 不包含context-path路径
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}

@Service
public class LoginServiceImpl implements LoginServcie {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//使用userid生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
//authenticate存入redis
redisCache.setCacheObject("login:"+userId,loginUser);
//把token响应给前端
HashMap<String,String> map = new HashMap<>();
map.put("token",jwt);
return new ResponseResult(200,"登陆成功",map);
}
}
2.3.3.4 认证过滤器#

​ 我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。

​ 使用userid去redis中获取对应的LoginUser对象。

​ 然后封装Authentication对象存入SecurityContextHolder

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter implements HandlerInterceptor {
@Autowired
private RedisCache redisCache;
//拦截器的前置处理
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//如果是预检请求,手动加上请求状态200
if (request.getMethod().equals(RequestMethod.OPTIONS.name())) {
response.setStatus(HttpStatus.OK.value());
return false;
}
return true;
}
//过滤器的内部处理
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 从header中获取token
String token = request.getHeader("token");
// 从请求参数中获取token
String[] values = request.getParameterValues("token");
if (!Objects.isNull(values)) {
token = values[0];
}
if (!StringUtils.hasText(token)) {
//放行
filterChain.doFilter(request, response);
return;
}
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//从redis中获取用户信息
String redisKey = "login:" + userid;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if(Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder
//TODO 获取权限信息封装到Authentication中
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Autowired
JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问 不包含context-path路径
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//把token校验过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
2.3.3.5 退出登陆#

​ 我们只需要定义一个登陆接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。

/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Service
public class LoginServiceImpl implements LoginServcie {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//使用userid生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
//authenticate存入redis
redisCache.setCacheObject("login:"+userId,loginUser);
//把token响应给前端
HashMap<String,String> map = new HashMap<>();
map.put("token",jwt);
return new ResponseResult(200,"登陆成功",map);
}
@Override
public ResponseResult logout() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userid = loginUser.getUser().getId();
redisCache.deleteObject("login:"+userid);
return new ResponseResult(200,"退出成功");
}
}

3. 授权#

3.0 权限系统的作用#

​ 例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。

​ 总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。

​ 我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。

​ 所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。

3.1 授权基本流程#

​ 在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。

​ 所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。

​ 然后设置我们的资源所需要的权限即可。

3.2 授权实现#

3.2.1 限制访问资源所需权限#

​ SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。

​ 但是要使用它我们需要先开启相关配置。

@EnableGlobalMethodSecurity(prePostEnabled = true)

​ 然后就可以使用对应的注解。@PreAuthorize

@RestController
public class HelloController {
@RequestMapping("/hello")
@PreAuthorize("hasAuthority('test')")
public String hello(){
return "hello";
}
}
3.2.2 封装权限信息#

​ 我们前面在写UserDetailsServiceImpl的时候说过,在查询出用户后还要获取对应的权限信息,封装到UserDetails中返回。

​ 我们先直接把权限信息写死封装到UserDetails中进行测试。

​ 我们之前定义了UserDetails的实现类LoginUser,想要让其能封装权限信息就要对其进行修改。

LoginUser#
package com.sangeng.domain;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;
//存储权限信息
private List<String> permissions;
public LoginUser(User user,List<String> permissions) {
this.user = user;
this.permissions = permissions;
}
/**
* 该属性为了提高效率
* 不能参与序列化
*/
//存储SpringSecurity所需要的权限信息的集合
@JSONField(serialize = false)
private List<GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if(authorities!=null){
return authorities;
}
//把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中
authorities = permissions.stream().
map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

​ LoginUser修改完后我们就可以在UserDetailsServiceImpl中去把权限信息封装到LoginUser中了。我们写死权限进行测试,后面我们再从数据库中查询权限信息。

UserDetailsServiceImpl#
package com.sangeng.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.sangeng.domain.LoginUser;
import com.sangeng.domain.User;
import com.sangeng.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
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.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
List<String> list = new ArrayList<>(Arrays.asList("test"));
return new LoginUser(user,list);
}
}
3.2.3 从数据库查询权限信息#
3.2.3.1 RBAC权限模型#

​ RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。

image-20211222110249727

3.2.3.2 准备工作#
CREATE DATABASE /*!32312 IF NOT EXISTS*/`sg_security` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
USE `sg_security`;
/*Table structure for table `sys_menu` */
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
`path` varchar(200) DEFAULT NULL COMMENT '路由地址',
`component` varchar(255) DEFAULT NULL COMMENT '组件路径',
`visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
`status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
`icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
`create_by` bigint(20) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(20) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';
/*Table structure for table `sys_role` */
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(128) DEFAULT NULL,
`role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
`status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
`del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
`create_by` bigint(200) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(200) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
/*Table structure for table `sys_role_menu` */
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
`role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
/*Table structure for table `sys_user` */
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
`password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` varchar(64) DEFAULT NULL COMMENT '邮箱',
`phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
`sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` varchar(128) DEFAULT NULL COMMENT '头像',
`user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
/*Table structure for table `sys_user_role` */
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SELECT
DISTINCT m.`perms`
FROM
sys_user_role ur
LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
WHERE
user_id = 2
AND r.`status` = 0
AND m.`status` = 0
package com.sangeng.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 菜单表(Menu)实体类
*
* @author makejava
* @since 2021-11-24 15:30:08
*/
@TableName(value="sys_menu")
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {
private static final long serialVersionUID = -54979041104113736L;
@TableId
private Long id;
/**
* 菜单名
*/
private String menuName;
/**
* 路由地址
*/
private String path;
/**
* 组件路径
*/
private String component;
/**
* 菜单状态(0显示 1隐藏)
*/
private String visible;
/**
* 菜单状态(0正常 1停用)
*/
private String status;
/**
* 权限标识
*/
private String perms;
/**
* 菜单图标
*/
private String icon;
private Long createBy;
private Date createTime;
private Long updateBy;
private Date updateTime;
/**
* 是否删除(0未删除 1已删除)
*/
private Integer delFlag;
/**
* 备注
*/
private String remark;
}
3.2.3.3 代码实现#

​ 我们只需要根据用户id去查询到其所对应的权限信息即可。

​ 所以我们可以先定义个mapper,其中提供一个方法可以根据userid查询权限信息。

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sangeng.domain.Menu;
import java.util.List;
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
public interface MenuMapper extends BaseMapper<Menu> {
List<String> selectPermsByUserId(Long id);
}

​ 尤其是自定义方法,所以需要创建对应的mapper文件,定义对应的sql语句

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sangeng.mapper.MenuMapper">
<select id="selectPermsByUserId" resultType="java.lang.String">
SELECT
DISTINCT m.`perms`
FROM
sys_user_role ur
LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
WHERE
user_id = #{userid}
AND r.`status` = 0
AND m.`status` = 0
</select>
</mapper>

​ 在application.yml中配置mapperXML文件的位置

spring:
datasource:
url: jdbc:mysql://localhost:3306/sg_security?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml

​ 然后我们可以在UserDetailsServiceImpl中去调用该mapper的方法查询权限信息封装到LoginUser对象中即可。

/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
List<String> permissionKeyList = menuMapper.selectPermsByUserId(user.getId());
// //测试写法
// List<String> list = new ArrayList<>(Arrays.asList("test"));
return new LoginUser(user,permissionKeyList);
}
}

4. 自定义失败处理#

​ 我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。

​ 在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

​ 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。

​ 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

​ 所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。

①自定义实现类#

认证过程中出现的异常#
package com.zy.security01.handler;
import com.alibaba.fastjson.JSON;
import com.zy.security01.utils.ResponseResult;
import com.zy.security01.utils.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class AuthenticationEntryPointHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ResponseResult<Object> result = new ResponseResult<>(HttpStatus.UNAUTHORIZED.value(), "用户还未登录");
WebUtils.renderString(response, JSON.toJSONString(result));
}
}
授权过程中出现的异常#
package com.zy.security01.handler;
import com.alibaba.fastjson.JSON;
import com.zy.security01.utils.ResponseResult;
import com.zy.security01.utils.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ResponseResult<Object> result = new ResponseResult<>(HttpStatus.FORBIDDEN.value(), "用户没有权限");
WebUtils.renderString(response, JSON.toJSONString(result));
}
}

②配置给SpringSecurity#

​ 我们可以使用HttpSecurity对象的方法去配置。

package com.zy.security01.config;
import com.zy.security01.handler.AuthenticationEntryPointHandler;
import com.zy.security01.handler.MyAccessDeniedHandler;
import com.zy.security01.security.JwtAuthenticationTokenFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.annotation.Resource;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
/**
* 认证配置类(security自带)
*/
@Resource
private AuthenticationConfiguration authenticationConfiguration;
/**
* 自定义jwttoken过滤器
*/
@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Resource
private AuthenticationEntryPointHandler authenticationEntryPointHandler;
@Resource
private MyAccessDeniedHandler myAccessDeniedHandler;
/**
* 加密解密
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 认证管理器
* @return AuthenticationManager
* @throws Exception 异常
*/
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 过滤器链
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception 异常
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/sysUser/user/login").anonymous()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
//授权异常
.exceptionHandling().authenticationEntryPoint(authenticationEntryPointHandler)
//访问异常
.accessDeniedHandler(myAccessDeniedHandler)
.and()
.build();
}
}

5. 跨域#

​ 浏览器出于安全的考虑,使用 XMLHttpRequest对象发起 HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。

​ 前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。

​ 所以我们就要处理一下,让前端能进行跨域请求。

①先对SpringBoot配置,运行跨域请求#

@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域的路径
registry.addMapping("/**")
// 设置允许跨域请求的域名
.allowedOriginPatterns("*")
// 是否允许cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的header属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
}

②开启SpringSecurity的跨域访问#

由于我们的资源都会收到SpringSecurity的保护,所以想要跨域访问还要让SpringSecurity运行跨域访问。

@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问 不包含context-path路径
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//添加过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//允许跨域
http.cors();
}

6. 遗留小问题#

RequestMatcher(请求匹配)#

HttpSecurity 内置了RequestMatcher 属性来处理路径匹配问题。RequestMatcher 可总结为以下几大类:

img

使用Ant路径:

httpSecurity.antMatcher("/foo/**");

如果你配置了全局的Servlet Path的话,例如/v1 ,配置ant路径的话就要/v1/foo/** ,使用MVC风格可以保持一致:

httpSecurity.mvcMatcher("/foo/**");

另外MVC风格可以自动匹配后缀,例如/foo/hello 可以匹配/foo/hello.do/foo/hello.action 等等。另外你也可以使用正则表达式来进行路径匹配:

httpSecurity.regexMatcher("/foo/.+");

如果上面的都满足不了需要的话,你可以通过HttpSecurity.requestMatcher 方法自定义匹配规则;如果你想匹配多个规则的话可以借助于HttpSecurity.requestMatchers 方法来自由组合匹配规则,就像这样:

httpSecurity.requestMatchers(requestMatchers ->
requestMatchers.mvcMatchers("/foo/**")
.antMatchers("/admin/*get"));

一旦你配置了路径匹配规则的话,你会发现默认的表单登录404了,因为默认是/login ,你加了前缀后当然访问不到了。

使用场景#

比如你后台管理系统和前端应用各自走不同的过滤器链,你可以根据访问路径来配置各自的过滤器链。例如:

/**
* Admin 过滤器链.
*
* @param http the http
* @return the security filter chain
* @throws Exception the exception
*/
@Bean
SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
http.requestMatchers(requestMatchers -> requestMatchers.mvcMatchers("/admin/**"))
//todo 其它配置
return http.build();
}
/**
* App 过滤器链.
*
* @param http the http
* @return the security filter chain
* @throws Exception the exception
*/
@Bean
SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
http.requestMatchers(requestMatchers -> requestMatchers.mvcMatchers("/app/**"));
//todo 其它配置
return http.build();
}

另外也可以使用该特性降低不同规则URI之间的耦合性。

spring security的permitAll以及webIgnore的区别#

permitAll配置实例#
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/css/**", "/js/**","/fonts/**").permitAll()
.anyRequest().authenticated();
}
}
web ignore配置实例#
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**");
web.ignoring().antMatchers("/js/**");
web.ignoring().antMatchers("/fonts/**");
}
}
二者区别#

顾名思义,WebSecurity主要是配置跟web资源相关的,比如css、js、images等等,但是这个还不是本质的区别,关键的区别如下:

  • ingore是完全绕过了spring security的所有filter,相当于不走spring security
  • permitall没有绕过spring security,其中包含了登录的以及匿名的。
AnonymousAuthenticationFilter#

spring-security-web-4.2.3.RELEASE-sources.jar!/org/springframework/security/web/authentication/AnonymousAuthenticationFilter.java

/**
* Detects if there is no {@code Authentication} object in the
* {@code SecurityContextHolder}, and populates it with one if needed.
*
* @author Ben Alex
* @author Luke Taylor
*/
public class AnonymousAuthenticationFilter extends GenericFilterBean implements
InitializingBean {
//......
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
SecurityContextHolder.getContext().setAuthentication(
createAuthentication((HttpServletRequest) req));
if (logger.isDebugEnabled()) {
logger.debug("Populated SecurityContextHolder with anonymous token: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'");
}
}
else {
if (logger.isDebugEnabled()) {
logger.debug("SecurityContextHolder not populated with anonymous token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'");
}
}
chain.doFilter(req, res);
}
protected Authentication createAuthentication(HttpServletRequest request) {
AnonymousAuthenticationToken auth = new AnonymousAuthenticationToken(key,
principal, authorities);
auth.setDetails(authenticationDetailsSource.buildDetails(request));
return auth;
}
//......
}

这个filter的主要功能就是给没有登陆的用户,填充AnonymousAuthenticationToken到SecurityContextHolder的Authentication,后续依赖Authentication的代码可以统一处理。

FilterComparator#

spring-security-config-4.1.4.RELEASE-sources.jar!/org/springframework/security/config/annotation/web/builders/FilterComparator.java

final class FilterComparator implements Comparator<Filter>, Serializable {
private static final int STEP = 100;
private Map<String, Integer> filterToOrder = new HashMap<String, Integer>();
FilterComparator() {
int order = 100;
put(ChannelProcessingFilter.class, order);
order += STEP;
put(ConcurrentSessionFilter.class, order);
order += STEP;
put(WebAsyncManagerIntegrationFilter.class, order);
order += STEP;
put(SecurityContextPersistenceFilter.class, order);
order += STEP;
put(HeaderWriterFilter.class, order);
order += STEP;
put(CorsFilter.class, order);
order += STEP;
put(CsrfFilter.class, order);
order += STEP;
put(LogoutFilter.class, order);
order += STEP;
put(X509AuthenticationFilter.class, order);
order += STEP;
put(AbstractPreAuthenticatedProcessingFilter.class, order);
order += STEP;
filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter",
order);
order += STEP;
put(UsernamePasswordAuthenticationFilter.class, order);
order += STEP;
put(ConcurrentSessionFilter.class, order);
order += STEP;
filterToOrder.put(
"org.springframework.security.openid.OpenIDAuthenticationFilter", order);
order += STEP;
put(DefaultLoginPageGeneratingFilter.class, order);
order += STEP;
put(ConcurrentSessionFilter.class, order);
order += STEP;
put(DigestAuthenticationFilter.class, order);
order += STEP;
put(BasicAuthenticationFilter.class, order);
order += STEP;
put(RequestCacheAwareFilter.class, order);
order += STEP;
put(SecurityContextHolderAwareRequestFilter.class, order);
order += STEP;
put(JaasApiIntegrationFilter.class, order);
order += STEP;
put(RememberMeAuthenticationFilter.class, order);
order += STEP;
put(AnonymousAuthenticationFilter.class, order);
order += STEP;
put(SessionManagementFilter.class, order);
order += STEP;
put(ExceptionTranslationFilter.class, order);
order += STEP;
put(FilterSecurityInterceptor.class, order);
order += STEP;
put(SwitchUserFilter.class, order);
}
//......
}

这个类定义了spring security内置的filter的优先级,AnonymousAuthenticationFilter在倒数第五个执行,在FilterSecurityInterceptor这个类之前。

FilterSecurityInterceptor#

spring-security-web-4.2.3.RELEASE-sources.jar!/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.java

/**
* Performs security handling of HTTP resources via a filter implementation.
* <p>
* The <code>SecurityMetadataSource</code> required by this security interceptor is of
* type {@link FilterInvocationSecurityMetadataSource}.
* <p>
* Refer to {@link AbstractSecurityInterceptor} for details on the workflow.
* </p>
*
* @author Ben Alex
* @author Rob Winch
*/
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
Filter {
//......
}

这个相当于spring security的核心处理类了,它继承抽象类AbstractSecurityInterceptor

spring-security-core-4.2.3.RELEASE-sources.jar!/org/springframework/security/access/intercept/AbstractSecurityInterceptor.java

public abstract class AbstractSecurityInterceptor implements InitializingBean,
ApplicationEventPublisherAware, MessageSourceAware {
//......
protected InterceptorStatusToken beforeInvocation(Object object) {
Assert.notNull(object, "Object was null");
final boolean debug = logger.isDebugEnabled();
if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
throw new IllegalArgumentException(
"Security invocation attempted for object "
+ object.getClass().getName()
+ " but AbstractSecurityInterceptor only configured to support secure objects of type: "
+ getSecureObjectClass());
}
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
if (attributes == null || attributes.isEmpty()) {
if (rejectPublicInvocations) {
throw new IllegalArgumentException(
"Secure object invocation "
+ object
+ " was denied as public invocations are not allowed via this interceptor. "
+ "This indicates a configuration error because the "
+ "rejectPublicInvocations property is set to 'true'");
}
if (debug) {
logger.debug("Public object - authentication not attempted");
}
publishEvent(new PublicInvocationEvent(object));
return null; // no further work post-invocation
}
if (debug) {
logger.debug("Secure object: " + object + "; Attributes: " + attributes);
}
if (SecurityContextHolder.getContext().getAuthentication() == null) {
credentialsNotFound(messages.getMessage(
"AbstractSecurityInterceptor.authenticationNotFound",
"An Authentication object was not found in the SecurityContext"),
object, attributes);
}
Authentication authenticated = authenticateIfRequired();
// Attempt authorization
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
accessDeniedException));
throw accessDeniedException;
}
if (debug) {
logger.debug("Authorization successful");
}
if (publishAuthorizationSuccess) {
publishEvent(new AuthorizedEvent(object, attributes, authenticated));
}
// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object,
attributes);
if (runAs == null) {
if (debug) {
logger.debug("RunAsManager did not change Authentication object");
}
// no further work post-invocation
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false,
attributes, object);
}
else {
if (debug) {
logger.debug("Switching to RunAs Authentication: " + runAs);
}
SecurityContext origCtx = SecurityContextHolder.getContext();
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
SecurityContextHolder.getContext().setAuthentication(runAs);
// need to revert to token.Authenticated post-invocation
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
}
//......
}

主要的逻辑在这个beforeInvocation方法,它就依赖了authentication

private Authentication authenticateIfRequired() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
if (authentication.isAuthenticated() && !alwaysReauthenticate) {
if (logger.isDebugEnabled()) {
logger.debug("Previously Authenticated: " + authentication);
}
return authentication;
}
authentication = authenticationManager.authenticate(authentication);
// We don't authenticated.setAuthentication(true), because each provider should do
// that
if (logger.isDebugEnabled()) {
logger.debug("Successfully Authenticated: " + authentication);
}
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication;
}

这个方法判断authentication如果是已经校验过的,则返回;没有校验过的话,则调用authenticationManager进行鉴权。

而AnonymousAuthenticationFilter设置的authentication在这个时候就派上用场了 spring-security-core-4.2.3.RELEASE-sources.jar!/org/springframework/security/authentication/AnonymousAuthenticationToken.java

public class AnonymousAuthenticationToken extends AbstractAuthenticationToken implements
Serializable {
private AnonymousAuthenticationToken(Integer keyHash, Object principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
if (principal == null || "".equals(principal)) {
throw new IllegalArgumentException("principal cannot be null or empty");
}
Assert.notEmpty(authorities, "authorities cannot be null or empty");
this.keyHash = keyHash;
this.principal = principal;
setAuthenticated(true);
}
//......
}

它默认就是authenticated

小结#
  • web ignore比较适合配置前端相关的静态资源,它是完全绕过spring security的所有filter的;
  • 而permitAll,会给没有登录的用户适配一个AnonymousAuthenticationToken,设置到SecurityContextHolder,方便后面的filter可以统一处理authentication。

其它权限校验方法#

​ 我们前面都是使用@PreAuthorize注解,然后在在其中使用的是hasAuthority方法进行校验。SpringSecurity还为我们提供了其它方法例如:hasAnyAuthority,hasRole,hasAnyRole等。

​ 这里我们先不急着去介绍这些方法,我们先去理解hasAuthority的原理,然后再去学习其他方法你就更容易理解,而不是死记硬背区别。并且我们也可以选择定义校验方法,实现我们自己的校验逻辑。

​ hasAuthority方法实际是执行到了SecurityExpressionRoot的hasAuthority,大家只要断点调试既可知道它内部的校验原理。

​ 它内部其实是调用authentication的getAuthorities方法获取用户的权限列表。然后判断我们存入的方法参数数据在权限列表中。

​ hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。

@PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
public String hello(){
return "hello";
}

​ hasRole要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。

@PreAuthorize("hasRole('system:dept:list')")
public String hello(){
return "hello";
}

​ hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。

@PreAuthorize("hasAnyRole('admin','system:dept:list')")
public String hello(){
return "hello";
}

自定义权限校验方法#

​ 我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法。

@Component("ex")
public class SGExpressionRoot {
public boolean hasAuthority(String authority){
//获取当前用户的权限
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
List<String> permissions = loginUser.getPermissions();
//判断用户权限集合中是否存在authority
return permissions.contains(authority);
}
}

​ 在SPEL表达式中使用 @ex相当于获取容器中bean的名字未ex的对象。然后再调用这个对象的hasAuthority方法

@RequestMapping("/hello")
@PreAuthorize("@ex.hasAuthority('system:dept:list')")
public String hello(){
return "hello";
}

基于配置的权限控制#

​ 我们也可以在配置类中使用使用配置的方式对资源进行权限控制。

@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问 不包含context-path路径
.antMatchers("/user/login").anonymous()
.antMatchers("/testCors").hasAuthority("system:dept:list222")
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//添加过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//允许跨域
http.cors();
}

CSRF#

​ CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。

https://blog.csdn.net/freeking101/article/details/86537087

​ SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。

​ 我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。

认证成功处理器#

​ 实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler就是登录成功处理器。

​ 我们也可以自己去自定义成功处理器进行成功后的相应处理。

@Component
public class SGSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("认证成功了");
}
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin().successHandler(successHandler);
http.authorizeRequests().anyRequest().authenticated();
}
}

认证失败处理器#

​ 实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler就是登录失败处理器。

​ 我们也可以自己去自定义失败处理器进行失败后的相应处理。

@Component
public class SGFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("认证失败了");
}
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Autowired
private AuthenticationFailureHandler failureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
// 配置认证成功处理器
.successHandler(successHandler)
// 配置认证失败处理器
.failureHandler(failureHandler);
http.authorizeRequests().anyRequest().authenticated();
}
}

登出成功处理器#

@Component
public class SGLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("注销成功");
}
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Autowired
private AuthenticationFailureHandler failureHandler;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
// 配置认证成功处理器
.successHandler(successHandler)
// 配置认证失败处理器
.failureHandler(failureHandler);
http.logout()
//配置注销成功处理器
.logoutSuccessHandler(logoutSuccessHandler);
http.authorizeRequests().anyRequest().authenticated();
}
}

其他认证方案畅想#

7. 源码讲解#

​ 投票过50更新源码讲解

8.修改security的loadUserByUsername#

9.添加短信验证码过滤器#

整体把握#

1、简介#

SpringSecurity是一个能够基于Spring的应用程序提供声明式安全保护的安全性框架,它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了SpringIOC(控制反转)和AOP(面向切面编程)功能,为应用系统提供安全访问控制功能,减少了为系统安全控制编写大量重复代码的工作。

官网地址:https://spring.io/projects/spring-security

安全框架主要包含两个操作。

认证:确认用户可以访问当前系统。

授权:确定用户在当前系统中是否能够执行某个操作,即用户所拥有的功能权限。

2、Security适配器#

创建一个自定义类继承WebSecurityConfigurerAdapter,并在改类中使用@EnableWebSecurity注解就可以通过重写config方法类配置所需要的安全配置。

WebSecurityConfigurerAdapter是SpringSecurity为Web应用提供的一个适配器,实现了WebSecurityConfigurerAdapter接口,提供了两个方法用于重写开发者需要的安全配置。

protected void configure(HttpSecurity http) throws Exception {
}
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
}

configure(HttpSecurity http)方法中可以通过HTTPSecurity的authorizeRequests()方法定义那些URL需要被保护、那些不需要被保护;通过formLogin()方法定义当前需要用户登录的时候,跳转到的登录页面。

configure(AuthenticationManagerBuilder auth)方法用于创建用户和用户的角色。

用户认证#

SpringSecurity是通过configure(AuthenticationManagerBuilder auth)完成用户认证的。使用AuthenticationManagerBuilder的inMemoryAuthentication()方法可以添加用户,并给用户指定权限。

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("aaa").password("{noop}1234").roles("USER");
auth.inMemoryAuthentication().withUser("admin").password("{noop}admin").roles("ADMIN", "DBA");
}

上面的代码中添加了两个用户,其中一个用户名是“aaa”,密码是“1234”,用户权限是“USER”;另一个用户名是“admin”,密码是“admin”,用户权限有两个,分别是“ADMIN”和“DBA”。需要注意的是SpringSecurity保存用户权限的时候,会默认使用“ROLE_”,也就是说“USER”实际上是“ROLE_USER”,“ADMIN”实际是“ROLE_ADMIN”,“DBA”实际上是“ROLE_DBA”。

当然,也可以查询数据库获取用户和权限。下面有写到。

用户授权#

SpringSecurity是通过configure(HttpSecurity http)完成用户授权的。

HTTPSecurity的authorizeRequests()方法有多个子节点,每个macher按照它们的声明顺序执行,指定用户可以访问的多个URL模式。

antMatchers使用Ant风格匹配路径。 regexMatchers使用正则表达式匹配路径。 在匹配了请求路径后,可以针对当前用户的信息对请求路径进行安全处理。下表是SpringSecurity提供的安全处理方法。

方法 用途 anyRequest 匹配所有请求路径 access(String) Spring EL 表达式结果为true时可以访问 anonymous() 匿名可以访问 denyAll() 用户不能访问 fullyAuthenticated() 用户完全认证可以访问(非remember-me下自动登录) hasAnyAuthority(String…) 如果有参数,参数表示权限,则其中任何一个权限可以访问 hasAnyRole(String…) 如果有参数,参数表示角色,则其中任何一个角色可以访问 hasAuthority(String…) 如果有参数,参数表示权限,则其权限可以访问 hasIpAddress(String) 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问 hasRole(String) 如果有参数,参数表示角色,则其角色可以访问 permitAll() 用户可以任意访问 rememberMe() 允许通过remember-me登录的用户访问 authenticated() 用户登录后可访问 示例代码如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable(); //安全器令牌
http.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/", "/home").hasRole("USER")
.antMatchers("/admin/**").hasAnyRole("ADMIN", "DBA")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.usernameParameter("loginName").passwordParameter("password")
.defaultSuccessUrl("/main")
.failureUrl("/login?error")
.and()
.logout()
.permitAll()
.and()
.exceptionHandling().accessDeniedPage("/accessDenied");
}
  • http.authorizeRequests() 开始请求权限配置。

  • antMatchers(“/login”).permitAll() 请求匹配“/login”,所有用户都可以访问。

  • antMatchers(”/”, “/home”).hasRole(“USER”) 请求匹配“/”和“/home”,拥有“ROLE_USER”角色的用户可以访问。

  • antMatchers(“/admin/“).hasAnyRole(“ADMIN”, “DBA”) 请求匹配“/admin/”,拥有“ROLE_ADMIN”或“ROLE_DBA”角色的用户可以访问。

  • anyRequest().authenticated() 其余所有的请求都需要认证(用户登录)之后才可以访问。

  • formLogin() 开始设置登录操作

  • loginPage(“/login”) 设置登录页面的访问地址

  • usernameParameter(“loginName”).passwordParameter(“password”) 登录时接收传递的参数“loginName”的值作为用户名,接收传递参数的“password”的值作为密码。如果不设置,默认“username”为用户名,“password”为密码

  • defaultSuccessUrl(“/main”) 指定登录成功后转向的页面。

  • failureUrl(“/login?error”) 指定登录失败后转向的页面和传递的参数。

  • logout() 设置注销操作

  • permitAll() 所有用户都可以访问。

  • exceptionHandling().accessDeniedPage(“/accessDenied”) 指定异常处理页面

SpringSecurity核心类#

SpringSecurity核心类包括Authentication、SecurityContextHolder、UserDetails、UserDetailsService、GrantedAuthority、DaoAuthenticationProvider和PasswordEncoder。只要掌握了这些SpringSecurity核心类,SpringSecurity就会变得非常简单。

1、Authentication类#

Authentication用来表示用户认证信息,用户登录认证之前,SpringSecurity会将相关信息封装为一个Authentication具体实现类的对象,在登录认证成功之后又会生成一个信息更全面、包含用户权限等信息的Authentication对象,然后把它保存在SecurityContextHolder所持有的SecurityContext中,供后续的程序进行调用,如访问权限的鉴定等。

SecurityContextHolder中的getContext()方法

public static SecurityContext getContext() {
return strategy.getContext();
}

2、SecurityContextHolder类#

SecurityContextHolder是用来保存SecurityContext的。SecurityContext中含有当前所访问系统的用户的详细信息。默认情况下,SecurityContextHolder将使用ThreadLocal来保存SecurityContext,这也就意味着在处于同一线程的方法中,可以从ThreadLocal获取到当前的SecurityContext。

SpringSecurity使用一个Authentication对象来描述当前用户的相关信息。SecurityContextHolder中持有的是当前用户的SecurityContext,而SecurityContext持有的是代表当前用户相关信息的Authentication的引用。这个Authentication对象不需要我们自己创建,在与系统交互的过程中,SpringSecurity会自动创建相应的Authentication对象,然后赋值给当前的SecurityContext。开发过程中常常需要在程序中获取当前用户的相关信息,比如最常见的获取当前登录用户的用户名。

String username = SecurityContextHolder.getContext().getAuthentication().getName();

获取UserDetails类,该类中包含用户认证相关等信息。

UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

3、UserDetails类#

UserDetails是SpringSecurity的一个核心接口。其中定义了一些可以获取用户名,密码、权限等与认证相关的信息的方法。SpringSecurity内部使用UserDetails实现类大都是内置的User类,要使用UserDetails,也可以直接使用该类。在SpringSecurity内部,很多需要使用用户信息的时候,基本上都是使用UserDetails,比如在登录认证的时候。

通常需要在应用中获取当前用户的其他信息,如E-mail、电话等。这时存放在Authentication中的principal只包含认证相关信息的UserDetails对象可能就不能满足我们的要求了。这时可以实现自己的UserDetails,在该实现类中可以定义一些获取用户其他信息的方法,这样将来就可以直接从当前SecurityContext的Authentication的principal中获取这些信息。

UserDetails是通过UserDetailsService的loadUserByUsername()方法进行加载的,UserDetailsService也是一个接口,我们也需要实现自己的UserDetailsService来加载自定义的UserDetails信息。

新建用户表的Service类实现UserDetailsService接口,来重写UserDetailsService的loadUserByUsername()方法,根据用户名查询当前用户信息并返回,返回的类必须继承Security内置的User类。

@Service
public class SysUserService implements UserDetailsService{
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
}
}

4、UserDetailsService类#

Authentication.getPrincipal() 的返回类型是Object。

Object getPrincipal();

但很多情况下返回的其实是一个UserDetails的实例。登录认证的时候SpringSecurity会通过UserDetailsService的loadUserByUsername()方法获取对应的UserDetails进行认证,认证通过后会将该UserDetails赋给认证通过的Authentication的Principal,然后在把该Authentication存入SecurityContext。之后如果需要使用用户信息,可以通过SecurityContextHolder获取存放在SecurityContext中的Authentication的principal,转为UserDetails类。

UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

5、GrantedAuthority类#

Authentication的getAuthorities()方法可以返回当前Authentication对象用户的所有的权限。

Collection<? extends GrantedAuthority> getAuthorities();

即当前用户拥有的权限。其返回值是一个GrantedAuthority类型的数组,每一个GrantedAuthority对象代表赋予给当前用户的一种权限。GrantedAuthority是一个接口,其通常是通过UserDetailsService进行加载,然后赋予UserDetails的。

GrantedAuthority中只定义了一个getAuthority()方法,该方法返回一个字符串,表示对应的权限,如果对应权限不能用字符串表示,则返回null

获取当前用户的所有权限,存放到List集合中。

UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
List<String> roleCodes=new ArrayList<>();
for (GrantedAuthority authority : userDetails.getAuthorities()) {
roleCodes.add(authority.getAuthority());
}

6、DaoAuthenticationProvider类#

SpringSecurity默认了会使用DaoAuthenticationProvider实现AuthenticationProvider接口,专门进行用户认证的处理。DaoAuthenticationProvider在进行认证的时候需要一个UserDetailsService来获取用户的信息UserDetails,其中包括用户名,密码和所拥有的权限等。如果需要改变认证的方式,开发者可以实现自己的AuthenticationProvider。

7、PasswordEncoder类#

在SpringSecurity中,对密码的加密都是由PasswordEncoder来完成的。在SpringSecurity中,已经对PasswordEncoder有了很多实现,包括md5加密,SHA-256加密等,开发者只需要直接拿来用就可以。在DaoAuthenticationProvider中,有一个就是PasswordEncoder熟悉,密码加密功能主义靠它来完成。

SpringSecurity的验证机制#

1、SpringSecurity的验证机制 SpringSecurity大体上是由一堆Filter实现的,Filter会在SpringMVC前拦截请求。Filter包括登出Filter(LogoutFilter)、用户名密码验证Filter(UsernamePasswordAuthenticationFilter)之类。Filter在交由其他组件完成细分的功能,最常用的UsernamePasswordAuthenticationFilter会持有一个AuthenticationManager引用,AuthenticationManager是一个验证管理器,专门负责验证。但AuthenticationManager本身并不做具体的验证工作,AuthenticationManager持有一个AuthenticationProvider集合,AuthenticationProvider才是做验证工作的组件,验证成功或失败之后调用对应的Handler。 ———————————————— 版权声明:本文为CSDN博主「小马 同学」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/qq_40205116/article/details/103439326

SpringBoot2.7 WebSecurityConfigurerAdapter类过期配置#

前言#

进入到 SpringBoot2.7 时代,有小伙伴发现有一个常用的类忽然过期了:

img

在 Spring Security 时代,这个类可太重要了。过期的类当然可以继续使用,但是你要是决定别扭,只需要稍微看一下注释,基本上就明白该怎么玩了。

WebSecurityConfigurerAdapter 的注释#

我们来看下 WebSecurityConfigurerAdapter 的注释:

img

从这段注释中我们大概就明白了咋回事了。

配置Spring Security#

以前我们自定义类继承自 WebSecurityConfigurerAdapter 来配置我们的 Spring Security,我们主要是配置两个东西:

  • configure(HttpSecurity)
  • configure(WebSecurity)

前者主要是配置 Spring Security 中的过滤器链,后者则主要是配置一些路径放行规则。

现在在 WebSecurityConfigurerAdapter 的注释中,人家已经把意思说的很明白了:

  • 以后如果想要配置过滤器链,可以通过自定义 SecurityFilterChain Bean 来实现。
  • 以后如果想要配置 WebSecurity,可以通过 WebSecurityCustomizer Bean 来实现。

那么接下来我们就通过一个简单的例子来看下。

引入Web和Spring Security依赖#

首先我们新建一个 Spring Boot 工程,引入 Web 和 Spring Security 依赖,注意 Spring Boot 选择最新的 2.7。

img

接下来我们提供一个简单的测试接口,如下:

@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello 江南一点雨!";
}
}

小伙伴们知道,在 Spring Security 中,默认情况下,只要添加了依赖,我们项目的所有接口就已经被统统保护起来了,现在启动项目,访问 /hello 接口,就需要登录之后才可以访问,登录的用户名是 user,密码则是随机生成的,在项目的启动日志中。

现在我们的第一个需求是使用自定义的用户,而不是系统默认提供的,这个简单,我们只需要向 Spring 容器中注册一个 UserDetailsService 的实例即可,像下面这样:

@Configuration
public class SecurityConfig {
@Bean
UserDetailsService userDetailsService() {
InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());
users.createUser(User.withUsername("江南一点雨").password("{noop}123").roles("admin").build());
return users;
}
}

这就可以了。

当然我现在的用户是存在内存中的,如果你的用户是存在数据库中,那么只需要提供 UserDetailsService 接口的实现类并注入 Spring 容器即可,这个之前在 vhr 视频中讲过多次了(公号后台回复 666 有视频介绍),这里就不再赘述了。

重写configure(WebSecurity方法进行配置#

但是假如说我希望 /hello 这个接口能够匿名访问,并且我希望这个匿名访问还不经过 Spring Security 过滤器链,要是在以前,我们可以重写 configure(WebSecurity) 方法进行配置,但是现在,得换一种玩法:

@Configuration
public class SecurityConfig {
@Bean
UserDetailsService userDetailsService() {
InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());
users.createUser(User.withUsername("江南一点雨").password("{noop}123").roles("admin").build());
return users;
}
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
return new WebSecurityCustomizer() {
@Override
public void customize(WebSecurity web) {
web.ignoring().antMatchers("/hello");
}
};
}
}

以前位于 configure(WebSecurity) 方法中的内容,现在位于 WebSecurityCustomizer Bean 中,该配置的东西写在这里就可以了。

定制登录页面参数等#

那如果我还希望对登录页面,参数等,进行定制呢?继续往下看:

@Configuration
public class SecurityConfig {
@Bean
UserDetailsService userDetailsService() {
InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());
users.createUser(User.withUsername("江南一点雨").password("{noop}123").roles("admin").build());
return users;
}
@Bean
SecurityFilterChain securityFilterChain() {
List&lt;Filter&gt; filters = new ArrayList&lt;&gt;();
return new DefaultSecurityFilterChain(new AntPathRequestMatcher("/**"), filters);
}
}

Spring Security 的底层实际上就是一堆过滤器,所以我们之前在 configure(HttpSecurity) 方法中的配置,实际上就是配置过滤器链。现在过滤器链的配置,我们通过提供一个 SecurityFilterChain Bean 来配置过滤器链,SecurityFilterChain 是一个接口,这个接口只有一个实现类 DefaultSecurityFilterChain,构建 DefaultSecurityFilterChain 的第一个参数是拦截规则,也就是哪些路径需要拦截,第二个参数则是过滤器链,这里我给了一个空集合,也就是我们的 Spring Security 会拦截下所有的请求,然后在一个空集合中走一圈就结束了,相当于不拦截任何请求。

此时重启项目,你会发现 /hello 也是可以直接访问的,就是因为这个路径不经过任何过滤器。

其实我觉得目前这中新写法比以前老的写法更直观,更容易让大家理解到 Spring Security 底层的过滤器链工作机制。

有小伙伴会说,这写法跟我以前写的也不一样呀!这么配置,我也不知道 Spring Security 中有哪些过滤器,其实,换一个写法,我们就可以将这个配置成以前那种样子:

@Configuration
public class SecurityConfig {
@Bean
UserDetailsService userDetailsService() {
InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());
users.createUser(User.withUsername("江南一点雨").password("{noop}123").roles("admin").build());
return users;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.permitAll()
.and()
.csrf().disable();
return http.build();
}
}

这么写,就跟以前的写法其实没啥大的差别了。

好啦,多余的废话我就不多说了,小伙伴们可以去试试 Spring Boot2.7 的最新玩法啦~

实践配置#

package com.zy.security01.config;
import com.zy.security01.security.JwtAuthenticationTokenFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.annotation.Resource;
@Configuration
public class SecurityConfig {
/**
* 认证配置类(security自带)
*/
@Resource
private AuthenticationConfiguration authenticationConfiguration;
/**
* 自定义jwttoken过滤器
*/
@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
/**
* 加密解密
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 认证管理器
* @return AuthenticationManager
* @throws Exception 异常
*/
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 过滤器链
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception 异常
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf().disable()
// .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// .and()
.authorizeRequests()
//必须以斜杠开头
.antMatchers("/sysUser/user/login").anonymous()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}

springsecurity怎么放掉swaager2和knife4j的静态资源#

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/swagger/**")
.antMatchers("/swagger-ui.html")
.antMatchers("/webjars/**")
.antMatchers("/v2/**")
.antMatchers("/v2/api-docs-ext/**")
.antMatchers("/swagger-resources/**")
.antMatchers("/doc.html");
}

Spring Security面试题#

image-20240401123114171

核心概念#

spring security是如何完成身份验证的?#

  • 用户名和密码被过滤器获取到,封装成Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。
  • AuthenticationManager 身份管理器负责验证这个Authentication
  • 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。
  • SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。

SecurityContextHolder

SecurityContextHolder用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限,这些都被保存在SecurityContextHolder中。SecurityContextHolder默认使用ThreadLocal 策略来存储认证信息。看到ThreadLocal 也就意味着,这是一种与线程绑定的策略。Spring Security在用户登录时自动绑定认证信息到当前线程,在用户退出时,自动清除当前线程的认证信息。但这一切的前提,是你在web场景下使用Spring Security。

Authentication

Authentication在spring security中是最高级别的身份/认证的抽象。由这个顶级接口,我们可以得到用户拥有的权限信息列表,密码,用户细节信息,用户身份信息,认证信息。

AuthenticationManager

AuthenticationManager(接口)是认证相关的核心接口,也是发起认证的出发点

AuthenticationProvider

认证具体实现

UserDetailService

负责从特定的地方(通常是数据库)加载用户信息

比较一下 Spring Security 和 Shiro 各自的优缺点 ?#

由于 Spring Boot 官方提供了大量的非常方便的开箱即用的 Starter ,包括 Spring Security 的 Starter ,使得在 Spring Boot 中使用 Spring Security 变得更加容易,甚至只需要添加一个依赖就可以保护所有的接口,所以,如果是 Spring Boot 项目,一般选择 Spring Security 。当然这只是一个建议的组合,单纯从技术上来说,无论怎么组合,都是没有问题的。Shiro 和 Spring Security 相比,主要有如下一些特点:

  • Spring Security 是一个重量级的安全管理框架;Shiro 则是一个轻量级的安全管理框架
  • Spring Security 概念复杂,配置繁琐;Shiro 概念简单、配置简单
  • Spring Security 功能强大;Shiro 功能简单

1.什么是Spring Security?核心功能?#

Spring Security是一个基于Spring框架的安全框架,提供了完整的安全解决方案,包括认证、授权、攻击防护等功能。

其核心功能包括:

  • 认证:提供了多种认证方式,如表单认证、HTTP Basic认证、OAuth2认证等,可以与多种身份验证机制集成。
  • 授权:提供了多种授权方式,如角色授权、基于表达式的授权等,可以对应用程序中的不同资源进行授权。
  • 攻击防护:提供了多种防护机制,如跨站点请求伪造(CSRF)防护、注入攻击防护等。
  • 会话管理:提供了会话管理机制,如令牌管理、并发控制等。
  • 监视与管理:提供了监视与管理机制,如访问日志记录、审计等。

Spring Security通过配置安全规则和过滤器链来实现以上功能,可以轻松地为Spring应用程序提供安全性和保护机制。

2.Spring Security的原理?#

Spring Security是一个基于Spring框架的安全性认证和授权框架,它提供了全面的安全性解决方案,可以保护Web应用程序中的所有关键部分。

Spring Security的核心原理是拦截器(Filter)。Spring Security会在Web应用程序的过滤器链中添加一组自定义的过滤器,这些过滤器可以实现身份验证和授权功能。当用户请求资源时,Spring Security会拦截请求,并使用配置的身份验证机制来验证用户身份。如果身份验证成功,Spring Security会授权用户访问所请求的资源。

image-20240401122903035

Spring Security的具体工作原理如下:

  1. 用户请求Web应用程序的受保护资源。
  2. Spring Security拦截请求,并尝试获取用户的身份验证信息。
  3. 如果用户没有经过身份验证,Spring Security将向用户显示一个登录页面,并要求用户提供有效的凭据(用户名和密码)。
  4. 一旦用户提供了有效的凭据,Spring Security将验证这些凭据,并创建一个已认证的安全上下文(SecurityContext)对象。
  5. 安全上下文对象包含已认证的用户信息,包括用户名、角色和授权信息。
  6. 在接下来的请求中,Spring Security将使用已经认证的安全上下文对象来判断用户是否有权访问受保护的资源。
  7. 如果用户有权访问资源,Spring Security将允许用户访问资源,否则将返回一个错误信息。

3.有哪些控制请求访问权限的方法?#

在Spring Security中,可以使用以下方法来控制请求访问权限:

  • permitAll()允许所有用户访问该请求,不需要进行任何身份验证。
  • denyAll()拒绝所有用户访问该请求。
  • anonymous()允许匿名用户访问该请求。
  • authenticated():要求用户进行身份验证,但是不要求用户具有任何特定的角色。
  • hasRole(String role):要求用户具有特定的角色才能访问该请求。
  • hasAnyRole(String... roles):要求用户具有多个角色中的至少一个角色才能访问该请求。
  • hasAuthority(String authority):要求用户具有特定的权限才能访问该请求。
  • hasAnyAuthority(String... authorities):要求用户具有多个权限中的至少一个权限才能访问该请求。

可以将这些方法应用于Spring Security的配置类或者在Spring Security注解中使用。

4.hasRole 和 hasAuthority 有区别吗?#

在Spring Security中,hasRole和hasAuthority都可以用来控制用户的访问权限,但它们有一些细微的差别。

hasRole方法是基于角色进行访问控制的。它检查用户是否有指定的角色,并且这些角色以”ROLE_“前缀作为前缀(例如”ROLE_ADMIN”)。

hasAuthority方法是基于权限进行访问控制的。它检查用户是否有指定的权限,并且这些权限没有前缀。

因此,使用hasRole方法需要在用户的角色名称前添加”ROLE_“前缀,而使用hasAuthority方法不需要这样做。

例如,假设用户有一个角色为”ADMIN”和一个权限为”VIEW_REPORTS”,可以使用以下方式控制用户对页面的访问权限:

.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/reports/**").hasAuthority("VIEW_REPORTS")

在这个例子中,只有具有”ROLE_ADMIN”角色的用户才能访问/admin/路径下的页面,而具有”VIEW_REPORTS”权限的用户才能访问/reports/路径下的页面。

5.如何对密码进行加密?#

在 Spring Security 中对密码进行加密通常使用的是密码编码器(PasswordEncoder)。PasswordEncoder 的作用是将明文密码加密成密文密码,以便于存储和校验。Spring Security 提供了多种常见的密码编码器,例如 BCryptPasswordEncoder、SCryptPasswordEncoder、StandardPasswordEncoder 等。

以 BCryptPasswordEncoder 为例,使用步骤如下:

1.在 pom.xml 文件中添加 BCryptPasswordEncoder 的依赖:#
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>5.6.1</version>
</dependency>

2.在 Spring 配置文件中注入 BCryptPasswordEncoder:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// ...
}

3.在使用密码的地方调用 passwordEncoder.encode() 方法对密码进行加密,例如注册时对密码进行加密:

@Service
public class UserServiceImpl implements UserService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public User register(User user) {
String encodedPassword = passwordEncoder.encode(user.getPassword());
user.setPassword(encodedPassword);
// ...
return user;
}
// ...
}

以上就是使用 BCryptPasswordEncoder 对密码进行加密的步骤。使用其他密码编码器的步骤类似,只需将 BCryptPasswordEncoder 替换为相应的密码编码器即可。

6.Spring Security基于用户名和密码的认证模式流程?#

请求的用户名密码可以通过表单登录,基础认证,数字认证三种方式从HttpServletRequest中获得,用于认证的数据源策略有内存,数据库,ldap,自定义等。

拦截未授权的请求,重定向到登录页面的过程:

当用户访问需要授权的资源时,Spring Security会检查用户是否已经认证(即是否已登录),如果没有登录则会重定向到登录页面。

重定向到登录页面时,用户需要输入用户名和密码进行认证。

表单登录的过程:

  1. 用户在登录页面输入用户名和密码,提交表单。
  2. Spring Security的UsernamePasswordAuthenticationFilter拦截表单提交的请求,并将用户名和密码封装成一个Authentication对象。
  3. AuthenticationManager接收到Authentication对象后,会根据用户名和密码查询用户信息,并将用户信息封装成一个UserDetails对象。
  4. 如果查询到用户信息,则将UserDetails对象封装成一个已认证的Authentication对象并返回,如果查询不到用户信息,则抛出相应的异常。
  5. 认证成功后,用户会被重定向到之前访问的资源。如果之前访问的资源需要特定的角色或权限才能访问,则还需要进行授权的过程。

Spring Security的认证流程大致可以分为两个过程,首先是用户登录认证的过程,然后是用户访问受保护资源时的授权过程。在认证过程中,用户需要提供用户名和密码,Spring Security通过UsernamePasswordAuthenticationFilter将用户名和密码封装成Authentication对象,并交由AuthenticationManager进行认证。

如果认证成功,则认证结果会存储在SecurityContextHolder中。在授权过程中,Spring Security会检查用户是否有访问受保护资源的权限,如果没有则会重定向到登录页面进行认证。

授权

拦截未授权的请求,重定向到登录页面

img

认证

表单登录的过程,进行账号密码认证

image-20240401124159423


SpringCloud Security(上)#

spring-cloud-security#

是什么:#

Spring Cloud Security提供了一组原语,用于构建安全的应用程序和服务,而且操作简便。可以在外部(或集中)进行大量配置的声明性模型有助于实现大型协作的远程组件系统,通常具有中央身份管理服务。它也非常易于在Cloud Foundry等服务平台中使用。在Spring Boot和Spring Security OAuth2的基础上,可以快速创建实现常见模式的系统,如单点登录,令牌中继和令牌交换。

它是基于spring-security之上的产物,对应的它的功能更强大

功能:

  • 安全的认证、授权
  • 从Zuul代理中的前端到后端服务中继SSO令牌
  • 资源服务器之间的中继令牌
  • 使Feign客户端表现得像OAuth2RestTemplate(获取令牌等)的拦截器
  • 在Zuul代理中配置下游身份验证

cloud版本控制流程图:#

注: spring-cloud-starter-oauth 也是由security-dependencies管理

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

spring social 依赖:

官方文档显示需要如下一下:

NameDescription
spring-social-coreSpring Social’s Connect Framework and OAuth client support.
spring-social-configJava and XML configuration support for Spring Social.
spring-social-securitySpring Security integration support.
spring-social-webSpring Social’s ConnectController which uses the Connect Framework to manage connections in a web application environment.1

怎么做?#

一:引入依赖

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Greenwich.SR2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
</dependencies>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.eim</groupId>
<artifactId>my_security</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>core</module>
<module>brower</module>
<module>demo</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Greenwich.SR2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- social start -->
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-config</artifactId>
<version>1.1.6.RELEASE</version>
</dependency>
<!-- 提供社交连接框架和OAuth 客户端支持 -->
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-core</artifactId>
<version>1.1.6.RELEASE</version>
</dependency>
<!-- 社交安全的一些支持 -->
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-security</artifactId>
<version>1.1.6.RELEASE</version>
</dependency>
<!-- 管理web应用程序的连接 -->
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-web</artifactId>
<version>1.1.6.RELEASE</version>
</dependency>
<!-- social end -->
</dependencies>

引入依赖后启动项目,springsecurity会自动配置安全,任何请求都会要求登录,且默认登录为表单登录方式(spirngboot2.0开始)。

第一个类:webSecurity

/**
* <p>@Description:web安全配置</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/20 11:10</p>
* <p>@Version: 1.0.0</p>
**/
@Component
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//表单登录
.formLogin()
//basic登录
// .httpBasic()
//认证请求+所有请求+鉴定---》认证鉴定所有请求
.and().authorizeRequests().anyRequest().authenticated()
;
}
}

spring-security基本原理:#

UsernamePasswordAuthenticationFilter
//表单登录过滤器
BasicAuthenticationFilter
//httpbacis登录过滤器
FilterSecurityInterceptor
//过滤器安全拦截器---》守门员

只有绿色的过滤器可以控制,其他的是无法进行控制的。

自定义用户认证逻辑#

一:处理用户信息获取逻辑#

封装在一个UserDetailsService接口来获取用户信息的:

public interface UserDetailsService {
// ~ Methods
// **=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=**====
/**
* Locates the user based on the username. In the actual implementation, the search
* may possibly be case sensitive, or case insensitive depending on how the
* implementation instance is configured. In this case, the <code>UserDetails</code>
* object that comes back may have a username that is of a different case than what
* was actually requested..
*
* @param username the username identifying the user whose data is required.
*
* @return a fully populated user record (never <code>null</code>)
*
* @throws UsernameNotFoundException if the user could not be found or the user has no
* GrantedAuthority
*/
//获取用户名字、返回一个用户信息(封装在了UserDetails中)--》最终存入session中
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

返回的UserDetails是一个接口,User(security.core.userdetails包下)的类实现了这个接口

public class User implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private static final Log logger = LogFactory.getLog(User.class);
// ~ Instance fields
// **=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=****=**=
private String password;//密码
private final String username;//用户名
private final Set<GrantedAuthority> authorities;//授权的权限集合
private final boolean accountNonExpired;//账号没过期
private final boolean accountNonLocked;//账号没被锁
private final boolean credentialsNonExpired;//认证没有过去
private final boolean enabled;//是可用
/**
*四个Boolean值是默认为true的,有一个返回为false则视为校验失败
*
/
}

eg:

@Component
public class UserServiceImpl implements UserDetailsService {
//加密类,新版本必须制定一个
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
//重写的方法,返回UserDetails的实现类(这么默认使用security自带的user类,可以自定义)
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new User(username,passwordEncoder().encode("123"), AuthorityUtils.createAuthorityList("admin"));
}
}

二:处理用户校验逻辑#

用户的校验逻辑封装在UserDetails这个接口中,可以选择自己实现这个接口,也可以使用自带的User类

@Component
public class UserServiceImpl implements UserDetailsService {
@Resource
private ClientUserDAO clientUserDAO;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
QueryWrapper<ClientUser> wrapper = new QueryWrapper<>();
wrapper.eq("username",username);
ClientUser clientUser = clientUserDAO.selectOne(wrapper);
if(clientUser == null){
throw new UsernameNotFoundException("用户不存在");
}
return new User(username,clientUser.getPassword(),
//设置用户的其他信息
true,true,true,true
,AuthorityUtils.createAuthorityList("admin"));
}
}

三:处理密码加密解密#

PasswordEncoder
//处理密码的加密和解密的接口,现有版本是必须制定一个加密的方式,不然会报错

个性化用户认证流程#

一:自定义登录页面#

1)、新建一个html登录页面

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>自定义登录页面</title>
</head>
<body>
<h2>标准登录页面</h2>
<form action="/form" method="post">
<input type="text" name="username">
<input type="password" name="password">
<button type="submit">登录</button>
</form>
</body>
</html>

2)、配置自定义登录页面

/**
* <p>@Description:web安全配置</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/20 11:10</p>
* <p>@Version: 1.0.0</p>
**/
@Component
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//springsecurity配置了csrf(),csrf会拦截我所有的post请求,这涉及到csrf攻击.。暂时关闭
.csrf().disable()
.formLogin()
//自定义登录页面
.loginPage("/eim-login.html")
//自定义表单登录请求地址(让security知道何时使用UserNamePasswordFilter)
.loginProcessingUrl("/form")
.and()
.authorizeRequests()
//放行自定义登录页
.antMatchers("/eim-login.html").permitAll()
.anyRequest()
.authenticated()
;
}
}

根据不同的请求方式,做不同的响应:

  • 是html请求则跳转到登录页面
  • 是请求则返回401和状态码

①、修改http自定义登录页面的url,.loginPage

/**
* <p>@Description:web安全配置</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/20 11:10</p>
* <p>@Version: 1.0.0</p>
**/
@Component
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//springsecurity配置了csrf(),csrf会拦截我所有的post请求,这涉及到csrf攻击.。暂时关闭
.csrf().disable()
.formLogin()
//自定义登录页面
.loginPage("/authentication/require")
//自定义表单登录请求地址(让security知道何时使用UserNamePasswordFilter)
.loginProcessingUrl("/form")
.and()
.authorizeRequests()
//放行自定义登录页
.antMatchers("/authentication/require").permitAll()
.anyRequest()
.authenticated()
;
}
}

②、自定义返回内容

/**
* <p>@Description:浏览安全跳转控制器</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/22 18:18</p>
* <p>@Version: 1.0.0</p>
**/
@RestController
public class BrowserSecurityController {
//可以拿到上一次请求中的数据
private RequestCache requestCache = new HttpSessionRequestCache();
//重定向的工具类
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
/**
* 当需要身份认证时,跳转到这里
*
* @param request 请求
* @param response 响应
* @return 状态码或者登录页面
*/
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
@RequestMapping("/authentication/require")
public ResponseEntity<String> requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
//获取上一次请求
SavedRequest savedRequest = requestCache.getRequest(request,response);
if(savedRequest != null){
//获取到引发跳转的URL
String targetUrl = savedRequest.getRedirectUrl();
if(StringUtils.endsWithIgnoreCase(targetUrl,".html")){
//是html跳转到登录页
redirectStrategy.sendRedirect(request,response,"可以自己配置");
}
}
return ResponseEntity.ok("请引导用户到登录页面");
}
}

二:自定义登录成功处理#

自定义登录成功的配置抽象类:AbstractAuthenticationTargetUrlRequestHandler(顶层)

继承这个类的子类SavedRequestAwareAuthenticationSuccessHandler,并实现方法

package com.eim.demo.config;
import com.eim.demo.properties.MySecurityProperties;
import com.eim.demo.properties.ResponseTypeEnum;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* <p>DESC: 授权成功处理器</p>
* <p>DATE: 2019-07-25 15:37</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private final ObjectMapper objectMapper;
public MyAuthenticationSuccessHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
//自定义配置类
@Resource
private MySecurityProperties mySecurityProperties;
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
//判断是返回json还是默认跳转到上请求
if(ResponseTypeEnum.JSON.equals(mySecurityProperties.getBrowser().getType())){
String valueAsString = objectMapper.writeValueAsString(authentication);
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
httpServletResponse.getWriter().write(valueAsString);
}
super.onAuthenticationSuccess(httpServletRequest,httpServletResponse,authentication);
}
}

三:自定义登录失败处理#

自定义登录失败抽象类:SimpleUrlAuthenticationFailureHandler(顶层)

package com.eim.demo.config;
import com.eim.demo.properties.MySecurityProperties;
import com.eim.demo.properties.ResponseTypeEnum;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* <p>DESC: 自定义认证失败</p>
* <p>DATE: 2019-07-25 16:27</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Resource
private ObjectMapper objectMapper;
//配置类
@Resource
private MySecurityProperties mySecurityProperties;
//AuthenticationException 返回异常信息
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
//判断返回json,还是默认跳转
if(ResponseTypeEnum.JSON.equals(mySecurityProperties.getBrowser().getType())){
String value = objectMapper.writeValueAsString(e);
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
httpServletResponse.getWriter().write(value);
}
super.onAuthenticationFailure(httpServletRequest,httpServletResponse,e);
}
}

四:生效自定义登录成功、失败#

/**
* <p>DESC: web安全配置</p>
* <p>DATE: 2019-07-24 16:47</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private MySecurityProperties securityProperties;
//注入自定成功处理器
@Resource
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
//注入自定失败处理器
@Resource
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/authentication/require")
//form表单登录请求
.loginProcessingUrl("/authentication/login")
//自定义成功处理器
.successHandler(myAuthenticationSuccessHandler)
//自定义失败处理器
.failureHandler(myAuthenticationFailureHandler)
.and().authorizeRequests()
.antMatchers("/authentication/require",securityProperties.getBrowser().getLoginPage()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}

个性化用户认证流程(自定义过滤器链)#

一、设置打印日志#

设置security打印日志,方便查看

logging:
level:
org:
springframework:
security: DEBUG
@EnableWebSecurity(debug = true)//开启debug
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter

二、配置自定义过滤链#

package com.imooc.uaa.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imooc.uaa.security.filter.RestAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import java.util.HashMap;
@Slf4j
@RequiredArgsConstructor
@EnableWebSecurity(debug = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final ObjectMapper objectMapper;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//禁用csrf攻击
.csrf(csrf -> csrf.disable())
.authorizeRequests(authorizeRequests -> authorizeRequests
.antMatchers("/authorize/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/api/**").hasRole("USER")
.anyRequest().authenticated())
/**
* addFilterAt 在什么位置设置过滤器
* 在指定的筛选器类的位置添加筛选器。
* 例如,如果你想要CustomFilter被注册在UsernamePasswordAuthenticationFilter的相同位置,你可以调用:
* UsernamePasswordAuthenticationFilter.class addFilterAt(新CustomFilter ())
*/
.addFilterAt(restAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
//自定义认证过滤器
private RestAuthenticationFilter restAuthenticationFilter() throws Exception {
RestAuthenticationFilter filter = new RestAuthenticationFilter(objectMapper);
//设置过滤器链
filter.setAuthenticationSuccessHandler(jsonLoginSuccessHandler());
filter.setAuthenticationFailureHandler(jsonLoginFailureHandler());
//认证管理器 系统默认
filter.setAuthenticationManager(authenticationManager());
//该过滤器链只处理此路径下的请求
filter.setFilterProcessesUrl("/authorize/login");
return filter;
}
//登出成功处理器
private LogoutSuccessHandler jsonLogoutSuccessHandler() {
return (req, res, auth) -> {
if (auth != null && auth.getDetails() != null) {
req.getSession().invalidate();
}
res.setStatus(HttpStatus.OK.value());
res.getWriter().println();
log.debug("成功退出登录");
};
}
//登录成功处理器
private AuthenticationSuccessHandler jsonLoginSuccessHandler() {
return (req, res, auth) -> {
ObjectMapper objectMapper = new ObjectMapper();
res.setStatus(HttpStatus.OK.value());
res.getWriter().println(objectMapper.writeValueAsString(auth));
log.debug("认证成功");
};
}
//登录失败处理器
private AuthenticationFailureHandler jsonLoginFailureHandler() {
return (req, res, exp) -> {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
res.setCharacterEncoding("UTF-8");
HashMap<String, Object> errData = new HashMap<>();
errData.put("title", "认证失败");
errData.put("details", exp.getMessage());
res.getWriter().println(objectMapper.writeValueAsString(errData));
};
}
//配置放行路径
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/public/**")
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
//固定配置用户密码
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder().encode("12345678"))
.roles("USER", "ADMIN");
}
//设置密码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

spring-security源码流程详解#

一:认证处理流程说明#

AuthenticationManager用于管理 AuthenticationProvider

AuthenticationManager拿到所有的AuthenticationProvider,一个一个取出来,询问当前provider是否支持当前认证方式(传入的Authentication),

支持则进入认证,列如表单登录,最终会调用UserDetailsService接口实现类中loadUserByUserName方法(和我们自定义的认证逻辑接上了),

如果所有都成功,会创建一个SuccessAuthentication,此时会反向传递到起始位置,如果认证成功,调用我们自己的成功处理器,如果认证失败,调用我们自己失败处理器。

二:认证结果如何在多个请求之间共享#

secutityContext和SeCurityHolder,

将我们认证成功的Authentication放在SecurityContext和SecurityContextHolder

SecurityContextHolder中有静态方法,可以将同线程中存入认证信息,和取出认证信息

三:获取用户信息#

securityContextPersistenceFilter

作用:

①:请求进来的时候,检查session中是否有SecurityContext,如果有,就把securityContext拿出来,放入线程中

②:最后响应前,检查线程中是否是有securityContext,如果有,就拿出来,放入session中

因为,是在一个线程中完成的,所以可以任意位置通过SecurityContext中获取用户信息

获取用户信息eg(三种):

@GetMapping("me")
public Object me(){
return SecurityContextHolder.getContext().getAuthentication();
}
@GetMapping("mi")
public Object mi(Authentication authentication){
return authentication;
}
@GetMapping("mp")
public Object mp(@AuthenticationPrincipal UserDetails user){
return user;
}

实现图片验证码#

一:开发生成图形验证码接口#

1):生成图形验证码#

​ 根据随机数生成验图片

​ 将随机数存入session中

​ 再将生成的图片写到接口的响应中

a、新建图片对象

package com.eim.demo.validate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.awt.image.BufferedImage;
import java.time.LocalDateTime;
/**
* <p>@Description:图片验证码对象</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/28 22:50</p>
* <p>@Version: 1.0.0</p>
**/
@AllArgsConstructor
@NoArgsConstructor
@Data
public class ImageCode {
/**
* 图片对象
*/
private BufferedImage image;
/**
* 验证码
*/
private String code;
/**
* 过期时间
*/
private LocalDateTime expireTime;
public ImageCode(BufferedImage image, String code, int expireTime) {
this.image = image;
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
}
}

b、新建验证码controller

package com.eim.demo.controller;
import com.eim.demo.validate.ImageCode;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Random;
/**
* <p>@Description:图片验证码控制器</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/28 23:02</p>
* <p>@Version: 1.0.0</p>
**/
@RestController
public class CodeController {
private static final String SESSION_KEY= "IMAGE_CODE_SESSION_KEY";
//操作session
private SessionStrategy strategy = new HttpSessionSessionStrategy();
@GetMapping("/code/image")
public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
//获取图片验证码对象
ImageCode imageCode = createImageCode();
//往session中存入code
strategy.setAttribute(new ServletWebRequest(request),SESSION_KEY,imageCode.getCode());
//以流的方式写回前端
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}
<<<<<<< HEAD
private ImageCode createImageCode() {
**=**==
/**
* 创建图片验证码
*
* @return 图片验证码对象
*/
public ImageCode createCode(HttpServletRequest request) {
>>>>>>> a260bfea592f0e0e5b2a34611c3eabe817ab9730
//从请求中获取宽度和高度,若没有则加载默认配置
// int width = ServletRequestUtils.getIntParameter(request, "width", mySecurityProperties.getBrowser().getValidateCode().getImage().getWidth());
// int height = ServletRequestUtils.getIntParameter(request, "height", mySecurityProperties.getBrowser().getValidateCode().getImage().getHeight());
int width = 200;
<<<<<<< HEAD
int height = 80;
**=**==
int height = 30;
>>>>>>> a260bfea592f0e0e5b2a34611c3eabe817ab9730
Random random = new Random();
// 默认背景为黑色
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// 获取画笔
Graphics graphics = image.getGraphics();
// 默认填充为白色
graphics.fillRect(0, 0, width, height);
// 验证码是由 数字 字母 干扰线 干扰点组成
// 文字素材
String words = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789";
char[] cs = words.toCharArray();
// 一般验证码为4位数
// 字母+数字
StringBuilder randomStr = new StringBuilder();
for (int i = 0; i < 4; i++) {
//设置随机的颜色
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.setFont(new Font("微软雅黑", Font.BOLD, 30));
char c = cs[random.nextInt(cs.length)];
graphics.drawString(c + "", i * 20, 30);
randomStr.append(c);
}
//画干扰线
int max = random.nextInt(10);
for (int i = 0; i < max; i++) {
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.drawLine(random.nextInt(100), random.nextInt(50), random.nextInt(100), random.nextInt(50));
}
// 画干扰点
int max2 = random.nextInt(10);
for (int i = 0; i < max2; i++) {
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.drawOval(random.nextInt(80), random.nextInt(40), random.nextInt(5), random.nextInt(10));
}
<<<<<<< HEAD
return new ImageCode(randomStr.toString(),image,100);
**=**==
return new ImageCode(image, randomStr.toString(), 10);
>>>>>>> a260bfea592f0e0e5b2a34611c3eabe817ab9730
}
}

二:在认证流程中加入图形验证码校验#

2)、新建自定义过滤器#

写一个validateCodeFilter去继承OncePerRequestFilter(保证我们的过滤器只会被调用一次)

package com.eim.demo.validate;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* <p>@Description:自定义验证码过滤器</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/30 21:45</p>
* <p>@Version: 1.0.0</p>
**/
@Component
public class ValidateCodeFilter extends OncePerRequestFilter {
private static final String SESSION_KEY = "IMAGE_CODE_SESSION_KEY";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
/**
* 失败处理器
*/
@Resource
private AuthenticationFailureHandler failureHandler;
/**
* 业务逻辑
*
* @param request request
* @param response response
* @param filterChain 過濾
* @throws ServletException 異常
* @throws IOException 異常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//拦截符合标准的请求
if (StringUtils.equals("/authentication/login", request.getRequestURI())
&& StringUtils.endsWithIgnoreCase(request.getMethod(), "post")) {
try {
//校验逻辑
validate(new ServletWebRequest(request));
//自定义异常
} catch (ValidateException e) {
//失败处理逻辑
failureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
//放行
filterChain.doFilter(request, response);
}
/**
* 校验逻辑
*
* @param request ServletWebRequest
* @throws ServletRequestBindingException 异常
*/
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
//从session中获取imageCode对象
ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, SESSION_KEY);
//从请求中获取验证码数字
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");
if (StringUtils.isNotBlank(codeInRequest)) {
throw new ValidateException("验证码不能为空");
}
if (StringUtils.isNotBlank(codeInSession.getCode())) {
throw new ValidateException("验证码不存在");
}
if (codeInSession.isExpired()) {
sessionStrategy.removeAttribute(request, SESSION_KEY);
throw new ValidateException("验证码已过期");
}
if (!StringUtils.equals(codeInRequest, codeInSession.getCode())) {
throw new ValidateException("验证码不匹配");
}
sessionStrategy.removeAttribute(request, SESSION_KEY);
}
}

3)、自定义异常继承AuthenticationException#

package com.eim.demo.validate;
import org.springframework.security.core.AuthenticationException;
/**
* <p>@Description:验证码异常信息</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/30 21:52</p>
* <p>@Version: 1.0.0</p>
**/
public class ValidateException extends AuthenticationException {
public ValidateException(String msg, Throwable t) {
super(msg, t);
}
public ValidateException(String msg) {
super(msg);
}
}

4)、web安全配置,使自定义filter生效#

  • 核心代码
//自定义过滤器
ValidateCodeFilter filter = new ValidateCodeFilter();
//设置失败处理器
filter.setFailureHandler(myAuthenticationFailureHandler);
//在用户名密码过滤器前面加上自定义过滤器
http.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
  • 完整实例:
package com.eim.demo.config;
import com.eim.demo.properties.MySecurityProperties;
import com.eim.demo.validate.ValidateCodeFilter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* <p>DESC: web安全配置</p>
* <p>DATE: 2019-07-24 16:47</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private MySecurityProperties securityProperties;
@Resource
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Resource
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
//自定义过滤器
ValidateCodeFilter filter = new ValidateCodeFilter();
//设置失败处理器
filter.setFailureHandler(myAuthenticationFailureHandler);
http
//在用户名密码过滤器前面加上自定义过滤器
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
.loginPage("/authentication/require")
//form表单登录请求
.loginProcessingUrl("/authentication/login")
//自定义成功处理器
.successHandler(myAuthenticationSuccessHandler)
//自定义失败处理器
.failureHandler(myAuthenticationFailureHandler)
.and()
.authorizeRequests()
.antMatchers("/code/image","/authentication/require",securityProperties.getBrowser().getLoginPage()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}

三:重构代码#

一:验证码基本参数可配#

1)、默认配置#

新建配置对象类验证码的基类:

/**
* <p>DESC: 验证码配置</p>
* <p>DATE: 2019-08-01 13:15</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class ValidateCodeProperties {
private ImageCodeProperties image = new ImageCodeProperties();
}

新建图片验证码配置类,并赋默认值:

/**
* <p>DESC: 图片验证码配置</p>
* <p>DATE: 2019-08-01 13:12</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class ImageCodeProperties {
/**
* 宽度
*/
private Integer width = 100;
/**
* 高度
*/
private Integer height = 50;
/**
* 长度
*/
private Integer length = 4;
/**
* 过期时间
*/
private int expireTime = 100;
/**
* 拦截的url
*/
private List<String> url;
}

在浏览器配置类中添加新增验证码基类

/**
* <p>DESC: 浏览器配置文件</p>
* <p>DATE: 2019-07-25 12:38</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class BrowserSecurityProperties {
private String loginPage = "/default_sigIn.html";
private ResponseTypeEnum type = JSON;
private ValidateCodeProperties validateCode = new ValidateCodeProperties();
}
2)、应用参数配置,请求参数可配#

在图片验证码生成中添加配置

主要代码:

int width = ServletRequestUtils.getIntParameter(request, “width”, mySecurityProperties.getBrowser().getValidateCode().getImage().getWidth());

从请求中拿width没有的话从配置文件类中获取

这里一共配置了width,height,length,expireTime

/**
* <p>DESC: 图片验证码实现类</p>
* <p>DATE: 2019-08-02 16:41</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class ImageCodeGeneratorImpl implements ImageCodeGenerator{
@Resource
private MySecurityProperties mySecurityProperties;
/**
* 创建图片验证码
*
* @return 图片验证码对象
*/
@Override
public ImageCode createImageCode(HttpServletRequest request) {
//从请求中获取宽度和高度,若没有则加载默认配置
int width = ServletRequestUtils.getIntParameter(request, "width", mySecurityProperties.getBrowser().getValidateCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", mySecurityProperties.getBrowser().getValidateCode().getImage().getHeight());
Random random = new Random();
// 默认背景为黑色
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// 获取画笔
Graphics graphics = image.getGraphics();
// 默认填充为白色
graphics.fillRect(0, 0, width, height);
// 验证码是由 数字 字母 干扰线 干扰点组成
// 文字素材
String words = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789";
char[] cs = words.toCharArray();
// 一般验证码为4位数
// 字母+数字
StringBuilder randomStr = new StringBuilder();
for (int i = 0; i < mySecurityProperties.getBrowser().getValidateCode().getImage().getLength(); i++) {
//设置随机的颜色
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.setFont(new Font("微软雅黑", Font.BOLD, 30));
char c = cs[random.nextInt(cs.length)];
graphics.drawString(c + "", i * 20, 30);
randomStr.append(c);
}
//画干扰线
int max = random.nextInt(10);
for (int i = 0; i < max; i++) {
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.drawLine(random.nextInt(100), random.nextInt(50), random.nextInt(100), random.nextInt(50));
}
// 画干扰点
int max2 = random.nextInt(10);
for (int i = 0; i < max2; i++) {
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.drawOval(random.nextInt(80), random.nextInt(40), random.nextInt(5), random.nextInt(10));
}
return new ImageCode(image, randomStr.toString(), mySecurityProperties.getBrowser().getValidateCode().getImage().getExpireTime());
}
}

二:验证码拦截的接口可配#

核心代码

List<String> urlList = mySecurityProperties.getBrowser().getValidateCode().getImage().getUrl();
//判断是否需要校验
AtomicBoolean isFilter = new AtomicBoolean(false);
urlList.forEach(url -> {
if(pathPattern.match(url,request.getRequestURI())){
isFilter.set(true);
}
});
/**
* <p>@Description:自定义验证码过滤器</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/30 21:45</p>
* <p>@Version: 1.0.0</p>
**/
@EqualsAndHashCode(callSuper = true)
@Data
@Component
public class ValidateCodeFilter extends OncePerRequestFilter {
private static final String SESSION_KEY= "IMAGE_CODE_SESSION_KEY";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
/**
* 失败处理器
*/
private AuthenticationFailureHandler failureHandler;
@Resource
private MySecurityProperties mySecurityProperties;
private AntPathMatcher pathPattern = new AntPathMatcher();
/**
* 业务逻辑
* @param request request
* @param response response
* @param filterChain filterChain
* @throws ServletException 异常
* @throws IOException 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
List<String> urlList = mySecurityProperties.getBrowser().getValidateCode().getImage().getUrl();
//判断是否需要校验
AtomicBoolean isFilter = new AtomicBoolean(false);
urlList.forEach(url -> {
if(pathPattern.match(url,request.getRequestURI())){
isFilter.set(true);
}
});
//拦截符合标准的请求
if(isFilter.get()){
try{
//校验逻辑
validate(new ServletWebRequest(request));
}catch (ValidateException e){
//失败处理逻辑
failureHandler.onAuthenticationFailure(request,response,e);
return;
}
}
//放行
filterChain.doFilter(request,response);
}

三:验证码的生成逻辑可配#

1)、新建一个生成图片验证码接口#
/**
* <p>DESC: 图片验证码生成器</p>
* <p>DATE: 2019-08-02 16:38</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface ImageCodeGenerator {
/**
* 创建图片验证码
* @param request request域
* @return 图片验证码对象
*/
ImageCode createImageCode(HttpServletRequest request);
}
2)、新建一个图片验证码接口实现类#
  • 注意:没有添加到spring容器中的任何注解
package com.eim.demo.validate;
import com.eim.demo.properties.MySecurityProperties;
import org.springframework.web.bind.ServletRequestUtils;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;
/**
* <p>DESC: 图片验证码实现类</p>
* <p>DATE: 2019-08-02 16:41</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class ImageCodeGeneratorImpl implements ImageCodeGenerator{
@Resource
private MySecurityProperties mySecurityProperties;
/**
* 创建图片验证码
*
* @return 图片验证码对象
*/
@Override
public ImageCode createImageCode(HttpServletRequest request) {
//从请求中获取宽度和高度,若没有则加载默认配置
int width = ServletRequestUtils.getIntParameter(request, "width", mySecurityProperties.getBrowser().getValidateCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", mySecurityProperties.getBrowser().getValidateCode().getImage().getHeight());
Random random = new Random();
// 默认背景为黑色
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// 获取画笔
Graphics graphics = image.getGraphics();
// 默认填充为白色
graphics.fillRect(0, 0, width, height);
// 验证码是由 数字 字母 干扰线 干扰点组成
// 文字素材
String words = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789";
char[] cs = words.toCharArray();
// 一般验证码为4位数
// 字母+数字
StringBuilder randomStr = new StringBuilder();
for (int i = 0; i < mySecurityProperties.getBrowser().getValidateCode().getImage().getLength(); i++) {
//设置随机的颜色
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.setFont(new Font("微软雅黑", Font.BOLD, 30));
char c = cs[random.nextInt(cs.length)];
graphics.drawString(c + "", i * 20, 30);
randomStr.append(c);
}
//画干扰线
int max = random.nextInt(10);
for (int i = 0; i < max; i++) {
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.drawLine(random.nextInt(100), random.nextInt(50), random.nextInt(100), random.nextInt(50));
}
// 画干扰点
int max2 = random.nextInt(10);
for (int i = 0; i < max2; i++) {
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.drawOval(random.nextInt(80), random.nextInt(40), random.nextInt(5), random.nextInt(10));
}
return new ImageCode(image, randomStr.toString(), mySecurityProperties.getBrowser().getValidateCode().getImage().getExpireTime());
}
}
3)、图片验证码注入spring容器配置#
package com.eim.demo.validate;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* <p>DESC: 图片验证码注入配置‘</p>
* <p>DATE: 2019-08-02 16:44</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
public class ImageCodeConfig {
/**
* 注入默认的图片验证码生成器
* @return 图片验证码
*/
@Bean
//在spring中缺失一个bean叫imageCodeGenerator的时候自动注入spring容器
@ConditionalOnMissingBean(name = "imageCodeGenerator")
public ImageCodeGenerator imageCodeGenerator(){
return new ImageCodeGeneratorImpl();
}
}

测试覆盖

一个类去实现接口,加上注解@Component(value = “imageCodeGenerator”)

/**
* <p>DESC: 测试图片验证码覆盖</p>
* <p>DATE: 2019-08-02 16:49</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component(value = "imageCodeGenerator")
public class ConvertImageConvert implements ImageCodeGenerator {
/**
* 创建图片验证码
*
* @param request request域
* @return 图片验证码对象
*/
@Override
public ImageCode createImageCode(HttpServletRequest request) {
log.info("覆盖了----------哦");
return null;
}
}

实现记住我登录#

一、配置webSecurityConfig#

  • 核心代码
/**
* 数据源(sql包下的)
*/
@Resource
private DataSource dataSource;
/**
* 配置一个持久化token仓库
* @return
*/
@Bean
public PersistentTokenRepository persistentTokenRepository(){
//新建一个PersistentTokenRepository的实现类
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
//需要设置数据源
tokenRepository.setDataSource(dataSource);
//启动的时候去创建表(默认存储token的表)
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
.rememberMe()
//配置持久化token
.tokenRepository(persistentTokenRepository())
//配置token验证秒数
.tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())
//配置用于登录的UserDetailsService
.userDetailsService(userDetailsService)
  • 完整配置
package com.eim.demo.config;
import com.eim.demo.handler.MyAuthenticationFailureHandler;
import com.eim.demo.handler.MyAuthenticationSuccessHandler;
import com.eim.demo.properties.MySecurityProperties;
import com.eim.demo.validate.SmsCodeFilter;
import com.eim.demo.validate.ValidateCodeFilter;
import com.eim.demo.validate.mobile.SmsCodeAuthenticationSecurityConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.sql.DataSource;
/**
* <p>DESC: web安全配置</p>
* <p>DATE: 2019-07-24 16:47</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private MySecurityProperties securityProperties;
@Resource
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Resource
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Resource
private ValidateCodeFilter validateCodeFilter;
@Resource
private SmsCodeFilter smsCodeFilter;
@Resource
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
/**
* 配置用户登录逻辑
*/
@Resource
private UserDetailsService userDetailsService;
/**
* 数据源(sql包下的)
*/
@Resource
private DataSource dataSource;
/**
* 配置一个持久化token仓库
* @return
*/
@Bean
public PersistentTokenRepository persistentTokenRepository(){
//新建一个PersistentTokenRepository的实现类
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
//需要设置数据源
tokenRepository.setDataSource(dataSource);
//启动的时候去创建表(默认存储token的表)
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(smsCodeFilter,UsernamePasswordAuthenticationFilter.class)
//在用户名密码过滤器前面加上自定义过滤器
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
.loginPage("/authentication/require")
//form表单登录请求
.loginProcessingUrl("/authentication/login")
//自定义成功处理器
.successHandler(myAuthenticationSuccessHandler)
//自定义失败处理器
.failureHandler(myAuthenticationFailureHandler)
.and()
.rememberMe()
//配置持久化token
.tokenRepository(persistentTokenRepository())
//配置token验证秒数
.tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())
//配置用于登录的UserDetailsService
.userDetailsService(userDetailsService)
.and()
.authorizeRequests()
.antMatchers(
"/code/*",
"/authentication/require",
securityProperties.getBrowser().getLoginPage()
).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
//注入自定义短信验证码的安全配置
.apply(smsCodeAuthenticationSecurityConfig)
;
}
}

二、配置html#

name是默认的remember-me

<td><input name="remember-me" type="checkbox" value="true"/>记住我</td>

完整代码

<form action="/authentication/login" method="post">
<table>
<tr>
<td>用户名:</td>
<td><input type="text" name="username"></td>
</tr>
<tr>
<td>密码:</td>
<td><input type="text" name="password"></td>
</tr>
<tr>
<td><input type="text" name="imageCode"></td>
<td><!-- 图片不存在,已移除: image --></td>
</tr>
<tr>
<td><input name="remember-me" type="checkbox" value="true"/>记住我</td>
</tr>
<tr>
<td><button type="submit">登录</button></td>
</tr>
</table>
</form>

实现短信验证码发送接口#

一、看懂处理器结构#

  • ValidateCodeController是用来统一创建返回的请求地址

  • ValidateCodeProcessor是验证码处理器接口。

    接口中包含一个方法,create方法。由实现类去实现

  • AbstractValidateCodeProcessor,是接口的抽象实现类

    该类实现了create方法。1:生成,2:保存到session,3:发送

    2:保存方法是公用的,自己实现。3是一个抽象方法,由子类去实现。1:是生成方法,有ValidateCodeGenerator接口的实现类去生成

  • ValidateCodeGenerator是验证码生成器接口,其中有个createCode方法。

    这个接口的实现类,去具体实现不同的创建验证码方法(1:生成方法)

    会根据请求中参数选择生成器(spring开发技巧:依赖查找)

  • ImageCodeProcessor和SmsCodeProcessor是抽象类AbstractValidateCodeProcessor实现。

    主要是实现3发送方法。

验证码架构#

准备工作#

验证码架构#

二、创建ValidateCodeController#

package com.eim.demo.validate;
import com.eim.demo.validate.code.ImageCode;
import com.eim.demo.validate.code.ValidateCode;
import com.eim.demo.validate.generator.ValidateCodeGenerator;
import com.eim.demo.validate.processor.ValidateCodeProcessor;
import com.eim.demo.validate.sms.SmsCodeSender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import javax.annotation.Resource;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
/**
* <p>@Description:图片验证码控制器</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/28 23:02</p>
* <p>@Version: 1.0.0</p>
**/
@Slf4j
@RestController
public class CodeController {
// 依赖查询技巧
@Resource
private Map<String, ValidateCodeProcessor> validateCodeProcessorMap;
/**
* 穿件验证码,根据验证码类型不同,调用不同的接口实现
*
* @param request 请求
* @param response 返回
* @param type 类型
* @throws Exception 异常
*/
@GetMapping("/code/{type}")
public void createCodeByType(HttpServletRequest request, HttpServletResponse response, @PathVariable String type) throws Exception {
validateCodeProcessorMap.get(type + "CodeProcessor").create(new ServletWebRequest(request, response));
}
}

三:创建ValidateCodeProcessor#

package com.eim.demo.validate.processor;
import org.springframework.web.context.request.ServletWebRequest;
/**
* <p>DESC: 验证码处理器</p>
* <p>DATE: 2019-08-07 16:30</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface ValidateCodeProcessor {
/**
* 验证码放入session的前缀
*/
String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";
/**
* 创建验证码
* @param request spring工具类,request和response封装类
* @throws Exception 异常
*/
void create(ServletWebRequest request) throws Exception;
}

四:创建AbstractValidateCodeProcessor#

注:

  • 泛型C(指的是验证码的类型),继承的子类去指定泛型是什么类型。
  • 创建验证码的类是通过类型匹配、依赖查找的方式调用接口下不同的实现类。
  • 发送方法是抽象的,需要子类去实现。
package com.eim.demo.validate.processor;
import com.eim.demo.validate.generator.ValidateCodeGenerator;
import org.apache.commons.lang3.StringUtils;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.context.request.ServletWebRequest;
import javax.annotation.Resource;
import java.util.Map;
/**
* <p>DESC: 验证码处理器抽象实现类</p>
* <p>DATE: 2019-08-07 16:35</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public abstract class AbstractValidateCodeProcessor<C> implements ValidateCodeProcessor {
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
/**
* 系统启动的时候回收集所有的ValidateCodeGenerator接口的实现。
*/
@Resource
private Map<String, ValidateCodeGenerator> validateCodeGeneratorMap;
/**
* 创建验证码(实现接口方法)
*
* @param request request和response封装类
* @throws Exception 异常
*/
@Override
public void create(ServletWebRequest request) throws Exception {
//创建验证码(创建验证码的子类实现)
C validateCode = generator(request);
//存入session(公共代码由抽象类自行实现)
save(request,validateCode);
//发送验证码(子类实现)
send(request,validateCode);
}
/**
* 保存验证码
* @param request 请求
* @param validateCode 验证码对象
*/
private void save(ServletWebRequest request, C validateCode){
sessionStrategy.setAttribute(request,SESSION_KEY_PREFIX+getProcessorType(request).toUpperCase(),validateCode);
};
/**
* 生成验证码
* @param request 请求
* @return 类型
*/
@SuppressWarnings("unchecked")
private C generator(ServletWebRequest request){
String type=getProcessorType(request);
ValidateCodeGenerator codeGenerator = validateCodeGeneratorMap.get(type + "CodeGenerator");
return (C)codeGenerator.createCode(request.getRequest());
}
/**
* 根据请求的url获取验证码的类型
* @param request 请求
* @return 类型
*/
public static String getProcessorType(ServletWebRequest request){
return StringUtils.substringAfter(request.getRequest().getRequestURI(),"/code/");
};
/**
* 发送验证码抽象方法
* @param request 请求
* @param validateCode 验证码对象
*/
protected abstract void send(ServletWebRequest request, C validateCode) throws Exception;
}

五(1)、创建ImageCodeProcessor和SmsCodeProcessor#

/**
* <p>DESC: 图片验证码处理器</p>
* <p>DATE: 2019-08-08 10:39</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component("imageCodeProcessor")
public class ImageCodeProcessor extends AbstractValidateCodeProcessor<ImageCode> {
/**
* 发送验证码抽象方法
*
* @param request 请求
* @param imageCode 验证码对象
*/
@Override
protected void send(ServletWebRequest request, ImageCode imageCode) throws Exception {
ImageIO.write(imageCode.getImage(),"JPEG",request.getResponse().getOutputStream());
}
}
/**
* <p>DESC: 短信验证码处理器</p>
* <p>DATE: 2019-08-08 10:40</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component("smsCodeProcessor")
public class SmsCodeProcessor extends AbstractValidateCodeProcessor<ValidateCode> {
@Resource
private SmsCodeSender smsCodeSender;
/**
* 发送验证码抽象方法
*
* @param request 请求
* @param validateCode 验证码对象
*/
@Override
protected void send(ServletWebRequest request, ValidateCode validateCode) throws Exception {
String mobile = ServletRequestUtils.getRequiredStringParameter(request.getRequest(), "mobile");
smsCodeSender.send(mobile,validateCode.getCode());
}
}

五(2)、创建存放验证码类#

/**
* <p>DESC: 验证码基类</p>
* <p>DATE: 2019-10-09 17:42</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class ValidateCode {
private String code;
private LocalDateTime expireTime;
public ValidateCode(String code,LocalDateTime expireTime) {
this.code = code;
this.expireTime = expireTime;
}
public ValidateCode(String code, int expireTime) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
}
}
/**
* <p>DESC: 图片验证码类</p>
* <p>DATE: 2019-10-07 15:05</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class ImageCode extends ValidateCode{
private BufferedImage image;
public ImageCode(String code,BufferedImage bufferedImage, LocalDateTime expireTime) {
super(code, expireTime);
this.image = bufferedImage;
}
public ImageCode(String code,BufferedImage bufferedImage, int expireTime) {
super(code, expireTime);
this.image = bufferedImage;
}
}

六、验证码生成器#

/**
* <p>DESC: 图片验证码生成器</p>
* <p>DATE: 2019-08-02 16:38</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface ValidateCodeGenerator {
/**
* 创建验证码
* @param request request域
* @return 验证码对象
*/
ValidateCode createCode(HttpServletRequest request);
}

7、图验证码生成器,短信验证码生成器#

/**
* <p>DESC: 短信验证码生成器实现类</p>
* <p>DATE: 2019-08-02 16:41</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component(value = "smsCodeGenerator")
public class SmsCodeGeneratorImpl implements ValidateCodeGenerator {
@Resource
private MySecurityProperties mySecurityProperties;
/**
* 创建短信验证码
*
* @return 验证码对象
*/
@Override
public ValidateCode createCode(HttpServletRequest request) {
String random = RandomStringUtils.randomNumeric(mySecurityProperties.getBrowser().getValidateCode().getSms().getLength());
return new ValidateCode(random,mySecurityProperties.getBrowser().getValidateCode().getSms().getExpireTime());
}
}
/**
* <p>DESC: 图片验证码实现类</p>
* <p>DATE: 2019-08-02 16:41</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class ValidateCodeGeneratorImpl implements ValidateCodeGenerator {
@Resource
private MySecurityProperties mySecurityProperties;
/**
* 创建图片验证码
*
* @return 图片验证码对象
*/
@Override
public ImageCode createCode(HttpServletRequest request) {
//从请求中获取宽度和高度,若没有则加载默认配置
int width = ServletRequestUtils.getIntParameter(request, "width", mySecurityProperties.getBrowser().getValidateCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", mySecurityProperties.getBrowser().getValidateCode().getImage().getHeight());
Random random = new Random();
// 默认背景为黑色
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// 获取画笔
Graphics graphics = image.getGraphics();
// 默认填充为白色
graphics.fillRect(0, 0, width, height);
// 验证码是由 数字 字母 干扰线 干扰点组成
// 文字素材
String words = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789";
char[] cs = words.toCharArray();
// 一般验证码为4位数
// 字母+数字
StringBuilder randomStr = new StringBuilder();
for (int i = 0; i < mySecurityProperties.getBrowser().getValidateCode().getImage().getLength(); i++) {
//设置随机的颜色
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.setFont(new Font("微软雅黑", Font.BOLD, 30));
char c = cs[random.nextInt(cs.length)];
graphics.drawString(c + "", i * 20, 30);
randomStr.append(c);
}
//画干扰线
int max = random.nextInt(10);
for (int i = 0; i < max; i++) {
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.drawLine(random.nextInt(100), random.nextInt(50), random.nextInt(100), random.nextInt(50));
}
// 画干扰点
int max2 = random.nextInt(10);
for (int i = 0; i < max2; i++) {
graphics.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
graphics.drawOval(random.nextInt(80), random.nextInt(40), random.nextInt(5), random.nextInt(10));
}
return new ImageCode(image, randomStr.toString(), mySecurityProperties.getBrowser().getValidateCode().getImage().getExpireTime());
}
}

八、添加过滤路径到webSecurityConfig#

核心: /code/*

.and()
.authorizeRequests()
.antMatchers(
"/code/*",
"/authentication/require",
securityProperties.getBrowser().getLoginPage()
).permitAll()
.anyRequest().authenticated()

实现短信验证码#

一、流程图#

  • 仿照UsernamePasswrodAuthenticationFilter写自己的Sms过滤器

二、创建SmsAuthenticationFilterToken#

/**
* <p>DESC: 短信验证码--认证Token信息--对象类</p>
* <p>DATE: 2019-08-08 15:19</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 510L;
/**
* 存放认证信息的对象
*/
private final Object principal;
//实际上放的是密码
/// private Object credentials;
/**
* 没登录的时候存入手机号
* 将我们的手机号放入Token
*
* @param mobile 手机号
*/
public SmsCodeAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
this.setAuthenticated(false);
}
/**
* 登录成功后,放入用户信息
* @param principal 用户信息
* @param authorities 用户授权信息
*/
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
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");
} else {
super.setAuthenticated(false);
}
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}

三、创建SmsAuthenticationFilter#

/**
* <p>DESC: 短信验证码认证过滤器</p>
* <p>DATE: 2019-08-08 15:56</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String EIM_SMS_MOBILE_KEY = "mobile";
/**
* 在请求中,携带参数的名字叫什么
*/
private String mobileParameter = EIM_SMS_MOBILE_KEY;
/**
* 当前过滤器只处理 post请求
*/
private boolean postOnly = true;
public SmsCodeAuthenticationFilter() {
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
/**
* 真正处理请求
* @param request 请求
* @param response 响应
* @return 认证信息
* @throws AuthenticationException 认证信息异常
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String mobile = this.obtainMobile(request);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
this.setDetails(request, authRequest);
//实际上是在用authenticationToken去调用authenticationManager
return this.getAuthenticationManager().authenticate(authRequest);
}
}
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(this.mobileParameter);
}
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setUsernameParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Username parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getMobileParameter() {
return this.mobileParameter;
}
}

四、创建SmsCodeAuthenticationProvider#

/**
* <p>DESC: 短信验证码服务提供类</p>
* <p>DATE: 2019-08-08 16:09</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
/**
* 认证用户逻辑
* @param authentication 用户信息
* @return 授权后的用户认证信息
* @throws AuthenticationException 认证异常
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken smsCodeAuthenticationToken = (SmsCodeAuthenticationToken) authentication;
Object principal = smsCodeAuthenticationToken.getPrincipal();
String mobile = (String) principal;
//根据手机号读取用户信息
UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);
if(userDetails == null){
throw new InternalAuthenticationServiceException("无法获取用户信息");
}
//如果读到了用户信息,重新封装一个已认证Token
SmsCodeAuthenticationToken tokenResult = new SmsCodeAuthenticationToken(userDetails,userDetails.getAuthorities());
//将未认证的details添加到已认证的token中
tokenResult.setDetails(smsCodeAuthenticationToken.getDetails());
return tokenResult;
}
/**
* 判断当前Provider是否支持SmsToken
* @param aClass 类型
* @return true支持,false不支持
*/
@Override
public boolean supports(Class<?> aClass) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass);
}
}

五、配置验证码过滤器#

/**
* <p>@Description:短信验证码过滤器</p>
* <p>@Author: zhengyong</p>
* <p>@Date: 2019/7/30 21:45</p>
* <p>@Version: 1.0.0</p>
**/
@EqualsAndHashCode(callSuper = true)
@Data
@Component
public class SmsCodeFilter extends OncePerRequestFilter {
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
/**
* 失败处理器
*/
@Resource
private AuthenticationFailureHandler failureHandler;
@Resource
private MySecurityProperties mySecurityProperties;
private AntPathMatcher pathPattern = new AntPathMatcher();
private static final String IMAGE_SESSION_KEY = SESSION_KEY_PREFIX + "SMS";
/**
* 业务逻辑
* @param request request
* @param response response
* @param filterChain filterChain
* @throws ServletException 异常
* @throws IOException 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
/// List<String> urlList = mySecurityProperties.getBrowser().getValidateCode().getImage().getUrl();
List<String> urlList = Collections.singletonList("/authentication/mobile");
//判断是否需要校验
AtomicBoolean isFilter = new AtomicBoolean(false);
urlList.forEach(url -> {
if(pathPattern.match(url,request.getRequestURI())){
isFilter.set(true);
}
});
//拦截符合标准的请求
if(isFilter.get()){
try{
//校验逻辑
validate(new ServletWebRequest(request));
}catch (ValidateException e){
//失败处理逻辑
failureHandler.onAuthenticationFailure(request,response,e);
return;
}
}
//放行
filterChain.doFilter(request,response);
}
/**
* 校验逻辑
* @param request ServletWebRequest
* @throws ServletRequestBindingException 异常
*/
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
//从session中获取imageCode对象
ValidateCode codeInSession = (ValidateCode) sessionStrategy.getAttribute(request,IMAGE_SESSION_KEY);
//从请求中获取验证码数字
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode");
if(StringUtils.isBlank(codeInRequest)){
throw new ValidateException("验证码不能为空");
}
if(StringUtils.isBlank(codeInSession.getCode())){
throw new ValidateException("验证码不存在");
}
if(codeInSession.isExpired()){
sessionStrategy.removeAttribute(request,IMAGE_SESSION_KEY);
throw new ValidateException("验证码已过期");
}
if(!StringUtils.equals(codeInRequest,codeInSession.getCode())){
throw new ValidateException("验证码不匹配");
}
sessionStrategy.removeAttribute(request,IMAGE_SESSION_KEY);
}
}

六、配置自己的流程到security#

/**
* <p>DESC: 短信验证码安全配置类</p>
* <p>DATE: 2019-08-08 16:28</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Resource
private MyAuthenticationFailureHandler failureHandler;
@Resource
private MyAuthenticationSuccessHandler successHandler;
@Resource
private UserDetailsService userDetailsService;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
//filter中需要配置ProviderManager
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
//配置成功处理器
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(failureHandler);
//配置失败处理器
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(successHandler);
//设置Provider
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
//设置用户信息
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
//添加进ProviderManager
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}

七、将配置生效到对应模块(browser)#

核心:

//添加过滤器在usernamePasswordAuthenticationFilter
.addFilterBefore(smsCodeFilter,UsernamePasswordAuthenticationFilter.class)
//注入自定义短信验证码的安全配置
.apply(smsCodeAuthenticationSecurityConfig)

完整:

/**
* <p>DESC: web安全配置</p>
* <p>DATE: 2019-07-24 16:47</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private MySecurityProperties securityProperties;
@Resource
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Resource
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Resource
private ValidateCodeFilter validateCodeFilter;
@Resource
private SmsCodeFilter smsCodeFilter;
@Resource
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
/**
* 配置用户登录逻辑
*/
@Resource
private UserDetailsService userDetailsService;
/**
* 数据源(sql包下的)
*/
@Resource
private DataSource dataSource;
/**
* 配置一个持久化token仓库
* @return
*/
@Bean
public PersistentTokenRepository persistentTokenRepository(){
//新建一个PersistentTokenRepository的实现类
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
//需要设置数据源
tokenRepository.setDataSource(dataSource);
//启动的时候去创建表(默认存储token的表)
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(smsCodeFilter,UsernamePasswordAuthenticationFilter.class)
//在用户名密码过滤器前面加上自定义过滤器
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
.loginPage("/authentication/require")
//form表单登录请求
.loginProcessingUrl("/authentication/login")
//自定义成功处理器
.successHandler(myAuthenticationSuccessHandler)
//自定义失败处理器
.failureHandler(myAuthenticationFailureHandler)
.and()
.rememberMe()
//配置持久化token
.tokenRepository(persistentTokenRepository())
//配置token验证秒数
.tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())
//配置用于登录的UserDetailsService
.userDetailsService(userDetailsService)
.and()
.authorizeRequests()
.antMatchers(
"/code/*",
"/authentication/require",
securityProperties.getBrowser().getLoginPage()
).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
//注入自定义短信验证码的安全配置
.apply(smsCodeAuthenticationSecurityConfig)
;
}
}

抽取配置config(http)#

一、使用apply#

  • 继承SecurityConfigurerAdapter,配置http
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
/**
* 配置
* @param http http
* @throws Exception 异常
*/
@Override
public void configure(HttpSecurity http) throws Exception {
  • 使用apply加入主配置
@Component
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.apply(smsCodeAuthenticationSecurityConfig).and()

二、使用抽象配置,适用于表单#

  • 编写一个类加入spring容器,
  • 写一个 public void configure(HttpSecurity http) 方法
@Component
public class FormSecurityConfig {
@Resource
private MySecurityProperties properties;
@Resource
private MySuccessHandler successHandler;
@Resource
private MyFailHandler failHandler;
@Resource
private UserDetailsService userDetailsService;
@Resource
private DataSource dataSource;
/**
* 配置token持久化
* @return 向spring中添加
*/
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
public void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage(properties.getBrowser().getLoginPage())
.........
  • 加入主配置类
@Component
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private FormSecurityConfig formSecurityConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
//第一优先级,手动加入,不能apply进来会报错。
formSecurityConfig.configure(http);
http...........

oauth#

一、oauth协议要解决的问题#

通过令牌而不是密码的方式,解决一下问题。

  • 应用可以访问用户在微信上的所有数据
  • 用户只有修改密码,才能收回授权
  • 密码泄露的可能性大大提高

二、oauth协议中的各种角色#

角色:

  • 服务提供商(provider),谁提供令牌谁就是服务提供商。
  • 服务提供商中—认证服务器
  • 服务提供商中—资源服务器(eg,提供用户自拍数据)
  • 资源所有者(resource owner)
  • 第三方应用

三、oauth协议运行流程#

注:

第二步统一授权,有四种方式来实现:

  • 授权码模式(最完整的)
  • 简化模式
  • 密码模式
  • 客户端模式

授权码模式

1)、用户同意授权动作在服务提供商中的认证服务器上完成的

2)、授权成功后导向第三方应用时,携带的是code(授权码)。并通过授权再次请求认证服务器,去申请令牌。(安全更高)

springsocial基本原理#

  • ServiceProvider(服务提供商的抽象,eg,WEIBO),springsocial提供了一个AbstractOauth2ServiceProvider一个抽象类。

serviceProvider中的操作类:

OAuth2Operations(oauth2协议相关的一下操作),实际上封装了第一步到第五步的处理流程,springsocial提供一个默认实现OAuth2Template

API(封装了用户信息)没有固定的接口。需要自己手动写。实际上是封装第6步的行为。springsocial提供一个抽象类:AbstractOAuth2ApiBinding

第七步相关的接口和类:

  • Connection (OAuth2Connection) :作用封装第六步获取到用户信息。实际用的实现类OAuth2Connection.
  • ConnectionFactory:Connection由ConnectionFactory创建出来的。实现类:OAuth2ConnectionFactory。

ConnectionFactory中包含了一个ServiceProvider的实例的

ConnectionFactory中会有一个ServiceProvider去封装1到5步,然后获取到用户信息,封装到Connection中。

Connection是一个标准的数据接口,但是每一个服务的用户信息都是不一样的,所有此时需要一个适配器来操作。(ApiAdaptor)

服务提供商的用户和业务系统中的用户有一个关联关系:

DB(UserConnetion)数据库中有一张表在维护。

由谁来操作这张维护表呢?UserConnectionRepository(jdbcUserConnectionRepository)来操作

实现QQ的登录#

开发流程

QQ互联官网查看文档(https://connect.qq.com/)#

  • 查看get_user_info 接口

3.2输入参数说明

各个参数请进行URL 编码,编码时请遵守 RFC 1738。 (1)通用参数 -OAuth2.0协议必须传入的通用参数,详见这里

3.3请求示例

以OAuth2.0协议为例(敏感信息都用*号进行了处理,实际请求中需要替换成真实的值):

https://graph.qq.com/user/get_user_info?
access_token=*************&
oauth_consumer_key=12345&
openid=****************
  • 解读:获取用户信息需要,token(调用j接口获取),openid(调用接口获取),appid(QQ分配)。

  • 使用Authorization_Code获取Access_Token

### 2. 过程详解
#### Step1:获取Authorization Code
**请求地址**
PC网站:https://graph.qq.com/oauth2.0/authorize
**请求方法**
GET
Step2:通过Authorization Code获取Access Token
请求地址:
PC网站:https://graph.qq.com/oauth2.0/token
请求方法:
GET
  • 获取openId是关键,官方文档也给出解释
1 请求地址
PC网站:https://graph.qq.com/oauth2.0/me
2 请求方法
GET
3 请求参数
请求参数请包含如下内容:
参数 是否必须 含义
access_token 必须 在Step1中获取到的access token。

先创建serviceProvider中的 API 和 Oauth2Opreations

API#

/**
* <p>DESC: </p>
* <p>DATE: 2019-11-04 22:37</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface QQ {
QQUserInfo getQQUserInfo();
}
/**
* <p>DESC: </p>
* <p>DATE: 2019-11-04 22:38</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ {
private String appId;
private String openId;
//获取openid
private static final String GET_OPEN_ID_URL = "https://graph.qq.com/oauth2.0/me?access_token=%s";
//获取用户信息
private static final String GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";
public QQImpl(String accessToken,String appId) {
//将accessToken作为参数写到url 上
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId = appId;
String format = String.format(GET_OPEN_ID_URL, accessToken);
String result = getRestTemplate().getForObject(format, String.class);
JSONObject jsonObject = JSONObject.parseObject(result);
String openid = (String) jsonObject.get("openid");
this.openId = openid;
}
@Override
public QQUserInfo getQQUserInfo() {
String format = String.format(GET_USER_INFO, this.appId,this.openId);
String result = getRestTemplate().getForObject(format, String.class);
JSONObject jsonObject = JSONObject.parseObject(result);
if(!((Integer)jsonObject.get("ret")).equals(0)){
throw new RuntimeException("无法获取");
}
return JSONObject.parseObject(result,QQUserInfo.class);
}
}
/**
* <p>DESC: qq用户信息</p>
* <p>DATE: 2019-11-04 22:37</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class QQUserInfo {
private Integer ret;
private String msg;
private String nickname;
private String figureurl;
private String figureurl_1;
private String figureurl_2;
private String figureurl_qq_1;
private String figureurl_qq_2;
private String gender;
private String is_yellow_vip;
private String vip;
private String yellow_vip_level;
private String level;
private String is_yellow_year_vip;
}

Oauth2Opreations#

使用默认的OAuth2Template

serviceProvider#

需要制定api泛型

/**
* <p>DESC: qq,serviceProvider</p>
* <p>DATE: 2019-11-04 23:10</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {
private String appId;
/**
* 获取code
*/
private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
/**
* 获取token
*/
private static final String URL_ACCESS_TOKEN= "https://graph.qq.com/oauth2.0/token";
//provider构造方法
public QQServiceProvider(String appId,String appSecret) {
//需要 Oauth2Opreations
super(new OAuth2Template(appId,appSecret,URL_AUTHORIZE,URL_ACCESS_TOKEN));
}
// api
@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken,appId);
}
}

ApiAdapter#

/**
* <p>DESC: qq api适配器</p>
* <p>DATE: 2019-11-05 21:12</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class QQAdapter implements ApiAdapter<QQ> {
/**
* 测试api获取用户信息接口是否通的
* @param qq qq 实现
* @return boolean
*/
@Override
public boolean test(QQ qq) {
return false;
}
/**
* 将我们的第三方信息和ConnectionValues 适配
* 设置 ConnectionValues
* @param qq api
* @param connectionValues ConnectionValues
*/
@Override
public void setConnectionValues(QQ qq, ConnectionValues connectionValues) {
QQUserInfo qqUserInfo = qq.getQQUserInfo();
//设置展示名字
connectionValues.setDisplayName(qqUserInfo.getNickname());
//设置头像URL
connectionValues.setImageUrl(qqUserInfo.getFigureurl_qq_1());
//设置首页地址
connectionValues.setProfileUrl(null);
//设置openID
connectionValues.setProviderUserId(qqUserInfo.getOpenId());
}
/**
* 获取用户的概要信息
* @param qq api
* @return 用户概要信息
*/
@Override
public UserProfile fetchUserProfile(QQ qq) {
return null;
}
/**
* 更新状态
* @param qq
* @param s
*/
@Override
public void updateStatus(QQ qq, String s) {
}
}

ConnectionFactory#

/**
* <p>DESC: QQConnectionFactory</p>
* <p>DATE: 2019-11-05 21:22</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class QQConnectionFactory extends OAuth2ConnectionFactory<QQ> {
/**
* providerId 应用ID 这里为 qq
*/
public QQConnectionFactory(String providerId,String appId,String appSecret) {
super(providerId, new QQServiceProvider(appId,appSecret), new QQAdapter());
}
}

Connection对象#

Connection对象的获取,不用我们去管,只要我们弄好ConnectionFactory对象,springsocial会自动根据ConnectionFactory对象生成相对应的Connection对象。

配置数据库操作#

还有: 添加到security过滤链中

/**
* <p>DESC: social 配置</p>
* <p>DATE: 2019-11-05 21:26</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
@Resource
private DataSource dataSource;
/**
* 将我们 JdbcUsersConnectionRepository 配置好
* @param connectionFactoryLocator locator
* @return UsersConnectionRepository
*/
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
/**
* 第二个参数的作用:根据条件查找该用哪个ConnectionFactory来构建Connection对象
* 第三个参数的作用: 对插入到userconnection表中的数据进行加密和解密
*/
JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
//设置userconnection表的前缀,也可以不设置
repository.setTablePrefix("dftc_");
return repository;
}
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
// 默认配置类,进行组件的组装
// 包括了过滤器SocialAuthenticationFilter 添加到security过滤链中
return new SpringSocialConfigurer();
}
}

UserConnection表#

是springsocial为我们准备的一张用来维护我们的系统和QQ、微信、微博等第三方应用之间关系的表,建表语句可在如下包目录结构下找到:

QQ 自动配置#

特别注意: 我最近写的一系列关于spring-security、springsocial、oauth2的文章都算是学习慕课网《Spring Security技术栈开发企业级认证与授权》这个课程的笔记,但是我使用的springboot是2.1.6.RELEASE版本,而老师课程中使用的是1.5.6.RELEASE版本。在标题对应的这个问题上,两个版本之间存在一些比较大的差异。 差异原因: 2.1.6.RELEASE版本的springboot相比于1.5.6.RELEASE版本来说org.springframework.boot.autoconfigure里没有social相关的配置简化类。

springboot为1.5.6.RELEASE的版本#

package com.imooc.security.core.social.qq.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.social.SocialAutoConfigurerAdapter;
import org.springframework.context.annotation.Configuration;
import org.springframework.social.connect.ConnectionFactory;
import com.imooc.security.core.properties.QQProperties;
import com.imooc.security.core.properties.SecurityProperties;
import com.imooc.security.core.social.qq.connet.QQConnectionFactory;
@Configuration
@ConditionalOnProperty(prefix = "imooc.security.social.qq", name = "app-id")
public class QQAutoConfig extends SocialAutoConfigurerAdapter {
/**这里不多解释,就是将providerId,appId和appSecret写入到配置类,
然后这里注入配置类,就可以读到这些信息
可以下载源码进行查看*/
@Autowired
private SecurityProperties securityProperties;
@Override
protected ConnectionFactory<?> createConnectionFactory() {
QQProperties qqConfig = securityProperties.getSocial().getQq();
return new QQConnectionFactory(qqConfig.getProviderId(), qqConfig.getAppId(), qqConfig.getAppSecret());
}
}

注意看import: 上面的SocialAutoConfigurerAdapter 来自于

org.springframework.boot.autoconfigure.social.SocialAutoConfigurerAdapter;

其实老师在配置providerId,appId和appSecret属性时也用到了org.springframework.boot.autoconfigure.social里的一个类,代码如下,(完整代码可以从老师的github地址下载:https://github.com/jojozhai/security)

package com.imooc.security.core.properties;
import org.springframework.boot.autoconfigure.social.SocialProperties;
public class QQProperties extends SocialProperties {
private String providerId = "qq";
public String getProviderId() {
return providerId;
}
public void setProviderId(String providerId) {
this.providerId = providerId;
}
}

springboot1.5.6和2.1.5中autoconfigure包对比及2.1.5中的解决方法#

仔细看一下源码,可以发现SocialAutoConfigurerAdapter和SocialProperties特别简单。

其实看到social这个包下面的类,我们解决这个问题的方式就多了,比如说

参照Facebook、LinkedIn或Twitter的栗子再联系一下各个类之间的依赖关系去自己写配置类 或者

解决方法:

  • 直接将SocialAutoConfigurerAdapter、SocialProperties和SocialWebAutoConfiguration这三个类拷贝到我们项目里 —> 我采用的这种方式(因为比较简单☺),可以去github查看代码commit记录https://github.com/nieandsun/security

注意1: 其实我一开始没有拷贝SocialWebAutoConfiguration这个类,因为觉得好像没用,但是后来启动项目时报了如下错误,大体意思就是必须有一个配置类实现getUserIdSource方法,后来发现SocialWebAutoConfiguration类中正好实现了该方法,所以也将其拷贝了过来。

注意2 SocialWebAutoConfiguration类中有个方法如下,飘红,说明有问题,看该方法的意思是如果有SpringResourceResourceResolver类型的类时,该方法才会生效。源码里都没该类,所以肯定可以将该方法注释掉。

SocialAutoConfigurerAdapter(可参照自己实现)

public abstract class SocialAutoConfigurerAdapter extends SocialConfigurerAdapter {
public SocialAutoConfigurerAdapter() {
}
@Override
public void addConnectionFactories(ConnectionFactoryConfigurer configurer, Environment environment) {
configurer.addConnectionFactory(this.createConnectionFactory());
}
protected abstract ConnectionFactory<?> createConnectionFactory();
}

SocialProperties

public abstract class SocialProperties {
private String appId;
private String appSecret;
public SocialProperties() {
}
public String getAppId() {
return this.appId;
}
public void setAppId(String appId) {
this.appId = appId;
}
public String getAppSecret() {
return this.appSecret;
}
public void setAppSecret(String appSecret) {
this.appSecret = appSecret;
}
}

SocialWebAutoConfiguration

@Configuration
@ConditionalOnClass({ConnectController.class, SocialConfigurerAdapter.class})
@ConditionalOnBean({ConnectionFactoryLocator.class, UsersConnectionRepository.class})
@AutoConfigureBefore({ThymeleafAutoConfiguration.class})
@AutoConfigureAfter({WebMvcAutoConfiguration.class})
public class SocialWebAutoConfiguration {
public SocialWebAutoConfiguration() {
}
private static class SecurityContextUserIdSource implements UserIdSource {
private SecurityContextUserIdSource() {
}
@Override
public String getUserId() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
Assert.state(authentication != null, "Unable to get a ConnectionRepository: no user signed in");
return authentication.getName();
}
}
//@Configuration
////@ConditionalOnClass({SpringResourceResourceResolver.class})
//protected static class SpringSocialThymeleafConfig {
// protected SpringSocialThymeleafConfig() {
// }
//
// @Bean
// @ConditionalOnMissingBean
// public SpringSocialDialect springSocialDialect() {
// return new SpringSocialDialect();
// }
//}
@Configuration
@EnableSocial
@ConditionalOnWebApplication
@ConditionalOnClass({SecurityContextHolder.class})
protected static class AuthenticationUserIdSourceConfig extends SocialConfigurerAdapter {
protected AuthenticationUserIdSourceConfig() {
}
@Override
public UserIdSource getUserIdSource() {
return new SocialWebAutoConfiguration.SecurityContextUserIdSource();
}
}
@Configuration
@EnableSocial
@ConditionalOnWebApplication
@ConditionalOnMissingClass({"org.springframework.security.core.context.SecurityContextHolder"})
protected static class AnonymousUserIdSourceConfig extends SocialConfigurerAdapter {
protected AnonymousUserIdSourceConfig() {
}
@Override
public UserIdSource getUserIdSource() {
return new UserIdSource() {
@Override
public String getUserId() {
return "anonymous";
}
};
}
}
@Configuration
@EnableSocial
@ConditionalOnWebApplication
protected static class SocialAutoConfigurationAdapter extends SocialConfigurerAdapter {
private final List<ConnectInterceptor<?>> connectInterceptors;
private final List<DisconnectInterceptor<?>> disconnectInterceptors;
private final List<ProviderSignInInterceptor<?>> signInInterceptors;
public SocialAutoConfigurationAdapter(ObjectProvider<List<ConnectInterceptor<?>>> connectInterceptorsProvider, ObjectProvider<List<DisconnectInterceptor<?>>> disconnectInterceptorsProvider, ObjectProvider<List<ProviderSignInInterceptor<?>>> signInInterceptorsProvider) {
this.connectInterceptors = (List)connectInterceptorsProvider.getIfAvailable();
this.disconnectInterceptors = (List)disconnectInterceptorsProvider.getIfAvailable();
this.signInInterceptors = (List)signInInterceptorsProvider.getIfAvailable();
}
@Bean
@ConditionalOnMissingBean({ConnectController.class})
public ConnectController connectController(ConnectionFactoryLocator factoryLocator, ConnectionRepository repository) {
ConnectController controller = new ConnectController(factoryLocator, repository);
if (!CollectionUtils.isEmpty(this.connectInterceptors)) {
controller.setConnectInterceptors(this.connectInterceptors);
}
if (!CollectionUtils.isEmpty(this.disconnectInterceptors)) {
controller.setDisconnectInterceptors(this.disconnectInterceptors);
}
return controller;
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(
prefix = "spring.social",
name = {"auto-connection-views"}
)
public BeanNameViewResolver beanNameViewResolver() {
BeanNameViewResolver viewResolver = new BeanNameViewResolver();
viewResolver.setOrder(-2147483648);
return viewResolver;
}
@Bean
@ConditionalOnBean({SignInAdapter.class})
@ConditionalOnMissingBean
public ProviderSignInController signInController(ConnectionFactoryLocator factoryLocator, UsersConnectionRepository usersRepository, SignInAdapter signInAdapter) {
ProviderSignInController controller = new ProviderSignInController(factoryLocator, usersRepository, signInAdapter);
if (!CollectionUtils.isEmpty(this.signInInterceptors)) {
controller.setSignInInterceptors(this.signInInterceptors);
}
return controller;
}
}
}

配置BrowserSecurityConfig 。。SocialAuthenticationFilter

将SocialAuthenticationFilter通过apply()加入到spring-security过滤器链中#

@Resource
private SpringSocialConfigurer imoocSocialSecurityConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.apply(imoocSocialSecurityConfig).and().....
}

增加一个QQ登陆按钮#

<h3>社交登录</h3>
<a href="/auth/qq">QQ登录</a>

QQ登陆url中/auth的含义#

SocialAuthenticationFilter会拦截所有路径中含有/auth的请求

QQ登陆url中/qq的含义#

/qq其实是在构建ConnectionFactory对象时第一个参数对应的值

即当一个请求的url中有/auth/qq时,spring-security会让SocialAuthenticationFilter过滤器将其拦截下来,然后利用我们写的QQConnectionFactory对象来走oauth2流程+构建connection对象。

在spring social中,该路径 有两个很重要的作用

  • 一是跳转应用最终引导用户到登录页面的路径
  • 二是用户授权后,跳转回来的路径。redirect _url

QQ登录测试#

点击QQ登陆会跳转到QQ的授权页面,但我们跳转到的页面里显示redirect uri is illegal(100010),具体原因将在接下来的博客中进行解决。

1 redirect uri is illegal(100010)错误的原因#

1、redirect uri是什么,怎么配置#

  • 首先我们要明确Oauth2协议的整个流程: 用户点击QQ进行QQ登陆----》用户通过扫码等方式进行授权 —》授权成功后QQ会调用一个回调地址,将授权码给我们的系统 —》然后我们的系统会拿着授权码等去换accesstoken —》换到accesstoken后再通过accesstoken等拿到openid—》接着再拿着accesstoken,openid等去QQ换取用户信息。

  • redirect uri就是上诉过程中的回调地址

  • 同时我们还要明确redirect uri不是随意生成的,而是在我们的系统在QQ上进行注册时需要填写的必填信息之一,它的填写有一定的规则,可参考QQ互联官网这里要注意,官网上明确说明了域名要包含”http://“部分

2、redirect uri与我们项目的关系#

我们把请求的路径拷贝如下(这个请求具体怎么生成的???为什么要这要生成???与QQ互联官网上获取授权码接口有什么关系???建议大家好好看看QQ互联的官网并仔细阅读一下源码:org.springframework.social.oauth2.OAuth2Template):

https://graph.qq.com/oauth2.0/show?which=error&display=pc&error=100010&client_id=100550231&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth%2Fqq&state=8f3b0533-e5a7-4dfe-a2b8-94f0119b0ad2

从中我们可以看到redirect_uri的路径如下:

http%3A%2F%2Flocalhost%3A8080%2Fauth%2Fqq

其实可以很容易地猜出来就是localhost://8080/auth/qq,这肯定不可能是我们在QQ互联上配置的redirect_uri,因此当我们的项目拿着这个redirect_uri去请求QQ的接口时就报出了redirect uri is illegal(100010)的错误。

同时我们也可以看出redirect_uri在我们项目中的生成规则是:

IP+端口号(或者域名+端口)+/auth/qq

其中/auth和/qq在[上文中]讲过它俩分别由SocialAuthenticationFilter和providerId控制。

2、解决 redirect uri错误#

通过前面的讲解,就可以很容易地知道假如我们在QQ互联上配置的网站地址和回调域名(redirect_uri)如下

那么为了让我们的项目不会报redirect_uri错误,我们需要做如下四件事。

(1) 修改我们项目的访问域名+端口

本地测试开发时可以通过SwitchHosts来将域名映射为IP,端口号需在项目里指定为80。

  • 本机指定域名和IP的映射关系
  • 将项目的访问端口号改为80

(2) 将SocialAuthenticationFilter默认拦截的请求/auth改为/qqLogin(可配置)

这个可以通过继承springsocialSocial为我们提供的SocialAuthenticationFilter的配置类SpringSocialConfigurer,并重写其后置处理方法的方式来完成,具体代码如下:

新建一个NrscSpringSocialConfigurer 类继承SpringSocialConfigurer并重写其后置处理方法

/**
* @author : Sun Chuan
* @date : 2019/8/19 22:48
* Description:继承springsocialSocial为我们提供的SocialAuthenticationFilter的配置类SpringSocialConfigurer
* 并重写其后置处理方法,让其默认拦截包含我们配置字符串的请求
*/
public class NrscSpringSocialConfigurer extends SpringSocialConfigurer {
private String filterProcessesUrl;
public NrscSpringSocialConfigurer(String filterProcessesUrl) {
this.filterProcessesUrl = filterProcessesUrl;
}
/**
* T 其实就是指的SocialAuthenticationFilter类型
*
* @param object
* @param <T>
* @return
*/
@Override
protected <T> T postProcess(T object) {
//在父类处理完SocialAuthenticationFilter之后的基础上,将其默认拦截url改成我们配置的值
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
filter.setFilterProcessesUrl(filterProcessesUrl);
return (T) filter;
}
}

可能会报错:

报错:cannot access javax.servlet.Filter 无法找找 这个东东

最近项目中需要用到javax.servlet.*,发现IDEA默认的没jar包

这个是Geronimo对外分发的可以给开源代码的servlet的jar,经过了sun的ctk兼容性测试。省去了原来加载JNDI,JDBC,SERVLET,JSP,JTA

解决办法:

<dependency>
<groupId>org.apache.geronimo.specs</groupId>
<artifactId>geronimo-servlet_2.4_spec</artifactId>
<version>1.1.1</version>
<scope>provided</scope>
</dependency>

将我们写的NrscSpringSocialConfigurer 类作为SocialAuthenticationFilter的配置类注入到spring容器,并加入到spring-security过滤器链中

注:在 SpringSocialConfigurer 注入的地方改造如下

@Autowired
private NrscSecurityProperties nrscSecurityProperties;
/**
* 通过apply()该实例,可以将SocialAuthenticationFilter加入到spring-security的过滤器链
*/
@Bean
public SpringSocialConfigurer nrscSocialSecurityConfig() {
// 默认配置类,进行组件的组装
// 包括了过滤器SocialAuthenticationFilter 添加到security过滤链中
String filterProcessesUrl = nrscSecurityProperties.getSocial().getFilterProcessesUrl();
NrscSpringSocialConfigurer configurer = new NrscSpringSocialConfigurer(filterProcessesUrl);
return configurer;
}

在配置文件里配置filterProcessesUrl让SocialA

uthenticationFilter默认拦截含有/qqLogin的请求

(3) 配置providerId将qq改为callback.do 配置如上图。

(4) 修改QQ登陆按钮的请求路径

<h3>社交登录</h3>
<a href="/qqLogin/callback.do">QQ登录</a>

/signin错误解决+springsocial源码解读#

虽然解决了redirect uri is illegal(100010)错误,但是扫码进行授权后,发现仍然不能完成第三方登陆的流程。而是会报出下图所示的错误,通过后端打印日志可以看到服务请求了XXX/signin,而在项目里并未对该url进行放行,因此前端页面展示出这样的错误是很好理解的。那为什么在进行QQ扫码授权后,会跳到该url上呢?这需要去springsocial源码中去寻求答案。

springsocial源码解读#

从用户点击QQ登陆 —》到程序引导用户进入QQ授权页面 —》到用户在QQ授权页面进行扫码授权—》到程序拿着获取到的用户信息与我们自己数据库里的用户信息进行关联+校验 —》再到校验成功并构建一个标识为校验成功的 Authentication对象 的整个过程springsocial/springsecurity所使用到的主要类大致如下图。

AbstractAuthenticationProcessingFilter#

这个Filter我在《spring-security入门6—表单登陆认证原理源码解析》这篇文章里详细地介绍过,它其实是一个抽象类,利用模版模式定义了认证的整个整体框架,但是具体的认证过程、认证成功后的行为和认证失败后的行为需要子类进行具体实现。其伪代码如下:

try{
//尝试进行登陆认证---抽象方法具体实现交给交给继承的子类,
//在springsocial的流程里就是SocialAuthenticationFilter
//用户名+密码登陆的流程里为UsernamePasswordAuthenticationFilter
Authentication authResult = attemptAuthentication(request, response);
}catch(认证失败){
//这里会讲两个认证失败相关的逻辑
// (1)点击QQ登陆,将用户引导到QQ授权页面时
// (2)用户在QQ授权页面进行扫码,最终未成功登陆而是访问XXX/signin路径的逻辑
unsuccessfulAuthentication(request, response, failed);
}
//认证成功
// -->将认证成功的Authentication对象放到线程的SecurityContext对象中
// -->走记住我相关的逻辑等
successfulAuthentication(request, response, chain, authResult);

SocialAuthenticationFilter SocialAuthenticationFilter和用户名+密码认证校验方式时用到的UsernamePasswordAuthenticationFilter一样,也是AbstractAuthenticationProcessingFilter的子类。当请求的URL为SocialAuthenticationFilter指定要拦截的URL时springsecurity/springsocial就会走SocialAuthenticationFilter中的attemptAuthentication((request, response))进行认证登陆。其伪代码如下:

  • attemptAuthentication — 认证校验的准备工作
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (detectRejection(request)) {
if (logger.isDebugEnabled()) {
logger.debug("A rejection was detected. Failing authentication.");
}
throw new SocialAuthenticationException("Authentication failed because user rejected authorization.");
}
Authentication auth = null;
Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds();
//获取到ProviderId,就是我们在yml配置文件里指定的ProviderId
String authProviderId = getRequestedProviderId(request);
if (!authProviders.isEmpty() && authProviderId != null && authProviders.contains(authProviderId)) {
//根据ProviderId获取相应的SocialAuthenticationService
SocialAuthenticationService<?> authService = authServiceLocator.getAuthenticationService(authProviderId);
//真正进行认证校验的方法
auth = attemptAuthService(authService, request, response);
if (auth == null) {
throw new AuthenticationServiceException("authentication failed");
}
}
return auth;
}
  • attemptAuthService — 定义了真正认证校验的整体框架
private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response)
throws SocialAuthenticationRedirectException, AuthenticationException {
//这里的token其实是用从QQ获取的用户信息(被封装到一个Connection对象里)进一步封装后得到的未被校验的对象
//类比一下,在用户名+密码登陆时也会有一个未校验的token
final SocialAuthenticationToken token = authService.getAuthToken(request, response);
if (token == null) return null;
Assert.notNull(token.getConnection());
//即Authentication auth=SecurityContextHolder.getContext().getAuthentication();
//即先去SecurityContextHolder里看看有没有Authentication对象
Authentication auth = getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
//进行密码是否过期,用户是否可用等校验,如校验成功,返回一个标识为校验成功的Authentication对象
return doAuthentication(authService, request, token);
} else {
//暂时不进行追究
addConnection(authService, request, token, auth);
return null;
}
}

Auth2AuthenticationService#

getAuthToken方法 — 走Oauth2协议流程很重要的一个方法

OAuth2AuthenticationService对上面attemptAuthService方法调用的 getAuthToken方法进行了具体实现,其主要代码如下:

public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
//获取授权码
String code = request.getParameter("code");
//用户点击QQ登陆,我们的程序里将用户引导到QQ授权页面时请求的URL(Oauth2协议中获取授权码的过程) 和 用户在QQ授权页面
//进行扫码授权后,QQ服务器回调我们服务的URL是相同的
// 两者不同的就是我们引导用户到QQ授权页面时的请求里没有授权码,而用户点击授权QQ回调时会携带者授权码
//因此下面其实就是在走用户点击QQ登陆,我们的程序将用户引导到QQ授权页面的逻辑
if (!StringUtils.hasText(code)) {
OAuth2Parameters params = new OAuth2Parameters();
params.setRedirectUri(buildReturnToUrl(request));
setScope(request, params);
params.add("state", generateState(connectionFactory, request));
addCustomParameters(params);
//拼装成访问QQ授权页面的路径,并throw出去
throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
} else if (StringUtils.hasText(code)) {
try {
//从request中拿出redirect_uri
String returnToUrl = buildReturnToUrl(request);
//拿着redirect_uri、授权码等去QQ服务器交换accessToken
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
// TODO avoid API call if possible (auth using token would be fine)
//获取到从QQ拿到的封装成Connection对象的用户信息
Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
//将Connection对象进一步封装成SocialAuthenticationToken对象,便于2.2中的attemptAuthService方法进行校验
return new SocialAuthenticationToken(connection, null);
} catch (RestClientException e) {
logger.debug("failed to exchange for access", e);
return null;
}
} else {
return null;
}
}

用户点击QQ登陆,引导用户到QQ授权页面的具体逻辑#

当用户点击QQ登陆时,会走到没有code的if语句块中,在这个语句块里其实就做了一件事,就是拼接访问QQ授权页面的路径(注意,这里拼接路径的规则和QQ互联上获取Authorization Code指明的规则是一致的),并将该路径封装到SocialAuthenticationRedirectException异常中抛出去。那将异常抛出去之后怎么就会访问到了QQ授权页面呢?这里就不得不提一下springsocial所定义的处理异常的类SocialAuthenticationFailureHandler了。首先抛出的异常会一路向上抛到AbstractAuthenticationProcessingFilter里,然后在该Filter里会捕获到抛出的异常,并根据异常类型,指定用SocialAuthenticationFailureHandler来进行处理,SocialAuthenticationFailureHandler的源码如下:

public class SocialAuthenticationFailureHandler implements AuthenticationFailureHandler {
//异常处理器
private AuthenticationFailureHandler delegate;
public SocialAuthenticationFailureHandler(AuthenticationFailureHandler delegate) {
this.delegate = delegate;
}
//具体处理异常的方法
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
//处理SocialAuthenticationRedirectException异常的逻辑
if (failed instanceof SocialAuthenticationRedirectException) {
//将异常中的URL取出,并进行重定向----》这里就是引导用户到QQ授权页面的奥秘!!!
response.sendRedirect(((SocialAuthenticationRedirectException) failed).getRedirectUrl());
return;
}
//处理其他springsocial认证失败的逻辑-----》就包含了本文标题中所说的/signin错误
delegate.onAuthenticationFailure(request, response, failed);
}
}

/signin错误的具体原因#

为什么会跳转(重定向)到/signin路径#

通过断点调试可以发现,用户在QQ页面进行扫码授权后,会走到OAuth2AuthenticationService的getAuthToken方法else if语句块 ,但是在运行到下面的方法时就会报错了

AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);

报错信息为: Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]

异常会被catch住(可以注意一下,这里的异常为RestClientException,它其实是请求相关的异常),catch住之后,只是打印了一个logger信息,然后返回了一个null。

—》这里返回null —》2.2中的attemptAuthService方法就会返回null —》然后2.2中attemptAuthentication方法里获得的auth就是null,当auth为null时就会抛出SocialAuthenticationException异常 —》AbstractAuthenticationProcessingFilter会捕捉到该异常,然后根据异常类型,指定用SocialAuthenticationFailureHandler(即2.3.2中所说的SocialAuthenticationFailureHandler)来进行处理 —》走delegate.onAuthenticationFailure(request, response, failed);语句进行处理 —》真正的处理该异常的具体实现在SimpleUrlAuthenticationFailureHandler类里,重定向到XXX/signin的具体逻辑就在该类里。(感兴趣的童鞋可以自己追踪着看一下,这里不再赘述!!!)

为什么获取accessToken会抛出RestClientException异常#

想要知道具体的原因必须要深入到报错的语句中,exchangeForAccess方法具体的实现(OAuth2Template类中)如下:

  • exchangeForAccess方法 — 预备工作:拼装请求参数
public AccessGrant exchangeForAccess(String authorizationCode, String redirectUri, MultiValueMap<String, String> additionalParameters) {
//拼接请求参数
MultiValueMap<String, String> params = new LinkedMultiValueMap<String, String>();
//注意这里的useParametersForClientAuthentication要为true才会将client_id 和 client_secret拼接为请求参数
//QQ互联(https://wiki.connect.qq.com/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token)上
//明确指出获取accessToken这两个参数为必传参数,因此要想办法保证该值在这里必须为true
if (useParametersForClientAuthentication) {
params.set("client_id", clientId);
params.set("client_secret", clientSecret);
}
params.set("code", authorizationCode);
params.set("redirect_uri", redirectUri);
params.set("grant_type", "authorization_code");
if (additionalParameters != null) {
params.putAll(additionalParameters);
}
//真正发送请求获取accessToken
return postForAccessGrant(accessTokenUrl, params);
}
  • postForAccessGrant — RestClientException异常发生的地方
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
//方法很简单,就是利用restTemplate发送一个rest请求,然后将结果交给 extractAccessGrant函数进行构建AccessGrant对象
//RestClientException异常就发生在这里
return extractAccessGrant(getRestTemplate().postForObject(accessTokenUrl, parameters, Map.class));
}

到底为什么会发生这个异常呢?通过异常信息Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]其实可以很清楚的知道,是因为当前的RestTemplate没有适合的HttpMessageConverter 去处理reponse为Map,content type为 [text/html]的返回信息。看一下OAuth2Template中创建RestTemplate对象的方法,可以发现,它只加了如下三个HttpMessageConverter,这三个HttpMessageConverter并不能处理content type为 [text/html]的返回信息,因此走到上面的postForAccessGrant方法会抛出RestClientException异常。

protected RestTemplate createRestTemplate() {
ClientHttpRequestFactory requestFactory = ClientHttpRequestFactorySelector.getRequestFactory();
RestTemplate restTemplate = new RestTemplate(requestFactory);
List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>(2);
converters.add(new FormHttpMessageConverter());
converters.add(new FormMapHttpMessageConverter());
converters.add(new MappingJackson2HttpMessageConverter());
restTemplate.setMessageConverters(converters);
restTemplate.setErrorHandler(new LoggingErrorHandler());
if (!useParametersForClientAuthentication) {
List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
if (interceptors == null) { // defensively initialize list if it is null. (See SOCIAL-430)
interceptors = new ArrayList<ClientHttpRequestInterceptor>();
restTemplate.setInterceptors(interceptors);
}
interceptors.add(new PreemptiveBasicAuthClientHttpRequestInterceptor(clientId, clientSecret));
}
return restTemplate;
}

/signin错误解决方式#

找到问题的具体原因,我们就可以着手去解决该问题了,其实解决方式也比较简单,就是我们只需要写一个类,继承OAuth2Template,并重写createRestTemplate方法,给RestTemplate对象加上一个可以处理content type为 [text/html]的HttpMessageConverter就可以了。

在此之前还要说明一点,OAuth2Template类里postForAccessGrant 发送post请求获取accessToken期待的返回值为Map,但通过QQ互联官网可以看到获取accessToken返回的是如下的一个字符串,并不是一个json,那就无法直接转成Map了,因此我们还需要重写一下postForAccessGrant方法。

access_token=FE04************************CCE2&expires_in=7776000&refresh_token=88E4************************BE14。
  • 继承了OAuth2Template的实体类
/**
*
*/
package com.nrsc.security.core.social.qq.connect;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
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.RestTemplate;
import java.nio.charset.Charset;
@Slf4j
public class QQOAuth2Template extends OAuth2Template {
public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
//只有这样才会将client_id 和 client_secret拼接为请求参数
setUseParametersForClientAuthentication(true);
}
/**
* 通过QQ互联(https://wiki.connect.qq.com/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token)
* 可以知道获取accessToken这一步其实获取到的是一个字符串,而OAuth2Template里的postForAccessGrant方法默认获取的类型
* 为一个Map(获取的信息为json格式才可直接转成Map),因此需要重写postForAccessGrant方法,将获取到的accessToken等
* 信息封装到AccessGrant对象里
* @param accessTokenUrl
* @param parameters
* @return
*/
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);
log.info("获取accessToke的响应:" + responseStr);
String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");
String accessToken = StringUtils.substringAfterLast(items[0], "=");
Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], "="));
String refreshToken = StringUtils.substringAfterLast(items[2], "=");
return new AccessGrant(accessToken, null, refreshToken, expiresIn);
}
/**
* 重写createRestTemplate方法,使其加上可以处理content type为 [text/html]的HttpMessageConverter
* @return
*/
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
return restTemplate;
}
}
  • 用我们自己的QQOAuth2Template对象来构建ServiceProvider对象
**
* <p>DESC: qq,serviceProvider</p>
* <p>DATE: 2019-11-04 23:10</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {
private String appId;
/**
* 获取code
*/
private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
/**
* 获取token
*/
private static final String URL_ACCESS_TOKEN= "https://graph.qq.com/oauth2.0/token";
/**
* provider构造方法
* @param appId appId
* @param appSecret appSecret
*/
public QQServiceProvider(String appId,String appSecret) {
//需要 Oauth2Opreations
super(new QQOAuth2Template(appId,appSecret,URL_AUTHORIZE,URL_ACCESS_TOKEN));
this.appId = appId;
}
/**
* api 重写方法
* @param accessToken token
* @return
*/
@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken,appId);
}
}

/signup错误#

当解决了/signin错误后,我们就已经从QQ获取到了用户信息,即Connection对象,而SpringSocial/SpringSecurity又将其进一步封装成了一个SocialAuthenticationToken对象进行后续的校验工作

attemptAuthService — 定义了真正认证校验的整体框架

private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response)
throws SocialAuthenticationRedirectException, AuthenticationException {
//这里的token其实是用从QQ获取的用户信息(被封装到一个Connection对象里)进一步封装后得到的未被校验的对象
//类比一下,在用户名+密码登陆时也会有一个未校验的token
final SocialAuthenticationToken token = authService.getAuthToken(request, response);
if (token == null) return null;
Assert.notNull(token.getConnection());
//即Authentication auth=SecurityContextHolder.getContext().getAuthentication();
//即先去SecurityContextHolder里看看有没有Authentication对象
Authentication auth = getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
//进行密码是否过期,用户是否可用等校验,如校验成功,返回一个标识为校验成功的Authentication对象
return doAuthentication(authService, request, token);
} else {
//暂时不进行追究
addConnection(authService, request, token, auth);
return null;
}
}

在获取到了封装了QQ用户信息的SocialAuthenticationToken对象后会走doAuthentication(authService, request, token);方法进行进一步的校验工作。该方法的具体实现如下:

private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) {
try {
if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null;
token.setDetails(authenticationDetailsSource.buildDetails(request));
//下面这句话在<<spring-security入门6---表单登陆认证原理源码解析>>那篇文章里详细地介绍过,有兴趣的可以看一下
//它的作用是循环遍历各个Provider,并根据token的类型找到相应的Provider进行下一步的校验工作
//在springsocial的认证过程中用到的是SocialAuthenticationProvider
Authentication success = getAuthenticationManager().authenticate(token);
Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principle type");
updateConnections(authService, token, success);
return success;
}
// 从下面源代码中的注释,其实可以很清楚地看到/signup错误发生的时机
//即当上面的代码出现BadCredentialsException异常时,SpringSocial/SpringSecurity会catch住该异常,
//并抛出一个重定向异常---》系统默认的重定向url就是XXX/signup(即注册),而我们未对/signup进行授权,
//所以最后就会出现一个/signup异常。
catch (BadCredentialsException e) {
// connection unknown, register new user?
if (signupUrl != null) {
// store ConnectionData in session and redirect to register page
sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));
throw new SocialAuthenticationRedirectException(buildSignupUrl(request));
}
throw e;
}
}

再来看一下SocialAuthenticationProvider中的authenticate方法,看一下为什么会报出BadCredentialsException异常

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(SocialAuthenticationToken.class, authentication, "unsupported authentication type");
Assert.isTrue(!authentication.isAuthenticated(), "already authenticated");
SocialAuthenticationToken authToken = (SocialAuthenticationToken) authentication;
String providerId = authToken.getProviderId();
Connection<?> connection = authToken.getConnection();
//拿着Connection对象去userconnection表里去查相应的userId,由于我们的系统里暂时还没有一个用户,
//所以查到的userId肯定为null----》为null就会报出BadCredentialsException---》然后就会回到上面的类
//让你跳转到一个XXX/signup的url----》其实说白了就是引导用户进入注册用户的页面(这一块需要我们自己实现)
String userId = toUserId(connection);
if (userId == null) {
throw new BadCredentialsException("Unknown access token");
}
//当能从userconnection表里查到数据时会走下面的语句
//即拿着userId去我们的业务表里拿到用户信息
UserDetails userDetails = userDetailsService.loadUserByUserId(userId);
if (userDetails == null) {
throw new UsernameNotFoundException("Unknown connected account id");
}
//校验用户是否过期等,并将校验成功后的用户对象封装到一个标记为已经校验成功的Authentication对象中
return new SocialAuthenticationToken(connection, userDetails, authToken.getProviderAccountData(), getAuthorities(providerId, userDetails));
}

代码解读到这里,其实已经可以很清楚地知道为什么项目会报出XXX/signup的错误了。那解决该问题的思路自然也就有了,具体思路如下:

  • 我们需要自己写一个注册页面
  • 让signupUrl对应的url可以指向我们写的注册页
  • 对signupUrl进行授权
  • 开发用户注册相关的逻辑

系统注册页面#

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>注册</title>
</head>
<body>
<h2>标准注册页面</h2>
<h3>这是系统注册页面,请配置nrsc.security.browser.signUpUrl属性来设置自己的注册页</h3>
</body>
</html>

指定默认的注册页面为系统注册页#

(1)在BrowserProperties里指定默认注册页所在的路径

package com.nrsc.security.core.properties;
import lombok.Data;
/**
* @author : Sun Chuan
* @date : 2019/6/20 22:13
*/
@Data
public class BrowserProperties {
/**指定默认的注册页面*/
private String signUpUrl = "/defaultPage/default-signUp.html";
/**指定默认的登陆页面*/
private String loginPage = SecurityConstants.DEFAULT_LOGIN_PAGE_URL;
/**指定默认的处理成功与处理失败的方法*/
private LoginType loginType = LoginType.JSON;
/**记住我的时间3600秒即1小时*/
private int rememberMeSeconds = 3600;
}

当然我们可以通过在yml里进行配置来修改具体的注册页面地址

(2)指定SpringSocial/SpringSecurity跳向注册页面时的url为我们配置的url

/**
* 通过apply()该实例,可以将SocialAuthenticationFilter加入到spring-security的过滤器链
*/
@Bean
public SpringSocialConfigurer nrscSocialSecurityConfig() {
// 默认配置类,进行组件的组装
// 包括了过滤器SocialAuthenticationFilter 添加到security过滤链中
String filterProcessesUrl = nrscSecurityProperties.getSocial().getFilterProcessesUrl();
NrscSpringSocialConfigurer configurer = new NrscSpringSocialConfigurer(filterProcessesUrl);
//指定SpringSocial/SpringSecurity跳向注册页面时的url为我们配置的url
configurer.signupUrl(nrscSecurityProperties.getBrowser().getSignUpUrl());
return configurer;
}

对signupUrl进行授权#

.authorizeRequests()
.antMatchers(
SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE,
nrscSecurityProperties.getBrowser().getLoginPage(),
SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX + "/*",
nrscSecurityProperties.getBrowser().getSignUpUrl() //对注册url进行授权
)

开发注册相关的逻辑#

@PostMapping("/register")
public void register(NrscUser user) {
//注册用户相关逻辑
}

未配置nrsc.security.browser.signUpUrl属性时#

扫码进行授权后会跳转到如下页面,说明开发的代码已经生效。

配置了signUpUrl时#

配置效果2.2(1)中的插图所示,nrsc-signUp.html的源码如下:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>注册</title>
</head>
<body>
<h2>Demo注册页</h2>
<form action="/user/regist" method="post">
<table>
<tr>
<td>用户名:</td>
<td><input type="text" name="username"></td>
</tr>
<tr>
<td>密码:</td>
<td><input type="password" name="password"></td>
</tr>
<tr>
<td colspan="2">
<button type="submit" name="type" value="register">注册</button>
<button type="submit" name="type" value="binding">绑定</button>
</td>
</tr>
</table>
</form>
</body>
</html>

此时扫码进行授权后会跳转到自定义注册页面,说明配置已经生效。

QQ登录和系统绑定#

由于这里的注册其实是从第三方登陆过程中重定向而来的注册,因此这种情况下的注册肯定与普通的用户注册有一定的区别。都要有哪些区别呢?其实主要在两块:

  • 一块是上篇文章说所的当用户注册完,肯定要与该第三方(即QQ、微信等)建立起关系,也就是说下次用户直接用QQ或微信等进行登陆,那肯定直接就可以登陆了。—》落实到代码上,就是用户注册完,需要同时往userconnection表里插入一条数据。
  • 第二块在于注册页面的展示与逻辑。下面是我直接在当当网进行QQ登陆时导向的注册页面,我们可以看到人家在这种情况下的注册页面里就不光有注册功能,还同时把从QQ获取到的你的头像和昵称信息给显示了出来,让你有种亲切感☺。当然下面的注册页面还包括一块逻辑是绑定,即当你已经有用户了,只是没与QQ进行过关联,所以需要绑定,说白了就是需要在userconnection表里插入一条你的用户与QQ号的关联数据,这块逻辑我会在下文进行讲解。

其实大家除了见过像当当这种直接点击QQ登陆进行登陆,由于没有进行注册或绑定过,而被导向一个用户注册页面的网站,肯定也见过那种从来就没上过的网站直接点击QQ登陆或者微信登陆,但是却可以直接登陆成功而不需要注册的网站,而且很多。那这种又是怎么弄得呢?这一块也是本文需要讲解的内容。 因此,本文将主要讲解如下三块内容:

第三方登陆时用户注册页面如何拿到QQ头像和昵称等信息 用户注册时如何将QQ与用户信息建立关联–》插入userconnection表 第三方登陆时,如何跳过注册逻辑

第三方登陆时用户注册页面如何拿到QQ头像和昵称等信息#

配置获取QQ用户信息的工具类 —ProviderSignInUtils#

上篇文章解读doAuthentication 方法时,有下面这样一段代码:

catch (BadCredentialsException e) {
// connection unknown, register new user?
if (signupUrl != null) {
// store ConnectionData in session and redirect to register page
sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));
throw new SocialAuthenticationRedirectException(buildSignupUrl(request));
}
throw e;
}

即SpringSocial/SpringSecurity在抛出跳转向/signup的重定向异常SocialAuthenticationRedirectException之前其实做了一件事,就是将从QQ获取到的Connection对象存到了session里,因此如果想在注册页面上显示QQ的头像和昵称信息,是可以从session里获取到的。springsocial其实提供了一个从session获取Connection对象的工具类,但我们需要配置一下:

ProviderSignInUtils

/**
* ProviderSignInUtils有两个作用:
* (1)从session里获取封装了第三方用户信息的Connection对象
* (2)将注册的用户信息与第三方用户信息(QQ信息)建立关系并将该关系插入到userconnection表里
*
* 需要的两个参数:
* (1)ConnectionFactoryLocator 可以直接注册进来,用来定位ConnectionFactory
* (2)UsersConnectionRepository----》getUsersConnectionRepository(connectionFactoryLocator)
* 即我们配置的用来处理userconnection表的bean
* @param connectionFactoryLocator
* @return
*/
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator) {
return new ProviderSignInUtils(connectionFactoryLocator,
getUsersConnectionRepository(connectionFactoryLocator)) {
};
}

创建获取QQ用户信息的接口#

注册页面展示QQ用户信息其实只需要头像和昵称就可以,我们可以简单封装一个实体类如下:

package com.nrsc.security.browser.beans;
import lombok.Data;
/**
* @author : Sun Chuan
* @date : 2019/9/11 23:03
* Description:展示给前端的第三方用户信息
*/
@Data
public class SocialUserInfo {
/**提供商唯一标识*/
private String providerId;
/***用户在提供商的唯一标识(其实就是openId)*/
private String providerUserId;
/**用户在提供商的昵称*/
private String nickName;
/**用户在提供商的头像*/
private String headImg;
}

获取用户信息#

则获取QQ用户信息的接口如下:

/**
* 获取第三方用户信息
*
* @param request
* @return
*/
@GetMapping("/social/user")
public SocialUserInfo getSocialUserInfo(HttpServletRequest request) {
SocialUserInfo userInfo = new SocialUserInfo();
//从session里取出封装了第三方信息QQ用户信息)的Connection对象
Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));
userInfo.setProviderId(connection.getKey().getProviderId());
userInfo.setProviderUserId(connection.getKey().getProviderUserId());
userInfo.setNickName(connection.getDisplayName());
userInfo.setHeadImg(connection.getImageUrl());
return userInfo;
}

注册页面获取QQ头像+昵称#

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>注册</title>
<script type="text/javascript" src="/js/jquery-3.4.1.min.js"></script>
</head>
<body>
<h2>Demo注册页</h2>
<img id="headImage">hi, <span id="nickName"></span> ,欢迎登陆XXX网
<hr>
<form action="/user/register" method="post">
<table>
<tr>
<td>用户名:</td>
<td><input type="text" name="username"></td>
</tr>
<tr>
<td>密码:</td>
<td><input type="password" name="password"></td>
</tr>
<tr>
<td colspan="2">
<button type="submit" name="type" value="register">注册</button>
<button type="submit" name="type" value="binding">绑定</button>
</td>
</tr>
</table>
</form>
<script type="text/javascript">
function getSocialUserInfo() {
$.get({
url: "/social/user",
type: "get",
success: function (res) {
$("#headImage").attr("src", res.headImg);
$("#nickName").text(res.nickName);
}
})
}
window.onload = getSocialUserInfo;
</script>
</body>
</html>

对注册页面获取第三方信息接口进行授权#

测试#

再次进行QQ登陆并扫码授权,进入的注册页面如下,即页面上已经获得到了QQ头像和QQ昵称信息。

用户注册时如何将QQ与用户信息建立关联–》插入userconnection表#

  • 对注册接口进行授权#

  • 处理注册逻辑+将QQ与用户信息进行关联#

@PostMapping("/register")
public void register(NrscUser user, HttpServletRequest request) {
//注册用户相关逻辑-----》即向用户表里插入一条用户数据-----》这里不写了
//不管是注册用户还是绑定用户,都会拿到一个用户唯一标识,如用户名。
String userId = user.getUsername();
//将用户userId和第三方用户信息建立关系并将其插入到userconnection表
providerSignInUtils.doPostSignUp(userId, new ServletWebRequest(request));
}

mysql里rank为关键字问题解决#

其实rank在我的mysql版本里是一个保留关键字,因此需要再将rank弄成 rank 的样子才可以。

为什么会报出这个bug呢?跟踪springsocial源码发现在执行 providerSignInUtils.doPostSignUp(userId, new ServletWebRequest(request));语句进行存储数据时,会调用JdbcConnectionRepository类里的如下方法:

@Transactional
public void addConnection(Connection<?> connection) {
try {
ConnectionData data = connection.createData();
int rank = jdbcTemplate.queryForObject("select coalesce(max(rank) + 1, 1) as rank from " + tablePrefix + "UserConnection where userId = ? and providerId = ?", new Object[]{ userId, data.getProviderId() }, Integer.class);
jdbcTemplate.update("insert into " + tablePrefix + "UserConnection (userId, providerId, providerUserId, rank, displayName, profileUrl, imageUrl, accessToken, secret, refreshToken, expireTime) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
userId, data.getProviderId(), data.getProviderUserId(), rank, data.getDisplayName(), data.getProfileUrl(), data.getImageUrl(), encrypt(data.getAccessToken()), encrypt(data.getSecret()), encrypt(data.getRefreshToken()), data.getExpireTime());
} catch (DuplicateKeyException e) {
throw new DuplicateConnectionException(connection.getKey());
}
}

追踪到该方法时,真有种不看不知道一看吓一跳的感觉,因为JdbcConnectionRepository这个类里很多方法都用到了rank字段,那操作数据库时肯定就会有很多的问题了。但是仔细分析一下可以看到该类是一个没有被public,private等字段修饰的类,即该类只允许同一个包中的类进行访问。那它在哪个包呢?其实就是JdbcUsersConnectionRepository所在的包,看下图:

再仔细一看,可以在JdbcUsersConnectionRepository类里看到如下方法

public ConnectionRepository createConnectionRepository(String userId) {
if (userId == null) {
throw new IllegalArgumentException("userId cannot be null");
}
return new JdbcConnectionRepository(userId, jdbcTemplate, connectionFactoryLocator, textEncryptor, tablePrefix);
}

即JdbcConnectionRepository类是在JdbcUsersConnectionRepository类里new出来的。那这个问题就好解决了

3.3.3 我的解决方式

  • 将数据库里的rank字段改成了sequence —》感觉尽量不要用mysql的保留关键字
  • 将org.springframework.social.connect.jdbc包下的JdbcUsersConnectionRepository类和JdbcConnectionRepository类拷贝了一份放到了我的项目目录中—》放在了com.nrsc.security.core.social.jdbc包路径下
  • 将JdbcConnectionRepository中的所有rank改成了sequence
  • 将UsersConnectionRepository换成了我自己的刚复制的NrscJdbcUsersConnectionRepository即
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
/**
* 第二个参数的作用:根据条件查找该用哪个ConnectionFactory来构建Connection对象
* 第三个参数的作用: 对插入到userconnection表中的数据进行加密和解密
*/
NrscJdbcUsersConnectionRepository repository = new NrscJdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
//设置userconnection表的前缀
repository.setTablePrefix("nrsc_");
return repository;
}

QQ免注册(自动注册)#

总结:1、实现ConnectionSignUp接口,并实现execute方法 ; 2、ConnectionSignUp具体实现类注入到JdbcUsersConnectionRepository

1 概述#

正如上文所诉,现实中,我们肯定碰到过很多网站或APP,虽然之前我们从未访问过这些网站,但是当我们用QQ或微信等进行登陆时,却可以直接登陆成功,根本不会被提醒让我们再注册一个新的用户。其实我觉得这样体验好像反而更好一些☺。

在springsocial/springsecurity的逻辑里,这要如何去实现呢?我们必须还是要从springsocial/springsecurity的源码里去探寻答案。

SocialAuthenticationProvider中的authenticate方法,其部分源码如下:

Connection<?> connection = authToken.getConnection();
//拿着Connection对象去userconnection表里去查相应的userId,由于我们的系统里暂时还没有一个用户,
//所以查到的userId肯定为null----》为null就会报出BadCredentialsException---》然后就会回到上面的类
//让你跳转到一个XXX/signup的url----》其实说白了就是引导用户进入注册用户的页面(这一块需要我们自己实现)
String userId = toUserId(connection);
if (userId == null) {
throw new BadCredentialsException("Unknown access token");
}

当时文章只是说由于我们的系统里还没有一个用户,所以查到的userId肯定为null,实际上深入该代码深处,可以看到其逻辑不止这么简单,而本篇文章标题所说的免注册的解决方法也正是隐藏于此。

这里需要注意:如果throw new BadCredentialsException(“Unknown access token”); 这个样的话,就会到 /sigin,登录页面。

  • 先看一下toUserId的具体代码
protected String toUserId(Connection<?> connection) {
//拿着Connection对象去找userIds
List<String> userIds = usersConnectionRepository.findUserIdsWithConnection(connection);
// only if a single userId is connected to this providerUserId
return (userIds.size() == 1) ? userIds.iterator().next() : null;
}
  • 再看一下findUserIdsWithConnection的具体代码(代码所在类JdbcUsersConnectionRepository):
public List<String> findUserIdsWithConnection(Connection<?> connection) {
ConnectionKey key = connection.getKey();
//通过providerId和providerUserId(openId)去userconnection表里查找useIds
List<String> localUserIds = jdbcTemplate.queryForList("select userId from " + tablePrefix + "UserConnection where providerId = ? and providerUserId = ?", String.class, key.getProviderId(), key.getProviderUserId());
//如果没找到useId(没关联过QQ肯定找不到userId)且connectionSignUp不为null
if (localUserIds.size() == 0 && connectionSignUp != null) {
//调用connectionSignUp的execute方法生成一个useId
String newUserId = connectionSignUp.execute(connection);
//如果生成成功
if (newUserId != null)
{ //则将生成的userId连同Connection对象,插入到userconnection表,即利用代码“偷偷地”注册一个用户
createConnectionRepository(newUserId).addConnection(connection);
//将生成的useId返回
return Arrays.asList(newUserId);
}
}
//走到这里说明通过providerId和providerUserId(openId)找到了useId
return localUserIds;
}

通过上面的源代码解读,我想大家肯定已经知道,只要我们在JdbcUsersConnectionRepository类里注入一个 connectionSignUp ,并实现其 execute 方法就可以让用户在进行第三方登陆的过程中,不再走注册新用户的逻辑,而直接登陆我们的网站了。

具体代码实现#

编写ConnectionSignUp的具体实现#

package com.nrsc.security.server.security;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionSignUp;
import org.springframework.stereotype.Component;
/**
* @author : Sun Chuan
* @date : 2019/9/14 23:29
* Description:ConnectionSignUp类,需要注入到JdbcUsersConnectionRepository类里,
* 以实现第三方登陆时免注册的逻辑功能
*/
@Component
public class DemoConnectionSignUp implements ConnectionSignUp {
@Override
public String execute(Connection<?> connection) {
//真实项目中: TODO
//这里其实应该向我们的用户业务表(user表)里插入一条数据
//可以将插入user数据后的主键作为userId进行返回
//新增之前,需要查询用户是否已存在。
//这里为了简单起见,我就直接将connection对象中的displayName作为useId进行返回了
return connection.getDisplayName();
}
}

将编写的ConnectionSignUp具体实现类注入到JdbcUsersConnectionRepository#

  • 这里将JdbcUsersConnectionRepository类注入到spring容器的全部代码贴出如下
package com.nrsc.security.core.social;
import com.nrsc.security.core.properties.NrscSecurityProperties;
import com.nrsc.security.core.social.jdbc.NrscJdbcUsersConnectionRepository;
import com.nrsc.security.core.social.qq.NrscSpringSocialConfigurer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.encrypt.Encryptors;
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.web.ProviderSignInUtils;
import org.springframework.social.security.SpringSocialConfigurer;
import javax.sql.DataSource;
/**
* @author : Sun Chuan
* @date : 2019/8/7 20:57
* Description:
* UsersConnectionRepository的实现类,用来拿着Connection对象查找UserConnection表中是否与之相对应的userId
* userId就是我们系统中的唯一标识,这个应该由各个系统自己根据业务去指定,比如说你系统里的username是唯一的,
* 则这个useId就可以是username
*/
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Autowired
private NrscSecurityProperties nrscSecurityProperties;
/**
* 并不一定所有的系统都会在用户进行第三方登陆时“偷偷地”给用户注册一个新用户
* 所以这里需要标明required = false
*/
@Autowired(required = false)
private ConnectionSignUp connectionSignUp;
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
/**
* 第二个参数的作用:根据条件查找该用哪个ConnectionFactory来构建Connection对象
* 第三个参数的作用: 对插入到userconnection表中的数据进行加密和解密
*/
NrscJdbcUsersConnectionRepository repository = new NrscJdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
//设置userconnection表的前缀
repository.setTablePrefix("nrsc_");
if (connectionSignUp != null) {
//如果有spring容器里connectionSignUp这个bean时,将其注入到UsersConnectionRepository
repository.setConnectionSignUp(connectionSignUp);
}
return repository;
}
/**
* 通过apply()该实例,可以将SocialAuthenticationFilter加入到spring-security的过滤器链
*/
@Bean
public SpringSocialConfigurer nrscSocialSecurityConfig() {
// 默认配置类,进行组件的组装
// 包括了过滤器SocialAuthenticationFilter 添加到security过滤链中
String filterProcessesUrl = nrscSecurityProperties.getSocial().getFilterProcessesUrl();
NrscSpringSocialConfigurer configurer = new NrscSpringSocialConfigurer(filterProcessesUrl);
//指定SpringSocial/SpringSecurity跳向注册页面时的url为我们配置的url
configurer.signupUrl(nrscSecurityProperties.getBrowser().getSignUpUrl());
return configurer;
}
/**
* ProviderSignInUtils有两个作用:
* (1)从session里获取封装了第三方用户信息的Connection对象
* (2)将注册的用户信息与第三方用户信息(QQ信息)建立关系并将该关系插入到userconnection表里
* <p>
* 需要的两个参数:
* (1)ConnectionFactoryLocator 可以直接注册进来,用来定位ConnectionFactory
* (2)UsersConnectionRepository----》getUsersConnectionRepository(connectionFactoryLocator)
* 即我们配置的用来处理userconnection表的bean
*
* @param connectionFactoryLocator
* @return
*/
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator) {
return new ProviderSignInUtils(connectionFactoryLocator,
getUsersConnectionRepository(connectionFactoryLocator)) {
};
}
}

测试#

QQ登录成功处理器和失败处理器#

源码分析:

非常重要的过滤器链 SocialAuthenticationFilter 部分源码如下。

public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private SocialAuthenticationServiceLocator authServiceLocator;
private String signupUrl = "/signup";
private String connectionAddedRedirectUrl = "/";
private boolean updateConnections = true;
private UserIdSource userIdSource;
private UsersConnectionRepository usersConnectionRepository;
private SimpleUrlAuthenticationFailureHandler delegateAuthenticationFailureHandler;
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
private String filterProcessesUrl = "/auth";
private static final String DEFAULT_FAILURE_URL = "/signin";
private static final String DEFAULT_FILTER_PROCESSES_URL = "/auth";
  • 此过滤器中只有失败处理器 SimpleUrlAuthenticationFailureHandler。

  • 由此联想,此过滤器继承的 AbstractAuthenticationProcessingFilter中有默认的成功处理器。

我们设置SocialAuthenticationFilter的时候,去覆盖对应的成功处理器和失败处理器,即完成我们想法。自定义跳转。

1、继承默认成功处理器#

/**
* <p>DESC: 成功登录处理器</p>
* <p>DATE: 2019-11-08 13:36</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class QQSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
this.getRedirectStrategy().sendRedirect(request, response, "http://www.baidu.com");
}
}

2、将我们的成功处理器加到SocialAuthenticationFilter#

/**
* <p>DESC: 覆盖后置方法,修改拦截地址</p>
* <p>DATE: 2019-11-06 16:38</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class MySpringSocialConfigurer extends SpringSocialConfigurer {
private String url;
private AuthenticationSuccessHandler handler;
public MySpringSocialConfigurer(String url, AuthenticationSuccessHandler redUrl) {
this.url = url;
this.handler = redUrl;
}
@Override
protected <T> T postProcess(T object) {
//在父类处理完SocialAuthenticationFilter之后的基础上,将其默认拦截url改成我们配置的值
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
filter.setFilterProcessesUrl(url);
filter.setAuthenticationSuccessHandler(handler);
return (T) filter;
}
}

构建自定义 config的时候,传入对应数据即可

@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
// 默认配置类,进行组件的组装
// 包括了过滤器SocialAuthenticationFilter 添加到security过滤链中
QQProperties qq = properties.getSocial().getQq();
MySpringSocialConfigurer config = new MySpringSocialConfigurer(qq.getFilterProcessesUrl(),qqSuccessHandler);
config.signupUrl(properties.getBrowser().getSignUpUrl());
return config;
}

实现微信公号登录#

相比QQ标准的oauth2开发流程,微信的有一些变化。

1、接口认证步骤又之前的 4 步变为3步

/**
* 跳转认证页面
*/
private static final String AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize";
/**
* 获取token路径
*/
private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token";
/**
* 获取用户信息
*/
private String infoUrl = "https://api.weixin.qq.com/sns/userinfo?openid=%s&lang=zh_CN";

2、social中的标准字段和微信的不匹配,例如:client_id ---》 appid

一、api实现#

1)、WeChat 接口(6步)#

注:

此处需要传入openId。原因:因为微信认证流程少了一步专门获取openid。直接在第二步获取token中就返回了openID。

/**
* <p>DESC: 微信api</p>
* <p>DATE: 2019-11-08 14:17</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface WeChat {
/**
* 获取用户信息
* @param openId openID
* @return 用户
*/
WeChatUserInfo getUserInfo(String openId);
}

2)、WeChat实现#

/**
* <p>DESC: 实现</p>
* <p>DATE: 2019-11-08 14:21</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@EqualsAndHashCode(callSuper = true)
@Data
public class WeChatImpl extends AbstractOAuth2ApiBinding implements WeChat {
/**
* 获取用户信息
*/
private String infoUrl = "https://api.weixin.qq.com/sns/userinfo?openid=%s&lang=zh_CN";
public WeChatImpl(String accessToken) {
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
}
/**
* 设置发送请求的 messageConverters
* 这里是重写的父类的方法
* @return list
*/
@Override
protected List<HttpMessageConverter<?>> getMessageConverters() {
List<HttpMessageConverter<?>> messageConverters = super.getMessageConverters();
messageConverters.remove(0);
messageConverters.add(new StringHttpMessageConverter(StandardCharsets.UTF_8));
return messageConverters;
}
/**
* 获取用户信息
* @param openId openID
* @return 自己封装的用户信息
*/
@Override
public WeChatUserInfo getUserInfo(String openId) {
String url = String.format(infoUrl,openId);
String result = getRestTemplate().getForObject(url, String.class);
WeChatUserInfo userInfo = JSON.parseObject(result, WeChatUserInfo.class);
log.info("【获取微信用户信息】={}",userInfo);
return userInfo;
}
}

3)、WeChatUserInfo 微信用户信息#

/**
* <p>DESC: 微信用户信息</p>
* <p>DATE: 2019-11-08 14:18</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class WeChatUserInfo {
private String openid;
private String nickname;
private String sex;
private String province;
private String city;
private String country;
private String headimgurl;
private String privilege;
private String unionid;
}

二、connect实现#

1)、OAuth2Template (1到5步)#

默认的OAuth2Template不满足需求,需要重写父类的方法

1、buildAuthorizeUrl 认证地址URL

2、exchangeForAccess 获取token的URL

3、postForAccessGrant 获取token接收返回参数

4、createRestTemplate,添加StringHttpMessageConverter

5、AccessGrant,标准的存储令牌类不满足要求,继承并添加需要的 openID 属性

/**
* <p>DESC: 操作</p>
* <p>DATE: 2019-11-08 15:50</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
public class WeChatOauth2Template extends OAuth2Template {
private String clientId;
private String clientSecret;
private String accessTokenUrl;
public WeChatOauth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, null, accessTokenUrl);
setUseParametersForClientAuthentication(true);
this.clientId = clientId;
this.clientSecret = clientSecret;
this.accessTokenUrl = accessTokenUrl;
}
/**
* 后续绑定微信构建认证url 要使用的方法,
* 和下面一样,需要重构
* @param parameters 参数
* @return 认证地址URL
*/
@Override
public String buildAuthorizeUrl(OAuth2Parameters parameters) {
String authorizeUrl = super.buildAuthorizeUrl(parameters);
log.info("【微信认证地址】 ={}",authorizeUrl);
String replace = authorizeUrl.replace("client_id", "appid");
replace = replace + "&scope=snsapi_userinfo#wechat_redirect";
log.info("【修复后微信认证地址】 ={}",replace);
return replace;
}
/**
* 重写构建 认证地址URL ---自定义
* @param grantType 授权类型
* @param parameters 参数
* @return 认证地址URL
*/
@Override
public String buildAuthorizeUrl(GrantType grantType, OAuth2Parameters parameters) {
String authorizeUrl = super.buildAuthorizeUrl(grantType, parameters);
log.info("【微信认证地址】 ={}",authorizeUrl);
String replace = authorizeUrl.replace("client_id", "appid");
replace = replace + "&scope=snsapi_userinfo#wechat_redirect";
log.info("【修复后微信认证地址】 ={}",replace);
return replace;
}
/**
*
* 前置交换令牌token,的方法 --- 此处需要自定义,因为微信提供接口和social有出入
* @param authorizationCode code
* @param redirectUri 重定向地址
* @param additionalParameters 参数
* @return 保存令牌的封装类
*/
@Override
public AccessGrant exchangeForAccess(String authorizationCode, String redirectUri, MultiValueMap<String, String> additionalParameters) {
StringBuilder accessTokenUrl = new StringBuilder(this.accessTokenUrl);
accessTokenUrl.append("?appid=").append(this.clientId);
accessTokenUrl.append("&secret=").append(this.clientSecret);
accessTokenUrl.append("&code=").append(authorizationCode);
accessTokenUrl.append("&grant_type=authorization_code");
return this.postForAccessGrant(String.valueOf(accessTokenUrl),null);
}
/**
* 后置组装令牌的方法 ----此处需要自定义,微信返回的参数和标准有出入
* AccessGrant 是保存令牌的封装类
* @param accessTokenUrl 获取tokenUrl
* @param parameters 参数
* @return 保存令牌的封装类
*/
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String result = getRestTemplate().getForObject(accessTokenUrl,String.class);
log.info("获取accessToke的响应:" + result);
assert result != null;
if(result.contains("errcode")){
log.error("【获取token】 失败 result={}",result);
throw new RuntimeException("获取token 失败");
}
JSONObject jsonObject = JSONObject.parseObject(result);
String accessToken = (String) jsonObject.get("access_token");
String refreshToken = (String) jsonObject.get("refresh_token");
Integer expiresIn = (Integer) jsonObject.get("expires_in");
String scope = (String) jsonObject.get("scope");
//应为微信是获取token的时候直接返回openID,所有需要另做处理
String openid = (String) jsonObject.get("openid");
return new WeChatAccessGrant(accessToken,scope,refreshToken,Long.valueOf(expiresIn),openid);
}
/**
* 修改发送 请求的 RestTemplate
* @return RestTemplate
*/
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(StandardCharsets.UTF_8));
return restTemplate;
}
}

自定义AccessGrant

/**
* <p>DESC: 自定义的AccessGrant</p>
* <p>DATE: 2019-11-08 16:02</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class WeChatAccessGrant extends AccessGrant {
private String openid;
public WeChatAccessGrant(String accessToken, String scope, String refreshToken, Long expiresIn, String openid) {
super(accessToken, scope, refreshToken, expiresIn);
this.openid = openid;
}
}

2)、OAuth2ServiceProvider#

/**
* <p>DESC: provider</p>
* <p>DATE: 2019-11-08 16:05</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class WeChatServiceProvider extends AbstractOAuth2ServiceProvider<WeChat> {
/**
* 跳转认证页面
*/
private static final String AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize";
/**
* 获取token路径
*/
private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token";
public WeChatServiceProvider(String appId,String appSecret) {
super(new WeChatOauth2Template(appId,appSecret,AUTHORIZE_URL,ACCESS_TOKEN_URL));
}
@Override
public WeChat getApi(String accessToken) {
return new WeChatImpl(accessToken);
}
}

3)、ApiAdapter#

注意 openID 在此处存放,也就意味着每个用户来,都应该对应一个自己的 ApiAdapter,保证openid不重复

创建连接工厂去构建connection的时候,都会去重写生成一个新的apiAdapter,这样保证openid唯一,多实例属性

/**
* <p>DESC: 微信API Adapter</p>
* <p>DATE: 2019-11-12 10:34</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class WeChatApiAdapter implements ApiAdapter<WeChat> {
private String openId;
/**
* 从微信api中获取用户信息,
* 然后设置标准连接的值
* @param weChat 微信api,获取用户信息
* @param connectionValues 标准连接的值
*/
@Override
public void setConnectionValues(WeChat weChat, ConnectionValues connectionValues) {
WeChatUserInfo userInfo = weChat.getUserInfo(openId);
connectionValues.setDisplayName(userInfo.getNickname());
connectionValues.setImageUrl(userInfo.getHeadimgurl());
connectionValues.setProfileUrl(null);
connectionValues.setProviderUserId(openId);
}
public WeChatApiAdapter() {
}
public WeChatApiAdapter(String openId) {
this.openId = openId;
}
@Override
public boolean test(WeChat weChat) {
return false;
}
@Override
public UserProfile fetchUserProfile(WeChat weChat) {
return null;
}
@Override
public void updateStatus(WeChat weChat, String s) {
}
}

4)、OAuth2ConnectionFactory连接工厂#

1、 AccessGrant 中有自定义的openid,需要获取出来

​ 重写父类的:extractProviderUserId

2、 为将自己的serviceProvider和apiAdapter添加到connection中

​ 覆盖父类测创建连接方法: createConnection

3、 getApiAdapter 将自己 apiAdapter 添加上

4、 getOAuth2ServiceProvider 将自己serviceProvider添加上

/**
* <p>DESC: 微信连接工厂</p>
* <p>DATE: 2019-11-12 10:36</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class WeChatConnectionFactory extends OAuth2ConnectionFactory<WeChat> {
/**
* 自己的 serviceProvider,apiAdapter
* @param providerId 服务ID
* @param appId appid
* @param appSecret secret
*/
public WeChatConnectionFactory(String providerId, String appId, String appSecret) {
super(providerId, new WeChatServiceProvider(appId, appSecret), new WeChatApiAdapter());
}
/**
* 提取服务商用户ID (openid)
* @param accessGrant 存放token令牌
* @return openID
*/
@Override
protected String extractProviderUserId(AccessGrant accessGrant) {
if (accessGrant instanceof WeChatAccessGrant) {
return ((WeChatAccessGrant) accessGrant).getOpenid();
}
return null;
}
/**
* 创建连接
* @param accessGrant 存放token令牌
* @return 返回连接
*/
@Override
public Connection<WeChat> createConnection(AccessGrant accessGrant) {
return new OAuth2Connection<>(getProviderId(), extractProviderUserId(accessGrant), accessGrant.getAccessToken(),
accessGrant.getRefreshToken(), accessGrant.getExpireTime(), getOAuth2ServiceProvider(), getApiAdapter(extractProviderUserId(accessGrant)));
}
/**
* 创建连接
* @param data 连接数据
* @return 返回连接
*/
@Override
public Connection<WeChat> createConnection(ConnectionData data) {
return new OAuth2Connection(data, getOAuth2ServiceProvider(), getApiAdapter(data.getProviderUserId()));
}
/**
* 拼装ApiAdapter 讲 openID 塞进去。
* @param providerUserId openid
* @return ApiAdapter
*/
private ApiAdapter<WeChat> getApiAdapter(String providerUserId) {
return new WeChatApiAdapter(providerUserId);
}
/**
* 获取 serviceProvider
* @return serviceProvider
*/
private OAuth2ServiceProvider<WeChat> getOAuth2ServiceProvider() {
return (OAuth2ServiceProvider<WeChat>) getServiceProvider();
}
}

三、config实现#

WeChatAutoConfig(创建工厂)#

/**
* <p>DESC: 微信自动配置类</p>
* <p>DATE: 2019-11-12 10:41</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
public class WeChatAutoConfig extends SocialAutoConfigurerAdapter {
@Resource
private MySecurityProperties properties;
@Override
protected ConnectionFactory<?> createConnectionFactory() {
WeChatProperties wechat = properties.getSocial().getWechat();
return new WeChatConnectionFactory(wechat.getProviderId(),wechat.getAppId(),wechat.getAppSecret());
}
}

SocialAutoConfigurerAdapter 是自己写的抽象类

public abstract class SocialAutoConfigurerAdapter extends SocialConfigurerAdapter {
public SocialAutoConfigurerAdapter() {
}
@Override
public void addConnectionFactories(ConnectionFactoryConfigurer configurer, Environment environment) {
configurer.addConnectionFactory(this.createConnectionFactory());
}
protected abstract ConnectionFactory<?> createConnectionFactory();
}

绑定和解绑#

场景:#

绑定和解绑是在用户已经登陆的情况下(即用户信息已经存在于session中),对QQ,微信等进行绑定和解绑

这里简单畅想一下我说的这种场景的绑定的实现步骤:

  • 在进入绑定页面前session里已经存了封装了QQ信息的Connection对象
  • 输入用户名+密码后,点击绑定按钮
  • 点击绑定按钮后第一个要做的事其实是用户名+密码登陆
  • 登陆成功后应该在走登陆成功事件之前从session里取出Connection对象和用户名或用户id
  • 利用ProviderSignInUtils工具类将Connection对象+用户名或用户id建立关系并插入到userConnection表 —》完成绑定工作

ConnectController#

ConnectController是springsocial为我们提供的一个专门用于处理绑定和解绑的Controller类。在该类里主要定义了如下几个方法:

  • 获取当前用户所有第三方账号的绑定状态的方法
  • 将当前用户与第三方账号进行绑定的方法
  • 解绑的方法

controller 方法解读#

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package org.springframework.social.connect.web;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.GenericTypeResolver;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionFactory;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.ConnectionKey;
import org.springframework.social.connect.ConnectionRepository;
import org.springframework.social.connect.DuplicateConnectionException;
import org.springframework.social.connect.support.OAuth1ConnectionFactory;
import org.springframework.social.connect.support.OAuth2ConnectionFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.view.RedirectView;
import org.springframework.web.util.UrlPathHelper;
@Controller
@RequestMapping({"/connect"})
public class ConnectController implements InitializingBean {
private static final Log logger = LogFactory.getLog(ConnectController.class);
private final ConnectionFactoryLocator connectionFactoryLocator;
private final ConnectionRepository connectionRepository;
private final MultiValueMap<Class<?>, ConnectInterceptor<?>> connectInterceptors = new LinkedMultiValueMap();
private final MultiValueMap<Class<?>, DisconnectInterceptor<?>> disconnectInterceptors = new LinkedMultiValueMap();
private ConnectSupport connectSupport;
private final UrlPathHelper urlPathHelper = new UrlPathHelper();
private String viewPath = "connect/";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
private String applicationUrl = null;
protected static final String DUPLICATE_CONNECTION_ATTRIBUTE = "social_addConnection_duplicate";
protected static final String PROVIDER_ERROR_ATTRIBUTE = "social_provider_error";
protected static final String AUTHORIZATION_ERROR_ATTRIBUTE = "social_authorization_error";
@Inject
public ConnectController(ConnectionFactoryLocator connectionFactoryLocator, ConnectionRepository connectionRepository) {
this.connectionFactoryLocator = connectionFactoryLocator;
this.connectionRepository = connectionRepository;
}
/** @deprecated */
@Deprecated
public void setInterceptors(List<ConnectInterceptor<?>> interceptors) {
this.setConnectInterceptors(interceptors);
}
public void setConnectInterceptors(List<ConnectInterceptor<?>> interceptors) {
Iterator var2 = interceptors.iterator();
while(var2.hasNext()) {
ConnectInterceptor<?> interceptor = (ConnectInterceptor)var2.next();
this.addInterceptor(interceptor);
}
}
public void setDisconnectInterceptors(List<DisconnectInterceptor<?>> interceptors) {
Iterator var2 = interceptors.iterator();
while(var2.hasNext()) {
DisconnectInterceptor<?> interceptor = (DisconnectInterceptor)var2.next();
this.addDisconnectInterceptor(interceptor);
}
}
public void setApplicationUrl(String applicationUrl) {
this.applicationUrl = applicationUrl;
}
public void setViewPath(String viewPath) {
this.viewPath = viewPath;
}
public void setSessionStrategy(SessionStrategy sessionStrategy) {
this.sessionStrategy = sessionStrategy;
}
public void addInterceptor(ConnectInterceptor<?> interceptor) {
Class<?> serviceApiType = GenericTypeResolver.resolveTypeArgument(interceptor.getClass(), ConnectInterceptor.class);
this.connectInterceptors.add(serviceApiType, interceptor);
}
public void addDisconnectInterceptor(DisconnectInterceptor<?> interceptor) {
Class<?> serviceApiType = GenericTypeResolver.resolveTypeArgument(interceptor.getClass(), DisconnectInterceptor.class);
this.disconnectInterceptors.add(serviceApiType, interceptor);
}
//查看所有绑定状态的控制器
@RequestMapping(
method = {RequestMethod.GET}
)
public String connectionStatus(NativeWebRequest request, Model model) {
this.setNoCache(request);
this.processFlash(request, model);
Map<String, List<Connection<?>>> connections = this.connectionRepository.findAllConnections();
model.addAttribute("providerIds", this.connectionFactoryLocator.registeredProviderIds());
model.addAttribute("connectionMap", connections);
return this.connectView();
}
//绑定应用回调controler
@RequestMapping(
value = {"/{providerId}"},
method = {RequestMethod.GET}
)
public String connectionStatus(@PathVariable String providerId, NativeWebRequest request, Model model) {
this.setNoCache(request);
this.processFlash(request, model);
List<Connection<?>> connections = this.connectionRepository.findConnections(providerId);
this.setNoCache(request);
if (connections.isEmpty()) {
return this.connectView(providerId);
} else {
model.addAttribute("connections", connections);
return this.connectedView(providerId);
}
}
//post 请求绑定指定应用的方法
@RequestMapping(
value = {"/{providerId}"},
method = {RequestMethod.POST}
)
public RedirectView connect(@PathVariable String providerId, NativeWebRequest request) {
ConnectionFactory<?> connectionFactory = this.connectionFactoryLocator.getConnectionFactory(providerId);
MultiValueMap<String, String> parameters = new LinkedMultiValueMap();
this.preConnect(connectionFactory, parameters, request);
try {
return new RedirectView(this.connectSupport.buildOAuthUrl(connectionFactory, request, parameters));
} catch (Exception var6) {
this.sessionStrategy.setAttribute(request, "social_provider_error", var6);
return this.connectionStatusRedirect(providerId, request);
}
}
@RequestMapping(
value = {"/{providerId}"},
method = {RequestMethod.GET},
params = {"oauth_token"}
)
public RedirectView oauth1Callback(@PathVariable String providerId, NativeWebRequest request) {
try {
OAuth1ConnectionFactory<?> connectionFactory = (OAuth1ConnectionFactory)this.connectionFactoryLocator.getConnectionFactory(providerId);
Connection<?> connection = this.connectSupport.completeConnection(connectionFactory, request);
this.addConnection(connection, connectionFactory, request);
} catch (Exception var5) {
this.sessionStrategy.setAttribute(request, "social_provider_error", var5);
logger.warn("Exception while handling OAuth1 callback (" + var5.getMessage() + "). Redirecting to " + providerId + " connection status page.");
}
return this.connectionStatusRedirect(providerId, request);
}
@RequestMapping(
value = {"/{providerId}"},
method = {RequestMethod.GET},
params = {"code"}
)
public RedirectView oauth2Callback(@PathVariable String providerId, NativeWebRequest request) {
try {
OAuth2ConnectionFactory<?> connectionFactory = (OAuth2ConnectionFactory)this.connectionFactoryLocator.getConnectionFactory(providerId);
Connection<?> connection = this.connectSupport.completeConnection(connectionFactory, request);
this.addConnection(connection, connectionFactory, request);
} catch (Exception var5) {
this.sessionStrategy.setAttribute(request, "social_provider_error", var5);
logger.warn("Exception while handling OAuth2 callback (" + var5.getMessage() + "). Redirecting to " + providerId + " connection status page.");
}
return this.connectionStatusRedirect(providerId, request);
}
@RequestMapping(
value = {"/{providerId}"},
method = {RequestMethod.GET},
params = {"error"}
)
public RedirectView oauth2ErrorCallback(@PathVariable String providerId, @RequestParam("error") String error, @RequestParam(value = "error_description",required = false) String errorDescription, @RequestParam(value = "error_uri",required = false) String errorUri, NativeWebRequest request) {
Map<String, String> errorMap = new HashMap();
errorMap.put("error", error);
if (errorDescription != null) {
errorMap.put("errorDescription", errorDescription);
}
if (errorUri != null) {
errorMap.put("errorUri", errorUri);
}
this.sessionStrategy.setAttribute(request, "social_authorization_error", errorMap);
return this.connectionStatusRedirect(providerId, request);
}
@RequestMapping(
value = {"/{providerId}"},
method = {RequestMethod.DELETE}
)
public RedirectView removeConnections(@PathVariable String providerId, NativeWebRequest request) {
ConnectionFactory<?> connectionFactory = this.connectionFactoryLocator.getConnectionFactory(providerId);
this.preDisconnect(connectionFactory, request);
this.connectionRepository.removeConnections(providerId);
this.postDisconnect(connectionFactory, request);
return this.connectionStatusRedirect(providerId, request);
}
@RequestMapping(
value = {"/{providerId}/{providerUserId}"},
method = {RequestMethod.DELETE}
)
public RedirectView removeConnection(@PathVariable String providerId, @PathVariable String providerUserId, NativeWebRequest request) {
ConnectionFactory<?> connectionFactory = this.connectionFactoryLocator.getConnectionFactory(providerId);
this.preDisconnect(connectionFactory, request);
this.connectionRepository.removeConnection(new ConnectionKey(providerId, providerUserId));
this.postDisconnect(connectionFactory, request);
return this.connectionStatusRedirect(providerId, request);
}
/*
* 创建视图的方法(查看绑定状态的视图)
*/
protected String connectView() {
return this.getViewPath() + "status";
}
/*
* 创建视图的方法(解绑成功视图)
*/
protected String connectView(String providerId) {
return this.getViewPath() + providerId + "Connect";
}
/*
* 创建视图的方法(绑定成功的视图)
*/
protected String connectedView(String providerId) {
return this.getViewPath() + providerId + "Connected";
}
protected RedirectView connectionStatusRedirect(String providerId, NativeWebRequest request) {
HttpServletRequest servletRequest = (HttpServletRequest)request.getNativeRequest(HttpServletRequest.class);
String path = "/connect/" + providerId + this.getPathExtension(servletRequest);
if (this.prependServletPath(servletRequest)) {
path = servletRequest.getServletPath() + path;
}
return new RedirectView(path, true);
}
public void afterPropertiesSet() throws Exception {
this.connectSupport = new ConnectSupport(this.sessionStrategy);
if (this.applicationUrl != null) {
this.connectSupport.setApplicationUrl(this.applicationUrl);
}
}
private boolean prependServletPath(HttpServletRequest request) {
return !this.urlPathHelper.getPathWithinServletMapping(request).equals("");
}
private String getPathExtension(HttpServletRequest request) {
String fileName = this.extractFullFilenameFromUrlPath(request.getRequestURI());
String extension = StringUtils.getFilenameExtension(fileName);
return extension != null ? "." + extension : "";
}
private String extractFullFilenameFromUrlPath(String urlPath) {
int end = urlPath.indexOf(63);
if (end == -1) {
end = urlPath.indexOf(35);
if (end == -1) {
end = urlPath.length();
}
}
int begin = urlPath.lastIndexOf(47, end) + 1;
int paramIndex = urlPath.indexOf(59, begin);
end = paramIndex != -1 && paramIndex < end ? paramIndex : end;
return urlPath.substring(begin, end);
}
private String getViewPath() {
return this.viewPath;
}
private void addConnection(Connection<?> connection, ConnectionFactory<?> connectionFactory, WebRequest request) {
try {
this.connectionRepository.addConnection(connection);
this.postConnect(connectionFactory, connection, request);
} catch (DuplicateConnectionException var5) {
this.sessionStrategy.setAttribute(request, "social_addConnection_duplicate", var5);
}
}
private void preConnect(ConnectionFactory<?> connectionFactory, MultiValueMap<String, String> parameters, WebRequest request) {
Iterator var4 = this.interceptingConnectionsTo(connectionFactory).iterator();
while(var4.hasNext()) {
ConnectInterceptor interceptor = (ConnectInterceptor)var4.next();
interceptor.preConnect(connectionFactory, parameters, request);
}
}
private void postConnect(ConnectionFactory<?> connectionFactory, Connection<?> connection, WebRequest request) {
Iterator var4 = this.interceptingConnectionsTo(connectionFactory).iterator();
while(var4.hasNext()) {
ConnectInterceptor interceptor = (ConnectInterceptor)var4.next();
interceptor.postConnect(connection, request);
}
}
private void preDisconnect(ConnectionFactory<?> connectionFactory, WebRequest request) {
Iterator var3 = this.interceptingDisconnectionsTo(connectionFactory).iterator();
while(var3.hasNext()) {
DisconnectInterceptor interceptor = (DisconnectInterceptor)var3.next();
interceptor.preDisconnect(connectionFactory, request);
}
}
private void postDisconnect(ConnectionFactory<?> connectionFactory, WebRequest request) {
Iterator var3 = this.interceptingDisconnectionsTo(connectionFactory).iterator();
while(var3.hasNext()) {
DisconnectInterceptor interceptor = (DisconnectInterceptor)var3.next();
interceptor.postDisconnect(connectionFactory, request);
}
}
private List<ConnectInterceptor<?>> interceptingConnectionsTo(ConnectionFactory<?> connectionFactory) {
Class<?> serviceType = GenericTypeResolver.resolveTypeArgument(connectionFactory.getClass(), ConnectionFactory.class);
List<ConnectInterceptor<?>> typedInterceptors = (List)this.connectInterceptors.get(serviceType);
if (typedInterceptors == null) {
typedInterceptors = Collections.emptyList();
}
return typedInterceptors;
}
private List<DisconnectInterceptor<?>> interceptingDisconnectionsTo(ConnectionFactory<?> connectionFactory) {
Class<?> serviceType = GenericTypeResolver.resolveTypeArgument(connectionFactory.getClass(), ConnectionFactory.class);
List<DisconnectInterceptor<?>> typedInterceptors = (List)this.disconnectInterceptors.get(serviceType);
if (typedInterceptors == null) {
typedInterceptors = Collections.emptyList();
}
return typedInterceptors;
}
private void processFlash(WebRequest request, Model model) {
this.convertSessionAttributeToModelAttribute("social_addConnection_duplicate", request, model);
this.convertSessionAttributeToModelAttribute("social_provider_error", request, model);
model.addAttribute("social_authorization_error", this.sessionStrategy.getAttribute(request, "social_authorization_error"));
this.sessionStrategy.removeAttribute(request, "social_authorization_error");
}
private void convertSessionAttributeToModelAttribute(String attributeName, WebRequest request, Model model) {
if (this.sessionStrategy.getAttribute(request, attributeName) != null) {
model.addAttribute(attributeName, Boolean.TRUE);
this.sessionStrategy.removeAttribute(request, attributeName);
}
}
private void setNoCache(NativeWebRequest request) {
HttpServletResponse response = (HttpServletResponse)request.getNativeResponse(HttpServletResponse.class);
if (response != null) {
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 1L);
response.setHeader("Cache-Control", "no-cache");
response.addHeader("Cache-Control", "no-store");
}
}
}

这个Controller有如下几个特点

(1)该Controller里的方法只提供了数据,并没有提供视图,比如说下面的方法为获取当前用户所有第三方账号绑定状态的Controller方法,它虽然向Model对象里写了数据并指定了返回视图的名称 —》connect/status,但并没提供该视图。

@RequestMapping(method=RequestMethod.GET)
public String connectionStatus(NativeWebRequest request, Model model) {
setNoCache(request);
processFlash(request, model);
Map<String, List<Connection<?>>> connections = connectionRepository.findAllConnections();
//将providerIds放到model中---Set<String>
model.addAttribute("providerIds", connectionFactoryLocator.registeredProviderIds());
//将Connection放到model中
model.addAttribute("connectionMap", connections);
//下面返回视图名其实是connect/status----但是springsocial并没有提供该名称的视图
//需要我们自己写代码实现该视图,并返回给前端特定的数据
return connectView();
}

(2) 该Controller提供的方法都是基于session的 。

(3) 该Controller提供的方法如解绑貌似只能用form请求

基于以上3个特点,其实我感觉在真实的项目里,为了灵活应对项目需求,我们完全可以模仿这个ConnectController来写一个适用于我们的项目的Controller类来处理绑定和解绑的业务

获取当前用户所有第三方账号的绑定状态#

当前用户所有第三方账号绑定状态绑定状态的处理视图#

如上所说,其实ConnectController提供的获取当前用户所有第三方账号绑定状态的方法已经为我们封装好了数据,我们只要写一个视图,将数据接收并返回给浏览器就好了,示例代码如下:

@Component("connect/status")
@Slf4j
public class NrscConnectionStatusView extends AbstractView {
@Autowired
private ObjectMapper objectMapper;
@Override
protected void renderMergedOutputModel(Map<String, Object> map, HttpServletRequest request,
HttpServletResponse response) throws Exception {
//取出所有的Connection对象
Map<String, List<Connection<?>>> connections = (Map<String, List<Connection<?>>>) map.get("connectionMap");
//取出所有的providerId
Set<String> providerIds = (Set<String>) map.get("providerIds");
log.info("providerIds:{}",providerIds);
//封装当前用户的第三方账号绑定状态 key为providerId , value为true或false
Map<String, Boolean> result = new HashMap<>();
for (String key : connections.keySet()) {
result.put(key, CollectionUtils.isNotEmpty(connections.get(key)));
}
//将当前用户的第三方账号绑定状态返回给浏览器
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(ResultVOUtil.success(result)));
}
}

测试:

结果:

{
"wechat": false,
"callback.do": false
}

将当前用户与第三方账号进行绑定#

需要一个post请求,请求url为/connect/{providerId},以微信为例页面可按照如下方式发送请求

<form action="/connect/weixin" method="post">
<button type="submit">绑定微信</button>
</form>

当ConnectController接收到该post请求后会拼接一个重定向到微信授权页面的url,并根据该url重定向到微信授权页面。用户在授权页面进行扫码授权后微信会利用url中的redirect_uri(回调地址)发送一个get请求回调我们的项目(接收这个回调的Controller也在ConnectController中,有兴趣的可以跟一下源码)—》 接着我们的项目会再去请求微信获取微信用户信息并将其封装成一个Connection对象 —》 然后将Connection对象和session中的用户信息进行关联,并将该关联关系存到userconnection表里(其实这一步已经完成了绑定) —》 接着调用一个视图,视图名为connect/+providerId+Connected(当然spingsocial没提供该视图)。

注:

这里可能会发生参数错误,原因是构建认证URL参数调用的方法是:buildAuthorizeUrl 需要手动修改

/**
* 后续绑定微信构建认证url 要使用的方法,
* 和下面一样,需要重构
* @param parameters 参数
* @return 认证地址URL
*/
@Override
public String buildAuthorizeUrl(OAuth2Parameters parameters) {
String authorizeUrl = super.buildAuthorizeUrl(parameters);
log.info("【微信认证地址】 ={}",authorizeUrl);
String replace = authorizeUrl.replace("client_id", "appid");
replace = replace + "&scope=snsapi_userinfo#wechat_redirect";
log.info("【修复后微信认证地址】 ={}",replace);
return replace;
}

下面提供一种各个第三方账号统一使用一个绑定和解绑逻辑的方法#

  • 绑定成功和绑定失败的处理逻辑
public class SuccessFailView extends AbstractView {
@Resource
private ObjectMapper objectMapper;
@Override
protected void renderMergedOutputModel(Map<String, Object> map, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType("application/json;charset=UTF-8");
//如果map(Model对象)里有Connection对象,则表示绑定,否则表示解绑
if (map.get("connections") == null) {
response.getWriter().write(objectMapper.writeValueAsString(ResponseEntity.ok().body("解绑成功")));
} else {
response.getWriter().write(objectMapper.writeValueAsString(ResponseEntity.ok().body("绑定成功")));
}
}
}
  • 视图配置
@Configuration
public class ViewConfig {
/***
* connect/wechatConnected 绑定成功的视图
* connect/wechatConnect 解绑成功的视图
*
* 两个视图可以写在一起,通过判断Model对象里有没有Connection对象来确定究竟是解绑还是绑定
*/
@Bean({"connect/wechatConnected", "connect/wechatConnected"})
//下面的注解的意思是当程序里有名字为weixinConnectedView的bean
// 我写的默认的weixinConnectedView这个bean不会生效,也就是你可以写一个更好的bean来覆盖掉我的
@ConditionalOnMissingBean(name = "weixinConnectedView")
public View weixinConnectedView() {
return new SuccessFailView();
}
}

测试成功#

插入表中的数据 userId是 用户名,怎么能自定义呢?

SocialConfiguration 类中,在调用这个方法

this.userIdSource().getUserId() 这个属性可以在之前的,自己导入的 SocialWebAutoConfiguration中进行修改

@Bean
@Scope(
value = "request",
proxyMode = ScopedProxyMode.INTERFACES
)
public ConnectionRepository connectionRepository(UsersConnectionRepository usersConnectionRepository) {
return usersConnectionRepository.createConnectionRepository(this.userIdSource().getUserId());
}

SocialWebAutoConfiguration 部分代码如下:

public class SocialWebAutoConfiguration {
public SocialWebAutoConfiguration() {
}
private static class SecurityContextUserIdSource implements UserIdSource {
private SecurityContextUserIdSource() {
}
@Override
public String getUserId() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
Assert.state(authentication != null, "Unable to get a ConnectionRepository: no user signed in");
//可以自定义
return authentication.getName();
}
}

QQ绑定 — redirect uri is illegal(100010)错误分析#

在进行完微信绑定之后,我试着写了QQ绑定的代码,发现点击QQ绑定时又出现了 redirect uri is illegal(100010)错误,追踪源代码发现在进行QQ绑定时拼接的跳向QQ授权页面的url如下:

https://graph.qq.com/oauth2.0/authorize?client_id=100550231&response_type=code&redirect_uri=http%3A%2F%2Fwww.pinzhi365.com%2Fconnect%2Fcallback.do&state=f0a0f313-310a-4c31-8365-2e903740445c

即redirect_uri解码后其实为 :http://www.pinzhi365.com/connect/callback.do ,但是对于QQ来说,QQ互联上明确说了,其回调域名并不是简单的/www.pinzhi365.com而是http://www.pinzhi365.com/connect/callback.do 这样一整串 — 》 具体规则可以参考我的文章《springsocial/oauth2—第三方登陆之QQ登陆4【redirect uri is illegal(100010)错误解决方式】》,当然也可以直接参考QQ互联官网。

但是这个项目在QQ上配置的redirect_uri为http://www.pinzhi365.com/qqLogin/callback.do,因此报redirect uri is illegal(100010)错误就很好理解了,其实大家可以试一下将QQ绑定拼接的url中的redirect_uri换成http://www.pinzhi365.com/qqLogin/callback.do是可以跳转到QQ授权页面的,但是这样QQ回调就无法回调到ConnectController里的方法了。

那该怎么解决这个问题呢?我觉得有如下两种方法:

自己徒手写代码实现绑定的逻辑 在QQ互联上多配置一个redirect_uri —》 下图是QQ互联上关于域名配置的介绍,可以看到redirect_uri 是可以配置多个的。

到这里不知道大家会不会想那微信为啥没有这个问题呢??? 其实很简单,因为微信中让用户指定的不是完整的回调地址,而是域名因此无论是下面这种地址,

https://open.weixin.qq.com/connect/qrconnect?client_id=wxd99431bbff8305a0&response_type=code&redirect_uri=http%3A%2F%2Fwww.pinzhi365.com%2Fconnect%2Fweixin&state=ebb12821-f2bd-4903-b609-9403f429e2ac&appid=wxd99431bbff8305a0&scope=snsapi_login 还是 https://open.weixin.qq.com/connect/qrconnect?client_id=wxd99431bbff8305a0&response_type=code&redirect_uri=http%3A%2F%2Fwww.pinzhi365.com%2Fconnect1111111111111%2Fweixin&state=ebb12821-f2bd-4903-b609-9403f429e2ac&appid=wxd99431bbff8305a0&scope=snsapi_login 都是可以访问到微信的授权页面的。

解绑#

绑貌似比较简单,只需要发送一个url为/connect/{provideId}的delete请求就可以了

<form action="/connect/weixin" method="post">
<input id="method" type="hidden" name="_method" value="delete"/>
<button>微信解绑</button>
</form>

session管理#

1、springboot项目session超时时间设置#

springboot项目session超时时间设置很简单,只需要在yml或properties文件里进行配置一下就可以了,我现在用的springboot的版本是2.1.7.RELEASE,在yml里配置如下

server:
servlet:
session:
timeout: 600 # session超时时间为600秒

注意1:早一点的springboot版本如1.5.6.RELEASE的配置可能如下:

server:
session:
timeout: 600 # session超时时间为600秒

注意2:如果设置的超时时间不满一分钟,将按一分钟来算,超过1分钟才按照你设置的超时时间来算。

  • springboot的版本为2.1.7.RELEASE的可以参看TomcatServletWebServerFactory这个类里对session-timeout的控制
  • springboot的版本为1.5.6.RELEASE的可以参看TomcatEmbeddedServletContainerFactory这个类

2、 springsecurity下如何通知用户session超时#

在之前实现的代码里,是没对用户session超时做过特殊处理的,因此当session超时的时候会走之前写的用户认证失败的逻辑,即

访问url里以.html结尾时重定向到登陆页 不以.html结尾时则返回一个json字符串 那如果是session超时的情况下,如何给用户一个特殊的提示,告诉他们是因为session超时引起的认证、校验失败呢?需要如下三步2.1 在配置文件

2.1、BrowserSecurityConfig里加上session超时跳向的url#

.sessionManagement() //session超时管理
.invalidSessionUrl("/session/invalid") //session超时跳向的url

2.2 在配置文件BrowserSecurityConfig里为指定session超时跳向的url授权(也就是该接口不走授权)#

2.3 写一个controller方法来处理session超时的提示逻辑#

/***
* session超时的时候会请求该方法
* @return
*/
@GetMapping("/session/invalid")
public ResultVO sessionInvalid(){
return ResultVOUtil.error(ResultEnum.SESSION_INVALID.getCode(), ResultEnum.SESSION_INVALID.getMessage());
}

3、关闭session管理#

设置无状态模式

.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.
csrf().disable()
//.addFilterAt(new MyUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> {
form
.loginProcessingUrl("/test/login/req")
.successHandler(formLoginSuccessHandler)
.failureHandler(formLoginFailHandler)
;
})
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
.antMatchers(
"/swagger-ui.html/**", "/swagger-ui.html/**", "/webjars/**", "/swagger-resources/**", "/v2/**", "/csrf").permitAll()
.anyRequest().authenticated();
;
}

springsecurity中session的并发控制#

1 是什么?#

以腾讯视频会员为例,假如我的账号买了会员,则我可以享受看视频免广告,某些热门电视剧提前看等福利。若我的朋友也想享受这些福利又不想花钱买会员,则我可以让他用我的号登陆。但是假如我有很多很多的朋友都不想买会员,都想用我的号登陆,那腾讯肯定感觉自己就亏了,所以腾讯视频最大可同时登陆的设备是有数量限制的,超过这个限制,前面的账户就会被踢下来,这就是所谓的session并发控制。 springsecurity在解决该问题时有两种策略

超过设置的最大session并发数量时,把之前的session失效掉,即踢掉前面的登陆 超过设置的最大session并发数量时,阻止后面的登陆

2 、超过最大并发数量时,踢掉前面的登陆#

在配置文件BrowserSecurityConfig里指定最大的并发数量

//session相关的控制
.sessionManagement()
//指定session超时跳向的url
.invalidSessionUrl("/session/invalid")
//指定最大的session并发数量---即一个用户只能同时在一处登陆(腾讯视频的账号好像就只能同时允许2-3个手机同时登陆)
.maximumSessions(1)
//超过最大session并发数量时的策略
.expiredSessionStrategy(new NRSCExpiredSessionStrategy())

超过最大session并发数量时的策略

package com.nrsc.security.browser.session;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nrsc.security.enums.ResultEnum;
import com.nrsc.security.utils.ResultVOUtil;
import com.nrsc.security.vo.ResultVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* @author : Sun Chuan
* @date : 2019/9/21 17:14
* Description:
* 如果设置的session并发策略为一个账户第二次登陆会将第一次给踢下来
* 则第一次登陆的用户再访问我们的项目时会进入到该类
* event里封装了request、response信息
*/
@Slf4j
public class NRSCExpiredSessionStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
String header = event.getRequest().getHeader("user-agent");
log.info("浏览器信息为:{}", header);
//告诉前端并发登陆异常
event.getResponse().setContentType("application/json;charset=UTF-8");
event.getResponse().getWriter().write("并发登陆!!!");
}
}

3 超过最大并发数量时,阻止后面的登陆#

//session相关的控制
.sessionManagement()
//指定session超时跳向的url
.invalidSessionUrl("/session/invalid")
//指定最大的session并发数量---即一个用户只能同时在一处登陆(腾讯视频的账号好像就只能同时允许2-3个手机同时登陆)
.maximumSessions(1)
//当超过指定的最大session并发数量时,阻止后面的登陆(感觉貌似很少会用到这种策略)
.maxSessionsPreventsLogin(true)
//超过最大session并发数量时的策略
.expiredSessionStrategy(new NRSCExpiredSessionStrategy())

springsecurity借助redis完成session集群管理#

1 spring-session+redis配置#

  • 首先需要在pom.xml里再引入如下两个依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  • 其次需要在yml或properties文件里指定存储session信息的为redis、并配置redis的数据库信息
spring:
### 指定用什么存储session信息---可选项可参看源码StoreType枚举类
session:
store-type: redis
###本地环境下不配置也可以
## redis:
## host: 127.0.0.1
## port: 6379
## password: 123
## database: 0

其实完成了以上配置,我们的项目就已经完成了借助redis进行session集群管理的开发。 但是这里必须要指出的一点是存放到redis里的对象,必须是要序列化的,即必须实现 Serializable 接口。

再强调一次:存到redis里的对象必须是序列化的即实现了Serializable 接口 — 其属性如果是一个对象的话,也必须实现了Serializable 接口。

2 ImageCode序列化报错问题#

启动我们的项目,访问登陆页发现图形验证码显示不出来,后端报如下错误: 验证码显示不出的效果

后端报错信息 org.springframework.data.redis.serializer.SerializationException: Cannot serialize; nested exception is org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer; nested exception is java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [com.nrsc.security.core.validate.code.image.ImageCode]

原因(非常重要)

由于我们项目里ImageCode对象没有实现Serializable 接口,且该类里有一个属性 — BufferedImage对象,该对象也没有实现Serializable 接口,因此才会报出序列化错误

再次强调:存到redis里的对象必须是序列化的即实现了Serializable 接口 — 其属性如果是一个对象的话,也必须实现了Serializable 接口。

spring-security退出登陆#

1 spring-security默认退出处理逻辑#

Spring Security的退出登陆功能由LogoutFilter过滤器拦截处理,默认的退出登陆url为 /logout。

在退出登陆时会做如下几件事:

  • 使当前session失效
  • 清除当前用户的remember-me记录
  • 清空当前的SecurityContext
  • 重定向到登录界面

2 自定义退出登陆的一些处理逻辑#

在配置文件里自定义退出登陆的一些处理逻辑

.and()
//退出登陆相关的逻辑
.logout()
//自定义退出的url---默认的为/logout
.logoutUrl("/signOut")
//自定义退出成功处理器
.logoutSuccessHandler(logoutSuccessHandler)
//自定义退出成功后跳转的url与logoutSuccessHandler互斥
//.logoutSuccessUrl("/index")
//指定退出成功后删除的cookie
.deleteCookies("JSESSIONID")

不要忘记对涉及到的URL进行授权

//配置不用进行认证校验的url
.antMatchers(
SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE,
nrscSecurityProperties.getBrowser().getLoginPage(),
SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX + "/*",
nrscSecurityProperties.getBrowser().getSignUpUrl(),
//session失效默认的跳转地址
nrscSecurityProperties.getBrowser().getSession().getSessionInvalidUrl(),
//获取第三方账号的用户信息的默认url
SecurityConstants.DEFAULT_GET_SOCIAL_USERINFO_URL,
//退出登陆默认跳转的url
nrscSecurityProperties.getBrowser().getSignOutUrl(),
"/user/register",
"/js/**"
)
.permitAll()

自定义的退出成功处理器

package com.nrsc.security.browser.logout;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nrsc.security.utils.ResultVOUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.transform.Result;
import java.io.IOException;
/**
* @author : Sun Chuan
* @date : 2019/9/22 12:00
* Description:退出成功处理器
*/
@Slf4j
public class NRSCLogoutSuccessHandler implements LogoutSuccessHandler {
/**
* 退出登陆url
* 可以在yml或properties文件里通过nrsc.security.browser.signOutUrl 进行指定
* 我指定的默认值为"/" --- 因为如果不指定一个默认的url时,配置授权那一块会报错
*/
private String signOutSuccessUrl;
private ObjectMapper objectMapper = new ObjectMapper();
public NRSCLogoutSuccessHandler(String signOutSuccessUrl) {
this.signOutSuccessUrl = signOutSuccessUrl;
}
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
log.info("退出成功");
//如果没有指定退出成功的页面则返回前端一个json字符串
if (StringUtils.equalsIgnoreCase("/",signOutSuccessUrl)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(ResultVOUtil.success("退出成功")));
} else {
//重定向到退出成功登陆页面
response.sendRedirect(signOutSuccessUrl);
}
}
}

SpringCloud Security(下)#

Spring Security 架构与源码分析#

Spring Security 主要实现了Authentication(认证,解决who are you? ) 和 Access Control(访问控制,也就是what are you allowed to do?,也称为Authorization)。Spring Security在架构上将认证与授权分离,并提供了扩展点。

核心对象#

主要代码在spring-security-core包下面。要了解Spring Security,需要先关注里面的核心对象。

  • SecurityContextHolder
  • SecurityContext
  • Authentication

SecurityContextHolder#

SecurityContextHolder 是 SecurityContext的存放容器,默认使用ThreadLocal 存储,意味SecurityContext在相同线程中的方法都可用。 SecurityContext主要是存储应用的principal信息,在Spring Security中用Authentication 来表示。

获取principal:

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

Authentication#

在Spring Security中,可以看一下Authentication定义:

public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 通常是密码
*/
Object getCredentials();
/**
* Stores additional details about the authentication request. These might be an IP
* address, certificate serial number etc.
*/
Object getDetails();
/**
* 用来标识是否已认证,如果使用用户名和密码登录,通常是用户名
*/
Object getPrincipal();
/**
* 是否已认证
*/
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

Token#

在实际应用中,通常使用UsernamePasswordAuthenticationToken

//抽象token
public abstract class AbstractAuthenticationToken implements Authentication,
CredentialsContainer {
}
//UsernamePasswordAuthenticationToken
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
}

认证流程#

一个常见的认证过程通常是这样的,创建一个UsernamePasswordAuthenticationToken,然后交给authenticationManager认证(后面详细说明),认证通过则通过SecurityContextHolder存放Authentication信息。

UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginVM.getUsername(), loginVM.getPassword());
Authentication authentication = this.authenticationManager.authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);

UserDetails与UserDetailsService#

UserDetails 是Spring Security里的一个关键接口,他用来表示一个principal。

public interface UserDetails extends Serializable {
/**
* 用户的授权信息,可以理解为角色
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 用户密码
*
* @return the password
*/
String getPassword();
/**
* 用户名
* */
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}

UserDetails提供了认证所需的必要信息,在实际使用里,可以自己实现UserDetails,并增加额外的信息,比如email、mobile等信息。

在Authentication中的principal通常是用户名,我们可以通过UserDetailsService来通过principal获取UserDetails:

public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

GrantedAuthority#

在UserDetails里说了,GrantedAuthority可以理解为角色,例如 ROLE_ADMINISTRATOR or ROLE_HR_SUPERVISOR

小结#

  • SecurityContextHolder, 用来访问 SecurityContext.
  • SecurityContext, 用来存储Authentication .
  • Authentication, 代表凭证.
  • GrantedAuthority, 代表权限.
  • UserDetails, 用户信息.
  • UserDetailsService,获取用户信息.

Authentication认证#

AuthenticationManager#

实现认证主要是通过AuthenticationManager接口,它只包含了一个方法:

public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}

authenticate()方法主要做三件事:

  1. 如果验证通过,返回Authentication(通常带上authenticated=true)。
  2. 认证失败抛出AuthenticationException
  3. 如果无法确定,则返回null

AuthenticationException是运行时异常,它通常由应用程序按通用方式处理,用户代码通常不用特意被捕获和处理这个异常。

AuthenticationManager的默认实现是ProviderManager,它委托一组AuthenticationProvider实例来实现认证。

AuthenticationProviderAuthenticationManager类似,都包含authenticate,但它有一个额外的方法supports,以允许查询调用方是否支持给定Authentication类型:

public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
boolean supports(Class<?> authentication);
}

ProviderManager包含一组AuthenticationProvider,执行authenticate时,遍历Providers,然后调用supports,如果支持,则执行遍历当前provider的authenticate方法,如果一个provider认证成功,则break。

public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
Authentication result = null;
boolean debug = logger.isDebugEnabled();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (debug) {
logger.debug("Authentication attempt using "
+ provider.getClass().getName());
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException e) {
prepareException(e, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw e;
}
catch (InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
}
catch (AuthenticationException e) {
lastException = e;
}
}
if (result == null && parent != null) {
// Allow the parent to try.
try {
result = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException e) {
lastException = e;
}
}
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
eventPublisher.publishAuthenticationSuccess(result);
return result;
}
// Parent was null, or didn't authenticate (or throw an exception).
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}
prepareException(lastException, authentication);
throw lastException;
}

从上面的代码可以看出, ProviderManager有一个可选parent,如果parent不为空,则调用parent.authenticate(authentication)

AuthenticationProvider#

AuthenticationProvider有多种实现,大家最关注的通常是DaoAuthenticationProvider,继承于AbstractUserDetailsAuthenticationProvider,核心是通过UserDetails来实现认证,DaoAuthenticationProvider默认会自动加载,不用手动配。

先来看AbstractUserDetailsAuthenticationProvider,看最核心的authenticate

public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// 必须是UsernamePasswordAuthenticationToken
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
// 获取用户名
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();
boolean cacheWasUsed = true;
// 从缓存获取
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
// retrieveUser 抽象方法,获取用户
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");
if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}
Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}
try {
// 预先检查,DefaultPreAuthenticationChecks,检查用户是否被lock或者账号是否可用
preAuthenticationChecks.check(user);
// 抽象方法,自定义检验
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
}
// 后置检查 DefaultPostAuthenticationChecks,检查isCredentialsNonExpired
postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}

上面的检验主要基于UserDetails实现,其中获取用户和检验逻辑由具体的类去实现,默认实现是DaoAuthenticationProvider,这个类的核心是让开发者提供UserDetailsService来获取UserDetails以及 PasswordEncoder来检验密码是否有效:

private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;

看具体的实现,retrieveUser,直接调用userDetailsService获取用户:

protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
UserDetails loadedUser;
try {
loadedUser = this.getUserDetailsService().loadUserByUsername(username);
}
catch (UsernameNotFoundException notFound) {
if (authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
passwordEncoder.isPasswordValid(userNotFoundEncodedPassword,
presentedPassword, null);
}
throw notFound;
}
catch (Exception repositoryProblem) {
throw new InternalAuthenticationServiceException(
repositoryProblem.getMessage(), repositoryProblem);
}
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}

再来看验证:

protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
Object salt = null;
if (this.saltSource != null) {
salt = this.saltSource.getSalt(userDetails);
}
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
// 获取用户密码
String presentedPassword = authentication.getCredentials().toString();
// 比较passwordEncoder后的密码是否和userdetails的密码一致
if (!passwordEncoder.isPasswordValid(userDetails.getPassword(),
presentedPassword, salt)) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}

小结:要自定义认证,使用DaoAuthenticationProvider,只需要为其提供PasswordEncoder和UserDetailsService就可以了。

定制 Authentication Managers#

Spring Security提供了一个Builder类AuthenticationManagerBuilder,借助它可以快速实现自定义认证。

看官方源码说明:

SecurityBuilder used to create an AuthenticationManager . Allows for easily building in memory authentication, LDAP authentication, JDBC based authentication, adding UserDetailsService , and adding AuthenticationProvider's.

AuthenticationManagerBuilder可以用来Build一个AuthenticationManager,可以创建基于内存的认证、LDAP认证、 JDBC认证,以及添加UserDetailsService和AuthenticationProvider。

简单使用:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class ApplicationSecurity extends WebSecurityConfigurerAdapter {
public SecurityConfiguration(AuthenticationManagerBuilder authenticationManagerBuilder, UserDetailsService userDetailsService,TokenProvider tokenProvider,CorsFilter corsFilter, SecurityProblemSupport problemSupport) {
this.authenticationManagerBuilder = authenticationManagerBuilder;
this.userDetailsService = userDetailsService;
this.tokenProvider = tokenProvider;
this.corsFilter = corsFilter;
this.problemSupport = problemSupport;
}
@PostConstruct
public void init() {
try {
authenticationManagerBuilder
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
} catch (Exception e) {
throw new BeanInitializationException("Security configuration failed", e);
}
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(problemSupport)
.accessDeniedHandler(problemSupport)
.and()
.csrf()
.disable()
.headers()
.frameOptions()
.disable()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/register").permitAll()
.antMatchers("/api/activate").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/account/reset-password/init").permitAll()
.antMatchers("/api/account/reset-password/finish").permitAll()
.antMatchers("/api/profile-info").permitAll()
.antMatchers("/api/**").authenticated()
.antMatchers("/management/health").permitAll()
.antMatchers("/management/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/v2/api-docs/**").permitAll()
.antMatchers("/swagger-resources/configuration/ui").permitAll()
.antMatchers("/swagger-ui/index.html").hasAuthority(AuthoritiesConstants.ADMIN)
.and()
.apply(securityConfigurerAdapter());
}
}

授权与访问控制#

一旦认证成功,我们可以继续进行授权,授权是通过AccessDecisionManager来实现的。框架有三种实现,默认是AffirmativeBased,通过AccessDecisionVoter决策,有点像ProviderManager委托给AuthenticationProviders来认证。

public void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;
// 遍历DecisionVoter
for (AccessDecisionVoter voter : getDecisionVoters()) {
// 投票
int result = voter.vote(authentication, object, configAttributes);
if (logger.isDebugEnabled()) {
logger.debug("Voter: " + voter + ", returned: " + result);
}
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
// 一票否决
if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}

来看AccessDecisionVoter:

boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);
int vote(Authentication authentication, S object,
Collection<ConfigAttribute> attributes);

object是用户要访问的资源,ConfigAttribute则是访问object要满足的条件,通常payload是字符串,比如ROLE_ADMIN 。所以我们来看下RoleVoter的实现,其核心就是从authentication提取出GrantedAuthority,然后和ConfigAttribute比较是否满足条件。

public boolean supports(ConfigAttribute attribute) {
if ((attribute.getAttribute() != null)
&& attribute.getAttribute().startsWith(getRolePrefix())) {
return true;
}
else {
return false;
}
}
public boolean supports(Class<?> clazz) {
return true;
}
public int vote(Authentication authentication, Object object,
Collection<ConfigAttribute> attributes) {
if(authentication == null) {
return ACCESS_DENIED;
}
int result = ACCESS_ABSTAIN;
// 获取GrantedAuthority信息
Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);
for (ConfigAttribute attribute : attributes) {
if (this.supports(attribute)) {
// 默认拒绝访问
result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : authorities) {
// 判断是否有匹配的 authority
if (attribute.getAttribute().equals(authority.getAuthority())) {
// 可访问
return ACCESS_GRANTED;
}
}
}
}
return result;
}

这里要疑问,ConfigAttribute哪来的?其实就是上面ApplicationSecurity的configure里的。

web security 如何实现#

Web层中的Spring Security(用于UI和HTTP后端)基于Servlet Filters,下图显示了单个HTTP请求的处理程序的典型分层。

Spring Security通过FilterChainProxy作为单一的Filter注册到web层,Proxy内部的Filter。

FilterChainProxy相当于一个filter的容器,通过VirtualFilterChain来依次调用各个内部filter

public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
if (clearContext) {
try {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
doFilterInternal(request, response, chain);
}
finally {
SecurityContextHolder.clearContext();
request.removeAttribute(FILTER_APPLIED);
}
}
else {
doFilterInternal(request, response, chain);
}
}
private void doFilterInternal(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FirewalledRequest fwRequest = firewall
.getFirewalledRequest((HttpServletRequest) request);
HttpServletResponse fwResponse = firewall
.getFirewalledResponse((HttpServletResponse) response);
List<Filter> filters = getFilters(fwRequest);
if (filters ** null || filters.size() ** 0) {
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(fwRequest)
+ (filters == null ? " has no matching filters"
: " has an empty filter list"));
}
fwRequest.reset();
chain.doFilter(fwRequest, fwResponse);
return;
}
VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
vfc.doFilter(fwRequest, fwResponse);
}
private static class VirtualFilterChain implements FilterChain {
private final FilterChain originalChain;
private final List<Filter> additionalFilters;
private final FirewalledRequest firewalledRequest;
private final int size;
private int currentPosition = 0;
private VirtualFilterChain(FirewalledRequest firewalledRequest,
FilterChain chain, List<Filter> additionalFilters) {
this.originalChain = chain;
this.additionalFilters = additionalFilters;
this.size = additionalFilters.size();
this.firewalledRequest = firewalledRequest;
}
public void doFilter(ServletRequest request, ServletResponse response)
throws IOException, ServletException {
if (currentPosition == size) {
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
+ " reached end of additional filter chain; proceeding with original chain");
}
// Deactivate path stripping as we exit the security filter chain
this.firewalledRequest.reset();
originalChain.doFilter(request, response);
}
else {
currentPosition++;
Filter nextFilter = additionalFilters.get(currentPosition - 1);
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
+ " at position " + currentPosition + " of " + size
+ " in additional filter chain; firing Filter: '"
+ nextFilter.getClass().getSimpleName() + "'");
}
nextFilter.doFilter(request, response, this);
}
}
}

spring security实现动态配置url权限的两种方法#

标准的RABC, 权限需要支持动态配置,spring security默认是在代码里约定好权限,真实的业务场景通常需要可以支持动态配置角色访问权限,即在运行时去配置url对应的访问角色。

基于spring security,如何实现这个需求呢?

最简单的方法就是自定义一个Filter去完成权限判断,但这脱离了spring security框架,如何基于spring security优雅的实现呢?

spring security 授权回顾#

spring security 通过FilterChainProxy作为注册到web的filter,FilterChainProxy里面一次包含了内置的多个过滤器,我们首先需要了解spring security内置的各种filter:

AliasFilter ClassNamespace Element or Attribute
CHANNEL_FILTERChannelProcessingFilterhttp/intercept-url@requires-channel
SECURITY_CONTEXT_FILTERSecurityContextPersistenceFilterhttp
CONCURRENT_SESSION_FILTERConcurrentSessionFiltersession-management/concurrency-control
HEADERS_FILTERHeaderWriterFilterhttp/headers
CSRF_FILTERCsrfFilterhttp/csrf
LOGOUT_FILTERLogoutFilterhttp/logout
X509_FILTERX509AuthenticationFilterhttp/x509
PRE_AUTH_FILTERAbstractPreAuthenticatedProcessingFilter SubclassesN/A
CAS_FILTERCasAuthenticationFilterN/A
FORM_LOGIN_FILTERUsernamePasswordAuthenticationFilterhttp/form-login
BASIC_AUTH_FILTERBasicAuthenticationFilterhttp/http-basic
SERVLET_API_SUPPORT_FILTERSecurityContextHolderAwareRequestFilterhttp/@servlet-api-provision
JAAS_API_SUPPORT_FILTERJaasApiIntegrationFilterhttp/@jaas-api-provision
REMEMBER_ME_FILTERRememberMeAuthenticationFilterhttp/remember-me
ANONYMOUS_FILTERAnonymousAuthenticationFilterhttp/anonymous 匿名认证过滤器
SESSION_MANAGEMENT_FILTERSessionManagementFiltersession-management
EXCEPTION_TRANSLATION_FILTERExceptionTranslationFilterhttp
FILTER_SECURITY_INTERCEPTORFilterSecurityInterceptorhttp 安全拦截器
SWITCH_USER_FILTERSwitchUserFilterN/A

最重要的是FilterSecurityInterceptor,该过滤器实现了主要的鉴权逻辑,最核心的代码在这里:

protected InterceptorStatusToken beforeInvocation(Object object) {
// 获取访问URL所需权限 默认从config中获取
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
Authentication authenticated = authenticateIfRequired();
// 通过accessDecisionManager鉴权,投票选出通过还是不通过
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
//认证失败,异常捕获
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
accessDeniedException));
throw accessDeniedException;
}
if (debug) {
logger.debug("Authorization successful");
}
if (publishAuthorizationSuccess) {
publishEvent(new AuthorizedEvent(object, attributes, authenticated));
}
// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object,
attributes);
if (runAs == null) {
if (debug) {
logger.debug("RunAsManager did not change Authentication object");
}
// no further work post-invocation
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false,
attributes, object);
}
else {
if (debug) {
logger.debug("Switching to RunAs Authentication: " + runAs);
}
SecurityContext origCtx = SecurityContextHolder.getContext();
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
SecurityContextHolder.getContext().setAuthentication(runAs);
// need to revert to token.Authenticated post-invocation
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
}

从上面可以看出,要实现动态鉴权,可以从两方面着手:

  • 自定义SecurityMetadataSource,实现从数据库加载ConfigAttribute
  • 另外就是可以自定义accessDecisionManager,官方的UnanimousBased其实足够使用,并且他是基于AccessDecisionVoter来实现权限认证的,因此我们只需要自定义一个AccessDecisionVoter就可以了

下面来看分别如何实现。

自定义AccessDecisionManager#

官方的三个AccessDecisionManager都是基于AccessDecisionVoter来实现权限认证的,因此我们只需要自定义一个AccessDecisionVoter就可以了。

自定义主要是实现AccessDecisionVoter接口,我们可以仿照官方的RoleVoter实现一个:

public class RoleBasedVoter implements AccessDecisionVoter<Object> {
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
if(authentication == null) {
return ACCESS_DENIED;
}
int result = ACCESS_ABSTAIN;
Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);
for (ConfigAttribute attribute : attributes) {
if(attribute.getAttribute()==null){
continue;
}
if (this.supports(attribute)) {
result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : authorities) {
if (attribute.getAttribute().equals(authority.getAuthority())) {
return ACCESS_GRANTED;
}
}
}
}
return result;
}
Collection<? extends GrantedAuthority> extractAuthorities(
Authentication authentication) {
return authentication.getAuthorities();
}
@Override
public boolean supports(Class clazz) {
return true;
}
}

如何加入动态权限呢?

vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes)里的Object object的类型是FilterInvocation,可以通过getRequestUrl获取当前请求的URL:

FilterInvocation fi = (FilterInvocation) object;
String url = fi.getRequestUrl();

因此这里扩展空间就大了,可以从DB动态加载,然后判断URL的ConfigAttribute就可以了。

如何使用这个RoleBasedVoter呢?在configure里使用accessDecisionManager方法自定义,我们还是使用官方的UnanimousBased,然后将自定义的RoleBasedVoter加入即可。

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(problemSupport)
.accessDeniedHandler(problemSupport)
.and()
.csrf()
.disable()
.headers()
.frameOptions()
.disable()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 自定义accessDecisionManager
.accessDecisionManager(accessDecisionManager())
.and()
.apply(securityConfigurerAdapter());
}
@Bean
public AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<? extends Object>> decisionVoters
= Arrays.asList(
new WebExpressionVoter(),
// new RoleVoter(),
new RoleBasedVoter(),
new AuthenticatedVoter());
return new UnanimousBased(decisionVoters);
}

自定义SecurityMetadataSource#

自定义FilterInvocationSecurityMetadataSource只要实现接口即可,在接口里从DB动态加载规则。

为了复用代码里的定义,我们可以将代码里生成的SecurityMetadataSource带上,在构造函数里传入默认的FilterInvocationSecurityMetadataSource。

public class AppFilterInvocationSecurityMetadataSource implements org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource {
private FilterInvocationSecurityMetadataSource superMetadataSource;
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
public AppFilterInvocationSecurityMetadataSource(FilterInvocationSecurityMetadataSource expressionBasedFilterInvocationSecurityMetadataSource){
this.superMetadataSource = expressionBasedFilterInvocationSecurityMetadataSource;
// TODO 从数据库加载权限配置
}
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
// 这里的需要从DB加载
private final Map<String,String> urlRoleMap = new HashMap<String,String>(){{
put("/open/**","ROLE_ANONYMOUS");
put("/health","ROLE_ANONYMOUS");
put("/restart","ROLE_ADMIN");
put("/demo","ROLE_USER");
}};
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
FilterInvocation fi = (FilterInvocation) object;
String url = fi.getRequestUrl();
for(Map.Entry<String,String> entry:urlRoleMap.entrySet()){
if(antPathMatcher.match(entry.getKey(),url)){
return SecurityConfig.createList(entry.getValue());
}
}
// 返回代码定义的默认配置
return superMetadataSource.getAttributes(object);
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}

怎么使用?和accessDecisionManager不一样,ExpressionUrlAuthorizationConfigurer 并没有提供set方法设置FilterSecurityInterceptorFilterInvocationSecurityMetadataSource,how to do?

发现一个扩展方法withObjectPostProcessor,通过该方法自定义一个处理FilterSecurityInterceptor类型的ObjectPostProcessor就可以修改FilterSecurityInterceptor

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(problemSupport)
.accessDeniedHandler(problemSupport)
.and()
.csrf()
.disable()
.headers()
.frameOptions()
.disable()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 自定义FilterInvocationSecurityMetadataSource
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(
O fsi) {
fsi.setSecurityMetadataSource(mySecurityMetadataSource(fsi.getSecurityMetadataSource()));
return fsi;
}
})
.and()
.apply(securityConfigurerAdapter());
}
@Bean
public AppFilterInvocationSecurityMetadataSource mySecurityMetadataSource(FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource) {
AppFilterInvocationSecurityMetadataSource securityMetadataSource = new AppFilterInvocationSecurityMetadataSource(filterInvocationSecurityMetadataSource);
return securityMetadataSource;
}

本文介绍了两种基于spring security实现动态权限的方法,一是自定义accessDecisionManager,二是自定义FilterInvocationSecurityMetadataSource。实际项目里可以根据需要灵活选择

消化security 框架源码—重点#

一、认证流程#

1、基于表单登陆认证所要经过的类#

下面将对各个类进行解析

1.1 SecurityContextPersistenceFilter#

请求进来时,检查session,如果有SecurityContext(已认证用户对象的一个封装类)拿出来放到线程里即SecurityContextHolder中,返回时校验SecurityContextHolder中是否有securityContext,有则放入session,从而实现认证信息在多个请求中共享。

1.2 AbstractAuthenticationProcessingFilter★★★----模版模式#

我觉得AbstractAuthenticationProcessingFilter是理解表单表单登陆认证原理最重要的一个类 其部分源码和注释如下:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!requiresAuthentication(request, response)) {//如果不需要认证直接放行
chain.doFilter(request, response);
return;
}
if (logger.isDebugEnabled()) {logger.debug("Request is to process authentication");}
Authentication authResult;
try {//走到这里说明用户没有进行登陆认证,而下面的方法就是去尝试进行登陆认证
authResult = attemptAuthentication(request, response);
if (authResult == null) {return;}
sessionStrategy.onAuthentication(authResult, request, response);
}
catch (InternalAuthenticationServiceException failed) {//认证失败走unsuccessfulAuthentication
logger.error("An internal error occurred while trying to authenticate the user.",failed);
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {//认证失败走unsuccessfulAuthentication
unsuccessfulAuthentication(request, response, failed);
return;
}
// Authentication success-->应该是如果走到这,发现不需要再走successfulAuthentication方法了也直接放行
if (continueChainBeforeSuccessfulAuthentication) {chain.doFilter(request, response);}
//认证成功后需要做的事
successfulAuthentication(request, response, chain, authResult);
}

我们再看一下如果认证成功后具体干了什么事 ,successfulAuthentication源码如下:

protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ authResult);
}
//将认证后的结果放入到SecurityContextHolder中的SecurityContext中
SecurityContextHolder.getContext().setAuthentication(authResult);
//与记住我功能相关认证方式------之后的博客中应该会有涉及
rememberMeServices.loginSuccess(request, response, authResult);
// Fire event----还没具体研究是干什么的
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
//执行登陆成功后的逻辑-----即实现了AuthenticationSuccessHandler接口的类中定义的逻辑
successHandler.onAuthenticationSuccess(request, response, authResult);
}

1.2小结:#

通过上面的解读可以知道AbstractAuthenticationProcessingFilter这个类定义了认证的整个整体框架,但是具体的认证过程、认证成功后的行为和认证失败后的行为需要其他类进行具体实现,该类的伪代码如下

1.3 UsernamePasswordAuthenticationFilter算不上一个真正的Filter#

按照上述分析,要想进行登陆认证,必然会有一个类继承AbstractAuthenticationProcessingFilter类,并实现其attemptAuthentication抽象方法。而UsernamePasswordAuthenticationFilter正是用户名、密码登录认证过程中的这个类,因此实际上UsernamePasswordAuthenticationFilter并不是一个Filter,它只是继承了AbstractAuthenticationProcessingFilter并实现了其attemptAuthentication抽象方法。 UsernamePasswordAuthenticationFilter关键源代码:

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 username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
//利用用户名和密码组装成一个未校验的token
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
//拿着用户名和密码组成的token进行校验,注意返回值为Authentication
//同时注意AuthenticationManager来自父类AbstractAuthenticationProcessingFilter
return this.getAuthenticationManager().authenticate(authRequest);
}

1.3 小结#

UsernamePasswordAuthenticationFilter很简单,主要做了三件事:

继承AbstractAuthenticationProcessingFilter,并实现attemptAuthentication方法,准备进行认证工作; 利用用户名和密码组装成一个未校验的token 利用父类提供的AuthenticationManager进行实际地认证工作生成未认证的token的源代码,这里需要注意的是返回值UsernamePasswordAuthenticationToken实际上是一个Authentication,它继承了AbstractAuthenticationToken,而AbstractAuthenticationToken实现了Authentication接口

public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}

1.4 AuthenticationManager、ProviderManager和AuthenticationProvider#

其实AuthenticationManager为一个接口,它里面只有一个抽象方法authenticate;

ProviderManager为AuthenticationManager的一个实现类,它实现了AuthenticationManager的authenticate方法,但是它并不是直接去进行登录认证,而是去循环所有的AuthenticationProvider;

那AuthenticationProvider又是干什么的呢?其实它也是一个接口,里面包含两个抽象方法

public interface AuthenticationProvider {
//登陆认证
Authentication authenticate(Authentication authentication) throws AuthenticationException;
//判断传入的token是否为支持的认证类型
boolean supports(Class<?> authentication);
}

1.5 AbstractUserDetailsAuthenticationProvider和DaoAuthenticationProvider—模板模式#

AbstractUserDetailsAuthenticationProvider类为用户名和密码方式登陆最后选择出的进行登陆校验的实际执行类,主要的校验逻辑如下(我对源代码进行了删减):

public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
try {
//获取用户信息
user = retrieveUser(username,(UsernamePasswordAuthenticationToken) authentication);
}catch (UsernameNotFoundException notFound) {}
try {
//预检查---四个权限中的三个是否为false
preAuthenticationChecks.check(user);
//用户名密码是否正确
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}}catch (UsernameNotFoundException notFound) {}
//后检查---四个权限中的最后一个是否为false
postAuthenticationChecks.check(user);
//都校验通过后创建一个校验成功后的Authentication对象
return createSuccessAuthentication(principalToReturn, authentication, user);
}

检验成功的Authentication创建源码:

protected Authentication createSuccessAuthentication(Object principal,
Authentication authentication, UserDetails user) {
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
principal, authentication.getCredentials(),
authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
return result;

其实AbstractUserDetailsAuthenticationProvider的设计很像AbstractAuthenticationProcessingFilter,它里面也定义了一个主要的框架,但retrieveUser、additionalAuthenticationChecks方法的具体实现都在DaoAuthenticationProvider中,当然AbstractUserDetailsAuthenticationProvider里面还有一些方法的实现在其他类中。

1.5 UserDetailsService和NRSCDetailsService#

首先来看一下DaoAuthenticationProvider中关于retrieveUser方法的具体实现:

try {
//调用UserDetailsService的loadUserByUsername方法获取用户详情信息
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}

由此我们可以定位到UserDetailsService其实是一个接口,它里面只有一个方法loadUserByUsername,当我们实现了该接口时spring-security就会调用我们实现的接口,进行用户的信息校验,这在前面的文章-spring-security入门2—自定义用户认证逻辑部分进行过讲解。

2、总结#

上面的内容已经对表单登陆认证的源码进行了比较全面的剖析,为了能更好地与前面几篇文章串起来,我将文章开篇展示的图片进行了如下标注,以求能更好的串联最近所学知识点。

二、授权流程#

1、流程示意图#

接下来看一下授权管理在spring security整个架构中所处的位置:

2 、权限表达式#

2.1 什么是权限表达式?#

首先来看一下,下面的配置:

上面的配置是我们之前代码中的配置,图中第一个适配器 — antMatchers因为调用了permitAll()方法—>访问它所包含的那些url就不需要认证+授权; 第二个适配器因为调用了hasRole()方法 —> 只有有ADMIN权限的用户才能访问它包含的url.那其具体实现原理是什么呢?跟踪spring security源码时可以很容易发现其秘密,原来如果某个适配器(antMatchers)调用了permitAll()方法,则将该适配器包含的所有URL的权限指定为一个字符串permitAll;如果某个适配器(antMatchers)调用了hasRole(“AAA”)方法,则将该适配器包含的所有URL的权限指定为一个字符串hasRole(ROLE_AAA);如果某个适配器(anyRequest也是一个适配器,表示所有请求)调用了authenticated()方法,则将该适配器包含的所有URL的权限指定为一个字符串authenticated. 等等等 然后spring security会将项目里所有的url与这些权限字符串放到一个大Map里 —> Map的key为服务器中的各个url,value为permitAll、hasRole(ROLE_AAA)、authenticated等权限字符串的集合. 如下图所示:

当服务器接收到一个请求后,spring security会拿着当前请求去遍历上面所说得url为key,权限字符串集合为value的Map —> 以此找到当前访问URL所需要的用户权限.

一言以蔽之,权限表达式就是一个个标识URL所需访问权限的字符串如permitAll、hasRole(ROLE_AAA)、authenticated等,我们可以在适配器(antMatchers/anyRequest)后调用相应的方法为某个或某些URL指定相应的权限字符串.

2.2 权限表达式总结#

除了permitAll、hasRole(ROLE_AAA)、authenticated三个权限表达式(权限字符串)之外,其实还有好多,总结如下:

注意: hasRole()方法生成权限表达式的源码如下:

private static String hasRole(String role) {
Assert.notNull(role, "role cannot be null");
if (role.startsWith("ROLE_")) {
throw new IllegalArgumentException("role should not start with 'ROLE_' since it is automatically inserted. Got '" + role + "'");
} else {
return "hasRole('ROLE_" + role + "')";
}
}

因此如果指定了一个URL的访问权限为hasRole(“ADMIN”)时,该用户应该配置的权限为ROLE_ADMIN

如果使用hasAuthority和hasAnyAuthority就会不加ROLE_ 前缀,直接使用即可。

2.3 如何联合使用权限表达式#

.antMatchers("/user/*").access("hasRole('ROLE_ADMIN') and hasIpAddress('127.0.0.1')")

剥离权限表达式#

  • AuthorizeConfigManager 统一管理所有 AuthorizeConfigProvider
  • AuthorizeConfigProvider 针对每个模块都有自己的实现

AuthorizeConfigProvider#

/**
* <p>DESC: 授权Provider</p>
* <p>DATE: 2019-11-15 13:31</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface AuthorizeConfigProvider {
/**
* 配置
* @param registry 实则对应 WebSecurityConfigurerAdapter configure 中的authorizeRequests()返回对象
*/
void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry);
}

provider 实现类

/**
* <p>DESC: provider实现</p>
* <p>DATE: 2019-11-15 13:34</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class MyAuthorizeConfigProvider implements AuthorizeConfigProvider {
@Resource
private MySecurityProperties properties;
@Override
public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) {
registry.antMatchers(
properties.getBrowser().getDefaultLoginHtml(),
properties.getBrowser().getLoginPage(),
properties.getBrowser().getLoginProcessingUrl(),
properties.getBrowser().getImageCode(),
properties.getBrowser().getSmsCodeValid(),
properties.getBrowser().getSignUpUrl()
).permitAll();
}
}

AuthorizeConfigManager#

/**
* <p>DESC: provider管理</p>
* <p>DATE: 2019-11-15 13:37</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface AuthorizeConfigManager {
/**
* 配置
* @param config 实则对应 WebSecurityConfigurerAdapter configure 中的authorizeRequests()返回对象
*/
void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config);
}

manager实现

/**
* <p>DESC: </p>
* <p>DATE: 2019-11-15 13:37</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class MyAuthorizeConfigManager implements AuthorizeConfigManager {
@Resource
private Set<AuthorizeConfigProvider> providers;
@Override
public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
providers.forEach( provider ->{
//遍历我们所有的Provider,并配置
provider.config(config);
});
//除了Provider以外的都需要权限
config.anyRequest().authenticated();
}
}

使我们的配置生效:#

@Component
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter //配置类中
//注入
@Resource
private AuthorizeConfigManager authorizeConfigManager;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.............;
//传入请求
authorizeConfigManager.config(http.authorizeRequests());
}

对接我们自己RBAC和spring security#

前置知识;#

.anyRequest().access("@rbacService.hasPermission(request,authentication)");

access中可以写入权限表达式,也可以指定方法(自定义校验)。

官方文档如下:

Referring to Beans in Web Security Expressions#

If you wish to extend the expressions that are available, you can easily refer to any Spring Bean you expose. For example, assuming you have a Bean with the name of webSecurity that contains the following method signature:

public class WebSecurity {
public boolean check(Authentication authentication, HttpServletRequest request) {
...
}
}

You could refer to the method using:

http
.authorizeRequests()
.antMatchers("/user/**").access("@webSecurity.check(authentication,request)")
...
Path Variables in Web Security Expressions#

At times it is nice to be able to refer to path variables within a URL. For example, consider a RESTful application that looks up a user by id from the URL path in the format /user/{userId}.

You can easily refer to the path variable by placing it in the pattern. For example, if you had a Bean with the name of webSecurity that contains the following method signature:

public class WebSecurity {
public boolean checkUserId(Authentication authentication, int id) {
...
}
}

You could refer to the method using:

http
.authorizeRequests()
.antMatchers("/user/{userId}/**").access("@webSecurity.checkUserId(authentication,#userId)")

实际使用:#

自定义接口,判断是否有权限

/**
* <p>DESC: 判断接口是否通过</p>
* <p>DATE: 2019-11-15 14:15</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface RbacService {
boolean hasPermission(HttpServletRequest request, Authentication authentication);
}

校验权限实现类

/**
* <p>DESC: </p>
* <p>DATE: 2019-11-15 14:16</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component(value = "rbacService")
public class RbacServiceImpl implements RbacService {
private AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
String requestUrl = request.getRequestURI();
Object principal = authentication.getPrincipal();
AtomicBoolean hasPermission = new AtomicBoolean(false);
//用户信息才做判断
if(principal instanceof UserDetails){
String username = ((UserDetails) principal).getUsername();
//查询数据库,获取用户信息,用户角色,用户可以访问的权限
//这里假设是set
Set<String> set = new HashSet<>();
set.forEach(e ->{
if(antPathMatcher.match(e,requestUrl)){
hasPermission.set(true);
}
});
}
return hasPermission.get();
}
}

自定义校验逻辑加入security

/**
* <p>DESC: </p>
* <p>DATE: 2019-11-15 13:52</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
@Order(2)
public class DomeProviderImpl implements AuthorizeConfigProvider {
@Override
public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) {
registry
.antMatchers("/test").hasAuthority("admin")
.antMatchers(
"/user/info",
"/user/register",
"/auth/wechat"
).permitAll()
//扩展校验请求 @ + 实例名 + 方法名(参数,会自动注入不用管)
.anyRequest().access("@rbacService.hasPermission(request,authentication)");
}
}

问题一:

.anyRequest() 只能出现一次,将前面的.anyRequest()注释掉

问题二:

.antMatchers 加载问题,.anyRequest() 必须要最后加入。

也就是说Provider 存在加载问题,需要我们将 Provider 上加上注解@Order(2) 加载顺序改变一下。

注意:

manager中的存放Provider的集合,一定要用list有序集合,不可用set;

自定义无权访问处理器#

http
.authorizeRequests()
.anyRequest().authenticated()
.and()
//无权限用户提示字符串消息设置
.exceptionHandling()
// getAccessDeniedHandler()是上文的方法
.accessDeniedHandler(getAccessDeniedHandler())

点进去看源码,我们只需要实现AccessDeniedHandler ,并加入到配置中即可。

实际操作#

/**
* <p>DESC: 访问拒绝处理器</p>
* <p>DATE: 2019-11-15 15:38</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.getWriter().write("{无权访问资源}");
System.out.println(e);
}
}

这里我配置在剥离的Provider中

/**
* <p>DESC: </p>
* <p>DATE: 2019-11-15 13:52</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
@Order(2)
public class DomeProviderImpl implements AuthorizeConfigProvider {
@Resource
private MyAccessDeniedHandler myAccessDeniedHandler;
@Override
public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) {
try {
registry
.antMatchers("/test").hasAuthority("admin")
.antMatchers(
"/user/info",
"/user/register",
"/auth/wechat"
).permitAll()
//扩展校验请求
.anyRequest().access("@rbacService.hasPermission(request,authentication)")
//我在在这里----------添加拒绝访问处理器
.and().exceptionHandling().accessDeniedHandler(myAccessDeniedHandler);
} catch (Exception e) {
e.printStackTrace();
}
}
}

security auth2 源码分析#

1 明确目标 + 获取token核心流程梳理#

Spring Security OAuth的原理大致如下图左半部分,而我们的目标就是将Spring Security OAuth默认的走授权流程后发放token的步骤替换成走我们自定义的认证方式后发放token,除此之外,其他的认证校验步骤(如访问资源服务器的认证、授权等)仍然可以使用Spring Security OAuth提供的默认实现来完成。

为了达到这个目的,我们肯定要对Spring Security OAuth发放token的源码进行剖析。下面是其主要流程:

(绿色的是类,蓝色的是接口)

2 源码解读#

  • 获取token的整体流程 — 所在类TokenEndpoint
@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
if (!(principal instanceof Authentication)) {
throw new InsufficientAuthenticationException(
"There is no client authentication. Try adding an appropriate authentication filter.");
}
//从请求头中获取到clientId
String clientId = getClientId(principal);
//根据clientId获取ClientDetails
ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
//将获取的第三方应用的信息和grant_type、scope等参数封装成一个TokenRequest对象---该对象其实是new 出来的
TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
if (clientId != null && !clientId.equals("")) {
// Only validate the client details if a client authenticated during this
// request. --- 英文注释的很详细,这里不再多解释
if (!clientId.equals(tokenRequest.getClientId())) {
// double check to make sure that the client ID in the token request is the same as that in the
// authenticated client
throw new InvalidClientException("Given client ID does not match authenticated client");
}
}
//校验请求中的scope是否合法
if (authenticatedClient != null) {
oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
}
//没传哪种授权模式 ---> 直接抛异常
if (!StringUtils.hasText(tokenRequest.getGrantType())) {
throw new InvalidRequestException("Missing grant type");
}
//简化模式在用户请求授权时就获取到令牌了,不会进到该方法里,所以直接抛异常
if (tokenRequest.getGrantType().equals("implicit")) {
throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
}
//是否为授权码模式的请求
if (isAuthCodeRequest(parameters)) {
// The scope was requested or determined during the authorization step
//授权码模式在获取授权码的过程中需要传递scope参数,那个参数就是用来获取授权的,
//也就是说授权码模式请求的授权是在获取授权码的过程中就已经定了---因为此步是用户真正在授权
//在获取token的过程中只能使用获取授权码时得到的权限
//因此这里的scope置空,稍后通过申请到的授权码获取其在授权过程中所请求的权限
if (!tokenRequest.getScope().isEmpty()) {
logger.debug("Clearing scope of incoming token request");
tokenRequest.setScope(Collections.<String> emptySet());
}
}
//如果是刷新令牌的请求 --- 重新设置其scope
if (isRefreshTokenRequest(parameters)) {
// A refresh token has its own default scopes, so we should ignore any added by the factory here.
tokenRequest.setScope(OAuth2Utils.parseParameterList(parameters.get(OAuth2Utils.SCOPE)));
}
//真正生成token的步骤
OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
if (token == null) {
throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
}
//将生成的token返回
return getResponse(token);
}
  • TokenRequest 的生成 — 很简单,其实是通过ClientDetails 和其他请求参数new出来的

    -— 所在类DefaultOAuth2RequestFactory

  • 本段代码对应主流程图中的第③步

public TokenRequest createTokenRequest(Map<String, String> requestParameters, ClientDetails authenticatedClient) {
//获取clientId并校验其是否合法
String clientId = requestParameters.get(OAuth2Utils.CLIENT_ID);
if (clientId == null) {
// if the clientId wasn't passed in in the map, we add pull it from the authenticated client object
clientId = authenticatedClient.getClientId();
}
else {
// otherwise, make sure that they match
if (!clientId.equals(authenticatedClient.getClientId())) {
throw new InvalidClientException("Given client ID does not match authenticated client");
}
}
//从请求参数中获取到grant_type
String grantType = requestParameters.get(OAuth2Utils.GRANT_TYPE);
//从请求中根据clientId获取到所有的权限 即 scopes
Set<String> scopes = extractScopes(requestParameters, clientId);
//利用请求参数,clientId,scopes和grantType new出一个TokenRequest
TokenRequest tokenRequest = new TokenRequest(requestParameters, clientId, scopes, grantType);
//将token返回给请求端
return tokenRequest;
}
  • 下面真正生成token步1-3对应了主流程图中的第④步
  • 真正生成token的步骤1 — getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest)具体实现 — 所在类CompositeTokenGranter

  • 真正生成token的步骤2 — 上图中OAuth2AccessToken grant = granter.grant(grantType, tokenRequest); 的具体实现 — 所在类ClientCredentialsTokenGranter
@Override
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
//去具体的实现类里去拿token
OAuth2AccessToken token = super.grant(grantType, tokenRequest);
//如果获取到token--对刷新令牌进行处理 --- >不过多解释
if (token != null) {
DefaultOAuth2AccessToken norefresh = new DefaultOAuth2AccessToken(token);
// The spec says that client credentials should not be allowed to get a refresh token
if (!allowRefresh) {
norefresh.setRefreshToken(null);
}
token = norefresh;
}
//无论拿到没拿到都返回,如果拿到的token为null,则上图中的grant就是null,然后就会再利用其他的具体实现类尝试获取token,
//一旦获取成功,就会将token返回给请求端
return token;
}
  • 真正生成token的步骤3 — 上面OAuth2AccessToken token = super.grant(grantType, tokenRequest);的具体实现 — 所在类AbstractTokenGranter
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
//判断当前grantType是否为本实现类指定要处理的grantType---如果不是直接返回null
if (!this.grantType.equals(grantType)) {
return null;
}
//获取clientId
String clientId = tokenRequest.getClientId();
//通过clientId再获取一遍ClientDetails对象
ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
//判断grantType是否合法
validateGrantType(grantType, client);
//打个注释
if (logger.isDebugEnabled()) {
logger.debug("Getting access token for: " + clientId);
}
//真正去具体的实现类里去拿token
return getAccessToken(client, tokenRequest);
}
//上面的getAccessToken方法的具体实现
protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
}
//上面getOAuth2Authentication方法的具体实现 --- 生成OAuth2Authentication,
//但是按照方法的加载机制如果具体实现里有该方法会先走实现类里的
//在简化+授权码+密码模式的情况下不会走下面的方法 **=****=****=****=****=《 特别注意 》****=****=****=**==
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
OAuth2Request storedOAuth2Request = requestFactory.createOAuth2Request(client, tokenRequest);
return new OAuth2Authentication(storedOAuth2Request, null);
}

注意:上面的方法其实写在了package org.springframework.security.oauth2.provider.token下的抽像类AbstractTokenGranter里,其子类才是四种授权模式+RefreshToken的具体实现类 — 模版模式

  • 真正生成token的步骤4 — 上面getOAuth2Authentication(client, tokenRequest);方法的具体实现 — 不同授权模式具体实现不一样
  • 本段代码对应主流程图中的第⑤、⑥两步 — 所在类ResourceOwnerPasswordTokenGranter 这里分析的是密码模式的下的代码:
@Override
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
Map<String, String> parameters = new LinkedHashMap<String, String>(tokenRequest.getRequestParameters());
String username = parameters.get("username");
String password = parameters.get("password");
// Protect from downstream leaks of password
parameters.remove("password");
//下面这部分代码在用户名+密码登陆时其实我们已经见过了,就是利用请求中的用户名+密码进行认证+授权校验
//校验成功后会生成一个已经认证了的Authentication对象 --- 这里不细分析了
//与之对应的是授权码模式会利用授权码进行校验并生成一个认证成功的Authentication对象
Authentication userAuth = new UsernamePasswordAuthenticationToken(username, password);
((AbstractAuthenticationToken) userAuth).setDetails(parameters);
try {
userAuth = authenticationManager.authenticate(userAuth);
}
catch (AccountStatusException ase) {
//covers expired, locked, disabled cases (mentioned in section 5.2, draft 31)
throw new InvalidGrantException(ase.getMessage());
}
catch (BadCredentialsException e) {
// If the username/password are wrong the spec says we should send 400/invalid grant
throw new InvalidGrantException(e.getMessage());
}
if (userAuth == null || !userAuth.isAuthenticated()) {
throw new InvalidGrantException("Could not authenticate user: " + username);
}
//将ClientDetails 对象和TokenRequest 对象封装成一个OAuth2Request 对象
OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
//利用OAuth2Request和已经认证校验成功的Authentication 对象new 一个OAuth2Authentication对象
return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
  • 真正生成token的步骤5 — tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest)); createAccessToken具体实现
  • 本段代码对应主流程图中的第⑦步

TokenStore的具体实现(为了方便看,放在了这里)

所在类DefaultTokenServices

@Transactional
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
//先去存储里看有没有已经存在的token --- 存储可以有多种,上图是TokenStore的具体实现类,可以看到既有内存、Jdbc、也有redis
//并且还有Jwt --- 》 这是用Jwt替换spring security oauth中默认token的依据
OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
OAuth2RefreshToken refreshToken = null;
//如果从存储里找到了已经存在的token
if (existingAccessToken != null) {
//如果找到的token已经过期 -- 》在tokenStore里将其RefreshToken和该token删掉
if (existingAccessToken.isExpired()) {
if (existingAccessToken.getRefreshToken() != null) {
refreshToken = existingAccessToken.getRefreshToken();
// The token store could remove the refresh token when the
// access token is removed, but we want to
// be sure...
tokenStore.removeRefreshToken(refreshToken);
}
tokenStore.removeAccessToken(existingAccessToken);
}
else {
// Re-store the access token in case the authentication has changed
//重新存一下获取到的token,因为你有可能是之前用授权码模式获取的token,但现在用密码模式获取的token,
//这时要存的信息是不一样的
tokenStore.storeAccessToken(existingAccessToken, authentication);
//将没过期的token返回给请求
return existingAccessToken;
}
}
// Only create a new refresh token if there wasn't an existing one
// associated with an expired access token.
// Clients might be holding existing refresh tokens, so we re-use it in
// the case that the old access token
// expired.
//首先看看有没有刷新令牌,如果没有就新建一个,注意一下上面的英文注释
if (refreshToken == null) {
refreshToken = createRefreshToken(authentication);
}
// But the refresh token itself might need to be re-issued if it has
// expired.
//如果有刷新令牌,但是已经过期,需要重新发放一个刷新令牌
else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
refreshToken = createRefreshToken(authentication);
}
}
//真正生成token的逻辑
OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
//将生成的token存入到存储里
tokenStore.storeAccessToken(accessToken, authentication);
//重生成的令牌里取出刷新令牌,将刷新令牌存到存储里,防止它在上一步被改变
// In case it was modified
refreshToken = accessToken.getRefreshToken();
if (refreshToken != null) {
tokenStore.storeRefreshToken(refreshToken, authentication);
}
return accessToken;
}
  • 真正生成token的逻辑 — OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);的具体实现 — 所在类DefaultTokenServices
  • 本段代码对应主流程图中的第⑦步下面的TokenStore和TokenEnhancer
private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
//利用UUID当成构造函数的参数生成一个令牌
DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
//获取过期时间并设置到token里
int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
if (validitySeconds > 0) {
token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
}
//设置刷新令牌
token.setRefreshToken(refreshToken);
//设置令牌的权限
token.setScope(authentication.getOAuth2Request().getScope());
//看是否配置了AccessTokenEnhancer的具体实现
//如果配置了对生成的token进行增强 --- 设置Jwt时会讲
return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
}

简单配置调通oauth2#

一、认证服务器#

pom.xml

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

认证服务配置

/**
* <p>DESC: 服务配置</p>
* <p>DATE: 2019-11-19 14:26</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableAuthorizationServer
public class ServiceConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//将客户端写入内存中
clients.inMemory().withClient("client")
.secret("secret")
.scopes("app")
.redirectUris("http://www.baidu.com")
.authorizedGrantTypes("authorization_code");
}
}

认证服务安全配置

/**
* <p>DESC: web配置类</p>
* <p>DATE: 2019-11-19 15:08</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class AppWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/oauth/check_token");
}
}

二、资源服务器#

pom.xml和认证服务器一致

application.yml

server:
port: 8090
security:
oauth2:
client:
client-id: client
client-secret: secret
access-token-uri: http://localhost/oauth/token
user-authorization-uri: http://localhost/oauth/authorize
resource:
token-info-uri: http://localhost/oauth/check_token
spring:
application:
name: resources

资源服务配置

/**
* <p>DESC: 资源配置类</p>
* <p>DATE: 2019-11-19 16:31</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class ResourcesConfig extends ResourceServerConfigurerAdapter {
}

controller

/**
* <p>DESC: 测试</p>
* <p>DATE: 2019-11-19 15:37</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@RestController
public class ResourceControllerTest {
@GetMapping("/test")
public Object test(){
return "你好呀";
}
}

三、资源服务器配置自定义权限检查#

安装上文的权限剥离来做配置,需要AuthorizeConfigManager、AuthorizeConfigProvider、ResourceServerConfigurerAdapter

还有自定义的provider和自定义权限校验,这里不再重复

这里的配置需要写到ResourceServerConfigurerAdapter中,不能是WebSecurityConfigurerAdapter。因为是资源服务器。

manager和Provider上文已有,不再重复。

  • 资源服务器配置
/**
* <p>DESC: 资源配置类</p>
* <p>DATE: 2019-11-19 16:31</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class ResourcesConfig extends ResourceServerConfigurerAdapter {
@Resource
private AuthorizeConfigManager authorizeConfigManager;
@Override
public void configure(HttpSecurity http) throws Exception {
authorizeConfigManager.config(http.authorizeRequests());
}

解决:无法识别表达式的异常#

  • 正常流程,获取token访问资源服务器,会无法识别表达式的异常抛出
java.lang.IllegalArgumentException: Failed to evaluate expression '#oauth2.throwOnError(@rbacService.hasPermission(request,authentication))'

参考: https://github.com/spring-projects/spring-security-oauth/issues/730

  • 解决办法,在config中加入默认表达式处理器
@Autowired
private OAuth2WebSecurityExpressionHandler expressionHandler;
@Bean
public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler(ApplicationContext applicationContext) {
OAuth2WebSecurityExpressionHandler expressionHandler = new OAuth2WebSecurityExpressionHandler();
expressionHandler.setApplicationContext(applicationContext);
return expressionHandler;
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.expressionHandler(expressionHandler);
}

用户名密码登陆嫁接到Spring Security OAuth#

1 将自定义认证方式嫁接到Spring Security OAuth的原理#

Spring Security OAuth生成token的核心源码,其主要流程如下图:

而我们的目标是将Spring Security OAuth默认的走授权流程后发放token的步骤替换成走我们自定义的认证方式后发放token。既然要在我们自定义的认证方式认证成功后才发放token,则我们发放token的代码应该写在认证成功处理器里,回看上图联系其源码,可以知道:

(1)ClientDetails是通过请求头中的client-id获得的 (2)TokenRequest对象是通过请求参数+ClientDetails对象new出来的 (3)第④步是真正与授权模式相关的 (4)走完第④步会生成两个对象OAuth2Request和Authentication,而OAuth2Request是对ClientDetails对象和TokenRequest对象的封装Authentication对象即授权用户的对象 — 走完我们自定义的认证方式其实就会获得一个Authentication对象,因此在我们将自定义的认证方式嫁接到Spring Security OAuth框架的过程中不需要考虑该对象 (5)获得了OAuth2Reques和tAuthentication对象可以直接new 出一个OAuth2Authentication对象 (6)有了OAuth2Authentication对象我们就可以调用Spring Security OAuth默认的获取token的方法了

参照上面的步骤将我们自定义的认证方式嫁接到Spring Security OAuth的大致流程如下

2 将用户名密码登陆嫁接到Spring Security OAuth#

2.1 认证成功处理器 — 认证成功后生成token#

/**
* <p>DESC: 成功处理器</p>
* <p>DATE: 2019-11-20 10:27</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class AppSuccessHandler implements AuthenticationSuccessHandler {
@Resource
private ClientDetailsService clientDetailsService;
@Resource
private AuthorizationServerTokenServices authorizationServerTokenServices;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("【登录成功】");
String header = request.getHeader("Authorization");
if (header == null || !header.toLowerCase().startsWith("basic ")) {
throw new UnapprovedClientAuthenticationException("请求头无authentization信息");
}
try {
String[] tokens = this.extractAndDecodeHeader(header, request);
assert tokens.length == 2;
String clientId = tokens[0];
String secret = tokens[1];
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
if (clientDetails == null) {
throw new UnapprovedClientAuthenticationException("clientid不存在");
} else if (!StringUtils.equals(clientDetails.getClientSecret(), secret)) {
throw new UnapprovedClientAuthenticationException("secret不匹配");
}
// 第一个参数为请求参数的一个Map集合,
// 在Spring Security OAuth的源码里要用这个Map里的用户名+密码或授权码来生成Authentication对象,
// 但我们已经获取到了Authentication对象,所以这里可以直接传一个空的Map
// 第三个参数为scope即请求的权限 ---》这里的策略是获得的ClientDetails对象里配了什么权限就给什么权限 //todo
// 第四个参数为指定什么模式 比如密码模式为password,授权码模式为authorization_code,
TokenRequest tokenRequest = new TokenRequest(new HashMap<>(), clientId, clientDetails.getScope(), "自定义的模式");
//获取OAuth2Request对象
OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
//new出一个OAuth2Authentication对象
OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication);
//生成token
OAuth2AccessToken accessToken = authorizationServerTokenServices.createAccessToken(oAuth2Authentication);
//回写token
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
String tokenStr = JSON.toJSONString(accessToken);
response.getWriter().write(tokenStr);
} catch (Exception e) {
throw new RuntimeException("失败");
}
}
/**
* 从header获取Authentication信息 --- 》 clientId和clientSecret
* @param header 请求头
* @param request 请求
* @return clientID,secret
* @throws IOException 异常
*/
private String[] extractAndDecodeHeader(String header, HttpServletRequest request) throws IOException {
byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
byte[] decoded;
try {
decoded = Base64.getDecoder().decode(base64Token);
} catch (IllegalArgumentException var7) {
throw new BadCredentialsException("Failed to decode basic authentication token");
}
String token = new String(decoded, StandardCharsets.UTF_8);
int delim = token.indexOf(":");
if (delim == -1) {
throw new BadCredentialsException("Invalid basic authentication token");
} else {
return new String[]{token.substring(0, delim), token.substring(delim + 1)};
}
}
}

2.2 app模块加入安全配置信息 — 让我们自定义的认证方式生效#

/**
* <p>DESC: web配置类</p>
* <p>DATE: 2019-11-19 15:08</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class AppWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private AppSuccessHandler appSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
//只是加入了成功处理器,其他均为默认
http.csrf().disable().formLogin().successHandler(appSuccessHandler);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/oauth/check_token");
}
}

3 测试#

发送的登陆请求为POST请求,路径为http://client:secret@localhost:80/login ,同时需要再请求头里加上Client-Id和Client-Secret,请求参数为username和password 可以看到已经获取到了token信息

访问资源服务器

http://localhost:8090/test?access_token=8fa94489-372e-4b27-871f-372f39c112a1

短信登陆嫁接到Spring Security OAuth#

一、短信登陆需要解决的问题和解决方式#

即之前开发的短信登陆和图形验证码都是基于cookie和session的,但是基于token的认证方式根本就不会cookie和session。

其实解决方式也简单,就是把原来存放到session中的验证码,我们直接存到数据库(如redis)中,然后验证的时候再去数据库里把这个验证码拿出来进行验证就可以了。

二、将短信登陆嫁接到Spring Security OAuth#

1、加入验证码配置#

.apply(smsCodeAuthenticationSecurityConfig())

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class AppWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig(){
return new SmsCodeAuthenticationSecurityConfig(appSuccessHandler,appFailHandler);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.apply(smsCodeAuthenticationSecurityConfig())
.and()
.formLogin().successHandler(appSuccessHandler)
;
}

2、 修改保存、获取和移出验证码的逻辑#

由于浏览器项目还是可以使用之前的cookie+session的方式保存、获取和移出验证码,而app项目需要将用到数据库(如redis)+deviceId(数据库相当于session、deviceId相当于cookie)的方式来保存、获取和移出验证码。两种项目都需要做保存、获取和移出验证码的动作,因此可以采用模版模式+策略模式的方式进行开发。

package com.nrsc.security.core.validate.code;
import org.springframework.web.context.request.ServletWebRequest;
/**
* @author : Sun Chuan
* @date : 2019/10/19 21:55
* Description:
*/
public interface ValidateCodeRepository {
/**
* 保存验证码
* @param request
* @param code
* @param validateCodeType
*/
void save(ServletWebRequest request, ValidateCode code, ValidateCodeType validateCodeType);
/**
* 获取验证码
* @param request
* @param validateCodeType
* @return
*/
ValidateCode get(ServletWebRequest request, ValidateCodeType validateCodeType);
/**
* 移除验证码
* @param request
* @param codeType
*/
void remove(ServletWebRequest request, ValidateCodeType codeType);
}

浏览器项目使用cookie+session的方式进行验证码的存、取、删

/**
* <p>DESC: session验证码仓库</p>
* <p>DATE: 2019-11-20 13:44</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class SessionValidateCodeRepository implements ValidateCodeRepository {
/**
* 验证码放入session时的前缀
*/
private static final String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";
/**
* 操作session的工具类
*/
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Override
public void save(ServletWebRequest request, ValidateCode code, ValidateCodeType validateCodeType) {
sessionStrategy.setAttribute(request,getSessionKey(request,validateCodeType),code);
}
private String getSessionKey(ServletWebRequest request,ValidateCodeType validateCodeType){
return SESSION_KEY_PREFIX + validateCodeType.toString();
}
@Override
public ValidateCode get(ServletWebRequest request, ValidateCodeType validateCodeType) {
return (ValidateCode) sessionStrategy.getAttribute(request,getSessionKey(request,validateCodeType));
}
@Override
public void remove(ServletWebRequest request, ValidateCodeType codeType) {
sessionStrategy.removeAttribute(request,getSessionKey(request,codeType));
}
}

app项目使用redis+deviceId的方式进行验证码的存、取、删

/**
* <p>DESC: app验证码仓库</p>
* <p>DATE: 2019-11-20 13:56</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class AppValidateCodeRepository implements ValidateCodeRepository {
@Resource
private RedisTemplate<Object,Object> redisTemplate;
@Override
public void save(ServletWebRequest request, ValidateCode code, ValidateCodeType validateCodeType) {
redisTemplate.opsForValue().set(buildKey(request,validateCodeType),code,30, TimeUnit.MINUTES);
}
private String buildKey(ServletWebRequest request,ValidateCodeType validateCodeType){
String deviceId = request.getHeader("deviceId");
if(StringUtils.isBlank(deviceId)){
throw new ValidateCodeException("deviceId不能为空");
}
return "code:"+validateCodeType.toString()+":"+deviceId;
}
@Override
public ValidateCode get(ServletWebRequest request, ValidateCodeType validateCodeType) {
return (ValidateCode) redisTemplate.opsForValue().get(buildKey(request,validateCodeType));
}
@Override
public void remove(ServletWebRequest request, ValidateCodeType codeType) {
redisTemplate.delete(buildKey(request,codeType));
}
}

3、 修改原来保存、获取和移出验证码的代码#

/**
* <p>DESC: 验证码抽象处理器</p>
* <p>DATE: 2019-10-09 15:49</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public abstract class AbstractValidateCodeProcessor<C> implements ValidateCodeProcessor {
@Resource
private ValidateCodeRepository validateCodeRepository;
/**
* 创建方法
*
* @param request 封装request和response
*/
@Override
public void create(ServletWebRequest request,ValidateCodeType validateCodeType) {
//创建验证码(创建验证码的子类实现)
C validateCode = generator(request);
//存入session(公共代码由抽象类自行实现)
save(request,validateCode,validateCodeType);
//发送验证码(子类实现)
send(request,validateCode);
}
/**
* 保存验证码到session中
* @param request 包装请求
*/
private void save(ServletWebRequest request, C validateCode, ValidateCodeType validateCodeType){
ValidateCode code = (ValidateCode) validateCode;
ValidateCode code1 = new ValidateCode(code.getCode(), code.getExpireTime());
validateCodeRepository.save(request,code1,validateCodeType);
};

4、验证码类型#

/**
* <p>DESC: 验证码类型</p>
* <p>DATE: 2019-11-20 13:42</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public enum ValidateCodeType {
/**
* 短信验证码
*/
SMS,
/**
* 图形验证码
*/
IMAGE
;
}

三、测试#

(1)获取短信验证码

localhost/validate/code/sms?mobile=18281975746

header : deviceId = 123456

此时后端会打印获取到的短息验证码,如下图

手机号<18281975746|发送手机验证码>:6635

此时redis里已经保存了我们的验证码 — 验证码保存成功

(2)发送通过短信验证码登陆的请求

client@localhost/sms/check

post

请求表单:

mobile<18281975746> smsCode<3986> deviceId<123456>

hearder:

Authorization = Basic Y2xpZW50OnNlY3JldA==

(3) 登录成功,返回token

{
"additionalInformation": {},
"expiration": 1574284411988,
"expired": false,
"expiresIn": 43199,
"scope": [
"app"
],
"tokenType": "bearer",
"value": "aa946e1c-18d5-49b4-9449-4b84453706f5"
}

(4) 访问资源服务器

http://localhost:8090/test?access_token=aa946e1c-18d5-49b4-9449-4b84453706f5

一、社交登录嫁接到Security OAuth2—服务提供商SDK使用简化模式时#

1 原理#

首先要明确SDK是服务提供商(如QQ、微信等)提供的用于引导第三方应用(比如说我们自己的APP)完成授权流程的工具包,不同的服务提供商的SDK的具体实现是不一样的,选择的授权模式也是不一样的,一般会有两种模式:①简化模式,②授权码模式。

如果服务提供商(如QQ、微信等)提供的SDK使用的是简化模式,开发APP社交登陆的原理如下:

即用户同意授权后就可以获得到openId(用户在服务提供商上的唯一标识)和服务提供商生成的accessToken了,但是我们并不能拿着这个token访问我们的应用(上图中的第三方应用),因为这个token是服务提供商发放的,不是我们的认证服务器发放的。 — 对应上图1、2、3三步

但是与此同时由于已经获得了openId,我们就可以拿着openId去我们存放本系统用户账号和社交账号对应关系的userconnection表(如下图)去验证该账户的信息了。

如果验证成功,就可以生成一个Authentication对象,然后我们就可以去我们的自己的认证服务器(上图中的第三方应用)获取token了。 — 对应上图4、5两步

假设已经走完1-3步,即我们已经获取到了openId,那如何拿着获取到的openId进行认证校验并获取到token呢?其实原理很简单,基本和短信登陆的原理一致:短信登陆是我们获取到了一个短信验证码,然后拿着这个验证码进行校验,并获取到一个token;而这里是拿着openId进行校验,然后获取一个token。

2 基于openId的认证开发#

  • 用来封装openId和providerId的token — OpenIdAuthenticationToken
/**
* <p>DESC: openid的token</p>
* <p>DATE: 2019-11-26 14:58</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class OpenIdAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 510L;
/**
* openid
*/
private final Object principal;
/**
* providerId
*/
private Object credentials;
public OpenIdAuthenticationToken(Object openid, Object providerId) {
super((Collection)null);
this.principal = openid;
this.credentials = providerId;
this.setAuthenticated(false);
}
public OpenIdAuthenticationToken(Object openid, Object providerId, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = openid;
this.credentials = providerId;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
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");
} else {
super.setAuthenticated(false);
}
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
}
  • 定义过滤器,拦截指定请求,并处理
/**
* <p>DESC: openid过滤器</p>
* <p>DATE: 2019-11-26 15:03</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class OpenIdAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String openid = "openid";
private String providerId = "providerId";
private boolean postOnly = true;
public OpenIdAuthenticationFilter() {
super(new AntPathRequestMatcher("/openid", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String openid = this.obtainOpenid(request);
String providerId = this.obtainProviderId(request);
if (openid == null) {
openid = "";
}
if (providerId == null) {
providerId = "";
}
openid = openid.trim();
OpenIdAuthenticationToken authRequest = new OpenIdAuthenticationToken(openid, providerId);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
protected String obtainProviderId(HttpServletRequest request) {
return request.getParameter(this.providerId);
}
protected String obtainOpenid(HttpServletRequest request) {
return request.getParameter(this.openid);
}
protected void setDetails(HttpServletRequest request, OpenIdAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
  • 自定义Provider,实现真正认证逻辑
/**
* <p>DESC: openid的provider</p>
* <p>DATE: 2019-11-26 15:10</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class OpenIdAuthenticationProvider implements AuthenticationProvider {
private SocialUserDetailsService socialUserDetailsService;
private UsersConnectionRepository usersConnectionRepository;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//未认证的token
OpenIdAuthenticationToken authenticationToken = (OpenIdAuthenticationToken) authentication;
//解析token中的参数
String openid = (String) authenticationToken.getPrincipal();
String providerId = (String) authenticationToken.getCredentials();
Set<String> providerUserIds = new HashSet<>();
providerUserIds.add(openid);
//根据参数查找是否存在用户
Set<String> idsConnectedTo = usersConnectionRepository.findUserIdsConnectedTo(providerId, providerUserIds);
String userId = idsConnectedTo.iterator().next();
SocialUserDetails user = socialUserDetailsService.loadUserByUserId(userId);
if(user == null){
throw new InternalAuthenticationServiceException("无法获取用户信息");
}
//组装token
OpenIdAuthenticationToken authenticationResult = new OpenIdAuthenticationToken(openid, providerId, user.getAuthorities());
//保证成认证成功的token 返回
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> aClass) {
return aClass.isAssignableFrom(OpenIdAuthenticationToken.class);
}
}
  • 将openId校验逻辑加入到spring-security过滤器的关键配置类 — OpenIdAuthenticationSecurityConfig
/**
* <p>DESC: openID认证配置类</p>
* <p>DATE: 2019-11-26 15:30</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class OpenIdAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Resource
private AppSuccessHandler successHandler;
@Resource
private MyFailHandler failHandler;
@Resource
private SocialUserDetailsService socialUserDetailsService;
@Resource
private UsersConnectionRepository usersConnectionRepository;
@Override
public void configure(HttpSecurity http) throws Exception {
OpenIdAuthenticationFilter openIdAuthenticationFilter = new OpenIdAuthenticationFilter();
openIdAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
openIdAuthenticationFilter.setAuthenticationSuccessHandler(successHandler);
openIdAuthenticationFilter.setAuthenticationFailureHandler(failHandler);
OpenIdAuthenticationProvider provider = new OpenIdAuthenticationProvider();
provider.setSocialUserDetailsService(socialUserDetailsService);
provider.setUsersConnectionRepository(usersConnectionRepository);
http.authenticationProvider(provider)
.addFilterAfter(openIdAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
  • 加入websecurityconfigAdapter配置
public class AppWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private AppSuccessHandler appSuccessHandler;
@Resource
private OpenIdAuthenticationSecurityConfig openIdAuthenticationSecurityConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.apply(openIdAuthenticationSecurityConfig).and()

3 测试#

二、社交登录嫁接到Security OAuth2—服务提供商SDK使用授权码模式时#

1 原理#

上篇文章 —《【Spring Security OAuth开发APP认证框架】— 重构社交登陆(1) — 服务提供商SDK使用简化模式时》讲解了服务提供商(如QQ、微信等)的SDK使用简化模式时我们开发社交登陆的原理和过程。本篇来讲解一下 服务提供商的SDK使用授权码模式时,开发社交登陆的基本原理和过程。

基本原理如下

而我们之前实现的QQ、微信登陆的基本原理如下:

两张图一进行对比,我们就可以很清楚的看到,在我们现有的基础上,我们要做的事主要有以下两件:

开发一个APP,然后将的原来浏览器项目里的0、1、2、3这四步转移到APP项目里; 在APP接到授权码后将授权码发给我们开发的第三方应用,然后第三方应用拿着获取的授权码向服务商的认证服务器申请令牌

这里0、1、2、3不具体开发了,代替做法为

(1)先在浏览器项目里走0、1、2、3步,然后在申请令牌之前打一个断点,获取到授权码 (2)紧接着再切到APP项目,拿着授权码去申请令牌 (3)接着会走APP项目的6、7两步 (4)然后会拿着获取到的用户信息构建Authentication对象 (5)构建成功会走APP的成功处理器,并获取到accessToken

其中(3)、(4)两步其实我们不用管,因为我们之前都已经开发完,第(5)步在APP项目里会有点问题,一会我们再说。接下来我们先完成(1)、(2)两步。

3 APP下指定springsocial的成功处理器使用APP的成功处理器#

3.1 代码开发 spring-security的默认成功处理器为认证成功后重新调用促发请求认证的url,在这browser项目里是没问题的,但在app项目里我们是想让它认证成功后调用成功处理器返回一个token,因此可以将这两块逻辑分别处理:

浏览器项目里仍然使用spring-security默认的成功处理器 app项目里调用app项目的成功处理器 具体实现如下:

(1) 定义一个指定springsocial过滤器成功处理器的接口

/**
* <p>DESC: 后置处理器接口</p>
* <p>DATE: 2019-11-26 16:21</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface SocialAuthenticationFilterPostProcessor {
/**
* 处理
* @param socialAuthenticationFilter 社交登录过滤器
*/
void processor(SocialAuthenticationFilter socialAuthenticationFilter);
}

(2)在APP项目里实现上面的接口,并指定springsocial的成功处理器为可以返回token的成功处理器

/**
* <p>DESC: 后置处理器</p>
* <p>DATE: 2019-11-26 16:26</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class AppSocialAuthenticationFilterProcessor implements SocialAuthenticationFilterPostProcessor {
@Resource
private AppSuccessHandler appSuccessHandler;
@Override
public void processor(SocialAuthenticationFilter socialAuthenticationFilter) {
socialAuthenticationFilter.setAuthenticationSuccessHandler(appSuccessHandler);
}
}

(3)在配置文件里让其生效

核心代码

//后置处理器,可配置
if(socialAuthenticationFilterPostProcessor != null){
socialAuthenticationFilterPostProcessor.processor(filter);
}

全代码

/**
* <p>DESC: 覆盖后置方法,修改拦截地址</p>
* <p>DATE: 2019-11-06 16:38</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class MySpringSocialConfigurer extends SpringSocialConfigurer {
private String url;
private AuthenticationSuccessHandler handler;
private SocialAuthenticationFilterPostProcessor socialAuthenticationFilterPostProcessor;
public MySpringSocialConfigurer(String url, AuthenticationSuccessHandler redUrl) {
this.url = url;
this.handler = redUrl;
}
@Override
protected <T> T postProcess(T object) {
//在父类处理完SocialAuthenticationFilter之后的基础上,将其默认拦截url改成我们配置的值
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
filter.setFilterProcessesUrl(url);
filter.setAuthenticationSuccessHandler(handler);
//后置处理器,可配置
if(socialAuthenticationFilterPostProcessor != null){
socialAuthenticationFilterPostProcessor.processor(filter);
}
return (T) filter;
}
}

social config中构建的时候传入

核心代码

@Autowired(required = false)
private SocialAuthenticationFilterPostProcessor socialAuthenticationFilterPostProcessor;
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
// 默认配置类,进行组件的组装
// 包括了过滤器SocialAuthenticationFilter 添加到security过滤链中
QQProperties qq = properties.getSocial().getQq();
MySpringSocialConfigurer config = new MySpringSocialConfigurer(qq.getFilterProcessesUrl(),qqSuccessHandler);
config.setSocialAuthenticationFilterPostProcessor(socialAuthenticationFilterPostProcessor);
return config;
}

全代码

/**
* <p>DESC: social 配置</p>
* <p>DATE: 2019-11-05 21:26</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
@Resource
private DataSource dataSource;
@Resource
private MySecurityProperties properties;
@Autowired(required = false)
private ConnectionSignUp connectionSignUp;
@Resource
private QQSuccessHandler qqSuccessHandler;
@Autowired(required = false)
private SocialAuthenticationFilterPostProcessor socialAuthenticationFilterPostProcessor;
/**
* 将我们 JdbcUsersConnectionRepository 配置好
* @param connectionFactoryLocator locator
* @return UsersConnectionRepository
*/
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
repository.setTablePrefix("dftc_");
if(connectionSignUp != null){
repository.setConnectionSignUp(connectionSignUp);
}
return repository;
}
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
// 默认配置类,进行组件的组装
// 包括了过滤器SocialAuthenticationFilter 添加到security过滤链中
QQProperties qq = properties.getSocial().getQq();
MySpringSocialConfigurer config = new MySpringSocialConfigurer(qq.getFilterProcessesUrl(),qqSuccessHandler);
config.setSocialAuthenticationFilterPostProcessor(socialAuthenticationFilterPostProcessor);
config.signupUrl(properties.getBrowser().getSignUpUrl());
return config;
}
/**
* ProviderSignInUtils有两个作用:
* (1)从session里获取封装了第三方用户信息的Connection对象
* (2)将注册的用户信息与第三方用户信息(QQ信息)建立关系并将该关系插入到userconnection表里
*
* 需要的两个参数:
* (1)ConnectionFactoryLocator 可以直接注册进来,用来定位ConnectionFactory
* (2)UsersConnectionRepository----》getUsersConnectionRepository(connectionFactoryLocator)
* 即我们配置的用来处理userconnection表的bean
* @param connectionFactoryLocator 连接工厂加载器
* @return ProviderSignInUtils
*/
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator) {
return new ProviderSignInUtils(connectionFactoryLocator,
getUsersConnectionRepository(connectionFactoryLocator)) {
};
}
}

3.2 测试 重新走2中的流程,即先在browser项目里登陆获取授权码,然后停掉,再将app项目启动,然后拿着服务提供商回调的URL和我们自己的认证服务器需要携带的client-id和client-id(比如我配置的为nrsc,123456)调用我们自己的APP项目就可以获取到我们项目的认证服务器发放的accessToken了,如下图。

  • 断点打在:OAuth2AuthenticationService 中的 getAuthToken方法中的code换区token的位置
AccessGrant accessGrant = this.getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, (MultiValueMap)null);
  • 获取到code,切换到APP模式,直接访问服务,走授权流程,成功后返回token

这里注意:这里传入clientId和secret,整个流程的request的header中都会有Authorization(clientID和secret编码后的值)。

client:secret@www.pinzhi365.com/qqLogin/callback.do?code=1995A7306F352814A4E2F4B21CF76451

这说明当服务提供商提供的SDK使用授权码模式时,开发APP社交登陆的整个流程已经走通。

【Spring Security OAuth开发APP认证框架】--- APP认证框架下社交登陆过程中注册逻辑处理#

1、原理#

上两篇文章讲解了开发APP认证框架时社交登陆的重构,但是这种重构其实是在一个前提下的,即用户已经在我们的系统里存在本系统账号与其社交账号的绑定关系。

但当这种关系不存在时,会发生什么呢?

在browser项目里的具体逻辑如下:

如果没找到社交账户与当前系统用户的绑定关系会有两种处理方式 处理方式(1) —》先将从社交账户获得的用户信息封装的Connection对象存到session —》然后将用户引导到一个注册或绑定用户的url(一般会对应注册、绑定页面),在注册或绑定页面可以从session里取出社交账户的头像,名称等信息展示给前端,并提醒用户注册或绑定账户 —》用户输入要注册或绑定的账户点击注册或绑定时,将输入的账户与session中的Connection对象进行绑定插入到本系统账号与社交账号绑定关系表—userconnection表 处理方式(2) 可以配置一个注册类(重写ConnectionSignUp中的execute方法即可),直接给其默认注册一个本系统账户,并将两者的绑定关系插入userconnection表。

在APP项目里处理方式(1)是有问题的,原因是处理方式(1)是基于session的,但是APP的每次访问都是在一个新的session里

解决思路如下:

(1) 如果没找到社交账户与当前系统用户的绑定关系 (2) 先将从社交账户获得的用户信息封装的Connection对象存到session (3) 将用户引导到一个注册或绑定用户的url — 后端服务,在该服务里先从session里取出Connection对象然后将其转存到redis或其他数据库,然后将社交登陆失败的原因返回给APP (4) APP拿到(3)返回的信息,引导用户到注册或绑定页面 (5) 用户输入要注册或绑定的账户点击注册或绑定时,将输入的账户与redis中存储的相对应的Connection对象进行绑定并插入到本系统账号与社交账号绑定关系表—userconnection表

2、具体实现#

2.1 (1)-(3)步代码开发#

  • 之前没找到社交账户与当前系统用户绑定关系时跳向注册逻辑的url配置如下:
/**
* 通过apply()该实例,可以将SocialAuthenticationFilter加入到spring-security的过滤器链
*/
@Bean
public SpringSocialConfigurer nrscSocialSecurityConfig() {
// 默认配置类,进行组件的组装
// 包括了过滤器SocialAuthenticationFilter 添加到security过滤链中
String filterProcessesUrl = nrscSecurityProperties.getSocial().getFilterProcessesUrl();
NrscSpringSocialConfigurer configurer = new NrscSpringSocialConfigurer(filterProcessesUrl);
//指定SpringSocial/SpringSecurity跳向注册页面时的url为我们配置的url
configurer.signupUrl(nrscSecurityProperties.getBrowser().getSignUpUrl());
//设置springsocial的认证成功处理器 -- app下可以返回token,browser下使用spring-security默认的
configurer.setSocialAuthenticationFilterPostProcessor(socialAuthenticationFilterPostProcessor);
return configurer;
}
  • 在browser项目下这个配置没有问题的,在不修改上面逻辑的情况下,可以在APP模块里实现BeanPostProcessor接口来修改该bean的signupUrl
package com.nrsc.security.app;
import com.nrsc.security.core.social.NrscSpringSocialConfigurer;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;
@Component
public class SpringSocialConfigurerPostProcessor implements BeanPostProcessor {
/***
* spring启动时所有的bean初始化之前都会调用该方法 --- 可以在bean初始化之前对bean做一些操作
* @param bean
* @param beanName
* @return
* @throws BeansException
*/
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
/***
* spring启动时所有的bean初始化之后都会调用该方法 --- 可以对初始化好的bean做一些修改
* @param bean
* @param beanName
* @return
* @throws BeansException
*/
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if(StringUtils.equals(beanName, "nrscSocialSecurityConfig")){
NrscSpringSocialConfigurer config = (NrscSpringSocialConfigurer)bean;
config.signupUrl("/social/signUp");
return config;
}
return bean;
}
}
  • 对应解决思路中的第三步
package com.nrsc.security.app.controller;
import com.nrsc.security.core.utils.AppSignUpUtils;
import com.nrsc.security.core.pojo.SocialUserInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.web.ProviderSignInUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import javax.servlet.http.HttpServletRequest;
/**
* @author : Sun Chuan
* @date : 2019/10/22 22:51
* Description:将社交账户从session转存到redis,并返回给APP一个
* 社交登陆失败的原因+社交账户信息(便于APP前端显示当前社交账户的用户名、头像等信息)
*/
@RestController
public class AppSecurityController {
@Autowired
private ProviderSignInUtils providerSignInUtils;
//将社交账户信息转存到redis和从redis取出社交账户信息的工具类
@Autowired
private AppSignUpUtils appSignUpUtils;
@GetMapping("/social/signUp")
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public SocialUserInfo getSocialUserInfo(HttpServletRequest request) {
SocialUserInfo userInfo = new SocialUserInfo();
//从session中取出已经认证的社交账户信息(Connection对象)
//-->本接口的执行和微信、QQ的等服务提供商拿着授权码回调我们的项目在一个session里,因此这里可以从session里取出社交账户信息
Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));
userInfo.setProviderId(connection.getKey().getProviderId());
userInfo.setProviderUserId(connection.getKey().getProviderUserId());
userInfo.setNickName(connection.getDisplayName());
userInfo.setHeadImg(connection.getImageUrl());
//从session里将社交账户信息取出并转存到redis
appSignUpUtils.saveConnectionData(new ServletWebRequest(request), connection.createData());
return userInfo;
}
}
  • 上面类中AppSingUpUtils — 将社交账户信息转存到redis和从redis取出社交账户信息的工具类,代码如下:
package com.nrsc.security.app.utils;
import com.nrsc.security.app.exception.AppSecretException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionData;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.WebRequest;
import java.util.concurrent.TimeUnit;
@Component
public class AppSingUpUtils {
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
@Autowired
private UsersConnectionRepository usersConnectionRepository;
@Autowired
private ConnectionFactoryLocator connectionFactoryLocator;
/***
* 将社交账户信息存到redis
* @param request
* @param connectionData
*/
public void saveConnectionData(WebRequest request, ConnectionData connectionData) {
redisTemplate.opsForValue().set(getKey(request), connectionData, 10, TimeUnit.MINUTES);
}
/***
* 从redis取出社交账户信息,并将其与本系统账户建立关联,并将关联关系存到数据库---userconnection表
* @param request
* @param userId
*/
public void doPostSignUp(WebRequest request, String userId) {
String key = getKey(request);
if(!redisTemplate.hasKey(key)){
throw new AppSecretException("无法找到缓存的用户社交账号信息");
}
ConnectionData connectionData = (ConnectionData) redisTemplate.opsForValue().get(key);
Connection<?> connection = connectionFactoryLocator.getConnectionFactory(connectionData.getProviderId())
.createConnection(connectionData);
usersConnectionRepository.createConnectionRepository(userId).addConnection(connection);
redisTemplate.delete(key);
}
/***
* 根据请求头中的deviceId拼接一个存到redis中的key
* @param request
* @return
*/
private String getKey(WebRequest request) {
String deviceId = request.getHeader("deviceId");
if (StringUtils.isBlank(deviceId)) {
throw new AppSecretException("设备id参数不能为空");
}
return "nrsc:security:social.connect." + deviceId;
}
}

2.2 测试#

测试内容:社交登陆 —> 指引用户进行绑定或注册 —》注册 整个流程

(1)修改userconnection表,去掉我微信账户与本系统账户的关联关系

(2)像上篇文章一样,先在browser项目里获取到微信带着授权码回调我们项目的请求 (3)切回到app项目,先重写一下注册逻辑 —》 该接口免授权

package com.nrsc.security.controller;
import com.nrsc.security.app.utils.AppSignUpUtils;
import com.nrsc.security.domain.NrscUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.social.connect.web.ProviderSignInUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private ProviderSignInUtils providerSignInUtils;
@Autowired
private AppSignUpUtils appSignUpUtils;
@PostMapping("/register")
public void register(NrscUser user, HttpServletRequest request) {
//注册用户相关逻辑-----》即向用户表里插入一条用户数据-----》这里不写了
//不管是注册用户还是绑定用户,都会拿到一个用户唯一标识,如用户名。
String userId = user.getUsername();
//将用户userId和第三方用户信息建立关系并将其插入到userconnection表
//providerSignInUtils.doPostSignUp(userId, new ServletWebRequest(request));
//使用我们自己的utils将用户userId和第三方用户信息建立关系、将该关系插入到userconnection表
//和删掉redis中保存的deviceId信息
appSignUpUtils.doPostSignUp(new ServletWebRequest(request), userId);
}
}

(4)启动APP项目,拿着(2)中获得的请求访问app项目 —》 由于找不到本系统账户与我微信账户的绑定关系,会重定向到我们指定的signupUrl,但是可能是我们的请求与微信回调我们的请求不是一个request对象的原因,会出现一个回调异常,如下图:

(5)我们手动调一下/social/signUp请求,获取到社交账户信息----》这个信息会返回给APP,APP根据这个请求将用户引到到注册或绑定页面。

(6)然后进行用户注册或绑定—》下图调用了上面说的注册接口:

在注册成功后,可以看到在数据库的userconnection表里新插入了一条yoyo与我微信的绑定关系:

上诉测试表明1中所说得处理方式(1)所面临的问题,在我们开发的APP认证框架里已经得到了解决。

客户端信息+token信息 数据库(mysql/redis)存储#

1、将客户端信息保存到mysql( 推荐)#

1.1 建表+代码开发#

  • 建表
create table oauth_client_details (
client_id VARCHAR(256) PRIMARY KEY,
resource_ids VARCHAR(256),
client_secret VARCHAR(256),
scope VARCHAR(256),
authorized_grant_types VARCHAR(256),
web_server_redirect_uri VARCHAR(256),
authorities VARCHAR(256),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additional_information VARCHAR(4096),
autoapprove VARCHAR(256)
);
  • 数据库里做一些简单的配置
  • 这样客户端的配置就由上面代码中的一坨变成了下面这样,且如果增加一个客户端或减少一个客户端并不用修改代码,直接删除或增加数据库里的一条数据就可以了
  • 内存中配置如下
/**
* <p>DESC: 服务配置</p>
* <p>DATE: 2019-11-19 14:26</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableAuthorizationServer
public class ServiceConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory().withClient("client")
.secret("secret")
.scopes("app")
//获取code后重定向地址
.redirectUris("http://www.baidu.com")
.authorizedGrantTypes("authorization_code");
}
}
  • 修改为数据库保存
/***
* 第三方客户端相关的配置在这里进行配置 ,之前我们在yml配置文件里对客户端进行过简单的配置
* 在这里进行配置会覆盖yml中的配置
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//直接将dataSource给clients就行了 --- jdbc会直接去库里拿客户端信息
clients.jdbc(dataSource);
}

2、token信息由存储在内存改为存到redis数据库#

2.1 代码实现#

  • 资源服务器配置类和具体解释看如下代码:

核心代码

endpoints
//指定使用的TokenStore,tokenStore用来存取token,默认使用InMemoryTokenStore
//这里使用redis存储token
.tokenStore(redisTokenStore)
;

完整代码:

/**
* <p>DESC: 服务配置</p>
* <p>DATE: 2019-11-19 14:26</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableAuthorizationServer
public class ServiceConfig extends AuthorizationServerConfigurerAdapter {
private final DataSource dataSource;
private final TokenStore redisTokenStore;
@Autowired
public ServiceConfig(DataSource dataSource, TokenStore redisTokenStore) {
this.dataSource = dataSource;
this.redisTokenStore = redisTokenStore;
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//指定使用的TokenStore,tokenStore用来存取token,默认使用InMemoryTokenStore
//这里使用redis存储token
.tokenStore(redisTokenStore)
;
}
}

token配置

/**
* <p>DESC: token存储配置</p>
* <p>DATE: 2019-12-02 14:56</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
public class TokenStoreConfig {
private final RedisConnectionFactory redisConnectionFactory;
/***
* RedisTokenStore需要一个连接工厂,这里可以直接注入进来
*/
@Autowired
public TokenStoreConfig(RedisConnectionFactory redisConnectionFactory) {
this.redisConnectionFactory = redisConnectionFactory;
}
/***
* 将RedisTokenStore注入到spring容器
* @return tokenStore
*/
@Bean
public TokenStore redisTokenStore(){
return new RedisTokenStore(redisConnectionFactory);
}
}

3 将token保存到mysql#

create table oauth_client_token (
token_id VARCHAR(256),
token BLOB,
authentication_id VARCHAR(256) PRIMARY KEY,
user_name VARCHAR(256),
client_id VARCHAR(256)
);
create table oauth_access_token (
token_id VARCHAR(256),
token BLOB,
authentication_id VARCHAR(256) PRIMARY KEY,
user_name VARCHAR(256),
client_id VARCHAR(256),
authentication BLOB,
refresh_token VARCHAR(256)
);
create table oauth_refresh_token (
token_id VARCHAR(256),
token BLOB,
authentication BLOB
);
create table oauth_code (
code VARCHAR(256), authentication BLOB
);
create table oauth_approvals (
userId VARCHAR(256),
clientId VARCHAR(256),
scope VARCHAR(256),
status VARCHAR(10),
expiresAt DATETIME,
lastModifiedAt DATETIME
);
  • 配置JdbcTokenStore并利用JdbcTokenStore替换RedisTokenStore
/***
* 将jdbcTokenStore注入到spring容器
* @return tokenStore
*/
@Bean
public TokenStore jdbcTokenStore(){
return new JdbcTokenStore(dataSource);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//指定使用的TokenStore,tokenStore用来存取token,默认使用InMemoryTokenStore
//这里使用redis存储token
.tokenStore(jdbcTokenStore)
;
}

使用JWT替换默认令牌#

1 JWT特点#

(1)自包含 — ★ JWT里面包含信息 (2)可扩展 — 即JWT生成时会包含一些默认信息,我们可以再往JWT里加入一些额外的信息 (3)密签 — 在生成JWT时指定一个密钥,然后校验时再拿着这个密钥进行校验(看了源码后可以理解的更清楚)

2 使用JWT替换默认令牌#

2.1 代码开发#

主要包括:

  • 配置TokenStore
TokenStore只负责token的存取,存到redis用到的TokenStore的实现类为RedisTokenStore,存到mysql用到的实现类为JdbcTokenStore。。。生成的token为JWT时要使用JwtTokenStore —》其实JwtTokenStore对对存、取token的操作就是啥也不做,因为jwt的自包含特性,服务端根本没必要去存储token。— 有兴趣的可以追踪一下源码
  • 配置JwtAccessTokenConverter — 真正生产JWT的类
JwtAccessTokenConverter 其实是一个TokenEnhancer。 通过阅读源码可知:TokenEnhancer是对生成的Token进行后续处理的(或者说是对Token进行增强的) 其实JwtAccessTokenConverter就是将默认生成的token做进一步处理使其成为一个JWT。
  • 将JwtTokenStore和JwtAccessTokenConverter设置到token的生成类中

具体代码如下: (1)配置TokenStore和JwtAccessTokenConverter

/**
* <p>DESC: token存储配置</p>
* <p>DATE: 2019-12-02 14:56</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
public class TokenStoreConfig {
private final RedisConnectionFactory redisConnectionFactory;
private final DataSource dataSource;
/***
* RedisTokenStore需要一个连接工厂,这里可以直接注入进来
*/
@Autowired
public TokenStoreConfig(RedisConnectionFactory redisConnectionFactory,DataSource dataSource) {
this.redisConnectionFactory = redisConnectionFactory;
this.dataSource = dataSource;
}
/***
* 将RedisTokenStore注入到spring容器
* @return tokenStore
*/
@Bean
@ConditionalOnProperty(prefix = "my-security.oauth2", name = "tokenStore", havingValue = "redis")
public TokenStore redisTokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
/***
* 将jdbcTokenStore注入到spring容器
* @return tokenStore
*/
@Bean
@ConditionalOnProperty(prefix = "my-security.oauth2", name = "tokenStore", havingValue = "jdbc")
public TokenStore jdbcTokenStore() {
return new JdbcTokenStore(dataSource);
}
/***
* 当yml配置文件里配置了my-security.oauth2.tokenStore = jwt 或者 根本就没配置该属性时 ---> 下面的配置生效
*/
@ConditionalOnProperty(prefix = "my-security.oauth2", name = "tokenStore", havingValue = "jwt", matchIfMissing = true)
@Configuration
public static class JwtConfig {
@Resource
private MySecurityProperties properties;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtTokenEnhancer());
}
@Bean
public JwtAccessTokenConverter jwtTokenEnhancer() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(properties.getOauth2().getJwtSignKey());
return converter;
}
}
}

(2)将JwtTokenStore和JwtAccessTokenConverter设置到token的生成类中

核心代码

private final TokenStore tokenStore;
private final JwtAccessTokenConverter jwtAccessTokenConverter;
endpoints
//指定使用的TokenStore,tokenStore用来存取token,默认使用InMemoryTokenStore
//这里使用redis存储token
.tokenStore(tokenStore)
;
if(jwtAccessTokenConverter != null){
endpoints.accessTokenConverter(jwtAccessTokenConverter);
}

完整代码:

/**
* <p>DESC: 服务配置</p>
* <p>DATE: 2019-11-19 14:26</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableAuthorizationServer
public class ServiceConfig extends AuthorizationServerConfigurerAdapter {
private final DataSource dataSource;
private final TokenStore redisTokenStore;
private final TokenStore jdbcTokenStore;
private final TokenStore tokenStore;
private final JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired(required = false)
public ServiceConfig(DataSource dataSource, TokenStore redisTokenStore,TokenStore jdbcTokenStore,JwtAccessTokenConverter jwtAccessTokenConverter,TokenStore tokenStore) {
this.dataSource = dataSource;
this.redisTokenStore = redisTokenStore;
this.jdbcTokenStore = jdbcTokenStore;
this.jwtAccessTokenConverter = jwtAccessTokenConverter;
this.tokenStore = tokenStore;
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//指定使用的TokenStore,tokenStore用来存取token,默认使用InMemoryTokenStore
//这里使用redis存储token
.tokenStore(tokenStore)
;
if(jwtAccessTokenConverter != null){
endpoints.accessTokenConverter(jwtAccessTokenConverter);
}
}
}

2.2 测试#

(1)获取token

(2)拿着token请求用户信息

通过JWT解析到的Authentication对象 (我代码里的/user/me1和/user/me2是获取Authentication对象)如下。其中principal为一个字符串,但是之前无论用户名+密码模式、短息登陆还是社交登陆过程中生成的Authentication对象里的principal都是一个UserDetails对象。— 》 这是一个很多的区别。

(3)正是由于通过JWT生成的Authentication对象里的principal为一个字符串而不是UserDetails对象的原因,请求下面这个接口将获取不到任何信息

@GetMapping("/me3")
public Object getCurrentUser3(@AuthenticationPrincipal UserDetails user) {
//方式3,只获取User对象
return user;
}

(4)但是下面这样可以

/***
* JWT 情况下获取的principal是一个字符串
* @param user
* @return
*/
@GetMapping("/me4")
public Object getCurrentUser4(@AuthenticationPrincipal String user) {
//方式3,只获取User对象
return user;
}

JWT生成源码解读 + JWT扩展#

1 JWT生成源码解读#

1.1 spring security oauth生成token核心源码#

所在类DefaultTokenServices

private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
//利用UUID当成构造函数的参数生成一个令牌
DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
//获取过期时间并设置到token里
int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
if (validitySeconds > 0) {
token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
}
//设置刷新令牌
token.setRefreshToken(refreshToken);
//设置令牌的权限
token.setScope(authentication.getOAuth2Request().getScope());
//看是否配置了AccessTokenEnhancer的具体实现
//如果配置了对生成的token进行增强 --- 设置Jwt时会讲
return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
}
  • 从这段代码可知spring security oauth默认生成的token其实是利用UUID进行构造生成的,那JWT到底是怎么生成的呢?其实就隐藏在那个return语句里,也就是说JWT的真正生成其实是在accessTokenEnhancer.enhance(token, authentication) 方法里。
  • 上面讲到我们向token生成类中配置了一个JwtAccessTokenConverter,而它其实就是一个TokenEnhancer(即上面代码中的accessTokenEnhancer ),其作用正是调用accessTokenEnhancer.enhance(token, authentication) 方法生成一个JWT。

1.2 JWT生成的核心源码#

下面我们来看一下JWT生成的具体代码: 所在类JwtAccessTokenConverter

public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
//构建jwt情况下真正返回的token对象
DefaultOAuth2AccessToken result = new DefaultOAuth2AccessToken(accessToken);
Map<String, Object> info = new LinkedHashMap<String, Object>(accessToken.getAdditionalInformation());
//将默认生成的access_token取出,并设置到result
String tokenId = result.getValue();
if (!info.containsKey(TOKEN_ID)) {
info.put(TOKEN_ID, tokenId);
}
else {
tokenId = (String) info.get(TOKEN_ID);
}
result.setAdditionalInformation(info);
//真正的生成jwt中的access_token ---》下面会继续讲解
result.setValue(encode(result, authentication));
//下面这块代码是对refreshToken 的处理---不细讲了
OAuth2RefreshToken refreshToken = result.getRefreshToken();
if (refreshToken != null) {
DefaultOAuth2AccessToken encodedRefreshToken = new DefaultOAuth2AccessToken(accessToken);
encodedRefreshToken.setValue(refreshToken.getValue());
// Refresh tokens do not expire unless explicitly of the right type
encodedRefreshToken.setExpiration(null);
try {
Map<String, Object> claims = objectMapper
.parseMap(JwtHelper.decode(refreshToken.getValue()).getClaims());
if (claims.containsKey(TOKEN_ID)) {
encodedRefreshToken.setValue(claims.get(TOKEN_ID).toString());
}
}
catch (IllegalArgumentException e) {
}
Map<String, Object> refreshTokenInfo = new LinkedHashMap<String, Object>(
accessToken.getAdditionalInformation());
refreshTokenInfo.put(TOKEN_ID, encodedRefreshToken.getValue());
refreshTokenInfo.put(ACCESS_TOKEN_ID, tokenId);
encodedRefreshToken.setAdditionalInformation(refreshTokenInfo);
//调用encode方法生成refresh-token
DefaultOAuth2RefreshToken token = new DefaultOAuth2RefreshToken(
encode(encodedRefreshToken, authentication));
//如果配置了refresh-token的过期时间则重新生成一个带过期时间的refresh-token
if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
Date expiration = ((ExpiringOAuth2RefreshToken) refreshToken).getExpiration();
encodedRefreshToken.setExpiration(expiration);
//真正设置refresh-token的过期时间
token = new DefaultExpiringOAuth2RefreshToken(encode(encodedRefreshToken, authentication), expiration);
}
result.setRefreshToken(token);
}
return result;
}

1.3 JWT自包含+密签源码#

encode的具体代码 — 将密钥和一些用户信息、第三方信息、过期时间等放到一起进行编码生成一个字符串 ★ 这就是为什么JWT会包含信息的原因 — 当然从这里也可以看出其实所谓的密签也没有什么神奇的。

所在类JwtAccessTokenConverter

protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
String content;
try {
//将access-token的过期时间、正在授权的用户的用户名、第三方应用的client-id等转成一个json字符串
content = objectMapper.formatMap(tokenConverter.convertAccessToken(accessToken, authentication));
}
catch (Exception e) {
throw new IllegalStateException("Cannot convert access token to JSON", e);
}
//★ 利用我们设置的密签signer和content进行编码生成access-token(有兴趣的可以跟踪一下更详细的代码)
String token = JwtHelper.encode(content, signer).getEncoded();
return token;
}

2 JWT扩展 — 往JWT里加入附加信息#

2.1 代码开发#

上面讲了JWT的三个特性,通过读源码已经了解了自包含+密签的原理,接下来在讲一下如何对JWT进行扩展。 主要需要三步:

  • 自定义一个TokenEnhancer
/**
* <p>DESC: jwt增强器</p>
* <p>DATE: 2019-12-03 17:04</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class MyJwtTokenEnhancer implements TokenEnhancer{
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
HashMap<String, Object> hashMap = new HashMap<>();
hashMap.put("chinese","xiaoming");
((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(hashMap);
return accessToken;
}
}
  • 将自定义的TokenEnhancer注入到spring容器
/***
* 将自定义的TokenEnhancer注入到spring容器 --- 》可以覆盖该bean,实现自己的需求
* @return
*/
@Bean
@ConditionalOnBean(TokenEnhancer.class)
public TokenEnhancer jwtTokenEnhancer() {
return new NrscJwtTokenEnhancer();
}
  • 将自定义的TokenEnhancer设置到token的生成类中
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//指定使用的TokenStore,tokenStore用来存取token,默认使用InMemoryTokenStore
.tokenStore(tokenStore)
//下面的配置主要用来指定"对正在进行授权的用户进行认证+校验"的类
//在实现了AuthorizationServerConfigurerAdapter适配器类后,必须指定下面两项
.authenticationManager(authenticationManager)
.userDetailsService(NRSCDetailsService);
if (jwtAccessTokenConverter != null && jwtTokenEnhancer != null) {
//配置增强器链
// 并利用增强器链将生成jwt的TokenEnhancer(jwtAccessTokenConverter)
// 和我们扩展的TokenEnhancer设置到token的生成类中
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancers = new ArrayList<>();
//注意调用顺序,一定是,先增强,然后再,转化(编码)
enhancers.add(jwtTokenEnhancer);
enhancers.add(jwtAccessTokenConverter);
enhancerChain.setTokenEnhancers(enhancers);
endpoints.tokenEnhancer(enhancerChain);
}
}

3 JWT扩展 — 后端从JWT里取出附加信息#

通过JWT解析获得的Authentication对象里并不会包含我们自定义的扩展信息 但我们可以借助工具类自己解析JWT获取到

主要步骤如下:

  • 加入依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
  • 从请求头中获取到JWT,并利用Jwts工具借助密钥解析获得到扩展信息
@GetMapping("/me2")
public Object getCurrentUser2(Authentication authentication, HttpServletRequest request) throws UnsupportedEncodingException {
//方式2---方式1的简写版
//从请求头中获取到JWT
String token = StringUtils.substringAfter(request.getHeader("Authorization"), "bearer ");
//借助密钥对JWT进行解析,注意由于JWT生成时编码格式用的UTF-8(看源码可以追踪到)
//但Jwts工具用到的默认编码格式不是,所以要设置其编码格式为UTF-8
Claims claims = Jwts.parser()
.setSigningKey(mySecurityProperties.getOauth2().getJwtSignKey().getBytes("UTF-8"))
.parseClaimsJws(token).getBody();
//取出扩展信息,并打印
String company = (String) claims.get("chinese");
System.err.println(company);
return authentication;
}

4、refresh - token的使用#

refresh-token一般设置的时间较长 —》比如说一个星期。它主要的用途在4里已经讲到,即在access-token过期后让客户端通过refresh-token无感知的获取到一个新的令牌,其请求路径和请求参数如下:

注意:项目中需要以下注意的点

  • 访问该路径,报 Encoded password does not look like BCrypt

问题出在:

刷新token去请求的时候,会调用配置的密码器去匹配,之前的client_secret 是没有加密的

(1)需要使用密码器加密。

(2)AppSuccessHandler处理器需要使用密码器匹配一下。

ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
if (clientDetails == null) {
throw new UnapprovedClientAuthenticationException("clientid不存在");
} else if (!passwordEncoder.matches(secret,clientDetails.getClientSecret())) {
throw new UnapprovedClientAuthenticationException("secret不匹配");
}

到这里获取刷新token成功。

  • AuthenticationManager注入到spring容器
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class AppWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private OpenIdAuthenticationSecurityConfig openIdAuthenticationSecurityConfig;
@Resource
private SpringSocialConfigurer socialConfigurer;
/***
* 真正将AuthenticationManager注入到spring容器
* @return AuthenticationManager
* @throws Exception 异常
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
  • 配置endpoint
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//指定使用的TokenStore,tokenStore用来存取token,默认使用InMemoryTokenStore
//这里使用redis存储token
.tokenStore(tokenStore)
//如果要使用refresh_token需要,根据解析的token去查找用户
.userDetailsService(userDetailsService)
.authenticationManager(authenticationManager)
  • 默认jwt是默认添加 user_name到 token中

我们之前用的openid传入token中,所以它拿着openID调用userdetailservice获取用户,获取不到报错。

骚操作:

在app成功处理器这里,将openid获取的用户名,构建一个新的认证OpenIdAuthenticationToken。传入。

//通过openID去获取用户名字
OpenIdAuthenticationToken admin = new OpenIdAuthenticationToken("admin", "", authentication.getAuthorities());
//new出一个OAuth2Authentication对象
OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, admin);

SpringCloud OAuth2#

oauth2#

什么是 oAuth#

oAuth 协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是 oAuth 的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此 oAuth 是安全的。

什么是 Spring Security#

Spring Security 是一个安全框架,前身是 Acegi Security,能够为 Spring 企业应用系统提供声明式的安全访问控制。Spring Security 基于 Servlet 过滤器、IoC 和 AOP,为 Web 请求和方法调用提供身份确认和授权处理,避免了代码耦合,减少了大量重复代码工作。

为什么需要 oAuth2#

应用场景#

我们假设你有一个“云笔记”产品,并提供了“云笔记服务”和“云相册服务”,此时用户需要在不同的设备(PC、Android、iPhone、TV、Watch)上去访问这些“资源”(笔记,图片)

那么用户如何才能访问属于自己的那部分资源呢?此时传统的做法就是提供自己的账号和密码给我们的“云笔记”,登录成功后就可以获取资源了。但这样的做法会有以下几个问题:

  • “云笔记服务”和“云相册服务”会分别部署,难道我们要分别登录吗?
  • 如果有第三方应用程序想要接入我们的“云笔记”,难道需要用户提供账号和密码给第三方应用程序,让他记录后再访问我们的资源吗?
  • 用户如何限制第三方应用程序在我们“云笔记”的授权范围和使用期限?难道把所有资料都永久暴露给它吗?
  • 如果用户修改了密码收回了权限,那么所有第三方应用程序会全部失效。
  • 只要有一个接入的第三方应用程序遭到破解,那么用户的密码就会泄露,后果不堪设想。

为了解决如上问题,oAuth 应用而生。

名词解释#

  • 第三方应用程序(Third-party application): 又称之为客户端(client),比如上节中提到的设备(PC、Android、iPhone、TV、Watch),我们会在这些设备中安装我们自己研发的 APP。又比如我们的产品想要使用 QQ、微信等第三方登录。对我们的产品来说,QQ、微信登录是第三方登录系统。我们又需要第三方登录系统的资源(头像、昵称等)。对于 QQ、微信等系统我们又是第三方应用程序。
  • HTTP 服务提供商(HTTP service): 我们的云笔记产品以及 QQ、微信等都可以称之为“服务提供商”。
  • 资源所有者(Resource Owner): 又称之为用户(user)。
  • 用户代理(User Agent): 比如浏览器,代替用户去访问这些资源。
  • 认证服务器(Authorization server): 即服务提供商专门用来处理认证的服务器,简单点说就是登录功能(验证用户的账号密码是否正确以及分配相应的权限)
  • 资源服务器(Resource server): 即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。简单点说就是资源的访问入口,比如上节中提到的“云笔记服务”和“云相册服务”都可以称之为资源服务器。

交互过程#

oAuth 在 “客户端” 与 “服务提供商” 之间,设置了一个授权层(authorization layer)。“客户端” 不能直接登录 “服务提供商”,只能登录授权层,以此将用户与客户端区分开来。“客户端” 登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。“客户端” 登录授权层以后,“服务提供商” 根据令牌的权限范围和有效期,向 “客户端” 开放用户储存的资料。

开放平台#

交互模型#

交互模型涉及三方:

  • 资源拥有者:用户
  • 客户端:APP
  • 服务提供方:包含两个角色
    • 认证服务器
    • 资源服务器

认证服务器#

认证服务器负责对用户进行认证,并授权给客户端权限。认证很容易实现(验证账号密码即可),问题在于如何授权。比如我们使用第三方登录 “有道云笔记”,你可以看到如使用 QQ 登录的授权页面上有 “有道云笔记将获得以下权限” 的字样以及权限信息

认证服务器需要知道请求授权的客户端的身份以及该客户端请求的权限。我们可以为每一个客户端预先分配一个 id,并给每个 id 对应一个名称以及权限信息。这些信息可以写在认证服务器上的配置文件里。然后,客户端每次打开授权页面的时候,把属于自己的 id 传过来,如:

http://www.funtl.com/login?client_id=yourClientId

随着时间的推移和业务的增长,会发现,修改配置的工作消耗了太多的人力。有没有办法把这个过程自动化起来,把人工从这些繁琐的操作中解放出来?当开始考虑这一步,开放平台的成型也就是水到渠成的事情了。

oAuth2 开放平台#

开放平台是由 oAuth2.0 协议衍生出来的一个产品。它的作用是让客户端自己去这上面进行注册、申请,通过之后系统自动分配 client_id ,并完成配置的自动更新(通常是写进数据库)。

客户端要完成申请,通常需要填写客户端程序的类型(Web、App 等)、企业介绍、执照、想要获取的权限等等信息。这些信息在得到服务提供方的人工审核通过后,开发平台就会自动分配一个 client_id 给客户端了。

到这里,已经实现了登录认证、授权页的信息展示。那么接下来,当用户成功进行授权之后,认证服务器需要把产生的 access_token 发送给客户端,方案如下:

  • 让客户端在开放平台申请的时候,填写一个 URL,例如:http://www.funtl.com
  • 每次当有用户授权成功之后,认证服务器将页面重定向到这个 URL(回调),并带上 access_token,例如:http://www.funtl.com?access_token=123456789
  • 客户端接收到了这个 access_token,而且认证服务器的授权动作已经完成,刚好可以把程序的控制权转交回客户端,由客户端决定接下来向用户展示什么内容

令牌的访问与刷新#

Access Token#

Access Token 是客户端访问资源服务器的令牌。拥有这个令牌代表着得到用户的授权。然而,这个授权应该是 临时 的,有一定有效期。这是因为,Access Token 在使用的过程中 可能会泄露。给 Access Token 限定一个 较短的有效期 可以降低因 Access Token 泄露而带来的风险。

然而引入了有效期之后,客户端使用起来就不那么方便了。每当 Access Token 过期,客户端就必须重新向用户索要授权。这样用户可能每隔几天,甚至每天都需要进行授权操作。这是一件非常影响用户体验的事情。希望有一种方法,可以避免这种情况。

于是 oAuth2.0 引入了 Refresh Token 机制

Refresh Token#

Refresh Token 的作用是用来刷新 Access Token。认证服务器提供一个刷新接口,例如:

http://www.funtl.com/refresh?refresh_token=&client_id=

传入 refresh_tokenclient_id,认证服务器验证通过后,返回一个新的 Access Token。为了安全,oAuth2.0 引入了两个措施:

  • oAuth2.0 要求,Refresh Token 一定是保存在客户端的服务器上 ,而绝不能存放在狭义的客户端(例如 App、PC 端软件)上。调用 refresh 接口的时候,一定是从服务器到服务器的访问。
  • oAuth2.0 引入了 client_secret 机制。即每一个 client_id 都对应一个 client_secret。这个 client_secret 会在客户端申请 client_id 时,随 client_id 一起分配给客户端。客户端必须把 client_secret 妥善保管在服务器上,决不能泄露。刷新 Access Token 时,需要验证这个 client_secret

实际上的刷新接口类似于:

http://www.funtl.com/refresh?refresh_token=&client_id=&client_secret=

以上就是 Refresh Token 机制。Refresh Token 的有效期非常长,会在用户授权时,随 Access Token 一起重定向到回调 URL,传递给客户端。

客户端授权模式#

概述#

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。oAuth 2.0 定义了四种授权方式。

  • implicit:简化模式,不推荐使用
  • authorization code:授权码模式
  • resource owner password credentials:密码模式
  • client credentials:客户端模式

简化模式#

简化模式适用于纯静态页面应用。所谓纯静态页面应用,也就是应用没有在服务器上执行代码的权限(通常是把代码托管在别人的服务器上),只有前端 JS 代码的控制权。

这种场景下,应用是没有持久化存储的能力的。因此,按照 oAuth2.0 的规定,这种应用是拿不到 Refresh Token 的。其整个授权流程如下

该模式下,access_token 容易泄露且不可刷新

授权码模式#

授权码模式适用于有自己的服务器的应用,它是一个一次性的临时凭证,用来换取 access_tokenrefresh_token。认证服务器提供了一个类似这样的接口:

https://www.funtl.com/exchange?code=&client_id=&client_secret=

需要传入 codeclient_id 以及 client_secret。验证通过后,返回 access_tokenrefresh_token。一旦换取成功,code 立即作废,不能再使用第二次。流程图如下:

这个 code 的作用是保护 token 的安全性。上一节说到,简单模式下,token 是不安全的。这是因为在第 4 步当中直接把 token 返回给应用。而这一步容易被拦截、窃听。引入了 code 之后,即使攻击者能够窃取到 code,但是由于他无法获得应用保存在服务器的 client_secret,因此也无法通过 code 换取 token。而第 5 步,为什么不容易被拦截、窃听呢?这是因为,首先,这是一个从服务器到服务器的访问,黑客比较难捕捉到;其次,这个请求通常要求是 https 的实现。即使能窃听到数据包也无法解析出内容。

有了这个 code,token 的安全性大大提高。因此,oAuth2.0 鼓励使用这种方式进行授权,而简单模式则是在不得已情况下才会使用。

密码模式#

密码模式中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向 “服务商提供商” 索要授权。在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分。

一个典型的例子是同一个企业内部的不同产品要使用本企业的 oAuth2.0 体系。在有些情况下,产品希望能够定制化授权页面。由于是同个企业,不需要向用户展示“xxx将获取以下权限”等字样并询问用户的授权意向,而只需进行用户的身份认证即可。这个时候,由具体的产品团队开发定制化的授权界面,接收用户输入账号密码,并直接传递给鉴权服务器进行授权即可。

有一点需要特别注意的是,在第 2 步中,认证服务器需要对客户端的身份进行验证,确保是受信任的客户端。

客户端模式#

如果信任关系再进一步,或者调用者是一个后端的模块,没有用户界面的时候,可以使用客户端模式。鉴权服务器直接对客户端进行身份验证,验证通过后,返回 token。

创建项目工程#

创建一个名为 hello-spring-security-oauth2 工程项目,POM 如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.funtl</groupId>
<artifactId>hello-spring-security-oauth2</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<url>http://www.funtl.com</url>
<modules>
<module>hello-spring-security-oauth2-dependencies</module>
</modules>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<licenses>
<license>
<name>Apache 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>
<developers>
<developer>
<id>liwemin</id>
<name>Lusifer Lee</name>
<email>[email protected]</email>
</developer>
</developers>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.funtl</groupId>
<artifactId>hello-spring-security-oauth2-dependencies</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<profiles>
<profile>
<id>default</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<spring-javaformat.version>0.0.12</spring-javaformat.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>io.spring.javaformat</groupId>
<artifactId>spring-javaformat-maven-plugin</artifactId>
<version>${spring-javaformat.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/*Tests.java</include>
</includes>
<excludes>
<exclude>**/Abstract*.java</exclude>
</excludes>
<systemPropertyVariables>
<java.security.egd>file:/dev/./urandom</java.security.egd>
<java.awt.headless>true</java.awt.headless>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>enforce-rules</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>commons-logging:*:*</exclude>
</excludes>
<searchTransitive>true</searchTransitive>
</bannedDependencies>
</rules>
<fail>true</fail>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-install-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
<inherited>true</inherited>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<repositories>
<repository>
<id>spring-milestone</id>
<name>Spring Milestone</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-snapshot</id>
<name>Spring Snapshot</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestone</id>
<name>Spring Milestone</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
<pluginRepository>
<id>spring-snapshot</id>
<name>Spring Snapshot</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>

依赖管理项目#

创建一个名为 hello-spring-security-oauth2-dependencies 项目,POM 如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.funtl</groupId>
<artifactId>hello-spring-security-oauth2-dependencies</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<url>http://www.funtl.com</url>
<properties>
<spring-cloud.version>Greenwich.RELEASE</spring-cloud.version>
</properties>
<licenses>
<license>
<name>Apache 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>
<developers>
<developer>
<id>liwemin</id>
<name>Lusifer Lee</name>
<email>[email protected]</email>
</developer>
</developers>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>spring-milestone</id>
<name>Spring Milestone</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-snapshot</id>
<name>Spring Snapshot</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestone</id>
<name>Spring Milestone</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
<pluginRepository>
<id>spring-snapshot</id>
<name>Spring Snapshot</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>

创建认证服务器#

POM#

创建一个名为 hello-spring-security-oauth2-server 项目,POM 如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.funtl</groupId>
<artifactId>hello-spring-security-oauth2</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>hello-spring-security-oauth2-server</artifactId>
<url>http://www.funtl.com</url>
<licenses>
<license>
<name>Apache 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>
<developers>
<developer>
<id>liwemin</id>
<name>Lusifer Lee</name>
<email>[email protected]</email>
</developer>
</developers>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.funtl.spring.security.oauth2.server.OAuth2ServerApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

Application#

package com.funtl.spring.security.oauth2.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OAuth2ServerApplication {
public static void main(String[] args) {
SpringApplication.run(OAuth2ServerApplication.class, args);
}
}

基于内存存储令牌#

概述#

本章节基于 内存存储令牌 的模式用于演示最基本的操作,帮助大家快速理解 oAuth2 认证服务器中 “认证”、“授权”、“访问令牌” 的基本概念

操作流程

  • 配置认证服务器
    • 配置客户端信息:ClientDetailsServiceConfigurer
      • inMemory:内存配置
      • withClient:客户端标识
      • secret:客户端安全码
      • authorizedGrantTypes:客户端授权类型
      • scopes:客户端授权范围
      • redirectUris:注册回调地址
  • 配置 Web 安全
  • 通过 GET 请求访问认证服务器获取授权码
    • 端点:/oauth/authorize
  • 通过 POST 请求利用授权码访问认证服务器获取令牌
    • 端点:/oauth/token

默认的端点 URL

  • /oauth/authorize:授权端点
  • /oauth/token:令牌端点
  • /oauth/confirm_access:用户确认授权提交端点
  • /oauth/error:授权服务错误信息端点
  • /oauth/check_token:用于资源服务访问的令牌解析端点
  • /oauth/token_key:提供公有密匙的端点,如果你使用 JWT 令牌的话

服务器安全配置#

创建一个类继承 WebSecurityConfigurerAdapter 并添加相关注解:

  • @Configuration
  • @EnableWebSecurity
  • @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true):全局方法拦截
package com.funtl.spring.security.oauth2.server.configure;
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.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
// 配置默认的加密方式
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 在内存中创建用户
auth.inMemoryAuthentication()
.withUser("user").password(passwordEncoder().encode("123456")).roles("USER")
.and()
.withUser("admin").password(passwordEncoder().encode("admin888")).roles("ADMIN");
}
}

配置认证服务器#

创建一个类继承 AuthorizationServerConfigurerAdapter 并添加相关注解:

  • @Configuration
  • @EnableAuthorizationServer
package com.funtl.spring.security.oauth2.server.configure;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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;
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 配置客户端
clients
// 使用内存设置
.inMemory()
// client_id
.withClient("client")
// client_secret
.secret(passwordEncoder.encode("secret"))
// 授权类型
.authorizedGrantTypes("authorization_code")
// 授权范围
.scopes("app")
// 注册回调地址
.redirectUris("https://www.baidu.com");
}
}

application.yml#

spring:
application:
name: oauth2-server
server:
port: 8080

访问获取授权码#

  • 通过浏览器访问
http://localhost:8080/oauth/authorize?client_id=client&response_type=code
  • 第一次访问会跳转到登录页面

  • 验证成功后会询问用户是否授权客户端

  • 选择授权后会跳转到百度,浏览器地址上还会包含一个授权码(code=1JuO6V),浏览器地址栏会显示如下地址:
https://www.baidu.com/?code=1JuO6V

向服务器申请令牌#

  • 通过 CURL 或是 Postman 请求
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d 'grant_type=authorization_code&code=1JuO6V' "http://client:secret@localhost:8080/oauth/token"

  • 得到响应结果如下
{
"access_token": "016d8d4a-dd6e-4493-b590-5f072923c413",
"token_type": "bearer",
"expires_in": 43199,
"scope": "app"
}
  • 通过Java代码发请求获取token

配置好 注册回调地址到自建服务(接受code,并发起请求获取token)。

踩坑

* 获取token也是需要认证的(Authorization),basic模式,传入编码后的 client_id和secret
* key是Authorization,value中的Basic是固定的代表基本认证(Basic后面有一个空格),
* 后面的字符串是认证信息比如client+secret字符串相加做base64(java.util中有)加密之后的加密串。
* postman中请求可以直接写 http://client:secret@localhost:8090/oauth/token
* postman会自动解析,并加上对应请求头(Authorization)
@GetMapping("/code")
public String getCode(@PathParam("code") String code) throws IOException {
log.info("【获取到的授权码】 code={}",code);
String url = "http://localhost:8080/oauth/token";
OkHttpClient client = new OkHttpClient();
RequestBody body = new FormBody.Builder()
.add("grant_type", "authorization_code")
.add("code", code)
.build();
String basic = "client:secret";
String encode = Base64.getEncoder().encodeToString(basic.getBytes());
Request request = new Request.Builder()
.url(url)
.header("Content-Type",MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.addHeader("Connection","keep-alive")
/**
* 获取token也是需要认证的(Authorization),basic模式,传入编码后的 client_id和secret
* key是Authorization,value中的Basic是固定的代表基本认证(Basic后面有一个空格),
* 后面的字符串是认证信息比如client+secret字符串相加做base64加密之后的加密串。
* postman中请求可以直接写 http://client:secret@localhost:8090/oauth/token
* postman会自动解析,并加上对应请求头(Authorization)
*/
.addHeader("Authorization","Basic "+encode)
.post(body)
.build();
Call call = client.newCall(request);
try {
Response response = call.execute();
System.out.println(response.body().string());
} catch (IOException e) {
e.printStackTrace();
}
log.info("【body】={}",body);
return "成功";
}
@GetMapping("/test")
public String test(){
return "test";
}

基于 JDBC 存储令牌#

  • 初始化 oAuth2 相关表

  • 在数据库中配置客户端

  • 配置认证服务器

    • 配置数据源:DataSource

    • 配置令牌存储方式:TokenStore -> JdbcTokenStore

    • 配置客户端读取方式:ClientDetailsService -> JdbcClientDetailsService

    • 配置服务端点信息:

      AuthorizationServerEndpointsConfigurer
      • tokenStore:设置令牌存储方式
    • 配置客户端信息:

      ClientDetailsServiceConfigurer
      • withClientDetails:设置客户端配置读取方式
  • 配置 Web 安全

    • 配置密码加密方式:BCryptPasswordEncoder
    • 配置认证信息:AuthenticationManagerBuilder
  • 通过 GET 请求访问认证服务器获取授权码

    • 端点:/oauth/authorize
  • 通过 POST 请求利用授权码访问认证服务器获取令牌

    • 端点:/oauth/token

默认的端点 URL

  • /oauth/authorize:授权端点
  • /oauth/token:令牌端点
  • /oauth/confirm_access:用户确认授权提交端点
  • /oauth/error:授权服务错误信息端点
  • /oauth/check_token:用于资源服务访问的令牌解析端点
  • /oauth/token_key:提供公有密匙的端点,如果你使用 JWT 令牌的话

初始化 oAuth2 相关表#

使用官方提供的建表脚本初始化 oAuth2 相关表,地址如下:

https://github.com/spring-projects/spring-security-oauth/blob/master/spring-security-oauth2/src/test/resources/schema.sql

由于我们使用的是 MySQL 数据库,默认建表语句中主键为 VARCHAR(256),这超过了最大的主键长度,请手动修改为 128,并用 BLOB 替换语句中的 LONGVARBINARY 类型,修改后的建表脚本如下:

CREATE TABLE `clientdetails` (
`appId` varchar(128) NOT NULL,
`resourceIds` varchar(256) DEFAULT NULL,
`appSecret` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`grantTypes` varchar(256) DEFAULT NULL,
`redirectUrl` varchar(256) DEFAULT NULL,
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additionalInformation` varchar(4096) DEFAULT NULL,
`autoApproveScopes` varchar(256) DEFAULT NULL,
PRIMARY KEY (`appId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `oauth_access_token` (
`token_id` varchar(256) DEFAULT NULL,
`token` blob,
`authentication_id` varchar(128) NOT NULL,
`user_name` varchar(256) DEFAULT NULL,
`client_id` varchar(256) DEFAULT NULL,
`authentication` blob,
`refresh_token` varchar(256) DEFAULT NULL,
PRIMARY KEY (`authentication_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `oauth_approvals` (
`userId` varchar(256) DEFAULT NULL,
`clientId` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`status` varchar(10) DEFAULT NULL,
`expiresAt` timestamp NULL DEFAULT NULL,
`lastModifiedAt` timestamp NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `oauth_client_details` (
`client_id` varchar(128) NOT NULL,
`resource_ids` varchar(256) DEFAULT NULL,
`client_secret` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`authorized_grant_types` varchar(256) DEFAULT NULL,
`web_server_redirect_uri` varchar(256) DEFAULT NULL,
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(256) DEFAULT NULL,
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `oauth_client_token` (
`token_id` varchar(256) DEFAULT NULL,
`token` blob,
`authentication_id` varchar(128) NOT NULL,
`user_name` varchar(256) DEFAULT NULL,
`client_id` varchar(256) DEFAULT NULL,
PRIMARY KEY (`authentication_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `oauth_code` (
`code` varchar(256) DEFAULT NULL,
`authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `oauth_refresh_token` (
`token_id` varchar(256) DEFAULT NULL,
`token` blob,
`authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

在数据库中配置客户端#

在表 oauth_client_details 中增加一条客户端配置记录,需要设置的字段如下:

  • client_id:客户端标识
  • client_secret:客户端安全码,此处不能是明文,需要加密
  • scope:客户端授权范围
  • authorized_grant_types:客户端授权类型
  • web_server_redirect_uri:服务器回调地址

使用 BCryptPasswordEncoder 为客户端安全码加密,代码如下:

package com.funtl.spring.security.oauth2.server;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.junit4.SpringRunner;
@SpringBootTest
@RunWith(SpringRunner.class)
public class OAuth2ServerApplicationTests {
@Test
public void testBCryptPasswordEncoder() {
System.out.println(new BCryptPasswordEncoder().encode("secret"));
}
}

数据库配置客户端效果图如下,授权类型填入 authorization_code

POM#

由于使用了 JDBC 存储,我们需要增加相关依赖,数据库连接池使用 HikariCP

<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikaricp.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<exclusions>
<!-- 排除 tomcat-jdbc 以使用 HikariCP -->
<exclusion>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>

application.yml#

spring:
application:
name: oauth2-server
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.141.130:3306/oauth2?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: 123456
hikari:
minimum-idle: 5
idle-timeout: 600000
maximum-pool-size: 10
auto-commit: true
pool-name: MyHikariCP
max-lifetime: 1800000
connection-timeout: 30000
connection-test-query: SELECT 1
server:
port: 8080

Application#

package com.funtl.spring.security.oauth2.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OAuth2ServerApplication {
public static void main(String[] args) {
SpringApplication.run(OAuth2ServerApplication.class, args);
}
}

配置认证服务器#

package com.funtl.spring.security.oauth2.server.configure;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;
import javax.sql.DataSource;
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Bean
@Primary
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
// 配置数据源(注意,我使用的是 HikariCP 连接池),以上注解是指定数据源,否则会有冲突
return DataSourceBuilder.create().build();
}
@Bean
public TokenStore tokenStore() {
// 基于 JDBC 实现,令牌保存到数据库
return new JdbcTokenStore(dataSource());
}
@Bean
public ClientDetailsService jdbcClientDetailsService() {
// 基于 JDBC 实现,需要事先在数据库配置客户端信息
return new JdbcClientDetailsService(dataSource());
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 设置令牌存储模式
endpoints.tokenStore(tokenStore());
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 客户端配置
clients.withClientDetails(jdbcClientDetailsService());
}
}

访问获取授权码#

  • 通过浏览器访问
http://localhost:8080/oauth/authorize?client_id=client&response_type=code
  • 第一次访问会跳转到登录页面
  • 验证成功后会询问用户是否授权客户端
  • 选择授权后会跳转到百度,浏览器地址上还会包含一个授权码(code=1JuO6V),浏览器地址栏会显示如下地址:
  • https://www.baidu.com/?code=1JuO6V

RBAC 基于角色的访问控制#

概述#

RBAC(Role-Based Access Control,基于角色的访问控制),就是用户通过角色与权限进行关联。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户-角色-权限”的授权模型。在这种模型中,用户与角色之间,角色与权限之间,一般是多对多的关系。(如下图)

目的#

在我们的 oAuth2 系统中,我们需要对系统的所有资源进行权限控制,系统中的资源包括:

  • 静态资源(对象资源):功能操作、数据列
  • 动态资源(数据资源):数据

系统的目的就是对应用系统的所有对象资源和数据资源进行权限控制,比如:功能菜单、界面按钮、数据显示的列、各种行级数据进行权限的操控

对象关系#

权限#

系统的所有权限信息。权限具有上下级关系,是一个树状的结构。如:

  • 系统管理
    • 用户管理
      • 查看用户
      • 新增用户
      • 修改用户
      • 删除用户

用户#

系统的具体操作者,可以归属于一个或多个角色,它与角色的关系是多对多的关系

角色#

为了对许多拥有相似权限的用户进行分类管理,定义了角色的概念,例如系统管理员、管理员、用户、访客等角色。角色具有上下级关系,可以形成树状视图,父级角色的权限是自身及它的所有子角色的权限的综合。父级角色的用户、父级角色的组同理可推。

关系图#

模块图#

表结构#

CREATE TABLE `tb_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) DEFAULT NULL COMMENT '父权限',
`name` varchar(64) NOT NULL COMMENT '权限名称',
`enname` varchar(64) NOT NULL COMMENT '权限英文名称',
`url` varchar(255) NOT NULL COMMENT '授权路径',
`description` varchar(200) DEFAULT NULL COMMENT '备注',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='权限表';
CREATE TABLE `tb_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) DEFAULT NULL COMMENT '父角色',
`name` varchar(64) NOT NULL COMMENT '角色名称',
`enname` varchar(64) NOT NULL COMMENT '角色英文名称',
`description` varchar(200) DEFAULT NULL COMMENT '备注',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='角色表';
CREATE TABLE `tb_role_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_id` bigint(20) NOT NULL COMMENT '角色 ID',
`permission_id` bigint(20) NOT NULL COMMENT '权限 ID',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='角色权限表';
CREATE TABLE `tb_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(64) NOT NULL COMMENT '密码,加密存储',
`phone` varchar(20) DEFAULT NULL COMMENT '注册手机号',
`email` varchar(50) DEFAULT NULL COMMENT '注册邮箱',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`) USING BTREE,
UNIQUE KEY `phone` (`phone`) USING BTREE,
UNIQUE KEY `email` (`email`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='用户表';
CREATE TABLE `tb_user_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户 ID',
`role_id` bigint(20) NOT NULL COMMENT '角色 ID',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='用户角色表';

基于 RBAC 的自定义认证#

概述#

在实际开发中,我们的用户信息都是存在数据库里的,本章节基于 RBAC 模型 将用户的认证信息与数据库对接,实现真正的用户认证与授权

操作流程

继续 基于 JDBC 存储令牌 章节的代码开发

  • 初始化 RBAC 相关表
  • 在数据库中配置“用户”、“角色”、“权限”相关信息
  • 数据库操作使用 tk.mybatis 框架,故需要增加相关依赖
  • 配置 Web 安全
    • 配置使用自定义认证与授权
  • 通过 GET 请求访问认证服务器获取授权码
    • 端点:/oauth/authorize
  • 通过 POST 请求利用授权码访问认证服务器获取令牌
    • 端点:/oauth/token

默认的端点 URL

  • /oauth/authorize:授权端点
  • /oauth/token:令牌端点
  • /oauth/confirm_access:用户确认授权提交端点
  • /oauth/error:授权服务错误信息端点
  • /oauth/check_token:用于资源服务访问的令牌解析端点
  • /oauth/token_key:提供公有密匙的端点,如果你使用 JWT 令牌的话

初始化 RBAC 相关表#

CREATE TABLE `tb_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) DEFAULT NULL COMMENT '父权限',
`name` varchar(64) NOT NULL COMMENT '权限名称',
`enname` varchar(64) NOT NULL COMMENT '权限英文名称',
`url` varchar(255) NOT NULL COMMENT '授权路径',
`description` varchar(200) DEFAULT NULL COMMENT '备注',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=44 DEFAULT CHARSET=utf8 COMMENT='权限表';
insert into `tb_permission`(`id`,`parent_id`,`name`,`enname`,`url`,`description`,`created`,`updated`) values
(37,0,'系统管理','System','/',NULL,'2019-04-04 23:22:54','2019-04-04 23:22:56'),
(38,37,'用户管理','SystemUser','/users/',NULL,'2019-04-04 23:25:31','2019-04-04 23:25:33'),
(39,38,'查看用户','SystemUserView','',NULL,'2019-04-04 15:30:30','2019-04-04 15:30:43'),
(40,38,'新增用户','SystemUserInsert','',NULL,'2019-04-04 15:30:31','2019-04-04 15:30:44'),
(41,38,'编辑用户','SystemUserUpdate','',NULL,'2019-04-04 15:30:32','2019-04-04 15:30:45'),
(42,38,'删除用户','SystemUserDelete','',NULL,'2019-04-04 15:30:48','2019-04-04 15:30:45');
CREATE TABLE `tb_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) DEFAULT NULL COMMENT '父角色',
`name` varchar(64) NOT NULL COMMENT '角色名称',
`enname` varchar(64) NOT NULL COMMENT '角色英文名称',
`description` varchar(200) DEFAULT NULL COMMENT '备注',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=38 DEFAULT CHARSET=utf8 COMMENT='角色表';
insert into `tb_role`(`id`,`parent_id`,`name`,`enname`,`description`,`created`,`updated`) values
(37,0,'超级管理员','admin',NULL,'2019-04-04 23:22:03','2019-04-04 23:22:05');
CREATE TABLE `tb_role_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_id` bigint(20) NOT NULL COMMENT '角色 ID',
`permission_id` bigint(20) NOT NULL COMMENT '权限 ID',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=43 DEFAULT CHARSET=utf8 COMMENT='角色权限表';
insert into `tb_role_permission`(`id`,`role_id`,`permission_id`) values
(37,37,37),
(38,37,38),
(39,37,39),
(40,37,40),
(41,37,41),
(42,37,42);
CREATE TABLE `tb_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(64) NOT NULL COMMENT '密码,加密存储',
`phone` varchar(20) DEFAULT NULL COMMENT '注册手机号',
`email` varchar(50) DEFAULT NULL COMMENT '注册邮箱',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`) USING BTREE,
UNIQUE KEY `phone` (`phone`) USING BTREE,
UNIQUE KEY `email` (`email`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=38 DEFAULT CHARSET=utf8 COMMENT='用户表';
insert into `tb_user`(`id`,`username`,`password`,`phone`,`email`,`created`,`updated`) values
(37,'admin','$2a$10$9ZhDOBp.sRKat4l14ygu/.LscxrMUcDAfeVOEPiYwbcRkoB09gCmi','15888888888','[email protected]','2019-04-04 23:21:27','2019-04-04 23:21:29');
CREATE TABLE `tb_user_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户 ID',
`role_id` bigint(20) NOT NULL COMMENT '角色 ID',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=38 DEFAULT CHARSET=utf8 COMMENT='用户角色表';
insert into `tb_user_role`(`id`,`user_id`,`role_id`) values
(37,37,37);

由于使用了 BCryptPasswordEncoder 的加密方式,故用户密码需要加密,代码如下:

System.out.println(new BCryptPasswordEncoder().encode("123456"));

POM#

数据库操作采用 tk.mybatis:mapper-spring-boot-starter:2.1.5 框架,需增加相关依赖,完整 POM 如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.funtl</groupId>
<artifactId>hello-spring-security-oauth2</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>hello-spring-security-oauth2-server</artifactId>
<url>http://www.funtl.com</url>
<licenses>
<license>
<name>Apache 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>
<developers>
<developer>
<id>liwemin</id>
<name>Lusifer Lee</name>
<email>[email protected]</email>
</developer>
</developers>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<!-- CP -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikaricp.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<exclusions>
<!-- 排除 tomcat-jdbc 以使用 HikariCP -->
<exclusion>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.funtl.spring.security.oauth2.server.OAuth2ServerApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

application.yml#

spring:
application:
name: oauth2-server
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.141.130:3306/oauth2?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: 123456
hikari:
minimum-idle: 5
idle-timeout: 600000
maximum-pool-size: 10
auto-commit: true
pool-name: MyHikariCP
max-lifetime: 1800000
connection-timeout: 30000
connection-test-query: SELECT 1
server:
port: 8080
mybatis:
type-aliases-package: com.funtl.spring.security.oauth2.server.domain
mapper-locations: classpath:mapper/*.xml

Application#

增加了 Mapper 的包扫描配置

package com.funtl.spring.security.oauth2.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import tk.mybatis.spring.annotation.MapperScan;
@SpringBootApplication
@MapperScan(basePackages = "com.funtl.spring.security.oauth2.server.mapper")
public class OAuth2ServerApplication {
public static void main(String[] args) {
SpringApplication.run(OAuth2ServerApplication.class, args);
}
}

关键步骤#

由于本次增加了 MyBatis 相关操作,代码增加较多,可以参考我 GitHub 上的源码,下面仅列出关键步骤及代码

获取用户信息#

目的是为了实现自定义认证授权时可以通过数据库查询用户信息,Spring Security oAuth2 要求使用 username 的方式查询,提供相关用户信息后,认证工作由框架自行完成

package com.funtl.spring.security.oauth2.server.service.impl;
import com.funtl.spring.security.oauth2.server.domain.TbUser;
import com.funtl.spring.security.oauth2.server.mapper.TbUserMapper;
import com.funtl.spring.security.oauth2.server.service.TbUserService;
import org.springframework.stereotype.Service;
import tk.mybatis.mapper.entity.Example;
import javax.annotation.Resource;
@Service
public class TbUserServiceImpl implements TbUserService {
@Resource
private TbUserMapper tbUserMapper;
@Override
public TbUser getByUsername(String username) {
Example example = new Example(TbUser.class);
example.createCriteria().andEqualTo("username", username);
return tbUserMapper.selectOneByExample(example);
}
}

获取用户权限信息#

认证成功后需要给用户授权,具体的权限已经存储在数据库里了

package com.funtl.spring.security.oauth2.server.mapper;
import com.funtl.spring.security.oauth2.server.domain.TbPermission;
import org.apache.ibatis.annotations.Param;
import tk.mybatis.mapper.MyMapper;
import java.util.List;
public interface TbPermissionMapper extends MyMapper<TbPermission> {
List<TbPermission> selectByUserId(@Param("id") Long id);
}

userdetailservice实现类

@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Resource
private TbUserMapper tbUserMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
QueryWrapper<TbUser> wrapper = new QueryWrapper<>();
wrapper.eq("username",s);
TbUser tbUser = tbUserMapper.selectOne(wrapper);
List<GrantedAuthority> list= Lists.newArrayList();
if(tbUser != null){
List<TbPermission> userPermission = tbUserMapper.getUserPermission(tbUser.getId());
userPermission.forEach(tbPermission -> {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(tbPermission.getEnname());
list.add(simpleGrantedAuthority);
});
return new User(tbUser.getUsername(),tbUser.getPassword(),list);
}
return null;
}
}

自定义的WebSecurityConfiguration

注入userdetailservice实现类

@Bean
public UserDetailsService userDetailService(){
return new UserDetailServiceImpl();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// super.configure(auth);
// auth.inMemoryAuthentication()
// .withUser("user").password(passwordEncoder().encode("123456")).roles("USER")
// .and()
// .withUser("admin").password(passwordEncoder().encode("admin888")).roles("ADMIN");
auth.userDetailsService(userDetailService());
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.funtl.spring.security.oauth2.server.mapper.TbPermissionMapper">
<resultMap id="BaseResultMap" type="com.funtl.spring.security.oauth2.server.domain.TbPermission">
<[email protected] generated on Tue Jul 16 00:41:48 CST 2019.-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="parent_id" jdbcType="BIGINT" property="parentId" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="enname" jdbcType="VARCHAR" property="enname" />
<result column="url" jdbcType="VARCHAR" property="url" />
<result column="description" jdbcType="VARCHAR" property="description" />
<result column="created" jdbcType="TIMESTAMP" property="created" />
<result column="updated" jdbcType="TIMESTAMP" property="updated" />
</resultMap>
<sql id="Base_Column_List">
<[email protected] generated on Tue Jul 16 00:41:48 CST 2019.-->
id, parent_id, `name`, enname, url, description, created, updated
</sql>
<select id="selectByUserId" resultMap="BaseResultMap">
SELECT
p.*
FROM
tb_user AS u
LEFT JOIN tb_user_role AS ur
ON u.id = ur.user_id
LEFT JOIN tb_role AS r
ON r.id = ur.role_id
LEFT JOIN tb_role_permission AS rp
ON r.id = rp.role_id
LEFT JOIN tb_permission AS p
ON p.id = rp.permission_id
WHERE u.id = #{id}
</select>
</mapper>

创建资源服务器#

  • 初始化资源服务器数据库
  • POM 所需依赖同认证服务器
  • 配置资源服务器
  • 配置资源(Controller)

初始化资源服务器数据库#

CREATE TABLE `tb_content` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`category_id` bigint(20) NOT NULL COMMENT '内容类目ID',
`title` varchar(200) DEFAULT NULL COMMENT '内容标题',
`sub_title` varchar(100) DEFAULT NULL COMMENT '子标题',
`title_desc` varchar(500) DEFAULT NULL COMMENT '标题描述',
`url` varchar(500) DEFAULT NULL COMMENT '链接',
`pic` varchar(300) DEFAULT NULL COMMENT '图片绝对路径',
`pic2` varchar(300) DEFAULT NULL COMMENT '图片2',
`content` text COMMENT '内容',
`created` datetime DEFAULT NULL,
`updated` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `category_id` (`category_id`),
KEY `updated` (`updated`)
) ENGINE=InnoDB AUTO_INCREMENT=42 DEFAULT CHARSET=utf8;
insert into `tb_content`(`id`,`category_id`,`title`,`sub_title`,`title_desc`,`url`,`pic`,`pic2`,`content`,`created`,`updated`) values
(28,89,'标题','子标题','标题说明','http://www.jd.com',NULL,NULL,NULL,'2019-04-07 00:56:09','2019-04-07 00:56:11'),
(29,89,'ad2','ad2','ad2','http://www.baidu.com',NULL,NULL,NULL,'2019-04-07 00:56:13','2019-04-07 00:56:15'),
(30,89,'ad3','ad3','ad3','http://www.sina.com.cn',NULL,NULL,NULL,'2019-04-07 00:56:17','2019-04-07 00:56:19'),
(31,89,'ad4','ad4','ad4','http://www.funtl.com',NULL,NULL,NULL,'2019-04-07 00:56:22','2019-04-07 00:56:25');
CREATE TABLE `tb_content_category` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '类目ID',
`parent_id` bigint(20) DEFAULT NULL COMMENT '父类目ID=0时,代表的是一级的类目',
`name` varchar(50) DEFAULT NULL COMMENT '分类名称',
`status` int(1) DEFAULT '1' COMMENT '状态。可选值:1(正常),2(删除)',
`sort_order` int(4) DEFAULT NULL COMMENT '排列序号,表示同级类目的展现次序,如数值相等则按名称次序排列。取值范围:大于零的整数',
`is_parent` tinyint(1) DEFAULT '1' COMMENT '该类目是否为父类目,1为true,0为false',
`created` datetime DEFAULT NULL COMMENT '创建时间',
`updated` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `parent_id` (`parent_id`,`status`) USING BTREE,
KEY `sort_order` (`sort_order`)
) ENGINE=InnoDB AUTO_INCREMENT=98 DEFAULT CHARSET=utf8 COMMENT='内容分类';
insert into `tb_content_category`(`id`,`parent_id`,`name`,`status`,`sort_order`,`is_parent`,`created`,`updated`) values
(30,0,'LeeShop',1,1,1,'2015-04-03 16:51:38','2015-04-03 16:51:40'),
(86,30,'首页',1,1,1,'2015-06-07 15:36:07','2015-06-07 15:36:07'),
(87,30,'列表页面',1,1,1,'2015-06-07 15:36:16','2015-06-07 15:36:16'),
(88,30,'详细页面',1,1,1,'2015-06-07 15:36:27','2015-06-07 15:36:27'),
(89,86,'大广告',1,1,0,'2015-06-07 15:36:38','2015-06-07 15:36:38'),
(90,86,'小广告',1,1,0,'2015-06-07 15:36:45','2015-06-07 15:36:45'),
(91,86,'商城快报',1,1,0,'2015-06-07 15:36:55','2015-06-07 15:36:55'),
(92,87,'边栏广告',1,1,0,'2015-06-07 15:37:07','2015-06-07 15:37:07'),
(93,87,'页头广告',1,1,0,'2015-06-07 15:37:17','2015-06-07 15:37:17'),
(94,87,'页脚广告',1,1,0,'2015-06-07 15:37:31','2015-06-07 15:37:31'),
(95,88,'边栏广告',1,1,0,'2015-06-07 15:37:56','2015-06-07 15:37:56'),
(96,86,'中广告',1,1,1,'2015-07-25 18:58:52','2015-07-25 18:58:52'),
(97,96,'中广告1',1,1,0,'2015-07-25 18:59:43','2015-07-25 18:59:43');

POM#

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.funtl</groupId>
<artifactId>hello-spring-security-oauth2</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>hello-spring-security-oauth2-resource</artifactId>
<url>http://www.funtl.com</url>
<licenses>
<license>
<name>Apache 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>
<developers>
<developer>
<id>liwemin</id>
<name>Lusifer Lee</name>
<email>[email protected]</email>
</developer>
</developers>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<!-- CP -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikaricp.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<exclusions>
<!-- 排除 tomcat-jdbc 以使用 HikariCP -->
<exclusion>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.funtl.spring.security.oauth2.resource.OAuth2ResourceApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

application.yml#

spring:
application:
name: oauth2-resource
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.141.130:3306/oauth2_resource?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: 123456
hikari:
minimum-idle: 5
idle-timeout: 600000
maximum-pool-size: 10
auto-commit: true
pool-name: MyHikariCP
max-lifetime: 1800000
connection-timeout: 30000
connection-test-query: SELECT 1
security:
oauth2:
client:
client-id: client
client-secret: secret
access-token-uri: http://localhost:8080/oauth/token
user-authorization-uri: http://localhost:8080/oauth/authorize
resource:
token-info-uri: http://localhost:8080/oauth/check_token
server:
port: 8081
servlet:
context-path: /contents
mybatis:
type-aliases-package: com.funtl.spring.security.oauth2.resource.domain
mapper-locations: classpath:mapper/*.xml
logging:
level:
root: INFO
org.springframework.web: INFO
org.springframework.security: INFO
org.springframework.security.oauth2: INFO

Application#

package com.funtl.spring.security.oauth2.resource;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import tk.mybatis.spring.annotation.MapperScan;
@SpringBootApplication
@MapperScan(basePackages = "com.funtl.spring.security.oauth2.resource.mapper")
public class OAuth2ResourceApplication {
public static void main(String[] args) {
SpringApplication.run(OAuth2ResourceApplication.class, args);
}
}

配置资源服务器#

创建一个类继承 ResourceServerConfigurerAdapter 并添加相关注解:

  • @Configuration
  • @EnableResourceServer:资源服务器
  • @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true):全局方法拦截
package com.funtl.spring.security.oauth2.resource.configure;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http
.exceptionHandling()
.and()
// Session 创建策略
// ALWAYS 总是创建 HttpSession
// IF_REQUIRED Spring Security 只会在需要时创建一个 HttpSession
// NEVER Spring Security 不会创建 HttpSession,但如果它已经存在,将可以使用 HttpSession
// STATELESS Spring Security 永远不会创建 HttpSession,它不会使用 HttpSession 来获取 SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 以下为配置所需保护的资源路径及权限,需要与认证服务器配置的授权部分对应
.antMatchers("/").hasAuthority("SystemContent")
.antMatchers("/view/**").hasAuthority("SystemContentView")
.antMatchers("/insert/**").hasAuthority("SystemContentInsert")
.antMatchers("/update/**").hasAuthority("SystemContentUpdate")
.antMatchers("/delete/**").hasAuthority("SystemContentDelete");
}
}

认证服务器配置忽略路径

web.ignoring().antMatchers("/oauth/check_token");

package com.funtl.spring.security.oauth2.server.config;
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.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* <p>DESC: 服务器安全配置</p>
* <p>DATE: 2019-11-01 21:14</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
//设置密码
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// super.configure(auth);
auth.inMemoryAuthentication()
.withUser("user").password(passwordEncoder().encode("123456")).roles("USER")
.and()
.withUser("admin").password(passwordEncoder().encode("admin888")).roles("ADMIN");
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/oauth/check_token");
}
}

数据传输对象#

创建一个名为 ResponseResult 的通用数据传输对象

package com.funtl.spring.security.oauth2.resource.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 通用的返回对象
*
* @param <T>
*/
@Data
public class ResponseResult<T> implements Serializable {
private static final long serialVersionUID = 3468352004150968551L;
/**
* 状态码
*/
private Integer state;
/**
* 消息
*/
private String message;
/**
* 返回对象
*/
private T data;
public ResponseResult() {
super();
}
public ResponseResult(Integer state) {
super();
this.state = state;
}
public ResponseResult(Integer state, String message) {
super();
this.state = state;
this.message = message;
}
public ResponseResult(Integer state, Throwable throwable) {
super();
this.state = state;
this.message = throwable.getMessage();
}
public ResponseResult(Integer state, T data) {
super();
this.state = state;
this.data = data;
}
public ResponseResult(Integer state, String message, T data) {
super();
this.state = state;
this.message = message;
this.data = data;
}
public Integer getState() {
return state;
}
public void setState(Integer state) {
this.state = state;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((data == null) ? 0 : data.hashCode());
result = prime * result + ((message == null) ? 0 : message.hashCode());
result = prime * result + ((state == null) ? 0 : state.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
ResponseResult<?> other = (ResponseResult<?>) obj;
if (data == null) {
if (other.data != null) {
return false;
}
} else if (!data.equals(other.data)) {
return false;
}
if (message == null) {
if (other.message != null) {
return false;
}
} else if (!message.equals(other.message)) {
return false;
}
if (state == null) {
if (other.state != null) {
return false;
}
} else if (!state.equals(other.state)) {
return false;
}
return true;
}
}

Controller#

package com.funtl.spring.security.oauth2.resource.controller;
import com.funtl.spring.security.oauth2.resource.domain.TbContent;
import com.funtl.spring.security.oauth2.resource.dto.ResponseResult;
import com.funtl.spring.security.oauth2.resource.service.TbContentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class TbContentController {
@Autowired
private TbContentService tbContentService;
@GetMapping(value = "/")
public ResponseResult<List<TbContent>> list() {
List<TbContent> tbContents = tbContentService.selectAll();
return new ResponseResult<List<TbContent>>(HttpStatus.OK.value(), HttpStatus.OK.toString(), tbContents);
}
}

访问资源#

访问获取授权码#

  • 通过浏览器访问

http://localhost:8080/oauth/authorize?client_id=client&response_type=code

  • 第一次访问会跳转到登录页面
  • 验证成功后会询问用户是否授权客户端
  • 选择授权后会跳转到百度,浏览器地址上还会包含一个授权码(code=1JuO6V),浏览器地址栏会显示如下地址:

https://www.baidu.com/?code=1JuO6V

向服务器申请令牌#

  • 通过 CURL 或是 Postman 请求
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d 'grant_type=authorization_code&code=1JuO6V' "http://client:secret@localhost:8080/oauth/token"
  • 得到响应结果如下
{
"access_token": "016d8d4a-dd6e-4493-b590-5f072923c413",
"token_type": "bearer",
"expires_in": 43199,
"scope": "app"
}

携带令牌访问资源服务器#

此处以获取全部资源为例,其它请求方式一样,可以参考我源码中的单元测试代码。可以使用以下方式请求:

  • 使用 Headers 方式:需要在请求头增加 Authorization: Bearer yourAccessToken
  • 直接请求带参数方式:http://localhost:8081/contents?access_token=yourAccessToken

使用 Headers 方式,通过 CURL 或是 Postman 请求

curl --location --request GET "http://localhost:8081/contents" --header "Content-Type: application/json" --header "Authorization: Bearer yourAccessToken"

公司实际RBAC模型#

表结构:#

CREATE TABLE `dftc_oss_admin` (
`id` bigint(20) NOT NULL COMMENT 'ID',
`user_name` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(128) NOT NULL COMMENT '密码',
`nickname` varchar(255) DEFAULT NULL COMMENT '昵称',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像',
`tel` varchar(11) DEFAULT NULL COMMENT '手机号',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
`birthday` date DEFAULT NULL COMMENT '生日',
`sex` tinyint(1) DEFAULT NULL COMMENT '性别 0:女 1:男',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否锁定 0:否 1:是',
`intro` varchar(255) DEFAULT NULL COMMENT '简介',
`memo` varchar(255) DEFAULT NULL COMMENT '备注',
`del_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除 0:未删除 1:已删除',
`last_login_ip` varchar(255) DEFAULT NULL COMMENT '最后一次登录ip',
`last_login_time` datetime DEFAULT NULL COMMENT '最后一次登录时间',
`last_login_address` varchar(255) DEFAULT NULL COMMENT '最后一次登录地址',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `username` (`user_name`) USING BTREE,
KEY `createtime` (`create_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
CREATE TABLE `dftc_oss_role` (
`id` bigint(22) NOT NULL,
`role_name` varchar(64) NOT NULL,
`role_number` varchar(32) NOT NULL,
`detail` varchar(255) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已经停止使用 (0:未停止 1:已停止)',
`memo` varchar(255) DEFAULT NULL,
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `role_number_index` (`role_number`) USING BTREE,
UNIQUE KEY `role_name_index` (`role_name`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
CREATE TABLE `dftc_oss_admin_role` (
`id` bigint(22) NOT NULL,
`admin_id` bigint(22) NOT NULL,
`role_id` bigint(22) NOT NULL,
`memo` varchar(255) DEFAULT NULL,
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `admin_role_index` (`admin_id`,`role_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
CREATE TABLE `dftc_oss_menu` (
`id` bigint(22) NOT NULL,
`menu_name` varchar(24) NOT NULL,
`menu_url` varchar(300) DEFAULT NULL COMMENT '资源访问地址',
`parent_id` bigint(22) DEFAULT NULL,
`icons` varchar(255) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT ' 菜单是否停用 0:未停用 1:停用',
`sort` int(4) NOT NULL DEFAULT '20',
`sys_menu` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否是系统级菜单(不可删除) 0:不是 1:是',
`type` tinyint(1) NOT NULL COMMENT '菜单类型1:顶级菜单 2:2级菜单 3:三级菜单 4:按钮',
`memo` varchar(255) DEFAULT NULL,
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `menu_name_index` (`menu_name`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
CREATE TABLE `dftc_oss_role_permissions` (
`id` bigint(22) NOT NULL,
`role_id` bigint(22) NOT NULL,
`menu_id` bigint(22) NOT NULL COMMENT '菜单id',
`memo` varchar(255) DEFAULT NULL,
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `role_resource_index` (`role_id`,`menu_id`) USING BTREE,
KEY `menu` (`menu_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;

读取文件yaml文件#

前置知识#

Java对YAML文件的操作#

1、SnakeYAML简介#

SnakeYAML是一个完整的YAML1.1规范Processor,支持UTF-8/UTF-16,支持Java对象的序列化/反序列化,支持所有YAML定义的类型。

2、SnakeYAML依赖添加#

<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.21</version>
</dependency>

3、SnakeYAML的使用方法#

1. 建立Person类

import lombok.Data;//lombok为一种Java工具框架
@Data
public class Person {
private String name;
private Integer age;
}

2、建立person.yml文件

## person.yml
!!com.demo.Person {age: 24, name: Adam}

3、读取并解析YAML文件

1 T load(InputStream input)

//获取YAML中的单个对象
@Test
public void testLoadYaml() throws FileNotFoundException {
Yaml yaml = new Yaml();
File ymlFile = new File(System.getProperty("user.dir") + "/src/main/resources/person.yml");
Person person = yaml.load(new FileInputStream(ymlFile));
System.out.println(person);
}
//获取YAML中的单个对象
@Test
public void testLoadYaml2() throws FileNotFoundException {
Yaml yaml = new Yaml();
File ymlFile = new File(System.getProperty("user.dir") + "/src/main/resources/person.yml");
List<Person> personList = yaml.load(new FileInputStream(ymlFile));
System.out.println(personList);
}

2 T loadAs(InputStream input, Class type)

//读取YAML文件并返回一个对应类的对象
@Test
public void testLoadYaml() throws FileNotFoundException {
Yaml yaml = new Yaml();
File ymlFile = new File(System.getProperty("user.dir") + "/src/main/resources/person.yml");
Person person = yaml.loadAs(new FileInputStream(ymlFile), Person.class);
System.out.println(person);
}

3 Iterable loadAll(InputStream input)

//读取YAML文件并返回一个Iterable接口的子类
@Test
public void testLoadAllYaml() throws FileNotFoundException {
Yaml yaml = new Yaml();
File ymlFile = new File(System.getProperty("user.dir") + "/src/main/resources/person.yml");
Iterable<Object> people = yaml.loadAll(new FileInputStream(ymlFile));
for (Object person : people) {
System.out.println(person);
}
}

结果

[{name=John, age=20}, {name=Steven, age=30}, {name=Jenny, age=18}]

4 void dump(Object data, Writer output)

//将POJO写入YAML文件
@Test
public void testDumpYaml() throws IOException {
Yaml yaml = new Yaml();
File ymlFile = new File(System.getProperty("user.dir") + "/src/main/resources/person.yml");
Person person = new Person();
person.setName("Adam");
person.setAge(24);
yaml.dump(person, new FileWriter(ymlFile));
}

结果:

!!com.liheng.demo.Person {age: 24, name: Adam}
注:dump方法会将YAML文件中的数据覆盖

5 void dumpAll(Iterator<? extends Object> data, Writer output)

//通过Iterator迭代器批量写入YAML文件
@Test
public void testDumpAllYaml() throws IOException {
Yaml yaml = new Yaml();
File ymlFile = new File(System.getProperty("user.dir") + "/src/main/resources/person.yml");
Iterable<Object> people = yaml.loadAll(new FileInputStream(ymlFile));
List<Person> personList = Lists.newArrayList();
for (Object person : people) {
LinkedHashMap map = (LinkedHashMap) person;
Person p = new Person();
p.setName((String) map.get("name"));
p.setAge((Integer) map.get("age"));
personList.add(p);
}
yaml.dumpAll(personList.iterator(), new FileWriter(ymlFile));
}

结果:

!!com.liheng.demo.Person {age: 20, name: John}
--- !!com.liheng.demo.Person {age: 30, name: Steven}
--- !!com.liheng.demo.Person {age: 18, name: Jenny}

工作实际使用#

/**
* PrivilegeConfig
*
* @author wjc
* @date 2019/3/14
*/
@Slf4j
@Configuration
public class PrivilegeConfig {
@Bean(name = "privMap")
public Map<String, List<String>> privConfig() {
InputStream is = null;
try {
Yaml yaml = new Yaml();
is = this.getClass().getClassLoader().getResourceAsStream("privilege.yml");
Iterator<Object> iter = yaml.loadAll(is).iterator();
Map<String, List<String>> result = new HashMap<>(16);
Map<String, List<String>> privMap;
while (iter.hasNext()) {
privMap = (Map<String, List<String>>) iter.next();
result.putAll(privMap);
}
return result;
} finally {
if (is != null) {
try {
is.close();
} catch (IOException e) {
log.error("close input stream failed", e);
}
}
}
}
}

privilege.yml

## 菜单管理
XTGL_CDGL: ["/menu/list"]
XTGL_CDGL_CDXZ: ["/menu/add","/menu/nameCheck"]
XTGL_CDGL_CDBJ: ["/menu/modify","/menu/nameCheck","/menu/listById"]
XTGL_CDGL_CDSC: ["/menu/drop"]
## 账号管理
XTGL_ZHGL: ["/admin/list"]
XTGL_ZHGL_ZHXZ: ["/admin/add","/admin/nameCheck"]
XTGL_ZHGL_ZHBJ: ["/admin/modify","/admin/nameCheck","/admin/listById"]
XTGL_ZHGL_ZHSC: ["/admin/drop"]
XTGL_ZHGL_ZHSD: ["/admin/lock"]
XTGL_ZHGL_ZHJS: ["/admin/unlock"]
## 权限管理
XTGL_QXGL: ["/role/list"]
XTGL_QXGL_QXXZ: ["/role/add","/role/nameCheck"]
XTGL_QXGL_QXBJ: ["/role/edit","/role/nameCheck","/role/listById"]
XTGL_QXGL_QXSC: ["/role/del"]
XTGL_QXGL_QXSZ: ["/role/refreshRolePermission","/role/getMenuWithPermission"]
## app用户管理
APPGL_APPYH: ["/clientUser/list","/clientUser/userInfo","/clientUser/getUserLogList"]
## app用户审核
APPGL_APPYH_APPSH: ["/clientUser/doUserVerify"]
## 企业认证
APPGL_QYRZ: ["/resume/getUserLicenseByUserId","/resume/getLicenseList","/resume/getLicenseOssInfo"]
## 认证审核
APPGL_QYRZ_RZSH: ["/resume/passOssLicense","/resume/failOssLicense"]

Security微服务实战#

security实战#

CSRF#

CSRF跨站点请求伪造(Cross—Site Request Forgery),

跟XSS攻击一样,存在巨大的危害性,你可以这样来理解: ​ 攻击者盗用了你的身份,以你的名义发送恶意请求,对服务器来说这个请求是完全合法的,但是却完成了攻击者所期望的一个操作,比如以你的名义发送邮件、发消息,盗取你的账号,添加系统管理员,甚至于购买商品、虚拟货币转账等。 如下:其中Web A为存在CSRF漏洞的网站,Web B为攻击者构建的恶意网站,User C为Web A网站的合法用户。

CSRF攻击攻击原理及过程如下:

​ 1. 用户C打开浏览器,访问受信任网站A,输入用户名和密码请求登录网站A;

​ 2.在用户信息通过验证后,网站A产生Cookie信息并返回给浏览器,此时用户登录网站A成功,可以正常发送请求到网站A;

​ 3. 用户未退出网站A之前,在同一浏览器中,打开一个TAB页访问网站B;

​ 4. 网站B接收到用户请求后,返回一些攻击性代码,并发出一个请求要求访问第三方站点A;

​ 5. 浏览器在接收到这些攻击性代码后,根据网站B的请求,在用户不知情的情况下携带Cookie信息,向网站A发出请求。网站A并不知道该请求其实是由B发起的,所以会根据用户C的Cookie信息以C的权限处理该请求,导致来自网站B的恶意代码被执行。

CORS#

全称”跨域资源共享”(Cross-origin resource sharing)

CORS需要浏览器和服务器同时支持,才可以实现跨域请求,目前几乎所有浏览器都支持CORS,IE则不能低于IE10。CORS的整个过程都由浏览器自动完成,前端无需做任何设置,跟平时发送ajax请求并无差异。so,实现CORS的关键在于服务器,只要服务器实现CORS接口,就可以实现跨域通信。

简易的oauth2认证中心#

1、认证中心#

1.1 搭建is-server-auth#

pom.xml

这里注意要确定Java编译的版本,防止出现出错

1.8 1.8
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.eim</groupId>
<artifactId>security_item</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>is-server-auth</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>

application.yml

server:
port: 10000
spring:
datasource:
url: jdbc:mysql://49.232.154.66:4406/my_security?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: zy123456
mybatis-plus:
global-config:
db-config:
id-type: id_worker

1.2存储客户端信息#

  • 存在内充中
  • 存在MySQL数据库

这里我们推荐存在数据库,建表语句如下

CREATE TABLE `oauth_client_details` (
`client_id` varchar(256) NOT NULL,
`resource_ids` varchar(256) DEFAULT NULL,
`client_secret` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`authorized_grant_types` varchar(256) DEFAULT NULL,
`web_server_redirect_uri` varchar(256) DEFAULT NULL,
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(256) DEFAULT NULL,
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

1.3服务器配置#

继承AuthorizationServerConfigurerAdapter(认证服务配置适配器),做自定义配置,源码如下:

public class AuthorizationServerConfigurerAdapter implements AuthorizationServerConfigurer {
public AuthorizationServerConfigurerAdapter() {
}
//目前不知道是做什么的
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
}
//客户端配置
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
}
//端点配置,也可以说是入口配置(如获取token等)
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
}
}

自定义配置:

/**
* <p>DESC: 服务器配置</p>
* <p>DATE: 2019-12-05 16:28</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Resource
private DataSource dataSource;
@Resource
private AuthenticationManager authenticationManager;
/**
* 配置客户端到数据库
* @param clients 客户端
* @throws Exception 异常
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
/**
* 配置端点
* @param endpoints 端点
* @throws Exception 异常
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager);
}
}

服务器web安全配置(WebSecurityConfigurerAdapter):

  • 构建一个自定义的AuthenticationManager
  • 配置忽略的路径
/**
* <p>DESC: web安全配置</p>
* <p>DATE: 2019-12-05 16:34</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableWebSecurity
public class AuthWebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* UserDetailsService 实现类
* 根据用户名获取用户信息
*/
@Resource
private OrderUserDetailServiceImpl userDetailService;
/**
* 加密解密器
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 重写AuthenticationManager构造器,这里构造好以后供服务端点配置中使用
* 因为需要制定自己的 userDetailService ,和 加密解密器
* @param auth 认证构造器
* @throws Exception 异常
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService)
.passwordEncoder(passwordEncoder());
}
/**
* 将认证管理器注入到spring容器中,供服务端点配置中使用
* @return AuthenticationManager
* @throws Exception 异常
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 配置忽略的路径,/oauth/check_token 是校验token的路径,security默认没有放行
* @param web web
* @throws Exception 异常
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/oauth/check_token");
}
}

2、资源服务(order)#

2.1构建order#

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.eim</groupId>
<artifactId>security_item</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>is-order-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>

application.yaml

server:
port: 10010
spring:
datasource:
url: jdbc:mysql://49.232.154.66:4406/my_security?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: zy123456
mybatis-plus:
global-config:
db-config:
id-type: id_worker

2.2ResourceServerConfigurerAdapter#

配置资源服务器ResourceServerConfigurerAdapter,源码如下

public class ResourceServerConfigurerAdapter implements ResourceServerConfigurer {
public ResourceServerConfigurerAdapter() {
}
//配置资源服务器
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
}
//web安全配置,默认所有请求都需要认证
public void configure(HttpSecurity http) throws Exception {
((AuthorizedUrl)http.authorizeRequests().anyRequest()).authenticated();
}
}

配置token校验地址:

资源服务器,需要对传入的token进行校验。

配置的方式有两种:

1、在代码中配置

2、在application.yml中配置

2.3代码中配置资源服务器#

  • 自定义资源服务配置
/**
* <p>DESC: 订单资源配置</p>
* <p>DATE: 2019-12-05 15:48</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableResourceServer
public class OrderResourceConfig extends ResourceServerConfigurerAdapter {
//配置resourceid,注意application.yml中配置此项不生效。原因未知
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("resourceid");
}
}
  • 自定义资源服务器web安全配置
/**
* <p>DESC: 订单资源web安全配置</p>
* <p>DATE: 2019-12-05 16:18</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableWebSecurity
public class OrderResourcesWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
/**
* ResourceServerTokenServices 资源服务tokenservices
* @return ResourceServerTokenServices
*/
@Bean
public ResourceServerTokenServices resourceServerTokenServices(){
RemoteTokenServices tokenServices = new RemoteTokenServices();
tokenServices.setClientId("client");
tokenServices.setClientSecret("secret");
tokenServices.setCheckTokenEndpointUrl("http://localhost:10000/oauth/check_token");
return tokenServices;
}
/**
* 重写认证管理器注入到spring,因为需要注入自定义的ResourceServerTokenServices
* @return AuthenticationManager
* @throws Exception 异常
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
OAuth2AuthenticationManager manager = new OAuth2AuthenticationManager();
manager.setTokenServices(resourceServerTokenServices());
return manager;
}
}

2.4配置文件中配置资源服务器#

application.yml中配置校验token地址

security:
oauth2:
client:
client-id: client
client-secret: secret
access-token-uri: http://localhost:10000/oauth/token
user-authorization-uri: http://localhost:10000/oauth/authorize
resource-ids:
## - 这里配置resourceid不生效,原因未知
- resourceid
resource:
token-info-uri: http://localhost:10000/oauth/check_token

3、资源服务(price)#

和order配置一致,自行参考order

4、测试#

获取token

携带token,访问资源服务器

控制请求的scope权限#

目的用scope控制资源服务器的资源权限

用法:

复写 config 参数是: HttpSecurity 的方法,加上我们的scope表达式

  • 配置
/**
* <p>DESC: 订单资源配置</p>
* <p>DATE: 2019-12-05 15:48</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableResourceServer
public class OrderResourceConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("resourceid");
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers(HttpMethod.GET).access("#oauth2.hasScope('read')")
.antMatchers(HttpMethod.POST).access("#oauth2.hasScope('write')");
}
}
  • 测试

1、在密码模式申请令牌的时候去,去指定申请scope的 read 权限。

2、获取到的token 去访问资源服务器的post请求。

3、返回结果,scope权限不足拒绝访问。

接口获取用户信息#

  • @AuthenticationPrincipal String username 注解获取用户名
@Slf4j
@RestController
@RequestMapping("/orders")
public class OrderController {
@PostMapping
public OrderInfo create(@RequestBody OrderInfo info, @AuthenticationPrincipal String username, HttpServletRequest srequest) throws IOException {
log.info("【用户名】={}",username);
String header = srequest.getHeader("Authorization");
  • 加入用户名获取用户信息,重写转换器convert方法

1、自定义UserDetailsService

过于简单,这里就不多说了

2、

/**
* <p>DESC: 订单资源web安全配置</p>
* <p>DATE: 2019-12-05 16:18</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableWebSecurity
public class OrderResourcesWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
/**
* ResourceServerTokenServices 资源服务tokenservices
* @return ResourceServerTokenServices
*/
@Bean
public ResourceServerTokenServices resourceServerTokenServices(){
RemoteTokenServices tokenServices = new RemoteTokenServices();
tokenServices.setClientId("client");
tokenServices.setClientSecret("secret");
tokenServices.setCheckTokenEndpointUrl("http://localhost:10000/oauth/check_token");
//重置token转换器
tokenServices.setAccessTokenConverter(accessTokenConverter());
return tokenServices;
}
private AccessTokenConverter accessTokenConverter() {
DefaultAccessTokenConverter tokenConverter = new DefaultAccessTokenConverter();
DefaultUserAuthenticationConverter userConvert = new DefaultUserAuthenticationConverter();
//设置用户名获取用户信息
userConvert.setUserDetailsService(userDetailsService);
//加入用户转换器
tokenConverter.setUserTokenConverter(userConvert);
return tokenConverter;
}

控制器用法:

@AuthenticationPrincipal + 用户类 变量

/**
* <p>DESC: 订单控制器</p>
* <p>DATE: 2019-12-05 12:56</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@RestController
@RequestMapping("/orders")
public class OrderController {
@PostMapping
public OrderInfo create(@RequestBody OrderInfo info, @AuthenticationPrincipal String username,
@AuthenticationPrincipal TbUser tbUser, HttpServletRequest srequest) throws IOException {
log.info("【用户名】={}",username);
log.info("【用户】={}",tbUser);
  • 获取单个属性,使用spring的表达式
@PostMapping
public OrderInfo create(@RequestBody OrderInfo info,
@AuthenticationPrincipal String username,
@AuthenticationPrincipal(expression = "#this.id") Long id,
@AuthenticationPrincipal TbUser tbUser, HttpServletRequest srequest) throws IOException {
log.info("【用户名】={}",username);
log.info("【用户】={}",tbUser);
log.info("【用户id】={}",id);

搭建zuul网关#

1、环境#

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.eim</groupId>
<artifactId>security_item</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>is-server-gateway</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>

application.yml

hystrix和ribbon是控制超时时间,防止请求时间过长,造成 熔断。

server:
port: 9070
zuul:
prefix: /api
routes:
token:
url: http://localhost:10000
order:
url: http://localhost:10010
## 另一种路由配置
## token:
## path: /token/**
## serviceId: ddd
sensitive-headers:
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMillisecond: 6000 # 熔断超时时长:6000ms
ribbon:
ConnectTimeout: 2500 # 连接超时时间(ms)
ReadTimeout: 20000 # 通信超时时间(ms)
OkToRetryOnAllOperations: true # 是否对所有操作重试
MaxAutoRetriesNextServer: 2 # 同一服务不同实例的重试次数
MaxAutoRetries: 1 # 同一实例的重试次数
okhttp:
enabled: true

2、开启zuul网关#

@EnableZuulProxy

/**
* <p>DESC: 启动类</p>
* <p>DATE: 2019-12-08 21:52</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@EnableZuulProxy
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class,args);
}
}

3、自定义过滤器#

继承ZuulFilter,就可以实现自定义过滤器

/**
* <p>DESC: 认证过滤器</p>
* <p>DATE: 2019-12-10 10:45</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class AuthenticationFilter extends ZuulFilter {
/**
* 过滤类型
* FilterConstants 过滤器常量池
* @return 字符串
*/
@Override
public String filterType() {
return PRE_TYPE;
}
/**
* 过滤器顺序
* @return 数字
*/
@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER -4;
}
/**
* 是否应该过滤
* @return boolean
*/
@Override
public boolean shouldFilter() {
return true;
}
/**
* 运行逻辑
* @return Object 返回 null,执行下一个过滤器(放行)。
* @throws ZuulException zuul异常
*/
@Override
public Object run() throws ZuulException {
log.info("【start authentication】");
return null;
}
}

4、Zuul降级#

实现FallbackProvider接口。重写两个方法

/**
* <p>DESC: Zuul提供一种降级功能</p>
* <p>DATE: 2019-12-10 14:01</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class ZuulFallback implements FallbackProvider {
/**
* 返回值表示需要针对此微服务做回退处理(该名称一定要是注册进入 eureka 微服务中的那个 serviceId 名称);
* @return string
*/
@Override
public String getRoute() {
/**
* 只有zuul配置serviceId 的形式,回退才能生效
* eg:
* token:
* path: /token/**
* serviceId: ddd
*
* api服务id,如果需要所有调用都支持回退,则return "*"或return null
*/
return null;
}
@Override
public ClientHttpResponse fallbackResponse(String route, Throwable cause) {
return new ClientHttpResponse(){
/**
* 设置返回头
* @return HttpHeaders
*/
@Override
public HttpHeaders getHeaders() {
log.warn("warning={}","进入zuulFallback,设置header");
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_JSON_UTF8);
httpHeaders.setCacheControl("private");
return httpHeaders;
}
/**
* 设置返回内容
* @return InputStream
* @throws IOException IOException
*/
@Override
public InputStream getBody() throws IOException {
log.warn("warning={}","进入zuulFallback,设置body");
ObjectMapper mapper = new ObjectMapper();
return new ByteArrayInputStream(mapper.writeValueAsBytes("服务暂不可用"));
}
/**
* 返回 HttpStatus 对象
* @return HttpStatus
* @throws IOException 异常
*/
@Override
public HttpStatus getStatusCode() throws IOException {
return HttpStatus.SERVICE_UNAVAILABLE;
}
/**
* 返回 httpstatus 状态码
* @return 状态码
* @throws IOException 异常
*/
@Override
public int getRawStatusCode() throws IOException {
return HttpStatus.SERVICE_UNAVAILABLE.value();
}
/**
* 返回状态文字
* @return 文字
* @throws IOException 异常
*/
@Override
public String getStatusText() throws IOException {
return HttpStatus.SERVICE_UNAVAILABLE.getReasonPhrase();
}
@Override
public void close() {
}
};
}
}

搭建eureka#

1、环境#

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>security_item</artifactId>
<groupId>com.eim</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>is-server-eureka</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>
</project>

application.yml

server:
port: 8080
spring:
application:
name: eureka # 应用名称
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://127.0.0.1:8080/eureka
instance:
instance-id: ${spring.application.name}:${server.port}
# 10秒即过期
lease-expiration-duration-in-seconds: 10
# 5秒一次心跳
lease-renewal-interval-in-seconds: 5

2、开启eureka服务#

/**
* <p>DESC: 启动类</p>
* <p>DATE: 2019-12-11 16:24</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@EnableEurekaServer
@SpringBootApplication
public class EurekaApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaApplication.class,args);
}
}

3、搭建安全的eureka#

3.1、eureka中加入security依赖#

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

或则

<!-- Security -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>

3.2、配置Eureka Server的application.yml文件#

#基于SpringBoot 1.X版本是如下配置:
security.basic.enabled: true
security.user.name=admin
security.user.password=12345
#基于SpringBoot 2.x版本将security属性移入到spring配置里,且basic.enabled属性无效
spring.security.user.name=admin
spring.security.user.password=12345

核心代码: defaultZone: http://admin:[email protected]:${server.port}/eureka

server:
port: 8080
spring:
application:
name: eureka # 应用名称
security:
user:
name: admin
password: 123456
eureka:
client:
#是否注册自己的信息到EurekaServer,默认是true
register-with-eureka: false
#是否拉取其它服务的信息,默认是true
fetch-registry: false
# EurekaServer的地址,现在是自己的地址,如果是集群,需要加上其它Server的地址。
service-url:
defaultZone: http://admin:[email protected]:${server.port}/eureka
instance:
instance-id: ${spring.application.name}:${server.port}
# 10秒即过期
lease-expiration-duration-in-seconds: 10
# 5秒一次心跳
lease-renewal-interval-in-seconds: 5

3.3、配置eureka安全配置#

1、需要httpBasic模式

2、关闭csrf

/**
* <p>DESC: eureka安全配置</p>
* <p>DATE: 2019-12-23 14:12</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
public class EurekaSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().httpBasic().and().authorizeRequests().anyRequest().authenticated();
}
}

网关过滤器#

1、认证过滤器#

1、指定过滤顺序

2、指定过滤类型

3、指定是否过滤,还是放行

4、如果过滤,执行run方法中的代码

/**
* <p>DESC: 认证过滤器</p>
* <p>DATE: 2019-12-10 10:45</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class AuthenticationFilter extends ZuulFilter {
/**
* 过滤类型S
* FilterConstants 过滤器常量池
* @return 字符串
*/
@Override
public String filterType() {
return PRE_TYPE;
}
/**
* 过滤器顺序
* @return 数字
*/
@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER -4;
}
/**
* 是否应该过滤
* @return boolean
*/
@Override
public boolean shouldFilter() {
return true;
}
/**
* 运行逻辑
* @return Object 返回 null,执行下一个过滤器(放行)。
* @throws ZuulException zuul异常
*/
@Override
public Object run() throws ZuulException {
log.info("【start authentication】");
//获取 HttpServletRequest 请求对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
String requestUrl = request.getRequestURI();
//1、判断请求地址是否是token开头,token 开头请求,是认证服务器的请求,不用校验
if(StringUtils.startsWith(requestUrl,"/api/token")){
return null;
}
//2、如果不是请求token,就是业务上的请求,需要从请求头中拿出Authorization token 信息
String authorization = request.getHeader("Authorization");
//如果没带请求头,继续往下走。不管token有没有,不能在这里阻塞。
if(StringUtils.isBlank(authorization)){
return null;
}
//3、bearer 是oauth认证token,不是oauth的token,也是直接放过
if(!StringUtils.startsWithIgnoreCase(authorization,"bearer ")){
return null;
}
//剩下的就是,业务接口,携带了 oauth认证token。做校验
try {
TokenInfo tokeninfo = getTokenInfo(authorization);
request.setAttribute("tokenInfo",tokeninfo);
}catch (Exception e){
log.error("【get tokenInfo fail】");
e.printStackTrace();
}
return null;
}
private TokenInfo getTokenInfo(String authorization) throws IOException {
String token = StringUtils.substringAfter(authorization, "bearer ");
String oauthUrl = "http://localhost:10000/oauth/check_token?token="+token;
OkHttpClient client = new OkHttpClient();
Headers header = new Headers.Builder()
.add("Content-Type", MediaType.APPLICATION_JSON_UTF8_VALUE)
.build();
Request request = new Request.Builder()
.url(oauthUrl)
.headers(header)
.build();
Call call = client.newCall(request);
Response response = call.execute();
int code = response.code();
if(code != 200){
throw new RuntimeException();
}
//这里调用的body的string()方法
String bodyStr = response.body().string();
log.info("【校验token】 code={},{}",code,bodyStr);
//返回文本 {"aud":["resourceid"],"user_name":"admin","scope":["read","write"],"active":true,"exp":1576071177,"authorities":["admin"],"client_id":"client"}
TokenInfo tokenInfo = JSON.parseObject(bodyStr, TokenInfo.class);
log.info("【tokeninfo】={}",tokenInfo.toString());
return tokenInfo;
}
}

TokenInfo

根据校验token返回的参数,组装成tokenInfo对象

/**
* <p>DESC: token信息</p>
* <p>DATE: 2019-12-11 14:33</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
public class TokenInfo {
/**
* 是否可用
*/
private boolean active;
private String client_id;
private String[] scope;
private String user_name;
/**
* resourceid
*/
private String[] aud;
private Date exp;
/**
* 角色权限
*/
private String[] authorities;
}

2、网关审计日志过滤器#

/**
* <p>DESC: 审计日志过滤器</p>
* <p>DATE: 2019-12-13 13:39</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class AuditLogFilter extends ZuulFilter {
@Override
public String filterType() {
return PRE_TYPE;
}
@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER-3;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
log.info("【新增审计日志】");
return null;
}
}

3、授权过滤器#

被誉为:最后的防线

/**
* <p>DESC: 授权过滤器</p>
* <p>DATE: 2019-12-13 13:43</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class AuthorizationFilter extends ZuulFilter {
@Override
public String filterType() {
return PRE_TYPE;
}
@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER - 2;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//判断当前的请求是不是需要身份认证,或者是需不需要做权限的判断
if(isNeedAuth(request)){
TokenInfo tokenInfo = (TokenInfo) request.getAttribute("tokenInfo");
//不为空且是 激活状态的
if(tokenInfo != null && tokenInfo.isActive()){
//能拿到用户信息,下一步就要判断权限。如果没有权限那么就报403的错误
if(!hasPermission(tokenInfo,request)){
//没有权限
handleError(403,currentContext);
}
}else{
log.error("【audit log update fail 401】");
//拒绝访问
handleError(401,currentContext);
}
}
return null;
}
private void handleError(int status, RequestContext currentContext) {
currentContext.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
currentContext.setResponseStatusCode(status);
currentContext.setResponseBody("{\"msg\":\"失败\"}");
//到此终止,不再往下走
currentContext.setSendZuulResponse(false);
}
private boolean hasPermission(TokenInfo tokenInfo, HttpServletRequest request) {
return RandomUtils.nextInt() % 2 == 0 ;
}
/**
* 判断当前的请求是不是需要身份认证,或者是需不需要做权限的判断
* @param request 请求
* @return boolean
*/
private boolean isNeedAuth(HttpServletRequest request) {
//放行请求token的请求
if(request.getRequestURI().startsWith("/api/token")){
return false;
}
return true;
}
}

4、测试#

(1)先去拿token

(2)拿到token,通过网关,获取访问order服务,或则price服务

测试成功:

说明我们现在写的业务逻辑时生效的,我们在网关这 把权限控制了。现在就可以把订单服务里面和安全相关的逻辑去掉。包括先关的代码 这些逻辑去掉后,我们之前说的那些问题。比如说安全的逻辑和业务逻辑耦合,安全逻辑一遍导致业务逻辑重新部署,包括随着业务接节点增加,你们安全信息也在这里面。业务节点节点增加。认证服务器压力增大等等这些问题。都被解决掉了

5、去掉订单服务安全的相关代码#

(1) 首先去掉Oauth2的依赖。

(2) 所有和安全相关的代码都删掉

(3)当前用户的信息使用这个注解来拿到的。现在这个注解依赖都被删掉了 那么如何拿到当前用户的信息

在网关授权往下走的之前 ,设置header。这里也可以传json对象

存入用户名到请求头中

6、启动测试#

启动oderApi和网关 还是调这个创建订单的服务,要多试几次,因为是50%的几率是200

上面是基本架构(目的为了让我们知道网关都具体干了什么),下面对服务进行改造,进一步减少代码

===================****=

微服务之间的通讯安全#

问题一:#

在网关上做限流还是有一些问题的。例如我的订单服务限流是100,库存服务限流也是100。但是我的订单服务会调用我的库存服务。那么在网关这,给订单转100个请求,库存转100个请求,最后订单又调了库存,库存会同时受到200个请求。这时候库存服务可能就挂掉了。 这是在网关这里做限流,可能会出现的一些问题。

第二个问题:#

就是身份认证。 效率低,在网关上每一个请求都要去认证服务器验令牌。这样就会导致多一次网络请求的开销。同时我的认证服务器压力就会很大,而且还要保证高可用。而且一旦我的认证服务器坏了,没法验令牌了。所有的请求就都没法处理了。 不安全:在传递用户信息的时候,是在请求头里面加了个username,然后把用户名放进去了。 订单服务从请求头里就知道他是谁了。

那么我再写一个其他服务,也调用订单服务,下一个订单,在请求头里面传一个张三。订单服务就认为我是张三。传个李四,订单服务就认为我是李四,这样做实际上是不安全的 你不能从头里面一个请求中明文的参数,来判断用户是谁。 第三个问题<传递麻烦>,把信息放在请求里面 传给了订单服务,比如说 订单服务还要调用库存服务。那么订单服务调用库存服务的时候,也要在请求头里面再加上username,再去调,库存服务才能知道当前这个用户是谁。传递起来也是比较麻烦的。这是认证面临的一些问题。

问题三:#

授权<和限流的问题类似>。例如我在权限控制里面 控制这个人只能访问订单服务,不能访问库存服务。,但是订单服务内部又访问了库存服务。 我一访问订单服务,实际上又访问库存的服务了。权限实际上是越权了。没有限制住, 微服务之间去调用的时候,通过网关调用的时候,还有一个是雪崩。当一个服务出现问题的时候,把其他的服务都给带死了。 比如说我的库存服务。 因为某些原因响应变慢了。比如说是网络的问题,数据库压力大或者是其他的一些问题。我的库存服务变慢了。会导致我的订单服务也变慢。然后所有调用库存服务的服务都变慢。这些线程全都在这等待着,然后这些线程可能都是通过网关进来的。那么网关上也有一大堆线程在这等待着,最后导致所有的服务,你的网关上所有线程都被占住了。 可能订单服务的所有线程都被占住了,最后导致,大量的服务都不能用了。但是最根上只有一个库存服务出了问题。这就是我们说的服务的雪崩,

刚才讲的这些问题就是我们第六章要解决的问题。

解决认证问题#

首先来解决认证的问题。

1.效率低,每次认证都要去认证服务器调一次服务。 2.传递用户身份,在请求头里面, 3.服务之间传递请求头比较麻烦。

jwt令牌。 spring提供了工具,帮你在微服务之间传递令牌。让你不用去写额外的代码

1、服务器端的改造#

看一下认证服务器配置的这个类。这里有个tokenStore,就是令牌的存储器。

  • 可以指定为jdbc存储token
  • 也可以指定为jwtToken

我们new 一个jwtTokenStore它 需要一个参数jwtTokenEnhancer

需要set一个key就是签名的键。jwt本身不是加密的,谁都可以看到令牌里的信息,它用来保证安全的方式就这个签名。你需要用一个key对这个token进行签名。然后使用token的人用相同的key去验证签名,如果那个签名证明是这个key签出去的。那么就说明token里面的内容没被改过。没被改过,我就认为它是安全的。

接收token的人需要这个key来验证签名,所以我们要把这个key当做一个服务暴露出去。 这样使用这个token的人才能通过这个服务拿到 这个key来验证签名。

2、运行认证服务器测试#

看一下jwt到底是个什么样子,然后再往下走。

只是防串改,并不信息保密。所以不建议往jwt里面放一些和自己业务 相关的信息。可能会导致你的信息泄露。

这里我们用了简单的串做为key,生产的时候不要这么去做。因为一旦泄露,发出去的token就危险了。

3、key做安全措施#

rsa工具类

可以指定路径和密码,构建出公钥和私钥

public class RsaUtils {
/**
* 从文件中读取公钥
*
* @param filename 公钥保存路径,相对于classpath
* @return 公钥对象
* @throws Exception
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**
* 从文件中读取密钥
*
* @param filename 私钥保存路径,相对于classpath
* @return 私钥对象
* @throws Exception
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* 获取公钥
*
* @param bytes 公钥的字节形式
* @return
* @throws Exception
*/
public static PublicKey getPublicKey(byte[] bytes) throws Exception {
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**
* 获取密钥
*
* @param bytes 私钥的字节形式
* @return
* @throws Exception
*/
public static PrivateKey getPrivateKey(byte[] bytes) throws Exception {
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根据密文,生存rsa公钥和私钥,并写入指定文件
*
* @param publicKeyFilename 公钥文件路径
* @param privateKeyFilename 私钥文件路径
* @param secret 生成密钥的密文
* @throws IOException
* @throws NoSuchAlgorithmException
*/
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
keyPairGenerator.initialize(1024, secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
// 获取公钥并写出
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
writeFile(publicKeyFilename, publicKeyBytes);
// 获取私钥并写出
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
writeFile(privateKeyFilename, privateKeyBytes);
}
private static byte[] readFile(String fileName) throws Exception {
return Files.readAllBytes(new File(fileName).toPath());
}
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}

看工具类的使用

/**
* <p>DESC: 做出公钥和私钥</p>
* <p>DATE: 2019-12-16 14:39</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class DoRsaKey {
public static final String pubPath = "C:/zy_java/security/security_item/is-server-auth/src/main/resources/key/rsa.pub";
public static final String priPath = "C:/zy_java/security/security_item/is-server-auth/src/main/resources/key/rsa.pri";
public static void main(String[] args) throws Exception {
//生成公钥私钥
//RsaUtils.generateKey(pubPath,priPath,"123456");
//获取公钥私钥
URL resource = DoRsaKey.class.getClassLoader().getResource("key/rsa.pub");
System.out.println(resource.getPath());
PublicKey publicKey = RsaUtils.getPublicKey(resource.getPath());
System.out.println(publicKey);
}
}

使用公钥和秘钥替换简单的字符串

/**
* token 增强器
* @return token增强器
*/
private JwtAccessTokenConverter jwtTokenEnhancer(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
try {
String pubPath = AuthServerConfig.class.getClassLoader().getResource("key/rsa.pub").getPath();
String priPath = AuthServerConfig.class.getClassLoader().getResource("key/rsa.pri").getPath();
PublicKey publicKey = RsaUtils.getPublicKey(pubPath);
PrivateKey privateKey = RsaUtils.getPrivateKey(priPath);
//设置秘钥对
converter.setKeyPair(new KeyPair(publicKey,privateKey));
} catch (Exception e) {
e.printStackTrace();
}
return converter;
}

JWT改造之网关和服务改造#

网关上认证去做哪些改造:

在网关上用jwt去解析用户信息,而不再发送校验令牌的请求了。

之前的时候网关上实际上写了很多的代码

包括认证,发check_token去把token请求,换成用户信息。

审计日志和授权。

限流

filter都删掉,Spring Security和Spring OAuth 已经把所有的都封装好了。除了限流和日志。审计、认证、授权都封装好了

1、用Spring Security实现功能#

在gateway网关 pom.xml加入Starter-oauth2的依赖。

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

本节课先讲认证相关的功能。我们现在不再发校验令牌的请求,而是直接从jwt里面读取用户的信息,jwt的信息会有一个签名,读取信息的时候要去验这个签名,验签名的时候需要有一个key,key我们需要在Gateway启动的时候,从认证服务器获取到这个key

key-uri首先要配置从哪里拿key。这里应该配置的是认证服务器的地址。

security:
oauth2:
resource:
jwt:
key-uri:

TokenKeyEndpoint来处理,/oauth/token_key这个请求。

它就会把证书里面存的密钥返回

上节课配置tokenStore的时候

下面配置配置了tokenKeyAccess。就是说访问这个tokenKeyUrl的时候必须要身份认证。

所以在这里我就要告诉他身份认证的信息。clinetId和clientSceret

security:
oauth2:
client:
client-id: client
client-secret: secret

2、配置资源服务器相关的配置#

网关是作为资源服务器存在的,所以继承ResourceServerConfigAdapter

/**
* <p>DESC: 网关资源服务器配置</p>
* <p>DATE: 2019-12-16 16:43</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableResourceServer
public class GatewaySecurityConfig extends ResourceServerConfigurerAdapter {
}

我们自己写的认证的时候,我们要把所有/token的请求都放过去,因为/token开头的请求都是在申请令牌,你不能让申请令牌的这些请求也需要身份认证。

默认情况下所有的请求都需要身份认证。

@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated();
}

我们在这里 加一句,申请令牌的所有请求都放行。不需要带令牌。

@Configuration
@EnableResourceServer
public class GatewaySecurityConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/token").permitAll()
.anyRequest().authenticated();
}
}

3、order-api#

我们在网关里面把filter都去掉了。已经不再传明文的username了。这个时候order-api也需要修改代码 因为oauth2的依赖。

也需要响应的配置,令牌传递过来,它也需要从令牌里解析当前用户是谁。因为我们传的是一个令牌,而不是明文的一个东西了。

在启动类里面加注解@EnableResourceServer

拿到用户名,通过@AuthenticationPrincipal注解

4、启动测试#

(1)启动网关#

在没有启动认证服务器的时候,直接启动网关 会报错。链接被拒绝,

它在启动的时候会去找认证服务器,拿签名的key。认证服务器如果还没有启动,连不上,所以会报错。你拿不到这个key 就没法验jwt

(2) 所以首先启动认证服务器,OAuth2的认证服务器。#

然后再启动网关

(3) 再次启动网关的时候又报了个404的错误。就是刚才那个拿key的路径没有。#

这是因为我们在配置Oauth2认证服务器的时候,改tokenStore的时候,声明的是私有的JwtAccessTokenConverter

这个类要声明称公有的,而且还要是Spring的一个Bean。只有在有这个Bean的情况下

tokenKeyEndpoint这个端点才会起作用。

这个请求才会被处理

如果没有这个public的Bean。这个端点是不会暴露的,所以就会报404 的错误。

(4) 重启认证服务器#

网关重启 网关就能成功的启动了。

​ 启动orderapi

(5)首先来获取令牌#

  • 通过网关访问认证服务器,获取token.
  • 通过网关调用创建订单的服务

(6) 403错误。#

无效的token,不包含resource id oauth2-resouce 这说明我们的resouceid的检查没过。

报错的oauth2-server是哪里的呢???我们在配置网关的时候,没有指定resource_id

默认情况下没有指定resourceId的情况,这个类里面会有一个默认的resourceId.进入到ResourceServerSerurityConfigurer

因为网关没有配置,所以默认的就是这个类里的resourceId,oauth2-resource

我们在发出去的令牌只能访问order-server

所以这个令牌在访问网关的时候,就被拦下来了。令牌是对的。但是没有权限。

令牌随便加个字母访问,错误的令牌就是401

(7) 解决方法#

  • 在网关设置resourceid
  • 然后把网关的resourceId配置成gateway
/**
* <p>DESC: 网关资源服务器配置</p>
* <p>DATE: 2019-12-16 16:43</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableResourceServer
public class GatewaySecurityConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/token/**").permitAll()
.anyRequest().authenticated();
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("resourceid");
}
}
  • 获取在数据库的客户端表中,将resourceid置为空,这样就可以访问任何resource了。

(8)重新测试#

重新申请令牌。新令牌就是可以访问任何的资源服务器。

创建订单的控制台,把用户名打印出来。

发了jwt后,在网关的时候,没有去校验令牌,在网关解析了jwt,判断这个jwt是有效的,再往下,jwt给到订单服务的时候,订单服务从jwt解析出来这个令牌对应的用户是jojo

5、令牌如何传递#

订单服务想去调库存服务的时候,令牌如何往下传。仍然要从请求里面拿到令牌,放到请求头里。但是spring security已经帮我封装了。不用自己去做。只需要用他的一个工具类就可以了。

1)、价格服务#

价格服务也加上oauth2的依赖

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

加上配置,配置从哪里拿tokenKey

security:
oauth2:
client:
client-id: client
client-secret: secret
resource:
jwt:
key-uri: http://localhost:10000/oauth/token_key

2)、修改orderController#

在请求上把令牌带上。不再用原来的restTemplate了 ,而是用SpringSecurity提供的OauthRestTemplate

OauthRestTemplate功能就是从你当前请求的上下文里,拿到令牌,然后把令牌放到请求头里面。然后再出去。只要用OauthRestTemplate来发请求就会自动带上请求头。

在springboot的启动类声明oauth2RestTemplate

/**
* <p>DESC: 启动类</p>
* <p>DATE: 2019-12-05 12:54</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@EnableDiscoveryClient
@SpringBootApplication
@EnableResourceServer
public class OrderApiApplication {
@Bean
public OAuth2RestTemplate oAuth2RestTemplate(OAuth2ProtectedResourceDetails resource, OAuth2ClientContext context){
return new OAuth2RestTemplate(resource);
}
public static void main(String[] args) {
SpringApplication.run(OrderApiApplication.class,args);
}
}

改造controller

@Slf4j
@RestController
@RequestMapping("/orders")
public class OrderController {
@Resource
private OAuth2RestTemplate oAuth2RestTemplate;
@PostMapping
public OrderInfo create(@RequestBody OrderInfo info,
@AuthenticationPrincipal String username,
//@AuthenticationPrincipal(expression = "#this.id") Long id,
//@AuthenticationPrincipal TbUser tbUser,
//@RequestHeader String username,
HttpServletRequest srequest) throws IOException {
log.info("【用户名】={}",username);
//log.info("【用户】={}",tbUser);
//log.info("【用户id】={}",id);
//Object forObject = oAuth2RestTemplate.getForObject("http://localhost:10086/price/" + info.getId(), Object.class);

网关权限控制改造#

(1)、控制权限#

角色和权限实时变化怎么去做授权? 在网关上做复杂的权限控制。 我们之前在网关上有这么个配置,除了token的请求,都需要做身份认证。

@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/token/**").permitAll()
.anyRequest().authenticated();
}

这里修改成access ,你给它指定一个访问的规则。 request就是当前的请求,authentication就是当前的用户。 调用一个服务的hasPerission方法,把当前请求和当前用户传进去。这个方法返回一个bool,true或false。如果是true你就能访问,false 就不能访问。

@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/token/**").permitAll()
.anyRequest().access("#permissionService.hasPermission(request,authentication)");
}

permissionService从哪里来?这个就要自己去写了。里面只有一个方法就是hasPermission。authentication里面就包含了用户的信息。

/**
* <p>DESC: 权限校验</p>
* <p>DATE: 2019-12-17 12:48</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public interface PermissionService {
boolean hasPermission(HttpServletRequest request, Authentication authentication);
}

实现类。

在这里调用你的远程服务,或者查询数据库,或者查redis,或者网关启动的时候,已经把权限信息缓存到内存里了。

这里应该是真正调用业务逻辑的服务来判断,这里我们就不调用服务了,打印一些信息。 50%的几率有权限访问,50%的几率没权限访问,

/**
* <p>DESC: 权限校验实现</p>
* <p>DATE: 2019-12-17 12:49</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Service
public class PermissionServiceImpl implements PermissionService {
@Override
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
log.info("【请求地址】 = {}",request.getRequestURI());
log.info("【authentication】 = {}",authentication.toString());
return RandomUtils.nextInt() % 2 == 0;
}
}

这个时候psermissonService实际上是不起作用的。现在网关不认这个permissionService,不知道这个permissionService到底是什么

如果此时重启网关,并访问接口,会出现:解析表达式失败了。不知道该怎么解析。

现在要告诉它怎么解析这个psermissionService,让他知道permissionService技术这个东西

(2)、写一个表达式的处理器#

继承的是Oauth2的web安全表达式处理器。在这里处理器里是不知道我自己写的服务的

Evaluation

创建一个评估的上下文来告诉它 permissionService怎么解析。 首先执行以下super的方法,创建一个标准的评估上下文。

在这个标准的评估上下文上,我们去设置一个变量。

有了这个表达式处理器,在表达式里面写psermissionService这个字符串的时候,她就知道我应该是去调用这个注入进来的psermissionService这个类里面的方法了。

/**
* <p>DESC: 网关表达式处理器</p>
* <p>DATE: 2019-12-17 12:55</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Component
public class GatewayWebSecurityExpressionHandler extends OAuth2WebSecurityExpressionHandler {
@Resource
private PermissionService permissionService;
@Override
protected StandardEvaluationContext createEvaluationContextInternal(Authentication authentication, FilterInvocation invocation) {
StandardEvaluationContext context = super.createEvaluationContextInternal(authentication, invocation);
context.setVariable("permissionService",permissionService);
return context;
}
}

注入我们自己写的表达式处理器。

/**
* <p>DESC: 网关资源服务器配置</p>
* <p>DATE: 2019-12-16 16:43</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableResourceServer
public class GatewaySecurityConfig extends ResourceServerConfigurerAdapter {
@Resource
private GatewayWebSecurityExpressionHandler gatewayWebSecurityExpressionHandler;
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/token/**").permitAll()
.anyRequest().access("#permissionService.hasPermission(request,authentication)");
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("resourceid")
//注入我们自己写的表达式处理器
.expressionHandler(gatewayWebSecurityExpressionHandler);
}
}

(3)、重启服务测试#

整合feign#

1、改造订单服务#

加入feign依赖

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

2、开启feign功能#

@EnableFeignClients

还可以指定feign接口位置

@EnableFeignClients({"com.dftcmedia.tckk.microservice.common.feginapis","com.dftcmedia.tckk.vservice2.logiccommon.feign"})
/**
* <p>DESC: 启动类</p>
* <p>DATE: 2019-12-05 12:54</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
@EnableResourceServer
public class OrderApiApplication {
//@Bean
//public OAuth2RestTemplate oAuth2RestTemplate(OAuth2ProtectedResourceDetails resource, OAuth2ClientContext context){
// return new OAuth2RestTemplate(resource);
//}
public static void main(String[] args) {
SpringApplication.run(OrderApiApplication.class,args);
}
}

3、写price的消费接口#

price : 对应的是服务的名字,跟网关上路由的服务名一样

configuration: 配置feign的拦截器

/**
* <p>DESC: </p>
* <p>DATE: 2019-12-17 14:03</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@FeignClient(value = "price",configuration = {FeignConfig.class})
public interface PriceFeign {
@GetMapping("/price/{id}")
Object getPrice(@PathVariable("id") String id);
}

4、传递token#

在进行认证鉴权的时候,不管是jwt不是security,当使用Feign时就会发现外部请求到A服务的时候,A服务是可以拿到token的,然而当服务使用Feign调用B服务时,token就会丢失,从而认证失败

通过实现RequestInterceptor拦截器,在调用Feign服务的时候,在请求头中添加需要传递的token

@Configuration
public class FeignConfig implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
requestTemplate.header(HttpHeaders.AUTHORIZATION, request.getHeader(HttpHeaders.AUTHORIZATION));
}
}

5、测试#

获取刷新token#

1、发送请求#

2、认证服务器报错#

抛出异常Handling error: IllegalStateException, UserDetailsService is required.#

3、解决方法:#

认证服务器中,在AuthorizationServerConfigurerAdapter 中配置UserDetailsService对象

@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Autowired
private MyUserDetailsService myUserDetailsService;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 执行token刷新需要带上此参数
endpoints.userDetailsService(myUserDetailsService);
}
}

jwt改造之日志及错误处理(1)#

在代码里,我们没有认证或者授权的filter。认证和授权的工作现在基本上完全由Spring Security的过滤器接管了。

本节就来看下 如何在Spring Security的过滤器链上加入我们自己的逻辑,因为现在这个过滤器链上只处理了认证和授权。我们还有其他的一些安全机制,比如说限流、日志。我们看下怎么把这些机制加到Spring的默认实现里面去,最后总结一下,到底都做了哪些事情,然后整个它的处理流程是什么样子的

1、日志过滤器#

首先来写处理日志的过滤器。和我们之前的处理是类似的

继承OncePerRequestFilter

这里一定注意不要用@Component注解把这个Filter声明成Spring 的Bean原因下面再讲。

日志的这个过滤器应该是在认证的过滤器前面,在授权的过滤器前面。所以在这个过滤器里面,我应该知道当前用户是谁了。如果你的认证成功的话。 前面认证的过滤器会把jwt的令牌转换成一个Authentication,然后把它放到SpringSecurityContext安全的上下文里面。

/**
* <p>DESC: 网关日志过滤器</p>
* <p>DATE: 2019-12-17 17:57</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class GatewayAuditLogSecurityFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
}
}

通过下面的代码就可以把它从安全上下文里面再拿出来。principal就是我们申请令牌的时候的用户名

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//前面认证的过滤器会把jwt的令牌转换成一个Authentication,然后把它放到SpringSecurityContext安全的上下文里面。
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = (String) authentication.getPrincipal();
log.info("【日志过滤器】 用户名={}",username);
}

调用后面的过滤器处理完之后,还要再加一句日志更新,日志的成功还是失败,更新到数据库里面。这里我简单的用一个sysout来处理。把日志更新成处理成功。

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//前面认证的过滤器会把jwt的令牌转换成一个Authentication,然后把它放到SpringSecurityContext安全的上下文里面。
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = (String) authentication.getPrincipal();
log.info("【日志过滤器】 用户名={}",username);
log.info("【日志过滤器】 1。 add log for ={}",username);
//调用下面的过滤器
filterChain.doFilter(request,response);
log.info("【日志过滤器】 3。 update log for success");
}

2、过滤器加到SpringSecurity配置中#

http的安全配置这里加一行。第一个addFilter一般不会去用. addFilterAfter、addFilterAt、addFilterBefore 就是你自己写的过滤器加到SpringSecurity的过滤器链上,指定一个位置。SpringSecurity过滤器链的顺序是固定的。所以你只要把你的过滤器加到spring的某一个过滤器的前面before或 后面after,at就是直接加入到这个过滤器的位置上。替换掉原来的过滤器。

我们加的是一个日志过滤器,要加载授权的前面。用addFilterBefore

/**
* <p>DESC: 网关资源服务器配置</p>
* <p>DATE: 2019-12-16 16:43</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Configuration
@EnableResourceServer
public class GatewaySecurityConfig extends ResourceServerConfigurerAdapter {
@Resource
private GatewayWebSecurityExpressionHandler gatewayWebSecurityExpressionHandler;
@Override
public void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(new GatewayAuditLogSecurityFilter(), ExceptionTranslationFilter.class)
.authorizeRequests()
.antMatchers("/api/token/**").permitAll()
.anyRequest().access("#permissionService.hasPermission(request,authentication)");
}

前面是自己定义的过滤器,后面那个过滤器ExceptionTranslationFilter是Spring 用来转换异常的一个过滤器。我们最后会在授权的过滤器里面抛出异常。要不然是401需要身份认证,要不然是403需要权限。就这两种异常。两个异常抛出来以后,都会由这个过滤器来处理

刚才我们说不要把自己定义的GatewayAuditLogFilter声明称一个spring的Bean。SprintBoot默认情况下(如果声明成了Spring的Bean) ,会把这个过滤器直接加到web程序的过滤器链上。因为这个类继承了OncePreRequestFilter 后面这里还有一个addFilter的操作。也就是说SpringBoot加了一次,自己这里写代码又加了一次。这个过滤器加入到过滤器的链上,加了两次。所以我们这里不声明称Spring成的Bean。这里只用代码加到链上一次。

3、启动测试#

申请令牌

复制令牌

去调用创建订单的服务。

看一下 网关上的日志。说明进入了日志的过滤器里面。jojo是jwt内解析的用户名, 这说明日志是在认证的过滤器后面的。所以才会解析出当前的用户。

【日志过滤器】 用户名=admin
【日志过滤器】 1。 add log for =admin
【请求地址】 = /api/order/orders
【日志过滤器】 3。 update log for success

第二句是在验证权限的服务,这里打印出来的。这就说明了日志过滤器在认证的过滤器之后,权限的过滤器之前生效的

在权限的过滤器判断完成后,最终还会回到日志的过滤器里面,把它的处理结果更新成处理成功。这个是我们想要的执行的顺序

4、异常处理#

多发几次创建订单的请求,因为是50%的成功率

403访问被拒绝

后台日志:

​ 这里并没有体现出来,请求是被拒绝掉的、现在的处理代码并没有处理这种情况,仍然是回到了日志Filter里面,把我的日志更新成功了。

这个就是访问被拒绝 ,它的处理器

默认的处理器就是返回下面这样的json

<ForbiddenException>
<error>access_denied</error>
<error_description>Access is denied</error_description>
</ForbiddenException>

我们可以自己定义自己的访问拒绝的处理器。在这个处理器里面我们可以记录日志,也可以自定义返回去的这个错误。这里只是针对没有权限403这种情况的一个处理。 还有另外一种情况401,一会再说。 我们要写的就是AccessDeniedHandler这个接口的实现。

4.1、AccessDeniedHandler实现#

自定义错误处理handler

直接继承一个父类。OAuth2AccessDeniedHandler

OAuth2AccessDeniedHandler是个默认的实现,如果不在网关的这里配置accessDeniedHandler这个配置的话。

默认用的就是这个 OAuth2AccessDeniedHandler。在这里处理器里面,它会把异常 转换成一个简单的json也就是我们看到页面返回的403的json

声明称Spring的Bean。然后覆盖handler方法。

在这里能拿到request、response、抛出的exception。

可以通过操作response,写你想写的错误信息。

/**
* <p>DESC: 网关访问拒绝处理器</p>
* <p>DATE: 2019-12-18 14:02</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class GatewayAccessDeniedHandler extends OAuth2AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException authException) throws IOException, ServletException {
log.info("【访问拒绝过滤器】update log to 403");
super.handle(request, response, authException);
}
}

注入我们自己写的错误处理器

@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources
.accessDeniedHandler(gatewayAccessDeniedHandler)
.resourceId("resourceid")
.expressionHandler(gatewayWebSecurityExpressionHandler);
}

4.2、重启网关服务。测试#

日志的过滤器里面又update了一次。更新为成功了。我们想要的是 如果报错了 就不再更新日志为成功

最简单的做法是在request里面设置一个attributes。value值随便定义。

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException authException) throws IOException, ServletException {
log.info("【访问拒绝过滤器】update log to 403");
request.setAttribute("updated", true);
super.handle(request, response, authException);
}
if(request.getAttribute("updated") == null){
log.info("【日志过滤器】 3。 update log for success");
}

第一次请求失败了 打印出了403,第二次更新成功

现在只处理了403这种情况,还有一种情况就是401,就是当前用户需要做身份认证,你没有做身份认证。

jwt改造之日志及错误处理(2)#

1、401的处理#

与403类似,也是在这里配置。EntryPoint入口点。

这个方法里面,我们要实现的接口是AuthenticationEntryPoint

不直接实现这个接口,而是继承一个父类。OAuth2AuthenticationEntryPoint

上面如果不配置。那么默认的实现就是OAuth2AuthenticationEntryPoint。它的默认实现就是返回一个401的错误码。然后返回一个和403类似的json

令牌随便加个1

这样就抛出401 的错误。

401的这段信息就是OAuth2AuthenticationEntryPoint来处理的

<InvalidTokenException>
<error>invalid_token</error>
<error_description>Cannot convert access token to JSON</error_description>
</InvalidTokenException>

2、EntryPoint#

加上@Component声明称Spring的Bean

覆盖的方法commence

/**
* <p>DESC: 网关配置401处理器</p>
* <p>DATE: 2019-12-18 14:36</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class GatewayAuthenticationEntryPoint extends OAuth2AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.info("【入口点】2. update log to 401");
request.setAttribute("updated", true);
super.commence(request, response, authException);
}
}

注入到配置类

@Resource
private GatewayAuthenticationEntryPoint authenticationEntryPoint;
@Override
public void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(new GatewayAuditLogSecurityFilter(), ExceptionTranslationFilter.class)
.authorizeRequests()
.antMatchers("/api/token/**").permitAll()
.anyRequest().access("#permissionService.hasPermission(request,authentication)");
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(gatewayAccessDeniedHandler)
.resourceId("resourceid")
.expressionHandler(gatewayWebSecurityExpressionHandler);
}
}

3、测试#

首先是发过来的令牌是有问题的,在令牌的前面加了一个1

返回的结果就是401

我们在后台日志里可以看到 ,只有一行日志

【入口点】2. update log to 401

这说明我的请求进入到认证的过滤器以后,因为令牌有问题所以 认证的 那个过滤器 直接抛了异常。 异常抛给了AuthenticationEntryPoint。它来处理,就输出了一行日志。然后整个处理就结束掉了。然后返回了错误信息。

在这种情况下,实际上你的请求就没有经过日志的Filter,也就是这个GatewayAuditLogFilter。而是直接从认证 的过滤器,直接抛异常。然后就走掉了。 这是一种情况。

另外一种情况:不传令牌,直接访问

多发几次请求。多点击几次请求的按钮。返回的永远是401

后台的日志里,出现了两种情况

anonymousUser是一个匿名的用户。因为没有传任何的令牌,所以实际上是一个匿名的用户,因为没传令牌,认证的过滤器默认建了一个匿名的用户身份放在他的authentication里面了。仍然往下走,走过了日志的过滤器。也走到了权限的判断,但是用的身份是匿名用户的身份。50%的概率,这里判断成功了。 请求放过去就执行了update log to success

那么401是哪里来的呢,是订单的服务,是需要身份认证的,返回了401,是在网关阶段判断出了没有权限。于是返回了401

两种情况:

1.请求匿名的被放过去,后面的order的服务返回401.

2。是网关这里权限没过,返回了401

1.令牌有问题,2。没传令牌。权限之类被拦住

4、分别处理两种情况#

最开始是这么配置的,一定要身份认证通过后,才能访问我们的这些请求

@Override
public void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(new GatewayAuditLogSecurityFilter(), ExceptionTranslationFilter.class)
.authorizeRequests()
.antMatchers("/api/token/**").permitAll()
.anyRequest().access("#permissionService.hasPermission(request,authentication)");
}

经过改造我们用了permissionService

permissonService里面没有做是不是登陆了的判断。而是直接是一个随机数。

  • 首先我们要把当前用户是不是登陆的判断加上。判断传进来的authentication就可以了

如果令牌是正确有效的,那么这里的authentication是一个oauth2的authentication,如果没带令牌,就是匿名的用户的情况,那么这里的authentication是一个匿名的authentication。所以这里只需要判断authentication的类型就看可以了。

所以这里判断如果当前的authentication是AnonymousAuthenticationToken匿名的 那么就抛出异常 必填的AccessToken的异常,

它的参数是当前服务的detials,这里直接传空就可以了

@Slf4j
@Service
public class PermissionServiceImpl implements PermissionService {
@Override
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
log.info("【请求地址】 = {}",request.getRequestURI());
log.info("【authentication】 = {}",authentication.toString());
if(authentication instanceof AnonymousAuthenticationToken){
throw new AccessTokenRequiredException(null);
}
return RandomUtils.nextInt() % 2 == 0;
//return true;
}
}

这个EntryPoint是用来处理401错误的。讲了401有两种情况。 1.是传的令牌有问题,无法解析,就直接从认证的过滤器里面抛异常,抛异常服务就断了,不往下走 了。

2.是没传令牌.以为anoymous的身份往下走。最后到PermissionService里面抛出一个AccessTokenRequiredException

所以这里的authException有两种情况。

一种是这里抛出的AccessTokenRequiredException。如果是这个exception,我认为你是没传令牌 ,如果不是这个异常,我就认为你传的令牌有问题

如果是这种异常,说明你没传令牌,而且你的请求是经过了日志服务器,日志服务器已经往数据库插入了一条记录了。所以这里就是更新log为401

如果不是这个,说明你的令牌本身是有问题的,这个时候不会过日志的Filter。所以这里就是add一个401的log。令牌有问题。

/**
* <p>DESC: 网关配置401处理器</p>
* <p>DATE: 2019-12-18 14:36</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
@Component
public class GatewayAuthenticationEntryPoint extends OAuth2AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
//匿名用户
if(authException instanceof AccessTokenRequiredException){
log.info("【entryPoint】 2。 update log for 401");
}
//token 自身有问题
else{
log.info("【entryPoint】 2。 add log for = {token无法解析} 401");
}
request.setAttribute("updated", true);
super.commence(request, response, authException);
}
}

5、重启网关测试#

  • 错误令牌
【entryPoint】 2。 add log for = {token无法解析} 401
  • 无令牌
【日志过滤器】 用户名=anonymousUser
【日志过滤器】 1。 add log for =anonymousUser
【请求地址】 = /api/order/orders
【entryPoint】 2。 update log for 401

网关日志实现#

1、表#

CREATE TABLE `dftc_sys_access_log` (
`id` bigint(22) NOT NULL,
`request_params` longtext,
`request_ip` varchar(255) DEFAULT NULL COMMENT '请求IP',
`request_url` varchar(255) DEFAULT NULL COMMENT '请求服务器的url资源',
`request_method` varchar(20) DEFAULT NULL COMMENT '请求方式',
`agent_info` varchar(600) DEFAULT NULL COMMENT ' 请求的客户端代理信息',
`response_code` smallint(6) DEFAULT NULL COMMENT '返回的业务code',
`response_msg` varchar(255) DEFAULT NULL COMMENT '业务返回信息',
`response_data` longtext COMMENT '返回的业务数据',
`consuming_time` bigint(22) DEFAULT NULL COMMENT '本次请求消耗时间',
`memo` varchar(255) DEFAULT NULL,
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;

2、实体类#

/**
* <p>DESC:访问日志 </p>
* <p>DATE: 2019/2/12</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Data
@TableName("dftc_sys_access_log")
public class AccessLogDO {
public AccessLogDO(String requestParams, String requestIp, String requestUrl, String requestMethod, String agentInfo,LocalDateTime now) {
if (StringUtils.isNotBlank(requestParams) && requestParams.length() > 600) {
this.requestParams = "数据太长,未记录";
}
this.requestParams = requestParams;
this.requestIp = requestIp;
this.requestUrl = requestUrl;
this.requestMethod = requestMethod;
this.agentInfo = agentInfo;
this.createTime = now;
}
private static final long serialVersionUID = -6031010158629417797L;
private Long id;
private LocalDateTime createTime;
private LocalDateTime updateTime;
/**
* 请求参数
*/
private String requestParams;
private String userId;
/**
* 请求IP
*/
private String requestIp;
/**
* 请求服务器的url资源
*/
private String requestUrl;
/**
* 请求方式
*/
private String requestMethod;
/**
* 请求的客户端代理信息
*/
private String agentInfo;
/**
* 返回的业务code
*/
private Integer responseCode;
/**
* 业务返回信息
*/
private String responseMsg;
/**
* 返回的业务数据
*/
private String responseData;
/**
* 本次请求消耗时间
*/
private Long consumingTime;
}

dao

/**
* <p>DESC: 访问日志DAO</p>
* <p>DATE: 2019-12-18 15:51</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Repository
@Mapper
public interface AccessLogDao extends BaseMapper<AccessLogDO> {
}

3、具体新增和修改业务#

/**
* 新增日志
* @param request 请求
* @return AccessLogDO
*/
private AccessLogDO insertLog(InputStreamHttpServletRequestWrapper request) throws IOException {
String params = HttpUtil.getBodyByBufferReader(request);
String ipAddr = HttpUtil.getIpAddr(request);
String requestUrl = request.getRequestURI();
String method = request.getMethod();
String agent = request.getHeader(HttpHeaders.USER_AGENT);
AccessLogDO accessLogDO = new AccessLogDO(params, ipAddr, requestUrl, method, agent,LocalDateTime.now());
accessLogDao.insert(accessLogDO);
//加入到请求域中
request.setAttribute("accessLog",accessLogDO);
return accessLogDO;
}
/**
* 修改日志
* @param request 请求
* @param response 返回
* @param body 返回体
*/
private void updateLog(HttpServletRequest request,HttpServletResponse response,String body){
AccessLogDO accessLog = (AccessLogDO) request.getAttribute("accessLog");
accessLog.setResponseData(body);
accessLog.setConsumingTime(Duration.between(accessLog.getCreateTime(),LocalDateTime.now()).getSeconds());
accessLog.setResponseCode(response.getStatus());
accessLogDao.updateById(accessLog);
}

4、审计日志过滤器#

/**
* <p>DESC: 网关日志过滤器</p>
* <p>DATE: 2019-12-17 17:57</p>
* <p>VERSION:2.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
@Slf4j
public class GatewayAuditLogSecurityFilter extends OncePerRequestFilter {
private AccessLogDao accessLogDao;
public GatewayAuditLogSecurityFilter(AccessLogDao accessLogDao) {
this.accessLogDao = accessLogDao;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//前面认证的过滤器会把jwt的令牌转换成一个Authentication,然后把它放到SpringSecurityContext安全的上下文里面。
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = (String) authentication.getPrincipal();
log.info("【日志过滤器】 用户名={}",username);
log.info("【日志过滤器】 1。 add log for ={}",username);
//具体实现新增
InputStreamHttpServletRequestWrapper servletRequest = new InputStreamHttpServletRequestWrapper(request);
insertLog(servletRequest);
//调用下面的过滤器
filterChain.doFilter(servletRequest,response);
if(request.getAttribute("updated") == null){
log.info("【日志过滤器】 3。 update log for success");
//具体实现修改
updateLog(servletRequest,response,"body");
}
}

5、问题#

由于 request中getReader()和getInputStream()只能调用一次,如果使用一次后继续使用就会抛出 stream.io 异常

解决方案:

在filter中request.getInputStream()取出请求后,在后面就会拿不到请求数据了,即一个InputStream对象在被读取完成后,将无法被再次读取。所以必须将读取出的数据进行缓存。

在javax.servlet.http包下面有一个装饰器类HttpServletRequestWrapper,利用这个装饰器类,我们可以重新包装一个HttpServletRequest对象。

定义一个装饰器继承HttpServletRequestWrapper,streamBody字节变量用来保存读取的数据,以便于多次读取。

public class InputStreamHttpServletRequestWrapper extends HttpServletRequestWrapper{
private final byte[] streamBody;
private static final int BUFFER_SIZE = 4096;
public InputStreamHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
byte[] bytes = inputStream2Byte(request.getInputStream());
if (bytes.length == 0 && RequestMethod.POST.name().equals(request.getMethod())) {
//从ParameterMap获取参数,并保存以便多次获取
bytes = request.getParameterMap().entrySet().stream()
.map(entry -> {
String result;
String[] value = entry.getValue();
if (value != null && value.length > 1) {
result = Arrays.stream(value).map(s -> entry.getKey() + "=" + s)
.collect(Collectors.joining("&"));
} else {
result = entry.getKey() + "=" + value[0];
}
return result;
}).collect(Collectors.joining("&")).getBytes();
}
streamBody = bytes;
}
private byte[] inputStream2Byte(InputStream inputStream) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] bytes = new byte[BUFFER_SIZE];
int length;
while ((length = inputStream.read(bytes, 0, BUFFER_SIZE)) != -1) {
outputStream.write(bytes, 0, length);
}
return outputStream.toByteArray();
}
@Override
public ServletInputStream getInputStream() throws IOException {
ByteArrayInputStream inputStream = new ByteArrayInputStream(streamBody);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener listener) {
}
@Override
public int read() throws IOException {
return inputStream.read();
}
};
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
}

声明一个带有HttpServletRequest入参的构造器,从该参数对象的流中解析数据,如果没有则继续从parameterMap中获取,然后以key=value&key=value形式拼接。用streamBody接收。然后我们重写getInputStream方法,以后每次调用getInputStream方法,其实是重新利用streamBody重新new一个流,所以可以多次读取。

有了装饰器后,我们就要装饰目标对象。我们都知道SpringMVC的一次请求会被一个个过滤器层层调用,也就是我们常说的责任链模式。利用Filter我们就可以在某个特定的位置装饰HttpServletRequest对象。

6、httputil#

/**
* <p>DESC: http工具</p>
* <p>DATE: 2019/1/30</p>
* <p>VERSION:1.0.0</p>
* <p>@AUTHOR: ZhengYong</p>
*/
public class HttpUtil {
/**
* 未知IP
*/
private static final String UNKNOWN = "unknown";
private final static String ENCODE = "UTF-8";
/**
* 获取请求的IP地址
*
* @param request request
* @return ip地址
*/
public static String getIpAddr(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip ** null || ip.length() ** 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip ** null || ip.length() ** 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip ** null || ip.length() ** 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
}
/**
* 获取域名和端口
*
* @param request request
* @return 域名和端口
*/
public static String getBasePath(HttpServletRequest request) {
String path = request.getContextPath();
return request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path;
}
/**
* 获取请求的全路径
*
* @param request request
* @return 全路径url
*/
public static String getFullPath(HttpServletRequest request) {
String basePath = getBasePath(request);
return basePath + request.getRequestURI();
}
/**
* 获取请求Body
*
* @param request request
* @return String
*/
public static String getRequestBody(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
InputStream inputStream = null;
BufferedReader reader = null;
String character = request.getCharacterEncoding();
try {
inputStream = request.getInputStream();
reader = new BufferedReader(new InputStreamReader(inputStream, character));
String line = "";
while ((line = reader.readLine()) != null) {
sb.append(line);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return sb.toString();
}
/**
* 获取请求body 通过 BufferReader
* @param request 请求
* @return body
* @throws IOException 异常
*/
public static String getBodyByBufferReader(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
try {
String line = "";
while ((line = reader.readLine()) != null) {
sb.append(line);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return sb.toString();
}
/**
* URL 解码
*
* @return String
*/
public static String getURLDecoderString(String str) {
String result = "";
if (null == str) {
return "";
}
try {
result = java.net.URLDecoder.decode(str, ENCODE);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return result;
}
public static byte[] readBytes(ServletInputStream inputStream) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] bffuer = new byte[1024];
int n = 0 ;
while (-1 != (n = inputStream.read(bffuer))){
outputStream.write(bffuer,0,n);
}
return outputStream.toByteArray();
}
}

jwt改造总结#

1、总览图#

左边本质上都是过滤器,虽然最后一个不是叫做什么什么filter。右边都是自己写的一些组件。组件的作用是改变过滤器的行为。 在过滤器链上,绿色是我们自己写的,蓝色是Spring Security提供的,自己写的组件注入到Spring的过滤器里面,来改变或者是增强Spring 自己的过滤器里面的行为。

1.限流 2.从jwt或者其他的这些令牌里面,把当前的用户 身份提取出来 3。审计日志 4.从名字看是异常的转换过滤器。本身并没有任何业务逻辑。他的作用就是catch后面这个Interceptor抛出的来的异常, 5.作用就是判断权限,我们写的permissonSerice最终就是在这里生效的。

2、过滤器链#

一个请求过来,他会按照这个顺序,经过所有的过滤器。当然还有一些其他的过滤器,就是SpringSecurity过滤器链上的其他过滤器。这些和我们核心讲 的没关系,我们就把它忽略了。但是跟我讲的安全相关的,就是核心的几个安全相关的过滤器都列出来了。按照这个顺序,请求会传过过滤器。

3、SecurityInterceptor#

我们会把自己的权限的判断的逻辑放在PermissionService里面。然后把PermissionService给到一个表达式处理器WebSecurityExpressionHandler。 表达式处理器给到我们的SecurityInterceptor

最终我们写的表达式是交给WebSecurityExpressionHandler来处理,然后它又交给了PermissionService

PermissionService里面我们又写了如果你没有带token,就是你当前是个匿名用户 我就会抛出异常,如果你是个可以认出来的合法用户,那么你有没有权限,也会有个逻辑来判断。如果你没有权限,这里就会抛出相应的错误。就是Exception

4、ExceptionTranslationFilter#

ExceptionTranslationFilter

抛出Exception会被ExceptionTranslationFilter捕获住,捕获住以后,会根据你抛出来的异常类型去调相应的处理器。在整个安全的里面一共就两种异常,一种是401一种是403

5、GatewayAuthenticationEntryPoint#

GatewayAuthenticationEntryPoint

401就交给了GatewayAuthenticationEntryPoint来处理。你的令牌有问题或者是你没传令牌。

6、GatewayAccessDeniedHandler#

GatewayAccessDeniedHandler

另一种是你的身份认证过了,但是当前这个请求你没权限,这是403交给GatewayAccessDeniedHandler来处理

这两个组件都是注到ExceptionTranslationFilter来进行相应的处理的

同时GatewayAuthenticationEntryPoint也会被注到 OAuth2ClientAuthenticationProcessingFilter。有种情况是你的令牌传的不对,根本解析不了。

sentinel限流实战#

简单来说就是干三件事,最终的结果就是保证你的服务可用,不会崩掉。保证服务高可用。

  • 限流
  • 熔断
  • 降级规则

1、流控#

先从最简单的场景来入手。 1.引用一个依赖, 2,声明一个资源。 3.声明一个规则 注意依赖是加在你的微服务上的,每一个微服务都要加一个sentinel的依赖。


OAuth2.0开发问题汇总#

问题1:#

Spring Security – There is no PasswordEncoder mapped for the id “null”

解决方法一: 问题解决:https://blog.csdn.net/Hello_World_QWP/article/details/81811462

解决方法二: 因为5.x版本新增了多种密码加密方式,必须指定一种,比如这样解决

@Bean
public static NoOpPasswordEncoder passwordEncoder() {
return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
}

问题2:#

调用接口/com-oauth/oauth/check_token失败

返回错误码:

{
"timestamp": "2019-07-10T02:57:43.818+0000",
"status": 403,
"error": "Forbidden",
"message": "Forbidden",
"path": "/com-oauth/oauth/check_token"
}

解决方法一: 设置 security.tokenKeyAccess(“permitAll()”).checkTokenAccess(“permitAll()”);

@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
super.configure(security);
security.tokenKeyAccess("permitAll()").checkTokenAccess("permitAll()");
}
}

解决犯法二:

@Configuration
@EnableWebSecurity
public class AuthWebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 配置忽略的路径,/oauth/check_token 是校验token的路径,security默认没有放行
* @param web web
* @throws Exception 异常
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/oauth/check_token");
}

问题3:#

在使用密码模式时,抛出异常:o.s.s.o.provider.endpoint.TokenEndpoint : Handling error: UnsupportedGrantTypeException, Unsupported grant type: password

解决方法:配置AuthenticationManager

@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
// 用户认证
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
super.configure(endpoints);
// 密码模式必须有这个参数
endpoints.authenticationManager(authenticationManager);
}
}

问题4#

Field authenticationManager in cn.springcloud.book.OAuthConfiguration required a bean of type ‘org.springframework.security.authentication.AuthenticationManager’ that could not be found

解决方法: 继承WebSecurityConfigurerAdapter 类,并重写方法authenticationManager(),使用 @Bean注解标记

@ComponentScan
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
}

问题5:#

在passwod模式下,执行刷新token时,抛出异常Handling error: IllegalStateException, UserDetailsService is required.

执行以下命令,抛出异常Handling error: IllegalStateException, UserDetailsService is required

curl -i -X POST -u 'clientapp2:112233' http://10.216.33.211:10808/com-oauth/oauth/token -H "accept: application/json" -d 'grant_type=refresh_token&refresh_token=b610dfa9-2ee4-4214-bc57-f6b2937d4b27'

解决方法: 在AuthorizationServerConfigurerAdapter 中配置UserDetailsService对象

@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Autowired
private MyUserDetailsService myUserDetailsService;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 执行token刷新需要带上此参数
endpoints.userDetailsService(myUserDetailsService);
}
}

问题6:#

不支持form表单提交

执行命令

curl -X POST "http://10.216.33.211:10808/com-oauth/oauth/token" -d "grant_type=client_credentials&scope=read_contacts&client_id=clientapp&client_secret=112233"

返回错误:

{"timestamp":"2019-07-11T02:27:29.962+0000","status":401,"error":"Unauthorized","message":"Unauthorized","path":"/com-oauth/oauth/token"}

解决方法: 支持Form表达提交: security.allowFormAuthenticationForClients();

@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
super.configure(security);
security.tokenKeyAccess("permitAll()").checkTokenAccess("permitAll()");
//允许表单认证
security.allowFormAuthenticationForClients();
}
}

执行命令

curl -X POST "http://10.216.33.211:10808/com-oauth/oauth/token" -d "grant_type=client_credentials&scope=read_contacts&client_id=clientapp&client_secret=112233"

返回正常结果:

{"access_token":"35ae4576-f7b3-480e-aeff-eee7ea2ce803","token_type":"bearer","refresh_token":"0493963a-22f5-4cff-8b50-3cc5da3577a6","expires_in":197,"scope":"read_contacts"}

SpringSecurity整合版v2#

SpringSecurity 基本原理#

项目地址:

[email protected]:eim-ZY/acl_parent.git
[email protected]:eim-ZY/securitydemo1.git

过滤器链#

SpringSecurity 本质是一个过滤器链:

从启动是可以获取到过滤器链:

org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFil
ter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.FilterSecurityInterceptor

代码底层流程:重点看三个过滤器:

FilterSecurityInterceptor:是一个方法级的权限过滤器, 基本位于过滤链的最底部。

image-20210316144237188

super.beforeInvocation(fi) 表示查看之前的 filter 是否通过。

fi.getChain().doFilter(fi.getRequest(), fi.getResponse());表示真正的调用后台的服务。

ExceptionTranslationFilter:是个异常过滤器,用来处理在认证授权过程中抛出的异常

image-20210316144517521

UsernamePasswordAuthenticationFilter :对/login 的 POST 请求做拦截,校验表单中用户名,密码。

DelegationFilterProxy首先加载 ,执行dofilter方法,执行delegateToUse = this.initDelegate(wac);

initDelegate方法中获取固定名字FilterChainProxy,收集所有过滤器并组装返回

image-20210316150130018

image-20210316152005164

UserDetailsService 接口讲解#

当什么也没有配置的时候,账号和密码是由 Spring Security 定义生成的。而在实际项目中

账号和密码都是从数据库中查询出来的。 所以我们要通过自定义逻辑控制认证逻辑。

如果需要自定义逻辑时,只需要实现 UserDetailsService 接口即可。接口定义如下:

image-20210316144716862

返回值 UserDetails

这个类是系统默认的用户“主体

web权限方案#

(1) 认证

(2)授权

1、设置登陆的用户名密码#

方式一:通过配置文件

方式二:通过配置类

方式三:自定义编写实现类

  • 通过配置文件#

image-20210316184802961

  • 通过配置类#

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String password = passwordEncoder.encode("123");
auth.inMemoryAuthentication().withUser("jack").password(password).roles();
}
}
  • 自定义编写实现类#

第一步:创建配置类,设置使用哪一个userDetailService实现类

第二步:编写实现类,返回User对象,User对象有用户名和操作权限

@Configuration
public class SecurityConfigTest extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
@Service("userDetailsService")
public class MyUserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role");
return new User("jack",new BCryptPasswordEncoder().encode("123"),authorities);
}
}

2、查询数据库完成用户认证#

  • 依赖
<!-- mysql数据库 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
  • 数据库配置
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.31.70:3306/security?serverTimezone=GMT%2B8
username: root
password: 123456
  • 数据库表
create table users(
id bigint primary key auto_increment,
username varchar(20) unique not null,
password varchar(100)
);
-- 密码 atguigu
insert into users values(1,'张
san','$2a$10$2R/M6iU3mCZt3ByG7kwYTeeW0w7/UqdeXrb27zkBIizBvAven0/na');
-- 密码 atguigu
insert into users values(2,'李
si','$2a$10$2R/M6iU3mCZt3ByG7kwYTeeW0w7/UqdeXrb27zkBIizBvAven0/na');
create table role(
id bigint primary key auto_increment,
name varchar(20)
);
insert into role values(1,'管理员');
insert into role values(2,'普通用户');
create table role_user(
uid bigint,
rid bigint
);
insert into role_user values(1,1);
insert into role_user values(2,2);
create table menu(
id bigint primary key auto_increment,
name varchar(20),
url varchar(100),
parentid bigint,
permission varchar(20)
);
insert into menu values(1,'系统管理','',0,'menu:system');
insert into menu values(2,'用户管理','',0,'menu:user');
create table role_menu(
mid bigint,
rid bigint
);
insert into role_menu values(1,1);
insert into role_menu values(2,1);
insert into role_menu values(2,2);
  • 实体类
@Data
@TableName(value = "users")
public class MyUser {
private Integer id;
private String username;
private String password;
}
  • 整合map
@Repository
public interface MyUserMapper extends BaseMapper<MyUser> {
}
  • userdetailservice中调用mapper查询数据库
@Service("userDetailsService")
public class MyUserDetailsServiceImpl implements UserDetailsService {
@Autowired
private MyUserMapper myUserMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//调用mapper查询数据库
QueryWrapper<MyUser> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username",username);
MyUser myUser = myUserMapper.selectOne(queryWrapper);
if(myUser == null){
throw new UsernameNotFoundException("用户名不存在!");
}
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role");
return new User(myUser.getUsername(),new BCryptPasswordEncoder().encode(myUser.getPassword()),authorities);
}
}
  • 启动类添加mapperscan
@MapperScan("com.eim.mapper")
@SpringBootApplication
public class SecurityDem1Application {
public static void main(String[] args) {
SpringApplication.run(SecurityDem1Application.class, args);
}
}

3、自定义登陆页面,不登陆访问#

  • 配置类(SecurityConfigTest extends WebSecurityConfigurerAdapter)中需要实现相关配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html") //登陆页面设置
.loginProcessingUrl("/user/login") //登陆访问路径
.defaultSuccessUrl("/test/index").permitAll() //登陆成功跳转页面
.and().authorizeRequests()
.antMatchers("/","/test/hello","/user/login").permitAll() //设置路径无需登陆即可认证
.anyRequest().authenticated() //其他请求必须认证
.and().csrf().disable(); //关闭csrf
}
  • 创建页面和controller
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>login</title>
</head>
<body>
<form action="/user/login" method="post">
用户名:<input type="text" name="username">
<br/>
密码: <input type="text" name="password">
<br/>
<input type="submit" value="登陆">
</form>
</body>
</html>
@GetMapping("index")
public String index(){
return "hello index";
}

4、基于角色或权限进行访问#

第一个:hasAuthority 方法#

如果当前的主体具有指定的权限,则返回 true,否则返回 false

  • 修改配置类,设置当前访问地址有那些权限

image-20210316204527715

在userDetailService中,把返回user对象设置权限

image-20210316204717790

第二个:hasAnyAuthority 方法#

如果当前的主体有任何提供的角色(给定的作为一个逗号分隔的字符串列表)的话,返回true

image-20210316205121100

第三个: hasRole 方法#

如果用户具备给定角色就允许访问,否则出现 403。

如果当前主体具有指定的角色,则返回 true。

底层源码:

image-20210316205442628

配置类:

image-20210316205722586

给用户添加角色:

image-20210316205915494

第四个:hasAnyRole#

表示用户具备任何一个条件都可以访问。

5、自定义403无权限页面#

修改配置类

image-20210316210350710

注解使用#

1、@Secured#

判断是否具有角色,另外需要注意的是这里匹配的字符串需要添加前缀“ROLE_“。

使用注解先要开启注解功能!放在启动类或则配置类

@EnableGlobalMethodSecurity(securedEnabled=true)

@EnableGlobalMethodSecurity(securedEnabled=true)
public class SecurityDem1Application {

在控制器方法上添加注解

@Secured({"ROLE_admin","ROLE_manager"})
@GetMapping("update")
public String update(){
return "hello update";
}

用户添加角色

image-20210316211623732

2、@PreAuthorize#

先开启注解功能:

@EnableGlobalMethodSecurity(prePostEnabled = true) 启动类上

image-20210316211837000

@PreAuthorize:注解适合进入方法的权限验证, @PreAuthorize 可以将登录用户的 roles/permissions 参数传到方法中。

@PreAuthorize("hasAuthority('admin')")
//@Secured({"ROLE_admin","ROLE_manager"})
@GetMapping("update")
public String update(){
return "hello update";
}

3、@PostAuthorize#

先开启注解功能:@EnableGlobalMethodSecurity(prePostEnabled = true)

@PostAuthorize 注解使用并不多,在方法执行后再进行权限验证,适合验证带有返回值的权限

@PostAuthorize("hasAuthority('admin')")
//@PreAuthorize("hasAuthority('admin')")
//@Secured({"ROLE_admin","ROLE_manager"})
@GetMapping("update")
public String update(){
System.out.println("update....");
return "hello update";
}

4、**@PostFilter*#

@PostFilter :权限验证之后对数据进行过滤 留下用户名是 admin1 的数据

表达式中的 filterObject 引用的是方法返回值 List 中的某一个元素

@RequestMapping("getAll")
@PreAuthorize("hasRole('ROLE_管理员')")
@PostFilter("filterObject.username == 'admin1'")
@ResponseBody
public List<UserInfo> getAllUser(){
ArrayList<UserInfo> list = new ArrayList<>();
list.add(new UserInfo(1l,"admin1","6666"));
list.add(new UserInfo(2l,"admin2","888"));
return list;
}

5、@PreFilter#

@PreFilter: 进入控制器之前对数据进行过滤

@RequestMapping("getTestPreFilter")
@PreAuthorize("hasRole('ROLE_管理员')")
@PreFilter(value = "filterObject.id%2==0")
@ResponseBody
public List<UserInfo> getTestPreFilter(@RequestBody List<UserInfo>
list){
list.forEach(t-> {
System.out.println(t.getId()+"\t"+t.getUsername());
});
return list;
}

用户注销#

在配置类中添加退出映射地址.

http.logout().logoutUrl("/logout").logoutSuccessUrl("/index").permitAll();

测试

image-20210316213825581

在登录页面添加一个退出连接

<body>
登录成功<br> <a href="/logout">退出</a>
</body

自动登陆#

原理#

1、cookie

2、安全框架机制实现自动登陆

image-20210316214650583

image-20210316214740832

具体实现#

1、创建数据库表

CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2、配置类,注入数据源,配置操作库对象

//注入数据源
@Autowired
private DataSource dataSource;
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
//jdbcTokenRepository.setCreateTableOnStartup(true);//自动创建表
return jdbcTokenRepository;
}

3、配置记住我

.and().rememberMe().tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(60)
.userDetailsService(userDetailsService)

image-20210316215917111

4、页面添加记住我复选框

记住我:<input type="checkbox"name="remember-me"title="记住密码"/><br/>

此处:name 属性值必须位 remember-me.不能改为其他值

CSRF#

跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click

attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已

登录的 Web 应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS

利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个

自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买

商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。

这利用了 web 中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的

浏览器,却不能保证请求本身是用户自愿发出的

从 Spring Security 4.0 开始,默认情况下会启用 CSRF 保护,以防止 CSRF 攻击应用

程序,Spring Security CSRF 会针对 PATCH,POST,PUT 和 DELETE 方法进行防护。

案例

在登录页面添加一个隐藏域

<input
type="hidden"th:if="${_csrf}!=null"th:value="${_csrf.token}"name="_csrf
"/>

关闭安全配置的类中的 csrf

// http.csrf().disable();

微服务权限认证方案#

1、什么是微服务#

1、微服务由来

微服务最早由 Martin Fowler 与 James Lewis 于 2014 年共同提出,微服务架构风格是一种

使用一套小服务来开发单个应用的方式途径,每个服务运行在自己的进程中,并使用轻量

级机制通信,通常是 HTTP API,这些服务基于业务能力构建,并能够通过自动化部署机制

来独立部署,这些服务使用不同的编程语言实现,以及不同数据存储技术,并保持最低限

度的集中式管理。

2、微服务优势

(1)微服务每个模块就相当于一个单独的项目,代码量明显减少,遇到问题也相对来说比

较好解决。

(2)微服务每个模块都可以使用不同的存储方式(比如有的用 redis,有的用 mysql

等),数据库也是单个模块对应自己的数据库。

(3)微服务每个模块都可以使用不同的开发技术,开发模式更灵活。

3、微服务本质

(1)微服务,关键其实不仅仅是微服务本身,而是系统要提供一套基础的架构,这种架构

使得微服务可以独立的部署、运行、升级,不仅如此,这个系统架构还让微服务与微服务

之间在结构上“松耦合”,而在功能上则表现为一个统一的整体。这种所谓的“统一的整

体”表现出来的是统一风格的界面,统一的权限管理,统一的安全策略,统一的上线过

程,统一的日志和审计方法,统一的调度方式,统一的访问入口等等。

(2)微服务的目的是有效的拆分应用,实现敏捷开发和部署。

image-20210316221642026

2、微服务认证与授权实现思路#

1**、认证授权过程分析**

(1)如果是基于 Session,那么 Spring-security 会对 cookie 里的 sessionid 进行解析,找

到服务器存储的 session 信息,然后判断当前用户是否符合请求的要求。

(2)如果是 token,则是解析出 token,然后将当前请求加入到 Spring-security 管理的权限

信息中去

image-20210316221143685

如果系统的模块众多,每个模块都需要进行授权与认证,所以我们选择基于 token 的形式

进行授权与认证,用户根据用户名密码认证成功,然后获取当前用户角色的一系列权限

值,并以用户名为 key,权限列表为 value 的形式存入 redis 缓存中,根据用户名相关信息

生成 token 返回,浏览器将 token 记录到 cookie 中,每次调用 api 接口都默认将 token 携带

到 header 请求头中,Spring-security 解析 header 头获取 token 信息,解析 token 获取当前

用户名,根据用户名就可以从 redis 中获取权限列表,这样 Spring-security 就能够判断当前

请求是否有权限访问

2**、权限管理数据模型**

image-20210316221355823

image-20210316222024967

数据库表结构

## Host: localhost (Version 5.7.19)
## Date: 2019-11-18 15:49:15
## Generator: MySQL-Front 6.1 (Build 1.26)
#
## Structure for table "acl_permission"
#
CREATE TABLE `acl_permission` (
`id` char(19) NOT NULL DEFAULT '' COMMENT '编号',
`pid` char(19) NOT NULL DEFAULT '' COMMENT '所属上级',
`name` varchar(20) NOT NULL DEFAULT '' COMMENT '名称',
`type` tinyint(3) NOT NULL DEFAULT '0' COMMENT '类型(1:菜单,2:按钮)',
`permission_value` varchar(50) DEFAULT NULL COMMENT '权限值',
`path` varchar(100) DEFAULT NULL COMMENT '访问路径',
`component` varchar(100) DEFAULT NULL COMMENT '组件路径',
`icon` varchar(50) DEFAULT NULL COMMENT '图标',
`status` tinyint(4) DEFAULT NULL COMMENT '状态(0:禁止,1:正常)',
`is_deleted` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '逻辑删除 1(true)已删除, 0(false)未删除',
`gmt_create` datetime DEFAULT NULL COMMENT '创建时间',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_pid` (`pid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限';
#
## Data for table "acl_permission"
#
INSERT INTO `acl_permission` VALUES ('1','0','全部数据',0,NULL,NULL,NULL,NULL,NULL,0,'2019-11-15 17:13:06','2019-11-15 17:13:06'),('1195268474480156673','1','权限管理',1,NULL,'/acl','Layout',NULL,NULL,0,'2019-11-15 17:13:06','2019-11-18 13:54:25'),('1195268616021139457','1195268474480156673','用户管理',1,NULL,'user/list','/acl/user/list',NULL,NULL,0,'2019-11-15 17:13:40','2019-11-18 13:53:12'),('1195268788138598401','1195268474480156673','角色管理',1,NULL,'role/list','/acl/role/list',NULL,NULL,0,'2019-11-15 17:14:21','2019-11-15 17:14:21'),('1195268893830864898','1195268474480156673','菜单管理',1,NULL,'menu/list','/acl/menu/list',NULL,NULL,0,'2019-11-15 17:14:46','2019-11-15 17:14:46'),('1195269143060602882','1195268616021139457','查看',2,'user.list','','',NULL,NULL,0,'2019-11-15 17:15:45','2019-11-17 21:57:16'),('1195269295926206466','1195268616021139457','添加',2,'user.add','user/add','/acl/user/form',NULL,NULL,0,'2019-11-15 17:16:22','2019-11-15 17:16:22'),('1195269473479483394','1195268616021139457','修改',2,'user.update','user/update/:id','/acl/user/form',NULL,NULL,0,'2019-11-15 17:17:04','2019-11-15 17:17:04'),('1195269547269873666','1195268616021139457','删除',2,'user.remove','','',NULL,NULL,0,'2019-11-15 17:17:22','2019-11-15 17:17:22'),('1195269821262782465','1195268788138598401','修改',2,'role.update','role/update/:id','/acl/role/form',NULL,NULL,0,'2019-11-15 17:18:27','2019-11-15 17:19:53'),('1195269903542444034','1195268788138598401','查看',2,'role.list','','',NULL,NULL,0,'2019-11-15 17:18:47','2019-11-15 17:18:47'),('1195270037005197313','1195268788138598401','添加',2,'role.add','role/add','/acl/role/form',NULL,NULL,0,'2019-11-15 17:19:19','2019-11-18 11:05:42'),('1195270442602782721','1195268788138598401','删除',2,'role.remove','','',NULL,NULL,0,'2019-11-15 17:20:55','2019-11-15 17:20:55'),('1195270621548568578','1195268788138598401','角色权限',2,'role.acl','role/distribution/:id','/acl/role/roleForm',NULL,NULL,0,'2019-11-15 17:21:38','2019-11-15 17:21:38'),('1195270744097742849','1195268893830864898','查看',2,'permission.list','','',NULL,NULL,0,'2019-11-15 17:22:07','2019-11-15 17:22:07'),('1195270810560684034','1195268893830864898','添加',2,'permission.add','','',NULL,NULL,0,'2019-11-15 17:22:23','2019-11-15 17:22:23'),('1195270862100291586','1195268893830864898','修改',2,'permission.update','','',NULL,NULL,0,'2019-11-15 17:22:35','2019-11-15 17:22:35'),('1195270887933009922','1195268893830864898','删除',2,'permission.remove','','',NULL,NULL,0,'2019-11-15 17:22:41','2019-11-15 17:22:41'),('1195349439240048642','1','讲师管理',1,NULL,'/edu/teacher','Layout',NULL,NULL,0,'2019-11-15 22:34:49','2019-11-15 22:34:49'),('1195349699995734017','1195349439240048642','讲师列表',1,NULL,'list','/edu/teacher/list',NULL,NULL,0,'2019-11-15 22:35:52','2019-11-15 22:35:52'),('1195349810561781761','1195349439240048642','添加讲师',1,NULL,'create','/edu/teacher/form',NULL,NULL,0,'2019-11-15 22:36:18','2019-11-15 22:36:18'),('1195349876252971010','1195349810561781761','添加',2,'teacher.add','','',NULL,NULL,0,'2019-11-15 22:36:34','2019-11-15 22:36:34'),('1195349979797753857','1195349699995734017','查看',2,'teacher.list','','',NULL,NULL,0,'2019-11-15 22:36:58','2019-11-15 22:36:58'),('1195350117270261762','1195349699995734017','修改',2,'teacher.update','edit/:id','/edu/teacher/form',NULL,NULL,0,'2019-11-15 22:37:31','2019-11-15 22:37:31'),('1195350188359520258','1195349699995734017','删除',2,'teacher.remove','','',NULL,NULL,0,'2019-11-15 22:37:48','2019-11-15 22:37:48'),('1195350299365969922','1','课程分类',1,NULL,'/edu/subject','Layout',NULL,NULL,0,'2019-11-15 22:38:15','2019-11-15 22:38:15'),('1195350397751758850','1195350299365969922','课程分类列表',1,NULL,'list','/edu/subject/list',NULL,NULL,0,'2019-11-15 22:38:38','2019-11-15 22:38:38'),('1195350500512206850','1195350299365969922','导入课程分类',1,NULL,'import','/edu/subject/import',NULL,NULL,0,'2019-11-15 22:39:03','2019-11-15 22:39:03'),('1195350612172967938','1195350397751758850','查看',2,'subject.list','','',NULL,NULL,0,'2019-11-15 22:39:29','2019-11-15 22:39:29'),('1195350687590748161','1195350500512206850','导入',2,'subject.import','','',NULL,NULL,0,'2019-11-15 22:39:47','2019-11-15 22:39:47'),('1195350831744782337','1','课程管理',1,NULL,'/edu/course','Layout',NULL,NULL,0,'2019-11-15 22:40:21','2019-11-15 22:40:21'),('1195350919074385921','1195350831744782337','课程列表',1,NULL,'list','/edu/course/list',NULL,NULL,0,'2019-11-15 22:40:42','2019-11-15 22:40:42'),('1195351020463296513','1195350831744782337','发布课程',1,NULL,'info','/edu/course/info',NULL,NULL,0,'2019-11-15 22:41:06','2019-11-15 22:41:06'),('1195351159672246274','1195350919074385921','完成发布',2,'course.publish','publish/:id','/edu/course/publish',NULL,NULL,0,'2019-11-15 22:41:40','2019-11-15 22:44:01'),('1195351326706208770','1195350919074385921','编辑课程',2,'course.update','info/:id','/edu/course/info',NULL,NULL,0,'2019-11-15 22:42:19','2019-11-15 22:42:19'),('1195351566221938690','1195350919074385921','编辑课程大纲',2,'chapter.update','chapter/:id','/edu/course/chapter',NULL,NULL,0,'2019-11-15 22:43:17','2019-11-15 22:43:17'),('1195351862889254913','1','统计分析',1,NULL,'/statistics/daily','Layout',NULL,NULL,0,'2019-11-15 22:44:27','2019-11-15 22:44:27'),('1195351968841568257','1195351862889254913','生成统计',1,NULL,'create','/statistics/daily/create',NULL,NULL,0,'2019-11-15 22:44:53','2019-11-15 22:44:53'),('1195352054917074946','1195351862889254913','统计图表',1,NULL,'chart','/statistics/daily/chart',NULL,NULL,0,'2019-11-15 22:45:13','2019-11-15 22:45:13'),('1195352127734386690','1195352054917074946','查看',2,'daily.list','','',NULL,NULL,0,'2019-11-15 22:45:30','2019-11-15 22:45:30'),('1195352215768633346','1195351968841568257','生成',2,'daily.add','','',NULL,NULL,0,'2019-11-15 22:45:51','2019-11-15 22:45:51'),('1195352547621965825','1','CMS管理',1,NULL,'/cms','Layout',NULL,NULL,0,'2019-11-15 22:47:11','2019-11-18 10:51:46'),('1195352856645701633','1195353513549205505','查看',2,'banner.list','',NULL,NULL,NULL,0,'2019-11-15 22:48:24','2019-11-15 22:48:24'),('1195352909401657346','1195353513549205505','添加',2,'banner.add','banner/add','/cms/banner/form',NULL,NULL,0,'2019-11-15 22:48:37','2019-11-18 10:52:10'),('1195353051395624961','1195353513549205505','修改',2,'banner.update','banner/update/:id','/cms/banner/form',NULL,NULL,0,'2019-11-15 22:49:11','2019-11-18 10:52:05'),('1195353513549205505','1195352547621965825','Bander列表',1,NULL,'banner/list','/cms/banner/list',NULL,NULL,0,'2019-11-15 22:51:01','2019-11-18 10:51:29'),('1195353672110673921','1195353513549205505','删除',2,'banner.remove','','',NULL,NULL,0,'2019-11-15 22:51:39','2019-11-15 22:51:39'),('1195354076890370050','1','订单管理',1,NULL,'/order','Layout',NULL,NULL,0,'2019-11-15 22:53:15','2019-11-15 22:53:15'),('1195354153482555393','1195354076890370050','订单列表',1,NULL,'list','/order/list',NULL,NULL,0,'2019-11-15 22:53:33','2019-11-15 22:53:58'),('1195354315093282817','1195354153482555393','查看',2,'order.list','','',NULL,NULL,0,'2019-11-15 22:54:12','2019-11-15 22:54:12'),('1196301740985311234','1195268616021139457','分配角色',2,'user.assgin','user/role/:id','/acl/user/roleForm',NULL,NULL,0,'2019-11-18 13:38:56','2019-11-18 13:38:56');
#
## Structure for table "acl_role"
#
CREATE TABLE `acl_role` (
`id` char(19) NOT NULL DEFAULT '' COMMENT '角色id',
`role_name` varchar(20) NOT NULL DEFAULT '' COMMENT '角色名称',
`role_code` varchar(20) DEFAULT NULL COMMENT '角色编码',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`is_deleted` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '逻辑删除 1(true)已删除, 0(false)未删除',
`gmt_create` datetime NOT NULL COMMENT '创建时间',
`gmt_modified` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
#
## Data for table "acl_role"
#
INSERT INTO `acl_role` VALUES ('1','普通管理员',NULL,NULL,0,'2019-11-11 13:09:32','2019-11-18 10:27:18'),('1193757683205607426','课程管理员',NULL,NULL,0,'2019-11-11 13:09:45','2019-11-18 10:25:44'),('1196300996034977794','test',NULL,NULL,0,'2019-11-18 13:35:58','2019-11-18 13:35:58');
#
## Structure for table "acl_role_permission"
#
CREATE TABLE `acl_role_permission` (
`id` char(19) NOT NULL DEFAULT '',
`role_id` char(19) NOT NULL DEFAULT '',
`permission_id` char(19) NOT NULL DEFAULT '',
`is_deleted` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '逻辑删除 1(true)已删除, 0(false)未删除',
`gmt_create` datetime NOT NULL COMMENT '创建时间',
`gmt_modified` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_role_id` (`role_id`),
KEY `idx_permission_id` (`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色权限';
#
## Data for table "acl_role_permission"
#
INSERT INTO `acl_role_permission` VALUES ('1196301979754455041','1','1',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301979792203778','1','1195268474480156673',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301979821563906','1','1195268616021139457',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301979842535426','1','1195269143060602882',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301979855118338','1','1195269295926206466',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301979880284161','1','1195269473479483394',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301979913838593','1','1195269547269873666',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301979926421506','1','1196301740985311234',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301979951587330','1','1195268788138598401',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980014501889','1','1195269821262782465',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980035473410','1','1195269903542444034',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980052250626','1','1195270037005197313',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980077416450','1','1195270442602782721',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980094193665','1','1195270621548568578',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980119359489','1','1195268893830864898',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980136136706','1','1195270744097742849',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980249382913','1','1195270810560684034',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980270354434','1','1195270862100291586',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980287131649','1','1195270887933009922',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980303908866','1','1195349439240048642',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980320686082','1','1195349699995734017',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980345851905','1','1195349979797753857',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980362629121','1','1195350117270261762',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980383600641','1','1195350188359520258',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980408766465','1','1195349810561781761',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980421349378','1','1195349876252971010',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980438126593','1','1195350299365969922',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980450709506','1','1195350397751758850',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980501041153','1','1195350612172967938',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980517818370','1','1195350500512206850',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980538789889','1','1195350687590748161',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980622675970','1','1195350831744782337',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980639453186','1','1195350919074385921',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980660424705','1','1195351159672246274',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980677201922','1','1195351326706208770',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980698173441','1','1195351566221938690',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980714950658','1','1195351020463296513',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980723339266','1','1195351862889254913',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980744310786','1','1195351968841568257',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980761088001','1','1195352215768633346',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980777865217','1','1195352054917074946',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980794642434','1','1195352127734386690',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980811419650','1','1195352547621965825',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980828196865','1','1195353513549205505',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980844974082','1','1195352856645701633',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980861751298','1','1195352909401657346',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980886917122','1','1195353051395624961',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980928860162','1','1195353672110673921',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980954025986','1','1195354076890370050',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980970803201','1','1195354153482555393',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196301980987580418','1','1195354315093282817',1,'2019-11-18 13:39:53','2019-11-18 13:39:53'),('1196305293070077953','1','1',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293099438081','1','1195268474480156673',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293120409602','1','1195268616021139457',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293153964034','1','1195269143060602882',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293183324162','1','1195269295926206466',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293212684290','1','1195269473479483394',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293237850114','1','1195269547269873666',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293271404545','1','1196301740985311234',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293485314049','1','1195268788138598401',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293506285569','1','1195269821262782465',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293527257089','1','1195269903542444034',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293552422914','1','1195270037005197313',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293565005825','1','1195270442602782721',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293594365954','1','1195270621548568578',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293611143169','1','1195268893830864898',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293627920385','1','1195270744097742849',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293657280513','1','1195349439240048642',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293674057729','1','1195349699995734017',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293690834946','1','1195349979797753857',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293716000770','1','1195350117270261762',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293736972290','1','1195350188359520258',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293749555202','1','1195349810561781761',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293766332417','1','1195349876252971010',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293795692546','1','1195350299365969922',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293812469762','1','1195350397751758850',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293837635586','1','1195350612172967938',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293858607106','1','1195350500512206850',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293875384322','1','1195350687590748161',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293892161538','1','1195350831744782337',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293950881794','1','1195350919074385921',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305293976047617','1','1195351159672246274',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294127042561','1','1195351326706208770',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294156402690','1','1195351566221938690',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294177374209','1','1195351862889254913',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294194151425','1','1195351968841568257',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294223511554','1','1195352215768633346',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294240288770','1','1195352054917074946',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294248677377','1','1195352127734386690',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294248677378','1','1195352547621965825',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294319980546','1','1195353513549205505',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294319980547','1','1195352856645701633',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294319980548','1','1195352909401657346',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294378700802','1','1195353051395624961',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294378700803','1','1195353672110673921',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294458392577','1','1195354076890370050',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294483558402','1','1195354153482555393',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305294500335618','1','1195354315093282817',1,'2019-11-18 13:53:03','2019-11-18 13:53:03'),('1196305566656139266','1','1',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566689693698','1','1195268474480156673',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566706470913','1','1195268616021139457',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566740025346','1','1195269143060602882',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566756802561','1','1195269295926206466',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566781968385','1','1195269473479483394',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566811328514','1','1195269547269873666',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566828105730','1','1196301740985311234',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566853271554','1','1195268788138598401',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566878437378','1','1195269821262782465',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566895214593','1','1195269903542444034',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566916186113','1','1195270037005197313',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566949740546','1','1195270442602782721',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566966517761','1','1195270621548568578',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305566991683585','1','1195268893830864898',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567012655106','1','1195270744097742849',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567029432322','1','1195270810560684034',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567042015233','1','1195270862100291586',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567100735490','1','1195270887933009922',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567117512705','1','1195349439240048642',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567142678530','1','1195349699995734017',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567155261442','1','1195349979797753857',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567172038658','1','1195350117270261762',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567188815873','1','1195350188359520258',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567218176001','1','1195349810561781761',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567234953217','1','1195349876252971010',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567251730434','1','1195350299365969922',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567272701954','1','1195350397751758850',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567289479170','1','1195350612172967938',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567310450690','1','1195350500512206850',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567327227905','1','1195350687590748161',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567448862722','1','1195350831744782337',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567478222850','1','1195350919074385921',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567495000065','1','1195351159672246274',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567520165889','1','1195351326706208770',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567541137409','1','1195351566221938690',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567570497538','1','1195351862889254913',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567587274754','1','1195351968841568257',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567604051970','1','1195352215768633346',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567633412098','1','1195352054917074946',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567683743745','1','1195352127734386690',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567721492481','1','1195352547621965825',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567742464002','1','1195353513549205505',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567771824129','1','1195352856645701633',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567792795650','1','1195352909401657346',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567809572866','1','1195353051395624961',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567843127298','1','1195353672110673921',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567868293122','1','1195354076890370050',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567885070338','1','1195354153482555393',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196305567910236162','1','1195354315093282817',1,'2019-11-18 13:54:08','2019-11-18 13:54:08'),('1196312702601695234','1','1',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702652026881','1','1195268474480156673',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702668804098','1','1195268616021139457',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702698164226','1','1195269143060602882',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702723330049','1','1195269295926206466',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702744301569','1','1195269473479483394',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702765273089','1','1195269547269873666',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702790438913','1','1196301740985311234',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702945628161','1','1195268788138598401',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312702970793985','1','1195269821262782465',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312703000154114','1','1195269903542444034',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312703025319938','1','1195270037005197313',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312703046291458','1','1195270442602782721',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312703063068673','1','1195270621548568578',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312703084040193','1','1195268893830864898',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312703113400321','1','1195270744097742849',0,'2019-11-18 14:22:29','2019-11-18 14:22:29'),('1196312703134371842','1','1195270810560684034',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703159537665','1','1195270862100291586',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703184703490','1','1195270887933009922',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703209869313','1','1195349439240048642',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703230840834','1','1195349699995734017',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703251812354','1','1195349979797753857',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703272783873','1','1195350117270261762',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703293755394','1','1195350188359520258',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703327309826','1','1195349810561781761',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703348281345','1','1195349876252971010',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703365058561','1','1195350299365969922',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703386030082','1','1195350397751758850',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703440556034','1','1195350612172967938',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703486693378','1','1195350500512206850',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703511859202','1','1195350687590748161',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703654465537','1','1195350831744782337',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703683825665','1','1195350919074385921',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703700602882','1','1195351159672246274',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703717380098','1','1195351326706208770',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703738351618','1','1195351566221938690',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703759323137','1','1195351020463296513',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703776100353','1','1195351862889254913',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703792877570','1','1195351968841568257',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703830626305','1','1195352215768633346',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703843209217','1','1195352054917074946',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703868375041','1','1195352127734386690',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703889346561','1','1195352547621965825',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703901929473','1','1195353513549205505',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703918706689','1','1195352856645701633',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703952261121','1','1195352909401657346',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703973232642','1','1195353051395624961',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312703990009857','1','1195353672110673921',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312704048730114','1','1195354076890370050',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312704069701633','1','1195354153482555393',0,'2019-11-18 14:22:30','2019-11-18 14:22:30'),('1196312704094867457','1','1195354315093282817',0,'2019-11-18 14:22:30','2019-11-18 14:22:30');
#
## Structure for table "acl_user"
#
CREATE TABLE `acl_user` (
`id` char(19) NOT NULL COMMENT '会员id',
`username` varchar(20) NOT NULL DEFAULT '' COMMENT '微信openid',
`password` varchar(32) NOT NULL DEFAULT '' COMMENT '密码',
`nick_name` varchar(50) DEFAULT NULL COMMENT '昵称',
`salt` varchar(255) DEFAULT NULL COMMENT '用户头像',
`token` varchar(100) DEFAULT NULL COMMENT '用户签名',
`is_deleted` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '逻辑删除 1(true)已删除, 0(false)未删除',
`gmt_create` datetime NOT NULL COMMENT '创建时间',
`gmt_modified` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
#
## Data for table "acl_user"
#
INSERT INTO `acl_user` VALUES ('1','admin','96e79218965eb72c92a549dd5a330112','admin','',NULL,0,'2019-11-01 10:39:47','2019-11-01 10:39:47'),('2','test','96e79218965eb72c92a549dd5a330112','test',NULL,NULL,0,'2019-11-01 16:36:07','2019-11-01 16:40:08');
#
## Structure for table "acl_user_role"
#
CREATE TABLE `acl_user_role` (
`id` char(19) NOT NULL DEFAULT '' COMMENT '主键id',
`role_id` char(19) NOT NULL DEFAULT '0' COMMENT '角色id',
`user_id` char(19) NOT NULL DEFAULT '0' COMMENT '用户id',
`is_deleted` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '逻辑删除 1(true)已删除, 0(false)未删除',
`gmt_create` datetime NOT NULL COMMENT '创建时间',
`gmt_modified` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_role_id` (`role_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
#
## Data for table "acl_user_role"
#
INSERT INTO `acl_user_role` VALUES ('1','1','2',0,'2019-11-11 13:09:53','2019-11-11 13:09:53');

3、使用架构#

image-20210316222333939

image-20210316222422750

image-20210316222858034

image-20210317053544723

注意包扫描#

image-20210319080821520

4、编写代码#

1、编写common

添加工具类

2、security整体规划

image-20210317080604726

第一步:#

image-20210317080835158

1、密码处理#
@Component
public class DefaultPasswordEncoder implements PasswordEncoder {
public DefaultPasswordEncoder(int strength) {
}
public DefaultPasswordEncoder() {
this(-1);
}
//进行MD5加密
@Override
public String encode(CharSequence rawPassword) {
return MD5.encrypt(rawPassword.toString());
}
//进行密码比对
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodedPassword.equals(MD5.encrypt(rawPassword.toString()));
}
}
2、token操作类#
@Component
public class TokenManager {
//token有效时常
private long tokeExpiration = 24 * 60 * 60 *1000;
//编码密钥
private String tokenSignKey = "123456";
//1、根据用户名生产token
public String creatToken(String userName) {
String token = Jwts.builder().setSubject(userName)
.setExpiration(new Date(System.currentTimeMillis() + tokeExpiration))
.signWith(SignatureAlgorithm.HS512, tokenSignKey).compressWith(CompressionCodecs.GZIP).compact();
return token;
}
//2、根据token字符串获取用户信息
public String getUserInfoFromToken(String token){
String userInfo = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody().getSubject();
return userInfo;
}
//3、删除token
public String deleteToken(String token){
return null;
}
}
3、退出处理器#
public class TokenLogoutHandler implements LogoutHandler {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenLogoutHandler(TokenManager tokenManager, RedisTemplate redisTemplate) {
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
//1、从header中获取token
//2、token不为空,移除token,从redis中删除
String token = request.getHeader("token");
if(token != null){
//移除
tokenManager.deleteToken(token);
//获取用户信息
String username = tokenManager.getUserInfoFromToken(token);
redisTemplate.delete(username);
}
ResponseUtil.out(response, R.ok());
}
}
4、未认证统一处理类#
public class UnauthEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
ResponseUtil.out(response, R.error());
}
}
5、处理无权限访问类#
@ControllerAdvice
public class SecurityGlobeException {
//处理访问拒绝异常
@ResponseBody
@ExceptionHandler(AccessDeniedException.class)
public R handleAccessDeniedException(AccessDeniedException e){
return R.error().message("403, 无权限访问该接口");
}
}

第二步:认证过滤器#

image-20210317202117940

认证的过滤器

public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
private AuthenticationManager authenticationManager;
public TokenLoginFilter(TokenManager tokenManager, RedisTemplate redisTemplate, AuthenticationManager authenticationManager) {
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
this.authenticationManager = authenticationManager;
this.setPostOnly(false);
//设置登陆路径
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login", "POST"));
}
//1、获取表单提交过来的用户名密码
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//获取表单提交的数据
try {
User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(),
new ArrayList<>()));
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException();
}
}
//2、认证成功调用的方法
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//获取认证成功之后用户信息
SecurityUser securityUser = (SecurityUser) authResult.getPrincipal();
//根据用户名生产token
String token = tokenManager.creatToken(securityUser.getCurrentUserInfo().getUsername());
//调用redis存入用户信息,返回token
redisTemplate.opsForValue().set(securityUser.getCurrentUserInfo().getUsername(),securityUser.getPermissionValueList());
//返回token
ResponseUtil.out(response, R.ok().data("token",token));
}
//3、认证失败调用的方法
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
ResponseUtil.out(response, R.error());
}
}

用户的实体类

@Data
@ApiModel(description = "用户实体类")
public class User implements Serializable {
private String username;
private String password;
private String nickName;
private String salt;
private String token;
}

用户权限实体类

@Data
@Slf4j
public class SecurityUser implements UserDetails {
//当前登录用户
private transient User currentUserInfo;
//当前权限
private List<String> permissionValueList;
public SecurityUser() {
}
public SecurityUser(User user) {
if (user != null) {
this.currentUserInfo = user;
}
}
//设置权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (String permissionValue : permissionValueList) {
if (StringUtils.isEmpty(permissionValue)){
continue;
}
SimpleGrantedAuthority authority = new
SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
return authorities;
}
@Override
public String getPassword() {
return currentUserInfo.getPassword();
}
@Override
public String getUsername() {
return currentUserInfo.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

第三步:授权过滤器#

package com.eim.security.filter;
import com.eim.security.security.TokenManager;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
public class TokenAuthFilter extends BasicAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenAuthFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//获取认证成功用户信息
UsernamePasswordAuthenticationToken authRequest = getAuthentication(request);
//如果有权限信息放入security的上下文中
if (authRequest != null) {
SecurityContextHolder.getContext().setAuthentication(authRequest);
}
chain.doFilter(request,response);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
//从header中获取token
String token = request.getHeader("token");
if (token != null) {
//解析token获取用户信息
String username = tokenManager.getUserInfoFromToken(token);
//从redis中获取对应权限列表
List<String> permissionList = (List<String>) redisTemplate.opsForValue().get(username);
//拼装权限列表
assert permissionList != null;
Collection<GrantedAuthority> authorities = permissionList.stream().map(e -> {
return new SimpleGrantedAuthority(e);
}).collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(username, token, authorities);
}
return null;
}
}

第四步:核心配置类#

image-20210317211919354

TokenWebSecurityConfig

关闭session管理

设置无状态模式,防止回话保持再没有token的情况下任然能够访问资源

.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
package com.eim.security.config;
import com.eim.security.filter.TokenAuthFilter;
import com.eim.security.filter.TokenLoginFilter;
import com.eim.security.security.DefaultPasswordEncoder;
import com.eim.security.security.TokenLogoutHandler;
import com.eim.security.security.TokenManager;
import com.eim.security.security.UnauthEntryPoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
private DefaultPasswordEncoder defaultPasswordEncoder;
private UserDetailsService userDetailsService;
@Autowired
public TokenWebSecurityConfig(TokenManager tokenManager, RedisTemplate redisTemplate, DefaultPasswordEncoder defaultPasswordEncoder, UserDetailsService userDetailsService) {
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
this.defaultPasswordEncoder = defaultPasswordEncoder;
this.userDetailsService = userDetailsService;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
//无权限时处理类
.authenticationEntryPoint(new UnauthEntryPoint())
//关闭session管理,设置成无状态
.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().csrf().disable()
//授权请求
.authorizeRequests()
//任何请求都需要已认证
.anyRequest().authenticated()
//设置推出路径
.and().logout().logoutUrl("/admin/acl/index/logout")
//推出处理器
.addLogoutHandler(new TokenLogoutHandler(tokenManager,redisTemplate))
//认证过滤器
.and().addFilter(new TokenLoginFilter(tokenManager,redisTemplate,authenticationManager()))
//授权过滤器
.addFilter(new TokenAuthFilter(tokenManager,redisTemplate,authenticationManager()))
;
}
//调用userDetailService和密码处理
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder);
}
//不进行认证的路径,可以直接访问
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/api/**");
}
}

第五步:实际项目模块完成UserDetailServiceIpml#

@Service
public class UserDetailServiceIpml implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private PermissionService permissionService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询数据库
User user = userService.selectByUsername(username);
//判断
if(user == null){
throw new UsernameNotFoundException("用户名不存在");
}
com.eim.security.entity.User curUser = new com.eim.security.entity.User();
BeanUtils.copyProperties(user,curUser);
//查询用户权限信息
List<String> permissionlist = permissionService.selectPermissionValueByUserId(user.getId());
SecurityUser securityUser = new SecurityUser();
securityUser.setCurrentUserInfo(curUser);
securityUser.setPermissionValueList(permissionlist);
return securityUser;
}
}

第6步:controller设置权限

  • 数据库拥有 permission_value 字段,设置权限值

image-20210401124210123

  • controller设置权限

添加@PreAuthorize(“hasAuthority(‘user.list’)”)

剩下交给spring security处理

@RestController
@RequestMapping("/admin/acl/role")
//@CrossOrigin
public class RoleController {
@Autowired
private RoleService roleService;
@PreAuthorize("hasAuthority('user.list')")
@ApiOperation(value = "获取角色分页列表")
@GetMapping("{page}/{limit}")
public R index(
@ApiParam(name = "page", value = "当前页码", required = true)
@PathVariable Long page,
@ApiParam(name = "limit", value = "每页记录数", required = true)
@PathVariable Long limit,
Role role) {
Page<Role> pageParam = new Page<>(page, limit);
QueryWrapper<Role> wrapper = new QueryWrapper<>();
if(!StringUtils.isEmpty(role.getRoleName())) {
wrapper.like("role_name",role.getRoleName());
}
roleService.page(pageParam,wrapper);
return R.ok().data("items", pageParam.getRecords()).data("total", pageParam.getTotal());
}
}

网关跨域#

package com.eim.gateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.util.pattern.PathPatternParser;
@Configuration
public class CorsConfig {
//解决跨域问题
@Bean
public CorsWebFilter corsWebFilter(){
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.addAllowedOrigin("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
source.registerCorsConfiguration("/**",configuration);
return new CorsWebFilter(source);
}
}

网关配置

#端口号
server.port=8222
#服务名称
spring.application.name=service-gateway
#nacos地址
spring.cloud.nacos.discovery.server-addr=192.168.31.70:8848
#使用服务发现路由
spring.cloud.gateway.discovery.locator.enabled=true
#配置路由规则
spring.cloud.gateway.routes[0].id=service-acl
#设置路由url lb://注册服务名称
spring.cloud.gateway.routes[0].uri.=lb://service-acl
#具体路径规则
spring.cloud.gateway.routes[0].predicates = Path=/*/acl/**

acl项目配置

## 服务端口
server.port=8009
## 服务名
spring.application.name=service-acl
## mysql数据库连接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.31.70:3307/guli?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=123456
#返回json的全局时间格式
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
spring.redis.host=192.168.31.70
spring.redis.port=6379
spring.redis.database= 0
#spring.redis.password=123456
spring.redis.timeout=1800000
spring.redis.lettuce.pool.max-active=20
spring.redis.lettuce.pool.max-wait=-1
#最大阻塞等待时间(负数表示没限制)
spring.redis.lettuce.pool.max-idle=5
spring.redis.lettuce.pool.min-idle=0
#最小空闲
#配置mapper xml文件的路径
mybatis-plus.mapper-locations=classpath:com/eim/aclservice/mapper/xml/*.xml
## nacos服务地址
spring.cloud.nacos.discovery.server-addr=192.168.31.70:8848
#mybatis日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

security源码分析#

Spring Security 采取过滤链实现认证与授权,只有当前过滤器通过,才能进入下一个过滤器:

image-20210318193405746

绿色部分是认证过滤器,需要我们自己配置,可以配置多个认证过滤器。认证过滤器可以使用 Spring Security 提供的认证过滤器,也可以自定义过滤器(例如:短信验证)。认证过滤器要在 **configure(HttpSecurity http)**方法中配置,没有配置不生效。下面会重点介绍以下三个过滤器:

UsernamePasswordAuthenticationFilter 过滤器:该过滤器会拦截前端提交的 POST 方式的登录表单请求,并进行身份认证。

ExceptionTranslationFilter 过滤器:该过滤器不需要我们配置,对于前端提交的请求会直接放行,捕获后续抛出的异常并进行处理(例如:权限访问限制)。

FilterSecurityInterceptor 过滤器:该过滤器是过滤器链的最后一个过滤器,根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果访问受限会抛出相关异常,并由 ExceptionTranslationFilter 过滤器进行捕获和处理

SpringSecurity 认证流程#

认证流程是在 UsernamePasswordAuthenticationFilter 过滤器中处理的,具体流程如下所示

image-20210318193615614

UsernamePasswordAuthenticationFilter 源码#

当前端提交的是一个 POST 方式的登录表单请求,就会被该过滤器拦截,并进行身份认证。该过滤器的 doFilter() 方法实现在其抽象父类

AbstractAuthenticationProcessingFilter 中,#

查看相关源码:

image-20210318193715597

image-20210318193735997

image-20210318193751579

image-20210318193803763

* 上述的 第二 过程调用了 UsernamePasswordAuthenticationFilter 的

attemptAuthentication() 方法#

源码如下

image-20210318193840113

image-20210318193850261

上述的(3)过程创建的 UsernamePasswordAuthenticationTokenAuthentication 接口的实现类,该类有两个构造器,一个用于封装前端请求传入的未认**

证的用户信息,一个用于封装认证成功后的用户信息:

image-20210318194011803

* **Authentication **#

** 接口的实现类用于存储用户认证信息,查看该接口具体定义:**

image-20210318194037138

ProviderManager 源码#

上述过程中,UsernamePasswordAuthenticationFilter 过滤器的attemptAuthentication() 方法的**(5)过程**将未认证的 Authentication 对象传入ProviderManager 类的 authenticate() 方法进行身份认证。

ProviderManager 是 AuthenticationManager 接口的实现类,该接口是认证相关的核心接

口,也是认证的入口。在实际开发中,我们可能有多种不同的认证方式,例如:用户名+

密码、邮箱+密码、手机号+验证码等,而这些认证方式的入口始终只有一个,那就是

AuthenticationManager。在该接口的常用实现类 ProviderManager 内部会维护一个

List列表,存放多种认证方式,实际上这是委托者模式

(Delegate)的应用。每种认证方式对应着一个 AuthenticationProvider,

AuthenticationManager 根据认证方式的不同(根据传入的 Authentication 类型判断)委托

对应的 AuthenticationProvider 进行用户认证。

image-20210318194439562

image-20210318194449518

image-20210318194502052

image-20210318194515426

上述认证成功之后的(6)过程,调用 CredentialsContainer 接口定义的

eraseCredentials() 方法去除敏感信息。查看

UsernamePasswordAuthenticationToken 实现的 eraseCredentials() 方法,该方

法实现在其父类中

image-20210318194540626

认证成功**/**失败处理#

上述过程就是认证流程的最核心部分,接下来重新回到

UsernamePasswordAuthenticationFilter 过滤器的 doFilter() 方法,查看认证成功/失败的处理:

image-20210318194617528

image-20210318194631836

image-20210318194654035

image-20210318194705443

image-20210318194730044

SpringSecurity 权限访问流程#

上一个部分通过源码的方式介绍了认证流程,下面介绍权限访问流程,主要是对

ExceptionTranslationFilter 过滤器和 FilterSecurityInterceptor 过滤器进行介绍。

ExceptionTranslationFilter 过滤器#

该过滤器是用于处理异常的,不需要我们配置,对于前端提交的请求会直接放行,捕获后

续抛出的异常并进行处理(例如:权限访问限制)。具体源码如下:

image-20210318194858825

FilterSecurityInterceptor 过滤器#

FilterSecurityInterceptor 是过滤器链的最后一个过滤器,该过滤器是过滤器链

的最后一个过滤器,根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果

访问受限会抛出相关异常,最终所抛出的异常会由前一个过滤器ExceptionTranslationFilter 进行捕获和处理。具体源码如下:

image-20210318194945592

需要注意,Spring Security 的过滤器链是配置在 SpringMVC 的核心组件

DispatcherServlet 运行之前。也就是说,请求通过 Spring Security 的所有过滤器,

不意味着能够正常访问资源,该请求还需要通过 SpringMVC 的拦截器链。

SpringSecurity 请求间共享认证信息#

一般认证成功后的用户信息是通过 Session 在多个请求之间共享,那么 Spring

Security 中是如何实现将已认证的用户信息对象 Authentication 与 Session 绑定的进行

具体分析

image-20210318195100765

  • 在前面讲解认证成功的处理方法 successfulAuthentication() 时,有以下代码:

image-20210318195123131

  • 查 看 SecurityContext 接 口 及 其 实 现 类 SecurityContextImpl , 该 类 其 实 就 是 对Authentication 的封装:

  • 查 看 SecurityContextHolder 类 , 该 类 其 实 是 对 ThreadLocal 的 封 装 , 存 储SecurityContext 对象:

image-20210318195220359

image-20210318195228598

image-20210318195237012

image-20210318195246784

SecurityContextPersistenceFilter 过滤器#

前面提到过,在 UsernamePasswordAuthenticationFilter 过滤器认证成功之

后,会在认证成功的处理方法中将已认证的用户信息对象 Authentication 封装进

SecurityContext,并存入 SecurityContextHolder。

之后,响应会通过 SecurityContextPersistenceFilter 过滤器,该过滤器的位置

在所有过滤器的最前面,请求到来先进它,响应返回最后一个通过它,所以在该过滤器中

处理已认证的用户信息对象 Authentication 与 Session 绑定。

认证成功的响应通过 SecurityContextPersistenceFilter 过滤器时,会从

SecurityContextHolder 中取出封装了已认证用户信息对象 Authentication 的

SecurityContext,放进 Session 中。当请求再次到来时,请求首先经过该过滤器,该过滤

器会判断当前请求的 Session 是否存有 SecurityContext 对象,如果有则将该对象取出再次

放入 SecurityContextHolder 中,之后该请求所在的线程获得认证用户信息,后续的资源访

问不需要进行身份认证;当响应再次返回时,该过滤器同样从 SecurityContextHolder 取出

SecurityContext 对象,放入 Session 中。具体源码如下:

image-20210318195338434

image-20210318195353409

常见异常#

spring security中配置了AccessDeniedHandler没有生效问题#

出现情况的配置:

在接口中增加了注解:

@PreAuthorize("hasRole('ROLE_AAA')")
@RequestMapping("/hello0")
public String hello(){
return "HELLO";
}

在WebSecurityConfigurerAdapter配置了代码:

http.exceptionHandling()
.authenticationEntryPoint(this.securityAuthenticateFailureHandler)
.accessDeniedHandler(this.securityAccessDeniedHandler);
http.cors().and()
// 访问所有的URL都需要登录
.anyRequest().authenticated();

访问接口:/hello0, 报无法访问错误,没有执行设置的accessDeniedHandler

原因:@PreAuthorize 注解的异常,抛出AccessDeniedException异常,不会被accessDeniedHandler捕获,而是会被全局异常捕获。

示例代码:

@RestControllerAdvice
public class GlobalExceptionHandler {
// 会执行该方法
@ExceptionHandler(AccessDeniedException.class)
public BaseResponse handleAccessDeniedException(AccessDeniedException e){
return BaseResponse.failure(403, "无权限访问该接口");
}
}

如果需要被accessDeniedHandler捕获处理,则需要代码这么写:

http.cors().and()
.authorizeRequests().antMatchers("/hello0").permitAll()
注意hasRole ,如果出现异常,
会调用设置的accessDeniedHandler方法,
.antMatchers("/hello0").hasRole("AAA")
.anyRequest().authenticated();

SpringSecurity整合版v3#

6、oauth2#

OAauth2.0包括以下角色:

1、客户端

本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:Android客户端、Web客户端(浏

览器端)、微信客户端等。

2、资源拥有者

通常为用户,也可以是应用程序,即该资源的拥有者。

3、授权服务器(也称认证服务器)用于服务提供商对资源拥有的身份进行认证、对访问资源进行授权,认证成功后会给客户端发放令牌

(access_token),作为客户端访问资源服务器的凭据。本例为微信的认证服务器。

4、资源服务器

存储资源的服务器,本例子为微信存储的用户信息。

现在还有一个问题,服务提供商能允许随便一个客户端就接入到它的授权服务器吗?答案是否定的,服务提供商会

给准入的接入方一个身份,用于接入时的凭据:

client_id:客户端标识 client_secret:客户端秘钥

因此,准确来说,授权服务器对两种OAuth2.0中的两个角色进行认证授权,分别是资源拥有者客户端

6.2 Spring Cloud Security OAuth2#

6.2.1 环境介绍#

Spring-Security-OAuth2是对OAuth2的一种实现,并且跟我们之前学习的Spring Security相辅相成,与Spring

Cloud体系的集成也非常便利,接下来,我们需要对它进行学习,最终使用它来实现我们设计的分布式认证授权解

决方案。

OAuth2.0的服务提供方涵盖两个服务,即授权服务 (Authorization Server,也叫认证服务) 和资源服务 (Resource

Server),使用 Spring Security OAuth2 的时候你可以选择把它们在同一个应用程序中实现,也可以选择建立使用

同一个授权服务的多个资源服务。

授权服务 (Authorization Server**)**应包含对接入端以及登入用户的合法性进行验证并颁发token等功能,对令牌

的请求端点由 Spring MVC 控制器进行实现,下面是配置一个认证服务必须要实现的endpoints:

AuthorizationEndpoint 服务于认证请求。默认 URL: /oauth/authorize 。

TokenEndpoint 服务于访问令牌的请求。默认 URL: /oauth/token 。

资源服务 (Resource Server),应包含对资源的保护功能,对非法请求进行拦截,对请求中token进行解析鉴

权等,下面的过滤器用于实现 OAuth 2.0 资源服务:

OAuth2AuthenticationProcessingFilter用来对请求给出的身份令牌解析鉴权。

本教程分别创建uaa授权服务(也可叫认证服务)和order订单资源服务。

image-20210411124622331

认证流程如下:

1、客户端请求UAA授权服务进行认证。

2、认证通过后由UAA颁发令牌。

3、客户端携带令牌Token请求资源服务。

4、资源服务校验令牌的合法性,合法即返回资源信息。

6.2.2 环境搭建#

6.2.2.1 父工程#

创建maven工程作为父工程,依赖如下:

<!-- security oauth2 -->
<!-- 此版本不能过高,过高之后直接某些方法报错 -->
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
<!-- security oauth2 -->

完整

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<modules>
<module>service</module>
<module>common</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.eim</groupId>
<artifactId>guli_parent</artifactId>
<packaging>pom</packaging>
<version>0.0.1-SNAPSHOT</version>
<name>guli_parent</name>
<description>父工程</description>
<properties>
<java.version>1.8</java.version>
<guli.version>0.0.1-SNAPSHOT</guli.version>
<mybatis-plus.version>3.4.2</mybatis-plus.version>
<mybatis-plus-plugin.version>3.4.0</mybatis-plus-plugin.version>
<velocity.version>2.3</velocity.version>
<swagger.version>2.7.0</swagger.version>
<swagger-ui.version>3.0.2</swagger-ui.version>
<commons.lang.version>2.6</commons.lang.version>
<aliyun.oss.version>2.8.3</aliyun.oss.version>
<jodatime.version>2.10.1</jodatime.version>
<poi.version>3.17</poi.version>
<commons-fileupload.version>1.3.1</commons-fileupload.version>
<commons-io.version>2.6</commons-io.version>
<httpclient.version>4.5.1</httpclient.version>
<jwt.version>0.7.0</jwt.version>
<aliyun-java-sdk-core.version>4.3.3</aliyun-java-sdk-core.version>
<aliyun-sdk-oss.version>3.1.0</aliyun-sdk-oss.version>
<aliyun-java-sdk-vod.version>2.15.2</aliyun-java-sdk-vod.version>
<aliyun-java-vod-upload.version>1.4.11</aliyun-java-vod-upload.version>
<aliyun-sdk-vod-upload.version>1.4.11</aliyun-sdk-vod-upload.version>
<fastjson.version>1.2.28</fastjson.version>
<gson.version>2.8.2</gson.version>
<json.version>20170516</json.version>
<commons-dbutils.version>1.7</commons-dbutils.version>
<canal.client.version>1.1.0</canal.client.version>
<docker.image.prefix>zx</docker.image.prefix>
<spring-cloud.version>Hoxton.RELEASE</spring-cloud.version>
<spring-cloud-ali.version>2.2.0.RELEASE</spring-cloud-ali.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- security oauth2 -->
<!-- 此版本不能过高,过高之后直接某些方法报错 -->
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
<!-- security oauth2 -->
<!--Spring Cloud-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-security-dependencies</artifactId>
</exclusion>
</exclusions>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-ali.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--mybatis-plus 持久层-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- velocity 模板引擎, Mybatis Plus 代码生成器需要 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>${mybatis-plus-plugin.version}</version>
</dependency>
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>${velocity.version}</version>
</dependency>
<!--swagger-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>${swagger.version}</version>
</dependency>
<!--swagger ui-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>${swagger.version}</version>
</dependency>
<!--增强swagger-bootstrap-ui-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-ui</artifactId>
<version>${swagger-ui.version}</version>
</dependency>
<!--日期时间工具-->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>${jodatime.version}</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>${commons.lang.version}</version>
</dependency>
<!--xls-->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>${poi.version}</version>
</dependency>
<!--xlsx-->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<!--文件上传-->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>${commons-fileupload.version}</version>
</dependency>
<!--commons-io-->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${commons-io.version}</version>
</dependency>
<!--httpclient-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>${httpclient.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>${gson.version}</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jwt.version}</version>
</dependency>
<!--aliyun-->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>${aliyun-java-sdk-core.version}</version>
</dependency>
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>${aliyun-sdk-oss.version}</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-vod</artifactId>
<version>${aliyun-java-sdk-vod.version}</version>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.aliyun</groupId>-->
<!-- <artifactId>aliyun-java-vod-upload</artifactId>-->
<!-- <version>${aliyun-java-vod-upload.version}</version>-->
<!-- </dependency>-->
<!-- <dependency>-->
<!-- <groupId>com.aliyun</groupId>-->
<!-- <artifactId>aliyun-sdk-vod-upload</artifactId>-->
<!-- <version>${aliyun-sdk-vod-upload.version}</version>-->
<!-- </dependency>-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>${json.version}</version>
</dependency>
<dependency>
<groupId>commons-dbutils</groupId>
<artifactId>commons-dbutils</artifactId>
<version>${commons-dbutils.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>${canal.client.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<encoding>UTF-8</encoding>
<source>8</source>
<target>8</target>
</configuration>
<groupId>org.apache.maven.plugins</groupId>
<version>3.1</version>
</plugin>
</plugins>
</build>
</project>

6.2.2.2 创建UAA授权服务工程#

1、创建distributed-security-uaa

创建distributed-security-uaa作为授权服务工程,依赖如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>guli_parent</artifactId>
<groupId>com.eim</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>uaa</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!--fegin组件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--安全框架-->
<!-- jwt工具类 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<!-- security -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<!-- security oauth2 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<!-- security-oauth2-autoconfigure -->
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
</dependency>
</dependencies>
</project>

2、启动类

@EnableDiscoveryClient
@SpringBootApplication
public class UaaApplication {
public static void main(String[] args) {
SpringApplication.run(UaaApplication.class,args);
}
}

3、配置文件

在resources下创建application.properties

server.port:8003
spring.application.name=uaa
spring.main.allow-bean-definition-overriding=true
## 环境设置:dev、test、prod
spring.profiles.active=dev
spring.http.encoding.enabled=true
spring.http.encoding.charset=utf-8
spring.http.encoding.force=true
server.tomcat.remote_ip_header=x‐forwarded‐for
server.tomcat.protocol_header=x‐forwarded‐proto
## mysql数据库连接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://8.135.57.242:3306/test?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=eq123456
spring.datasource.hikari.max-lifetime=60000
#mybatis日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis-plus.mapper-locations=classpath*:com/eim/eduservice/mapper/xml/*.xml
mybatis-plus.global-config.db-config.logic-delete-field=isDeleted
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8

bootstrap.properties

spring.application.name=uaa
spring.cloud.nacos.discovery.server-addr=8.135.57.242:8848
spring.cloud.nacos.config.server-addr=8.135.57.242:8848

6.2.2.3 创建service-edu资源服务工程#

本工程为service-edu订单服务工程,访问本工程的资源需要认证通过。

本工程的目的主要是测试认证授权的功能,所以不涉及订单管理相关业务。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>service</artifactId>
<groupId>com.eim</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>service-edu</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>com.eim</groupId>
<artifactId>common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--fegin组件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- oauth -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
</dependency>
<!-- oauth -->
</dependencies>
<build>
<resources>
<resource>
<!-- xml放在java目录下-->
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
<!--指定资源的位置(xml放在resources下,可以不用指定)-->
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>
</project>

3、配置文件

在resources中创建application.properties

## 服务端口
server.port=8001
## 服务名
spring.application.name=service-edu
## 环境设置:dev、test、prod
spring.profiles.active=dev
## mysql数据库连接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://8.135.57.242:3306/test?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=eq123456
#mybatis日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis-plus.mapper-locations=classpath*:com/eim/eduservice/mapper/xml/*.xml
mybatis-plus.global-config.db-config.logic-delete-field=isDeleted
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8

bootstrap.properties

spring.application.name=uaa
spring.cloud.nacos.discovery.server-addr=8.135.57.242:8848
spring.cloud.nacos.config.server-addr=8.135.57.242:8848

**6.2.2.**授权服务器配置#

6.2.2.1 EnableAuthorizationServer#

可以用 @EnableAuthorizationServer 注解并继承AuthorizationServerConfifigurerAdapter来配置OAuth2.0 授权

服务器。

在Confifig包下创建AuthorizationServer:

@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
//略...
}

AuthorizationServerConfifigurerAdapter要求配置以下几个类,这几个类是由Spring创建的独立的配置对象,它们

会被Spring传入AuthorizationServerConfifigurer中进行配置。

public class AuthorizationServerConfigurerAdapter implements AuthorizationServerConfigurer {
public AuthorizationServerConfigurerAdapter() {
}
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
}
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
}
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
}
}
  • ClientDetailsServiceConfifigurer:用来配置客户端详情服务(ClientDetailsService),客户端详情信息在 这里进行初始化,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息。

  • AuthorizationServerEndpointsConfifigurer:用来配置令牌(token)的访问端点和令牌服务(token services)。

  • AuthorizationServerSecurityConfifigurer:用来配置令牌端点的安全约束.

**6.2.2.1.**配置客户端详细信息#

ClientDetailsServiceConfifigurer 能够使用内存或者JDBC来实现客户端详情服务(ClientDetailsService),

ClientDetailsService负责查找ClientDetails,而ClientDetails有几个重要的属性如下列表:

  • clientId:(必须的)用来标识客户的Id。

  • secret:(需要值得信任的客户端)客户端安全码,如果有的话。

  • scope:用来限制客户端的访问范围,如果为空(默认)的话,那么客户端拥有全部的访问范围。

  • authorizedGrantTypes:此客户端可以使用的授权类型,默认为空。

  • authorities:此客户端可以使用的权限(基于Spring Security authorities)。

客户端详情(Client Details)能够在应用程序运行的时候进行更新,可以通过访问底层的存储服务(例如将客户

端详情存储在一个关系数据库的表中,就可以使用 JdbcClientDetailsService)或者通过自己实现

ClientRegistrationService接口(同时你也可以实现 ClientDetailsService 接口)来进行管理。

我们暂时使用内存方式存储客户端详情信息,配置如下:

/**
* 配置客户端
*
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 使用in‐memory存储
clients.inMemory()
//client_id
.withClient("c1")
.secret(new BCryptPasswordEncoder().encode("secret"))
.resourceIds("res1")
// 该client允许的授权类型
//authorization_code,password,refresh_token,implicit,client_credentials
.authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")
// 允许的授权范围
.scopes("all")
//false跳转到授权页面
.autoApprove(false)
//加上验证回调地址
.redirectUris("http://www.baidu.com");
}
**6.2.2.2.**管理令牌#

AuthorizationServerTokenServices 接口定义了一些操作使得你可以对令牌进行一些必要的管理,令牌可以被用来

加载身份信息,里面包含了这个令牌的相关权限。

自己可以创建 AuthorizationServerTokenServices 这个接口的实现,则需要继承 DefaultTokenServices 这个类,

里面包含了一些有用实现,你可以使用它来修改令牌的格式和令牌的存储。默认的,当它尝试创建一个令牌的时

候,是使用随机值来进行填充的,除了持久化令牌是委托一个 TokenStore 接口来实现以外,这个类几乎帮你做了

所有的事情。并且 TokenStore 这个接口有一个默认的实现,它就是 InMemoryTokenStore ,如其命名,所有的

令牌是被保存在了内存中。除了使用这个类以外,你还可以使用一些其他的预定义实现,下面有几个版本,它们都

实现了TokenStore接口:

  • InMemoryTokenStore:这个版本的实现是被默认采用的,它可以完美的工作在单服务器上(即访问并发量

压力不大的情况下,并且它在失败的时候不会进行备份),大多数的项目都可以使用这个版本的实现来进行

尝试,你可以在开发的时候使用它来进行管理,因为不会被保存到磁盘中,所以更易于调试。

  • JdbcTokenStore:这是一个基于JDBC的实现版本,令牌会被保存进关系型数据库。使用这个版本的实现时,

你可以在不同的服务器之间共享令牌信息,使用这个版本的时候请注意把”spring-jdbc”这个依赖加入到你的

classpath当中。

  • JwtTokenStore:这个版本的全称是 JSON Web Token(JWT),它可以把令牌相关的数据进行编码(因此对

于后端服务来说,它不需要进行存储,这将是一个重大优势),但是它有一个缺点,那就是撤销一个已经授

权令牌将会非常困难,所以它通常用来处理一个生命周期较短的令牌以及撤销刷新令牌(refresh_token)。

另外一个缺点就是这个令牌占用的空间会比较大,如果你加入了比较多用户凭证信息。JwtTokenStore 不会保存

任何数据,但是它在转换令牌值以及授权信息方面与 DefaultTokenServices 所扮演的角色是一样的。

1、定义TokenConfifig#

在confifig包下定义TokenConfifig,我们暂时先使用InMemoryTokenStore,生成一个普通的令牌。

@Configuration
public class TokenConfig {
@Bean
public TokenStore tokenStore() {
return new InMemoryTokenStore();
}
}
2、定义AuthorizationServerTokenServices#

在AuthorizationServer中定义AuthorizationServerTokenServices

@Autowired
private TokenStore tokenStore;
@Autowired
private ClientDetailsService clientDetailsService;
/**
* 认证服务的token服务
*
* @return
*/
@Bean
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service = new DefaultTokenServices();
//默认客户端操作
service.setClientDetailsService(clientDetailsService);
service.setSupportRefreshToken(true);
//设置tokenStore
service.setTokenStore(tokenStore);
// 令牌默认有效期2小时
service.setAccessTokenValiditySeconds(7200);
// 刷新令牌默认有效期3天
service.setRefreshTokenValiditySeconds(259200);
return service;
}
**6.2.2.3.**令牌访问端点配置#

AuthorizationServerEndpointsConfifigurer 这个对象的实例可以完成令牌服务以及令牌endpoint配置。

配置授权类型(Grant Types

AuthorizationServerEndpointsConfifigurer 通过设定以下属性决定支持的授权类型(Grant Types

  • authenticationManager:认证管理器,当你选择了资源所有者密码(password)授权类型的时候,请设置 这个属性注入一个 AuthenticationManager 对象。

  • userDetailsService:如果你设置了这个属性的话,那说明你有一个自己的 UserDetailsService 接口的实现, 或者你可以把这个东西设置到全局域上面去(例如 GlobalAuthenticationManagerConfifigurer 这个配置对 象),当你设置了这个之后,那么 “refresh_token” 即刷新令牌授权类型模式的流程中就会包含一个检查,用 来确保这个账号是否仍然有效,假如说你禁用了这个账户的话。

  • authorizationCodeServices:这个属性是用来设置授权码服务的(即 AuthorizationCodeServices 的实例对

    象),主要用于 “authorization_code” 授权码类型模式。

  • implicitGrantService:这个属性用于设置隐式授权模式,用来管理隐式授权模式的状态。

  • tokenGranter:当你设置了这个东西(即 TokenGranter 接口实现),那么授权将会交由你来完全掌控,并 且会忽略掉上面的这几个属性,这个属性一般是用作拓展用途的,即标准的四种授权模式已经满足不了你的需求的时候,才会考虑使用这个。

**配置授权端点的URLEndpoint URLs):

AuthorizationServerEndpointsConfifigurer 这个配置对象有一个叫做 pathMapping() 的方法用来配置端点URL链接,它有两个参数:

  • 第一个参数:String 类型的,这个端点URL的默认链接。

  • 第二个参数:String 类型的,你要进行替代的URL链接。.

以上的参数都将以 ”/” 字符为开始的字符串,框架的默认URL链接如下列表,可以作为这个 pathMapping() 方法的 第一个参数:

  • /oauth/authorize:授权端点。

  • /oauth/token:令牌端点。

  • /oauth/confifirm_access:用户确认授权提交端点。

  • /oauth/error:授权服务错误信息端点。

  • /oauth/check_token:用于资源服务访问的令牌解析端点。

  • /oauth/token_key:提供公有密匙的端点,如果你使用JWT令牌的话

需要注意的是授权端点这个URL应该被Spring Security保护起来只供授权用户访问. 在AuthorizationServer配置令牌访问端点

@Autowired
private AuthorizationCodeServices authorizationCodeServices;
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.authenticationManager(authenticationManager)
.authorizationCodeServices(authorizationCodeServices)
.tokenServices(tokenService())
.allowedTokenEndpointRequestMethods(HttpMethod.POST);
}
@Bean public AuthorizationCodeServices authorizationCodeServices() {
//设置授权码模式的授权码如何 存取,暂时采用内存方式
return new InMemoryAuthorizationCodeServices();
}
**6.2.2.4.**令牌端点的安全约束#

AuthorizationServerSecurityConfifigurer:用来配置令牌端点(Token Endpoint)的安全约束,在

AuthorizationServer中配置如下

@Override
public void configure(AuthorizationServerSecurityConfigurer security){
security .tokenKeyAccess("permitAll()") (1)
.checkTokenAccess("permitAll()") (2)
.allowFormAuthenticationForClients() (3);
}

(1)tokenkey这个endpoint当使用JwtToken且使用非对称加密时,资源服务用于获取公钥而开放的,这里指这个 endpoint完全公开。

(2)checkToken这个endpoint完全公开

(3) 允许表单认证

授权服务配置总结:授权服务配置分成三大块,可以关联记忆。 既然要完成认证,它首先得知道客户端信息从哪儿读取,因此要进行客户端详情配置。 既然要颁发token,那必须得定义token的相关endpoint,以及token如何存取,以及客户端支持哪些类型的 token。 既然暴露除了一些endpoint,那对这些endpoint可以定义一些安全上的约束等。

6.2.2.5 web****安全配置#

将Spring-Boot工程中的WebSecurityConfifig拷贝到UAA工程中。

/**
* @author Administrator
* @version 1.0
**/
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//认证管理器
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//安全拦截机制(最重要)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/r/r1").hasAnyAuthority("p1")
.antMatchers("/login*").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
;
}
}

**6.2.3.**授权码模式#

6.2.3.1 授权码模式介绍#

下图是授权码模式交互图:

image-20210411133730219

(1)资源拥有者打开客户端,客户端要求资源拥有者给予授权,它将浏览器被重定向到授权服务器,重定向时会附加客户端的身份信息。如:

/uaa/oauth/authorize?client_id=c1&response_type=code&scope=all&redirect_uri=http://www.baidu.com

参数列表如下:

  • client_id:客户端准入标识。

  • response_type:授权码模式固定为code。

  • scope:客户端权限。

  • redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)。

(2)浏览器出现向授权服务器授权页面,之后将用户同意授权。**

(3)授权服务器将授权码(AuthorizationCode)转经浏览器发送给client(通过redirect_uri)。**

(4)客户端拿着授权码向授权服务器索要访问access_token,请求如下:

/uaa/oauth/token? client_id=c1&client_secret=secret&grant_type=authorization_code&code=5PgfcD&redirect_uri=http://www.baidu.com

参数列表如下

  • client_id:客户端准入标识。

  • client_secret:客户端秘钥。

  • grant_type:授权类型,填写authorization_code,表示授权码模式

  • code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。

  • redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。

(5)授权服务器返回令牌****(access_token)**

这种模式是四种模式中最安全的一种模式。一般用于client是Web服务器端应用或第三方的原生App调用资源服务

的时候。因为在这种模式中access_token不会经过浏览器或移动端的App,而是直接从服务端去交换,这样就最大

限度的减小了令牌泄漏的风险。

6.2.3.2 测试#

浏览器访问认证页面:

http://localhost:53020/uaa/oauth/authorize? client_id=c1&response_type=code&scope=all&redirect_uri=http://www.baidu.com

然后输入模拟的账号和密码点登陆之后进入授权页面:

image-20210411134211702

image-20210411134244863

确认授权后,浏览器会重定向到指定路径(oauth_client_details表中的web_server_redirect_uri)并附加验证码?

code=DB2mFj(每次不一样),最后使用该验证码获取token。

POST
http://localhost:53020/uaa/oauth/token

image-20210411134331859

**6.2.4.**简化模式#

6.2.4.1 简化模式介绍#

下图是简化模式交互图:

image-20210411134431087

(1)资源拥有者打开客户端,客户端要求资源拥有者给予授权,它将浏览器被重定向到授权服务器,重定向时会**

附加客户端的身份信息。如:

/uaa/oauth/authorize?client_id=c1&response_type=token&scope=all&redirect_uri=http://www.baidu.com

参数描述同授权码模式 ,注意response_type=token,说明是简化模式。

(2)浏览器出现向授权服务器授权页面,之后将用户同意授权。**

(3)授权服务器将授权码将令牌(access_token)以Hash的形式存放在重定向urifargment中发送给浏览***器。

注:fragment 主要是用来标识 URI 所标识资源里的某个资源,在 URI 的末尾通过 (#)作为 fragment 的开头, 其中 # 不属于 fragment 的值。如https://domain/index#L18这个 URI 中 L18 就是 fragment 的值。大家只需要 知道js通过响应浏览器地址栏变化的方式能获取到fragment 就行了。 一般来说,简化模式用于没有服务器端的第三方单页面应用,因为没有服务器端就无法接收授权码。

6.2.4.2 测试#

浏览器访问认证页面

http://localhost:53020/uaa/oauth/authorize? client_id=c1&response_type=token&scope=all&redirect_uri=http://www.baidu.com

image-20210411134654153

然后输入模拟的账号和密码点登陆之后进入授权页面:

image-20210411134707900

确认授权后,浏览器会重定向到指定路径(oauth_client_details表中的web_server_redirect_uri)并以Hash的形

式存放在重定向uri的fargment中,如:

http://aa.bb.cc/receive#access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZW5hbn...

**6.2.5.**密码模式#

6.2.5.1 授权码模式介绍#

下图是密码模式交互图:

image-20210411134802621

1)资源拥有者将用户名、密码发送给客户端

2)客户端拿着资源拥有者的用户名、密码向授权服务器请求令牌(access_token,请求如下:

/uaa/oauth/token? client_id=c1&client_secret=secret&grant_type=password&username=shangsan&password=123

参数列表如下:

  • client_id:客户端准入标识。

  • client_secret:客户端秘钥。

  • grant_type:授权类型,填写password表示密码模式

  • username:资源拥有者用户名。

  • password:资源拥有者密码

3)授权服务器将令牌(access_token)发送给****client

这种模式十分简单,但是却意味着直接将用户敏感信息泄漏给了client,因此这就说明这种模式只能用于client是我

们自己开发的情况下。因此密码模式一般用于我们自己开发的,第一方原生App或第一方单页面应用。

6.2.5.2 测试

POST http://localhost:53020/uaa/oauth/token

请求参数:

image-20210411134927267

**6.2.6.**客户端模式#

6.2.6.1 客户端模式介绍#

image-20210411134943073

1)客户端向授权服务器发送自己的身份信息,并请求令牌(access_token

2)确认客户端身份无误后,将令牌(access_token)发送给****client,请求如下:

/uaa/oauth/token?client_id=c1&client_secret=secret&grant_type=client_credentials

参数列表如下:

  • client_id:客户端准入标识。

  • client_secret:客户端秘钥。

  • grant_type:授权类型,填写client_credentials表示客户端模式

这种模式是最方便但最不安全的模式。因此这就要求我们对client完全的信任,而client本身也是安全的。因 此这种模式一般用来提供给我们完全信任的服务器端服务。比如,合作方系统对接,拉取一组用户信息。

6.2.6.2 客户端模式介绍

POST http://localhost:53020/uaa/oauth/token

image-20210411135102720

**6.2.7.**资源服务测试#

6.2.7.1 资源服务器配置#

@EnableResourceServer 注解到一个 @Confifiguration 配置类上,并且必须使用 ResourceServerConfifigurer 这个

配置对象来进行配置(可以选择继承自 ResourceServerConfifigurerAdapter 然后覆写其中的方法,参数就是这个

对象的实例),下面是一些可以配置的属性:

ResourceServerSecurityConfifigurer中主要包括:

  • tokenServices:ResourceServerTokenServices 类的实例,用来实现令牌服务。

  • tokenStore:TokenStore类的实例,指定令牌如何访问,与tokenServices配置可选

  • resourceId:这个资源服务的ID,这个属性是可选的,但是推荐设置并在授权服务中进行验证。

其他的拓展属性例如 tokenExtractor 令牌提取器用来提取请求中的令牌。

HttpSecurity配置这个与Spring Security类似:

  • 请求匹配器,用来设置需要进行保护的资源路径,默认的情况下是保护资源服务的全部路径。

  • 通过http.authorizeRequests()来设置受保护资源的访问规则

  • 其他的自定义权限保护规则通过 HttpSecurity 来进行配置。

@EnableResourceServer 注解自动增加了一个类型为 OAuth2AuthenticationProcessingFilter 的过滤器链

编写ResouceServerConfifig:

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResouceServerConfig extends ResourceServerConfigurerAdapter {
public static final String RESOURCE_ID = "res1";
@Autowired
private TokenStore tokenStore;
//资源服务令牌解析服务
// @Bean
// public ResourceServerTokenServices tokenService() {
// // 使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
// RemoteTokenServices service=new RemoteTokenServices();
// service.setCheckTokenEndpointUrl("http://localhost:8003/oauth/check_token");
// service.setClientId("c1"); service.setClientSecret("secret");
// return service;
// }
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources
.resourceId(RESOURCE_ID)
// .tokenServices(tokenService())
.tokenStore(tokenStore)
.stateless(true);
}
@Override
public void configure(HttpSecurity http) throws Exception {
//可以从数据库查询
String scop = "all";
http.authorizeRequests()
.antMatchers("/**")
.access("#oauth2.hasScope('"+scop+"')")
.and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
6.2.7.2 验证****token#

ResourceServerTokenServices 是组成授权服务的另一半,如果你的授权服务和资源服务在同一个应用程序上的

话,你可以使用 DefaultTokenServices ,这样的话,你就不用考虑关于实现所有必要的接口的一致性问题。如果

你的资源服务器是分离开的,那么你就必须要确保能够有匹配授权服务提供的 ResourceServerTokenServices,它

知道如何对令牌进行解码。令牌解析方法: 使用 DefaultTokenServices 在资源服务器本地配置令牌存储、解码、解析方式 使用

RemoteTokenServices 资源服务器通过 HTTP 请求来解码令牌,每次都请求授权服务器端点 /oauth/check_token

使用授权服务的 /oauth/check_token 端点你需要在授权服务将这个端点暴露出去,以便资源服务可以进行访问,

这在咱们授权服务配置中已经提到了,下面是一个例子,在这个例子中,我们在授权服务中配置了

/oauth/check_token 和 /oauth/token_key 这两个端点:

/**
* 安全约束
*
* @param security security
* @throws Exception 异常
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.tokenKeyAccess("permitAll()") //oauth/token_key 安全配置
.checkTokenAccess("permitAll()")//oauth/check_token 安全配置
.allowFormAuthenticationForClients();
}

在资源 服务配置RemoteTokenServices ,在ResouceServerConfifig中配置:

@Bean
public ResourceServerTokenServices tokenService() {
// 使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
RemoteTokenServices service=new RemoteTokenServices();
service.setCheckTokenEndpointUrl("http://localhost:8003/oauth/check_token");
service.setClientId("c1"); service.setClientSecret("secret");
return service;
}
6.2.7.3****编写资源#

在controller包下编写OrderController,此controller表示订单资源的访问类:

@RequestMapping("/eduservice")
@RestController
public class OrderController {
@GetMapping(value = "/r1")
@PreAuthorize("hasAnyAuthority('p1')")
public String r1(HttpServletRequest request) {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
System.out.println("【principal】="+principal.toString());
String str = request.getHeader("Authorization");
System.out.println("【Authorization】="+str);
return "访问资源1";
}
}
6.2.7.4 添加安全访问控制#

@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)

这个注解只能用一次,切记resourceServerConfig中不要加入

@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//安全拦截机制(最重要)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() .authorizeRequests()
.antMatchers("/r/r1").hasAuthority("p2")
.antMatchers("/r/r2").hasAuthority("p2")
.antMatchers("/r/**").authenticated()//所有/r/**的请求必须认证通过
.anyRequest().permitAll();//除了/r/**,其它的请求可以访问
}
}
6.2.7.5 测试#

1、申请令牌

这里我们使用密码方式

image-20210411141400224

2、请求资源

按照oauth2.0协议要求,请求资源需要携带token,如下:

token的参数名称为:Authorization,值为:Bearer token值

image-20210411141416547

如果token错误,则授权失败,如下

{
"error": "invalid_token",
"error_description": "f3360db3‐2d85‐41c9‐ac5e‐b9adba48e26c"
}

6.3 JWT****令牌#

6.3.1 JWT****介绍#

通过上边的测试我们发现,当资源服务和授权服务不在一起时资源服务使用RemoteTokenServices 远程请求授权

服务验证token,如果访问量较大将会影响系统的性能 。

解决上边问题:

令牌采用JWT格式即可解决上边的问题,用户认证通过会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信

息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证

服务完成授权。

1、什么是JWT?

JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于

在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公

钥/私钥对来签名,防止被篡改。

官网:https://jwt.io/

标准:https://tools.ietf.org/html/rfc7519

JWT令牌的优点:

1)jwt基于json,非常方便解析。

2)可以在令牌中自定义丰富的内容,易扩展。

3)通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。

4)资源服务使用JWT可不依赖认证服务即可完成授权。缺点:

1)JWT令牌较长,占存储空间比较大。

2**、JWT令牌结构**

通过学习JWT令牌结构为自定义jwt令牌打好基础。

JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz

Header

头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)

一个例子如下:

下边是Header部分的内容

{
"alg": "HS256",
"typ": "JWT"
}

将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。

Payload

第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的现成字段,比

如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。

此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。

最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。

一个例子:

{
"sub": "1234567890",
"name": "456",
"admin": true
}

Signatre

第三部分是签名,此部分用于防止jwt内容被篡改。

这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明

签名算法进行签名。

一个例子:

HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

base64UrlEncode(header):jwt令牌的第一部分。

base64UrlEncode(payload):jwt令牌的第二部分。

secret:签名所使用的密钥

6.3.2 配置JWT令牌服务#

在uaa中配置jwt令牌服务,即可实现生成jwt格式的令牌。

@Configuration
public class TokenConfig {
private String SIGNING_KEY = "uaa123";
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SIGNING_KEY);
//对称秘钥,资源服务器使用该秘钥来验证
return converter;
}
}

2、定义JWT令牌服务

@Autowired
private JwtAccessTokenConverter accessTokenConverter;
/**
* 认证服务的token服务
*
* @return
*/
@Bean
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service = new DefaultTokenServices();
//默认客户端操作
service.setClientDetailsService(clientDetailsService);
service.setSupportRefreshToken(true);
//设置tokenStore
service.setTokenStore(tokenStore);
//token增强
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Collections.singletonList(accessTokenConverter));
service.setTokenEnhancer(tokenEnhancerChain);
// 令牌默认有效期2小时
service.setAccessTokenValiditySeconds(7200);
// 刷新令牌默认有效期3天
service.setRefreshTokenValiditySeconds(259200);
return service;
}
6.3.3 生成jwt令牌#

image-20210411142036948

6.3.4 校验jwt令牌#

资源服务需要和授权服务拥有一致的签字、令牌服务等:

1、将授权服务中的TokenConfifig类拷贝到资源 服务中

2、屏蔽资源 服务原来的令牌服务类

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResouceServerConfig extends ResourceServerConfigurerAdapter {
public static final String RESOURCE_ID = "res1";
@Autowired
private TokenStore tokenStore;
//资源服务令牌解析服务
// @Bean
// public ResourceServerTokenServices tokenService() {
// // 使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
// RemoteTokenServices service=new RemoteTokenServices();
// service.setCheckTokenEndpointUrl("http://localhost:8003/oauth/check_token");
// service.setClientId("c1"); service.setClientSecret("secret");
// return service;
// }
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources
.resourceId(RESOURCE_ID)
// .tokenServices(tokenService())
.tokenStore(tokenStore)
.stateless(true);
}

3、测试

1)申请jwt令牌

2)使用令牌请求资源

image-20210411142157665

小技巧:

令牌申请成功可以使用/uaa/oauth/check_token校验令牌的有效性,并查询令牌的内容,例子如下:

image-20210411142213561

6.4 完善环境配置#

截止目前客户端信息和授权码仍然存储在内存中,生产环境中通过会存储在数据库中,下边完善环境的配置:

6.4.1 创建表#

在user_db中创建如下表:

SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
`client_id` varchar(255) NOT NULL,
`resource_ids` varchar(255) DEFAULT NULL,
`client_secret` varchar(255) DEFAULT NULL,
`scope` varchar(255) DEFAULT NULL,
`authorized_grant_types` varchar(255) DEFAULT NULL,
`web_server_redirect_uri` varchar(255) DEFAULT NULL,
`authorities` varchar(255) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additional_information` longtext,
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`archived` tinyint(4) DEFAULT NULL,
`trusted` tinyint(4) DEFAULT NULL,
`autoapprove` varchar(255) DEFAULT NULL,
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of oauth_client_details
-- ----------------------------
INSERT INTO `oauth_client_details` VALUES ('c1', 'res1', '$2a$10$Dappn49nM8ZjkCO3ASwR/evnHQN3iPH4npP45js0d9Ug3Vm4ui.sq', 'ROLE_ADMIN,ROLE_USER,ROLE_API', 'authorization_code,password,client_credentials,implicit,refresh_token', 'https://www.baidu.com', null, '7200', '259200', null, '2020-12-13 15:53:10', '0', '0', 'false');
INSERT INTO `oauth_client_details` VALUES ('c2', 'res2', 'secret', 'ROLE_API', 'authorization_code,password,client_credentials,implicit,refresh_token', 'https://www.baidu.com', null, '7200', '259200', null, '2020-12-13 15:53:10', '0', '0', 'false');

oauth_code表,Spring Security OAuth2使用,用来存储授权码:

-- ----------------------------
-- Table structure for oauth_code
-- ----------------------------
DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
`code` varchar(255) DEFAULT NULL,
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of oauth_code
-- ----------------------------
6.4.2 配置授权服务#

1)修改AuthorizationServer

ClientDetailsService和AuthorizationCodeServices从数据库读取数据

package com.eim.uaa.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
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.ClientDetailsService;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import javax.sql.DataSource;
import java.util.Collections;
/**
* @author zhengyong
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Autowired
private ClientDetailsService clientDetailsService;
@Autowired
private AuthorizationCodeServices authorizationCodeServices;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtAccessTokenConverter accessTokenConverter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public ClientDetailsService clientDetailsService(DataSource dataSource) {
ClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);
((JdbcClientDetailsService) clientDetailsService).setPasswordEncoder(passwordEncoder());
return clientDetailsService;
}
/**
* 认证服务的token服务
*
* @return
*/
@Bean
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service = new DefaultTokenServices();
//默认客户端操作
service.setClientDetailsService(clientDetailsService);
service.setSupportRefreshToken(true);
//设置tokenStore
service.setTokenStore(tokenStore);
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Collections.singletonList(accessTokenConverter));
service.setTokenEnhancer(tokenEnhancerChain);
// 令牌默认有效期2小时
service.setAccessTokenValiditySeconds(7200);
// 刷新令牌默认有效期3天
service.setRefreshTokenValiditySeconds(259200);
return service;
}
//授权码
@Bean
public AuthorizationCodeServices authorizationCodeServices(DataSource dataSource) {
//设置授权码模式的授权码如何 存取,暂时采用内存方式
// return new InMemoryAuthorizationCodeServices();
return new JdbcAuthorizationCodeServices(dataSource);
}
/**
* 安全约束
*
* @param security security
* @throws Exception 异常
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();
}
/**
* 配置令牌访问端点
*
* @param endpoints 端点
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
//认证管理器
.authenticationManager(authenticationManager)
.authorizationCodeServices(authorizationCodeServices)
.tokenServices(tokenService())
.allowedTokenEndpointRequestMethods(HttpMethod.POST);
}
/**
* 配置客户端
*
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetailsService);
}
}

6.4.3****测试

1、测试申请令牌

使用密码模式申请令牌,客户端信息需要和数据库中的信息一致。

2、测试授权码模式

生成的授权存储到数据库中。

7.Spring Security****实现分布式系统授权#

7.1 需求分析

回顾技术方案如下:

image-20210411144558995

1、UAA认证服务负责认证授权。

2、所有请求经过 网关到达微服务

3、网关负责鉴权客户端以及请求转发

4、网关将token解析后传给微服务,微服务进行授权。

**7.2.**注册中心#

所有微服务的请求都经过网关,网关从注册中心读取微服务的地址,将请求转发至微服务。

本节完成注册中心的搭建,注册中心采用nacos

**7.3.**网关#

网关整合 OAuth2.0 有两种思路,一种是认证服务器生成jwt令牌, 所有请求统一在网关层验证,判断权限等操作;

另一种是由各资源服务处理,网关只做请求转发。

我们选用第一种。我们把API网关作为OAuth2.0的资源服务器角色,实现接入客户端权限拦截、令牌解析并转发当

前登录用户信息(jsonToken)给微服务,这样下游微服务就不需要关心令牌格式解析以及OAuth2.0相关机制了。

API网关在认证授权体系里主要负责两件事:

(1)作为OAuth2.0的资源服务器角色,实现接入方权限拦截。

(2)令牌解析并转发当前登录用户信息(明文token)给微服务

微服务拿到明文token(明文token中包含登录用户的身份和权限信息)后也需要做两件事:

(1)用户授权拦截(看当前用户是否有权访问该资源)

(2)将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)

7.4.转发明文token****给微服务#

通过gateway过滤器的方式实现,目的是让下游微服务能够很方便的获取到当前的登录用户信息(明文token)

1)实现Zuul前置过滤器,完成当前登录用户信息提取,并放入转发微服务的**request*

JwtTokenFilter

package com.eim.gateway.filter;
import com.alibaba.fastjson.JSON;
import com.eim.gateway.common.EncryptUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Component
public class JwtTokenFilter implements GlobalFilter, Ordered {
/**
* 跳过校验url数组
*/
private final String[] skipAuthUrls = {"/api/uaa/oauth/token"};
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String url = exchange.getRequest().getURI().getPath();
System.out.println("【url】="+url);
//跳过不需要验证的路径
if (null != skipAuthUrls && Arrays.asList(skipAuthUrls).contains(url)) {
return chain.filter(exchange);
}
//获取token
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StringUtils.isBlank(token)) {
//没有token
throw new RuntimeException("请登陆");
} else {
String[] splitToken = token.split(" ");
token = splitToken[1];
System.out.println("【token】="+token);
//解密token
Claims jwt = getTokenBody(token);
//判断scope权限
checkScope(jwt.get("scope"));
//解构token
String userName = (String)jwt.get("user_name");
Integer exp = (Integer) jwt.get("exp");
String clientId = (String) jwt.get("client_id");
List<String> aud = (List<String>) jwt.get("aud");
List<String> scope = (List<String>) jwt.get("scope");
List<String> authorities = (List<String>) jwt.get("authorities");
Map<String,Object> jsonToken = new HashMap<>();
jsonToken.put("principal",userName);
jsonToken.put("authorities",authorities);
ServerHttpRequest oldRequest= exchange.getRequest();
URI uri = oldRequest.getURI();
ServerHttpRequest newRequest = oldRequest.mutate().uri(uri).build();
// 定义新的消息头
HttpHeaders headers = new HttpHeaders();
//放入以前所有请求头
headers.putAll(exchange.getRequest().getHeaders());
//headers.remove("Authorization");
headers.set("json-token", EncryptUtil.encodeUTF8StringBase64(JSON.toJSONString(jsonToken)));
System.out.println("【jwt.toString()】="+jwt.toString());
newRequest = new ServerHttpRequestDecorator(newRequest) {
@Override
public HttpHeaders getHeaders() {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.putAll(headers);
return httpHeaders;
}
};
return chain.filter(exchange.mutate().request(newRequest).build());
}
}
/**
* 判断scope
* @param scope 范围
*/
private void checkScope(Object scope) {
List<String> scopeList = (List<String>)scope;
String eduScope="all";
List<String> result = scopeList.stream().filter(e -> {
return e.equals(eduScope);
}).collect(Collectors.toList());
if(CollectionUtils.isEmpty(result)){
throw new RuntimeException("没有合适的scope权限");
}
}
private static Claims getTokenBody(String token){
String signingKey = "uaa123";
return Jwts.parser()
//不使用.getBytes(StandardCharsets.UTF_8)会报错
.setSigningKey(signingKey.getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(token)
.getBody();
}
@Override
public int getOrder() {
return -201;
}
}

7.5****微服务用户鉴权拦截#

当微服务收到明文token时,应该怎么鉴权拦截呢?自己实现一个fifilter?自己解析明文token,自己定义一套资源 访问策略?

能不能适配Spring Security呢,是不是突然想起了前面我们实现的Spring Security基于token认证例子。咱们还拿 统一用户服务作为网关下游微服务,对它进行改造,增加微服务用户鉴权拦截功能。

**(1)增加测试资源

OrderController增加以下endpoint

@RestController
public class OrderController {
@GetMapping(value = "/r1")
@PreAuthorize("hasAuthority('p1')")//拥有p1权限方可访问此url
public String r1(){
//获取用户身份信息
UserDTO userDTO = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return userDTO.getFullname()+"访问资源1";
}
@GetMapping(value = "/r2")
@PreAuthorize("hasAuthority('p2')")//拥有p1权限方可访问此url
public String r2(){
//获取用户身份信息
UserDTO userDTO = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return userDTO.getFullname()+"访问资源2";
}
}

2Spring Security配置

开启方法保护,并增加Spring配置策略,除了/login方法不受保护(统一认证要调用),其他资源全部需要认证才能访 问。

@Override
public void configure(HttpSecurity http) throws Exception {
//可以从数据库查询
String scop = "all";
http.authorizeRequests()
.antMatchers("/**")
.access("#oauth2.hasScope('"+scop+"')")
.and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}

(3)定义fifilter拦截token,并形成Spring Security的Authentication对象

/**
* 扩展获取用户信息
*/
@Component
public class Myfilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
FilterChain filterChain) throws ServletException, IOException {
String token = httpServletRequest.getHeader("json-token");
if (token != null) {
//1.解析token
String json = EncryptUtil.decodeUTF8StringBase64(token);
JSONObject userJson = JSON.parseObject(json);
// UserDTO user = new UserDTO();
// user.setUsername(userJson.getString("principal"));
String userName = userJson.getString("principal");
JSONArray authoritiesArray = userJson.getJSONArray("authorities");
String[] authorities = authoritiesArray.toArray(new String[authoritiesArray.size()]);
//2.新建并填充authentication
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userName,
null,
AuthorityUtils.createAuthorityList(authorities));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
//3.将authentication保存进安全上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}

经过上边的过虑 器,资源 服务中就可以方便到的获取用户的身份信息:

SecurityContextHolder.getContext().getAuthentication().getPrincipal();

还是三个步骤:

1.解析token

2.新建并填充authentication

3.将authentication保存进安全上下文

剩下的事儿就交给Spring Security好了。

7.6 扩展用户信息#

7.6.1 需求分析#

目前jwt令牌存储了用户的身份信息、权限信息,网关将token明文化转发给微服务使用,目前用户身份信息仅包括

了用户的账号,微服务还需要用户的ID、手机号等重要信息。

所以,本案例将提供扩展用户信息的思路和方法,满足微服务使用用户信息的需求。

下边分析JWT令牌中扩展用户信息的方案:

在认证阶段DaoAuthenticationProvider会调用UserDetailService查询用户的信息,这里是可以获取到齐全的用户

信息的。由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,

这里有两个思路:第一是可以扩展UserDetails,使之包括更多的自定义属性,第二也可以扩展username的内容

,比如存入json数据内容作为username的内容。相比较而言,方案二比较简单还不用破坏UserDetails的结构,我

们采用方案二。

7.6.2 修改****UserDetailService#

从数据库查询到user,将整体user转成json存入userDetails对象。

关键

//根据账号去数据库查询...
UserDto user = new UserDto();
user.setUsername("zs");
user.setId("2454654");
user.setMobile("12365");
//将userDto转成json
String principal = JSON.toJSONString(user);
@Service
public class SpringDataUserDetailsServiceImpl implements UserDetailsService {
//根据 账号查询用户信息
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据账号去数据库查询...
UserDto user = new UserDto();
user.setUsername("zs");
user.setId("2454654");
user.setMobile("12365");
//将userDto转成json
String principal = JSON.toJSONString(user);
List<String> permissions = new ArrayList<>();
permissions.add("p1");
//将permissions转成数组
String[] permissionArray = new String[permissions.size()];
permissions.toArray(permissionArray);
return User.withUsername(principal)
.password(new BCryptPasswordEncoder().encode("123"))
.authorities(permissionArray).build();
//将来连接数据库根据账号查询用户信息
// UserDto userDto = userDao.getUserByUsername(username);
// if(userDto == null){
// //如果用户查不到,返回null,由provider来抛出异常
// return null;
// }
// //根据用户的id查询用户的权限
// List<String> permissions = userDao.findPermissionsByUserId(userDto.getId());
// //将permissions转成数组
// String[] permissionArray = new String[permissions.size()];
// permissions.toArray(permissionArray);
// //将userDto转成json
// String principal = JSON.toJSONString(userDto);
// UserDetails userDetails = User.withUsername(principal).password(userDto.getPassword()).authorities(permissionArray).build();
// return userDetails;
}
}

7.6.3 修改资源服务过虑器#

资源服务中的过虑 器负责 从header中解析json-token,从中即可拿网关放入的用户身份信息,部分关键代码如 下:

image-20210411173844384

@Component
public class Myfilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
FilterChain filterChain) throws ServletException, IOException {
String token = httpServletRequest.getHeader("json-token");
if (token != null) {
//1.解析token
String json = EncryptUtil.decodeUTF8StringBase64(token);
JSONObject jsonObject = JSON.parseObject(json);
String principal = jsonObject.getString("principal");
UserDto userDto = JSON.parseObject(principal, UserDto.class);
System.out.println("[principal=userDto]"+userDto);
JSONArray authoritiesArray = jsonObject.getJSONArray("authorities");
String[] authorities = authoritiesArray.toArray(new String[authoritiesArray.size()]);
//2.新建并填充authentication
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDto,
null,
AuthorityUtils.createAuthorityList(authorities));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
//3.将authentication保存进安全上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}

7.6.4 测试使用#

@GetMapping(value = "/r1")
@PreAuthorize("hasAnyAuthority('p1')")
public String r1(HttpServletRequest request) {
UserDto userDto = (UserDto) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
System.out.println("【userDto】="+userDto.toString());
String str = request.getHeader("Authorization");
System.out.println("【Authorization】="+str);
return "访问资源1";
}

8、回顾#

重点回顾:

什么是认证、授权、会话。

Java Servlet为支持http会话做了哪些事儿。

基于session认证机制的运作流程。

基于token认证机制的运作流程。

理解Spring Security的工作原理,Spring Security结构总览,认证流程和授权,中间涉及到哪些组件,这些组件分

别处理什么,如何自定义这些组件满足个性需求。

OAuth2.0认证的四种模式?它们的大体流程是什么?

Spring cloud Security OAuth2包括哪些组件?职责?

分布式系统认证需要解决的问题?

掌握学习方法,掌握思考方式。 .

9、生成密码测试#

public class Test01 {
public static void main(String[] args) {
String hashpw = BCrypt.hashpw("secret", BCrypt.gensalt());
System.out.println(hashpw);
}
}

附录#

HttpSecurity

HttpSecurity配置列表:

openidLogin()

用于基于 OpenId 的验证

headers()

将安全标头添加到响应

cors()

配置跨域资源共享( CORS )

sessionManagement()

允许配置会话管理

portMapper()

允许配置一个PortMapper(HttpSecurity#(getSharedObject(class))),其他提供SecurityConfifigurer的对象使用 PortMapper 从 HTTP 重定

向到 HTTPS 或者从 HTTPS 重定向到 HTTP。默认情况下,Spring Security使用一个PortMapperImpl映射 HTTP 端口8080到 HTTPS 端口

8443,HTTP 端口80到 HTTPS 端口443

jee()

配置基于容器的预认证。 在这种情况下,认证由Servlet容器管理

x509()

配置基于x509的认证

rememberMe

允许配置“记住我”的验证

authorizeRequests()

允许基于使用HttpServletRequest限制访问

requestCache()

允许配置请求缓存

exceptionHandling()

允许配置错误处理

securityContext()

在HttpServletRequests之间的SecurityContextHolder上设置SecurityContext的管理。 当使用WebSecurityConfifigurerAdapter时,这将

自动应用

servletApi()

将HttpServletRequest方法与在其上找到的值集成到SecurityContext中。 当使用WebSecurityConfifigurerAdapter时,这将自动应用

csrf()

添加 CSRF 支持,使用WebSecurityConfifigurerAdapter时,默认启用

logout()

添加退出登录支持。当使用WebSecurityConfifigurerAdapter时,这将自动应用。默认情况是,访问URL”/ logout”,使HTTP Session无效来

清除用户,清除已配置的任何#rememberMe()身份验证,清除SecurityContextHolder,然后重定向到”/login?success”

anonymous()

允许配置匿名用户的表示方法。 当与WebSecurityConfifigurerAdapter结合使用时,这将自动应用。 默认情况下,匿名用户将使用

org.springframework.security.authentication.AnonymousAuthenticationToken表示,并包含角色 “ROLE_ANONYMOUS”

formLogin()

指定支持基于表单的身份验证。如果未指定FormLoginConfifigurer#loginPage(String),则将生成默认登录页面

oauth2Login()

根据外部OAuth 2.0或OpenID Connect 1.0提供程序配置身份验证

requiresChannel()

配置通道安全。为了使该配置有用,必须提供至少一个到所需信道的映射

httpBasic()

配置 Http Basic 验证

addFilterAt()

在指定的Filter类的位置添加过滤器

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

SpringSecurity与OAuth2安全框架完全指南
https://onecodemaker.cn/posts/spring生态-05-springsecurity/
作者
糖糖IT
发布于
2026-02-14
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录