Spring Security + JWT实现访问控制
前置知识点
整体流程
- 用户向服务器发送用户名、密码以及验证码用于登陆系统。
- 如果用户用户名、密码以及验证码校验正确的话,服务端会返回已经签名的 Token,也就是 JWT。
- 用户以后每次向后端发请求都在 Header 中带上这个 JWT 。
- 服务端检查 JWT 并从中获取用户相关信息。
如下图所示:
JWT (JSON Web Token)简单介绍
本文主要利用JWT实现登录授权,一旦登录之后,就返回给前端一个Token,利用Token进行信息交互。
Token由三部分组成:Header、Payload、Signature。
- Header:通常由两部分组成:
- typ(Type):令牌类型,也就是 JWT。
- alg(Algorithm) :签名算法,比如 HS256。
- Payload:Payload 也是 JSON 格式数据,其中包含了 Claims(声明,包含 JWT 的相关信息)。一般不存放隐私信息。
- Signature:Signature 部分是对前两部分的签名,作用是防止 JWT(主要是 payload) 被篡改。
Spring Security 简单流程介绍
翻看源码可知,Spring Security通过职责链模式,通过各类XXXFilter对请求进行了拦截。实际操作时经常需要实现XXXFilter来自定义的登录以及访问控制。
具体实现(仅展示关键实现类)
测试接口
/**
* @author qyl
* @program HelloController.java
* @Description Test for diff auths
* @createTime 2022-07-12 10:24
*/
@RestController
@RequestMapping("/test")
public class TestController {
@ApiOperation(value = "hello")
@PostMapping("hello")
@PreAuthorize("hasAuthority('hello')")
public R hello() {
return R.ok().data("hello", "hello");
}
@ApiOperation(value = "manage")
@PostMapping("manage")
@PreAuthorize("hasAuthority('manage')")
public R manage() {
return R.ok().data("manage", "manage");
}
}
自定义访问过滤器:
/**
* <p>
* 访问过滤器
* 首先获取header中的token 判断是否已经登录;
* 1.没登陆 抛异常
* 2.登录过了 就将授权信息一起放入安全上下文中
* </p>
*
* @author qyl
* @since 2022-7-09
*/
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenAuthenticationFilter(AuthenticationManager authManager, TokenManager tokenManager,RedisTemplate redisTemplate) {
super(authManager);
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws IOException, ServletException {
UsernamePasswordAuthenticationToken authentication = null;
authentication = getAuthentication(req);
if (authentication != null) {
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
ResponseUtil.out(res, R.error());
}
chain.doFilter(req, res);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
// token置于header里
String token = request.getHeader("token");
if (token != null && !"".equals(token.trim())) {
String userName = tokenManager.getUserFromToken(token);
List<String> permissionValueList = (List<String>) redisTemplate.opsForValue().get(userName);
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) continue;
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
if (!StringUtils.isEmpty(userName)) {
return new UsernamePasswordAuthenticationToken(userName, token, authorities);
}
return null;
}
return null;
}
}
自定义登录过滤器:
/**
* <p>
* 登录过滤器,继承UsernamePasswordAuthenticationFilter,
* 通过对Post发送的/login请求进行拦截,对用户名密码进行登录校验
* </p>
*
* @author qyl
* @since 2022-7-09
*/
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) {
this.authenticationManager = authenticationManager;
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
this.setPostOnly(false);
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/login","POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
throws AuthenticationException {
String username = req.getParameter("username");
String password = req.getParameter("password");
User user = new User();
user.setUsername(username);
user.setPassword(password);
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>()));
}
/**
* 登录成功将用户信息放入redis中:
* username 作为 key
* permissionValueList 作为 value
* 之后将token返回以便在访问控制时从redis中取出权限,实现访问控制
*
* @param req
* @param res
* @param chain
* @param auth
* @throws IOException
* @throws ServletException
*/
@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain,
Authentication auth) throws IOException, ServletException {
SecurityUser user = (SecurityUser) auth.getPrincipal();
String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername());
redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(), user.getPermissionValueList());
ResponseUtil.out(res, R.ok().data("token", token));
}
/**
* 登录失败
* @param request
* @param response
* @param e
* @throws IOException
* @throws ServletException
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException {
ResponseUtil.out(response, R.error());
}
}
自定义登录过程中账号密码查询方式(不写就不好从数据库获取账号密码)
/**
* <p>
* 自定义userDetailsService - 认证用户详情
* </p>
*/
@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private PermissionService permissionService;
/***
* 根据账号获取用户信息
* @param username:
* @return: org.springframework.security.core.userdetails.UserDetails
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从数据库中取出用户信息
User user = userService.selectByUsername(username);
// 判断用户是否存在
if (null == user){
throw new UsernameNotFoundException("用户名不存在!");
}
// 返回UserDetails实现类
User curUser = new User();
BeanUtils.copyProperties(user,curUser);
List<String> authorities = permissionService.selectPermissionValueByUserId(user.getId());
SecurityUser securityUser = new SecurityUser(curUser);
securityUser.setPermissionValueList(authorities);
return securityUser;
}
Security 配置类
/**
* <p>
* Security配置类
* </p>
*
* @author qyl
* @since 2022-7-09
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private UserDetailsService userDetailsService;
private TokenManager tokenManager;
private DefaultPasswordEncoder defaultPasswordEncoder;
private RedisTemplate redisTemplate;
@Autowired
public SecurityConfig(UserDetailsService userDetailsService, DefaultPasswordEncoder defaultPasswordEncoder,
TokenManager tokenManager, RedisTemplate redisTemplate) {
this.userDetailsService = userDetailsService;
this.defaultPasswordEncoder = defaultPasswordEncoder;
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
/**
* 配置设置
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling() // 如果有异常 直接就抛出 不再跳转到登录页面
.authenticationEntryPoint(new UnauthorizedEntryPoint())
.and().csrf().disable() //关闭csrf
.authorizeRequests()
.anyRequest().authenticated() // 对所有的请求都进行验证
.and().addFilter(new TokenLoginFilter(authenticationManager(), tokenManager, redisTemplate)) // 增加两个过滤器 职责链模式
.addFilter(new TokenAuthenticationFilter(authenticationManager(), tokenManager, redisTemplate)).httpBasic();
}
/**
* 密码处理 这里通过注入的userDetailsService来实现Form传来的账号密码与数据库账号密码进行比对
*
* @param auth
* @throws Exception
*/
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder);
}
/**
* 配置哪些请求不拦截
*
* @param web
* @throws Exception
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/v2/**", "/api/**");
}
}
实际运行流程解析
访问控制流程解析
- 发送一个hello请求
- 进入TokenAuthenticationFilter判断用户是否登录 如果存在token了(已经登录了),就把用户权限封装到UsernamePasswordAuthenticationToken中,把该UsernamePasswordAuthenticationToken放入安全信息上下文,以便后面存取。
- 之后执行下一个Filter
- 最后进入SecurityContextPersistenceFilter,即在所有的Filter执行完毕后,把安全信息保存起来,做个持久化
- 当保存完安全信息之后就放行,开始请求TestController接口中的hello方法,并开始权限验证。
整个过程就是这样,这里test用户没有manage权限,请求自然失败了。
登录流程解析
- 发送post请求
- 进入TokenLoginFilter把信息传入ProviderManager中进行数据校验。
这里用到了上述我们自定义的UserDetailsService来查询信息。
- 在比对成功之后就进入successfulAuthentication方法,实现自己的一些业务逻辑。
- 再看redis已经将username和permissionList存进去了
总结
JWT需要注意细节:
- 使用安全系数高的加密算法。
- 使用成熟的开源库,没必要造轮子。
- JWT 存放在 localStorage 中而不是 Cookie 中,避免 CSRF 风险。
- 一定不要将隐私信息存放在 Payload 当中。密钥一定保管好,一定不要泄露出去。
- JWT 安全的核心在于签名,签名安全的核心在密钥。
- Payload 要加入 exp (JWT 的过期时间),永久有效的 JWT 不合理。并且,JWT 的过期时间不易过长。