本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、等等等等…
依赖配置:暂时先不引入SpringSecurity
org.springframework.boot
spring-boot-starter-parent
2.3.10.RELEASE
org.springframework.boot
spring-boot-starter-web
io.jsonwebtoken
jjwt
0.9.0
com.baomidou
mybatis-plus-boot-starter
3.4.1
mysql
mysql-connector-java
org.projectlombok
lombok
org.springframework.boot
spring-boot-starter-data-redis
8
8
UTF-8
用户实体类:
@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.Driver
url: jdbc:mysql://localhost:3306/demo?characterEncoding=utf-8&useSSL=false
username: root
password: root
用户的crud
@RequestMapping("/system/user")
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/list/{page}/{limit}")
private Result getUserList(@PathVariable(name = "page") Integer page,
@PathVariable(name = "limit") Integer limit){
IPage 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
)
访问成功
org.springframework.boot
spring-boot-starter-security
再次访问http://localhost:8080/system/user/list/1/5,会直接跳转到SpringSecurity默认的登录页面(样式没显示出来)
用户名:user
密码:控制台有输出
用户认证流程:
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组件对密码进行校验
自定义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 {
//加密
@Override
public String encode(CharSequence charSequence) {
return MD5.encrypt(charSequence.toString());
}
//是否匹配
@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(MD5.encrypt(charSequence.toString()));
}
}
该接口就是我们所说的用户对象,它提供了用户的一些通用属性,源码如下:
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;
}
}
该接口很简单只有一个方法loadUserByUsername:
public interface UserDetailsService {
/**
* 根据用户名获取用户对象(获取不到直接抛异常)
*/
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
我们实现该接口,就完成了自己的业务
@Component
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userMapper.selectOne(new LambdaQueryWrapper().eq(SysUser::getUsername, username));
if(null == sysUser) {
throw new UsernameNotFoundException("用户名不存在!");
}
//返回用户对象,权限列表(暂时设置为空)
return new CustomerUser(sysUser, Collections.emptyList());
}
}
AuthenticationManager
校验所调用的三个组件我们就已经做好实现了!
此时我们就可以通过默认的登录页面实现查询数据库的认证过程了!
自定义自己的登录逻辑(采用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
*/
@Override
public 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
*/
@Override
protected 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 map = new HashMap<>();
map.put("token", token);
ResponseUtil.out(response, Result.ok(map));
}
/**
* 登录失败
* @param request
* @param response
* @param e
* @throws IOException
* @throws ServletException
*/
@Override
protected 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 {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
http
//关闭csrf
.csrf().disable()
// 开启跨域以便前端调用接口
.cors().and()
.authorizeRequests()
// 指定某些接口不需要通过验证即可访问。登陆接口肯定是不需要认证的
.antMatchers("/admin/login").permitAll()
// 这里意思是其它所有接口需要认证才能访问
.anyRequest().authenticated()
.and()
.addFilter(new TokenLoginFilter(authenticationManager()));
//禁用session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
/**
* 配置哪些请求不拦截
* 排除swagger相关请求
* @param web
* @throws Exception
*/
@Override
public 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;
/**
*
* 认证解析token过滤器
*
*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {
public TokenAuthenticationFilter() {
}
@Override
protected 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()));
修改UserDetailsServiceImpl中的loadUserByUsername方法,设置用户权限
之前我们只封装了用户信息,没有设置用户权限
@Component
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userMapper.selectOne(new LambdaQueryWrapper().eq(SysUser::getUsername, username));
if(null == sysUser) {
throw new UsernameNotFoundException("用户名不存在!");
}
//查询用户权限
List permission = userPermission(sysUser);
//返回用户对象,权限列表(暂时设置为空)
return new CustomerUser(sysUser, permission);
}
public List userPermission(SysUser sysUser){
List menus=userMapper.findRouter(sysUser.getId());
List collect = menus.stream().filter(item->item.getPerms()!=null).map(SysMenu::getPerms).collect(Collectors.toList());
List authorities = new ArrayList<>();
for (String perm : collect) {
authorities.add(new SimpleGrantedAuthority(perm.trim()));
}
return authorities;
}
}
配置redis
将登录成功的用户权限存放到redis中,使用token认证时,直接从redis中取出该用户的权限
org.springframework.boot
spring-boot-starter-data-redis
配置文件
spring:
redis:
host: 192.168.6.100
port: 6379
database: 0
timeout: 1800000
password: root
jedis:
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
*/
@Override
public 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
*/
@Override
protected 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 map = new HashMap<>();
map.put("token", token);
//存入redis
redisTemplate.opsForValue().set(customUser.getUsername(),customUser.getAuthorities());
ResponseUtil.out(response, Result.ok(map));
}
/**
* 登录失败
* @param request
* @param response
* @param e
* @throws IOException
* @throws ServletException
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException {
ResponseUtil.out(response,Result.build(null,444,"登录失败"));
}
}
修改TokenAuthenticationFilter认证成功后,从redis里面获取权限数据
RedisTemplate注入方式同上
/**
*
* 认证解析token过滤器
*
*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private RedisTemplate redisTemplate;
public TokenAuthenticationFilter(RedisTemplate redisTemplate) {
this.redisTemplate=redisTemplate;
}
@Override
protected 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 authorities = (Collection)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 iPage=new Page<>(page,limit);
userService.page(iPage);
return Result.ok(iPage);
}
捕获没有权限异常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中的,别导错包