简单介绍
本专栏主要结合实战讲解,不过多介绍细节的概念,概念可以通过搜索引擎查找,一搜一大把,切入正题。
本专栏的实战项目是基于Springboot+SpringSecurity+RSA+JWT+VUE的全栈开发项目,每个环节都会专门讲,本期讲如何集成SpringSecurity
- 主要讲解一下部分:
1、基于RBAC的权限系统
2、SpringSecurity核心安全配置
3、登录过滤器
4、权限校验过滤器
5、默认的登录接口
一、基于RBAC的权限系统
设计以下表,用于管理维护用户的角色和权限(表的详细设计可见第1期)
common_user: 用户表,系统的用户都存在这张表里
common_role:角色表,用于表示系统有哪些角色
common_permission:权限表,也可以理解为资源,用于表示系统中所有的资源权限,粒度可大可小
common_user_role:用户和角色的关联表,表示某个用户拥有哪几种角色
common_role_permission:角色和权限的关联表,表示某个角色拥有哪些资源的访问权限
SpringSecurity可以基于角色进行粗粒度的权限控制,也可以基于权限进行细粒度的权限控制,还可以讲二者混合使用进行复杂的权限控制
二、SpringSecurity核心安全配置
1、创建auth模块
2、添加SpringSecurity的依赖
这里没有指定版本号,是因为在root的pom.xml中定义了依赖管理,统一进行版本号的管理,这是常规操作
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
3、创建一个SpringSecurity自定义配置类
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{}
核心配置如下:
http.csrf:禁用跨站请求伪造。从 Spring Security4 开始 CSRF 防护默认开启。默认会拦截请求。进行 CSRF 处理。CSRF 为了保证不是其他第三方网站访问,要求访问时携带参数名为_csrf 值为 token(token 在服务端产生)的内容,如果token 和服务端的 token 匹配成功,则正常访问。
anthorizeRequests():表示后面的资源通过认证即可访问
antMatchers(“xxxx”).permitAll():SpringSecurity允许这类资源被所有人访问
addFilter:添加自定义的过滤器,这里是添加了登录过滤器和权限认证过滤器
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS):关闭session管理,前后端分离不涉及session,所以关闭
如上图,注释掉的几行permitAll如果放开之后,swagger页面可以打开吗?
答案是:如果没有下面的过滤器就可以打开,如果有,那就无法打开,因为会被权限认证过滤器拦截,所以这里配置了上述白名单资源无效
4、有效配置绕过双重认证的白名单
双重认证
:SpringSeurity认证+JWT Token认证(这里不展开讲,后续会讲)
- 如何绕过双重认证:在当前的安全配置类,重写configure(WebSecurity web)方法,将下面的白名单资源和接口设置为忽略认证即可
三、登录过滤器
1、创建过滤器,继承UsernamePasswordAuthenticationFilter
public class UserLoginFilter extends UsernamePasswordAuthenticationFilter {
}
2、重写登录成功处理方法
这里可按照自己业务逻辑去实现登录成功以后的逻辑。
这里的逻辑是:
1、获取当前登录成功的用户
2、根据RSA私钥以及用户信息生成JWT Token,有效期设置为24小时(关于RSA安全加解密和JWT的生成和反序列化为用户信息后续为展开讲)
3、将登录用户信息写入Redis缓存,过期时间与JWT时间保持一致
4、将token放入响应header
5、组装接口响应体并响应给接口调用方
疑问
:可能有人发现了,用户信息从哪里来的,和数据库的common_user如何关联得起来?
3、登录的用户信息从哪里来
- 用户持久化对象实现SpringSecurity的UserDetails接口
此接口用于获取用户的关键信息,如账号、密码、权限集合、是否没过期、是否没被锁定、是否认证没过期等等,这些逻辑可以按照业务重写
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserPo extends BaseEntity<Long> implements UserDetails {
private String loginName;
private String password;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime loginExpireTime;
private LoginStatusEnum status;
private String phone;
@JsonFormat(pattern = "yyyy-MM-dd")
@JsonSerialize(using = LocalDateSerializer.class)
@JsonDeserialize(using = LocalDateDeserializer.class)
private LocalDate born;
private Integer failCount;
private List<RolePo> roles;
private List<PermissionPo> permissions;
@JsonIgnore
@Override
public Collection<GrantedAuthority> getAuthorities() {
// 保存的角色要加上ROLE_,接口配置角色时不带ROLE_
List<GrantedAuthority> authorities = Lists.newArrayList();
if (roles != null) {
for (RolePo rolePo : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + rolePo.getName()));
}
}
if (permissions != null) {
for (PermissionPo perm : permissions) {
authorities.add(new SimpleGrantedAuthority(perm.getName()));
}
}
return authorities;
}
@Override
public String getPassword() {
return this.password;
}
@JsonProperty(value = "loginName")
@Override
public String getUsername() {
return this.loginName;
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
- 数据库的用户信息从哪里来呢,请看用户接口及实现类
public interface UserService extends BaseService<UserPo>, UserDetailsService {
}
@Service
public class UserServiceImpl extends BaseServiceImpl<UserPo> implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private RoleService roleService;
@Autowired
PermissionService permissionService;
@Override
public BaseMapper<UserPo> getMapper() {
return userMapper;
}
@Override
public UserDetails loadUserByUsername(String loginName) throws UsernameNotFoundException {
UserPo userPo = userMapper.selectByLoginName(loginName);
if (userPo == null) {
throw new ForbiddenException("用户不存在");
}
List<RolePo> roles = roleService.queryRolesByUserId(userPo.getId());
List<PermissionPo> permissions = permissionService.queryPermissionsByUserId(userPo.getId());
userPo.setRoles(roles);
userPo.setPermissions(permissions);
return userPo;
}
}
从上面代码可以看出来,用户信息是通过UserDetailsService接口的loadUserByUsername加载的,而参数loginName就是登录传入的用户名
这里不仅查询了用户信息,还查询了用户的角色和权限,用于接下来的权限校验过滤器校验请求的合法性
4、登录失败处理
如果账号密码不正确,登录失败,构造无权的响应即可
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
UserPo sysUser = null;
try {
sysUser = new ObjectMapper().readValue(request.getInputStream(), UserPo.class);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(sysUser.getUsername(), sysUser.getPassword());
return authenticationManager.authenticate(authRequest);
} catch (Exception exception) {
try {
log.error("用户:{}登录出现异常:{}", sysUser == null ? "未知" : sysUser.getLoginName(), exception.getMessage(),exception);
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter out = response.getWriter();
Response<String> denied = ResponseResult.denied("用户名或密码错误!");
out.write(new ObjectMapper().writeValueAsString(denied));
out.flush();
out.close();
} catch (Exception outEx) {
outEx.printStackTrace();
}
}
return null;
}
四、权限校验过滤器
创建权限校验过滤器类,继承BasicAuthenticationFilter过滤器类
public class TokenVerifyFilter extends BasicAuthenticationFilter {
}
核心逻辑说明:
- 1、从请求头获取token
String xAuthToken = request.getHeader(Const.Header.AUTH_KEY);
- 2、token没传的话,交给后续其他过滤器处理(如果有的话),最后构造重新登录的响应
if (xAuthToken == null) {
//没有携带token,则给用户提示请登录!
chain.doFilter(request, response);
this.responseReLogin(response);
return;
}
- 3、通过token从redis获取用户信息
String userInfo = redisClient.get(Const.Header.AUTH_KEY + ":" + xAuthToken);
- 4、如果用户信息没获取到,那代表用户登录的时效过了,需要重新登录
if (StringUtils.isBlank(userInfo)) {
this.responseReLogin(response);
return;
}
- 5、从jwt token中反序列化出token中的载荷信息,jwt的组成部分可以搜一下,这里传的是公钥
Payload<UserPo> payload = JwtUtils.getInfoFromToken(xAuthToken, prop.getPublicKey(), UserPo.class);
- 6、从载荷中获取用户信息并使用用户名和用户的权限构造认证信息传给SpringSecurity进行权限的认证
UsernamePasswordAuthenticationToken authResult = new UsernamePasswordAuthenticationToken(user.getUsername(), null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authResult);
chain.doFilter(request, response);
五、默认的登录接口
SpringSecurity默认提供了http://127.0.0.1:8080/login的登录接口,所以很多新手包括本人最初也是困扰了很久,登录接口没有却能处理登录,最终登录的逻辑还是在登录的过滤器中。
包括退出登录接口,SpringSecurity也是提供了,如果要自定义退出的逻辑,安全设置中禁用退出即可,自定义退出登录逻辑
http.logout().disable()
自定义退出
@Operation(tags = "用户退出")
@PostMapping("/api/v1/logout")
public Response<String> logout(HttpServletRequest request) {
loginService.logout(request);
return ResponseResult.success("成功退出");
}
这里的退出逻辑很简单,就是从redis删除用户信息即可,其他退出业务逻辑也可在退出的方法中实现,比如用户退出时间、地点、本次登录时长等等。下方代码的token校验其实可以去掉的,因为退出接口也是需要鉴权的,如果执行到了这里,那说明token是有的并且是正确的没过期的。
@Override
@Transactional(rollbackFor = Exception.class)
public void logout(HttpServletRequest request) {
String token = request.getHeader(Const.Header.AUTH_KEY);
if (token == null) {
// 没有携带token,不允许退出,不是正常的操作
throw new DeniedException("无权退出");
}
redisClient.deleteKey(Const.Header.AUTH_KEY + ":" + token);
}