千億級平臺技術架構:為了支撐高併發,我把身份證存到了JS裡

碼大叔發表於2020-04-20

@


接著上一篇《千億級網際網路平臺背後那些事-欲上青天攬明月》,今天我們來聊一聊關於使用者隱私資訊的事。

隨著時代及網際網路的發展,人們對個人隱私越來越重視,但隱私資訊洩露及濫用的問題依然屢見不鮮。之前有一份《中國個人資訊保安和隱私保護報告》曾抽取100萬份調查資料,80%使用者遭遇隱私洩露,還比如萬豪在18年遭遇3.83億隱私資料洩露後於2020年3月31日再次爆出520萬客戶資訊洩露。這背後的緣由我們們就不做多講,除了一些流氓公司的惡意行為,肯定還有很多的商業利益的驅使。今天我們來聊一聊開發人員該如何處理使用者隱私,想起半年前在知乎上爆出的某省普通話水平測試查詢系統開發人員把身份證直接寫在了js裡,有網友笑稱這才是真正的前後端分離,支撐億級併發完全不是事
文章開始之前,先丟擲一個小問題:除了姓名、身份證、銀行卡、手機號外,你覺得還有哪些是使用者的敏感資訊,需要加密儲存?在這裡插入圖片描述

什麼叫個人資訊,哪些又算敏感資訊?個人資訊該如何儲存,又該如何展示?遊戲中的兌換碼是不是敏感資訊?住宿資訊是不是敏感資訊??作為一名優秀的開發人員,我們不能把目光僅僅聚焦在程式碼上,不能永遠是產品經理或者專案經理讓我這麼做,還應該掌握所在行業的業務知識,包括法律及政策規範等,提升拓寬我們的業務知識面。

一、使用者資訊保安規範

關於資訊系統建設這一塊,國家及行業其實有很多的標準和規範的,比如國家標準全文公開系統(http://openstd.samr.gov.cn/))。關於個人資訊,最新的是今年釋出的《GB/T 35273-2020 資訊保安技術-個人資訊保安規範 》,將於2020-10-01正式實施,取代老的標準GB/T 35273-2017。 整個規範文件主要體現了七大原則:權責一致原則、目的明確原則、選擇同意原則、最少夠用原則、公開透明原則、確保安全原則、主體參與原則
在這裡插入圖片描述

1.1 ​使用者資訊、敏感資訊定義及判斷依據

1.1.1 個人資訊

個人資訊,personal information。指以電子或者其他方式記錄的能夠單獨或者與其他資訊結合識別特定自然人身份或者反映特定自然人活動情況的各種資訊。

判定方式

  1. 識別:即從資訊到個人,由資訊本身的特殊性識別出特定自然人,個人資訊應有助於識別出特定個人。
  2. 關聯:即從個人到資訊,如已知特定自然人,由該特定自然人在其活動中產生的資訊(如個人位置資訊、個人通話記錄、個人瀏覽記錄等)即為個人資訊。
    符合上述兩種情形之一的資訊,均應判定為個人資訊。

個人資訊舉例個人資訊舉例
:個人資訊控制者通過個人資訊或其他資訊加工處理後形成的資訊,例如,使用者畫像或特徵標籤,能夠單獨或者與其他資訊結合識別特定自然人身份或者反映特定自然人活動情況的,也屬於個人資訊。

1.1.2 個人敏感資訊

個人敏感資訊,personal sensitive information。指一旦洩露、非法提供或濫用可能危害人身和財產安全,極易導致個人名譽、身心健康受到損害或歧視性待遇等的個人資訊。通常情況下,14歲以下(含)兒童的個人資訊和涉及自然人隱私的資訊屬於個人敏感資訊

判定方式

  1. 洩露:個人資訊一旦洩露,將導致個人資訊主體及收集、使用個人資訊的組織和機構喪失對個人資訊的控制能力,造成個人資訊擴散範圍和用途的不可控。某些個人資訊在洩漏後,被以違背個人資訊主體意願的方式直接使用或與其他資訊進行關聯分析,可能對個人資訊主體權益帶來重大風險,應判定為個人敏感資訊。例如,個人資訊主體的身份證影印件被他人用於手機號卡實名登記、銀行賬戶開戶辦卡等。

  2. 非法提供:某些個人資訊僅因在個人資訊主體授權同意範圍外擴散,即可對個人資訊主體權益帶來重大風險,應判定為個人敏感資訊。例如,性取向、存款資訊、傳染病史等。

  3. 濫用:某些個人資訊在被超出授權合理界限時使用(如變更處理目的、擴大處理範圍等),可能對個人資訊主體權益帶來重大風險,應判定為個人敏感資訊。例如,在未取得個人資訊主體授權時,將健康資訊用於保險公司營銷和確定個體保費高低。

個人敏感資訊舉例
個人敏感資訊舉例
:個人資訊控制者通過個人資訊或其他資訊加工處理後形成的資訊,如一旦洩露、非法提供或濫GB/T 35273—20206用可能危害人身和財產安全,極易導致個人名譽、身心健康受到損害或歧視性待遇等的,屬於個人敏感資訊。

1.2 ​使用者資訊儲存的注意事項

  1. 個人資訊儲存時間最小化,超過個人資訊儲存期限後,應對個人資訊進行刪除或匿名化處理。
  2. 傳輸和儲存個人敏感資訊時,應採用加密等安全措施;採用密碼技術時宜遵循密碼管理相關國家標準。
  3. 個人生物識別資訊應與個人身份資訊分開儲存
  4. 原則上不應儲存原始個人生物識別資訊(如樣本、影像等),可採取的措施包括但不限於:僅儲存個人生物識別資訊的摘要資訊;在採集終端中直接使用個人生物識別資訊實現身份識別、認證等功能; 在使用面部識別特徵、指紋、掌紋、虹膜等實現識別身份、認證等功能後刪除可提取個人生物識別資訊的原始影像。

整個規範檔案中,還提到了使用者資訊的使用、展示、第三方接入、安全管理等等,有興趣的小夥伴可以自定搜尋瞭解一下。

二、​框架技術實現

2.1 使用者敏感資訊自動加解密

正如第一章節提到的,使用者的真實姓名、手機號、銀行卡號、包括住宿等敏感資訊需要加密儲存到資料庫中,業務正常使用的時候再轉化為明文資料。從技術實現角度來看,無非就是新增、編輯時進行加密,查詢時解密,這樣一個個操作起來還是比較low的,而且很可能哪天新增了一個方法又忘記加解密了。所以大部分會通過框架來實現,實現的原理無外乎反射機器+攔截器。接下來以Mybatis為例,原理如下圖,具體可參考:https://blog.csdn.net/weixin_39494923/article/details/91534658
在這裡插入圖片描述

2.1.1 通過Interceptor實現資料的自動加解密

Mybatis預設提供了一個攔截器介面Interceptor,大部分Mybatis的增強工具都是通過該介面實現的。如果要實現自定義的攔截器,只需要實現 org.apache.ibatis.plugin.Interceptor 介面,該介面有三個方法:

Object intercept(Invocation invocation) throws Throwable;

Object plugin(Object target);

void setProperties(Properties properties);

首先以自定義一個註解@Crypt,作用在欄位上,用於告訴攔截器那個欄位需要加解密。

@Target({ ElementType.FIELD,ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface Crypt {

}

接下來新增一個自定義攔截器,selelct方法時進行解密,update和add方法時進行加密。

@Intercepts({ @Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class, }),
        @Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
                RowBounds.class, ResultHandler.class }),
        @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = { Statement.class }) })
public class CryptInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        if (args.length <= 0 || invocation.getMethod() == null || args[0] == null) {
            return invocation.proceed();
        }

        String methodName = invocation.getMethod().getName();
        if ("update".equals(methodName) && args[1] != null) {
            return this.interceptUpdate(invocation);
        } else if ("query".equals(methodName) && args[1] != null) {
            return this.interceptQuery(invocation);
        } else if ("handleResultSets".equals(methodName)) {
            return this.interceptHandleResultSets(invocation);
        }
        return invocation.proceed();
    }

    private Object interceptHandleResultSets(Invocation invocation) throws Throwable {
        Object resultCollection = invocation.proceed();
        // 略 將resultCollection的物件中有@Crypt註解的Feild進行解密
        return newObject;
    }

    private Object interceptUpdate(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        Object args1Obj = args[1];
        // 略 將args1Obj的物件進行加密
        args[1] = newObject;
        return invocation.proceed();
    }
    
    private Object interceptQuery(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        Object condition = args[1];
        // 略 將condition物件進行解密
        args[1] = newObject;
        return invocation.proceed();
    }    
}

2.1.2 通過BaseTypeHandler實現資料的自動加解密

一般情況下不會通過Interceptor介面對Mybatis的請求進行攔截,除非類似於“讀寫分離”這樣的一些複雜的需求。參見上面的mybatis的執行過程,我們發現最後一步呼叫了TypeHander,這個類的作用就是把資料庫與實體之間進行型別轉換,比如把MySql的varchar轉為Java的Long,把Java的Integer轉為Mysql的int,所以我們可以藉助於BaseTypeHandler類。

@Component
@Alias("CryptHandler")
@MappedTypes(value = {Crypt.class})
public class EncryptHandler extends BaseTypeHandler {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType)
        throws SQLException {
        ps.setString(i, encrypt(parameter.toString()));
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String columnValue = rs.getString(columnName);
        return decrypt(columnValue);
    }

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String columnValue = rs.getString(columnIndex);
        return decrypt(columnValue);
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String columnValue = cs.getString(columnIndex);
        return decrypt(columnValue);
    }

    private String encrypt(String parameter) {
        // 加密
        return parameter;
    }

    private String decrypt(String columnValue) {
        // 解密
        return columnValue;
    }
}

完整 程式碼見上面,不做多講。接下來需要告訴Mybatis哪些欄位需要加解密,為了簡化書寫,定義一個類Crypt重新命名為crypt,上面的類EncryptHandler也重新命名為EncryptHandler

@Alias("crypt")
public final class Crypt {

}

上面的兩個類都放在cn.itmds.plugin目錄下,配置yml檔案告訴Mybatis讀取重新命名的配置

mybatis:
  type-aliases-Package: cn.itmds.plugin.dbcrypt
 

接下來,假設有一張member表的realname(真實姓名)欄位需要加解密,寫起來就很簡單了:

 <sql id="memberConditionSql">
        <where>
            <if test="id != null">and id = #id}</if>
            <!--這個地方只需要指定javaType=crypt,如果上面沒有重新命名,這個地方需要寫成javaType= cn.itmds.plugin.dbcrypt.Crypt,寫起來比較麻煩 -->
            <if test=realName != null">and real_name = #{realName,javaType=crypt}</if>
        </where>
    </sql>
    <resultMap id="memberDOResultMap" type="MemberDO">
        <!--這個地方只需要指定typeHandler=CryptHandler,如果上面沒有重新命名,這個地方需要寫成javaType= cn.itmds.plugin.dbcrypt.CryptHandler,寫起來比較麻煩 -->
        <!--另外,只需要將需要解密的欄位寫到這個resultMap裡即可,不需要寫全部的欄位,其他欄位系統會自動對映為MemberDO -->
        <result column="phone" property="phone" typeHandler="CryptHandler"/>
    </resultMap>

2.1.3 MybatisPlus實現資料的自動加解密

MyBatis-Plus(簡稱 MP)是一個 MyBatis 的增強工具,在 MyBatis的基礎上只做增強不做改變,為簡化開發、提高效率而生。

MyBatis-Plus只需簡單配置,即可快速進行 CRUD 操作,從而節省大量時間。而且還支援Lambda表示式,通過物件來操作sql等,所以現在使用的人越來越多。那麼它如何來實現資料的自動加解密呢,超級簡單。實現原理和2.1.2一樣,也是通過BaseTypeHandler來實現。

1、增加@TableField(typeHandler = EncryptHandler.class),其中EncryptHandler就是2.1.2定義的EncryptHandler.java,此時新增、修改時就實現了自動加密。
2、在@TableName上設定autoResultMap = true,此時就實現了返回值的自動解密。

Done!示例:

@Data
@TableName(value = "user_info",autoResultMap = true)
public class UserPO {

    /**  */
    @TableId(type = IdType.AUTO)
    private Long id;

    /** 真實姓名 */
    @TableField(typeHandler = EncryptHandler.class)
    private String realName;
}

2.2 日誌檔案自動過濾使用者敏感資訊

為了便於開發除錯及產線問題定位,開發框架基本都會定義日誌攔截器,對所有的controller層和service層的方法進行攔截,列印詳細等入參、出參。在2.1中我們提到了使用者的敏感資訊的加解密是在dao底層自動完成的,所以也就導致了日誌中還會列印了使用者的敏感資訊,那麼此時該如何處理呢?接下來提供一個完整的案例。

  1. 定義一個註解@ServiceLog,可以作用在類上或者方法上。提供一個引數:ignore,預設為false。如果為true,表示該方法不需要列印日誌。比如某一個類裡有很多個方法需要日誌,但其中某個方法是用於檔案上傳的或者定時任務每秒都會執行1次,這些場景下不需要列印日誌,則可以設定ignore=true。
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ServiceLog {

    boolean ignore() default false;
}
  1. 定義一個全域性攔截器,列印入參、出參日誌,在這裡使用的是FastJson將物件轉化為字串。
@Aspect
@Component
public class ServiceLogAspect {

	@Around("@within(cn.itmds.log.ServiceLog)")
    protected Object aroundJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        Method method = signature.getMethod();
        ServiceLog serviceLog = method.getAnnotation(ServiceLog.class);
        if (null != serviceLog && serviceLog.ignore()) {
            return joinPoint.proceed();
        }
        long beginTime = System.currentTimeMillis();
        Class clazz = joinPoint.getTarget().getClass();
        String methodName = clazz.getSimpleName() + "." + method.getName();
        // 列印請求所有的入參
        log.info("Begin|{}|{}", methodName, jsonString(joinPoint.getArgs()));

        Object result = null;
        try {
            result = joinPoint.proceed();
        } finally {
        	// 列印所有的出參
            log.info("End|{}|{}ms|{}", methodName, System.currentTimeMillis(),
            	 - beginTime, jsonString(result));
        }
        return result;
    }
}

  1. 增加一個配置項,定義需要過濾的敏感資訊,比如真實姓名、手機號、身份證、密碼等
logging:
  sensitiveChars: realName,phoneNumber,idCard,mail,password
  1. 接下來,我們可以利用FastJSON的過濾器特性來實現日誌的過濾。
    private ValueFilter valueFilter = (object, name, value) -> {
        if (null == value || "".equals(value)) {
            return value;
        }

        if (value instanceof byte[]) {
            // 如果是byte位元組,直接列印長度
            return "byte length:" + ((byte[])value).length;
        } else if (value instanceof String) {
            // 在該方法裡檢查name,如果name包含我們配置的敏感資訊,則將value設定為加*隱藏。
            return stringValueProcess(name, (String)value);
        } else {
            return value;
        }
    };

在第二步攔截器的方法aroundJoinPoint中,物件轉化為String時,使用FastJSON的過濾器。

    protected String jsonString(Object object) {
        return JSON.toJSONString(object, valueFilter);
    }
  1. Controller層同樣,攔截所有的controller目錄下的檔案即可。
@Around("execution(public * cn.itmds.controller..*(..) )")

Controller通過該方法實現時要注意,http請求和response請求有些欄位是無法序列化的,所以務必要進行過濾。

public static <T> Stream<T> streamOf(T[] array) {
        return ArrayUtils.isEmpty(array) ? Stream.empty() : Arrays.asList(array).stream();
    }

//... 攔截器的方法中增加過濾
 List<Object> logArgs = (List)streamOf(args).filter((arg) -> {
                return !(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse);
            }).collect(Collectors.toList());
// 列印請求所有的入參
log.info("Begin|{}|{}", methodName, jsonString(logArgs));

2.3 密碼加密和《密碼法》

關於密碼,國家也是有一部《密碼法》的,最近好像也在推廣宣傳。當然我們平時常說的使用者名稱“密碼”只是“口令”,並不是密碼法中的“密碼”。《密碼法》中的密碼使用範圍包含二代身份證、電子簽名、增值稅發票密碼區之類的,具體大家可以去看看全文,不做多講。
在這裡插入圖片描述

2.3.1 密碼加密的注意事項

現在的開發人員基本都具備一定的安全知識,很少有明文儲存密碼的了,甚至直接md5的也很少,大部分都開始採用sha1,sha256了,也有一些公司開始使用用Argon2

Argon2 是一種慢雜湊函式,在 2015 年獲得 Password Hashing Competition 冠軍,利用大量記憶體計算抵禦GPU 和其他定製硬體的破解,提高雜湊結果的安全性。

這裡主要講幾點:

  1. 每一個密碼都要加上不同的鹽,確保相同的密碼也產生不同的hash。比如兩個人的密碼都是abcd1234,生成的hash一定要是不同的。
  2. 不要使用普通的隨機演算法生成鹽,一定要使用CSPRNG(Cryptographically Secure Pseudo-Random Number Generator);對應java就是Java.security.SecureRandom,對應C/C++ CryptGenRandom。
  3. 有些系統使用使用者的id、手機號等來作為鹽加密密碼,這其實不符合鹽的生成規則要求。但對於一般性的安全性要求並不是那麼高的網站,也基本能用。

2.3.2 使用BCrypt實現密碼加密

Bcrypt是一個跨平臺的檔案加密工具,SpringSecurity預設使用了該演算法。如果專案中沒有依然SpringSecurity,也可以單獨引入jar包。 bcrypt演算法與md5/sha演算法有一個很大的區別,就是每次生成的hash值都是不同的,不需要我們自行指定鹽。加密後的字元長度比較長,有60位,資料庫欄位設計時務必要注意。示例如下:

    public static void main(String[] args) {
        BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder();
        String pwd = "abcd1234";
        for (int i = 0; i < 5; i++) {
            String encodePwd = bcrypt.encode(pwd);
            boolean result = bcrypt.matches(pwd, encodePwd);
            System.out.println(encodePwd + "|" + result);
        }
    }

在這裡插入圖片描述
加密後的字串值組成

  • $是分割符,無意義;
  • 2a是bcrypt加密版本號;
  • 10是cost的值;
  • 後面的字串中,前22位是salt值;再然後的字串就是密碼的密文了。

有興趣的可以看下原始碼

public static String gensalt(int log_rounds, SecureRandom random) {
		if (log_rounds < MIN_LOG_ROUNDS || log_rounds > MAX_LOG_ROUNDS) {
			throw new IllegalArgumentException("Bad number of rounds");
		}
		StringBuilder rs = new StringBuilder();
		byte rnd[] = new byte[BCRYPT_SALT_LEN];

		random.nextBytes(rnd);

		rs.append("$2a$");
		if (log_rounds < 10) {
			rs.append("0");
		}
		rs.append(log_rounds);
		rs.append("$");
		encode_base64(rnd, rnd.length, rs);
		return rs.toString();
	}

2.3.3 Dropbox密碼加密儲存防範

Dropbox是提供檔案線上儲存的著名廠商,曾在其官方技術部落格發表名為《How Dropbox securely stores your passwords》的文章,講述了他們的使用者密碼加密儲存方案。
在這裡插入圖片描述

  1. 首先使用sha512,將使用者密碼歸一化為64位元組hash值。因為兩個原因:一個是Bcrypt算對輸入敏感,如果使用者輸入的密碼較長,可能導致Bcrypt計算過慢從而影響響應時間;另一個是有些Bcrypt演算法的實現會將長輸入直接截斷為72位元組,從資訊理論的角度講,這導致使用者資訊的熵變小;
  2. 然後使用Bcrypt演算法。選擇Bcrypt的原因,是Dropbox的工程師對這個演算法更熟悉調優更有經驗,引數選擇的標準,是Dropbox的線上API伺服器可以在100ms左右的時間可計算出結果。另外,關於Bcrypt和Scrypt哪個演算法更優,密碼學家也沒有定論。同時,Dropbox也在關注密碼hash演算法新秀Argon2,並表示會在合適的時機引入;
  3. 最後使用AES加密。因為Bcrypt不是完美的演算法,所以Dropbox使用AES和全域性金鑰進一步降低密碼被破解的風險,為了防止金鑰洩露,Dropbox採用了專用的金鑰儲存硬體。Dropbox還提到了最後使用AES加密的另一個好處,即金鑰可定時更換,以降低使用者資訊/金鑰洩露帶來的風險。

使用者隱私保護,遠不是開發人員加解密這麼簡單,還需要運營、運維團隊各方面的配合,任重而道遠!

【人總要給自己留一些隱私的空間,就像你總是會站在你的影子前擋住了光的視線】
People always want to give yourself some privacy space, just like you will always be standing in front of the shadow of you blocking the line of sight of the light.

參考:
https://www.cnblogs.com/xinzhao/p/6035847.html
https://blog.csdn.net/weixin_39494923/article/details/91534658


架構師,十年戎【碼】,老【叔】開花。個人微訊號:qiaojs,關注架構設計、大資料、微服務、技術管理。
在這裡插入圖片描述

相關文章