SpringBoot+Vue.js项目中使用Spring Security+JWT实现登录权限控制

目标和需求

我们的目的是在 SpringBoot+Vue.js 这样的前后端分离的项目中增加一个登录权限控制模块。这个模块的需求如下:

  1. 包含多个角色,每个角色所具有的权限不同(可以看到不同的菜单和页面)
  2. 对整个系统做登录控制,即未登录强制跳转到登录页面
  3. 登出功能

实现方案:
登录控制使用 Spring Security 框架,登录控制使用 JWT 实现,登录成功后使用 JWT 生成 token 返回前端,前端所有请求都在 cookie 中带上 token 到后端校验。
在这里插入图片描述

Spring Security原理

概述

Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。

框架原理

Spring Security 中主要通过 Filter 拦截 http 请求实现权限控制。

以下为框架自带主要过滤器:

  1. WebAsyncManagerIntegrationFilter
  2. SecurityContextPersistenceFilter
  3. HeaderWriterFilter
  4. CorsFilter
  5. LogoutFilter
  6. RequestCacheAwareFilter
  7. SecurityContextHolderAwareRequestFilter
  8. AnonymousAuthenticationFilter
  9. SessionManagementFilter
  10. ExceptionTranslationFilter
  11. FilterSecurityInterceptor
  12. UsernamePasswordAuthenticationFilter
  13. BasicAuthenticationFilter

框架核心组件如下:

  1. SecurityContextHolder:提供对SecurityContext的访问
  2. SecurityContext:持有Authentication对象和其他可能需要的信息
  3. AuthenticationManager 其中可以包含多个AuthenticationProvider
  4. ProviderManager对象为AuthenticationManager接口的实现类
  5. AuthenticationProvider:主要用来进行认证操作的类 调用其中的authenticate()方法去进行认证操作
  6. Authentication:Spring Security方式的认证主体
  7. GrantedAuthority:对认证主题的应用层面的授权,含当前用户的权限信息,通常使用角色表示
  8. UserDetails:构建Authentication对象必须的信息,可以自定义,可能需要访问DB得到
  9. UserDetailsService:通过username构建UserDetails对象,通过loadUserByUsername根据userName获取UserDetail对象

根据业务需求,这里我们继承UsernamePasswordAuthenticationFilter自定义过滤器作为后端拦截登录请求的过滤器。

由于Spring Security没有自带解析 token 的过滤器,因此我们需要自己实现对 JWT 适配的鉴权过滤器,通过继承 BasicAuthenticationFilter实现自定义鉴权过滤器。

自定义安全配置

通过实现WebSecurityConfigurerAdapter中的configure(HttpSecurity http),可以自定义安全配置,比如装配自定义过滤器,设置除部分请求外均需要鉴权,设置一些回调Handler等。

JWT原理

JSON Web Token(JWT)是目前最流行的跨域身份验证解决方案。

其原理是将用户信息的JSON字符串加密生成唯一的token返回给前端,后端通过解析token来验证是否已登录。

模块实现

数据库表设计

一共涉及四个表,分别为用户表,角色表,菜单表以及用户角色关联表。

其中角色与菜单一一对应。

前端实现

前端使用Vue.js+Element-UI实现,对于菜单的存储,使用了Element-UI中的树形组件产生的value值,实际为一个JSON字符串,前端通过获取该字符串,解析得到菜单信息,显示具体的菜单按钮。

token使用cookie保存,浏览器会自动将cookie携带在每一个请求中。

toFirstPage(level) {//根据角色level选择进入默认页面
    if(level === '999'){
        this.$router.push({ path: "/enterpriseManage" });
    }else if(level === '800'){
        this.$router.push({ path: "/enterpriseAccount" });
    }else{
        this.$router.push({ path: "/extAccount" });
    }
    
},
async checkLogin(loginUser){
    try {
        let username = this.loginUser.username;
        var res = await checkUsernameAndPassword({
            username: username,
            password: this.loginUser.password,
        });
        if(res.resultCode == '0'){
            window.name =  this.$moment(new Date()).format("YYYY-MM-DD HH:mm:ss");
            //全局缓存登录的用户信息
            this.$store.baseStore.commit('setUserInfo', res.result);
            this.$store.baseStore.commit('setWindowname',  window.name);
            if(res.result.managerLevel ==='999'){
                this.$store.baseStore.dispatch('getAllDeptId');
            }
            this.toFirstPage(res.result.managerLevel);
        } else {
            this.$message({
                type: 'error',
                message: res.result
            });
        }
    } catch (error) {
        this.$message({
            type: 'error',
            message: error
        });
    }
},

上面的代码是前端发送登录请求的方法,具体请求路径保存在checkUsernameAndPassword变量中,登录通过后,通过调用baseStore中的方法存储登录信息,并且根据角色跳转到对应的路由中。

baseStore中的部分方法如下,

Vue.use(Vuex);
export const baseStore = new Vuex.Store({
    //全局缓存
    state: {
      userInfo:{},
    },
    mutations: {
        setUserInfo(state, payload) {
            //保存到浏览器缓存
            window.localStorage.setItem("userInfo", JSON.stringify(payload));
            state.userInfo = payload;
        },
    },
    getters: {
        getUserInfo: state => {
            if(state.userInfo == undefined || state.userInfo.username == undefined){
                var userInfo = window.localStorage.getItem("userInfo");
                if(userInfo == undefined){
                    return new Object();
                }else{
                    state.userInfo=JSON.parse(userInfo);
                    return state.userInfo;
                }
            }else{
                return state.userInfo
            }
        },
    },
});

在登录成功进入到默认页面之前,我们需要解析登录信息,得到菜单信息以显示特定的菜单。

menu.vue

created() {
    // 得到登录信息
    let userInfo = this.$store.baseStore.getters.getUserInfo;
    //从菜单信息中得到一级菜单信息
    let menu = userInfo.menu.firstmenuset;
    //逐个查找菜单项是否在firstmenuset中
    if(menu.indexOf("账号详情") != -1){
        this.$data.items.push({ topage: '/extAccount', userName: 'iconfont ucc-shouye2 flew-left-menuicon', text: '账号详情', modelName: 'extAccount', });
    }
},

除了登录成功之后的操作外,登录失败则跳转到登录页面,前面代码中我们的登录请求是这样发送的:

login.vue

import {
    checkUsernameAndPassword,
}from '@/api/getData';

const res = await checkUsernameAndPassword({
            username: username,
            password: this.loginUser.password,
        });

checkUsernameAndPassword 是从getData.js中导入的

getData.js

import fetch from '@/config/fetch'
export const checkUsernameAndPassword = (args) => fetch('/login', args, 'POST');

这里的fetch方法实际上是所有请求发送的方法,我们可以在这里首先解析后端返回码,若为未登录,则跳转到登录页面:

import { baseUrl } from './env'
import router from '../router'
export default async(url = '', data = {}, type = 'GET', method = 'fetch') => {

    //省略其他代码
    ......
    let requestConfig = {
        credentials: 'include',
        method: type,
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8'
        },
        mode: "cors",
        cache: "no-cache"
    }
    
    if (type == 'POST') {
        requestConfig.body=dataStr;
    }
    try {
        const response = await fetch(url, requestConfig);
        const responseJson = await response.json();
        if(responseJson.resultCode == "00015"){
            router.push("login");
        }else{
        return responseJson;
        }
    } catch (error) {
        throw new Error(error);
    }
    ......

}
后端实现

后端我们需要实现:

  1. 登录过滤器JWTLoginFilter
  2. 请求鉴权器JWTAuthenticationFilter
  3. 登录认证器CustomAuthenticationProvider
  4. 自定义配置SecurityConfig
  5. 鉴权失败Handler:GoAuthenticationEntryPoint
  6. 登录成功Handler:GoAuthenticationSuccessHandler

首先要准备基础设施,相关的Bean,验证账号密码的Service以及token的生成等。需要注意的是,用户信息Bean必须继承Spring Security中的UserDetails。Service需要实现UserDetailsService中的loadUserByUsername(String userName)方法。

这里只贴token的生成:

public class JWTTokenUtil {
	public static final String SECRET = "spring security Jwt Secret";
	public static final String BEARER = "Bearer:";
	public static final String AUTHORIZATION = "Authorization";
	
	public static String getToken(JSONObject user) {
		return Jwts.builder()
				.setSubject(JSONObject.toJSONString(user))
				.setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 24 * 1000))
				.signWith(SignatureAlgorithm.HS512, SECRET).compact();
	}

}

登录过滤器,继承Spring Security自带的用户名密码过滤器,实现对登录请求的拦截和转发。

public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {

	private AuthenticationManager authenticationManager;

	public JWTLoginFilter(AuthenticationManager authenticationManager) {
		this.authenticationManager = authenticationManager;
	}

	// 接收并解析用户凭证
	@Override
	public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
			throws AuthenticationException {
		User user = new User();
		user.setUsername(req.getParameter("username").trim());
		user.setPassword(req.getParameter("password"));
		return authenticationManager
				.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
	}

}

请求鉴权器,拦截所有请求,从cookie中查询token信息,并进行解析鉴定。

public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
	public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
		super(authenticationManager);
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		Cookie [] cookies = request.getCookies();
		String authorization = "";
		if(cookies != null){
			for(Cookie cookie : cookies){
				if(JWTTokenUtil.AUTHORIZATION.equals(cookie.getName())){
					authorization = cookie.getValue();
				}
			}
		}
		if ("".equals(authorization)) {
			chain.doFilter(request, response);
			return;
		}
		
		UsernamePasswordAuthenticationToken authentication = getAuthentication(authorization);

		if(authentication == null){
			chain.doFilter(request, response);
			return;
		}
		//保存到spring security上下文中
		SecurityContextHolder.getContext().setAuthentication(authentication);
		chain.doFilter(request, response);

	}

	private UsernamePasswordAuthenticationToken getAuthentication(String authorization) {
			// parse the token.
		try {
			String userJson = Jwts.parser().setSigningKey(JWTTokenUtil.SECRET)
					.parseClaimsJws(authorization.replace(JWTTokenUtil.BEARER, "")).getBody().getSubject();
			User user = JSONObject.parseObject(userJson, User.class);
			if (user != null) {
				return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
			}
		} catch (MalformedJwtException | ExpiredJwtException e) {
			//token解析失败,token过期
			return null;
		}
		
		return null;
	}
}

登录认证器,负责将登录过滤器拦截得到的登录账号密码进行验证。

public class CustomAuthenticationProvider implements AuthenticationProvider {

	private final UserDetailsService service;  
    public CustomAuthenticationProvider(UserDetailsService userDetailsService) {  
        this.service = userDetailsService;
    }  
	
    /**
     * 验证类
     */
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
		String username = token.getName();
		User userDetails = null;  
        if(username != null) {  
            //调用相应service从数据库获取对应用户信息
            userDetails = (User) service.loadUserByUsername(username);  
        } 
        
        if(userDetails == null) {  
            throw new UsernameNotFoundException("用户名或密码无效");  
        }else if (!userDetails.isEnabled()){  
            throw new DisabledException("用户已被禁用");  
        }else if (!userDetails.isAccountNonExpired()) {  
            throw new AccountExpiredException("账号已过期");
        }else if (!userDetails.isAccountNonLocked()) {  
            throw new LockedException("账号已被锁定");  
        }else if (!userDetails.isCredentialsNonExpired()) {  
            throw new LockedException("凭证已过期");  
        }
        
        String password = userDetails.getPassword();
        if(!password.equals(Md5.encodeByMD5((String)token.getCredentials()))) {  
            throw new BadCredentialsException("用户名/密码无效");  
        } 
		return new UsernamePasswordAuthenticationToken(userDetails, password,userDetails.getAuthorities());
	}

	@Override
	public boolean supports(Class<?> authentication) {
		return UsernamePasswordAuthenticationToken.class.equals(authentication);
	}

}

登录成功和失败的回调处理器,可以在这两个处理器中实现cookie的存储,失败状态码的返回等

public class GoAuthenticationEntryPoint implements AuthenticationEntryPoint {
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
			throws IOException, ServletException {

        Result res = null;
        response.setCharacterEncoding("UTF-8");
        if(exception instanceof InsufficientAuthenticationException){
            res = new Result("00015",Result.FAILURE,exception.getMessage());
        }else{
            res = new Result("00010",Result.FAILURE,"未知权限错误");
        }
        response.setContentType("application/json; charset=utf-8");
        PrintWriter out = null;
        try {
            out = response.getWriter();
            out.write(JSONObject.toJSONString(res));
        } catch (IOException e) {
             e.printStackTrace();
        } finally {
            if (out != null) {
                out.close();
            }
        }
	}

}
public class GoAuthenticationSuccessHandler implements AuthenticationSuccessHandler  {

	
	@Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException {
        User user = (User)authentication.getPrincipal();
        //返回前端的token
        JSONObject userToken = new JSONObject();
        userToken.put("username", user.getUsername());
        cookie.setPath("/ucc");
        
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        response.addCookie(cookie);
        response.addCookie(new Cookie(JWTTokenUtil.AUTHORIZATION, JWTTokenUtil.BEARER + JWTTokenUtil.getToken(userToken)));

        Result result = new Result("0",Result.SUCCESS,user);
        PrintWriter out = null;
        try {
            out = response.getWriter();
            out.write(JSONObject.toJSONString(result));
        } catch (IOException e) {
             e.printStackTrace();
        } finally {
            if (out != null) {
                out.close();
            }
        }
    }

}

最后是Spring Security的自定义配置类,将配置一些权限管理的规则以及整合上面各项功能类。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class securityConfig extends WebSecurityConfigurerAdapter {

	@Autowired
	UserDetailsService service;

	@Bean  
    public AuthenticationProvider authenticationProvider(){  
        AuthenticationProvider authenticationProvider=new CustomAuthenticationProvider(service);  
        return authenticationProvider;
    }

	/**
	 * 验证用户权限的方法
	 */
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		 auth.authenticationProvider(authenticationProvider()); 
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		JWTLoginFilter jwtLoginFilter= new JWTLoginFilter(authenticationManager());
		JWTAuthenticationFilter authenticationFilter = new JWTAuthenticationFilter(authenticationManager());
		//设置回调Handler
		jwtLoginFilter.setAuthenticationSuccessHandler(new GoAuthenticationSuccessHandler());
		
		http.cors().and().csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
            .exceptionHandling()  //异常处理
            .authenticationEntryPoint(new GoAuthenticationEntryPoint())
        .and().authorizeRequests()
			.antMatchers().permitAll()//可以设置不需要认证的请求
			.anyRequest().authenticated()
        .and()
        	.addFilter(jwtLoginFilter)
        	.addFilter(authenticationFilter)
        	.logout() //拦截登出
            .logoutUrl("/logout")//登出URL
            .logoutSuccessHandler(new GoLogoutSuccessHandler()) //登出成功回调函数
            .invalidateHttpSession(true)
            .deleteCookies(JWTTokenUtil.AUTHORIZATION);
	}

}
  • 5
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值