原来一直使用shiro作安全框架,配置起来至关方便,正好有机会接触下SpringSecurity,学习下这个。顺道结合下jwt,把安全信息管理的问题扔给客户端,前端
首先用的是SpringBoot,省去写各类xml的时间。而后把依赖加入一下web
<!--安全--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!--jwt--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
application.yml加上一点配置信息,后面会用spring
jwt: secret: secret expiration: 7200000 token: Authorization
可能用到代码,目录结构放出来一下数据库
首先是配置SecurityConfig,代码以下json
@Configuration @EnableWebSecurity// 这个注解必须加,开启Security @EnableGlobalMethodSecurity(prePostEnabled = true)//保证post以前的注解可使用 public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @Autowired JwtUserDetailsService jwtUserDetailsService; @Autowired JwtAuthorizationTokenFilter authenticationTokenFilter; //先来这里认证一下 @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(jwtUserDetailsService).passwordEncoder(passwordEncoderBean()); } //拦截在这配 @Override protected void configure(HttpSecurity http) throws Exception { http .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint) .and() .authorizeRequests() .antMatchers("/login").permitAll() .antMatchers("/haha").permitAll() .antMatchers("/sysUser/test").permitAll() .antMatchers(HttpMethod.OPTIONS, "/**").anonymous() .anyRequest().authenticated() // 剩下全部的验证都须要验证 .and() .csrf().disable() // 禁用 Spring Security 自带的跨域处理 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 定制咱们本身的 session 策略:调整为让 Spring Security 不建立和使用 session http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); } @Bean public PasswordEncoder passwordEncoderBean() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
ok,下面娓娓道来。首先咱们这个配置类继承了WebSecurityConfigurerAdapter,这里面有三个重要的方法须要咱们重写一下:后端
configure(HttpSecurity http):这个方法是咱们配置拦截的地方,exceptionHandling().authenticationEntryPoint(),这里面主要配置若是没有凭证,能够进行一些操做,这个后面会看jwtAuthenticationEntryPoint这个里面的代码。进行下一项配置,为了区分必须加入.and()。authorizeRequests()这个后边配置那些路径有须要什么权限,好比我配置的那几个url都是permitAll(),及不须要权限就能够访问。值得一提的是antMatchers(HttpMethod.OPTIONS, "/**"),是为了方便后面写先后端分离的时候前端过来的第一次验证请求,这样作,会减小这种请求的时间和资源使用。csrf().disable()是为了防止csdf攻击的,至于什么是csdf攻击,请自行百度。跨域
另起一行,以示尊重。sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);由于咱们要使用jwt托管安全信息,因此把Session禁止掉。看下SessionCreationPolicy枚举的几个参数:安全
public enum SessionCreationPolicy { ALWAYS,//老是会新建一个Session。 NEVER,//不会新建HttpSession,可是若是有Session存在,就会使用它。 IF_REQUIRED,//若是有要求的话,会新建一个Session。 STATELESS;//这个是咱们用的,不会新建,也不会使用一个HttpSession。 private SessionCreationPolicy() { } }
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);这行代码主要是用于JWT验证,后面再说。session
configureGlobal(AuthenticationManagerBuilder auth):这个方法是主要进行验证的地方,其中jwtUserDetailsService代码待会会看,passwordEncoder(passwordEncoderBean())是密码的一种加密方式。app
还有两个注解:@EnableWebSecurity,这个注解必须加,开启Security。
@EnableGlobalMethodSecurity(prePostEnabled = true),保证post以前的注解可使用
以上,咱们能够肯定了哪些路径访问不须要任何权限了,至于哪些路径须要什么权限接着往下看。
Security 中也有相似于shiro中主体的概念,就是在内存中存了一个东西,方便程序判断当前请求的用户有什么权限,须要实现UserDetails这个接口,因此我写了这个类,而且继承了我本身的类SysUser。
@Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) public class SecurityUserDetails extends SysUser implements UserDetails { private Collection<? extends GrantedAuthority> authorities; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } public SecurityUserDetails(String userName, Collection<? extends GrantedAuthority> authorities){ this.authorities = authorities; this.setUsername(userName); String encode = new BCryptPasswordEncoder().encode("123456"); this.setPassword(encode); this.setAuthorities(authorities); } /** * 帐户是否过时 * @return */ @Override public boolean isAccountNonExpired() { return true; } /** * 是否禁用 * @return */ @Override public boolean isAccountNonLocked() { return true; } /** * 密码是否过时 * @return */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 是否启用 * @return */ @Override public boolean isEnabled() { return true; } }
authorities就是咱们的权限,构造方法中我手动把密码set进去了,这不合适,包括权限我也是手动传进去的。这些东西都应该从数据库搜出来,我如今只是体验一把Security,角色权限那一套都没写,因此说明一下就行了,这个构造方法就是传进来一个标志(我这里用的是username,或者应该用userId什么的均可以),而后给你一个完整的主体信息,供其余地方使用。ok,next。
SecurityConfig配置里面不是有个方法是作真正的认证嘛,或者说从数据库拿信息,具体那认证信息的方法就是在这个方法里面。
@Service public class JwtUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String user) throws UsernameNotFoundException { System.out.println("JwtUserDetailsService:" + user); List<GrantedAuthority> authorityList = new ArrayList<>(); authorityList.add(new SimpleGrantedAuthority("ROLE_USER")); return new SecurityUserDetails(user,authorityList); } }
继承了Security提供的UserDetailsService接口,实现loadUserByUsername这个方法,咱们这里手动模拟从数据库搜出来一个叫USER的权限,经过刚才的构造方法,模拟生成当前user的信息,供后面jwt Filter一大堆验证。至于为何USER权限要加上“ROLE_”前缀,待会会说。
ok,如今咱们知道了怎么配置各类url是否须要权限才能访问,也知道了哪里能够拿到咱们的主体信息,那么继续。
千呼万唤始出来,JWT终于能够上场了。至于怎么生成这个token凭证,待会会说,如今假设前端已经拿到了token凭证,要访问某个接口了,看看怎么进行jwt业务的拦截吧。
@Component public class JwtAuthorizationTokenFilter extends OncePerRequestFilter { private final UserDetailsService userDetailsService; private final JwtTokenUtil jwtTokenUtil; private final String tokenHeader; public JwtAuthorizationTokenFilter(@Qualifier("jwtUserDetailsService") UserDetailsService userDetailsService, JwtTokenUtil jwtTokenUtil, @Value("${jwt.token}") String tokenHeader) { this.userDetailsService = userDetailsService; this.jwtTokenUtil = jwtTokenUtil; this.tokenHeader = tokenHeader; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { final String requestHeader = request.getHeader(this.tokenHeader); String username = null; String authToken = null; if (requestHeader != null && requestHeader.startsWith("Bearer ")) { authToken = requestHeader.substring(7); try { username = jwtTokenUtil.getUsernameFromToken(authToken); } catch (ExpiredJwtException e) { } } if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); if (jwtTokenUtil.validateToken(authToken, userDetails)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } chain.doFilter(request, response); } }
提早说一下,关于@Value注解参数开头写了。
doFilterInternal() 这个方法就是这个过滤器的精髓了。首先从header中获取凭证authToken,从中挖掘出来咱们的username,而后看看上下文中是否有咱们以这个username为标识的主体。没有,ok,去new一个(若是对象也能够new就行了。。。)。而后就是验证这个authToken 是否在有效期呢啊,验证token是否对啊等等吧。其实咱们刚刚把咱们SecurityUserDetails这个对象叫作主体,到这里我才发现有点自作多情了,由于生成Security认可的主体是经过UsernamePasswordAuthenticationToken相似与这种类去实现的,以前之因此叫SecurityUserDetails为主体,只是它存了一些关键信息。而后将主体信息————authentication,存入上下文环境,供后面使用。
个人不少工具类代码都放到了jwtTokenUtil,下面贴一下代码:
@Component public class JwtTokenUtil implements Serializable { private static final long serialVersionUID = -3301605591108950415L; @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; @Value("${jwt.token}") private String tokenHeader; private Clock clock = DefaultClock.INSTANCE; public String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); return doGenerateToken(claims, userDetails.getUsername()); } private String doGenerateToken(Map<String, Object> claims, String subject) { final Date createdDate = clock.now(); final Date expirationDate = calculateExpirationDate(createdDate); return Jwts.builder() .setClaims(claims) .setSubject(subject) .setIssuedAt(createdDate) .setExpiration(expirationDate) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } private Date calculateExpirationDate(Date createdDate) { return new Date(createdDate.getTime() + expiration); } public Boolean validateToken(String token, UserDetails userDetails) { SecurityUserDetails user = (SecurityUserDetails) userDetails; final String username = getUsernameFromToken(token); return (username.equals(user.getUsername()) && !isTokenExpired(token) ); } public String getUsernameFromToken(String token) { return getClaimFromToken(token, Claims::getSubject); } public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) { final Claims claims = getAllClaimsFromToken(token); return claimsResolver.apply(claims); } private Claims getAllClaimsFromToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } private Boolean isTokenExpired(String token) { final Date expiration = getExpirationDateFromToken(token); return expiration.before(clock.now()); } public Date getExpirationDateFromToken(String token) { return getClaimFromToken(token, Claims::getExpiration); } }
根据注释你能猜个大概吧,就再也不说了,有些东西是jwt方面的东西,今天就再也不多说了。
前面还说了一个发现没有凭证走一个方法,代码也贴一下。
@Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { System.out.println("JwtAuthenticationEntryPoint:"+authException.getMessage()); response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"没有凭证"); } }
实现AuthenticationEntryPoint这个接口,发现没有凭证,往response中放些东西。
下面跑一下几个接口,看看具体是怎么具体访问某个方法的吧,还有前面一点悬念一并解决。
先登陆一下,看看怎么生成token扔给前端的吧。
@RestController public class LoginController { @Autowired @Qualifier("jwtUserDetailsService") private UserDetailsService userDetailsService; @Autowired private JwtTokenUtil jwtTokenUtil; @PostMapping("/login") public String login(@RequestBody SysUser sysUser, HttpServletRequest request){ final UserDetails userDetails = userDetailsService.loadUserByUsername(sysUser.getUsername()); final String token = jwtTokenUtil.generateToken(userDetails); return token; } @PostMapping("haha") public String haha(){ UserDetails userDetails = (UserDetails) org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return "haha:"+userDetails.getUsername()+","+userDetails.getPassword(); } }
咱们前面配置中已经把login设置为随便访问了,这边经过jwt生成一个token串,具体方法请看jwtTokenUtil.generateToken,已经写了。只要知道这里面存了username、加密规则、过时时间就行了。
而后跑下haha接口,发现没问题,正常打印,说明主体也在上下文中了。
而后咱们访问一个须要权限的接口吧。
@RestController @RequestMapping("/sysUser") public class SysUserController { @GetMapping(value = "/test") public String test() { return "Hello Spring Security"; } @PreAuthorize("hasAnyRole('USER')") @PostMapping(value = "/testNeed") public String testNeed() { return "testNeed"; } }
访问testNeed接口,看到没,@PreAuthorize("hasAnyRole('USER')")这个说明须要USER权限!咱们在刚刚生成SecurityUserDetails这个的时候已经模拟加入了USER权限了,因此能够访问。如今说说为何加权限的时候须要加入前缀“ROLE_”.看hasAnyRole源码:
public final boolean hasAnyRole(String... roles) { return hasAnyAuthorityName(defaultRolePrefix, roles); } private boolean hasAnyAuthorityName(String prefix, String... roles) { Set<String> roleSet = getAuthoritySet(); for (String role : roles) { String defaultedRole = getRoleWithDefaultPrefix(prefix, role); if (roleSet.contains(defaultedRole)) { return true; } } return false; } private static String getRoleWithDefaultPrefix(String defaultRolePrefix, String role) { if (role == null) { return role; } if (defaultRolePrefix == null || defaultRolePrefix.length() == 0) { return role; } if (role.startsWith(defaultRolePrefix)) { return role; } return defaultRolePrefix + role; } 关键是 defaultRolePrefix 看这个类最上面 private String defaultRolePrefix = "ROLE_";
人家源码这么干的,我们就这么写呗,咱也不敢问。其实也有不须要前缀的方式,去看看SecurityExpressionRoot这个类吧,用的方法不同,也就是@PreAuthorize里面有另一个参数。
先说结论:Security上下文环境(里面有主体)生命周期只限于一次请求。
我作了一个测试:
把SecurityConfig里面configure(HttpSecurity http)这个方法里面
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
这行代码注释掉,不走那个jwt filter。就是不每次都添加上下上下文环境。
而后loginController改为
@RestController public class LoginController { @Autowired @Qualifier("jwtUserDetailsService") private UserDetailsService userDetailsService; @Autowired private JwtTokenUtil jwtTokenUtil; @PostMapping("/login") public String login(@RequestBody SysUser sysUser, HttpServletRequest request){ final UserDetails userDetails = userDetailsService.loadUserByUsername(sysUser.getUsername()); final String token = jwtTokenUtil.generateToken(userDetails); //添加 start UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); //添加 end return token; } @PostMapping("haha") public String haha(){ UserDetails userDetails = (UserDetails) org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return "haha:"+userDetails.getUsername()+","+userDetails.getPassword(); } }
而后登录,而后访问/haha,崩了,发现userDetails里面没数据。说明这会上下文环境中咱们主体不存在。
为何会这样呢?
SecurityContextPersistenceFilter 一次请求,filter链结束以后 会清除掉Context里面的东西。所说以,主体数据生命周期是一次请求。
源码以下:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { ...伪装有一堆代码... try { } finally { SecurityContext contextAfterChainExecution = SecurityContextHolder .getContext(); // Crucial removal of SecurityContextHolder contents - do this before anything // else. SecurityContextHolder.clearContext(); repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse()); request.removeAttribute(FILTER_APPLIED); } }
关键就是finally里面 SecurityContextHolder.clearContext(); 这句话。这才体现了那句,把维护信息的事扔给了客户端,你不请求,我也不知道你有啥。
配置起来感受还能够吧,使用jwt方式,生成token.因为上下文环境的生命周期是一次请求,因此在不请求的状况下,服务端不清楚用户有那些权限,真正实现了客户端维护安全信息,因此项目中也没有登出接口,由于不必。即便前端退出了,你有token,依然能够经过postman请求接口(token没有过时)。不一样于shiro能够把信息维护在服务端,要是登出,clear主体信息,访问接口就须要在登陆。不过Security这样也有好处,能够实现单点登录了,也方便作分布式。(只要你不一样子系统中验证那一套逻辑相同,或者在分布式的状况下有单独的验证系统)。