安全管理 | 前后端方案详解:Vue/SpringBoot+SpringSecurity+JWT
背景
前后端分离项目,直接暴露api接口,是很危险的一件事情,因此需要引入安全管理框架。在安全管理这个领域,我们熟知的框架有Shiro、Spring Security,考虑到后端使用Spring Boot,并且它Spring Security提供了自动化配置方案,可以零配置使用,因此选择Spring Security。
当然Shiro与SSM(Spring+SpringMVC+MyBatis)更搭配。
一、安全管理SpringSecurity
1、概述
Spring Security是一个功能强大且高度可定制的身份验证和访问控制框架,致力于为Java应用程序提供身份验证和授权。具有如下特点:
1. 全面和可扩展的身份验证和授权支持 2. 防御会话固定,点击劫持,跨站点请求伪造等攻击 3. Servlet API集成 4. 与Spring Web MVC的可选集成
2、执行路程

通过流程图,可以认为SpringSecurity是一组filter过滤器组成的权限认证。
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。
org.springframework.boot
spring-boot-starter-security
4.2 创建SpringSecurity自定义配置类
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());
}
}
@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)));
}
}
@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 list = roleService.selectRoleList(role);
ExcelUtil util = new ExcelUtil(SysRole.class);
return util.exportExcel(list, "角色数据");
}
5.3 自定义用户认证逻辑
通过SpringSecurity集成JWT的方式来实现,4.2节配置文件已声明,下面一起看看具体实现方式。
二、用户身份验证JWT
1、概述
JWT是JSON Web Token的简称,是一个开放标准(RFC 7519),用于作为JSON对象在各方之间安全的传输信息。该信息可以被验证和信任,属于数字签名,存在于客户端。
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 claims = new HashMap<>();
claims.put("user_key", token);
return createToken(claims);
}
// 用数据声明创建token
private String createToken(Map 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的指令来实现。
// 声明权限指令 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指令钩子函数:
Vue.directive('my-directive', {
// 只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
bind: function () {},
// 被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
inserted: function () {},
// 所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。
// 指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新
update: function () {},
// 指令所在组件的 VNode 及其子 VNode 全部更新后调用。
componentUpdated: function () {},
// 只调用一次,指令与元素解绑时调用
unbind: function () {}
})
四、总结
至此本文梳理了前后端安全管理实现,涉及SpringSecurity、JWT以及axios封装请求、Vue指令,能够较完备的满足前后端分离开发的用户身份认证、UI权限控制、接口权限控制等需求。当然权限通常是根据角色挂靠,关于角色的控制,与权限同理,便不再叙述。