如何用Spring Security和JWT轻松实现用户登录验证与权限控制
引言
在Web应用开发中,安全一直是非常重要的一个方面。Spring Security基于Spring 框架,提供了一套Web应用安全性的完整解决方案。
JwT (JSON Web Token) 是当前比较主源的Token令牌生成方案,非常适合作为登录和授权认证的凭证。
这里我们就使用 Spring Security并结合JWT实现用户认证(Authentication) 和用户授权(Authorization) 两个主要部分的安全内容。
一、JWT与OAuth2的区别
在此之前,只是停留在用的阶段,对二者的使用场景很是模糊,感觉都是一样的呀,有啥不同呢,这里我也是根据网上的指点,在这罗列一下。
1、跨域实现不同
首先是涉及到跨域的问题:
如果ABC三个系统是相同域名的,比如都是www.a.com,那么就可以使用JWT的方式,将三个系统改造成统一的一个登录和拦截校验。
如果ABC不是相同域名的,比如:
www. a.com,www.b.com,www.c.com,建议不要使用JWT这种方式,因为需要涉及到跨域,这样跨域获取token可能存在安全问题,可以考虑使用传统cookie+session方式来实现跨域的SSO机制。或者可以使用SpringBoot+Security+OAuth2来实现,这就涉及到了OAuth2了.
2、所属性质原理不同
OAuth2是一种授权框架
OAuth2是一种授权框架,提供了一套详细的授权机制(指导)。用户或应用可以通过公开的或私有的设置,授权第三方应用访问特定资源。
JWT是一种认证协议
JWT提供了一种用于发布接入令牌(Access Token),并对发布的签名接入令牌进行验证的方法。 令牌(Token)本身包含了一系列声明,应用程序可以根据这些声明限制用户对资源的访问。
3、应用场景不同
OAuth2用在使用第三方账号登录的情况(比如使用weibo, qq, github登录某个app)
JWT是用在前后端分离, 需要简单的对后台API进行保护时使用.(前后端分离无session, 频繁传用户密码不安全)
OAuth2是一个相对复杂的协议, 有4种授权模式, 其中的access code模式在实现时可以使用jwt才生成code, 也可以不用. 它们之间没有必然的联系;
OAuth2有client和scope的概念,jwt没有。
如果只是拿来用于颁布token的话,二者没区别,常用的bearer算法oauth、jwt都可以用,只是应用场景不同而已。
具体的比较推荐给大家一片文章,写的很详细
https://blog.****.net/A15712399740/article/details/95233903
二、正题Spring Security并结合JWT实现用户认证和用户授权
2.1、添加pom.xml依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
2.2、添加配置文件WebSecurityConfig
在config 包下新建一个Spring Security 的配置类WebSecurityConfig, 主要是进行一些安全相关的配置,比如权限URL匹配策略、认证过滤器配置、定制身份验证组件、开启权限认证注解等,具体代码作用参见代码注释。
/** * @program: mangocms * @description: 安全配置类 * @author: zjc * @create: 2020-08-05 19:53 **/ @Configuration @EnableWebSecurity //开启Spring Security @EnableGlobalMethodSecurity(prePostEnabled = true) //开启权限注解 例如:@PreAuthorize注解 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //使用自定义身份认证组件 auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService)); } @Override protected void configure(HttpSecurity http) throws Exception { //禁用csrf,由于使用的是jwt,我们这里不需要csrf http.cors().and().csrf().disable().authorizeRequests() //跨域预检请求 .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() //web jars .antMatchers("/webjars/**").permitAll() //查看SQL监控(druid) .antMatchers("/druid/**").permitAll() //首页和登录页面 .antMatchers("/").permitAll().antMatchers("/login").permitAll() //swagger .antMatchers("/swagger-ui.html").permitAll() .antMatchers("/swagger-resources/**").permitAll() .antMatchers("/v2/api-docs").permitAll() .antMatchers("/webjars/springfox-swagger-ui/**").permitAll() //验证码 .antMatchers("/captcha.jpg**").permitAll() //服务监控 .antMatchers("/actuator/**").permitAll() //其他所有请求需要身份验证 .anyRequest().authenticated(); //退出登录处理器 http.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()); //token验证过滤器 http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()),UsernamePasswordAuthenticationFilter.class); } @Bean @Override public AuthenticationManager authenticationManager() throws Exception{ return super.authenticationManager(); } }
2.3、JwtAurthenticationFilter登录认证过滤器
登录认证过滤器负责登录认证时检查并生产令牌并保存到上下文,接口权限认证过程时,系统从上下文获取令牌校验接口访问权限,新建一个security包,在其下创建JwtAurthenticationFilter并继承BasicAuthenticationFilter, 覆写其中的doFilterlntermal 方法进行Token校验。
/** * @program: mangocms * @description: 登录认证过滤器 * @author: zjc * @create: 2020-08-05 20:53 **/ @Configuration public class JwtAuthenticationFilter extends BasicAuthenticationFilter { @Autowired public JwtAuthenticationFilter(AuthenticationManager authenticationManager){ super(authenticationManager); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { //获取token,并检查登陆状态,检查request中的请求信息 SecurityUtils.checkAuthentication(request); chain.doFilter(request,response); } }
这里我们把验证逻辑抽取到了SecurityUtils 的checkAuthentication 方法中,checkAuthentication通过JwtTokenUtils的方法获取认证信息并保存到Spring Security上下文中。
2.4、SecurityUtils获取令牌并进行认证
/** * 获取令牌进行认证 */ public static void checkAuthentication(HttpServletRequest request){ //获取令牌并根据令牌获取登录认证信息 Authentication authentication =JwtTokenUtils.getAuthentticattionFromToken(request); //设置登录认证信息到上下文 SecurityContextHolder.getContext().setAuthentication(authentication); }
2.5、JwtTokenUtils根据请求令牌获取登录认证信息
这里只是再进行主要方法的逻辑追踪,后面我会附上完整的代码下载地址。
/** * 根据请求令牌获取登录认证信息 * */ public static Authentication getAuthentticattionFromToken(HttpServletRequest request){ Authentication authentication =null; //获取请求携带的令牌 String token = JwtTokenUtils.getToken(request); if (token != null){ //请求令牌并不能为空 if(SecurityUtils.getAuthentication() == null){ //上下文中的Authentication Claims claims = getClaimsFromToken(token); if(claims == null){ return null; } String username =claims.getSubject(); if(username == null){ return null; } if(isTokenExpired(token)){ return null; } Object authors =claims.get(AUTHORITIES); List<GrantedAuthority> authorities =new ArrayList<>(); if(authors != null && authors instanceof List){ for(Object object : (List) authors){ authorities.add(new GrantedAuthorityImpl( (String)((Map) object).get("authority"))); } } authentication = new JwtAuthenticatioToken(username,null,authorities,token); }else{ if(validateToken(token,SecurityUtils.getUsername())){ //如果上下文中Authentication非空,且请求命令合法 //直接返回当前登录认证信息 authentication = SecurityUtils.getAuthentication(); } } } return authentication; } /** * 获取请求Token * @param request * @return */ //尝试从请求头中获取请求写带的令牌,默认从请求头中的“Authentication”参数以“Bearer”开头的信息为令牌信息, //若为空的话,尝试从token参数获取 public static String getToken(HttpServletRequest request){ String token = request.getHeader("Authorization"); String tokenHead = "Bearer"; if(token == null){ token = request.getHeader("token"); }else if(token.contains(tokenHead)){ //当且仅当此字符串包含指定的tokenHead值序列时,返回true。 token =token.substring(tokenHead.length()); } if("".equals(token)){ token = null; } return token; }
2.6身份认证组件
SpringSecurity 的登录验证是交由ProviderManager负责的,ProviderManager 在实际验证时其通过调用AuthenticationProvider的authenticate方法来进行认证。数据库类型的默认实现方案是DaoAuthenticationProvider。我们这里通过继承DaoAuthenticationProvider 定制默认的登录认证逻辑,在Security 包下新建验证器JwtAuthenticationProvider并继承DaoAuthenicationProvider,覆盖实现additionalAuthenticationChecks方法进行密码匹配,我们这里没有使用默认的密码认证器 (我们使用盐salt来对密码加密,默认密码验证器没有加盐),所以这里定制了自己的密码校验逻辑, 当然你也可以通过直接覆写authenticate方法来完成更大范围的登录认证需求定制。
JwtAuthenticationProvider身份验证提供者
/** * @program: mangocms * @description: 身份认证提供者 * @author: zjc * @create: 2020-08-08 09:35 **/ public class JwtAuthenticationProvider extends DaoAuthenticationProvider { public JwtAuthenticationProvider(UserDetailsService userDetailsService) { setUserDetailsService(userDetailsService); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { if(authentication.getCredentials() == null){ logger.debug("Authentraction failed: no credentials provided"); throw new BadCredentialsException(messages.getMessage("AbstractUserDetalisAuthenticationProvider.badCredentials","Bad credentials")); } String presentedPassword = authentication.getCredentials().toString(); String salt = ((JwtUserDetails)userDetails).getSalt(); if(! new PasswordEncoder(salt).matches(userDetails.getPassword(),presentedPassword)){ //覆写密码验证逻辑 matches:匹配两者 logger.debug("Authentication failed: password does not match"); throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials" )); } } }