SpringSecurity
SpringSecurity
本Demo地址:https://gitee.com/bai-xiaoyun/spring-security-demo/tree/master
依赖包(spring-boot-starter-security)导入后,Spring Security就默认提供了许多功能将整个应用给保护了起来:
1、要求经过身份验证的用户才能与应用程序进行交互
2、创建好了默认登录表单
3、生成用户名为user的随机密码并打印在控制台上
4、CSRF攻击防护、Session Fixation攻击防护
5、等等等等…
1、构建基本项目框架
依赖配置:暂时先不引入SpringSecurity
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.3.10.RELEASE</version></parent><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--jjwt--><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.0</version></dependency><!-- SpringSecurity-->
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-security</artifactId>-->
<!-- </dependency>--><!-- mp--><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.4.1</version></dependency><!-- jdbc驱动类--><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><!-- lombok--><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency>
<!-- redis--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency></dependencies><properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
用户实体类:
@TableName("sys_user")
@Data
public class SysUser {@TableId(type = IdType.AUTO)private Integer id;private String username;private String password;private String name;@TableLogic(value="0",delval = "1")private Integer deleted;
}
角色实体类:
@Data
@TableName("sys_role")
public class SysRole {@TableId(type = IdType.AUTO)private Integer id;private String roleName;@TableLogic(value = "0",delval = "1")private Integer deleted;
}
菜单实体类:
@Data
@TableName("sys_menu")
public class SysMenu {@TableId(type = IdType.AUTO)private Integer id;private Integer parent_id;private String name;private Integer type;@TableLogic(value = "0",delval = "1")private Integer deleted;}
用户角色实体类:
@Data
@TableName("sys_user_role")
public class SysUserRole {@TableId(type = IdType.AUTO)private Integer id;private Integer userId;private Integer roleId;@TableLogic(value = "0",delval = "1")private Integer deleted;}
菜单角色实体类
@Data
@TableName("sys_menu_role")
public class SysMenuRole {@TableId(type = IdType.AUTO)private Integer id;private Integer menuId;private Integer roleId;@TableLogic(value = "0",delval = "1")private Integer deleted;
}
yml配置文件:
server:port: 8080
mybatis-plus:configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 查看日志
spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/demo?characterEncoding=utf-8&useSSL=falseusername: rootpassword: root
用户的crud
@RequestMapping("/system/user")
@RestController
public class UserController {@Autowiredprivate UserService userService;@GetMapping("/list/{page}/{limit}")private Result getUserList(@PathVariable(name = "page") Integer page,@PathVariable(name = "limit") Integer limit){IPage<SysUser> iPage=new Page<>(page,limit);userService.page(iPage);return new Result<>(200,"查询成功",iPage);}@PostMapping("/add")private Result addUser(@RequestBody SysUser sysUser){userService.save(sysUser);return new Result(200,"添加成功",null );}@PutMapping("/update")private Result updateUser(@RequestBody SysUser sysUser){userService.updateById(sysUser);return new Result(200,"修改成功",null );}@DeleteMapping("/delete/{userId}")private Result deleteUser(@PathVariable Integer userId){userService.removeById(userId);return new Result(200,"删除成功",null );}}
测试:
http://localhost:8080/system/user/list/1/5
)
访问成功

2、加入SpringSecurity依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId>
</dependency>
再次访问http://localhost:8080/system/user/list/1/5,会直接跳转到SpringSecurity默认的登录页面(样式没显示出来)

用户名:user
密码:控制台有输出

3、用户认证
用户认证流程:

Spring Security中三个核心组件:
1、Authentication:存储了认证信息,代表当前登录用户
2、SeucirtyContext:上下文对象,用来获取Authentication
3、SecurityContextHolder:上下文管理对象,用来在程序任何地方获取SecurityContext
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
其中SecurityContextHolder原理非常简单,就是使用ThreadLocal来保证一个线程中传递同一个对象!
Authentication中是什么信息呢:
1、Principal:用户信息,没有认证时一般是用户名,认证后一般是用户对象
2、Credentials:用户凭证,一般是密码
3、Authorities:用户权限
Spring Security是怎么进行用户认证的呢?
AuthenticationManager 就是Spring Security用于执行身份验证的组件,只需要调用它的authenticate方法即可完成认证。Spring Security默认的认证方式就是在UsernamePasswordAuthenticationFilter这个过滤器中进行认证的,该过滤器负责认证逻辑。
AuthenticationManager的校验逻辑非常简单:
获得用户账号密码后先通过PasswordEncoder组件,进行密码加密后,交由**UserDetailsService** 处理,接口只有一个方法loadUserByUsername(String username),通过用户名查询用户对象,查询到对象之后封装到*UserDetails** ,该接口中提供了账号、密码等通用属性。然后通过PasswordEncoder组件对密码进行校验
①加密器PasswordEncoder
自定义MD5加密工具类:
public final class MD5 {public static String encrypt(String strSrc) {try {char hexChars[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8','9', 'a', 'b', 'c', 'd', 'e', 'f' };byte[] bytes = strSrc.getBytes();MessageDigest md = MessageDigest.getInstance("MD5");md.update(bytes);bytes = md.digest();int j = bytes.length;char[] chars = new char[j * 2];int k = 0;for (int i = 0; i < bytes.length; i++) {byte b = bytes[i];chars[k++] = hexChars[b >>> 4 & 0xf];chars[k++] = hexChars[b & 0xf];}return new String(chars);} catch (NoSuchAlgorithmException e) {e.printStackTrace();throw new RuntimeException("MD5加密出错!!+" + e);}}}
自定义加密处理组件:CustomMd5PasswordEncoder
@Component
public class CustomMd5PasswordEncoder implements PasswordEncoder {//加密@Overridepublic String encode(CharSequence charSequence) {return MD5.encrypt(charSequence.toString());}//是否匹配@Overridepublic boolean matches(CharSequence charSequence, String s) {return s.equals(MD5.encrypt(charSequence.toString()));}
}
②用户对象UserDetails
该接口就是我们所说的用户对象,它提供了用户的一些通用属性,源码如下:
public interface UserDetails extends Serializable {/*** 用户权限集合(这个权限对象现在不管它,到权限时我会讲解)*/Collection<? extends GrantedAuthority> getAuthorities();/*** 用户密码*/String getPassword();/*** 用户名*/String getUsername();/*** 用户没过期返回true,反之则false*/boolean isAccountNonExpired();/*** 用户没锁定返回true,反之则false*/boolean isAccountNonLocked();/*** 用户凭据(通常为密码)没过期返回true,反之则false*/boolean isCredentialsNonExpired();/*** 用户是启用状态返回true,反之则false*/boolean isEnabled();
}
实际开发中我们的用户属性各种各样,这些默认属性可能是满足不了(比如没有id属性),所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User类,该类实现了UserDetails接口帮我们省去了重写方法的工作:
public class CustomerUser extends User {private SysUser sysUser;public CustomerUser(SysUser sysUser, Collection<? extends GrantedAuthority> authorities) {super(sysUser.getUsername(), sysUser.getPassword(), authorities);this.sysUser=sysUser;}public SysUser getSysUser() {return sysUser;}public void setSysUser(SysUser sysUser) {this.sysUser = sysUser;}
}
③业务对象UserDetailsService
该接口很简单只有一个方法loadUserByUsername:
public interface UserDetailsService {/*** 根据用户名获取用户对象(获取不到直接抛异常)*/UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
我们实现该接口,就完成了自己的业务
@Component
public class UserDetailServiceImpl implements UserDetailsService {@Autowiredprivate UserMapper userMapper;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {SysUser sysUser = userMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username));if(null == sysUser) {throw new UsernameNotFoundException("用户名不存在!");}//返回用户对象,权限列表(暂时设置为空)return new CustomerUser(sysUser, Collections.emptyList());}
}
AuthenticationManager校验所调用的三个组件我们就已经做好实现了!
此时我们就可以通过默认的登录页面实现查询数据库的认证过程了!
4、添加登录过滤器
自定义自己的登录逻辑(采用token的方式)
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {public TokenLoginFilter(AuthenticationManager authenticationManager){this.setAuthenticationManager(authenticationManager);//指定登录接口和处理方式this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/login","POST"));}/*** 登录认证* @param request* @param response* @return* @throws AuthenticationException*/@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {try {LoginVo loginVo = new ObjectMapper().readValue(request.getInputStream(), LoginVo.class);Authentication authenticationToken = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword());return this.getAuthenticationManager().authenticate(authenticationToken);} catch (IOException e) {throw new RuntimeException(e);}}/*** 登录成功->返回token* @param request* @param response* @param chain* @param auth* @throws IOException* @throws ServletException*/@Overrideprotected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,Authentication auth) throws IOException, ServletException {CustomerUser customUser = (CustomerUser) auth.getPrincipal();String token = JwtHelper.createToken(customUser.getSysUser().getId(), customUser.getSysUser().getUsername());Map<String, Object> map = new HashMap<>();map.put("token", token);ResponseUtil.out(response, Result.ok(map));}/*** 登录失败* @param request* @param response* @param e* @throws IOException* @throws ServletException*/@Overrideprotected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,AuthenticationException e) throws IOException, ServletException {ResponseUtil.out(response,Result.build(null,444,"登录失败"));}}
配置Security
@Configuration
@EnableWebSecurity //@EnableWebSecurity是开启SpringSecurity的默认行为
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {// 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护http//关闭csrf.csrf().disable()// 开启跨域以便前端调用接口.cors().and().authorizeRequests()// 指定某些接口不需要通过验证即可访问。登陆接口肯定是不需要认证的.antMatchers("/admin/login").permitAll()// 这里意思是其它所有接口需要认证才能访问.anyRequest().authenticated().and().addFilter(new TokenLoginFilter(authenticationManager()));//禁用sessionhttp.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);}/*** 配置哪些请求不拦截* 排除swagger相关请求* @param web* @throws Exception*/@Overridepublic void configure(WebSecurity web) throws Exception {web.ignoring().antMatchers("/favicon.ico","/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**", "/doc.html");}
}
添加工具类:ResponseUtil
package com.atguigu.common.util;import com.atguigu.common.result.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;import javax.servlet.http.HttpServletResponse;
import java.io.IOException;public class ResponseUtil {public static void out(HttpServletResponse response, Result r) {ObjectMapper mapper = new ObjectMapper();response.setStatus(HttpStatus.OK.value());response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);try {mapper.writeValue(response.getWriter(), r);} catch (IOException e) {e.printStackTrace();}}
}
认证解析token
因为用户登录状态在token中存储在客户端,所以每次请求接口请求头携带token, 后台通过自定义token过滤器拦截解析token完成认证并填充用户信息实体
import com.iflytek.util.JwtHelper;
import com.iflytek.util.ResponseUtil;
import com.iflytek.util.Result;
import com.iflytek.util.ResultCodeEnum;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;/*** <p>* 认证解析token过滤器* </p>*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {public TokenAuthenticationFilter() {}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws IOException, ServletException {
// logger.info("uri:"+request.getRequestURI());//如果是登录接口,直接放行if("/admin/login".equals(request.getRequestURI())) {chain.doFilter(request, response);return;}UsernamePasswordAuthenticationToken authentication = getAuthentication(request);if(null != authentication) {//放到上下文对象中SecurityContextHolder.getContext().setAuthentication(authentication);//放行请求chain.doFilter(request, response);} else {ResponseUtil.out(response, Result.build(null, ResultCodeEnum.PERMISSION));}}private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {// token置于header里String token = request.getHeader("token");logger.info("token:"+token);if (!StringUtils.isEmpty(token)) {String username = JwtHelper.getUsername(token);logger.info("useruame:"+username);if (!StringUtils.isEmpty(username)) {//返回一个认证对象return new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());}}return null;}
}
将解析token的过滤器也加入配置类WebSecurityConfig中
.and()
//TokenAuthenticationFilter放到UsernamePasswordAuthenticationFilter的前面,这样做就是为了除了登录的时候去查询数据库外,其他时候都用token进行认证。
.addFilterBefore(new TokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilter(new TokenLoginFilter(authenticationManager()));
5、用户授权
修改UserDetailsServiceImpl中的loadUserByUsername方法,设置用户权限
之前我们只封装了用户信息,没有设置用户权限
@Component
public class UserDetailServiceImpl implements UserDetailsService {@Autowiredprivate UserMapper userMapper;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {SysUser sysUser = userMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username));if(null == sysUser) {throw new UsernameNotFoundException("用户名不存在!");}//查询用户权限List<SimpleGrantedAuthority> permission = userPermission(sysUser);//返回用户对象,权限列表(暂时设置为空)return new CustomerUser(sysUser, permission);}public List<SimpleGrantedAuthority> userPermission(SysUser sysUser){List<SysMenu> menus=userMapper.findRouter(sysUser.getId());List<String> collect = menus.stream().filter(item->item.getPerms()!=null).map(SysMenu::getPerms).collect(Collectors.toList());List<SimpleGrantedAuthority> authorities = new ArrayList<>();for (String perm : collect) {authorities.add(new SimpleGrantedAuthority(perm.trim()));}return authorities;}
}
配置redis
将登录成功的用户权限存放到redis中,使用token认证时,直接从redis中取出该用户的权限
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置文件
spring:redis:host: 192.168.6.100port: 6379database: 0timeout: 1800000password: rootjedis:pool:max-active: 20 #最大连接数max-wait: -1 #最大阻塞等待时间(负数表示没限制)max-idle: 5 #最大空闲min-idle: 0 #最小空闲
第一次登录成功我们将权限数据保存到reids
注意RedisTemplate的注入方式是通过构造器注入
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {private RedisTemplate redisTemplate;public TokenLoginFilter(AuthenticationManager authenticationManager,RedisTemplate redisTemplate){this.setAuthenticationManager(authenticationManager);//指定登录接口和处理方式this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/login","POST"));this.redisTemplate=redisTemplate;}/*** 登录认证* @param request* @param response* @return* @throws AuthenticationException*/@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {try {LoginVo loginVo = new ObjectMapper().readValue(request.getInputStream(), LoginVo.class);Authentication authenticationToken = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword());return this.getAuthenticationManager().authenticate(authenticationToken);} catch (IOException e) {throw new RuntimeException(e);}}/*** 登录成功->返回token* @param request* @param response* @param chain* @param auth* @throws IOException* @throws ServletException*/@Overrideprotected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,Authentication auth) throws IOException, ServletException {CustomerUser customUser = (CustomerUser) auth.getPrincipal();String token = JwtHelper.createToken(customUser.getSysUser().getId(), customUser.getSysUser().getUsername());Map<String, Object> map = new HashMap<>();map.put("token", token);//存入redisredisTemplate.opsForValue().set(customUser.getUsername(),customUser.getAuthorities());ResponseUtil.out(response, Result.ok(map));}/*** 登录失败* @param request* @param response* @param e* @throws IOException* @throws ServletException*/@Overrideprotected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,AuthenticationException e) throws IOException, ServletException {ResponseUtil.out(response,Result.build(null,444,"登录失败"));}}
修改TokenAuthenticationFilter认证成功后,从redis里面获取权限数据
RedisTemplate注入方式同上
/*** <p>* 认证解析token过滤器* </p>*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {private RedisTemplate redisTemplate;public TokenAuthenticationFilter(RedisTemplate redisTemplate) {this.redisTemplate=redisTemplate;}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws IOException, ServletException {
// logger.info("uri:"+request.getRequestURI());//如果是登录接口,直接放行if("/admin/login".equals(request.getRequestURI())) {chain.doFilter(request, response);return;}UsernamePasswordAuthenticationToken authentication = getAuthentication(request);if(null != authentication) {//放到上下文对象中SecurityContextHolder.getContext().setAuthentication(authentication);//放行请求chain.doFilter(request, response);} else {ResponseUtil.out(response, Result.build(null, ResultCodeEnum.PERMISSION));}}private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {// token置于header里String token = request.getHeader("token");logger.info("token:"+token);if (!StringUtils.isEmpty(token)) {String username = JwtHelper.getUsername(token);logger.info("useruame:"+username);if (!StringUtils.isEmpty(username)) {//返回一个认证对象//从redis中读取用户权限Collection<GrantedAuthority> authorities = (Collection<GrantedAuthority>)redisTemplate.opsForValue().get(username);return new UsernamePasswordAuthenticationToken(username, null, authorities);}}return null;}
}
在WebSecurityConfig中注入
@Autowired
private RedisTemplate redisTemplate;
将redisTemplate传参给两个过滤器
.and()//TokenAuthenticationFilter放到UsernamePasswordAuthenticationFilter的前面,这样做就是为了除了登录的时候去查询数据库外,其他时候都用token进行认证。.addFilterBefore(new TokenAuthenticationFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class).addFilter(new TokenLoginFilter(authenticationManager(),redisTemplate));
并开启基于方法的安全认证机制,也就是说在web层的controller启用注解机制的安全确认
在配置类上启用
@EnableGlobalMethodSecurity(prePostEnabled = true)
控制controller层接口权限
Spring Security默认是禁用注解的,要想开启注解,需要在继承WebSecurityConfigurerAdapter的类上加@EnableGlobalMethodSecurity注解,来判断用户对某个控制层的方法是否具有访问权限
通过@PreAuthorize标签控制controller层接口权限
@PreAuthorize("hasAuthority('btn.sysUser.list')")
@GetMapping("/list/{page}/{limit}")
private Result getUserList(@PathVariable(name = "page") Integer page,@PathVariable(name = "limit") Integer limit){IPage<SysUser> iPage=new Page<>(page,limit);userService.page(iPage);return Result.ok(iPage);
}
6、捕获权限异常
捕获没有权限异常AccessDeniedException
在全局异常处理器中添加以下方法
/*** spring security异常* @param e* @return*/
@ExceptionHandler(AccessDeniedException.class)
@ResponseBody
public Result error(AccessDeniedException e) throws AccessDeniedException {return Result.build(null, ResultCodeEnum.PERMISSION);
}
注意:AccessDeniedException导入的是Spring Security中的,别导错包