第一步:
在pom文件中导入相关配置spring-boot-starter-security,fastjson,jjwt
写securityConfig配置类,该配置类需要继承WebSecurityConfigurerAdapter
然后我们可以重写里面的一些方法如 configure(HttpSecurity http)我们可以在里面配置白名单(可以不需要登录既可以访问),禁用csrf(可以防止伪造的跨域攻击),对请求进行相应的认证与授权,是否启用自带的登录页面,添加自定义过滤器(一定要将自定义的JWT过滤器添加在(spring内置的过滤器(UsernamePasswordAuthenticationFilter)之前)),
还可以在里面装配一些之后要用到的组件
,如:AuthenticationManager authenticationManagerBean()(要用自己的账号密码就要有这个配置),
,PasswordEncoder passwordEncoder()(加密需要用这个)
//要用自己的账号密码就要有这个配置 @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }
//加密需要用这个 @Bean public PasswordEncoder passwordEncoder() { log.debug("创建@Bean方法定义的对象:PasswordEncoder"); return new BCryptPasswordEncoder(); // return NoOpPasswordEncoder.getInstance(); // 无操作的密码编码器,即:不会执行加密处理 } 判断密码是否正确: 如果要使用spring security则必须要一个密码编码器,如没有则报错; 当Spring容器中存在密码编码器时,在Spring Security处理认证时会自动调用! 本质上,是调用了密码编码器的以下方法: boolean matches(CharSequence rawPassword, String encodedPassword); 也就是说,Spring Security会使用用户提交的密码作为以上方法的第1个参数,使用`UserDetails`对象中的密码作为以上方法的第2个参数,然后根据调用以上方法返回的`boolean`结果来判断此用户是否能通过密码验证! 所以,如果配置了`BCryptPasswordEncoder`,则返回的`UserDetails`对象中的密码必须是BCrypt密文
//配置白名单 @Override protected void configure(HttpSecurity http) throws Exception { String [] urls={ "/doc.html", "/**/*.js", "/**/*.css", "/swagger-resources", "/v2/api-docs", "/admins/login" }; // 禁用CSRF(防止伪造的跨域攻击) http.csrf().disable(); //启用spring security的corsFilter过滤器,此过滤器会放行跨域请求, // ,包括预检的OPTIONS请求 http.cors(); http.authorizeRequests() // 对请求执行认证与授权 .antMatchers(urls) // 匹配某些请求路径 .permitAll() // (对此前匹配的请求路径)不需要通过认证即允许访问 // 以下2行代码是用于对预检的OPTIONS请求直接放行的 // .antMatchers(HttpMethod.OPTIONS, "/**") // .permitAll() // .anyRequest() // 除以上配置过的请求路径以外的所有请求路径 .authenticated(); // 要求是已经通过认证的 //是否启用登录页面 // http.formLogin(); }
第二步
在默认情况下,spring security使用user作为用户名,使用随机的UUID作为密码登录,如果我们想要自定义账号密码来登录需要自定义一个类来实现UserDetailsService接口,然后强制重写loadUserByUsername(String username)方法,这个方法的返回值是UserDetails
对象,这里面就应该包含用户的一些信息,比如例如密码等,当Spring Security得到调用loadUserByUsername()
返回的UserDetails
对象后,会自动处理后续的认证过程,例如验证密码是否匹配等。但因为框架提供的UserDetails不能满足我们的需求,所以我们要重写这个实体类,所以我们在security包下创建一个AdminDetails实体类继承User类,重写里面的构造方法,然后在UserDetailsServiceImpl中利用查询出来的数据生成UserDetails并返回给spring security;
完成这里以后就可以实现用自己的用户名密码来登录,但此时我们还没有结束,因为我们并没有给用户相应的权限,且此时还没进行前后端分离;
第三步
启动前端,
并将前端登录路径设置到白名单里面,
禁用掉框架自带的表单验证,
然后在IAdminServiceImpl里面处理登录认证:调用AuthenticationManager对象的authenticate()方法,将由Spring完成认证;
这个方法的返回值类型是Authentication authenticateResult,在这里面就封装着我们登录的时候存进去的内容,如:id,用户名,权限,我们可以用getPrincipal()方法来接收,这个方法的返回值为Object,但实际类型我们可以通过log.debug("认证结果中的Principal数据类型:{}", principal.getClass().getName())来查看,查看后发现他的类型为cn.tedu.csmall.passport.security.AdminDetails,使我们之前写的那个继承自User的类,所以我们可以对其进行强转AdminDetails adminDetails = (AdminDetails) principal;这里取出来的数据我们会在之后往jwt里面存数据的时候用到;
而要调用authentication()方法还需要一个Authentication对象,而Authentication又是一个接口,所以我们new了一个Authentication的实现类UsernamePasswordAuthenticationToken,我们只需在new UsernamePasswordAuthenticationToken时传进去用户名和密码;
然后调用authentication()方法时,我们只需要将创建出来的UsernamePasswordAuthenticationToken对象传进去,Spring Security框架就会自动调用UserDetailsService对象的loadUserByUsername()方法,并自动处理后续的认证(判断密码、enable等)
至此,我们已经完成了前后端分离的登录功能,但是http协议是个无状态协议,所以我们下次发起请求依旧需要登录,但这明显不是我们想要的效果,所以我们现在就需要用到类似session的token(Token机制是目前主流的取代Session用于服务器端识别客户端身份的机制)里面的jwt(JSON Web Token,是使用JSON格式来组织多个属性于值,主要用于Web访问的Token)
为什么不用session?
-
不能直接用于集群甚至分布式系统(可以通过共享Session技术来解决)
-
session需要占用服务器内存,存储的时间也短,不能满足我们的需求
我们用到的jwt就类似一个我们平常坐火车的过程:登录就是买票,票存在用户手里,当用户访问其他需要登录的服务时spring security就会进行验票,如果发现我们没票或者票不对,就不会让我们上车,所以我们接下来的事情就是要让服务器具有发票功能,即当我们登录时,服务器会响应一个独一无二的jwt给客户端,当用户在下次发请求时带上这个票(这个票被存在消息头(Request Headers)中名为Authorization
的属性中),服务器就可以提取出这个票,并对其进行验证,为了保证这个jwt是独一无二的,并且为了方便我们之后利用jwt提取出来有用的数据,所以我们会在生成jwt时将用户的id,用户名,密码,权限等存入jwt中;
spring security验证权限是在SecurityContext中去获取的,所以我们如果想要让用户具有某些权限就需要在SecurityContext中放入相应的权限,所以我们也要在jwt中存入权限,以便于之后解析出权限并进行相应操作;
了解完这些我们就可以在IadminServiceImpl里面生成并返回一个jwt;
首先我们要获取到要存入进jwt的数据,这个数据之前我们已经得到了,就在adminDetails里面,封装id和用户名都很简单,但是权限不好处理,因为我们进行尝试后可以发现,我们存进去的时候是一个list,但当我们取出来的时候就变成了另一种(实际我忘记了,大家可以尝试一下),导致我们很难取出来我们原始的数据,所以我们这时候就要引入一个新工具fastjson,这个工具可以实现对象与JSON的相互转换,之后我们就可以根据获取到的数据生成一个jwt了
<!-- fastjson:实现对象与JSON的相互转换 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.75</version> </dependency>
Map<String, Object> claims = new HashMap<>(); claims.put("id", adminDetails.getId()); // 向JWT中封装id claims.put("username", adminDetails.getUsername()); // 向JWT中封装username claims.put("authorities", JSON.toJSONString(adminDetails.getAuthorities())); // 向JWT中封装权限 Date expirationDate = new Date(System.currentTimeMillis() + durationInMinute * 60 * 1000)//单位为ms,这里的durationInMinute是我们在配置文件中写的一个常量,值为10*24*60
jwt的生成用到的方法为 Jwts.builder()通过这个方法来打点,生成之后我们就可以把生成的这个jwt响应给客户端;
String jwt = Jwts.builder() .setHeaderParam("alg", "HS256") .setHeaderParam("typ", "JWT") .setClaims(claims) .setExpiration(expirationDate) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();
setHeaderParam()(头部参数,里面存的内容相对固定),可以传入的参数为类似键值对的内容左侧类型为String,右侧类型为Object,或者传入一个map,setClaims()可以传入的参数为map或者Claims对象(此处我们使用的是map)(里面存的内容为我们想要封装进去的数据例如id,用户名,权限,最好不要往里面放私密数据,因为jwt不是算法,任何一个人都可以根据你的jwt来获取到你存进去的数据,所以如果你需要往里面封装一些私密数据,请加密后再进行封装),setExpiration()设置jwt过期时间,传入一个Date类型的数据,单位为毫秒,过期后这个jwt就不能用了,再使用就会报错),signWith()(可以传入的第一个参数为使用的加密算法.第二个是我们想加入的盐),最后以compact()结尾,这样我们就可以把这个生成的jwt在可以在客户端发起登录请求的时候响应给客户端,好让客户端在下次发其他请求的时候携带着这个jwt以便于服务端识别;
但当客户端向服务器端提交请求,在跨域的前提下,如果提交的请求配置了请求头中的非典型参数,例如配置了Authorization
,此请求会被视为“复杂请求”,则会要求执行“预检”(PreFlight),如果预检不通过,则会导致跨域请求错误!
关于预检,浏览器会自动向服务器端提交OPTIONS
类型的请求执行预检,为了确保预检通过,不影响处理正常的请求,需要在SecurityConfiguration
的configurer()
方法中对预检请求放行,可以采取的解决方案有:
http.authorizeRequests() .antMatchers(urls) .permitAll() // 以下2行代码是用于对预检的OPTIONS请求直接放行的 .antMatchers(HttpMethod.OPTIONS, "/**") .permitAll() .anyRequest() .authenticated();
或者也可以
http.cors(); // 启用Spring Security框架的处理跨域的过滤器,此过滤器将放行跨域请求,包括预检的OPTIONS请求
这样客户端就可以携带复杂请求头进行访问了;
所以在服务器我们要在哪去解析用户发来的jwt呢?联想一下上面提到的spring security查找用户的权限是在SecurityContext中获取的,所以如果我们在spring security框架之后解析jwt,此时我们还没有将解析出来的数据存入SecurityContext中,所以spring security就会认为我们还没有登录,就会拒绝我们的访问,所以我们要在spring security框架之前就解析jwt并把相应权限存入到SecurityContext中,那我们要怎么才能做到在spring security框架之前就解析呢?这时我们就要用到国斌老师讲过的过滤器,因为过滤器是服务器端第一个接收到我们请求的,spring security框架实质上本来就是由很多过滤器组成的,但是spring security框架提供的过滤器都满足不了我们的需求,所以我们要自己写一个过滤器类,且要设置这个过滤器要在spring security框架过滤器之前启动,以免无法及时解析并存入权限而导致访问被拒绝;
第四步
在项目的根包下创建filter包,包里创建JwtAuthorizationFilter类,在此之前我们要先在配置类中的
configure(HttpSecurity http)中添加如下配置,为了设置这个过滤器在spring security框架过滤器之前启动
//一定要将自定义的JWT过滤器添加在(spring内置的过滤器之前) //UsernamePasswordAuthenticationFilter之前 http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
写过滤器类要继承OncePerRequestFilter并重写里面的doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)方法;
然后就要在用户发送的消息头中获取我们所需要的jwt;
String jwt = request.getHeader("Authorization");
获取之后我们最好验证一下这个jwt是否有效,避免有人恶意攻击我们的服务器;
如果我们没有获取到有效的jwt,我们会直接放行,此时会有两种可能,即未携带(未登录),还有携带的jwt无效(恶意攻击),后面自然会有spring security框架的其他地方来阻止这些请求,所以在这里我们并不需要处理,直接放行;
if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) { log.debug("未获取到有效的JWT数据,将直接放行"); filterChain.doFilter(request, response); return; }
如果获取到有效的jwt我们将开始解析jwt解析;
在我们开始解析之前要知道,解析jwt是有可能抛出异常的例如:签名异常(SignatureException),jwt格式错误异常(MalformedJwtException),或者jwt过期异常(ExpiredJwtException),且又因为我们的过滤器是最先执行的,所以我们不能用spring mvc的全局异常处理机制来处理我们的异常,只能手动try catch,注意!当我们try catch时最好做一个保底机制,即catch一下throwable为了避免未知的错误,且在此时我们如果想相应给客户端内容就不可以使用return了,我们要用到传奇老师教我们的PrintWriter 来向客户端相应内容,且需要用response.setContentType来设置响应格式,因为要统一响应格式,所以我们要响应一个JSON字符串的格式给客户端,所以又用到了我们的fastjson来进行转换,转换完就可以响应给客户端JSONString格式的错误了
// 设置响应的文档类型 response.setContentType("application/json; charset=utf-8"); PrintWriter writer = response.getWriter(); writer.println(jsonResultString); String jsonResultString = JSON.toJSONString(jsonResult); writer.println(jsonResultString);
// 尝试解析JWT log.debug("将尝试解析JWT……"); Claims claims = null; try { claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody(); } catch (SignatureException e) { String message = "非法访问!"; JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_SIGNATURE, message); String jsonResultString = JSON.toJSONString(jsonResult); PrintWriter writer = response.getWriter(); writer.println(jsonResultString); writer.close(); return; } catch (MalformedJwtException e) { String message = "非法访问!"; JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_MALFORMED, message); String jsonResultString = JSON.toJSONString(jsonResult); PrintWriter writer = response.getWriter(); writer.println(jsonResultString); writer.close(); return; } catch (ExpiredJwtException e) { String message = "登录已过期,请重新登录!"; JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, message); String jsonResultString = JSON.toJSONString(jsonResult); PrintWriter writer = response.getWriter(); writer.println(jsonResultString); writer.close(); return; } catch (Throwable e) { e.printStackTrace(); // 重要 String message = "服务器忙,请稍后再次尝试!"; JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_UNKNOWN, message); String jsonResultString = JSON.toJSONString(jsonResult); PrintWriter writer = response.getWriter(); writer.println(jsonResultString); writer.close(); return; }
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
我们解析出来的数据为一个 Claims 对象,我们可以用这个对象来打点获取我们之前传入的内容;
// 从JWT中获取用户的相关数据,例如id、username等 String username = claims.get("username", String.class); String username = claims.get("username", String.class); String authoritiesJsonString = claims.get("authorities", String.class);
此时我们得到的authorities(权限)还是一个Json字符串格式,所以我们还要把他转为List<GrantedAuthority>格式,又因为SimpleGrantedAuthority是GrantedAuthority的实现类,且更容易使用,所以我们把authorities转换为List<SimpleGrantedAuthority>格式;
在SecurityContext的认证信息中,包含着当事人(Principal)和权限(Authorities),又因为在使用了Spring Security框架的项目中,当事人的数据是可以被注入到处理请求的方法中的且因为我们要用到id和username两个参数,所以我们在生成认证信息的时候要把id和username封装到自定义对象(LoginPrincipal)中,所以我们要创建一个LoginPrincipal类,里面有id,username属性;
至此我们已经有了创建认证信息所需要的所有数据,所以我们要开始创建认证信息了
// 准备用于创建认证信息的当事人数据 LoginPrincipal loginPrincipal = new LoginPrincipal(); loginPrincipal.setId(id); loginPrincipal.setUsername(username);
// 准备用于创建认证信息的权限数据 List<SimpleGrantedAuthority> authorities = JSON.parseArray(authoritiesJsonString, SimpleGrantedAuthority.class);
// 将认证信息存储到SecurityContext中 log.debug("即将向SecurityContext中存入认证信息:{}", authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
至此,关于获取相应权限的事情已经结束,接下来的事情就是配置权限,给每个管理员相应的权限;
第一步,我们需要先在配置类上添加@EnableGlobalMethodSecurity(prePostEnabled = true)
开启在方法上使用注解配置权限的功能;
第二步,在任何处理请求的方法上,通过@PreAuthorize
注解来配置对应请求所需的权限,例如@PreAuthorize("hasAuthority('/ams/admin/read')"),这样持有对应权限的管理员就可以使用相应的功能了,
至此我们的spring security框架的基础使用就到此结束了;