写点什么

安全管理 | 前后端方案详解:Vue/SpringBoot+SpringSecurity+JWT

用户头像
梁龙先森
关注
发布于: 2020 年 12 月 18 日
安全管理 | 前后端方案详解:Vue/SpringBoot+SpringSecurity+JWT

背景

前后端分离项目,直接暴露api接口,是很危险的一件事情,因此需要引入安全管理框架。在安全管理这个领域,我们熟知的框架有Shiro、Spring Security,考虑到后端使用Spring Boot,并且它Spring Security提供了自动化配置方案,可以零配置使用,因此选择Spring Security。 

当然Shiro与SSM(Spring+SpringMVC+MyBatis)更搭配。



文章篇幅较长,建议在PC上浏览,且其左侧有【目录】,便于快速预览大纲以及定位!

一、安全管理SpringSecurity

1、概述

Spring Security是一个功能强大且高度可定制的身份验证和访问控制框架,致力于为Java应用程序提供身份验证和授权。具有如下特点:

1. 全面和可扩展的身份验证和授权支持 2. 防御会话固定,点击劫持,跨站点请求伪造等攻击 3. Servlet API集成 4. 与Spring Web MVC的可选集成

2、执行路程



通过流程图,可以认为SpringSecurity是一组filter过滤器组成的权限认证。



流程图说明:

WebAsyncManagerIntegrationFilter:

将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。

SecurityContextPersistenceFilter:

在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将 SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将 SecurityContextHolder 中的信息清除,例如在Session中维护一个用户的安全信息就是这个过滤器处理的。

HeaderWriterFilter:用于将头信息加入响应中。

CsrfFilter:用于处理跨站请求伪造。

LogoutFilter:用于处理退出登录。

UsernamePasswordAuthenticationFilter:用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。

DefaultLoginPageGeneratingFilter:如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。

BasicAuthenticationFilter:检测和处理 http basic 认证。

RequestCacheAwareFilter:用来处理请求的缓存。

SecurityContextHolderAwareRequestFilter:主要是包装请求对象request。

AnonymousAuthenticationFilter:检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在为其提供一个匿名 Authentication。

SessionManagementFilter:管理 session 的过滤器

ExceptionTranslationFilter:处理 AccessDeniedException 和 AuthenticationException 异常。

FilterSecurityInterceptor:可以看做过滤器链的出口。

RememberMeAuthenticationFilter:当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。

3、注解

这里讲解下方法级安全的几个注解,@PreAuthorize, @PostAuthorize, @Secured。

3.1 开启注解
// prePostEnabled:开启@PreAuthorize, @PostAuthorize
// securedEnabled:开启@Secured
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
}
3.2 @Secured
@GetMapping("/hello")
@Secured({"ROLE_normal","ROLE_admin"})
public String hello() {
return "hello world";
}

说明:拥有normal或者admin角色的用户都可以访问hello方法,但若要求同时拥有则,则@Secured无能为力。

3.3 @PreAuthorize
@GetMapping("/hello")
// 拥有normal或者admin角色的用户都可以方法helloUser()方法。
@PreAuthorize("hasAnyRole('normal','admin')")
// 同时拥有normal、admin两个角色可以访问
// @PreAuthorize("hasRole('normal') AND hasRole('admin')")
public String hello() {
return "hello world";
}
3.4 @PostAuthorize

在方法执行后再进行权限校验,适合验证带有返回值的权限。

@GetMapping("/hello")
@PostAuthorize(" returnObject!=null && returnObject.username == authentication.name")
public User hello() {
Object pricipal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User user;
if("anonymousUser".equals(pricipal)) {
user = null;
}else {
user = (User) pricipal;
}
return user;
}

4、自定义配置

Spring Boot为Spring Security提供的可零配置的自动化配置方案本文不做介绍。

4.1 添加依赖

环境:采用Spring Initializr快速构建的Spring Boot。

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
4.2 创建SpringSecurity自定义配置类
  1. 自定义配置

EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter
{
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;
/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
/**
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/captchaImage").anonymous()
.antMatchers(
HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js"
).permitAll()
.antMatchers("/profile/**").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
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);
}
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()
{
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
  1. 认证失败处理类,返回未授权

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable
{
private static final long serialVersionUID = -8970718410437077606L;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException
{
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
}
  1. 登出处理类,返回成功

@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{
@Autowired
private TokenService tokenService;
/**
* 退出处理
*
* @return
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException
{
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser))
{
String userName = loginUser.getUsername();
// 删除用户缓存记录
tokenService.delLoginUser(loginUser.getToken());
// 记录用户退出日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, "退出成功"));
}
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.SUCCESS, "退出成功")));
}
}

5、自定义请求权限校验

5.1 自定义权限服务
@Service("ss")
public class PermissionService{
public boolean hasPermi(String permission){
if (StringUtils.isEmpty(permission)){
return false;
}
// 通过请求头的token字段,去获取登录用户信息,token生成下面介绍。
LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())){
return false;
}
return hasPermissions(loginUser.getPermissions(), permission);
}
.... // 其他代码省略
}



public class ServletUtils{
public static HttpServletRequest getRequest(){
return getRequestAttributes().getRequest();
}
public static ServletRequestAttributes getRequestAttributes(){
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return (ServletRequestAttributes) attributes;
}
... // 其他代码省略
}
5.2 请求添加注解
@Log(title = "角色管理", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('system:role:export')")
@GetMapping("/export")
public AjaxResult export(SysRole role)
{
List<SysRole> list = roleService.selectRoleList(role);
ExcelUtil<SysRole> util = new ExcelUtil<SysRole>(SysRole.class);
return util.exportExcel(list, "角色数据");
}
5.3 自定义用户认证逻辑

通过SpringSecurity集成JWT的方式来实现,4.2节配置文件已声明,下面一起看看具体实现方式。

二、用户身份验证JWT

1、概述

JWT是JSON Web Token的简称,是一个开放标准(RFC 7519),用于作为JSON对象在各方之间安全的传输信息。该信息可以被验证和信任,属于数字签名,存在于客户端。



JWT广泛应用于Authorization(授权)和Informatica Exchange(信息交换)。一方面是因为它开销小,并且可以轻松跨域使用;另一方面是JWT可以被签名,如使用公钥/秘钥对,确定发送方,并且签名是使用头和有效负载计算,还可以验证内容有无被篡改。

2、JWT结构

JWT是由Header、Payload、Signature三部分组成,直接用圆点(".")连接。典型的JWT看起来像下面这个样式:

aaa.bbb.ccc


其中Header和Payload可以通过base64解密出来,因此通常不能在Payload中放置敏感的信息。



Header的构成:由token类型和算法名称组成

{
'alg': "HS256", // 算法名称,如:HMAC/SHA256/RSA等
'typ': "JWT" // token的类型
}



Payload的构成:包含声明,通常是关于实体(用户)和其他数据的声明,base64对这个JSON编码就得到了Payload。

{
user_key:'ada_xbasf_weet',
time:'12394395'
}



Signature的构成:需由经过编码的header、payload和一个秘钥,经过header指定的签名算法进行签名而生成。例如:

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

签名用于消息传递中有无被篡改,并且使用私钥签名的token,还可以验证JWT的发送方。

3、 JWT认证流程

4、JWT生成

用户登录成功后,我们这里将用户信息写入redies,并使用uuid随机数作为key,配置过期时间等数据。 同时使用uuid,作为JWT的声明,创建token返回前端,代码仅供流程参考:

@Component
public class TokenService{
// 用户登录后调用的创建token方法
public String createToken(LoginUser loginUser)
{
// 生成随机uuid
String token = IdUtils.fastUUID();
loginUser.setToken(token);
// 设置用户代理信息
setUserAgent(loginUser);
// 刷新token有效期,登录信息写redies
refreshToken(loginUser);
// 建立数据声明(Preload),创建token
Map<String, Object> claims = new HashMap<>();
claims.put("user_key", token);
return createToken(claims);
}
// 用数据声明创建token
private String createToken(Map<String, Object> claims)
{
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
// 从令牌中获取数据声明
private Claims parseToken(String token)
{
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
... // 其他代码省略
}

5、集成JWT

我们在SpringSecurity中已经添加了Jwt过滤器,用于验证token的有效性,下面看看实现:

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
// 通过令牌获取登录用户信息
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
{
// 验证令牌有效期,低于10分钟过期,则刷新
tokenService.verifyToken(loginUser);
// usernamePasswordAuthenticationtoken:对用户名和密码约定进行了一定的封装,将username复制到了principal,而将password赋值到了credentials.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
//WebAuthenticationDetailsSource: 提供登录请求的用户的信息
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 重新设置当前的用户信息
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}

至此我们简单梳理实现了,登录令牌(token)的生成,以及SpringSecurity集成JWT对token的验证。

三、前端认证权限控制

1、axios封装请求携带token

import axios from 'axios'
import store from '@/store'
import { getToken } from '@/utils/auth'
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
// axios中请求配置有baseURL选项,表示请求URL公共部分
baseURL: process.env.VUE_APP_BASE_API,
// 超时
timeout: 30 * 1000
})
// request拦截器
service.interceptors.request.use(config => {
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false
if (getToken() && !isToken) {
config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
}
return config
}, error => {
console.log(error)
Promise.reject(error)
})
// 其他代码省略
...



// @/utils/auth 文件
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}

2、页面模块权限控制

页面模块根据用户权限来展示,这里可以使用Vue的指令来实现。



  1. 页面UI声明权限

<template>
<div v-hasPermi="['system:user:admin']">
<button>管理员权限才可以点击</button>
</div>
</template>



  1. 创建全局指令

// 声明权限指令 hasPermis.js
//操作权限处理
import store from '@/store'
export default {
inserted(el, binding, vnode) {
const { value } = binding
const all_permission = '*:*:*'
// 权限信息存储在vuex的store
const permissions = store.getters && store.getters.permissions
if (value && value instanceof Array && value.length > 0) {
const permissionFlag = value
// 判断是否存在权限
const hasPermissions = permissions.some(permission => {
return all_permission === permission || permissionFlag.includes(permission)
})
// 不存在权限,则删除元素
if (!hasPermissions) {
el.parentNode && el.parentNode.removeChild(el)
}
} else {
throw new Error(`请设置操作权限标签值`)
}
}
}



// 注册指令
import hasPermis from './hasPermis.js'
const install = function(Vue) {
Vue.directive('hasPermi', hasPermi)
}
if (window.Vue) {
window['hasPermi'] = hasPermi
Vue.use(install); // eslint-disable-line
}
export default install



3、Vue指令讲解



Vue指令原理:

常用于对Dom的底层进行操作。

指令本质上是装饰器,是vue对HTML元素的扩展,给HTML元素增加自定义功能,语义化HTML标签。

vue编译DOM时,会执行与指令关联的JS代码,即找到指令对象,执行指令对象的相关方法。



Vue指令钩子函数:

Vue.directive('my-directive', {
// 只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
bind: function () {},
// 被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
inserted: function () {},
// 所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。
// 指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新
update: function () {},
// 指令所在组件的 VNode 及其子 VNode 全部更新后调用。
componentUpdated: function () {},
// 只调用一次,指令与元素解绑时调用
unbind: function () {}
})

四、总结

至此本文梳理了前后端安全管理实现,涉及SpringSecurity、JWT以及axios封装请求、Vue指令,能够较完备的满足前后端分离开发的用户身份认证、UI权限控制、接口权限控制等需求。当然权限通常是根据角色挂靠,关于角色的控制,与权限同理,便不再叙述。



发布于: 2020 年 12 月 18 日阅读数: 48
用户头像

梁龙先森

关注

寒江孤影,江湖故人,相逢何必曾相识。 2018.03.17 加入

1月的计划是:重学JS,点个关注,一起学习。

评论

发布
暂无评论
安全管理 | 前后端方案详解:Vue/SpringBoot+SpringSecurity+JWT