0. 简介 SpringSecurity是安全框架,准确来说是安全管理框架。相比与另外一个安全框架Shiro ,SpringSecurity提供了更丰富的功能,社区资源也比Shiro丰富。
SpringSecurity框架用于Web应用的需要进行认证和授权。
认证(Authentication): 验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户。
授权(Authorization): 经过认证后判断当前用户是否有权限进行某个操作。
认证和授权也是SpringSecurity作为安全框架的核心功能。
1. 快速入门
版本:SpringBoot 2.7.3 + SpringSecurity 5.7.9
1.1 环境搭建 1、新建一个SpringBoot项目,创建项目时,勾选 lombok 与 web
2、新建HelloController类
1 2 3 4 5 6 7 8 9 @RestController public class HelloController { @RequestMapping("/hello") public String hello () { return "hello" ; } }
3、启动项目,访问 http://localhost:8080/hello
浏览器出现 hello
说明项目环境搭建成功。
1.2 整合SpringSecurity 添加依赖
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency >
再次重启服务访问 http://localhost:8080/hello
接口会发现访问被拦截,自动跳转到 http://localhost:8080/login
接口并出现如下页面:
这是 SpringSecurity 的默认登录页面,默认用户名是 user
,密码会输出在控制台:
1 Using generated security password: a6e7b9ca-74a0-4899-a836-e6fa67c11832
必须登录之后才能对接口进行访问。
输入用户名和密码之后即可访问 /hello
接口,如果你想退出登录,SpringSecurity 还提供了默认的退出登录接口 /logout
。
2. 认证 2.1 登录校验流程
2.2 认证原理 2.2.1 SpringSecurity完整流程 SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器:
图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
UsernamePasswordAuthenticationFilter
:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要由它负责。
ExceptionTranslationFilter
:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
FilterSecurityInterceptor
:负责权限校验的过滤器。
我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。
2.2.3 认证流程详解
2.3 自定义security 2.3.1 自定义security的思路分析 在 快速入门 中,我们在项目里面引入了Security依赖,实现了当我们访问某个业务接口时,会被Security的login接口拦截,但是如果我们不想使用Security默认的登录页面,那么怎么办呢?还有,SpringSecurity的校验,我们希望是根据数据库来做校验,那么怎么实现呢?
我们需要实现如下:
登录
自定义 UserDetailsService
接口的实现类。在这个实现类中去查询数据库。
自定义登录接口。用于调用 ProviderManager
的方法进行认证,如果认证通过生成jwt,然后把用户信息存入redis中。
校验
定义Jwt认证过滤器。用于获取token,然后解析token获取其中的userid,还需要从redis中获取用户信息,然后存入SecurityContextHolder
。
2.3.2 自定义security的准备工作 1、响应类 ResponseResult
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 @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; } }
2、实体类User
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 @Data @AllArgsConstructor @NoArgsConstructor public class User implements Serializable { @Serial private static final long serialVersionUID = -40356785423868312L ; private Long id; private String userName; private String nickName; private String password; private String status; private String email; private String phonenumber; private String sex; private String avatar; private String userType; private Long createBy; private Date createTime; private Long updateBy; private Date updateTime; private Integer delFlag; }
准备数据库
1、我们先创建一个用户表,建表语句如下:
PS:要想让用户的密码是明文存储,需要在密码前加 {noop}
。 具体原因后面会介绍😀
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 create database if not exists security;use security; 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= '用户表' ; insert into sys_user values (1 ,'admin' ,'管理员' ,'{noop}123456' ,'0' ,DEFAULT ,DEFAULT ,DEFAULT ,DEFAULT ,'0' ,DEFAULT ,DEFAULT ,DEFAULT ,DEFAULT ,DEFAULT );insert into sys_user values (2 ,'muyoukule' ,'木又枯了' ,'{noop}123456' ,'0' ,DEFAULT ,DEFAULT ,DEFAULT ,DEFAULT ,'1' ,DEFAULT ,DEFAULT ,DEFAULT ,DEFAULT ,DEFAULT );
2、添加依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-boot-starter</artifactId > <version > 3.5.3.1</version > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > <version > 8.0.33</version > </dependency >
3、配置数据库信息
1 2 3 4 5 6 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security?characterEncoding=utf-8&serverTimezone=UTC username: root password: root
4、定义Mapper接口
1 2 public interface UserMapper extends BaseMapper <User> {}
5、修改 User 实体类,类名上加 @TableName(value = "sys_user")
6、启动类配置 Mapper 扫描 @MapperScan("com.muyoukule.mapper")
7、测试 MP 是否能正常使用
1 2 3 4 5 6 7 8 9 @Autowired private UserMapper userMapper;@Test public void testUserMapper () { List<User> users = userMapper.selectList(null ); System.out.println(users); }
2.3.3 自定义security的认证实现 从之前的分析我们可以知道,我们需要自定义一个 UserDetailsService
,让SpringSecurity使用我们的 UserDetailsService
。我们自己的 UserDetailsService
可以从数据库中查询用户名和密码。
上面我们已经把准备工作做好了。接下来我们会实现让security在认证的时候,根据我们数据库的用户和密码进行认证,也就是被security拦截业务接口,出现登录页面之后,我们需要通过输入数据库里的用户和密码来登录,而不是使用security默认的用户和密码进行登录。
思路: 只需要新建一个实现类,在这个实现类里面实现Security官方的 UserDetailsService
接口,然后重写里面的loadUserByUsername
方法。
1、先新建 LoginUser 类,作为 UserDetails 接口(Security官方提供的接口)的实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Data @NoArgsConstructor @AllArgsConstructor public class LoginUser implements UserDetails { private User user; @Override public Collection<? extends GrantedAuthority > getAuthorities() { return null ; } @Override public String getPassword () { return user.getPassword(); } @Override public String getUsername () { return user.getUserName(); } @Override public boolean isAccountNonExpired () { return true ; } @Override public boolean isAccountNonLocked () { return true ; } @Override public boolean isCredentialsNonExpired () { return true ; } @Override public boolean isEnabled () { return true ; } }
2、创建一个类实现 UserDetailsService
接口,重写其中的方法。更加用户名从数据库中查询用户信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper <>(); wrapper.eq(User::getUserName, username); User user = userMapper.selectOne(wrapper); if (Objects.isNull(user)) { throw new RuntimeException ("用户名或密码错误" ); } return new LoginUser (user); } }
3、启动项目测试。输入登录的用户名和密码,检查是否根据数据库进行认证。
2.4 密码加密存储 上面我们实现了自定义Security的认证机制,让Security根据数据库的数据,来认证用户输入的数据是否正确。但是在数据库存入用户表的时候,插入的muyoukule用户的密码是 {noop}123456
,而不是123456
,为什么呢?
这是因为在SpringSecurity中,密码通常需要以一种特定的格式存储在数据库中,格式为:{加密方式}密码
。{noop}
是一个表示密码没有加密的标识,它告诉SpringSecurity不要对密码进行任何加密处理。这适用于测试或遗留系统,但不推荐用于生产环境,因为这会暴露明文密码,存在安全隐患。
所以为了增强安全性,我们需要使用SpringSecurity提供的一种加密方式 BCryptPasswordEncoder
。
BCryptPasswordEncoder
使用BCrypt强哈希函数来加密密码,每次加密都会生成一个不同的哈希值,这使得破解密码变得更加困难。
那要怎么使用呢?
只需要创建一个配置类,使这个类继承 WebSecurityConfigurerAdapter
。然后在配置类中创建一个 BCryptPasswordEncoder
的Bean,SpringSecurity 会自动使用这个 Bean 来加密和校验密码。
新建 SecurityConfig 配置类:
1 2 3 4 5 6 7 8 9 10 11 @Configuration public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } }
这个配置类的作用是根据原文,生成一个密文。
加密
测试加密效果:
1 2 3 4 5 6 7 8 9 10 11 12 @Autowired private PasswordEncoder passwordEncoder;@Test public void TestBCryptPasswordEncoder () { String encode1 = passwordEncoder.encode("1234" ); String encode2 = passwordEncoder.encode("1234" ); System.out.println(encode1); System.out.println(encode2); }
测试结果:
1 2 $2a$10$zvC.rgZmur/fEjNQowIBcOw/eBMg3Y9ol5heENOox2K2WwUB2k7Ju $2a$10$hNQaSqHL.oQQ0ljjX.bVEO0WFdeSGXlDmBVzA5fcvVEdesuGdAV1m
虽然这两次的密码都是一样的,但是加密后是不一样的。每次运行,对同一原文都会有不同的加密结果。
原因是内部在进行加密的时候会添加随机的盐,加密结果=盐+原文+加密
。这样就达到每次加密后的密文都不相同的效果。
校验
测试校验:
1 2 3 4 5 6 7 8 9 10 @Test public void TestBCryptPasswordEncoder () { BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder (); boolean result = passwordEncoder.matches("1234" , "$2a$10$zvC.rgZmur/fEjNQowIBcOw/eBMg3Y9ol5heENOox2K2WwUB2k7Ju" ); System.out.println(result); }
测试结果:
说明校验成功。
2.5 jwt工具类实现加密校验
环境准备
1、引入依赖:
1 2 3 4 5 6 7 8 9 10 11 12 <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt</artifactId > <version > 0.9.0</version > </dependency > <dependency > <groupId > javax.xml.bind</groupId > <artifactId > jaxb-api</artifactId > <version > 2.3.1</version > </dependency >
2、JwtUtil工具类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 public class JwtUtil { public static final Long JWT_TTL = 60 * 60 * 1000L ; public static final String JWT_KEY = "mukumuku" ; public static String getUUID () { String token = UUID.randomUUID().toString().replaceAll("-" , "" ); return token; } public static String createJWT (String subject) { JwtBuilder builder = getJwtBuilder(subject, null , getUUID()); return builder.compact(); } public static String createJWT (String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID()); return builder.compact(); } private static JwtBuilder getJwtBuilder (String subject, Long ttlMillis, String uuid) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; 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() .setId(uuid) .setSubject(subject) .setIssuer("muyoukule" ) .setIssuedAt(now) .signWith(signatureAlgorithm, secretKey) .setExpiration(expDate); } public static String createJWT (String id, String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id); return builder.compact(); } public static void main (String[] args) throws Exception { Claims claims = parseJWT("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhMDE1N2UzZDliOGM0ZjRjOWIzZWQ1MzMxMGIxZTA3MyIsInN1YiI6IjEyMzQ1NiIsImlzcyI6Im11eW91a3VsZSIsImlhdCI6MTcxNTMyODI0NCwiZXhwIjoxNzE1MzMxODQ0fQ.Ij5P9bfqxEjLimz4JxxSTtTZolOFoccNCSYfm9EvUcI" ); String subject = claims.getSubject(); System.out.println(subject); } public static SecretKey generalKey () { byte [] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY); SecretKey key = new SecretKeySpec (encodedKey, 0 , encodedKey.length, "AES" ); return key; } public static Claims parseJWT (String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }
加密
修改 JwtUtil 类的 main 方法:
1 2 3 4 5 public static void main (String[] args) { String jwt = createJWT("123456" ); System.out.println(jwt); }
控制台打印:
1 eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhMDE1N2UzZDliOGM0ZjRjOWIzZWQ1MzMxMGIxZTA3MyIsInN1YiI6IjEyMzQ1NiIsImlzcyI6Im11eW91a3VsZSIsImlhdCI6MTcxNTMyODI0NCwiZXhwIjoxNzE1MzMxODQ0fQ.Ij5P9bfqxEjLimz4JxxSTtTZolOFoccNCSYfm9EvUcI
校验
修改 JwtUtil 类的 main 方法:
1 2 3 4 5 6 7 public static void main (String[] args) throws Exception { Claims claims = parseJWT("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhMDE1N2UzZDliOGM0ZjRjOWIzZWQ1MzMxMGIxZTA3MyIsInN1YiI6IjEyMzQ1NiIsImlzcyI6Im11eW91a3VsZSIsImlhdCI6MTcxNTMyODI0NCwiZXhwIjoxNzE1MzMxODQ0fQ.Ij5P9bfqxEjLimz4JxxSTtTZolOFoccNCSYfm9EvUcI" ); String subject = claims.getSubject(); System.out.println(subject); }
控制台打印:
2.6 自定义登陆接口 接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。
在接口中我们通过AuthenticationManager的 authenticate
方法来进行用户认证,所以需要在SecurityConfig中配置把 AuthenticationManager
注入容器。
认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。
2.6.1 环境准备 1、引入依赖
1 2 3 4 5 6 7 8 9 10 11 12 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > fastjson</artifactId > <version > 1.2.33</version > </dependency >
2、Redis 序列化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public class FastJsonRedisSerializer <T> implements RedisSerializer <T> { public static final Charset DEFAULT_CHARSET = Charset.forName("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); } }
3、配置类 RedisConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Configuration public class RedisConfig { @Bean @SuppressWarnings(value = {"unchecked", "rawtypes"}) public RedisTemplate<Object, Object> redisTemplate (RedisConnectionFactory connectionFactory) { RedisTemplate<Object, Object> template = new RedisTemplate <>(); template.setConnectionFactory(connectionFactory); FastJsonRedisSerializer serializer = new FastJsonRedisSerializer (Object.class); template.setKeySerializer(new StringRedisSerializer ()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer ()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }
4、工具类 RedisCache
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 @SuppressWarnings(value = {"unchecked", "rawtypes"}) @Component public class RedisCache { @Autowired public RedisTemplate redisTemplate; public <T> void setCacheObject (final String key, final T value) { redisTemplate.opsForValue().set(key, value); } public <T> void setCacheObject (final String key, final T value, final Integer timeout, final TimeUnit timeUnit) { redisTemplate.opsForValue().set(key, value, timeout, timeUnit); } public boolean expire (final String key, final long timeout) { return expire(key, timeout, TimeUnit.SECONDS); } public boolean expire (final String key, final long timeout, final TimeUnit unit) { return redisTemplate.expire(key, timeout, unit); } public <T> T getCacheObject (final String key) { ValueOperations<String, T> operation = redisTemplate.opsForValue(); return operation.get(key); } public boolean deleteObject (final String key) { return redisTemplate.delete(key); } public long deleteObject (final Collection collection) { return redisTemplate.delete(collection); } public <T> long setCacheList (final String key, final List<T> dataList) { Long count = redisTemplate.opsForList().rightPushAll(key, dataList); return count == null ? 0 : count; } public <T> List<T> getCacheList (final String key) { return redisTemplate.opsForList().range(key, 0 , -1 ); } 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; } public <T> Set<T> getCacheSet (final String key) { return redisTemplate.opsForSet().members(key); } public <T> void setCacheMap (final String key, final Map<String, T> dataMap) { if (dataMap != null ) { redisTemplate.opsForHash().putAll(key, dataMap); } } public <T> Map<String, T> getCacheMap (final String key) { return redisTemplate.opsForHash().entries(key); } public <T> void setCacheMapValue (final String key, final String hKey, final T value) { redisTemplate.opsForHash().put(key, hKey, value); } public <T> T getCacheMapValue (final String key, final String hKey) { HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash(); return opsForHash.get(key, hKey); } public void delCacheMapValue (final String key, final String hkey) { HashOperations hashOperations = redisTemplate.opsForHash(); hashOperations.delete(key, hkey); } public <T> List<T> getMultiCacheMapValue (final String key, final Collection<Object> hKeys) { return redisTemplate.opsForHash().multiGet(key, hKeys); } public Collection<String> keys (final String pattern) { return redisTemplate.keys(pattern); } }
2.6.2 实现登录接口 准备好环境后,就可以开始实现登录接口了。
1、修改数据库的 muyoukule 用户的密码,把 123456 明文修改为对应的密文。
1 $2a$10$UQ8uz4MczvlmPPAqZwEHKe1kbFrlwyWwnM3hL5GK0/ml2tronh0Wq
2、编写Controller
1 2 3 4 5 6 7 8 9 10 11 12 @RestController public class LoginController { @Autowired private LoginService loginService; @PostMapping("/user/login") public ResponseResult login (@RequestBody User user) { return loginService.login(user); } }
3、使 SecurityConfig
继承 WebSecurityConfigurerAdapter
并且添加如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean @Override public AuthenticationManager authenticationManagerBean () throws Exception { return super .authenticationManagerBean(); } @Override protected void configure (HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/user/login" ).anonymous() .anyRequest().authenticated(); } }
4、编写 Service
1 2 3 public interface LoginService { public ResponseResult login (User user) ; }
5、编写 ServiceImpl
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @Service public class LoginServiceImpl implements LoginService { @Autowired private AuthenticationManager authenticationManager; @Autowired private RedisCache redisCache; public ResponseResult login (User user) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (user.getUserName(), user.getPassword()); Authentication authenticate = null ; try { authenticate = authenticationManager.authenticate(authenticationToken); } catch (Exception e) { throw new RuntimeException ("登录失败" ); } LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); String userid = loginUser.getUser().getId().toString(); String jwt = JwtUtil.createJWT(userid); Map<String, String> map = new HashMap <>(); map.put("token" , jwt); redisCache.setCacheObject("login:" + userid, loginUser); return new ResponseResult (200 , "登录成功" , map); } }
6、启动项目测试,访问 localhost:8080/user/login
。PS:测试前记得启动 redis 服务。
可以看到登录成功,测试通过。登录接口成功实现!!!
2.6.3 认证过滤器 上面我们实现登录接口的时,用户登录后就会有一个token值,我们可以借助这个token来实现认证过滤器。有token值且token值认证通过,那么该用户访问我们的业务接口时,就不会被Security拦截。简单理解作用就是登录过的用户可以访问我们的业务接口,拿到对应的资源。
我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的 userId
。这是为了取出 userId
去数据库查询该用户具有权限,即授权做准备!!!
方法步骤:
使用 userId
去redis中获取对应的LoginUser对象。
然后封装 Authentication
对象存入 SecurityContextHolder
。
1、过滤器 JwtAuthenticationTokenFilter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private RedisCache redisCache; @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = request.getHeader("token" ); if (!StringUtils.hasText(token)) { filterChain.doFilter(request, response); return ; } String userId; try { Claims claims = JwtUtil.parseJWT(token); userId = claims.getSubject(); } catch (Exception e) { throw new RuntimeException ("token非法" ); } String redisKey = "login:" + userId; LoginUser loginUser = redisCache.getCacheObject(redisKey); if (Objects.isNull(loginUser)) { throw new RuntimeException ("用户未登录" ); } UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (loginUser, null , null ); SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(request, response); } }
2、配置过滤认证器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Override protected void configure (HttpSecurity http) throws Exception { http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); } }
测试
启动项目访问 localhost:8080/user/login
测试,先登录拿到用户的token,将其记录下来。
携带token访问 localhost:8080/hello
,查看是否被拦截:
可以看到携带正确的token再去访问其他接口时可以正常访问。认证过滤器配置成功!!!
PS:可以自己验证不携带token或者携带错误token访问 localhost:8080/hello
接口,会发现控制台提示异常,说明请求被拦截。
2.6.4 退出登录 退出登录,就是让某个用户的登录状态消失,也就是让token失效。
实现起来也比较简单,只需要定义一个登陆接口,然后获取 SecurityContextHolder 中的认证信息,删除redis中对应的数据即可。
1、LoginController添加如下方法:
1 2 3 4 @RequestMapping("/user/logout") public ResponseResult logout () { return loginService.logout(); }
2、LoginService接口
1 public ResponseResult logout () ;
3、LoginService接口实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public ResponseResult logout () { UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); Long userid = loginUser.getUser().getId(); redisCache.deleteObject("login:" + userid); return new ResponseResult (200 , "注销成功" ); }
测试
启动项目访问 localhost:8080/user/login
测试,先登录拿到用户的token,将其记录下来。
携带token访问 localhost:8080/hello
,请求未被拦截,成功访问。
携带token访问 localhost:8080/user/logout
,注销成功。
注销后再次携带token访问 localhost:8080/hello
,请求被拦截,访问失败,控制台提示异常。说明token失效。
至此退出登录功能成功实现!!!
3. 授权 3.1 权限系统的作用 为什么要设计权限系统?
例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。
总结起来就是不同的用户可以使用不同的功能 。这就是权限系统要去实现的效果。
有的同学就会问了,那我直接在系统界面显示不同角色要实现的功能不就行了嘛?我的回答是NO NO NO!还是上面的例子,如果我是普通学生的角色,不怀好意的得到删除书籍的接口地址,那我有可能将图书馆的所有图书信息全部删除了,这是非常不安全的 。
有句话说得好:前端防君子,后端防小人 。因此我们需要在前后端都加上防护,确保万无一失。
3.2 授权基本流程 在SpringSecurity中,会使用默认的 FilterSecurityInterceptor
来进行权限校验。在 FilterSecurityInterceptor 中会从 SecurityContextHolder
获取其中的 Authentication
,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
所以我们在项目中只需要把当前登录用户的权限信息也存入 Authentication
,然后设置我们的资源所需要的权限即可。
3.3 授权实现 3.3.1 限制访问资源所需权限 SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。但是要使用它我们需要先开启相关配置。
1、在 SecurityConfig
添加启动全局安全配置。
1 2 3 4 5 6 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { }
开启了相关配置之后,就能使用 @PreAuthorize
等注解了。
2、在 HelloController 类的 hello 方法添加如下注解,其中test表示自定义权限的名字:
1 2 3 4 5 6 7 8 9 10 @RestController public class HelloController { @RequestMapping("/hello") @PreAuthorize("hasAuthority('test')") public String hello () { return "hello" ; } }
@PreAuthorize
:使用前必须授权
hasAuthority('test')
:需要有test权限才能访问
3.3.2 封装权限信息 我们前面在写 UserDetailsServiceImpl 的时候说过,在查询出用户后还要获取对应的权限信息,封装到UserDetails中返回。
我们先直接把权限信息写死 封装到UserDetails中进行测试。
我们之前定义了 UserDetails 的实现类 LoginUser
,想要让其能封装权限信息就要对其进行修改。
1、修改 LoginUser,封装权限信息。增加用户权限字符串的集合,将用户权限转换封装在 authorities
变量里面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @Data @NoArgsConstructor public class LoginUser implements UserDetails { private User user; private List<String> permissions; public LoginUser (User user, List<String> permissions) { this .user = user; this .permissions = permissions; } @JSONField(serialize = false) private List<SimpleGrantedAuthority> authorities; @Override public Collection<? extends GrantedAuthority > getAuthorities() { if (authorities != null ) { return authorities; } authorities = permissions .stream() .map(SimpleGrantedAuthority::new ) .collect(Collectors.toList()); return authorities; } }
2、在 UserDetailsServiceImpl 中去把权限信息封装到 LoginUser
中。
我们写死权限进行测试,在下一节我们再从数据库中查询权限信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { List<String> list = new ArrayList <>(Arrays.asList("test" ,"admin" )); return new LoginUser (user,list); } }
3、完善 JwtAuthenticationTokenFilter,获取权限信息封装到Authentication中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private RedisCache redisCache; @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (loginUser, null ,loginUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(request, response); } }
测试
启动项目访问 localhost:8080/user/login
测试,先登录拿到用户的token,将其记录下来。
携带token访问 localhost:8080/hello
,请求未被拦截,成功访问。
修改 HelloController 类的 @PreAuthorize
注解的权限字符串,把 test
改为 test666
。重新启动项目。
保持其他信息不变,直接再次访问 localhost:8080/hello
,发现拿不到对应资源。
原因:要正常访问 /hello
接口,不仅需要 携带正确的token 还需要 具备对应的权限 。修改 @PreAuthorize
注解的权限字符串为 test666
后说明访问 /hello
接口需要具备 test666
权限,而访问的用户只具备 test
和 admin
权限,并没有 test666
权限,所以被Security拦截。
3.4 授权-RBAC权限模型 3.4.1 介绍 刚刚我们实现了只有当用户具备某种权限,才能访问我们的某个业务接口。但是存在一个问题,我们在给用户设置权限的时候,是写死的,在真正的开发中,我们是需要从数据库查询权限信息,下面就来学习如何从数据库查询权限信息,然后封装给用户。
RBAC权限模型(Role-Based Access Control) 即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。
该模型由以下五个主要组成部分构成:
用户: 在系统中代表具体个体的实体,可以是人员、程序或其他实体。用户需要访问系统资源。
角色: 角色是权限的集合,用于定义一组相似权限的集合。角色可以被赋予给用户,从而授予用户相应的权限。
权限: 权限表示系统中具体的操作或功能,例如读取、写入、执行等。每个权限定义了对系统资源的访问规则。
用户-角色映射: 用户-角色映射用于表示用户与角色之间的关系。通过为用户分配适当的角色,用户可以获得与角色相关联的权限。
角色-权限映射: 角色-权限映射表示角色与权限之间的关系。每个角色都被分配了一组权限,这些权限决定了角色可执行的操作。
3.4.2 数据库环境准备 由于目前我们数据库只有1张 sys_user 用户表,所以下面我们会新增4张表。
1、新增表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 create database if not exists security;use security; 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= '权限表' ; 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= '角色表' ; 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; 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; insert into sys_user_role values (2 ,1 );insert into sys_role values (1 ,'经理' ,'ceo' ,0 ,0 ,default ,default ,default ,default ,default ), (2 ,'程序员' ,'coder' ,0 ,0 ,default ,default ,default ,default ,default ); insert into sys_role_menu values (1 ,1 ),(1 ,2 );insert into sys_menu values (1 ,'部门管理' ,'dept' ,'system/dept/index' ,0 ,0 ,'system:dept:list' ,'#' ,default ,default ,default ,default ,default ,default ), (2 ,'测试' ,'test' ,'system/test/index' ,0 ,0 ,'system:test:list' ,'#' ,default ,default ,default ,default ,default ,default )
2、测试确认建表、插入数据是否达到要求
1 2 3 4 5 6 7 8 9 10 11 12 # 通过用户id去查询这个用户具有的权限列表 SELECT DISTINCT m.`perms` FROM sys_user_role ur LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id` LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id` LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id` WHERE user_id = 2 AND r.`status` = 0 AND m.`status` = 0
3、得到以下两条 权限字符串 数据,说明数据无误
3.4.3 查询数据库的权限信息 第一步: 新建 Menu 实体类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 @Data @AllArgsConstructor @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @TableName(value = "sys_menu") public class Menu implements Serializable { @Serial private static final long serialVersionUID = -54979041104113736L ; @TableId private Long id; private String menuName; private String path; private String component; private String visible; private String status; private String perms; private String icon; private Long createBy; private Date createTime; private Long updateBy; private Date updateTime; private Integer delFlag; private String remark; }
2、定义个MenuMapper,其中提供一个方法可以根据 userid 查询权限信息。
1 2 3 public interface MenuMapper extends BaseMapper <Menu> { List<String> selectPermsByUserId (Long id) ; }
3、 新建MenuMapper.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?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.muyoukule.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 >
4、在 application.yml 配置 mapper.xml 所在路径
1 2 3 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml
5、编写测试,测试能否拿到数据库的权限字符串
1 2 3 4 5 6 7 8 @Autowired private MenuMapper menuMapper;@Test public void testSelectPermsByUserId () { List<String> list = menuMapper.selectPermsByUserId(2L ); System.out.println(list); }
6、控制台打印:
1 [system:dept:list, system:test:list]
结果无误,测试通过!!
3.4.4 RBAC权限模型的实现 完成了上面的步骤后,RBAC权限模型的实现就很简单了。
只需要将查到的权限数据替换掉死数据,然后修改 @PreAuthorize
的权限字符串就好了。
1、修改 UserDetailsServiceImpl,替换权限数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Autowired private MenuMapper menuMapper; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { List<String> list = menuMapper.selectPermsByUserId(user.getId()); return new LoginUser (user,list); } }
2、把 HelloController 类的权限字符串修改为 system:dept:list
或者 system:test:list
1 2 3 4 5 @RequestMapping("/hello") @PreAuthorize("hasAuthority('system:dept:list')") public String hello () { return "hello" ; }
4. 自定义异常处理 上面的我们学习了 认证 和 授权 ,实现了基本的权限管理。我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的 json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。
在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被 ExceptionTranslationFilter
捕获到。在ExceptionTranslationFilter
中会去判断是认证失败还是授权失败出现的异常。
如果是认证过程中 出现的异常会被封装成 AuthenticationException
然后调用 AuthenticationEntryPoint
对象的方法去进行异常处理。
如果是授权过程中 出现的异常会被封装成 AccessDeniedException
然后调用 AccessDeniedHandler
对象的方法去进行异常处理。
所以如果我们需要自定义异常处理,我们只需要自定义 AuthenticationEntryPoint
和 AccessDeniedHandler
然后配置给SpringSecurity 即可。
准备工具类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class WebUtils { 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 ; } }
认证异常处理器
1 2 3 4 5 6 7 8 9 10 11 12 13 @Component public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { @Override public void commence (HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { ResponseResult result = new ResponseResult (HttpStatus.UNAUTHORIZED.value(), "认证失败,请重新登录" ); String jsonString = JSON.toJSONString(result); WebUtils.renderString(response, jsonString); } }
授权异常处理器
1 2 3 4 5 6 7 8 9 10 11 12 13 @Component public class AccessDeniedHandlerImpl implements AccessDeniedHandler { @Override public void handle (HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { ResponseResult result = new ResponseResult (HttpStatus.FORBIDDEN.value(), "权限不足,无法进入" ); String json = JSON.toJSONString(result); WebUtils.renderString(response, json); } }
在 SecurityConfig 配置处理器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationEntryPoint authenticationEntryPoint; @Autowired private AccessDeniedHandler accessDeniedHandler; @Override protected void configure (HttpSecurity http) throws Exception { http.exceptionHandling() .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler); } }
测试
认证:
1、启动项目访问 localhost:8080/user/login
,模拟登录。
2、登录成功后不携带token访问 localhost:8080/hello
授权:
1、修改 HelloController 类的权限字符串,确保数据库中查询出来的权限没有此权限。
1 2 3 4 5 @RequestMapping("/hello") @PreAuthorize("hasAuthority('system:dept:list666')") public String hello () { return "hello" ; }
2、启动项目访问 localhost:8080/user/login
,登录拿到用户的token,将其记录下来。
3、仍然携带 token
访问 /hello
,访问 /hello
需要 system:dept:list666
的权限,但是没有此权限:
可以看到无论是认证失败还是授权失败,都能返回和我们的接口一样结构的 json,说明处理器配置成功。
5. 跨域
⚠教程视频提到,要使外界用户能够访问SpringBoot和SpringSecurity配置的接口,需要同时配置两者的跨域资源共享(CORS)。但我在实际操作中发现,无论是否配置Security的跨域,只要SpringBoot配置了CORS,跨域问题就能解决。而仅仅配置Security的跨域,不配置SpringBoot跨域,是不能解决跨域问题的!!具体原因是因为什么呢?额…还有待探究😬
浏览器出于安全的考虑,使用XMLHttpRequest对象发起 HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。所以我们就要处理一下,让前端能进行跨域请求。
1、先简单准备一个前端页面,用于发送axios请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <script setup lang ="ts" > import { ref } from 'vue' import axios from 'axios' ;const loginData = ref ({ userName : '' , password : '' }) function login ( ) { axios.post ('http://localhost:8080/user/login' , loginData.value ) .then (response => { console .log (response.data ); }) .catch (err => { }); } </script > <template > <div > 用户:<input type ="text" v-model ="loginData.userName" > <br > 密码:<input type ="password" v-model ="loginData.password" > <br > <button @click ="login" > 登录</button > </div > </template >
2、对SpringBoot配置,允许跨域请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings (CorsRegistry registry) { registry.addMapping("/**" ) .allowedOriginPatterns("*" ) .allowCredentials(true ) .allowedMethods("GET" , "POST" , "DELETE" , "PUT" ) .allowedHeaders("*" ) .maxAge(3600 ); } }
3、开启SpringSecurity的跨域访问
由于我们的资源都会受到SpringSecurity的保护,所以想要跨域访问还要让SpringSecurity运行跨域访问。
1 2 3 4 5 6 7 8 9 10 11 12 13 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.cors(); } }
6. 授权-权限校验的方法 我们前面都是使用@PreAuthorize
注解,然后在在其中使用的是hasAuthority
方法进行校验。SpringSecurity还为我们提供了其它方法例如:hasAnyAuthority
,hasRole
,hasAnyRole
等。
我们先不着急了解其他方法,而先去理解 hasAuthority
的源码是怎么执行的,我们打上断点进行调试!
6.1 hasAuthority源码执行流程 1、找到HelloController类的 @PreAuthorize("hasAuthority('system:dept:list')")
注解所在位置,Ctrl + 鼠标左键
点击hasAuthority
进入对应代码。在如下位置打个断点,debug 方式启动:
参数 authority
接收的就是权限字符串参数,这个方法实际上是调用了 hasAnyAuthority
方法。
2、进入 hasAnyAuthority
方法,这个方法实际上是调用了 hasAnyAuthorityName
方法,并传入 preflx
和 authorities
3、hasAnyAuthorityName
方法里面的 getAuthoritySet
用于获取用户的权限信息
4、进入 getAuthoritySet
方法(核心代码 ),方法里的 this.authentication
就是去获取我们之前在JwtAuthenticationTokenFilter
存入 SecurityContextHolder
中的用户的权限信息,通过 getAuthorities
拿到一个权限对象, getAuthorities
方法最终调用我们自己在LoginUser重写的方法。然后将list集合转换为set集合,最终将权限返回。
5、执行 getAuthoritySet
方法之后,接着遍历roles并进行字符串拼接,遍历出来的role是访问资源需要具备的权限 。将roles里的权限参数与 getAuthoritySet
方法返回的权限参数(用户拥有的权限) 进行比对,返回true说明有权限访问。
到这里hasAuthority
的源码就扒完啦,是不是还挺很简单😎???!!!
6.2 hasAnyAuthority hasAnyAuthority
:方法可以传入多个权限,只有用户有其中任意一个 权限都可以访问对应资源。
1 2 3 4 5 @RequestMapping("/hello") @PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')") public String hello () { return "hello" ; }
6.3 hasRole hasRole
:要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
1 2 3 4 5 @RequestMapping("/hello") @PreAuthorize("hasRole('system:dept:list')") public String hello () { return "hello" ; }
6.4 hasAnyRole hasAnyRole
:有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
1 2 3 4 5 @RequestMapping("/hello") @PreAuthorize("hasAnyRole('admin','system:dept:list')") public String hello () { return "hello" ; }
6.5 自定义权限校验的方法 在上面的源码中,我们知道security校验权限的 @PreAuthorize
注解,其实就是获取用户权限,然后跟业务接口的权限进行比较,最后返回一个布尔类型。要自定义权限校验,需要创建一个类并定义一个返回布尔值的方法。然后在@PreAuthorize
注解中调用这个自定义方法即可实现权限校验。
1、自定义权限校验类MukuExpressionRoot
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Component("ex") public class MukuExpressionRoot { public boolean hasAuthorities (String... authorities) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); List<String> permissions = loginUser.getPermissions(); for (String authority : authorities) { if (!permissions.contains(authority)) { return false ; } } return true ; } }
2、在SPEL表达式来获取容器中bean的名字,就可以让 @PreAuthorize
注解去使用 hasAuthorities
方法,修改HelloController类:
1 2 3 4 5 6 7 8 9 10 @RestController public class HelloController { @RequestMapping("/hello") @PreAuthorize("@ex.hasAuthorities('system:dept:list')") public String hello () { return "hello" ; } }
6.6 基于配置的权限控制 前面学习的权限控制是基于@PreAuthorize
注解来完成的,也可以在配置类当中实现权限控制。
1、在HelloController类添加如下方法:
1 2 3 4 5 @RequestMapping("/testCors") public ResponseResult testCors () { return new ResponseResult (200 , "访问成功" ); }
2、配置类 SecurityConfig 添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/user/login" ).anonymous() .antMatchers("/testCors" ).hasAuthority("system:dept:list" ) .anyRequest().authenticated(); } }
7. 防护CSRF攻击 在SecurityConfig类里面的configure方法里面,有一个配置如下,我们还没有学习,下面就来了解一下
CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。
详细介绍参考:CSRF攻击与防御
SpringSecurity去防止CSRF攻击的方式就是通过csrf_token
。后端会生成一个csrf_token
,前端发起请求的时候需要携带这个csrf_token
,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。
8. 认证-自定义处理器 新建一个SpringBoot项目,创建项目时,勾选 lombok、web 和 security
8.1 认证成功处理器 实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler
就是登录成功处理器。
我们也可以自己去自定义成功处理器进行成功后的相应处理。
1 2 3 4 5 6 7 @Component public class MySuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("认证成功!!!" ); } }
1 2 3 4 5 6 7 8 9 10 11 12 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationSuccessHandler successHandler; @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin().successHandler(successHandler); http.authorizeRequests().anyRequest().authenticated(); } }
启动项目访问 http://localhost:8080/login
接口后输入正确用户名和密码,控制台打印:
8.2 认证失败处理器 实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler
就是登录失败处理器。
我们也可以自己去自定义失败处理器进行失败后的相应处理。
1 2 3 4 5 6 7 @Component public class FailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { System.out.println("认证失败!!!" ); } }
1 2 3 4 5 6 7 8 9 10 11 12 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationFailureHandler failureHandler; @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin().failureHandler(failureHandler); http.authorizeRequests().anyRequest().authenticated(); } }
启动项目访问 http://localhost:8080/login
接口后输入错误用户名和密码,控制台打印:
8.3 登出成功处理器 Security中有个登出操作的过滤器DefaultLogoutPageGeneratingFilter,可以指定登出成功后的处理。Security中有个LogoutSuccessHandler
接口,我们需要新建一个类,然后实现这个接口,重新接口里面的方法,就能实现自定义登出成功处理器。
1 2 3 4 5 6 7 @Component public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("注销成功!!!" ); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private LogoutSuccessHandler logoutSuccessHandler; @Override protected void configure (HttpSecurity http) throws Exception { http.logout() .logoutSuccessHandler(logoutSuccessHandler); http.authorizeRequests().anyRequest().authenticated(); } }
启动项目先访问 http://localhost:8080/login
接口后输入正确用户名和密码,再访问 http://localhost:8080/logout
接口退出登录,控制台打印: