security加密解密
一. 密码加密简介
1. 散列加密概述
我们开发时进行密码加密,可用的加密手段有很多,比如对称加密、非对称加密、信息摘要等。在一般的项目里,常用的就是信息摘要算法,也可以被称为散列加密函数,或者称为散列算法、哈希函数。这是一种可以从任何数据中创建数字“指纹”的方法,常用的散列函数有 MD5 消息摘要算法、安全散列算法(Secure Hash Algorithm)等。
2. 散列加密原理
散列函数通过把消息或数据压缩成摘要信息,使得数据量变小,将数据的格式固定下来,然后将数据打乱混合,再重新创建成一个散列值,从而达到加密的目的。散列值通常用一个短的随机字母和数字组成的字符串来代表,一个好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理时,如果我们不抑制冲突来区别数据,会使得数据库中的记录很难找到。
但是仅仅使用散列函数还不够,如果我们只是单纯的使用散列函数而不做特殊处理,其实是有风险的!比如在两个用户密码明文相同时,生成的密文也会相同,这样就增加了密码泄漏的风险。
所以为了增加密码的安全性,一般在密码加密过程中还需要“加盐”,而所谓的“盐”可以是一个随机数,也可以是用户名。”加盐“之后,即使密码的明文相同,用户生成的密码密文也不相同,这就可以极大的提高密码的安全性。
传统的加盐方式需要在数据库中利用专门的字段来记录盐值,这个字段可以是用户名字段(因为用户名唯一),也可以是一个专门记录盐值的字段,但这样的配置比较繁琐。
二、SpringSecurity 中的密码源码分析
1、PasswordEncoder
security中加密接口是PasswordEncoder,接口用于执行密码的单向转换,以便安全地存储密码,源码如下
public interface PasswordEncoder { //该方法提供了明文密码的加密处理,加密后密文的格式主要取决于PasswordEncoder接口实现类实例。String encode(CharSequence rawPassword); //匹配存储的密码以及登录时传递的密码(登录密码是经过加密处理后的字符串)是否匹配,如果匹配该方法则会返回true,第一个参数表示需要被解析的密码 第二个参数表示存储的密码boolean matches(CharSequence rawPassword, String encodedPassword);default boolean upgradeEncoding(String encodedPassword) {return false;} }
接口实现类列表如下
举例使用
Spring Security 5.0之前默认的PasswordEncoder实现类,即默认的加密方案是NoOpPasswordEncoder,5之后这个类已经被标记为过时了,因为不安全(NoOpPasswordEncoder的encode方法就只是简单地把字符序列转成字符串)。
2、DelegatingPasswordEncoder
2.1、介绍
Security 5之后用的默认加密方案实现类是DelegatingPasswordEncoder,既然默认密码编码器NoOpPasswordEncoder已经被”不推荐”了,那我们有理由推测现在的默认密码编码器换成了使用某一特定算法的编码器.可是这样便会带来三个问题:
- 有许多使用旧密码编码的应用程序无法轻松迁移
- 密码存储的最佳做法(算法)可能会再次发生变化
- 作为一个框架,Spring Security不能经常发生突变
DelegatingPasswordEncoder是怎么解决这个问题的,在看解决方法之前先看使用DelegatingPasswordEncoder所能达到的效果:
- 确保使用当前密码存储建议对密码进行编码
- 允许验证现代和传统格式的密码
- 允许将来升级编码算法
事实上DelegatingPasswordEncoder并不是传统意义上的编码器,它并不使用某一特定算法进行编码,顾名思义,它是一个委派密码编码器,它将具体编码的实现根据要求委派给不同的算法,以此来实现不同编码算法之间的兼容和变化协调,也就是说它是一个代理类,主要用来代理不同的密码加密方案
DelegatingPasswordEncoder 构造方法
public DelegatingPasswordEncoder(String idForEncode,Map<String, PasswordEncoder> idToPasswordEncoder) {if(idForEncode == null) {throw new IllegalArgumentException("idForEncode cannot be null");}if(!idToPasswordEncoder.containsKey(idForEncode)) {throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);}for(String id : idToPasswordEncoder.keySet()) {if(id == null) {continue;}if(id.contains(PREFIX)) {throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);}if(id.contains(SUFFIX)) {throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);}}this.idForEncode = idForEncode;this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);}
idForEncode决定密码编码器的类型,idToPasswordEncoder决定判断匹配时兼容的类型
而且idToPasswordEncoder必须包含idForEncode(不然加密后就无法匹配了)
围绕这个构造方法通常有两种创建思路,如下:
2.2、创建方式
第一种:工厂构造
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
其具体实现如下:
public class PasswordEncoderFactories {public static PasswordEncoder createDelegatingPasswordEncoder() {String encodingId = "bcrypt";//默认Map<String, PasswordEncoder> encoders = new HashMap();encoders.put(encodingId, new BCryptPasswordEncoder());encoders.put("ldap", new LdapShaPasswordEncoder());encoders.put("MD4", new Md4PasswordEncoder());encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));encoders.put("noop", NoOpPasswordEncoder.getInstance());encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());encoders.put("scrypt", new SCryptPasswordEncoder());encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));encoders.put("sha256", new StandardPasswordEncoder());encoders.put("argon2", new Argon2PasswordEncoder());return new DelegatingPasswordEncoder(encodingId, encoders);}private PasswordEncoderFactories() {}
这个可以简单地理解为,遇到新密码,DelegatingPasswordEncoder会委托给BCryptPasswordEncoder(encodingId为bcryp*)进行加密,同时,对历史上使用ldap,MD4,MD5等等加密算法的密码认证保持兼容(如果数据库里的密码使用的是MD5算法,那使用matches方法认证仍可以通过,但新密码会使bcrypt进行储存),十分神奇,原理后面会讲
第二种:定制构造
接下来是定制构造,其实和工厂方法是一样的,一般情况下推荐直接使用工厂方法,这里给一个小例子
String idForEncode = "bcrypt"; Map encoders = new HashMap<>(); encoders.put(idForEncode, new BCryptPasswordEncoder()); encoders.put("noop", NoOpPasswordEncoder.getInstance()); encoders.put("pbkdf2", new Pbkdf2PasswordEncoder()); encoders.put("scrypt", new SCryptPasswordEncoder()); encoders.put("sha256", new StandardPasswordEncoder());PasswordEncoder passwordEncoder =new DelegatingPasswordEncoder(idForEncode, encoders);
3、密码存储格式
标准的存储格式如下
{id}encodedPassword
其中,id标识使用PaswordEncoder的种类
encodedPassword是原密码被编码后的密码
注意:
rawPassword,encodedPassword,密码存储格式(prefixEncodedPassword),这三者是不同的概念!
rawPassword相当于字符序列”123456”
encodedPassword是使用id为”mycrypt”对应的密码编码器”123456”编码后的字符串,假设为”qwertyuiop”
存储的密码 prefixEncodedPassword是在数据库中,我们所能见到的形式,如”{mycrypt}qwertyuiop”
这个概念在后面讲matches方法的源码时会用到,请留意
例如rawPassword为password在使用不同编码算法的情况下在数据库的存储如下:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{noop}password
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
这里需要指明,密码的可靠性并不依赖于加密算法的保密,即密码的可靠在于就算你知道我使用的是什么算法你也无法还原出原密码(当然,对于本身就可逆的编码算法来说就不是这样了,但这样的算法我们通常不会认为是可靠的),而且,即使没有标明使用的是什么算法,攻击者也很容易根据一些规律从编码后的密码字符串中推测出编码算法,如bcrypt算法通常是以$2a$开头的
4、密码编码与匹配
从上文可知,idForEncode这个构造参数决定使用哪个PasswordEncoder进行密码的编码,编码的方法如下:
public String encode(CharSequence rawPassword) {return "{" + this.idForEncode + "}" + this.passwordEncoderForEncode.encode(rawPassword); }
所以用上文构造的DelegatingPasswordEncoder默认使用BCryptPasswordEncoder,结果格式如
{bcrypt}2a2a10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
密码编码方法比较简单,重点在于匹配.匹配方法源码如下:
@Overridepublic boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {if(rawPassword == null && prefixEncodedPassword == null) {return true;}//取出编码算法的idString id = extractId(prefixEncodedPassword);//根据编码算法的id从支持的密码编码器Map(构造时传入)中取出对应编码器PasswordEncoder delegate = this.idToPasswordEncoder.get(id);if(delegate == null) {//如果找不到对应的密码编码器则使用默认密码编码器进行匹配判断,此时比较的密码字符串是 prefixEncodedPasswordreturn this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);}//从 prefixEncodedPassword 中提取获得 encodedPassword String encodedPassword = extractEncodedPassword(prefixEncodedPassword);//使用对应编码器进行匹配判断,此时比较的密码字符串是 encodedPassword ,不携带编码算法id头return delegate.matches(rawPassword, encodedPassword);}
这个匹配方法其实也挺好理解的,唯一需要特别注意的就是找不到对应密码编码器时使用的默认密码编码器,
我们来看看defaultPasswordEncoderForMatches是一个什么东西
在DelegatingPasswordEncoder的源码里对应内容如下
private static final String PREFIX = "{";private static final String SUFFIX = "}";private final String idForEncode;private final PasswordEncoder passwordEncoderForEncode;private final Map<String, PasswordEncoder> idToPasswordEncoder;private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();public void setDefaultPasswordEncoderForMatches(PasswordEncoder defaultPasswordEncoderForMatches) {if(defaultPasswordEncoderForMatches == null) {throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");}this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;}private class UnmappedIdPasswordEncoder implements PasswordEncoder {@Overridepublic String encode(CharSequence rawPassword) {throw new UnsupportedOperationException("encode is not supported");}@Overridepublic boolean matches(CharSequence rawPassword,String prefixEncodedPassword) {String id = extractId(prefixEncodedPassword);throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");}}
可以看到,DelegatingPasswordEncoder里面,
PREFIX 和SUFFIX 是常量,
idForEncode,passwordEncoderForEncode和idToPasswordEncoder是在构造方法中传入决定并不可修改的,
只有defaultPasswordEncoderForMatches 是有一个setDefaultPasswordEncoderForMatches方法进行设置的可变对象.
而且,它有一个私有的默认实现UnmappedIdPasswordEncoder,这个所谓的默认实现的唯一作用就是抛出异常提醒你要自己选择一个默认密码编码器来取代它,通常我们只会可能用到它的matches方法,这个时候就会报抛出如下异常
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
遇到这个异常,最简单的做法就是明确提供一个PasswordEncoder对密码进行编码,如果是从Spring Security 5.0 之前迁移而来的,由于之前默认使用的是NoOpPasswordEncoder并且数据库的密码保存格式不带有加密算法id头,会报id为null异常,所以应该明确提供一个NoOpPasswordEncoder密码编码器.
这里有两种思路,其一就是使用NoOpPasswordEncoder取代DelegatingPasswordEncoder,以恢复到之前版本的状态,这也是笔者在其他博客上看得比较多的一种解决方法.另外就是使用DelegatingPasswordEncoder的setDefaultPasswordEncoderForMatches方法指定默认的密码编码器为NoOpPasswordEncoder,这两种方法孰优孰劣自然不言而喻,官方文档是这么说的
Reverting to NoOpPasswordEncoder is not considered to be secure. You should instead migrate to using DelegatingPasswordEncoder to support secure password encoding.
恢复到NoOpPasswordEncoder不被认为是安全的。您应该转而使用DelegatingPasswordEncoder支持安全密码编码
当然,你也可以将数据库保存的密码都加上一个{noop}前缀,这样DelegatingPasswordEncoder就知道要使用NoOpPasswordEncoder了,这确实是一种方法,但没必要,这里我们来看一下前面的两种解决方法的实现
第一种:使用NoOpPasswordEncoder取代DelegatingPasswordEncoder
@Bean
public static NoOpPasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}
第二种:使用DelegatingPasswordEncoder指定defaultPasswordEncoderForMatches
@Bean
public static PasswordEncoder passwordEncoder( ){
DelegatingPasswordEncoder delegatingPasswordEncoder =
(DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();
//设置defaultPasswordEncoderForMatches为NoOpPasswordEncoder
delegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(NoOpPasswordEncoder.getInstance());
return delegatingPasswordEncoder;
}
5、项目使用
5.1、非DelegatingPasswordEncoder方式
如果我们项目中不需要使用DelegatingPasswordEncoder委托密码编码方式,可以通过@Bean的方式来统一配置全局共用的PasswordEncoder,如下所示:
@Bean
public PasswordEncoder passwordEncoder() {
//可以根据项目自行选择所使用的PasswordEncoder实现类。
return new BCryptPasswordEncoder();
}
使用
@Controller public class Test {@AutowiredPasswordEncoder passwordEncoder;//模拟注册用户@RequestMapping("addUser")public void addUser(User user){//$2a$10$zfkqrT3EtxUiRinpMaGvBe.CsVD7YV9DJKURyONO6L4q6LxOd3.cyString encode = passwordEncoder.encode("123456");user.setPassWord(encode);userDao.addUser(user);} }
5.2、DelegatingPasswordEncoder方式
DelegatingPasswordEncoder是默认的PasswordEncoder加密方式,所以我们可以为不同的用户配置所使用不同的密码加密方式,只需要密码格式按照:{away}encodePassword来进行持久化即可。
@Configuration @EnableWebSecurity public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {http.formLogin().and().csrf().disable().authorizeRequests().antMatchers("/**").authenticated();}@Beanpublic UserDetailsService users() {// {MD5}value必须大写,value值必须是32位小写// adminUserDetails admin = User.builder()//.passwordEncoder(encoder::encode).username("admin").password("{MD5}e10adc3949ba59abbe56e057f20f883e").roles("admin").build();// hengboyUserDetails hengboy = User.builder().username("hengboy").password("{bcrypt}$2a$10$iMz8sMVMiOgRgXRuREF/f.ChT/rpu2ZtitfkT5CkDbZpZlFhLxO3y").roles("admin").build();// yuqiyuUserDetails yuqiyu = User.builder().username("yuqiyu")//.password("{noop}123456").password("{pbkdf2}cc409867e39f011f6332bbb6634f58e98d07be7fceefb4cc27e62501594d6ed0b271a25fd9f7fc2e").roles("user").build();return new InMemoryUserDetailsManager(admin, yuqiyu, hengboy);} }
5.3、多密码加密方案共存
我们进行开发时,经常需要对老旧项目进行改造。这个老旧项目,一开始用的密码加密方案可能是MD5,后来因为种种原因,可能会觉得这个MD5加密不合适,想更新替换一种新的加密方案。但是我们进行项目开发时,密码加密方式一旦确定,基本上没法再改了,毕竟我们不能让用户重新注册再设置一次新密码吧。但是我们此时确实又想使用最新的密码加密方案,那怎么办呢?
这时候,我们就可以考虑使用DelegatingPasswordEncoder来实现多密码加密方案了!
首先配置DelegatingPasswordEncoder对象
@Bean
public PasswordEncoder passwordEncoder() {
//利用工厂类PasswordEncoderFactories实现,工厂类内部采用的是委派密码编码方案!
//推荐使用该方案,因为后期可以实现多密码加密方案共存效果!
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
测试接口
@RestController@RequestMapping("/user")public class UserController {@Autowiredprivate PasswordEncoder passwordEncoder;@Autowiredprivate UserMapper userMapper;@GetMapping("hello")public String hello() {return "hello, user";}/*** 采用默认的PasswordEncoder,即BCryptPasswordEncoder来加密。** 添加用户.这里我们采用表单形式传参,传参形式如下:* http://localhost:8080/user/register?username=test&password=123*/@GetMapping("/register")public User registerUser(@RequestParam(required = false) User user) {user.setEnable(true);user.setRoles("ROLE_ADMIN");//对密码进行加密user.setPassword(passwordEncoder.encode(user.getPassword()));userMapper.addUser(user);return user;}/*** 利用MD5加密密码*/@GetMapping("/registerMd5")public User registerUserWithMd5(@RequestParam(required = false, name = "username") String username, @RequestParam(required = false, name = "password") String password) {User user = new User();user.setUsername(username);user.setEnable(true);user.setRoles("ROLE_ADMIN");Map<String, PasswordEncoder> encoders = new HashMap<>(16);//encoders.put("bcrypt", new BCryptPasswordEncoder());//encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));DelegatingPasswordEncoder md5Encoder = new DelegatingPasswordEncoder("MD5", encoders);//对密码进行加密user.setPassword(md5Encoder.encode(password));userMapper.addUser(user);return user;}/*** 不进行密码加密*/@GetMapping("/registerNoop")public User registerUserWithNoop(@RequestParam(required = false, name = "username") String username, @RequestParam(required = false, name = "password") String password) {User user = new User();user.setUsername(username);user.setEnable(true);user.setRoles("ROLE_ADMIN");Map<String, PasswordEncoder> encoders = new HashMap<>(16);//encoders.put("bcrypt", new BCryptPasswordEncoder());//encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));encoders.put("noop", NoOpPasswordEncoder.getInstance());DelegatingPasswordEncoder noopEncoder = new DelegatingPasswordEncoder("noop", encoders);//对密码进行加密user.setPassword(noopEncoder.encode(password));userMapper.addUser(user);return user;}}
记得配置类中对上三个接口放行,浏览器中分别请求以上的3个接口,添加3个用户
我的数据库中,此时就会有3个采用不同加密方案的用户了。
然后我们可以分别利用这三个用户进行登录,可以发现在同一个项目中,实现了支持3种不同的密码加密方案的效果。
参考
Spring Security系列教程22--Spring Security中的密码加密_一一哥Sun的博客-CSDN博客_springsecurity默认加密方式
Spring Security 5.0的DelegatingPasswordEncoder详解_linshenkx的博客-CSDN博客
security加密解密相关推荐
- Yii2 security 加密解密库
编码和解码函数,比通过密码的方式要快做程序的时候,加密解密是绕不开的话题,使用yii2开发应用的时候,都内置了哪些有关加密解密(安全)方便的支持,以下做下详细对 security 的说明. 在yii2 ...
- iOS使用Security.framework进行RSA 加密解密签名和验证签名
iOS 上 Security.framework为我们提供了安全方面相关的api: Security框架提供的RSA在iOS上使用的一些小结 支持的RSA keySize 大小有:512,768,10 ...
- SECURITY:加密与解密,AIDE入侵检测系统,扫面与抓包
文章目录 加密与解密 加密目的及方式 MD5 GPG加/解密工具 介绍 使用GPG对称加密方式 使用GPG非对称加密方式 AIDE入侵检测系统 部署AIDE入侵检测系统 初始化数据库,入侵后检测 扫描 ...
- 提供一个基于.NET的加密/解密算法
提供一个基于.NET SymmetricAlgorithm 类的.带私钥的加密/解密算法的包装类.使用方法: symmcrypto de = new SymmCrypto(SymmCrypto.Sym ...
- 加密解密-DES算法和RSA算法
昨天忽然对加密解密有了兴趣,今天上班查找了一些资料,现在就整理一下吧:) 一.DES算法 这种算法如图所示,这里将描述它的每一个步骤.这个算法进行了16次迭代(圈),把各块明文交织起来与 从密钥中获得 ...
- java之php、Android、JAVA、C# 3DES加密解密
异常如下 1.javax.crypto.BadPaddingException: Given final block not properly padded 1)要确认下是否加密和解密都是使用相同的填 ...
- .net实现md5加密 sha1加密 sha256加密 sha384加密 sha512加密 des加密解密
写项目时,后台一直用md5加密,一天群里人问,除了MD5还有其它的加密方法吗?当时只知道还有个SHA,但怎么实现什么的都不清楚,于是当网上找了下,把几种常见的加密方法都整理了下,用winform写了个 ...
- C# 加密解密(DES,3DES,MD5,Base64) 类
public sealed class EncryptUtils{#region Base64加密解密/// <summary>/// Base64加密/// </summary&g ...
- ASP.NET常用加密解密方法
一.MD5加密解密 1.加密 C# 代码 复制 public static string ToMd5(string clearString) { Byte[] clearBytes = Syste ...
最新文章
- 20180719 (内置函数68个)
- 《漫画算法2》源码整理-9 股票交易最大收益
- SpringBoot+Vue.js实现大文件分片上传、断点续传与极速秒传
- 空值的日期类型和update 中的null
- 关于如何将多个Cpp文件关联起来
- Unity获取物体下的子物体+只获取子物体
- 帝国cms 自定义页面 php,帝国CMS增加自定义页面模板修改教程
- Node-RED中建立Websocket客户端连接
- 2023年安徽省中职网络安全跨站脚本攻击
- element 表格全局筛选(筛选结果请求后端接口)
- 浅提计算机未来的想法,浅述未来计算机的发展趋势论文
- 一文搞懂 php 中的 DI 依赖注入
- EM算法(算法原理+算法收敛性)
- 南京大学主动拒绝世界大学排名
- 诺亚财富通过聆讯:年营收43亿 汪静波有49%投票权,红杉是股东
- proteus中仿真arduino驱动模拟器件(蜂鸣器继电器电机)
- 20200524 碎碎念
- 手机文件夹与电脑文件夹实时同步
- 5月1日起施行,三种情形不予信用修复!
- 信阳农林学院计算机应用好就业吗,信阳农林学院怎么样好就业吗?属于几本?王牌专业是什么...
热门文章
- CarSim2020 安装和操作001
- 万能乘法速算法大全_小学生两位数乘法容易出错?只因没掌握这个“万能”速算法...
- Java基础教程-首篇前序-二进制符号位及原反补
- 教你如何进行DNS域名解析
- Vue2 大型项目升级 Vue3 详细经验总结
- CGAN条件对抗生成网络一瞥
- 神器工具:新一代多系统启动 U 盘装机解决方案
- 里加一列为1_米饭里加把藜麦米,低脂饱腹不长胖,不用节食,照样甩赘肉
- 艺体计算机教师考核细则,艺体教师考核细则.docx
- openlayers和百度API实现点击地图加载全景的功能