Spring Security是一个高度自定义的安全框架,它利用Spring IoC和AOP的特性,为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码,使代码更加高内聚、低耦合。Spring Security作为Spring 家族的一员,与Spring MVC及其它Spring框架能很好地集成。本文将展示Spring Security整合JWT实现登陆和退出等功能,并解释一下其运行原理。
当访问一个系统时(应用),输入账户名、密码来登陆系统的这一过程,便叫做认证。认证的主要作用是保护系统资源、防止匿名用户恶意攻击。同时系统能够根据登录用户,分配用户权限信息,防止用户越权访问。常见的用户身份认证方式有:用户名密码输入、扫码登陆、短信登陆、面部识别登陆、指纹识别登陆等。
授权指的是根据不同的用户,系统赋予不同的权限,不同的权限对系统的数据访问和操作也不一样。举个简单例子,普通用户和系统管理员的权限一般是不一致的。登陆一个银行app,你只能看到你的账户余额,而管理员登陆,能看到他所有用户的存款余额情况(鉴于保密协议,他不能随便透露用户存款余额信息)。这种根据用户权限来控制用户访问的信息和可操作的信息,就是授权。
会话是系统为了保持与当前已登录用户状态所提供的一种机制,用户认证完成后,为了避免用户每次访问系统都要认证,将用户的信息保存于当前的会话中。JAVA目前实现会话的机制包括session方式,基于token的方式等。基于session的登陆原理可查看SSO单点登录-基于cookie的单点登录,基于token的访问方式如下图所示:
用户携带账号、密码进行登陆,认证成功智慧,服务端会生成一个token返回给客户端,客户端会存储到cookie或localStorage,每次访问时都会携带token,服务端接收到token之后都会进行解析认证,成功之后才返回请求结果。
Spring Security是由一系列过滤器组成,每个过滤器具备自己独特的功能。Spring Security采用了设计模式中的责任链模式,由多个过滤器组成过滤器链来完成认证和授权的功能。框架的整体结构如下所示:
上述SecurityFilterChain对应的就是Spring Security的过滤器。对应客户端发起的请求,在进入Controller之前,需要进过应用本身一系列过滤器,包括Spring Security过滤器链的处理(认证和授权等)。下面是请求经过过滤器链的顺序:
上述图中主要展示了核心过滤器,非核心过滤器未展示,设置@EnableWebSecurity(debug = true)打印过滤器执行流程,如下所示:
由上图可知,Spring Security过滤器链中共有15个过滤器,接下来分别介绍一下这些过滤器的作用:
过滤器名称 | 作用 |
---|---|
WebAsyncManagerIntegrationFilter | 将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成 |
SecurityContextPersistenceFilter | 在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将SecurityContextHolder 中的信息清除 |
HeaderWriterFilter | 用于将头信息加入响应中 |
CsrfFilter | 用于处理跨站请求伪造 |
LogoutFilter | 用于处理退出登录 |
UsernamePasswordAuthenticationFilter | 用于处理基于表单的登陆请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的 usernameParameter 和 passwordParameter 两个参数的值进行修改 |
DefaultLoginPageGeneratingFilter | 如果没有配置登陆页面,系统初始化时就会调用这个过滤器,生成一个登陆表单页面 |
DefaultLogoutPageGeneratingFilter | 默认生成登出页面过滤器 |
BasicAuthenticationFilter | 检测和处理 http basic 认证 |
RequestCacheAwareFilter | 用来处理请求的缓存 |
SecurityContextHolderAwareRequestFilter | 主要是包装请求对象 request |
AnonymousAuthenticationFilter | 检测 SecurityContextHolder 中是否存在Authentication 对象,如果不存在为其提供一个匿名 Authentication |
SessionManagementFilter | 管理 session 的过滤器 |
ExceptionTranslationFilter | 负责处理过滤器链中抛出的任何 AccessDeniedException 和AuthenticationException 异常 |
FilterSecurityInterceptor | 过滤器链的出口,负责权限校验的过滤器 |
默认登录流程如下图所示,当用户提交用户名和登陆密码之后,会进入UsernamePasswordAuthenticationFilter过滤器中进行认证,该方法会调用ProviderManager中authenticate方法进行认证,authenticate方法内部又会调用DaoAuthenticationProvider中loadUserByUsername方法查询用户信息,若在内存中查询到用户信息,则封装成UserDetail对象返回。
本文将整合SpringBoot、Spring Security与JWT,编写一个用户登陆的案例。借助于Spring Security框架,可以减少大量代码的编写。当用户登陆成功之后,会返回一个token给前端服务,前端服务下次携带token来访问即可,后台服务需要定义一个JWT认证过滤器,解析token中的用户信息,判断合法性,若合法则允许访问系统资源。验证如下图所示:
本文案例登陆流程图如下所示:
1.登录流程
(1)自定义登录接口:调用ProviderManager的authenticate方法进行认证,认证通过生成jwt,同时以userId为key,存储用户信息到redis中;
(2)自定义UserDetailService:在这个实现类中查询用户信息。
2.校验
(1)定义jwt认证过滤器:解析token,获取token中的userId,利用userId从缓存中查询用户信息,若存在验证通过。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.0.9.RELEASE</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.3</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.24</version>
</dependency>
1.UserDetailsServiceImpl
UserDetailsServiceImpl 继承Spring Security接口UserDetailsService ,主要方法包括根据userName从数据库中查询用户信息。
import com.eckey.lab.dao.SysUserDao;
import com.eckey.lab.domain.LoginUser;
import com.eckey.lab.domain.SysUser;
import com.eckey.lab.utils.RedisUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
RedisUtils redisUtils;
@Resource
SysUserDao sysUserDao;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (StringUtils.isEmpty(username)) {
log.error("username不能为空!");
return null;
}
SysUser sysUser = sysUserDao.selectByUserName(username);
if (sysUser == null) {
throw new RuntimeException("用户不存在!");
}
return new LoginUser(sysUser);
}
}
2.UserServiceImpl
UserServiceImp类中主要有用户登入和登出操作方法,具体如下:
import com.alibaba.fastjson.JSON;
import com.eckey.lab.dao.SysUserDao;
import com.eckey.lab.domain.LoginUser;
import com.eckey.lab.domain.SysUser;
import com.eckey.lab.service.UserService;
import com.eckey.lab.utils.JwtUtils;
import com.eckey.lab.utils.RedisUtils;
import com.eckey.lab.utils.ResultData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
public class UserServiceImpl implements UserService {
public static final String LOGIN_FLAG = "loginUser:";
@Resource
private SysUserDao sysUserDao;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisUtils redisUtils;
@Autowired
private JwtUtils jwtUtils;
@Override
public ResultData queryByUserName(String userName) {
if (StringUtils.isEmpty(userName)) {
return ResultData.fail("userName不能为空!");
}
SysUser sysUser = sysUserDao.selectByUserName(userName);
log.info("根据userName:{}查询到结果为:{}", userName, JSON.toJSONString(sysUser));
return ResultData.success(sysUser);
}
@Override
public ResultData login(SysUser sysUser) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(sysUser.getUsername(), sysUser.getPassword());
//查询用户是否存在且密码是否合法
Authentication authentication = authenticationManager.authenticate(authenticationToken);
if (authentication == null) {
return ResultData.fail("登陆失败!");
}
//认证通过,获取用户信息
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long id = loginUser.getSysUser().getId();
if (id != null) {
//生成jwt token返回前端,同时将用户信息存入redis
String token = jwtUtils.generateToken(id);
Map<String, String> maps = new HashMap<>();
maps.put("token", token);
redisUtils.set(LOGIN_FLAG + id, loginUser);
return ResultData.success(maps);
}
return ResultData.fail("获取token失败");
}
@Override
public ResultData logOut() {
//若用户未登录执行登出操作,在TokenFilterComponent拦截器中会被拦截,认证会不通过
//从SecurityContextHolder获取用户信息,能执行登出操作,说明该用户已登录且通过认证
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long id = loginUser.getSysUser().getId();
if (id != null) {
redisUtils.del(LOGIN_FLAG + id);
}
return ResultData.success();
}
}
3.TokenFilterComponent
TokenFilterComponent是jwt token拦截器,用来获取用户请求头中的token信息,若携带token,且token合法,则允许访问,具体如下:
import com.eckey.lab.domain.LoginUser;
import com.eckey.lab.utils.JwtUtils;
import com.eckey.lab.utils.RedisUtils;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class TokenFilterComponent extends OncePerRequestFilter {
@Autowired
private RedisUtils redisUtils;
@Autowired
private JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
//未获取到token信息,直接放行,SecurityContextHolder无用户信息,会被拦截器拦截
filterChain.doFilter(request, response);
return;
}
//解析jwt token,获取用户id
Claims claimsFromToken = jwtUtils.getClaimsFromToken(token);
Integer id = (Integer) claimsFromToken.get("USERID");
//根据用户id查询缓存中用户信息
LoginUser loginUser = (LoginUser) redisUtils.get("loginUser:" + id);
if (loginUser == null) {
throw new RuntimeException("非法用户!");
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
//认证通过,将用户信息存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
4.SecurityConfig
SecurityConfig是关于Spring Security的配置类,包括密码加密方式、拦截路径、拦截方式等,这里编写一些基础配置,具体大家可根据需要自定义。
import com.eckey.lab.filter.TokenFilterComponent;
import com.eckey.lab.service.impl.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.annotation.Resource;
@EnableWebSecurity
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsServiceImpl userDetailsService;
@Autowired
private TokenFilterComponent tokenFilterComponent;
@Override
protected void configure(HttpSecurity http) throws Exception {
//不通过Session获取SecurityContext
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//登录接口允许匿名访问
.authorizeRequests()
.antMatchers("/login/user").anonymous()
//除上述接口,均需要授权访问
.anyRequest().authenticated();
// 关闭csrf
http.csrf().disable();
//添加token认证过滤器
http.addFilterBefore(tokenFilterComponent, UsernamePasswordAuthenticationFilter.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 设置密码加密方式,验证密码的在这里
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 指定加密方式,密码需要BCryptPasswordEncoder加密方式存入数据库,否则校验不通过
*/
@Bean
public PasswordEncoder passwordEncoder() {
// 使用BCrypt加密密码
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
一些工具类代码就不在这里展示了,具体可查看附录代码。
还有登出接口,登出成功之后,携带上次token请求就无法访问了,缓存用户信息数据已被清理,需要重新登陆,具体可自行测试。
1.在Spring Security框架中,密码不会被明文存储在数据库中。默认PasswordEncoder要求数据库中的密码格式为{id}password,它会根据id去判断密码的加密方式,一般不会采用这种方式,常用的方式是利用Spring Security中的BCryptPasswordEncoder来进行密码加密;
2.在接口中通过AuthenticationManager的authenticate方式来进行用户认证,所以需要在SecurityConfig中把AuthenticationManager注入容器;
3.认证成功后生成jwt token返回响应,并且为了让下次请求过来时能准确识别用户,需要将用户信息存入redis,token中需要携带用户id信息;
4.token拦截器中需要解析token信息,若合法则放行,并将用户信息存入SecurityContextHolder,方便后面拦截器进行鉴权操作,且token拦截器需要放在UsernamePasswordAuthenticationFilter拦截器之前,便于后面拦截器可以直接获取SecurityContextHolder用户信息;
5.本文未展示token有效期的情形,下一篇将展示token指定时间有效,过期提示重新登陆。
1.https://juejin.cn/post/6861394327159963655
2.https://juejin.cn/post/6844903861237317640
3.https://www.bilibili.com/video/BV1mm4y1X7Hc
https://gitee.com/Marinc/springboot-demos/tree/master/spring-security