本专栏主要结合实战讲解,不过多介绍细节的概念,概念可以通过搜索引擎查找,一搜一大把,切入正题。
本专栏的实战项目是基于Springboot+SpringSecurity+RSA+JWT+VUE的全栈开发项目,每个环节都会专门讲,本期讲如何集成SpringSecurity
1、基于RBAC的权限系统
2、SpringSecurity核心安全配置
3、登录过滤器
4、权限校验过滤器
5、默认的登录接口
设计以下表,用于管理维护用户的角色和权限(表的详细设计可见第1期)
common_user: 用户表,系统的用户都存在这张表里
common_role:角色表,用于表示系统有哪些角色
common_permission:权限表,也可以理解为资源,用于表示系统中所有的资源权限,粒度可大可小
common_user_role:用户和角色的关联表,表示某个用户拥有哪几种角色
common_role_permission:角色和权限的关联表,表示某个角色拥有哪些资源的访问权限
SpringSecurity可以基于角色进行粗粒度的权限控制,也可以基于权限进行细粒度的权限控制,还可以讲二者混合使用进行复杂的权限控制
这里没有指定版本号,是因为在root的pom.xml中定义了依赖管理,统一进行版本号的管理,这是常规操作
org.springframework.boot
spring-boot-starter-security
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页面可以打开吗?
答案是:如果没有下面的过滤器就可以打开,如果有,那就无法打开,因为会被权限认证过滤器拦截,所以这里配置了上述白名单资源无效
双重认证
:SpringSeurity认证+JWT Token认证(这里不展开讲,后续会讲)
public class UserLoginFilter extends UsernamePasswordAuthenticationFilter {
}
这里可按照自己业务逻辑去实现登录成功以后的逻辑。
这里的逻辑是:
1、获取当前登录成功的用户
2、根据RSA私钥以及用户信息生成JWT Token,有效期设置为24小时(关于RSA安全加解密和JWT的生成和反序列化为用户信息后续为展开讲)
3、将登录用户信息写入Redis缓存,过期时间与JWT时间保持一致
4、将token放入响应header
5、组装接口响应体并响应给接口调用方
疑问
:可能有人发现了,用户信息从哪里来的,和数据库的common_user如何关联得起来?
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserPo extends BaseEntity 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 roles;
private List permissions;
@JsonIgnore
@Override
public Collection getAuthorities() {
// 保存的角色要加上ROLE_,接口配置角色时不带ROLE_
List 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, UserDetailsService {
}
@Service
public class UserServiceImpl extends BaseServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private RoleService roleService;
@Autowired
PermissionService permissionService;
@Override
public BaseMapper getMapper() {
return userMapper;
}
@Override
public UserDetails loadUserByUsername(String loginName) throws UsernameNotFoundException {
UserPo userPo = userMapper.selectByLoginName(loginName);
if (userPo == null) {
throw new ForbiddenException("用户不存在");
}
List roles = roleService.queryRolesByUserId(userPo.getId());
List permissions = permissionService.queryPermissionsByUserId(userPo.getId());
userPo.setRoles(roles);
userPo.setPermissions(permissions);
return userPo;
}
}
从上面代码可以看出来,用户信息是通过UserDetailsService接口的loadUserByUsername加载的,而参数loginName就是登录传入的用户名
这里不仅查询了用户信息,还查询了用户的角色和权限,用于接下来的权限校验过滤器校验请求的合法性
如果账号密码不正确,登录失败,构造无权的响应即可
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 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 {
}
核心逻辑说明:
String xAuthToken = request.getHeader(Const.Header.AUTH_KEY);
if (xAuthToken == null) {
//没有携带token,则给用户提示请登录!
chain.doFilter(request, response);
this.responseReLogin(response);
return;
}
String userInfo = redisClient.get(Const.Header.AUTH_KEY + ":" + xAuthToken);
if (StringUtils.isBlank(userInfo)) {
this.responseReLogin(response);
return;
}
Payload payload = JwtUtils.getInfoFromToken(xAuthToken, prop.getPublicKey(), UserPo.class);
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 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);
}