spring security在分布式项目下的配置方法(案例详解)
分布式项目和传统项目的区别就是,分布式项目有多个服务,每一个服务仅仅只实现一套系统中一个或几个功能,所有的服务组合在一起才能实现系统的完整功能。这会产生一个问题,多个服务之间session不能共享,你在其中一个服务中登录了,登录信息保存在这个服务的session中,别的服务不知道啊,所以你访问别的服务还得在重新登录一次,对用户十分不友好。为了解决这个问题,于是就产生了单点登录:
**jwt单点登录:**就是用户在登录服务登录成功后,登录服务会产生向前端响应一个token(令牌),以后用户再访问系统的资源的时候都要带上这个令牌,各大服务对这个令牌进行验证(令牌是否过期,令牌是否被篡改),验证通过了,可以访问资源,同时,令牌中也会携带一些不重要的信息,比如用户名,权限。通过解析令牌就能知道当前登录的用户和用户所拥有的权限。
下面我们就来写一个案例项目看看具体如何使用
1 创建项目结构
1.1 父工程cloud-security
这是父工程所需要的包
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.3.RELEASE</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> </dependencies>
1.2 公共工程 security-common
这是公共工程所需要的包
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.60</version> </dependency> <!--jwt所需包--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.2</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.2</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred --> <version>0.11.2</version> <scope>runtime</scope> </dependency>
1.3 认证服务security-sever
这个服务仅仅只有两项功能:
(1)用户登录,颁发令牌
(2)用户注册
我们这里只实现第一个功能
1.3.1 认证服务所需的包
<dependency> <groupId>cn.lx.security</groupId> <artifactId>security-common</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--通用mapper--> <dependency> <groupId>tk.mybatis</groupId> <artifactId>mapper-spring-boot-starter</artifactId> <version>2.0.4</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
1.3.2 配置application.yml
这里面的配置没什么好说的,都很简单
server: port: 8080 spring: datasource: url: jdbc:mysql:///security_authority?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC username: root password: driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false main: allow-bean-definition-overriding: true mybatis: type-aliases-package: cn.lx.security.doamin configuration: #驼峰 map-underscore-to-camel-case: true logging: level: cn.lx.security: debug
1.3.3 导入domain,dao,service,config
这个可以在上篇文档中找到,我们只需要service中的loadUserByUsername方法及其所调用dao中的方法
完整项目在我的github中,地址:git@github.com:lx972/cloud-security.git
配置文件我们也从上篇中复制过来MvcConfig,SecurityConfig
1.3.4 测试
访问http://localhost:8080/loginPage成功出现登录页面,说明认证服务的骨架搭建成功了
1.4 资源服务security-resource1
实际项目中会有很多资源服务,我只演示一个
为了简单,资源服务不使用数据库
1.4.1 资源服务所需的包
<dependency> <groupId>cn.lx.security</groupId> <artifactId>security-common</artifactId> <version>1.0-SNAPSHOT</version> </dependency>
1.4.2 配置application.yml
server: port: 8090 logging: level: cn.lx.security: debug
1.4.3 controller
拥有ORDER_LIST权限的才能访问
@RestController @RequestMapping("/order") public class OrderController { //@Secured("ORDER_LIST") @PreAuthorize(value = "hasAuthority('ORDER_LIST')") @RequestMapping("/findAll") public String findAll(){ return "order-list"; } }
拥有PRODUCT_LIST权限的才能访问
@RestController @RequestMapping("/product") public class ProductController { //@Secured("PRODUCT_LIST") @PreAuthorize(value = "hasAuthority('PRODUCT_LIST')") @RequestMapping("/findAll") public String findAll(){ return "product-list"; } }
1.4.4 security配置类
@Configuration @EnableWebSecurity //这个注解先不要加 //@EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { /** * Override this method to configure the {@link HttpSecurity}. Typically subclasses * should not invoke this method by calling super as it may override their * configuration. The default configuration is: * * <pre> * http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic(); * </pre> * * @param http the {@link HttpSecurity} to modify * @throws Exception if an error occurs */ @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests().anyRequest().authenticated(); } }
1.4.5 测试
访问http://localhost:8090/order/findAll
成功打印出order-list,服务搭建成功。
2 认证服务实现登录,颁发令牌
首先,我们必须知道我们的项目是前后端分离的项目,所以我们不能由后端控制页面跳转了,只能返回json串通知前端登录成功,然后前端根据后端返回的信息控制页面跳转。
2.1 登录成功或者登录失败后的源码分析
UsernamePasswordAuthenticationFilter中登录成功后走successfulAuthentication方法
/** * Default behaviour for successful authentication.认证成功之后的默认操作 * <ol> * <li>Sets the successful <tt>Authentication</tt> object on the * {@link SecurityContextHolder}</li> * <li>Informs the configured <tt>RememberMeServices</tt> of the successful login</li> * <li>Fires an {@link InteractiveAuthenticationSuccessEvent} via the configured * <tt>ApplicationEventPublisher</tt></li> * <li>Delegates additional behaviour to the {@link AuthenticationSuccessHandler}.</li> * </ol> * * Subclasses can override this method to continue the {@link FilterChain} after * successful authentication. * @param request * @param response * @param chain * @param authResult the object returned from the <tt>attemptAuthentication</tt> * method. * @throws IOException * @throws ServletException */ protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { if (logger.isDebugEnabled()) { logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult); } //将已通过认证的Authentication保存到securityContext容器中,应为后面的过滤器需要使用 SecurityContextHolder.getContext().setAuthentication(authResult); //记住我 rememberMeServices.loginSuccess(request, response, authResult); // Fire event if (this.eventPublisher != null) { eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent( authResult, this.getClass())); } //这个方法你点进去,就会发现,真正作业面跳转是在这里 successHandler.onAuthenticationSuccess(request, response, authResult); }
UsernamePasswordAuthenticationFilter中登录成功后走unsuccessfulAuthentication方法
/** * Default behaviour for unsuccessful authentication.认证失败之后的默认操作 * <ol> * <li>Clears the {@link SecurityContextHolder}</li> * <li>Stores the exception in the session (if it exists or * <tt>allowSesssionCreation</tt> is set to <tt>true</tt>)</li> * <li>Informs the configured <tt>RememberMeServices</tt> of the failed login</li> * <li>Delegates additional behaviour to the {@link AuthenticationFailureHandler}.</li> * </ol> */ protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { SecurityContextHolder.clearContext(); if (logger.isDebugEnabled()) { logger.debug("Authentication request failed: " + failed.toString(), failed); logger.debug("Updated SecurityContextHolder to contain null Authentication"); logger.debug("Delegating to authentication failure handler " + failureHandler); } //记住我失败 rememberMeServices.loginFail(request, response); //失败后的页面跳转都在这里 failureHandler.onAuthenticationFailure(request, response, failed); }
2.2 重写successfulAuthentication和unsuccessfulAuthentication方法
我们继承UsernamePasswordAuthenticationFilter这个过滤器
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter { /** * 这个方法必须有 * 在过滤器创建的时候手动将AuthenticationManager对象给这个过滤器使用 * @param authenticationManager 这个对象在自己写的SecurityConfig里面 */ public AuthenticationFilter(AuthenticationManager authenticationManager) { super.setAuthenticationManager(authenticationManager); } /** * Default behaviour for successful authentication.认证成功之后的默认操作 * @param request * @param response * @param chain * @param authResult the object returned from the <tt>attemptAuthentication</tt> * method. * @throws IOException * @throws ServletException */ @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { //认证成功的对象放入securityContext容器中 SecurityContextHolder.getContext().setAuthentication(authResult); // Fire event if (this.eventPublisher != null) { eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent( authResult, this.getClass())); } //创建令牌 Map<String, Object> claims=new HashMap<>(); SysUser sysUser = (SysUser) authResult.getPrincipal(); claims.put("username",sysUser.getUsername()); claims.put("authorities",authResult.getAuthorities()); //这个方法在下面介绍 String jwt = JwtUtil.createJwt(claims); //直接返回json ResponseUtil.responseJson(new Result("200", "登录成功",jwt),response); } /** * Default behaviour for unsuccessful authentication. * @param request * @param response * @param failed */ @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { //清理容器中保存的认证对象 SecurityContextHolder.clearContext(); //直接返回json ResponseUtil.responseJson(new Result("500", "登录失败"),response); } }
2.3 令牌创建
String jwt = JwtUtil.createJwt(claims);
这个方法干了什么事呢
/** * 创建令牌 * @param claims * @return */ public static String createJwt(Map<String, Object> claims){ //获取私钥 String priKey = KeyUtil.readKey("privateKey.txt"); //将string类型的私钥转换成PrivateKey,jwt只能接受PrivateKey的私钥 PKCS8EncodedKeySpec priPKCS8 = null; try { priPKCS8 = new PKCS8EncodedKeySpec(new BASE64Decoder().decodeBuffer(priKey)); KeyFactory keyf = KeyFactory.getInstance("RSA"); PrivateKey privateKey = keyf.generatePrivate(priPKCS8); //创建令牌 String jws = Jwts.builder() //设置令牌过期时间30分钟 .setExpiration(new Date(System.currentTimeMillis()+1000*60*30)) //为令牌设置额外的信息,这里我们设置用户名和权限,还可以根据需要继续添加 .addClaims(claims) //指定加密类型为rsa .signWith(privateKey, SignatureAlgorithm.RS256) //得到令牌 .compact(); log.info("创建令牌成功:"+jws); return jws; } catch (Exception e) { throw new RuntimeException("创建令牌失败"); } }
获取秘钥的方法
public class KeyUtil { /** * 读取秘钥 * @param keyName * @return */ public static String readKey(String keyName){ //文件必须放在resources根目录下 ClassPathResource resource=new ClassPathResource(keyName); String key =null; try { InputStream is = resource.getInputStream(); key = StreamUtils.copyToString(is, Charset.defaultCharset()); }catch (Exception e){ throw new RuntimeException("读取秘钥错误"); } if (key==null){ throw new RuntimeException("秘钥为空"); } return key; } }
2.4 响应json格式数据给前端
封装成了一个工具类
public class ResponseUtil { /** * 将结果以json格式返回 * @param result 返回结果 * @param response * @throws IOException */ public static void responseJson(Result result, HttpServletResponse response) throws IOException { response.setContentType("application/json;charset=utf-8"); response.setStatus(200); PrintWriter writer = response.getWriter(); writer.write(JSON.toJSONString(result)); writer.flush(); writer.close(); } }
返回结果
@Data @AllArgsConstructor @NoArgsConstructor public class Result { private String code; private String msg; private Object data; public Result(String code, String msg) { this.code = code; this.msg = msg; } }
3 认证服务实现令牌验证和解析
除了security配置类中配置的需要忽略的请求之外,其他所有请求必须验证请求头中是否携带令牌,没有令牌直接响应json数据,否则就验证和解析令牌。
security中有一个过滤器是实现令牌BasicAuthenticationFilter认证的,只不过他是basic的,没关系,我们继承它,然后重写解析basic的方法
3.1 源码分析
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { final boolean debug = this.logger.isDebugEnabled(); //获取请求头中Authorization的值 String header = request.getHeader("Authorization"); if (header == null || !header.toLowerCase().startsWith("basic ")) { //值不符合条件直接放行 chain.doFilter(request, response); return; } try { //就是解析Authorization String[] tokens = extractAndDecodeHeader(header, request); assert tokens.length == 2; //tokens[0]用户名 tokens[1]密码 String username = tokens[0]; if (debug) { this.logger .debug("Basic Authentication Authorization header found for user '" + username + "'"); } //判断是否需要认证(容器中有没有该认证对象) if (authenticationIsRequired(username)) { //创建一个对象 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, tokens[1]); authRequest.setDetails( this.authenticationDetailsSource.buildDetails(request)); //进行认证,我们不关心它如何认证,我们需要按自己的方法对令牌认证解析 Authentication authResult = this.authenticationManager .authenticate(authRequest); if (debug) { this.logger.debug("Authentication success: " + authResult); } //已认证的对象保存到securityContext中 SecurityContextHolder.getContext().setAuthentication(authResult); //记住我 this.rememberMeServices.loginSuccess(request, response, authResult); onSuccessfulAuthentication(request, response, authResult); } } catch (AuthenticationException failed) { SecurityContextHolder.clearContext(); if (debug) { this.logger.debug("Authentication request for failed: " + failed); } this.rememberMeServices.loginFail(request, response); onUnsuccessfulAuthentication(request, response, failed); if (this.ignoreFailure) { chain.doFilter(request, response); } else { this.authenticationEntryPoint.commence(request, response, failed); } return; } chain.doFilter(request, response); }
3.2 重写doFilterInternal方法
继承BasicAuthenticationFilter
public class TokenVerifyFilter extends BasicAuthenticationFilter { /** * Creates an instance which will authenticate against the supplied * {@code AuthenticationManager} and which will ignore failed authentication attempts, * allowing the request to proceed down the filter chain. * 在过滤器创建的时候手动将AuthenticationManager对象给这个过滤器使用 * @param authenticationManager 这个对象在自己写的SecurityConfig里面 */ public TokenVerifyFilter(AuthenticationManager authenticationManager) { super(authenticationManager); } /** * 过滤请求,判断是否携带令牌 * @param request * @param response * @param chain * @throws IOException * @throws ServletException */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String header = request.getHeader("Authorization"); if (header == null || !header.toLowerCase().startsWith("bearer ")) { //直接返回json ResponseUtil.responseJson(new Result("403", "用户未登录"),response); return; } //得到jwt令牌 String jwt = StringUtils.replace(header, "bearer ", ""); //解析令牌 String[] tokens = JwtUtil.extractAndDecodeJwt(jwt); //用户名 String username = tokens[0]; //权限 List<SysPermission> authorities= JSON.parseArray(tokens[1], SysPermission.class); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, null, authorities ); //放入SecurityContext容器中 SecurityContextHolder.getContext().setAuthentication(authRequest); chain.doFilter(request, response); } }
3.3 验证解析令牌
/** * 解析令牌 * @param compactJws * @return */ public static String decodeJwt(String compactJws){ //获取公钥 String pubKey = KeyUtil.readKey("publicKey.txt"); //将string类型的私钥转换成PublicKey,jwt只能接受PublicKey的公钥 KeyFactory keyFactory; try { X509EncodedKeySpec bobPubKeySpec = new X509EncodedKeySpec( new BASE64Decoder().decodeBuffer(pubKey)); keyFactory = KeyFactory.getInstance("RSA"); PublicKey publicKey = keyFactory.generatePublic(bobPubKeySpec); Claims body = Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(compactJws).getBody(); String jwtString = JSON.toJSONString(body); //OK, we can trust this JWT log.info("解析令牌成功:"+jwtString); return jwtString; } catch (Exception e) { throw new RuntimeException("解析令牌失败"); } } /** * 解析令牌并获取用户名和权限 * @param compactJws * @return String[0]用户名 * String[1]权限 */ public static String[] extractAndDecodeJwt(String compactJws){ //获取令牌的内容 String decodeJwt = decodeJwt(compactJws); JSONObject jsonObject = JSON.parseObject(decodeJwt); String username = jsonObject.getString("username"); String authorities = jsonObject.getString("authorities"); return new String[] { username, authorities }; }
3.4 修改security配置类
将自定义过滤器加入过滤器链
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private IUserService iUserService; @Autowired private BCryptPasswordEncoder bCryptPasswordEncoder; /** * 只有这个配置类有AuthenticationManager对象,我们要把这个类中的这个对象放入容器中 * 这样在别的地方就可以自动注入了 * @return * @throws Exception */ @Bean @Override public AuthenticationManager authenticationManager() throws Exception { AuthenticationManager authenticationManager = super.authenticationManagerBean(); return authenticationManager; } /** * Used by the default implementation of {@link #authenticationManager()} to attempt * to obtain an {@link AuthenticationManager}. If overridden, the * {@link AuthenticationManagerBuilder} should be used to specify the * {@link AuthenticationManager}. * * <p> * The {@link #authenticationManagerBean()} method can be used to expose the resulting * {@link AuthenticationManager} as a Bean. The {@link #userDetailsServiceBean()} can * be used to expose the last populated {@link UserDetailsService} that is created * with the {@link AuthenticationManagerBuilder} as a Bean. The * {@link UserDetailsService} will also automatically be populated on * {@link HttpSecurity#getSharedObject(Class)} for use with other * {@link SecurityContextConfigurer} (i.e. RememberMeConfigurer ) * </p> * * <p> * For example, the following configuration could be used to register in memory * authentication that exposes an in memory {@link UserDetailsService}: * </p> * * <pre> * @Override * protected void configure(AuthenticationManagerBuilder auth) { * auth * // enable in memory based authentication with a user named * // "user" and "admin" * .inMemoryAuthentication().withUser("user").password("password").roles("USER").and() * .withUser("admin").password("password").roles("USER", "ADMIN"); * } * * // Expose the UserDetailsService as a Bean * @Bean * @Override * public UserDetailsService userDetailsServiceBean() throws Exception { * return super.userDetailsServiceBean(); * } * * </pre> * * @param auth the {@link AuthenticationManagerBuilder} to use * @throws Exception */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //在内存中注册一个账号 //auth.inMemoryAuthentication().withUser("user").password("{noop}123").roles("USER"); //连接数据库,使用数据库中的账号 auth.userDetailsService(iUserService).passwordEncoder(bCryptPasswordEncoder); } /** * Override this method to configure {@link WebSecurity}. For example, if you wish to * ignore certain requests. * * @param web */ @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/css/**", "/img/**", "/plugins/**", "/favicon.ico", "/loginPage"); } /** * Override this method to configure the {@link HttpSecurity}. Typically subclasses * should not invoke this method by calling super as it may override their * configuration. The default configuration is: * * <pre> * http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic(); * </pre> * * @param http the {@link HttpSecurity} to modify * @throws Exception if an error occurs */ @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .httpBasic() .and() .authorizeRequests() .anyRequest().authenticated() .and() /** * 不要将自定义过滤器加component注解,而是在这里直接创建一个过滤器对象加入到过滤器链中,并传入authenticationManager * 启动后,过滤器链中会同时出现自定义过滤器和他的父类,他会自动覆盖,并不会过滤两次 * * 使用component注解会产生很多问题: * 1. web.ignoring()会失效,上面的资源还是会经过自定义的过滤器 * 2.过滤器链中出现的是他们父类中的名字 * 3.登录的时候(访问/login),一直使用匿名访问,不会去数据库中查询 */ .addFilterAt(new AuthenticationFilter(super.authenticationManager()), UsernamePasswordAuthenticationFilter.class) .addFilterAt(new TokenVerifyFilter(super.authenticationManager()), BasicAuthenticationFilter.class) //.formLogin().loginPage("/login.jsp").loginProcessingUrl("/login").defaultSuccessUrl("/index.jsp").failureForwardUrl("/failer.jsp").permitAll() .formLogin().loginPage("/loginPage").loginProcessingUrl("/login").permitAll() .and() .logout().logoutUrl("/logout").logoutSuccessUrl("/loginPage").invalidateHttpSession(true).permitAll(); } }
4 资源服务实现令牌验证和解析
复制认证服务的TokenVerifyFilter到资源服务
然后修改security的配置文件
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { /** * Override this method to configure the {@link HttpSecurity}. Typically subclasses * should not invoke this method by calling super as it may override their * configuration. The default configuration is: * * <pre> * http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic(); * </pre> * * @param http the {@link HttpSecurity} to modify * @throws Exception if an error occurs */ @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests().anyRequest().authenticated() .and() //禁用session .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() //添加自定义过滤器 .addFilterAt(new TokenVerifyFilter(super.authenticationManager()), BasicAuthenticationFilter.class); } }
到此这篇关于spring security在分布式项目下的配置方法(案例详解)的文章就介绍到这了,更多相关spring security分布式内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!