原来一直使用 shiro 做安全框架,配置起来相当方便,正好有机会接触下 SpringSecurity,学习下这个。顺道结合下 jwt,把安全信息管理的问题扔给客户端,
准备
首先用的是 SpringBoot,省去写各种 xml 的时间。然后把依赖加入一下
<!-- 安全 -->
<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 加上一点配置信息,后面会用
jwt:
secret: secret
expiration: 7200000
token: Authorization
可能用到代码,目录结构放出来一下
配置
SecurityConfig 配置
首先是配置 SecurityConfig,代码如下
@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 验证,后面再说。
- configure(WebSecurity web):这个方法我代码中没有用,这个方法主要用于访问一些静态的东西控制。其中 ignoring()方法可以让访问跳过 filter 验证。
- configureGlobal(AuthenticationManagerBuilder auth):这个方法是主要进行验证的地方,其中 jwtUserDetailsService 代码待会会看,passwordEncoder(passwordEncoderBean())是密码的一种加密方式。
还有两个注解:@EnableWebSecurity,这个注解必须加,开启 Security。
@EnableGlobalMethodSecurity(prePostEnabled = true),保证 post 之前的注解可以使用
以上,我们可以确定了哪些路径访问不需要任何权限了,至于哪些路径需要什么权限接着往下看。
SecurityUserDetails
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。
JwtUserDetailsService
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 是否需要权限才能访问,也知道了哪里可以拿到我们的主体信息,那么继续。
JwtAuthorizationTokenFilter
千呼万唤始出来,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 方面的东西,今天就不再多说了。
JwtAuthenticationEntryPoint
前面还说了一个发现没有凭证走一个方法,代码也贴一下。
@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 中放些东西。
run code
下面跑一下几个接口,看看具体是怎么具体访问某个方法的吧,还有前面一点悬念一并解决。
登录
先登录一下,看看怎么生成 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 这样也有好处,可以实现单点登陆了,也方便做分布式。(只要你不同子系统中验证那一套逻辑相同,或者在分布式的情况下有单独的验证系统)。