Spring Security
免责声明:本文章仅做个人学习记录使用,内容或许会有错误,还请各位看官评论赐教不胜感激!代码均摘自若依开源项目
一.简介
Spring Security是Spring项目组提供的安全服务框架,核心功能包括认证和授权。它为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码的工作。
1.1 认证
认证即系统判断用户的身份是否合法,合法可继续访问,不合法则拒绝访问。常见的用户身份认证方式有:用户名密码登录、二维码登录、手机短信登录、脸部识别认证、指纹认证等方式。认证是为了保护系统的隐私数据与资源,用户的身份合法才能访问该系统的资源。
1.2 授权
授权即认证通过后,根据用户的权限来控制用户访问资源的过程,拥有资源的访问权限则正常访问,没有权限则拒绝访问。比如在一些视频网站中,普通用户登录后只有观看免费视频的权限,而VIP用户登录后,网站会给该用户提供观看VIP视频的权限。认证是为了保证用户身份的合法性,授权则是为了更细粒度的对隐私数据进行划分,控制不同的用户能够访问不同的资源。
举个例子:认证是公司大门识别你作为员工能进入公司,而授权则是由于你作为公司会计可以进入财务室,查看账目,处理财务数据
二.流程梳理
2.1 登录效验流程
2.2 Security流程
SpringSecurity的原理就是一个过滤器链,内部包含了提供各种功能的过滤器,下图整理出一些主要的过滤器。
SpringSecurity的认证流程
概念速查:
-
Authentication接口:它的实现类,表示当前访问系统的用户,封装了用户相关信息
-
AuthenticationManager接口:定义了认证Authentication的方法
-
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法
-
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中
-
SecurityContextHolder对象:上下对象,其他方法中用用户信息的时候就用这个东西获取。
个人理解:从上面的认证流程中总结,基本就是把入参的用户名密码封装到Authentication对象里,中间通过一系列的骚操作处理,处理成UserDetails对象,然后在把UserDetails对象封装成Authentication对象返回的过程
1:上述流程是Security框架默认的处理流程,数据对比是获取的内存中的,真正的开发中我们要在数据库中获取用户信息做对比,主要处理的就是UserDetailsService实现类里面做对比的内容
2:输入密码和验证通过之后,都会到AbstractAuthenticationProcessingFilter实现类中进行处理,真正开发中不能用他提供的登录页面,并且当登录成功之后我们还有一些其他的操作要做,所以我们要重新搞一个自己的AbstractAuthenticationProcessingFilter实现类
总结而言就是,自己重写头尾两个实现类来搞自己的业务逻辑。
三.快速开始
在pom中引入Security的Starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Tips:引入之后,启动项目会跳到一个他默认的页面,这个页面由security提供,默认的用户名是user密码会在启动的时候控制台输出
四.功能实现
4.1 思路分析
4.1.1 登录
-
自定义登录接口
- 不用UsernamePasswordAuthenticationFilter,自己写一个,用来获表单提交的用户名密码
- 调用AuthenticationManager中的authenticate方法让Filter链走下去
- 认证通过后,使用JWT封装Token,数据存入Redis
-
自定义UserDetailsService实现类
- 查库,检查用户名密码是否有效
4.1.2 校验
-
定义Jwt认证过滤器
- 获取、解析Token
- 根据Token从Redis中获取用户相关信息
- 存入SecurityContextHolder对象方便其他内容获取用户信息
4.1.3 配置类
- 创建一个类,继承WebSecurityConfigurerAdapter
- 注入AuthenticationManager对象(登录接口中会有用到这个对象,所以得注入到容器中)
- 注入密码加密方式(我也不知道为啥非得写这里,反正老师和若依都这么写的,那就这么写吧)
- 重写configure(HttpSecurity httpSecurity)方法,配置拦截相关内容(具体的配置内容可以参考代码)
4.1.4 加密
-
加密方式
- 注入BCryptPasswordEncoder替换默认的PasswordEncoder实现类的加密方式(当然也可以自定义加密方式,只要实现PasswordEncoder并且注入到Spring容器中就行)
Tips:Security默认的加密方式是PasswordEncoder:{id}password。他会根据id去判定加密方式,但是一般我们都会改掉,BCryptPasswordEncoder也是Security提供的一种加密方式
4.1.5 退出
- 获取Token,然后删掉Resid中的信息
4.1.6 权限
- 开启权限使用注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) - 自定义权限方式
4.2 功能实现
Security配置类
/**
* spring security配置
*
* @author system
*/
//开启权限注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;
/**
* 允许匿名访问的地址
*/
@Autowired
private PermitAllUrlProperties permitAllUrl;
/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
/**
* 注入BCryptPasswordEncoder(强散列哈希加密实现)替换默认的加密方式
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()
{
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 禁用HTTP响应标头
.headers().cacheControl().disable().and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated().and()
//禁用X-Frame-Options头,从而允许网页在iframe中嵌套显示
.headers().frameOptions().disable();
// 添加Logout filter
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS 跨域filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
❓❓❓:这里我有个疑惑,已经不走Securaty的UsernamePasswordAuthenticationFilter了,为什么还要把authenticationTokenFilter添加在这个之前,我们之前登录逻辑到底是怎么走的?
猜测答案:在登录功能中,之所以能直接访问到登录接口,是因为配置了Securaty路径放行,放行不代表就不走UsernamePasswordAuthenticationFilter了,所以该走还是走,只不过给放行了而已,所以验证Token的操作还是要在UsernamePasswordAuthenticationFilter之前执行
编写登录接口实现类(Controller中调用这个方法就好了)
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid)
{
// 用户验证
Authentication authentication = null;
try{
//loadUserByUsername需要一个Authentication类型的入参,所以用这东西封装一下,username和password
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
//把Authentication扔给全局域对象,以便在其他地方获取
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername 让Filter链走下去
authentication = authenticationManager.authenticate(authenticationToken);
}
//没查到用户,捕获UserDetailsServiceImpl.loadUserByUsername 抛出的异常
catch (Exception e){
if (e instanceof BadCredentialsException){throw new UserPasswordNotMatchException();}
else{throw new ServiceException(e.getMessage());}
}
//清除Authentication防止内存泄露
finally{AuthenticationContextHolder.clearContext();}
//在全局对象中获取user对象并转换成LoginUser对象,因为在UserDetailsService中是用这个对象封装进去的
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 获取随机UUID,使用性能更好的ThreadLocalRandom生成UUID 用于 生成token
String token = IdUtils.fastUUID();
loginUser.setToken(token);
//封装内容:把用户的浏览器系统等相关信息封装到loginUser对象中
setUserAgent(loginUser);
//封装内容:存入redis 并设置失效时间
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
Tips:AuthenticationContextHolder的实现是依赖于ThreadLocal,所以在每个请求结束之后一定要清理掉,否则就会造成内存泄露
为何会内存泄露请学习ThreadLocal相关内容
自定义UserDetailsService实现类查库
/**
* 用户验证处理
*
* @author system
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
@Autowired
//注入自己写的用户信息的接口
private ISysUserService userService;
@Autowired
//注入自己写的查权限的接口
private SysPermissionService permissionService;
@Override
public UserDetails loadUserByUsername(String username)
{
//查库获取用户信息
SysUser user = userService.selectUserByUserName(username);
//没查到用户,就是信息有误,抛出异常
if (StringUtils.isNull(user))
{
log.info("登录用户不存在.");
throw new ServiceException("登录用户不存在");
}
//如果用户存在 把数据封装成UserDetails对象
return createLoginUser(user);
}
public UserDetails createLoginUser(SysUser user)
{
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}
}
创建自己的登录对象,实现UserDetails
/**
* 登录用户身份权限
*
* @author system
*/
@Data
public class LoginUser implements UserDetails
{
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private Long userId;
/**
* 部门ID
*/
private Long deptId;
/**
* 用户唯一标识
*/
private String token;
/**
* 登录时间
*/
private Long loginTime;
/**
* 过期时间
*/
private Long expireTime;
/**
* 登录IP地址
*/
private String ipaddr;
/**
* 登录地点
*/
private String loginLocation;
/**
* 浏览器类型
*/
private String browser;
/**
* 操作系统
*/
private String os;
/**
* 权限列表
*/
private Set<String> permissions;
/**
* 用户信息
*/
private SysUser user;
public LoginUser()
{
}
public LoginUser(SysUser user, Set<String> permissions)
{
this.user = user;
this.permissions = permissions;
}
public LoginUser(Long userId, Long deptId, SysUser user, Set<String> permissions)
{
this.userId = userId;
this.deptId = deptId;
this.user = user;
this.permissions = permissions;
}
@JSONField(serialize = false)
@Override
public String getPassword()
{
return user.getPassword();
}
@Override
public String getUsername()
{
return user.getUserName();
}
/**
* 账户是否未过期,过期无法验证
*/
@JSONField(serialize = false)
@Override
public boolean isAccountNonExpired()
{
return true;
}
/**
* 指定用户是否解锁,锁定的用户无法进行身份验证
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isAccountNonLocked()
{
return true;
}
/**
* 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isCredentialsNonExpired()
{
return true;
}
/**
* 是否可用 ,禁用的用户不能身份验证
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isEnabled()
{
return true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities()
{
return null;
}
}
Tips:注意下面@Override的getUsername等方法,在Security框架中,使用UserDetails实现类对象的时候,是根据这个来获取对象的相关信息的,所以要
return user.getUserName();这样把相关的信息穿进去,框架获取的全是null
JWT认证过滤器
/**
* token过滤器 验证token有效性
* @param chain 过滤器链
* @author system
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
//从request中获取Token
LoginUser loginUser = tokenService.getLoginUser(request);
//如果有Token进行处理
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
{
//重新刷新Redis中Token有限期
tokenService.verifyToken(loginUser);
//把User信息封装到authenticationToken对象中,以备后面Security的过滤器使用
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
//如果没有Tonek直接放行,去走后面的过滤器链
chain.doFilter(request, response);
}
}
Tips
- 这是一个过滤器,在之前的JavaWab中我们是去实现一个Filter接口的,但是那个有点问题(在不同Servelet版本中,有时候会一个请求过来过滤器会被调用多次),现在使用Spring为我们提供的Fielter实现类OncePerRequestFilter就行了。
- 用户的数据已经被放置在SecurityContextHolder中,后续过滤器就可以获取到用户信息了。同时在其他的功能中也可以在SecurityContextHolder中拿去用户信息了。
退出处理类(没啥说的,获取Token,删掉Redis中的数据)
/**
* 自定义退出处理类 返回成功
*
* @author system
*/
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{
@Autowired
private TokenService tokenService;
/**
* 退出处理
*
* @return
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException
{
//解析Token中的信息
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser))
{
String userName = loginUser.getUsername();
// 删除Redis中的用户缓存记录
tokenService.delLoginUser(loginUser.getToken());
}
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success("退出成功")));
}
}
权限方法
/**
* RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
*
* @author system
*/
@Service("ss")
public class PermissionService
{
/** 所有权限标识 */
private static final String ALL_PERMISSION = "*:*:*";
/** 管理员角色权限标识 */
private static final String SUPER_ADMIN = "admin";
private static final String ROLE_DELIMETER = ",";
private static final String PERMISSION_DELIMETER = ",";
/**
* 验证用户是否具备某权限
*
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
public boolean hasPermi(String permission)
{
if (StringUtils.isEmpty(permission))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
{
return false;
}
PermissionContextHolder.setContext(permission);
return hasPermissions(loginUser.getPermissions(), permission);
}
/**
* 验证用户是否不具备某权限,与 hasPermi逻辑相反
*
* @param permission 权限字符串
* @return 用户是否不具备某权限
*/
public boolean lacksPermi(String permission)
{
return hasPermi(permission) != true;
}
/**
* 验证用户是否具有以下任意一个权限
*
* @param permissions 以 PERMISSION_DELIMETER 为分隔符的权限列表
* @return 用户是否具有以下任意一个权限
*/
public boolean hasAnyPermi(String permissions)
{
if (StringUtils.isEmpty(permissions))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
{
return false;
}
PermissionContextHolder.setContext(permissions);
Set<String> authorities = loginUser.getPermissions();
for (String permission : permissions.split(PERMISSION_DELIMETER))
{
if (permission != null && hasPermissions(authorities, permission))
{
return true;
}
}
return false;
}
/**
* 判断用户是否拥有某个角色
*
* @param role 角色字符串
* @return 用户是否具备某角色
*/
public boolean hasRole(String role)
{
if (StringUtils.isEmpty(role))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
{
return false;
}
for (SysRole sysRole : loginUser.getUser().getRoles())
{
String roleKey = sysRole.getRoleKey();
if (SUPER_ADMIN.equals(roleKey) || roleKey.equals(StringUtils.trim(role)))
{
return true;
}
}
return false;
}
/**
* 验证用户是否不具备某角色,与 isRole逻辑相反。
*
* @param role 角色名称
* @return 用户是否不具备某角色
*/
public boolean lacksRole(String role)
{
return hasRole(role) != true;
}
/**
* 验证用户是否具有以下任意一个角色
*
* @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表
* @return 用户是否具有以下任意一个角色
*/
public boolean hasAnyRoles(String roles)
{
if (StringUtils.isEmpty(roles))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
{
return false;
}
for (String role : roles.split(ROLE_DELIMETER))
{
if (hasRole(role))
{
return true;
}
}
return false;
}
/**
* 判断是否包含权限
*
* @param permissions 权限列表
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
private boolean hasPermissions(Set<String> permissions, String permission)
{
return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}
}
这里若依直接把权限相关内容封装到了RequestContextHolder里面
权限使用示例
/**
* 获取菜单列表
*/
@PreAuthorize("@ss.hasPermi('system:menu:list')")
@GetMapping("/list")
public AjaxResult list(SysMenu menu)
{
List<SysMenu> menus = menuService.selectMenuList(menu, getUserId());
return success(menus);
}