SpringSecurity入门到精通
SpringSecurity从入门到精通
课程介绍

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>② 创建启动类
@SpringBootApplicationpublic 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;
@RestControllerpublic 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 登陆校验流程

2.2 原理初探
想要知道如何实现自己的登陆流程就必须要先知道入门案例中SpringSecurity的流程。
2.2.1 SpringSecurity完整流程
SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。

图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
UsernamePasswordAuthenticationFilter<负责处理我们在登陆页面填写了用户名密码后的登陆请求>负责处理我们在登陆页面填写了用户名密码后的登陆请求>。入门案例的认证工作主要有它负责。
**ExceptionTranslationFilter:**处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
**FilterSecurityInterceptor:**负责权限校验的过滤器。
我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。

2.2.2 认证流程详解

概念速查:
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;
@Configurationpublic 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工具类 */@Datapublic 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" })@Componentpublic 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@NoArgsConstructorpublic 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 */@SpringBootTestpublic 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 */@Servicepublic 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@AllArgsConstructorpublic 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}。例如

这样登陆的时候就可以用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 */@Configurationpublic 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。
@RestControllerpublic 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 */@Configurationpublic 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(); }}
@Servicepublic 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
@Componentpublic 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 */@Configurationpublic 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 */@Servicepublic 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
@RestControllerpublic 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@NoArgsConstructorpublic 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 */@Servicepublic 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)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。

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` = 0package 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: 6379mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml 然后我们可以在UserDetailsServiceImpl中去调用该mapper的方法查询权限信息封装到LoginUser对象中即可。
/** * @Author 三更 B站: https://space.bilibili.com/663528522 */@Servicepublic 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;
@Componentpublic 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;
@Componentpublic 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配置,运行跨域请求
@Configurationpublic 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
可总结为以下几大类:

使用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配置实例
@EnableWebSecuritypublic class SecurityConfig extends WebSecurityConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/css/**", "/js/**","/fonts/**").permitAll() .anyRequest().authenticated(); }}web ignore配置实例
@EnableWebSecuritypublic 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就是登录成功处理器。
我们也可以自己去自定义成功处理器进行成功后的相应处理。
@Componentpublic class SGSuccessHandler implements AuthenticationSuccessHandler {
@Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("认证成功了"); }}@Configurationpublic 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就是登录失败处理器。
我们也可以自己去自定义失败处理器进行失败后的相应处理。
@Componentpublic class SGFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { System.out.println("认证失败了"); }}@Configurationpublic 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(); }}登出成功处理器
@Componentpublic class SGLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("注销成功"); }}@Configurationpublic 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()方法可以添加用户,并给用户指定权限。
@Overrideprotected 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() 用户登录后可访问 示例代码如下:
@Overrideprotected 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类。
@Servicepublic 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 时代,有小伙伴发现有一个常用的类忽然过期了:

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

从这段注释中我们大概就明白了咋回事了。
配置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。

接下来我们提供一个简单的测试接口,如下:
@RestControllerpublic class HelloController { @GetMapping("/hello") public String hello() { return "hello 江南一点雨!"; }}小伙伴们知道,在 Spring Security 中,默认情况下,只要添加了依赖,我们项目的所有接口就已经被统统保护起来了,现在启动项目,访问 /hello 接口,就需要登录之后才可以访问,登录的用户名是 user,密码则是随机生成的,在项目的启动日志中。
现在我们的第一个需求是使用自定义的用户,而不是系统默认提供的,这个简单,我们只需要向 Spring 容器中注册一个 UserDetailsService 的实例即可,像下面这样:
@Configurationpublic 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) 方法进行配置,但是现在,得换一种玩法:
@Configurationpublic 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 中,该配置的东西写在这里就可以了。
定制登录页面参数等
那如果我还希望对登录页面,参数等,进行定制呢?继续往下看:
@Configurationpublic 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<Filter> filters = new ArrayList<>(); return new DefaultSecurityFilterChain(new AntPathRequestMatcher("/**"), filters); }}Spring Security 的底层实际上就是一堆过滤器,所以我们之前在 configure(HttpSecurity) 方法中的配置,实际上就是配置过滤器链。现在过滤器链的配置,我们通过提供一个 SecurityFilterChain Bean 来配置过滤器链,SecurityFilterChain 是一个接口,这个接口只有一个实现类 DefaultSecurityFilterChain,构建 DefaultSecurityFilterChain 的第一个参数是拦截规则,也就是哪些路径需要拦截,第二个参数则是过滤器链,这里我给了一个空集合,也就是我们的 Spring Security 会拦截下所有的请求,然后在一个空集合中走一圈就结束了,相当于不拦截任何请求。
此时重启项目,你会发现 /hello 也是可以直接访问的,就是因为这个路径不经过任何过滤器。
其实我觉得目前这中新写法比以前老的写法更直观,更容易让大家理解到 Spring Security 底层的过滤器链工作机制。
有小伙伴会说,这写法跟我以前写的也不一样呀!这么配置,我也不知道 Spring Security 中有哪些过滤器,其实,换一个写法,我们就可以将这个配置成以前那种样子:
@Configurationpublic 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;
@Configurationpublic 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的静态资源
@Overridepublic 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面试题

核心概念
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会授权用户访问所请求的资源。

Spring Security的具体工作原理如下:
- 用户请求Web应用程序的受保护资源。
- Spring Security拦截请求,并尝试获取用户的身份验证信息。
- 如果用户没有经过身份验证,Spring Security将向用户显示一个登录页面,并要求用户提供有效的凭据(用户名和密码)。
- 一旦用户提供了有效的凭据,Spring Security将验证这些凭据,并创建一个已认证的安全上下文(SecurityContext)对象。
- 安全上下文对象包含已认证的用户信息,包括用户名、角色和授权信息。
- 在接下来的请求中,Spring Security将使用已经认证的安全上下文对象来判断用户是否有权访问受保护的资源。
- 如果用户有权访问资源,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:
@Configurationpublic class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
// ...}3.在使用密码的地方调用 passwordEncoder.encode() 方法对密码进行加密,例如注册时对密码进行加密:
@Servicepublic 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会检查用户是否已经认证(即是否已登录),如果没有登录则会重定向到登录页面。
重定向到登录页面时,用户需要输入用户名和密码进行认证。
表单登录的过程:
- 用户在登录页面输入用户名和密码,提交表单。
- Spring Security的UsernamePasswordAuthenticationFilter拦截表单提交的请求,并将用户名和密码封装成一个Authentication对象。
- AuthenticationManager接收到Authentication对象后,会根据用户名和密码查询用户信息,并将用户信息封装成一个UserDetails对象。
- 如果查询到用户信息,则将UserDetails对象封装成一个已认证的Authentication对象并返回,如果查询不到用户信息,则抛出相应的异常。
- 认证成功后,用户会被重定向到之前访问的资源。如果之前访问的资源需要特定的角色或权限才能访问,则还需要进行授权的过程。
Spring Security的认证流程大致可以分为两个过程,首先是用户登录认证的过程,然后是用户访问受保护资源时的授权过程。在认证过程中,用户需要提供用户名和密码,Spring Security通过UsernamePasswordAuthenticationFilter将用户名和密码封装成Authentication对象,并交由AuthenticationManager进行认证。
如果认证成功,则认证结果会存储在SecurityContextHolder中。在授权过程中,Spring Security会检查用户是否有访问受保护资源的权限,如果没有则会重定向到登录页面进行认证。
授权
拦截未授权的请求,重定向到登录页面

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

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 依赖:
官方文档显示需要如下一下:
| Name | Description |
|---|---|
| spring-social-core | Spring Social’s Connect Framework and OAuth client support. |
| spring-social-config | Java and XML configuration support for Spring Social. |
| spring-social-security | Spring Security integration support. |
| spring-social-web | Spring 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> **/@Componentpublic 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:
@Componentpublic 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类
@Componentpublic 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> **/@Componentpublic 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> **/@Componentpublic 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> **/@RestControllerpublic 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> */@Componentpublic 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> */@Componentpublic 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> */@Componentpublic 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@Configurationpublic 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)@Configurationpublic 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@Datapublic 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> **/@RestControllerpublic 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> **/@Componentpublic 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> */@Componentpublic 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> */@Datapublic 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> */@Datapublic 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> */@Datapublic 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@Componentpublic 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> */@Configurationpublic 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> */@Componentpublic 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@RestControllerpublic 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> */@Datapublic 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)@Datapublic 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> */@Datapublic 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@Componentpublic 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> */@Componentpublic 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> */@Componentpublic 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
@Componentpublic class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
/** * 配置 * @param http http * @throws Exception 异常 */ @Override public void configure(HttpSecurity http) throws Exception {- 使用apply加入主配置
@Componentpublic class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Override protected void configure(HttpSecurity http) throws Exception { http .apply(smsCodeAuthenticationSecurityConfig).and()二、使用抽象配置,适用于表单
- 编写一个类加入spring容器,
- 写一个 public void configure(HttpSecurity http) 方法
@Componentpublic 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()) .........- 加入主配置类
@Componentpublic 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> */@Datapublic 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@EnableSocialpublic 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):
从中我们可以看到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;@Slf4jpublic 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 */@Datapublic 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:展示给前端的第三方用户信息 */@Datapublic 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表
@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类里, * 以实现第三方登陆时免注册的逻辑功能 */@Componentpublic 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@EnableSocialpublic 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> */@Componentpublic 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)@Datapublic 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> */@Datapublic 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> */@Slf4jpublic 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)@Datapublic 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> */@Datapublic 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> */@Configurationpublic 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")@Slf4jpublic 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))); }}测试:
- 使用用户名密码登录
- 访问:<http://localhost/connect
结果:
{"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("绑定成功"))); } }}- 视图配置
@Configurationpublic 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如下:
即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超时跳向的url2.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信息 */@Slf4jpublic 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:退出成功处理器 */@Slf4jpublic 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:
//抽象tokenpublic abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer { }//UsernamePasswordAuthenticationTokenpublic 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()方法主要做三件事:
- 如果验证通过,返回Authentication(通常带上authenticated=true)。
- 认证失败抛出
AuthenticationException - 如果无法确定,则返回null
AuthenticationException是运行时异常,它通常由应用程序按通用方式处理,用户代码通常不用特意被捕获和处理这个异常。
AuthenticationManager的默认实现是ProviderManager,它委托一组AuthenticationProvider实例来实现认证。
AuthenticationProvider和AuthenticationManager类似,都包含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:
| Alias | Filter Class | Namespace Element or Attribute |
|---|---|---|
| CHANNEL_FILTER | ChannelProcessingFilter | http/intercept-url@requires-channel |
| SECURITY_CONTEXT_FILTER | SecurityContextPersistenceFilter | http |
| CONCURRENT_SESSION_FILTER | ConcurrentSessionFilter | session-management/concurrency-control |
| HEADERS_FILTER | HeaderWriterFilter | http/headers |
| CSRF_FILTER | CsrfFilter | http/csrf |
| LOGOUT_FILTER | LogoutFilter | http/logout |
| X509_FILTER | X509AuthenticationFilter | http/x509 |
| PRE_AUTH_FILTER | AbstractPreAuthenticatedProcessingFilter Subclasses | N/A |
| CAS_FILTER | CasAuthenticationFilter | N/A |
| FORM_LOGIN_FILTER | UsernamePasswordAuthenticationFilter | http/form-login |
| BASIC_AUTH_FILTER | BasicAuthenticationFilter | http/http-basic |
| SERVLET_API_SUPPORT_FILTER | SecurityContextHolderAwareRequestFilter | http/@servlet-api-provision |
| JAAS_API_SUPPORT_FILTER | JaasApiIntegrationFilter | http/@jaas-api-provision |
| REMEMBER_ME_FILTER | RememberMeAuthenticationFilter | http/remember-me |
| ANONYMOUS_FILTER | AnonymousAuthenticationFilter | http/anonymous 匿名认证过滤器 |
| SESSION_MANAGEMENT_FILTER | SessionManagementFilter | session-management |
| EXCEPTION_TRANSLATION_FILTER | ExceptionTranslationFilter | http |
| FILTER_SECURITY_INTERCEPTOR | FilterSecurityInterceptor | http 安全拦截器 |
| SWITCH_USER_FILTER | SwitchUserFilter | N/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方法设置FilterSecurityInterceptor的FilterInvocationSecurityMetadataSource,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> */@Componentpublic 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> */@Componentpublic 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> */@Componentpublic 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
@Overridepublic 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
@Transactionalpublic 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@EnableAuthorizationServerpublic 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: 8090security: 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_tokenspring: 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> */@RestControllerpublic 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@Componentpublic 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> */@Componentpublic 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> */@Componentpublic 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|发送手机验证码>18281975746|发送手机验证码>:6635
此时redis里已经保存了我们的验证码 — 验证码保存成功
(2)发送通过短信验证码登陆的请求
client
@localhost/sms/check post
请求表单:
mobile<18281975746>18281975746> smsCode<3986>3986> deviceId<123456>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)@Datapublic 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> */@Datapublic 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> */@Componentpublic 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> */@Componentpublic 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)@Datapublic 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@EnableSocialpublic 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;@Componentpublic 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前端显示当前社交账户的用户名、头像等信息) */@RestControllerpublic 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;@Componentpublic 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@EnableAuthorizationServerpublic 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@EnableAuthorizationServerpublic 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> */@Configurationpublic 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
- 建表sql — 具体字段含义可以去看这篇文章《OAuth2相关数据表字段的详细说明》
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> */@Configurationpublic 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@EnableAuthorizationServerpublic 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> */@Componentpublic 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的生成类中
@Overridepublic 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_token 和 client_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_token 和 refresh_token。认证服务器提供了一个类似这样的接口:
https://www.funtl.com/exchange?code=&client_id=&client_secret=需要传入 code、client_id 以及 client_secret。验证通过后,返回 access_token 和 refresh_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> </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> </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> </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;@SpringBootApplicationpublic class OAuth2ServerApplication { public static void main(String[] args) { SpringApplication.run(OAuth2ServerApplication.class, args); }}基于内存存储令牌
概述
本章节基于 内存存储令牌 的模式用于演示最基本的操作,帮助大家快速理解 oAuth2 认证服务器中 “认证”、“授权”、“访问令牌” 的基本概念
操作流程

- 配置认证服务器
- 配置客户端信息:ClientDetailsServiceConfigurer
inMemory:内存配置withClient:客户端标识secret:客户端安全码authorizedGrantTypes:客户端授权类型scopes:客户端授权范围redirectUris:注册回调地址
- 配置客户端信息:ClientDetailsServiceConfigurer
- 配置 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@EnableAuthorizationServerpublic 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-serverserver: 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 -
配置服务端点信息:
AuthorizationServerEndpointsConfigurertokenStore:设置令牌存储方式
-
配置客户端信息:
ClientDetailsServiceConfigurerwithClientDetails:设置客户端配置读取方式
-
-
配置 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 1server: port: 8080Application
package com.funtl.spring.security.oauth2.server;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic 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@EnableAuthorizationServerpublic 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> </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 1server: port: 8080mybatis: type-aliases-package: com.funtl.spring.security.oauth2.server.domain mapper-locations: classpath:mapper/*.xmlApplication
增加了 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;@Servicepublic 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实现类
@Servicepublic 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> </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 1security: 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_tokenserver: port: 8081 servlet: context-path: /contentsmybatis: type-aliases-package: com.funtl.spring.security.oauth2.resource.domain mapper-locations: classpath:mapper/*.xmllogging: level: root: INFO org.springframework.web: INFO org.springframework.security: INFO org.springframework.security.oauth2: INFOApplication
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> */@Datapublic 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;@RestControllerpublic 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),浏览器地址栏会显示如下地址:
向服务器申请令牌
- 通过 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工具框架
@Datapublic class Person { private String name; private Integer age;}2、建立person.yml文件
## person.yml!!com.demo.Person {age: 24, name: Adam}3、读取并解析YAML文件
1
//获取YAML中的单个对象@Testpublic 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中的单个对象@Testpublic 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
//读取YAML文件并返回一个对应类的对象@Testpublic 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
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时