项目环境
本篇文章环境:Spring Boot + Mybatis + Spring Security + Redis + JWT
预期成果:实现具有验证码校验、RBAC 权限控制的前后端分离项目
注意:为什么要使用 Json Web Token(JWT)?这是因为前后端分离项目中,前端与后端之间的通信采用 RESTFul API 的交互方式进行交互。这种前后端分离的交互是无状态的交互方式,所以,每次交互都必须进行身份验证。而传统方案是根据用户信息生成token,将token 存入浏览器 cookie(或存入数据库),之后每次请求都会带上这个 cookie,由后端根据这个 cookie 来查询用户并验证是否过期。这种方案存在很多的问题,由于 cookie 是可以被 JavaScript 读取的,这会导致用户 token 泄露。
数据库设计
Web 的安全控制一般分为两个部分,一个是认证,一个是授权。认证即判断是否为合法用户,简单的说就是登录。用户名和密码匹配成功即认证成功。授权是基于已认证的前提下,根据用户的不同权限,开放不同的资源(本文简单化处理,认为资源就是 API,实际的资源可能包括菜单、静态图片等)。一般 RBAC 权限控制有三层,即:用户
<–>角色
<–>权限
,用户与角色是多对多,角色和权限也是多对多,最后由权限控制资源(URL)的访问。本文为了便于处理,在数据库设计时对表内的字段进行了简化。
在这里我们先暂时不考虑权限,只考虑用户
<–>角色
<–>资源
。(源码被注释的部分中含有用户、角色、权限三层控制)
认证管理
在这一步中,我们需要自定义UserDetailsService
,将用户信息和权限注入进来,为后面的授权做准备。
在实现UserDetailsService
之后,需要重写loadUserByUsername
方法,参数是用户输入的用户名。返回值是UserDetails
,这是一个接口,一般使用它的子类org.springframework.security.core.userdetails.User
,它有三个参数,分别是用户名、密码和权限集。(实际开发中,我们可以将实体类中的 User 继承org.springframework.security.core.userdetails.User
以满足更多需求)
并且实际应用中,为了减少对数据库的访问次数,我们通常会将权限集放入缓存中,下次可以直接从缓存中获取,可以有效提高效率。
UserDetailsService 实现类
packagecom.security.service;importcom.security.mapper.APIMapper;importcom.security.mapper.AuthoritiesMapper;importcom.security.mapper.RoleMapper;importcom.security.mapper.UserMapper;importcom.security.pojo.SysUser;importcom.security.utils.RedisUtils;importorg.apache.commons.lang3.StringUtils;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.security.core.GrantedAuthority;importorg.springframework.security.core.authority.AuthorityUtils;importorg.springframework.security.core.authority.SimpleGrantedAuthority;importorg.springframework.security.core.userdetails.User;importorg.springframework.security.core.userdetails.UserDetails;importorg.springframework.security.core.userdetails.UserDetailsService;importorg.springframework.security.core.userdetails.UsernameNotFoundException;importorg.springframework.security.crypto.password.PasswordEncoder;importorg.springframework.stereotype.Service;importjava.util.ArrayList;importjava.util.List;@Service("userDetailsService")publicclassUserDetailServiceImplimplementsUserDetailsService{@AutowiredprivatePasswordEncoder passwordEncoder;@AutowiredprivateUserMapper userMapper;@AutowiredprivateRoleMapper roleMapper;@AutowiredprivateAuthoritiesMapper authoritiesMapper;@AutowiredprivateRedisUtils redisUtils;//自定义的登录逻辑@OverridepublicUserDetailsloadUserByUsername(String username)throwsUsernameNotFoundException{SysUser user= userMapper.queryUserByUsername(username);//根据用户名去数据库进行查询,如不存在则抛出异常if(user==null){thrownewUsernameNotFoundException("用户不存在");}List<GrantedAuthority> authorities=newArrayList<>();//方法一:使用用户、角色、资源建立关系,直接使用角色控制权限List<String> codeList= roleMapper.queryUserRole(user.getUsername());//添加权限信息进入缓存
redisUtils.set(username,StringUtils.join(codeList,","),60*60);//方法二:添加权限(资源表),通过建立用户、角色、权限、资源之间的关系,使用"权限"实现按钮级别的权限控制// List<String> codeList = authoritiesMapper.queryAuthoritiesList(user.getUsername());
codeList.forEach(code->{SimpleGrantedAuthority simpleGrantedAuthority=newSimpleGrantedAuthority(code);
authorities.add(simpleGrantedAuthority);});returnnewUser(username, user.getPassword(), authorities);}}
部分 mapper 接口和具体实现
packagecom.security.mapper;importcom.security.pojo.Role;importorg.apache.ibatis.annotations.Mapper;importorg.apache.ibatis.annotations.Param;importorg.springframework.stereotype.Repository;importjava.util.List;@Mapper@RepositorypublicinterfaceRoleMapper{List<String>queryUserRole(@Param("username")String username);List<Role>selectListByUrl(String url);}
packagecom.security.mapper;importcom.security.pojo.SysUser;importorg.apache.ibatis.annotations.Mapper;importorg.apache.ibatis.annotations.Param;importorg.springframework.stereotype.Repository;@Mapper@RepositorypublicinterfaceUserMapper{SysUserqueryUserByUsername(@Param("username")String username);}
<?xml version="1.0" encoding="UTF-8" ?><!DOCTYPEmapperPUBLIC"-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mappernamespace="com.security.mapper.RoleMapper"><selectid="queryUserRole"resultType="String">
select
r.code
from
user_role ur
left join sys_user u on ur.user_id = u.id
left join sys_role r on ur.role_id = r.id
where
u.username = #{username}</select><selectid="selectListByUrl"resultType="Role">
SELECT
r.*
FROM
sys_role r
LEFT JOIN sys_role_api ra ON ra.role_id = r.id
LEFT JOIN sys_api sa ON sa.id = ra.api_id
WHERE sa.api_url = #{url}</select></mapper>
<?xml version="1.0" encoding="UTF-8" ?><!DOCTYPEmapperPUBLIC"-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mappernamespace="com.security.mapper.UserMapper"><selectid="queryUserByUsername"resultType="SysUser">
select * from sys_user where username = #{username}</select></mapper>
接着,创建一个配置类并继承自WebSecurityConfigurerAdapter
,并重写configure(AuthenticationManagerBuilder auth)
方法就可以完成简单的登录认证了。在这一步里,我们需要自定义两个处理器,分别是成功处理器和失败处理器,以及统一的前后端通信消息体格式。
成功处理器
packagecom.security.config;importcom.security.common.ResponseBody;importcom.security.utils.JwtUtils;importcom.security.utils.ResponseBodyUtil;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.security.core.Authentication;importorg.springframework.security.web.authentication.AuthenticationSuccessHandler;importorg.springframework.stereotype.Component;importjavax.servlet.ServletException;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;importjava.io.IOException;@ComponentpublicclassAjaxAuthenticationSuccessHandlerextendsJSONAuthenticationimplementsAuthenticationSuccessHandler{//JWT处理工具类@AutowiredprivateJwtUtils jwtUtils;@OverridepublicvoidonAuthenticationSuccess(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Authentication authentication)throwsIOException,ServletException{ResponseBody responseBody=ResponseBodyUtil.success();//生成jwt TokenString jwt= jwtUtils.generateToken(authentication.getName());
httpServletResponse.setHeader(jwtUtils.getHeader(), jwt);//继承封装的输出JSON格式类,并调用父类方法即可this.WriteJSON(httpServletRequest,httpServletResponse,responseBody);}}
失败处理器
packagecom.security.config;importcom.security.common.ResponseBody;importcom.security.common.ResponseCode;importcom.security.utils.ResponseBodyUtil;importorg.springframework.security.authentication.*;importorg.springframework.security.core.AuthenticationException;importorg.springframework.security.web.authentication.AuthenticationFailureHandler;importorg.springframework.stereotype.Component;importjavax.servlet.ServletException;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;importjava.io.IOException;@ComponentpublicclassAjaxAuthenticationFailureHandlerextendsJSONAuthenticationimplementsAuthenticationFailureHandler{@OverridepublicvoidonAuthenticationFailure(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,AuthenticationException e)throwsIOException,ServletException{ResponseBody responseBody=null;if(einstanceofAccountExpiredException){//账号过期
responseBody=ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_EXPIRED);}elseif(einstanceofBadCredentialsException){//密码错误
responseBody=ResponseBodyUtil.fail(ResponseCode.USER_CREDENTIALS_ERROR);}elseif(einstanceofCredentialsExpiredException){//密码过期
responseBody=ResponseBodyUtil.fail(ResponseCode.USER_CREDENTIALS_EXPIRED);}elseif(einstanceofDisabledException){//账号不可用
responseBody=ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_DISABLE);}elseif(einstanceofLockedException){//账号锁定
responseBody=ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_LOCKED);}elseif(einstanceofInternalAuthenticationServiceException){//用户不存在
responseBody=ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_NOT_EXIST);}else{//其他错误
responseBody=ResponseBodyUtil.fail(ResponseCode.COMMON_FAIL);}//继承封装的输出JSON格式类,并调用父类方法即可this.WriteJSON(httpServletRequest,httpServletResponse,responseBody);}}
封装的 JSONAuthentication 抽象类
该抽象类主要是对处理器内都需要实现的一些功能的一个封装
packagecom.security.config;importcom.alibaba.fastjson.JSON;importjavax.servlet.ServletException;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;importjava.io.IOException;importjava.io.PrintWriter;publicabstractclassJSONAuthentication{/**
* 输出JSON格式数据
* @param httpServletRequest
* @param httpServletResponse
* @param obj
* @throws IOException
* @throws ServletException
*/protectedvoidWriteJSON(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object obj)throwsIOException,ServletException{//设置编码格式
httpServletResponse.setContentType("text/json;charset=utf-8");//处理跨域问题
httpServletResponse.setHeader("Access-Control-Allow-Origin","*");
httpServletResponse.setHeader("Access-Control-Allow-Methods","POST, GET");//输出JSONPrintWriter out= httpServletResponse.getWriter();
out.write(JSON.toJSONString(obj));
out.flush();
out.close();}}
封装的消息体
packagecom.security.common;importlombok.Data;importlombok.Getter;importlombok.NoArgsConstructor;importlombok.Setter;importjava.io.Serializable;@Data@Getter@Setter@NoArgsConstructorpublicclassResponseBody<T>implementsSerializable{privateBoolean success;privateInteger statusCode;privateString msg;privateT data;publicResponseBody(boolean success){this.success= success;this.statusCode= success?ResponseCode.SUCCESS.getCode():ResponseCode.COMMON_FAIL.getCode();this.msg= success?ResponseCode.SUCCESS.getMessage():ResponseCode.COMMON_FAIL.getMessage();}publicResponseBody(boolean success,ResponseCode resultEnum){this.success= success;this.statusCode= success?ResponseCode.SUCCESS.getCode():(resultEnum==null?ResponseCode.COMMON_FAIL.getCode(): resultEnum.getCode());this.msg= success?ResponseCode.SUCCESS.getMessage():(resultEnum==null?ResponseCode.COMMON_FAIL.getMessage(): resultEnum.getMessage());}publicResponseBody(boolean success,T data){this.success= success;this.statusCode= success?ResponseCode.SUCCESS.getCode():ResponseCode.COMMON_FAIL.getCode();this.msg= success?ResponseCode.SUCCESS.getMessage():ResponseCode.COMMON_FAIL.getMessage();this.data= data;}publicResponseBody(boolean success,ResponseCode resultEnum,T data){this.success= success;this.statusCode= success?ResponseCode.SUCCESS.getCode():(resultEnum==null?ResponseCode.COMMON_FAIL.getCode(): resultEnum.getCode());this.msg= success