安全问题是一个比较复杂的问题,之前使用过Shiro这个安全框架,确实挺简单的,后来使用SpringSecurity,SpringSecurity更细粒度可控,现在做项目基本都使用前后端分离的,很少再使用Thymeleaf这类模板引擎,而基于前后端分离的权限问题,则需要使用JWT(json web token)
本次搭建基于JWT的SpringSecurity,并搭建前后端分离的安全权限的开发环境,希望读者有一点springsecurity的基础
代码放在GitHub上
https://github.com/lhc0512/springsecurity-jwt
在pom.xml引入jar包
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
写一个继承于WebSecurityConfigurerAdapter的配置类,在重写带参httpsecurity,注入自定义的各种返回json的Handler
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AjaxLogoutSuccessHandler logoutSuccessHandler;
@Autowired
private AjaxAuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private AjaxAccessDeniedHandler accessDeniedHandler;
@Autowired
private AjaxAuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private AjaxAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
//取消session
http
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.httpBasic().authenticationEntryPoint(authenticationEntryPoint)
.and()
.authorizeRequests()
.anyRequest()
//使用rbac 角色绑定资源的方式
.access("@rbacauthorityservice.hasPermission(request,authentication)")
//.authenticated()
.and()
//该url比较特殊,需要和login.html的form的action的的url一致
.formLogin().loginPage("/login").successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler).permitAll()
.and()
.logout().logoutSuccessHandler(logoutSuccessHandler).permitAll()
.and()
.csrf().disable();
http.rememberMe().rememberMeParameter("remember-me")
.userDetailsService(myUserDetailsService).tokenValiditySeconds(300);
http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);
//使用jwt的Authentication
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 禁用headers缓存
http.headers().cacheControl();
}
}
这些handler的写法基本一样,你需要先写一个返回json的类
包含状态码,状态信息,返回对象,以及token
@Component
public class AjaxResponseBody implements Serializable {
private String status;
private String msg;
private Object result;
private String jwtToken;
以登陆的处理为例,你需要配置好返回的json,使用fastjson进行转换为json,最后返回给前端
@Component
public class AjaxAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
AjaxResponseBody responseBody = new AjaxResponseBody();
responseBody.setStatus("00");
responseBody.setMsg("Login Success!");
MyUserDetails myUserDetails = (MyUserDetails) authentication.getPrincipal();
String jwtToken = JwtTokenUtil.generateToken(myUserDetails.getUsername(), 300);
responseBody.setJwtToken(jwtToken);
response.getWriter().write(JSON.toJSONString(responseBody));
}
}
接下来在springsecurity的核心配置类中添加和数据库及密码加密的相关配置,注入自定义userDetailsService
使用BCryptPasswordEncoder进行加密
@Autowired
private MyUserDetailsService myUserDetailsService;
@Override
protected void configure (AuthenticationManagerBuilder auth) throws Exception {
//使用数据库
auth.userDetailsService(myUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
自定义一个UserDetails类,
@Component
public class MyUserDetails implements UserDetails ,Serializable {
private String username;
private String password;
private Collection<? extends GrantedAuthority> authorities;
自定义一个UserDitailsService,为了方便起见,我就不使用mybatis了,在代码中模拟从加密的数据库中查询用户信息,你注册用户信息的时候就该作如下加密
@Component
public class MyUserDetailsService implements UserDetailsService,Serializable {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUserDetails myUserDetails = new MyUserDetails();
myUserDetails.setUsername(username);
//模拟从数据库取出的密码
myUserDetails.setPassword(new BCryptPasswordEncoder().encode("12345"));
//模拟从数据库取出的权限
HashSet<SimpleGrantedAuthority> set = new HashSet<>();
// set.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
set.add(new SimpleGrantedAuthority("ROLE_USER"));
myUserDetails.setAuthorities(set);
return myUserDetails;
}
}
这个BCryptPasswordEncoder很强大,每次加密产生的密码都不一样,而认证的使用它又能识别出来,也是现在较为主流的加密算法,像MD5和SHA256等算法都被淘汰了
在springsecurity的核心配置有jwtAuthenticationTokenFilter,其配置如下,作用就是把传过来的token解析为username,再从数据库中查询用户信息放在authentication中
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
MyUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
//请求头为 Authorization
//请求体为 Bearer token
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
final String authToken = authHeader.substring("Bearer ".length());
String username = JwtTokenUtil.parseToken(authToken);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails != null) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
chain.doFilter(request, response);
}
}
注意UsernamePasswordAuthenticationToken的第一个参数有两种方式,建议传用户的整个信息,因为现在比较流行使用RBAC角色绑定资源的细粒度权限控制,该方式较为灵活,而不是硬编码在代码中,而使用该方式需要用到用户的权限信息
前面的配置有.access(“@rbacauthorityservice.hasPermission(request,authentication)”)
下面介绍如何使用RBAC
@Component("rbacauthorityservice")
public class RbacAuthorityService {
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
//得到的principal的信息是用户名还是整个用户信息取决于在自定义的authenticationProvider中传参的方式
Object userInfo = authentication.getPrincipal();
boolean hasPermission = false;
if (userInfo instanceof UserDetails) {
String username = ((UserDetails) userInfo).getUsername();
Collection<? extends GrantedAuthority> authorities = ((UserDetails) userInfo).getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().equals("ROLE_ADMIN")) {
//admin 可以访问的资源
Set<String> urls = new HashSet();
urls.add("/sys/**");
urls.add("/test/**");
AntPathMatcher antPathMatcher = new AntPathMatcher();
for (String url : urls) {
if (antPathMatcher.match(url, request.getRequestURI())) {
hasPermission = true;
break;
}
}
}
}
//user可以访问的资源
Set<String> urls = new HashSet();
urls.add("/test/**");
AntPathMatcher antPathMatcher = new AntPathMatcher();
for (String url : urls) {
if (antPathMatcher.match(url, request.getRequestURI())) {
hasPermission = true;
break;
}
}
return hasPermission;
} else {
return false;
}
}
}
接下来说说非对称加密的token怎样产生和解析的,你可以使用jdk自带的keytool工具,注意配置好JAVA_HOME,
输入,如下内容
keytool -genkey -alias jwt -keyalg RSA -keysize 1024 -validity 365 -keystore jwt.jks
意思是使用keytool生成密钥,别名为jwt,算法为RSA,有效期为365天,文件名为jwt,jks,把文件保存在当前打开cmd的路径下,它提示输入密码,我就输入lhc123吧
接下的输入可以忽略,回车pass
把生成的文件复制到resources目录下,写一个JwtTokenUtil 的生成和解析两个方法
public class JwtTokenUtil {
//加载jwt.jks文件
private static InputStream inputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream("jwt.jks");
private static PrivateKey privateKey = null;
private static PublicKey publicKey = null;
static {
try {
KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(inputStream, "lhc123".toCharArray());
privateKey = (PrivateKey) keyStore.getKey("jwt", "lhc123".toCharArray());
publicKey = keyStore.getCertificate("jwt").getPublicKey();
} catch (Exception e) {
e.printStackTrace();
}
}
public static String generateToken(String subject, int expirationSeconds) {
return Jwts.builder()
.setClaims(null)
.setSubject(subject)
.setExpiration(new Date(System.currentTimeMillis() + expirationSeconds * 1000))
.signWith(SignatureAlgorithm.RS256, privateKey)
.compact();
}
public static String parseToken(String token) {
String subject = null;
try {
Claims claims = Jwts.parser()
.setSigningKey(publicKey)
.parseClaimsJws(token).getBody();
subject = claims.getSubject();
} catch (Exception e) {
}
return subject;
}
}
好了项目搭建完毕,内容比较多,我也尽可能减少篇幅,但给大家一个清晰的思路,需要注意的是,每次请求后台,后台都需要刷新token,上名设置的token的有效期是5分钟,5分钟不做任何操作就需要重新登录,最标准的做法是把token保存到redis中,并且设置其有效时间