新手建设网站步骤视频营销
目标和需求
我们的目的是在 SpringBoot+Vue.js 这样的前后端分离的项目中增加一个登录权限控制模块。这个模块的需求如下:
- 包含多个角色,每个角色所具有的权限不同(可以看到不同的菜单和页面)
- 对整个系统做登录控制,即未登录强制跳转到登录页面
- 登出功能
实现方案:
登录控制使用 Spring Security 框架,登录控制使用 JWT 实现,登录成功后使用 JWT 生成 token 返回前端,前端所有请求都在 cookie 中带上 token 到后端校验。
Spring Security原理
概述
Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。
框架原理
Spring Security 中主要通过 Filter 拦截 http 请求实现权限控制。
以下为框架自带主要过滤器:
- WebAsyncManagerIntegrationFilter
- SecurityContextPersistenceFilter
- HeaderWriterFilter
- CorsFilter
- LogoutFilter
- RequestCacheAwareFilter
- SecurityContextHolderAwareRequestFilter
- AnonymousAuthenticationFilter
- SessionManagementFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
- UsernamePasswordAuthenticationFilter
- BasicAuthenticationFilter
框架核心组件如下:
- SecurityContextHolder:提供对SecurityContext的访问
- SecurityContext:持有Authentication对象和其他可能需要的信息
- AuthenticationManager 其中可以包含多个AuthenticationProvider
- ProviderManager对象为AuthenticationManager接口的实现类
- AuthenticationProvider:主要用来进行认证操作的类 调用其中的authenticate()方法去进行认证操作
- Authentication:Spring Security方式的认证主体
- GrantedAuthority:对认证主题的应用层面的授权,含当前用户的权限信息,通常使用角色表示
- UserDetails:构建Authentication对象必须的信息,可以自定义,可能需要访问DB得到
- 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.vuecreated() {// 得到登录信息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.vueimport {checkUsernameAndPassword,
}from '@/api/getData';const res = await checkUsernameAndPassword({username: username,password: this.loginUser.password,});
checkUsernameAndPassword 是从getData.js中导入的
getData.jsimport 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);}......}
后端实现
后端我们需要实现:
- 登录过滤器JWTLoginFilter
- 请求鉴权器JWTAuthenticationFilter
- 登录认证器CustomAuthenticationProvider
- 自定义配置SecurityConfig
- 鉴权失败Handler:GoAuthenticationEntryPoint
- 登录成功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;}// 接收并解析用户凭证@Overridepublic 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);}@Overrideprotected 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;} /*** 验证类*/@Overridepublic 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());}@Overridepublic boolean supports(Class<?> authentication) {return UsernamePasswordAuthenticationToken.class.equals(authentication);}}
登录成功和失败的回调处理器,可以在这两个处理器中实现cookie的存储,失败状态码的返回等
public class GoAuthenticationEntryPoint implements AuthenticationEntryPoint {@Overridepublic 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 {@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)throws IOException, ServletException {User user = (User)authentication.getPrincipal();//返回前端的tokenJSONObject 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 {@AutowiredUserDetailsService service;@Bean public AuthenticationProvider authenticationProvider(){ AuthenticationProvider authenticationProvider=new CustomAuthenticationProvider(service); return authenticationProvider;}/*** 验证用户权限的方法*/@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.authenticationProvider(authenticationProvider()); }@Overrideprotected void configure(HttpSecurity http) throws Exception {JWTLoginFilter jwtLoginFilter= new JWTLoginFilter(authenticationManager());JWTAuthenticationFilter authenticationFilter = new JWTAuthenticationFilter(authenticationManager());//设置回调HandlerjwtLoginFilter.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);}}