时隔多日,再次操刀,记录spring security学习之旅。
在此之前,推荐几部小说:完美世界、遮天、圣墟、剑来。
再推荐一首歌:少女的祈祷--杨千嬅
前言:关于spring security的基本原理、特性、与shiro的优缺点各位同学自行百度(笔者属实太懒!)。还有,demo所用技术为 spring boot + spring security + mybatis-plus。
demo地址:https://github.com/XGLLHZ/huangzi-frame
前台demo:https://github.com/XGLLHZ/react-web (基于 react.js)
用户认证:认证是指登录逻辑(用户是否存在、用户名密码是否正确、用户所具有的角色,登录成功或失败、用户信息存储)
权限决策:也就是授权,解析当前请求信息,获取请求地址,根据地址查询访问该请求所需要的角色(也就是权限)
即 sys_uer(用户表)、sys_role(角色表)、sys_permission(权限表/资源表)、sys_user_role(用户角色表)、sys_perm_role(权限角色表)
sys_user(用户表):
sys_role(角色表):
sys_permission(权限表):
sys_user_role(用户角色表):
sys_perm_role(权限角色表):
1、创建一个类 FilterRequestRole,作用类似于拦截器,实现 FilterInvocationSecurityMetadataSource 接口。该类的作用是拦截请求,解析 url(eg:/admin/student/list),根据 url 获取访问该 url 所需的角色集合
@Autowired
SYSPermMapper sysPermMapper;
@Override
public Collection getAttributes(Object o) throws IllegalArgumentException {
//获取请求地址,如:admin/user/list
String requestUrl = ((FilterInvocation) o).getRequestUrl();
System.out.println(requestUrl);
//如果请求地址为 /admin/user/login || /admin/user/login_code 则放行
if ("/admin/user/login".equals(requestUrl) || "/admin/user/login_code".equals(requestUrl)) {
System.out.println("如果返回 null 则放行");
return null;
}
//获取所有权限(数据库中所有的url)及其对应的角色列表
List list = sysPermMapper.allUrlRole();
AntPathMatcher antPathMatcher = new AntPathMatcher();
for (SYSPermission sysPermission : list) {
//match()方法可以比较地址是否相同
if (antPathMatcher.match(sysPermission.getPermUrl(), requestUrl)) {
List sysPermissionList = sysPermission.getRoles();
String[] roleArrays = new String[sysPermissionList.size()];
for (int i = 0; i < sysPermissionList.size(); i++) {
roleArrays[i] = sysPermissionList.get(i).getRoleNamey();
}
return SecurityConfig.createList(roleArrays);
}
}
//没有匹配上的地址则单独创建一个登录 的角色集合(实际上没有这个角色),后面会对这个角色单独处理
return SecurityConfig.createList("LOGIN_ROLE");
}
如上述代码所示,首先会获得 requestUrl ,然后判断是否为不需要登录就可访问的请求(eg:登录等),如果是则返回 null ,放行,然后查询数据库获取到所有的权限即 url 集合(注:这里在获取所有 url 集合时会同时获取到每个 url 对应的角色集合即roles 属性),然后通过框架自带的 match 方法比较 url,若匹配到,则取出其中的角色列表中的角色英文名,并将其转化为数组,最后创建一个访问当前请求所需角色的角色数组。若没有匹配上,则返回一个只有 LOGIN_ROLE 角色的数组,后面会对其特殊处理。
2、权限决策,也就是判断当前用户是否具有访问该请求的权限。创建一个类 UrlRoleAccessDecisionManager,实现 AccessDecisionManager 接口。该类的主要作用是通过比较当前登录用户的角色列表与访问当前请求所需要的角色列表,来判断当前用户是否有权限。
/**
* 根据用户账号所具有的角色与请求该地址所需要的角色对比,判断用户是否有权限
* @param authentication 用户所具有的角色列表
* @param o
* @param collection 请求该资源(地址/权限)所需要的角色列表
* @throws AccessDeniedException
* @throws InsufficientAuthenticationException
*/
@Override
public void decide(Authentication authentication, Object o, Collection collection)
throws AccessDeniedException, InsufficientAuthenticationException {
Iterator iterator = collection.iterator();
while (iterator.hasNext()) {
ConfigAttribute configuration = iterator.next();
String requestRole = configuration.getAttribute();
//如果角色为 LOGIN_ROLE ,则说明当前的请求不需要任何角色,所以直接放过
if ("LOGIN_ROLE".equals(requestRole)) {
System.out.println("也是放行");
return;
}
Collection extends GrantedAuthority> authorities = authentication.getAuthorities();
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().equals(requestRole)) {
return;
}
}
}
throw new AccessDeniedException("权限不足!");
}
如上述代码所示,在 decide 方法中有三个参数,其中第一个参数 authentication 是当前登录用户所具有的角色列表,至于为什么会有后面会讲到,第三个参数 collection 是访问当前请求所需要的角色列表。首先处理 LOGIN_ROLE 角色,也就是在第一步中没有匹配到的地址,直接返回即可(也就是放行)。然后遍历两个角色集合,判断当前用户是否巨有访问当前请求的权限,若无则抛出权限不足异常,下面对这个异常特殊处理。
3、创建一个类 PermissionAccessDeniedHandler 实现 AccessDeniedHandler 接口。该类的作用就是当出现权限不足的异常时,直接返回一个返回体到前台,提示权限不足。其中 APIResponse 是笔者自己封装的返回体类,recode:如:103,remsg:如:权限不足!
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
APIResponse apiResponse = new APIResponse();
apiResponse.setRecode(ConstConfig.RE_AUTHORITY_ERROR_CODE);
apiResponse.setRemsg(ConstConfig.RE_AUTHORITY_ERROR_MESSAGE);
ObjectMapper objectMapper = new ObjectMapper();
PrintWriter out = response.getWriter();
out.write(objectMapper.writeValueAsString(apiResponse));
out.flush();
out.close();
}
4、登录认证,也就是登录模块。首先创建用户实体类 SYSUser,需要实现 UserDetails 接口,该接口中有一个 getAuthorities 方法,该方法返回的是用户的角色列表。具体看代码:
@TableId(type = IdType.AUTO)
private Integer id; //主键
private String username; //账号
private String password; //密码
private Integer deleteFlag; //删除状态:0:未删除;1:已删除
private Timestamp createdTime; //创建时间
private Timestamp updateTime; //修改时间
@TableField(exist = false)
private int[] roleIds; //用户对应的角色id数组
@TableField(exist = false)
private List list; //用户所具有的角色
@Override
public Collection extends GrantedAuthority> getAuthorities() {
List authorities = new ArrayList<>();
if (list != null) {
for (SYSRole role : list) {
authorities.add(new SimpleGrantedAuthority(role.getRoleNamey()));
}
}
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
注:其中用户名和密码最好为 username、password
5、在用户事务层,也就是 SYSUserService 需要实现 UserDetailService 接口。该接口有一个方法 loadUserByUsername,是框架中登录时用来获取用户信息的,还可以在其中获取用户角色列表,跟用户实体中的 getAuthorities 方法相关联。
@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
SYSUser sysUser = sysUserMapper.selectOne(
new QueryWrapper().eq("username", userName));
if (sysUser != null) {
List list = sysUserMapper.userRoleList(sysUser.getId());
sysUser.setList(list);
} else {
throw new UsernameNotFoundException("用户名不存在!");
}
return sysUser;
}
注:在用户登录成功后,spring security 会将用户信息(包括角色列表)存入 SpringContext 中,然后会将其封装在 Authentication 中,这就是为什么第二步的方法中,第一个参数为什么会有用户信息的原因了。
6、 接下来再看 spring security 总配置文件。创建一个类 SecurityConfig,继承 WebSecurityConfigurerAdapter 类。该类中统一处理了认证成功、失败和授权成功、失败的结果。
@Autowired
SYSUserService sysUserService; //系统用户信息-账号、角色
@Autowired
FilterRequestRole filterRequestRole; //请求信息-url、角色
@Autowired
UrlRoleAccessDecisionManager urlRoleAccessDecisionManager; //当前登录用户的角色与请求资源需要的角色对比
@Autowired
PermissionAccessDeniedHandler permissionAccessDeniedHandler; //授权失败-权限不足
@Autowired
SYSTokenMapper sysTokenMapper;
@Autowired
SYSTokenService sysTokenService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//获取当前登录用户,并用用户添加时的加密规则对用户密码解密(之前的加密规则已被 spring security 抛弃)
//注:用户新增(即注册)时,需要对用户密码用 BCryptPasswordEncoder 类中的方法加密
auth.userDetailsService(sysUserService)
.passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeRequests()
.withObjectPostProcessor(new ObjectPostProcessor() {
@Override
public O postProcess(O o) {
o.setSecurityMetadataSource(filterRequestRole);
o.setAccessDecisionManager(urlRoleAccessDecisionManager);
return o;
}
}).and().formLogin()
.loginPage("/admin/user/login_code") //在此接口中系统会返回一个 recode(105),前端根此返回码跳转到登录页
.loginProcessingUrl("/admin/user/login") //登录接口 实际上没有此接口,登录逻辑的处理 spring security 会自动处理
.usernameParameter("username") //系统-用户实体中的用户账号属性
.passwordParameter("password") //系统-用户实体中的密码账号属性
.failureHandler(new AuthenticationFailureHandler() { //授权或决策失败时
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
APIResponse apiResponse = new APIResponse();
//用户名或密码错误时返回 101
if (e instanceof UsernameNotFoundException || e instanceof BadCredentialsException) {
apiResponse.setRecode(ConstConfig.RE_USERNAME_USERPWD_ERROR_CODE);
apiResponse.setRemsg(ConstConfig.RE_USERNAME_USERPWD_ERROR_MESSAGE);
} else { //其它异常时返回 102
apiResponse.setRecode(ConstConfig.RE_LOGIN_ERROR_CODE);
apiResponse.setRemsg(ConstConfig.RE_LOGIN_ERROR_MESSAGE);
}
//ObjectMapper为阿里推出的 jackson 依赖中的类,可将对象转化为字符串
ObjectMapper objectMapper = new ObjectMapper();
PrintWriter out = response.getWriter();
out.write(objectMapper.writeValueAsString(apiResponse));
out.flush();
out.close();
}
})
.successHandler(new AuthenticationSuccessHandler() { //授权或决策成功时
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
//将用户信息和登录凭证(token)返回
SYSUser sysUser = SYSUserUtil.getCurrentUser();
//登陆成功后 创建或修改token
String token = sysTokenService.createToken(sysUser.getId());
//查找用户是否有登录历史
SYSToken sysToken = sysTokenMapper.selectOne(
new QueryWrapper().eq("user_id", sysUser.getId())
);
if (sysToken != null) { //如果有登录历史 则为其更新token
sysToken.setToken(token);
sysTokenMapper.updateById(sysToken);
} else { //若无登录历史 则创建新的token
SYSToken sysToken1 = new SYSToken();
sysToken1.setUserId(sysUser.getId());
sysToken1.setToken(token);
sysTokenMapper.insert(sysToken1);
}
Map map = new HashMap<>();
sysUser.setToken(token);
map.put("dataInfo", sysUser);
ObjectMapper objectMapper = new ObjectMapper();
PrintWriter out = response.getWriter();
out.write(objectMapper.writeValueAsString(new APIResponse(map)));
out.flush();
out.close();
}
})
.and()
.logout()
.permitAll()
.and()
.csrf()
.disable()
.exceptionHandling()
.accessDeniedHandler(permissionAccessDeniedHandler);
httpSecurity.csrf().disable();
}
讲道理这个类的代码是有点多!总共有两个方法。
首先是该类的第一个方法,是关于密码加密的。主要是判断密码对错。这里主要说一下spring security 中的密码加密。
BCryptPasswordEncoder:该类中提供了两个方法,encode 和 matches,即密码加密和密码匹配。
* 密码加密:采用的是 SHA-256+随机盐+密钥对密码加密,其中的SHA-256是 hash 算法,不可逆,
* 但在加密算法中,加密算法是可逆的,可逆就意味着可解密,那就不安全。
* 密码匹配:是将用户登录时传入的密码用同样的算法加密,然后与数据库中已经加密后的密码进行比较,
* 虽然每次对相同的密码(123456)加密的结果也就是hash值是不一样的,但是通过密码匹配方法得出的
* 却是 true。
* 注:在密码传输的过程中使用 bcrypt.js 加密(也就是从前端传到后台时),因为 BCryptPasswordEncoder
* 内部采用应该是相同的算法吧!
其次再来看该类中的第二个方法。
因为 spring security 自带了登录页,但不符合各种 业务场景,所以我们得用自己的登录页。
.loginPage("/admin/user/login_code"):当 spring security 发现用户未登录时就会进入此方法,其中的 /admin/user/login_code 接口返回的是一个code(eg:105),前台拿到此 code 后,判断 code 是否是105,然后去登录也页面。
注:当 spring security 发现用户未登录时是自动进入 /admin/user/login_code 中的,也就是说是直接从后台进入接口,笔者在这里遇到一个坑,那就是这里会出现跨域,所以需要在后台写一个跨域配置(项目中的类为:CorsConfig,请查看Github)
.loginProcessingUrl("/admin/user/login"):当前台发起登录请求时,进入此方法,其中的 /admin/user/login 接口实际上并不存在(在Controller 中并没有此接口),但是这里的值必须与前台提交请求时的 url 相等。也就是说 spring security 是自动获取 username 、password这些参数值的,这里笔者也遇到一个坑,那就是spring security 自动获取时可能是从url里面获取的(猜测),所以前台传来的参数形式不能是json,不然拿不到,可以在浏览器控制台里看到,如果是json,则控制台显示的是 Request Payload,如果是地址拼接的形式,则是Form Data。这两种不同的形式跟请求头中的Content-Type属性有关,当其值为 application/x-www-form-urlencoded时,对应的是Form Data,其值为 application/json 时,对应的是 Request Payload,所以我们需要在请求头中将 Content-Type 的值设置为 application/x-www-form-urlencoded。
注:传统的 Content-Type 的值为application/x-www-form-urlencoded,但 axios 的值默认为 application/json,而且在 Vue.js跟react.js 中设置其值的方式是有所区别的,各位宝贝注意。
.failureHandler():当认证失败或者授权失败时会进入这个方法。这个方法里集中处理了各种异常,eg:UsernameNotFoundException(用户名不存在异常)、BadCredentialsException(密码错误异常)等等,这些都是各位小伙伴根据自己的业务需求来定的,将这些异常处理后将结果返回前台。
.successHandler():当认证成功或授权成功时会进入这个方法。这个方法里返回的是用户登录成功后的用户信息,包括登录凭证 token 等。这里还对 token 进行了处理,就不详细叙述了。
注:
关于session、cookie、sessionStorage、localStorage:
* 为什么选择localStorage来存放用户信息(比如token):
* session:session是存放在服务器上的,这就要求用户在不同时间访问的必须是同一台服务器,但这会在极
* 大程度上限制负载均衡的能力,所以不推荐
* cookie:cookie是存放在浏览器的,也就是客户端,但cookie存在path概念,容易被破解,而且其大小限制
* 极小,只适合存放小数据,所以不推荐
* sessionStorage:sessionStorage是存放在客户端的,但是只在当前窗口关闭前有效,达不到业务需求,所
* 以不推荐
* localStorage:存放在客户端,满足了分布式的负载均衡;持久有效,满足了业务需求;可存放大数据,最多
* 为5M,适合存放登录凭证(token等);安全性高,不会被破解。
哇!终于写完了,各位同学有什么问题下面评论,笔者二十四小时在线,请指教!
先去来一把紧张刺激的英雄联盟压压惊!