赞
踩
从结构上看,CAS 包含两个部分: CAS Server 和 CAS Client。CAS Server 需要独立部署,主要负责对用户的认证工作;CAS Client 负责处理对客户端受保护资源的访问请求,需要登录时,重定向到 CAS Server。图1 是 CAS 最基本的协议过程:
CAS Client 与受保护的客户端应用部署在一起,以 Filter 方式保护受保护的资源。对于访问受保护资源的每个 Web 请求,CAS Client 会分析该请求的 Http 请求中是否包含 Service Ticket,如果没有,则说明当前用户尚未登录,于是将请求重定向到指定好的 CAS Server 登录地址,并传递 Service (也就是要访问的目的资源地址),以便登录成功过后转回该地址。用户在第 3 步中输入认证信息,如果登录成功,CAS Server 随机产生一个相当长度、唯一、不可伪造的 Service Ticket,并缓存以待将来验证,之后系统自动重定向到 Service 所在地址,并为客户端浏览器设置一个 Ticket Granted Cookie(TGC),CAS Client 在拿到 Service 和新产生的 Ticket 过后,在第 5,6 步中与 CAS Server 进行身份核实,以确保 Service Ticket 的合法性
在该协议中,所有与 CAS 的交互均采用 SSL 协议,确保,ST 和 TGC 的安全性。协议工作过程中会有 2 次重定向的过程,但是 CAS Client 与 CAS Server 之间进行 Ticket 验证的过程对于用户是透明的
另外,CAS 协议中还提供了 Proxy (代理)模式,以适应更加高级、复杂的应用场景,具体介绍可以参考 CAS 官方网站上的相关文档
以上摘自百度百科
建表主要以下几张表,不过也可以添加一些其他表,比如保存前端模块的表module,保存前端菜单的表menu,至于表中字段根据自己需求来设计,学习也是需要自己来参与的。
主要依赖版本:
springboot-2.3.0
spring-security-casspring-security-cas 5.2.2
<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.security</groupId> <artifactId>spring-security-cas</artifactId> <version>5.2.2.RELEASE</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.11</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version> </dependency> <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.12</version> </dependency> </dependencies>
对于CAS服务端搭建就省略了,可以去官网 下载,实际就是一个war包,放到tomcat的webapps下就可以了,具体安装流程可以自行Google,资源有很多。
server: servlet: context-path: "/test" spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cas?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 #客户端地址 app: server: host: url: http://${host:localhost}:8080/test login: url: /login logout: url: /logout #cas认证中心地址 cas: server: host: http://localhost:8081/cas login_url: http://localhost:8081/cas/login logout_url: http://localhost:8081/cas/logout?service=http://${host:localhost}:8080/test/app/login #logging: # level: # root: debug
声明一下主要展示spring security如何配置CAS,其他基本操作就不一一列举。
首先新建一个YmlProperties类来读取application.yml的相关配置
@Component @Data public class YmlProperties { @Value("${app.server.host.url}") private String appServerUrl; @Value("${app.login.url}") private String appLoginUrl; @Value("${app.logout.url}") private String appLogoutUrl; @Value("${cas.server.host}") private String casServerUrl; @Value("${cas.server.login_url}") private String casServerLoginUrl; @Value("${cas.server.logout_url}") private String casServerLogoutUrl; }
新建UserDetail类封装我们需要的用户信息
注意如果没有相关需求,此类可以不建,直接使用spring security提供的org.springframework.security.core.userdetails.User类。
public class UserDetail extends User implements UserDetails, CredentialsContainer { private Long userId; public UserDetail(String username,String password, Collection<? extends GrantedAuthority> authorities){ super(username,password,true,true,true,true,authorities); } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } }
新建MyUserDetailsService类实现AuthenticationUserDetailsService接口
涉及的查询语句可以自己完成,一个是根据用户名查询用户信息,一个是根据用户id查询角色权限,根据上述建的中间表关联查询,都是一些基础的查询语句,这里就不贴出来了。
@Service public class MyUserDetailsService implements AuthenticationUserDetailsService<CasAssertionAuthenticationToken>{ @Autowired private UserService userService; @Autowired private PermissionMapper permissionMapper; @Override public UserDetails loadUserDetails(CasAssertionAuthenticationToken token) throws UsernameNotFoundException { User user = userService.findByName(token.getName()); if (Objects.isNull(user)) { throw new RuntimeException("用户不存在"); } List<String> roleCodes = permissionMapper.getPermissionByUser(user.getId()); if (CollectionUtils.isEmpty(roleCodes)) { throw new RuntimeException("账户[" + user.getUsername() + "]未绑定角色"); } Set<GrantedAuthority> authorities = new HashSet<>(); for (String roleCode : roleCodes) { authorities.add(new SimpleGrantedAuthority(roleCode)); } UserDetail userDetail=new UserDetail(user.getUsername(),user.getPassword(),authorities); userDetail.setUserId(user.getId()); return userDetail; } }
新建SecurityConfiguration类实现WebSecurityConfigurerAdapter接口
如果想了解每个Bean的相关作用和spring security是如何和CAS交互的,参考spring官网说明 ,官网详细介绍了整个流程,一定要自己理解。
不管是spring security还是其他技术,个人建议还是首先去看官网给的文档,他可以解决大部分问题,而后再去Google。
其实以下这些配置就是根据官网一步一步配置的,官网给的是xml配置,可能有的小伙伴一看到就直接放弃不看去Google了,如今都用springboot谁还学习xml如何配置,不过如果静下心去看的话,完全可以根据xml配置自己转化成配置类。
@Slf4j @EnableWebSecurity //开启@Secured和@PreAuthorize @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) @Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired private YmlProperties ymlProperties; @Autowired private MyUserDetailsService myUserDetailsService; @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() //配置安全策略 .antMatchers("/login").permitAll() .anyRequest().authenticated() .and() .csrf().disable() .logout().permitAll() //logout不需要验证 .and() .headers().frameOptions().disable() .and() .cors() .and().formLogin(); //使用form表单登录 http.exceptionHandling().authenticationEntryPoint(myCasAuthenticationEntryPoint) .and() .addFilter(casAuthenticationFilter()) .addFilterBefore(casLogoutFilter(), LogoutFilter.class) .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { super.configure(auth); auth.authenticationProvider(casAuthenticationProvider()); } /** * 主要配置的是ServiceProperties的service属性,它指定的是cas回调的地址 */ @Bean public ServiceProperties serviceProperties() { ServiceProperties serviceProperties = new ServiceProperties(); serviceProperties.setService(ymlProperties.getAppServerUrl() + ymlProperties.getAppLoginUrl()); serviceProperties.setSendRenew(false); serviceProperties.setAuthenticateAllArtifacts(true); return serviceProperties; } /** * 认证的入口,指定cas服务器的登录地址,指定ServiceProperties(主要是获取回调地址) */ @Bean public CasAuthenticationEntryPoint casAuthenticationEntryPoint() { CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint(); casAuthenticationEntryPoint .setLoginUrl(ymlProperties.getCasServerLoginUrl()); casAuthenticationEntryPoint.setServiceProperties(serviceProperties()); return casAuthenticationEntryPoint; } @Bean public CasAuthenticationFilter casAuthenticationFilter() throws Exception { CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter(); casAuthenticationFilter.setServiceProperties(serviceProperties()); casAuthenticationFilter.setFilterProcessesUrl(ymlProperties.getAppLoginUrl()); casAuthenticationFilter.setAuthenticationManager(authenticationManager()); //指定登录成功后跳转页面,也可以使用SavedRequestAwareAuthenticationSuccessHandler // SavedRequestAwareAuthenticationSuccessHandler handler=new SavedRequestAwareAuthenticationSuccessHandler(); // handler.setRedirectStrategy(new MyRedirectStrategy()); // casAuthenticationFilter.setAuthenticationSuccessHandler(handler); casAuthenticationFilter.setAuthenticationSuccessHandler( new SimpleUrlAuthenticationSuccessHandler( ymlProperties.getAppServerUrl() + "/hello")); casAuthenticationFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy()); return casAuthenticationFilter; } // class MyRedirectStrategy extends DefaultRedirectStrategy { // // @Override // public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) // throws IOException { // String redirectUrl = calculateRedirectUrl(request.getContextPath(), url); // redirectUrl = response.encodeRedirectURL(redirectUrl); // log.info(redirectUrl); // if(redirectUrl.startsWith("http://")){ // if(redirectUrl.contains("/app/login")){ // redirectUrl="http://localhost:8080/test/index"; // } // } // response.sendRedirect(redirectUrl); // } // } @Bean public CasAuthenticationProvider casAuthenticationProvider() { CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider(); casAuthenticationProvider.setServiceProperties(serviceProperties()); casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator()); casAuthenticationProvider .setAuthenticationUserDetailsService(myUserDetailsService); casAuthenticationProvider.setKey("casAuthenticationProviderKey"); return casAuthenticationProvider; } /** * 验证ticker,向cas服务器发送验证请求 */ @Bean public Cas20ServiceTicketValidator cas20ServiceTicketValidator() { //转Https请求 // HttpURLConnectionFactory httpURLConnectionFactory=new HttpURLConnectionFactory() { // @Override // public HttpURLConnection buildHttpURLConnection(URLConnection urlConnection) { // SSLContext sslContext=null; // try { // sslContext = SSLContext.getInstance("SSL"); // sslContext.init(new KeyManager[]{},new TrustManager[]{new X509TrustManager() { // @Override // public void checkClientTrusted(X509Certificate[] x509Certificates, String s) // throws CertificateException { // // } // // @Override // public void checkServerTrusted(X509Certificate[] x509Certificates, String s) // throws CertificateException { // // } // // @Override // public X509Certificate[] getAcceptedIssuers() { // return new X509Certificate[0]; // } // }},null); // SSLSocketFactory socketFactory=sslContext.getSocketFactory(); // // if(urlConnection instanceof HttpsURLConnection){ // HttpsURLConnection httpsURLConnection=(HttpsURLConnection)urlConnection; // httpsURLConnection.setSSLSocketFactory(socketFactory); // httpsURLConnection.setHostnameVerifier((s,l)->true); // } // // return (HttpURLConnection)urlConnection; // // }catch (Exception e){ // throw new RuntimeException(e); // } // } // }; Cas20ServiceTicketValidator cas20ServiceTicketValidator = new Cas20ServiceTicketValidator( ymlProperties.getCasServerUrl()); cas20ServiceTicketValidator.setEncoding("UTF-8"); return cas20ServiceTicketValidator; } @Bean public SessionAuthenticationStrategy sessionAuthenticationStrategy() { SessionAuthenticationStrategy sessionAuthenticationStrategy = new SessionFixationProtectionStrategy(); return sessionAuthenticationStrategy; } /** * 此过滤器向cas发送登出请求 */ @Bean public SingleSignOutFilter singleSignOutFilter() { SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter(); singleSignOutFilter.setCasServerUrlPrefix(ymlProperties.getCasServerUrl()); singleSignOutFilter.setIgnoreInitConfiguration(true); return singleSignOutFilter; } /** * 此过滤器拦截客户端的logout请求,发现logout请求后向cas服务器发送登出请求 */ @Bean public LogoutFilter casLogoutFilter() { LogoutFilter logoutFilter = new LogoutFilter(ymlProperties.getCasServerLogoutUrl(), new SecurityContextLogoutHandler()); logoutFilter.setFilterProcessesUrl(ymlProperties.getAppLogoutUrl()); return logoutFilter; } /** * 去除@Secured的前缀 "ROLE_" * @return */ @Bean public GrantedAuthorityDefaults grantedAuthorityDefaults() { return new GrantedAuthorityDefaults(""); } }
新建HelloController
@Slf4j
@RestController
public class HelloController {
@Secured("security_index")
//@PreAuthorize("hasAnyAuthority('security_index')")
@GetMapping("/hello")
public String hello() {
return "首页";
}
}
浏览器访问http://localhost:8080/test/login 如果未登录会跳转到CAS服务端登录页。
对于前后端分离面临的问题是后端不干涉前端页面跳转,在退出登录后,访问前端页面仍可以访问,前端向后端发送请求后端拦截重定向到CAS服务端地址,但是前端页面跳转失败。虽说页面数据是不会加载,但是这不符合我们希望实现的。
目前想到通过实现AuthenticationEntryPoint并重定向到指定接口,返回页面
CAS服务端跳转地址,前端通过拦截器在每个页面访问时都会向后端指定接口发送请求,如果没有登录就会返回CAS服务端地址,登录后直接放行,具体判断我们通过返回状态码来实现。
新建AuthController
方法中Result类是自定义的统一返回json格式,我会在下面贴出来。
@Slf4j @RestController public class HelloController { @Autowired private YmlProperties ymlProperties; /** * 适用前后端分离 * 当未登录时重定向到此请求,返回给前端CAS服务器登录地址,通过前端跳转 * @return */ @GetMapping("/send") public Result send() { String url = ymlProperties.getCasServerLoginUrl() + "?service=" + ymlProperties.getAppServerUrl() + ymlProperties.getAppLoginUrl(); return Result.failed().setCode(444).setData(url).setMessage("未登录").setSuccess(false); } /** * 适用前后端分离 * 当登录成功后返回前端数据 * @return */ @GetMapping("/login") public Result login(){ return Result.success(null,"已登录"); } }
Result统一返回json格式
对于Result类中静态方法可以自己扩展。
public interface IErrorCode {
long getCode();
String getMessage();
}
public enum ResultCode implements IErrorCode { FAILED(500,"操作失败"), SUCCESS(200,"操作成功"); private long code; private String message; private ResultCode(long code,String message){ this.code=code; this.message=message; } @Override public long getCode() { return code; } @Override public String getMessage() { return message; } }
@Data @Accessors(chain = true) public class Result<T> { private boolean success; private long code; private String message; private T data; private Result() { } private Result(boolean success,long code,String message,T data){ this.success=success; this.code=code; this.message=message; this.data=data; } /** * 成功返回结果 * @param data 获取的数据 */ public static <T> Result<T> success(T data){ return new Result<T>(true,ResultCode.SUCCESS.getCode(),ResultCode.SUCCESS.getMessage(),data); } /** * 成功返回结果 * @param data 获取的数据 * @param message 提示信息 */ public static <T> Result<T> success(T data,String message){ return new Result<>(true,ResultCode.SUCCESS.getCode(),message,data); } /** * 失败返回结果 * @param <T> * @return */ public static <T> Result<T> failed(){ return new Result<>(false,ResultCode.FAILED.getCode(),ResultCode.FAILED.getMessage(),null); } /** * 失败返回结果 * @param message 提示信息 */ public static <T> Result<T> failed(String message){ return new Result<>(false,ResultCode.FAILED.getCode(),message,null); } }
新建MyCasAuthenticationEntryPoint类实现AuthenticationEntryPoint
@Component
public class MyCasAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Autowired
private YmlProperties ymlProperties;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.sendRedirect(ymlProperties.getAppServerUrl()+"/send");
}
}
重写SecurityConfiguration配置类
@Slf4j @EnableWebSecurity //开启@Secured和@PreAuthorize @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) @Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired private YmlProperties ymlProperties; @Autowired private MyCasAuthenticationEntryPoint myCasAuthenticationEntryPoint; @Autowired private MyUserDetailsService myUserDetailsService; @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() //配置安全策略 .antMatchers("/login","/send").permitAll() .anyRequest().authenticated() //所有请求都要验证 .and() .csrf().disable() .logout().permitAll() //logout不需要验证 .and() .headers().frameOptions().disable() .and() .cors() .and().formLogin(); //使用form表单登录 http.exceptionHandling().authenticationEntryPoint(myCasAuthenticationEntryPoint) .and() .addFilter(casAuthenticationFilter()) .addFilterBefore(casLogoutFilter(), LogoutFilter.class) .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { super.configure(auth); auth.authenticationProvider(casAuthenticationProvider()); } /** * 主要配置的是ServiceProperties的service属性,它指定的是cas回调的地址 */ @Bean public ServiceProperties serviceProperties() { ServiceProperties serviceProperties = new ServiceProperties(); serviceProperties.setService(ymlProperties.getAppServerUrl() + ymlProperties.getAppLoginUrl()); serviceProperties.setSendRenew(false); serviceProperties.setAuthenticateAllArtifacts(true); return serviceProperties; } /** * 认证的入口,指定cas服务器的登录地址,指定ServiceProperties(主要是获取回调地址) */ // @Bean // public CasAuthenticationEntryPoint casAuthenticationEntryPoint() { // CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint(); // casAuthenticationEntryPoint // .setLoginUrl(casProperties.getCasServerLoginUrl()); // casAuthenticationEntryPoint.setServiceProperties(serviceProperties()); // return casAuthenticationEntryPoint; // } @Bean public CasAuthenticationFilter casAuthenticationFilter() throws Exception { CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter(); casAuthenticationFilter.setServiceProperties(serviceProperties()); casAuthenticationFilter.setFilterProcessesUrl(ymlProperties.getAppLoginUrl()); casAuthenticationFilter.setAuthenticationManager(authenticationManager()); //指定登录成功后跳转页面,也可以使用SavedRequestAwareAuthenticationSuccessHandler // SavedRequestAwareAuthenticationSuccessHandler handler=new SavedRequestAwareAuthenticationSuccessHandler(); // handler.setRedirectStrategy(new MyRedirectStrategy()); // casAuthenticationFilter.setAuthenticationSuccessHandler(handler); casAuthenticationFilter.setAuthenticationSuccessHandler( new SimpleUrlAuthenticationSuccessHandler( ymlProperties.getAppServerUrl() + "/hello")); casAuthenticationFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy()); return casAuthenticationFilter; } // class MyRedirectStrategy extends DefaultRedirectStrategy { // // @Override // public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) // throws IOException { // String redirectUrl = calculateRedirectUrl(request.getContextPath(), url); // redirectUrl = response.encodeRedirectURL(redirectUrl); // log.info(redirectUrl); // if(redirectUrl.startsWith("http://")){ // if(redirectUrl.contains("/app/login")){ // redirectUrl="http://10.11.36.21:8080/test/hello"; // } // } // response.sendRedirect(redirectUrl); // } // } @Bean public CasAuthenticationProvider casAuthenticationProvider() { CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider(); casAuthenticationProvider.setServiceProperties(serviceProperties()); casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator()); casAuthenticationProvider .setAuthenticationUserDetailsService(myUserDetailsService); casAuthenticationProvider.setKey("casAuthenticationProviderKey"); return casAuthenticationProvider; } /** * 验证ticker,向cas服务器发送验证请求 */ @Bean public Cas20ServiceTicketValidator cas20ServiceTicketValidator() { //转Https请求 // HttpURLConnectionFactory httpURLConnectionFactory=new HttpURLConnectionFactory() { // @Override // public HttpURLConnection buildHttpURLConnection(URLConnection urlConnection) { // SSLContext sslContext=null; // try { // sslContext = SSLContext.getInstance("SSL"); // sslContext.init(new KeyManager[]{},new TrustManager[]{new X509TrustManager() { // @Override // public void checkClientTrusted(X509Certificate[] x509Certificates, String s) // throws CertificateException { // // } // // @Override // public void checkServerTrusted(X509Certificate[] x509Certificates, String s) // throws CertificateException { // // } // // @Override // public X509Certificate[] getAcceptedIssuers() { // return new X509Certificate[0]; // } // }},null); // SSLSocketFactory socketFactory=sslContext.getSocketFactory(); // // if(urlConnection instanceof HttpsURLConnection){ // HttpsURLConnection httpsURLConnection=(HttpsURLConnection)urlConnection; // httpsURLConnection.setSSLSocketFactory(socketFactory); // httpsURLConnection.setHostnameVerifier((s,l)->true); // } // // return (HttpURLConnection)urlConnection; // // }catch (Exception e){ // throw new RuntimeException(e); // } // } // }; Cas20ServiceTicketValidator cas20ServiceTicketValidator = new Cas20ServiceTicketValidator( ymlProperties.getCasServerUrl()); cas20ServiceTicketValidator.setEncoding("UTF-8"); return cas20ServiceTicketValidator; } @Bean public SessionAuthenticationStrategy sessionAuthenticationStrategy() { SessionAuthenticationStrategy sessionAuthenticationStrategy = new SessionFixationProtectionStrategy(); return sessionAuthenticationStrategy; } /** * 此过滤器向cas发送登出请求 */ @Bean public SingleSignOutFilter singleSignOutFilter() { SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter(); singleSignOutFilter.setCasServerUrlPrefix(ymlProperties.getCasServerUrl()); singleSignOutFilter.setIgnoreInitConfiguration(true); return singleSignOutFilter; } /** * 此过滤器拦截客户端的logout请求,发现logout请求后向cas服务器发送登出请求 */ @Bean public LogoutFilter casLogoutFilter() { LogoutFilter logoutFilter = new LogoutFilter(ymlProperties.getCasServerLogoutUrl(), new SecurityContextLogoutHandler()); logoutFilter.setFilterProcessesUrl(ymlProperties.getAppLogoutUrl()); return logoutFilter; } /** * 取出@Secured的前缀 "ROLE_" * @return */ @Bean public GrantedAuthorityDefaults grantedAuthorityDefaults() { return new GrantedAuthorityDefaults(""); } }
前端只需要访问/login
请求,登录就会返回200状态码和“已登录”提示信息,未登录后端就会重定向/send
请求,并返回444状态码和CAS服务端地址,前端根据444状态码拿到CAS服务端地址并跳转页面。
注意未登录时后端会重定向一次,所以想一想前端如何拿到重定向的数据。
对于以上有什么建议,请在评论区留下宝贵意见!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。