Spring Boot + Security + JWT 实现Token验证+多Provider——登录系统
首先呢就是需求:
1、账号、密码进行第一次登录,获得token,之后的每次请求都在请求头里加上这个token就不用带账号、密码或是session了。
2、用户有两种类型,具体表现在数据库中存用户信息时是分开两张表进行存储的。
为什么会分开存两张表呢,这个设计的时候是先设计的表结构,有分开的必要所以就分开存了,也没有想过之后Security 这块需要进行一些修改,但是分开存就分开存吧,Security 这块也不是很复杂。
maven就是这两:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency>
然后直接说代码吧,首先呢是实现dao层,这一层就不贴代码了,反正就是能根据用户名能返回用户信息就好了。
所以其实第一步是实现自己的安全模型:
这一步是实现UserDetails这个接口,其中我额外添加了用户类型、用户Id。其他的都是UserDetails接口必须实现的。
/** * 安全用户模型 * @author xuwang * Created on 2019/05/28 20:07 */ public class XWUserDetails implements UserDetails { //用户类型code public final static String USER_TYPE_CODE = "1"; //管理员类型code public final static String MANAGER_TYPE_CODE = "2"; //用户id private Integer userId; //用户名 private String username; //密码 private String password; //用户类型 private String userType; //用户角色表 private Collection<? extends GrantedAuthority> authorities; public XWUserDetails(Integer userId,String username, String password, String userType, Collection<? extends GrantedAuthority> authorities){ this.userId = userId; this.username = username; this.password = password; this.userType = userType; this.authorities = authorities; } /** * 获取权限列表 * @return Collection */ @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } /** * 获取用户Id * @return String */ public Integer getUserId() { return userId; } /** * 获取用户类型 * @return String */ public String getUserType() { return userType; } /** * 获取密码 * @return String */ @Override public String getPassword() { return password; } /** * 获取用户名 * @return String */ @Override public String getUsername() { return username; } /** * 账号是否未过期 * @return boolean */ @Override public boolean isAccountNonExpired() { return true; } /** * 账号是否未锁定 * @return boolean */ @Override public boolean isAccountNonLocked() { return true; } /** * 凭证是否未过期 * @return boolean */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 账号是否已启用 * @return boolean */ @Override public boolean isEnabled() { return true; }
第二步是实现两个UserDetailsService因为要从两张表里进行查询,所以我就实现了两个UserDetailsService
这一步呢,注入了dao层的东西,从数据库中查询出用户信息,构建XWUserDetails并返回。
/** * Manager专用的UserDetailsService * @author xuwang * Created on 2019/06/01 15:58 */ @Service("managerDetailsService") public class ManagerDetailsServiceImpl implements UserDetailsService { @Resource ScManagerMapper_Security scManagerMapper_security; @Resource ScRoleMapper_Security scRole_Mapper_security; /** * 根据用户名从数据库中获取XWUserDetails * @param username * @return UserDetails * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //获取用户信息 ScManager user = scManagerMapper_security.findByUsername(username); //获取角色列表 List<String> roles = scRole_Mapper_security.findByUsername(username); if (user == null) { throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username)); } else { return new XWUserDetails(user.getId(),user.getManagerName(), user.getLoginPass(),XWUserDetails.MANAGER_TYPE_CODE, roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())); } } }
/** * User专用的UserDetailsService * @author xuwang * Created on 2019/06/01 15:58 */ @Service("userDetailsService") public class UserDetailsServiceImpl implements UserDetailsService { @Resource ScUserMapper_Security userMapper_security; @Resource ScRoleMapper_Security scRole_Mapper_security; /** * 根据用户名从数据库中获取XWUserDetails * @param username * @return UserDetails * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //获取用户信息 ScUser user = userMapper_security.findByUsername(username); //获取角色列表 List<String> roles = scRole_Mapper_security.findByUsername(username); if (user == null) { throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username)); } else { return new XWUserDetails(user.getId(),user.getName(),user.getPassword(), XWUserDetails.MANAGER_TYPE_CODE, roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())); } } }
第三步,实现两个UsernamePasswordAuthenticationToken
这一步的话,其实单看不知道为什么实现两个类,但是注释里面我写了,然后真正的为什么,整体流程,到最后说吧。
/** * manager专用的UsernamePasswordAuthenticationToken * AuthenticationManager会遍历使用Provider的supports()方法,判断AuthenticationToken是不是自己想要的 * @author xuwang * Created on 2019/06/01 15:58 */ public class ManagerAuthenticationToken extends UsernamePasswordAuthenticationToken { public ManagerAuthenticationToken(Object principal, Object credentials) { super(principal, credentials); } }
/** * User专用的UsernamePasswordAuthenticationToken * AuthenticationManager会遍历使用Provider的supports()方法,判断AuthenticationToken是不是自己想要的 * @author xuwang * Created on 2019/06/01 15:58 */ public class UserAuthenticationToken extends UsernamePasswordAuthenticationToken { public UserAuthenticationToken(Object principal, Object credentials){ super(principal,credentials); } }
第四步,实现两个AuthenticationProvider
这个地方用到了上面的两个类,重点是supports()方法,这个方法是用来校验传进来的UsernamePasswordAuthenticationToken的,反正就代表着这个ManagerAuthenticationProvider就只适用于ManagerAuthenticationToken,另一个同理,具体也是最后说吧。
/** * Manager专用的AuthenticationProvider * 选择实现DaoAuthenticationProvider是因为比较方便且能用 * @author xuwang * Created on 2019/06/01 15:58 */ public class ManagerAuthenticationProvider extends DaoAuthenticationProvider { /** * 初始化 将使用Manager专用的userDetailsService * @param encoder * @param userDetailsService */ public ManagerAuthenticationProvider(PasswordEncoder encoder, UserDetailsService userDetailsService){ setPasswordEncoder(encoder); setUserDetailsService(userDetailsService); } @Override public void setPasswordEncoder(PasswordEncoder passwordEncoder) { super.setPasswordEncoder(passwordEncoder); } @Override public void setUserDetailsPasswordService(UserDetailsPasswordService userDetailsPasswordService) { super.setUserDetailsPasswordService(userDetailsPasswordService); } /** * 判断只有传入ManagerAuthenticationToken的时候才使用这个Provider * supports会在AuthenticationManager层被调用 * @param authentication * @return */ public boolean supports(Class<?> authentication) { return ManagerAuthenticationToken.class.isAssignableFrom(authentication); } }
/** * 实现User专用的AuthenticationProvider * 选择实现DaoAuthenticationProvider是因为比较方便且能用 * @author xuwang * Created on 2019/06/01 15:58 */ public class UserAuthenticationProvider extends DaoAuthenticationProvider { /** * 初始化 将使用User专用的userDetailsService * @param encoder * @param userDetailsService */ public UserAuthenticationProvider(PasswordEncoder encoder, UserDetailsService userDetailsService){ setPasswordEncoder(encoder); setUserDetailsService(userDetailsService); } @Override public void setPasswordEncoder(PasswordEncoder passwordEncoder) { super.setPasswordEncoder(passwordEncoder); } @Override public void setUserDetailsPasswordService(UserDetailsPasswordService userDetailsPasswordService) { super.setUserDetailsPasswordService(userDetailsPasswordService); } /** * 判断只有传入UserAuthenticationToken的时候才使用这个Provider * supports会在AuthenticationManager层被调用 * @param authentication * @return */ public boolean supports(Class<?> authentication) { return UserAuthenticationToken.class.isAssignableFrom(authentication); } }
第五步就是继承实现这个WebSecurityConfigurerAdapter
这一步呢,主要是将上面两个AuthenticationProvider加入到AuthenticationManager中,并向Spring中注入这个AuthenticationManager供Service在校验账号密码时使用。
同时还注入了一个PasswordEncoder,也是同样供Service层使用,反正就是其他地方能用就是了,就不用new了。
然后是configure方法,这个里面,具体就是Security 的配置了,为什么怎么写我就不说了,反正我这里实现了url的配置、Session的关闭、Filter的设置、设置验证失败权限不足自定义返回值。
其中Filter、和验证失败权限不足再看后面的代码吧,我也会贴上的。
关于AuthenticationManager,就是先用加密工具、和之前实现的UserDetailsService 构造两个DaoAuthenticationProvider,然后在configureGlobal()方法中添加这两个DaoAuthenticationProvider,最后authenticationManagerBean()方法进行注入。
/** * Security 配置 * @author xuwang * Created on 2019/06/01 15:58 */ @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class XWSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired @Qualifier("userDetailsService") private UserDetailsService userDetailsService; @Autowired @Qualifier("managerDetailsService") private UserDetailsService managerDetailsService; @Resource private XWAuthenticationTokenFilter xwAuthenticationTokenFilter; @Resource private EntryPointUnauthorizedHandler entryPointUnauthorizedHandler; @Resource private RestAccessDeniedHandler restAccessDeniedHandler; /** * 注入UserAuthenticationProvider * @return */ @Bean("UserAuthenticationProvider") DaoAuthenticationProvider daoUserAuthenticationProvider(){ return new UserAuthenticationProvider(encoder(), userDetailsService); } /** * 注入ManagerAuthenticationProvider * @return */ @Bean("ManagerAuthenticationProvider") DaoAuthenticationProvider daoMangerAuthenticationProvider(){ return new ManagerAuthenticationProvider(encoder(), managerDetailsService); } /** * 向AuthenticationManager添加Provider * @return */ @Autowired public void configureGlobal(AuthenticationManagerBuilder auth){ auth.authenticationProvider(daoUserAuthenticationProvider()); auth.authenticationProvider(daoMangerAuthenticationProvider()); } @Autowired public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception { authenticationManagerBuilder.userDetailsService(this.userDetailsService).passwordEncoder(passwordEncoder); } /** * 注入AuthenticationManager * @return */ @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } /** * 注入PasswordEncoder * @return */ @Bean public PasswordEncoder encoder() { PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); return encoder; } /** * 具体Security 配置 * @return */ @Override protected void configure(HttpSecurity http) throws Exception { http. csrf().disable().//默认开启,这里先显式关闭csrf sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //Spring Security永远不会创建HttpSession,它不会使用HttpSession来获取SecurityContext .and() .authorizeRequests() .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() //任何用户任意方法可以访问/** .antMatchers("/base/login").permitAll() //任何用户可以访问/user/** .anyRequest().authenticated() //任何没有匹配上的其他的url请求,只需要用户被验证 .and() .headers().cacheControl(); http.addFilterBefore(xwAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); http.exceptionHandling().authenticationEntryPoint(entryPointUnauthorizedHandler).accessDeniedHandler(restAccessDeniedHandler); } }
然后是上面的两个Handler
这个很简单,就是实现AccessDeniedHandler和AuthenticationEntryPoint就是了。
/** * 身份验证失败自定401返回值 * * @author xuwang * Created on 2019/05/29 16:10. */ @Component public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { httpServletResponse.setHeader("Access-Control-Allow-Origin", "*"); httpServletResponse.setStatus(401); } }
/** * 权限不足自定403返回值 * * @author xuwang * Created on 2019/05/29 16:10. */ @Component public class RestAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { httpServletResponse.setHeader("Access-Control-Allow-Origin", "*"); httpServletResponse.setStatus(403); } }
再然后就是JWT这一块了。
首先是一个Token的工具类,里面有些什么东西直接看注释就好了。
/** * JWT工具类 * * @author xuwang * Created on 2019/05/28 20:16. */ @Component public class XWTokenUtil implements Serializable { /** * 密钥 */ private final String secret = "11111111"; /** * 从数据声明生成令牌 * * @param claims 数据声明 * @return 令牌 */ private String generateToken(Map<String, Object> claims) { //有效时间 Date expirationDate = new Date(System.currentTimeMillis() + 2592000L * 1000); return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, secret).compact(); } /** * 从令牌中获取数据声明 * * @param token 令牌 * @return 数据声明 */ private Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } catch (Exception e) { claims = null; } return claims; } /** * 生成令牌 * * @param userDetails 用户 * @return 令牌 */ public String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(2); claims.put("sub", userDetails.getUsername()); claims.put("userId", ((XWUserDetails)userDetails).getUserId()); claims.put("userType", ((XWUserDetails)userDetails).getUserType()); claims.put("created", new Date()); return generateToken(claims); } /** * 从令牌中获取用户名 * * @param token 令牌 * @return 用户名 */ public String getUsernameFromToken(String token) { String username; try { Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } /** * 从令牌中获取用户类型 * * @param token 令牌 * @return 用户类型 */ public String getUserTypeFromToken(String token) { String userType; try { Claims claims = getClaimsFromToken(token); userType = (String) claims.get("userType"); } catch (Exception e) { userType = null; } return userType; } /** * 从令牌中获取用户Id * * @param token 令牌 * @return 用户Id */ public Integer getUserIdFromToken(String token) { Integer userId; try { Claims claims = getClaimsFromToken(token); userId = (Integer) claims.get("userId"); } catch (Exception e) { userId = null; } return userId; } /** * 判断令牌是否过期 * * @param token 令牌 * @return 是否过期 */ public Boolean isTokenExpired(String token) { try { Claims claims = getClaimsFromToken(token); Date expiration = claims.getExpiration(); return expiration.before(new Date()); } catch (Exception e) { return false; } } /** * 刷新令牌 * * @param token 原令牌 * @return 新令牌 */ public String refreshToken(String token) { String refreshedToken; try { Claims claims = getClaimsFromToken(token); claims.put("created", new Date()); refreshedToken = generateToken(claims); } catch (Exception e) { refreshedToken = null; } return refreshedToken; } /** * 验证令牌 * * @param token 令牌 * @param userDetails 用户 * @return 是否有效 */ public Boolean validateToken(String token, UserDetails userDetails) { XWUserDetails user = (xwUserDetails) userDetails; String username = getUsernameFromToken(token); return (username.equals(user.getUsername()) && !isTokenExpired(token)); } }
然后是Filter
这个Filter大家都知道请求发过来,会先进行这个Filter里面的方法,这里的逻辑也很简单,从Token中拿到身份信息,并进行验证,验证这里我写得比简单,可以再加逻辑。
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
重点是这三行,这三行是什么意思呢,前面说了需求是第一次登录验证成功,以后发请求使用Token就好了,这三行之前的逻辑是在校验Token,从Token中获取用户信息,但系统中进行权限管理的是Spring Security,并没有使用Spring Security 进行验证啊,
所以需要做的就是这三行,这三行中的SecurityContextHolder就是:SecurityContextHolder是用来保存SecurityContext的。SecurityContext中含有当前正在访问系统的用户的详细信息,
实际就是使用用户信息构建authentication放到SecurityContextHolder就等于用户已经登录了,就不用再校验密码什么的了。
/** * JWT Filter * * @author xuwang * Created on 2019/05/29 16:10. */ @Component public class XWAuthenticationTokenFilter extends OncePerRequestFilter { @Resource ManagerDetailsServiceImpl managerDetailsService; @Resource UserDetailsServiceImpl userDetailsService; @Resource private XWTokenUtil xwTokenUtil; /** * 获取验证token中的身份信息 * @author xuwang */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { //从请求头中获取token String authHeader = request.getHeader("Authorization"); //token前缀 String tokenHead = "Bearer "; if (authHeader != null && authHeader.startsWith(tokenHead)) { //去掉token前缀 String authToken = authHeader.substring(tokenHead.length()); //从token中获取用户名 String username = XWTokenUtil.getUsernameFromToken(authToken); String userType = XWTokenUtil.getUserTypeFromToken(authToken); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = null; //根据从token中获取用户名从数据库中获取一个userDetails if(userType.equals(XWUserDetails.USER_TYPE_CODE)){ //普通用户 userDetails = userDetailsService.loadUserByUsername(username); }else if(userType.equals(XWUserDetails.MANAGER_TYPE_CODE)){ //管理员 userDetails = managerDetailsService.loadUserByUsername(username); } if (xwTokenUtil.validateToken(authToken, userDetails)) { //token中的用户信息和数据库中的用户信息对比成功后将用户信息加入SecurityContextHolder相当于登陆 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } chain.doFilter(request, response); } }
然后是用户登录的接口了,我直接贴Service层的代码吧
/** * @ClassName: loginServiceImpl * @ClassNameExplain: * @Description: 业务层实现类 * @author xuwang * @date 2019-05-31 16:15:46 */ @Service public class LoginServiceImpl implements ILoginService { static final Logger logger = LoggerFactory.getLogger(LoginServiceImpl.class); @Resource private AuthenticationManager authenticationManager; @Autowired @Qualifier("userDetailsService") private UserDetailsService userDetailsService; @Autowired @Qualifier("managerDetailsService") private UserDetailsService managerDetailsService; @Resource private XWTokenUtil xwTokenUtil; @Override public LoginVO login(LoginIO loginIO) throws Exception { //不同的用户类型使用不同的登陆方式 String token = ""; UserDetails userDetails = null; if(loginIO.getType().equals(XWUserDetails.USER_TYPE_CODE)){ //登录 login(new UserAuthenticationToken(loginIO.getUserName(), loginIO.getPassword())); userDetails = userDetailsService.loadUserByUsername(loginIO.getUserName()); token = xwTokenUtil.generateToken(userDetails); logger.info("user[{}]登陆成功",loginIO.getUserName()); }else if(loginIO.getType().equals(XWUserDetails.MANAGER_TYPE_CODE)){ login(new ManagerAuthenticationToken(loginIO.getUserName(), loginIO.getPassword())); userDetails = managerDetailsService.loadUserByUsername(loginIO.getUserName()); token = xwUtil.generateToken(userDetails); logger.info("manager[{}]登陆成功",loginIO.getUserName()); }else { logger.error("type[{}]参数错误",loginIO.getType()); //type参数错误 throw new BusinessException(ExceptionConstants.PARAM_INVALID_CODE, ExceptionConstants.PARAM_INVALID_MSG); } LoginVO loginVO = new LoginVO(); loginVO.setToken(token); loginVO.setUserId(((XWUserDetails)userDetails).getUserId()); return loginVO == null ? new LoginVO() : loginVO; } /** * 校验账号密码并进行登陆 * @param upToken */ private void login(UsernamePasswordAuthenticationToken upToken){ //验证 Authentication authentication = authenticationManager.authenticate(upToken); //将用户信息保存到SecurityContextHolder=登陆 SecurityContextHolder.getContext().setAuthentication(authentication); } }
这个Service解释一下就是:
loginIO能接收到用户信息:账号UserName、密码Password、类型Type之类的,然后使用AuthenticationManager 进行校验,再使用SecurityContextHolder进行登录操作(上面解释过了),最后返回Token(xwTokenUtil工具类生成的)和用户Id。
最后解释一下其中的流程,已经我为什么这么去实现吧。
从Service中我们可以看到,登录时使用的是先使用账号密码构建了一个UsernamePasswordAuthenticationToken,我这里构建的是UserAuthenticationToken、ManagerAuthenticationToken,不过影响不大,都是它的子类,AuthenticationManager的authenticate将接受一个UsernamePasswordAuthenticationToken来进行验证,最后才登录。
上面的相当于Security的登录使用流程。
然后解释一下前面的那些所有的疑惑,在Service中使用AuthenticationManager的authenticate()方法进行校验的时候,实际上是会把UsernamePasswordAuthenticationToken传递给Provider进行校验的,Provider里呢又让Service去校验的。这是类和类之间的关系,然后还有实际代码关系是,AuthenticationManager中会有一个Provider列表,进行校验的时候会遍历使用每一个Provider的supports()方法,这个supports()方法将校验传进来的UsernamePasswordAuthenticationToken是自己想要的UsernamePasswordAuthenticationToken吗,如果是的话就使用这个Provider进行校验。所以我实现了UserAuthenticationToken、ManagerAuthenticationToken,还实现了ManagerAuthenticationProvider、UserAuthenticationProvider以及其中的supports()方法,这样authenticationManager.authenticate(new UserAuthenticationToken)就会使用UserAuthenticationProvider中的UserDetailsServiceImpl去校验了。ManagerAuthenticationToken同理。
上面的我自己是觉得写得是比较清晰了。如果实在是看不明白,或者其实是我还是写得太烂了,可以自己跟一下代码,就从AuthenticationManager.authenticate()方法跟进去就好了,
具体跟代码的时候要注意,AuthenticationManager的默认实现是ProviderManager,所以其实看到的是ProviderManager的authenticate()方法
图中:
1. 获取到Authentication的类信息
2. 得到Provider列表的迭代器
3.进行遍历
4.调用Provider的supports()方法
所以我重写了两个provider和其中supports()方法,和两个AuthenticationToken。
然后其实这个东西并不算是太复杂,自己去用和学习的时候,最好还是先实现,然后在慢慢跟代码,去猜去思考其中的流程,就好了。
最后是为什么要这样去写代码、去注入、用这个方式进行加密、以及token中存放的信息、loginIo得设置等等的,这些都是可以任意更改的,无需纠结太多,根据根据个人习惯和当时的业务改就好了,至于到底怎样才是最好的,我也没太认真的去思考过,毕竟加班的时候只能先实现功能了,至于为什么在写这个博客的时候还不去思考的原因就是。。。因为这些并不是重点