游戏注册、登录和鉴权¶
很多游戏公司都会搞一个通行证,官网、旗下所有游戏都用它登录。为了泛用性,这部分用的是 https 协议。我尝试用 golang + gin 框架 + MongoDB 搞了个类似的服务,包括注册、登录和鉴权。然后,用 C# 写了一套 SDK,方便在 Unity 里用。
前端大致流程¶
flowchart TD
A[尝试自动登录] -->|Old Token| B{后端}
B -->|成功,返回 New Token| C[登录成功]
B -->|失败| D[登录/注册]
D -->|账号密码| B
登录成功后,每次请求 API 都带上 Token,后端会做鉴权。
密码传输与保存¶
很多人都是所有账号用一个密码,如果一个地方密码明文泄露了,黑客拿去撞库的话,一大堆账号都没了。只要明文不泄露,出意外的时候,损失就能仅仅控制在当前的站点。
传输密码的时候,用的是 https 协议,一般情况下已经足够安全了。Google 在传输密码时就没额外做加密。百度是自己又做了一次 RSA。还有些网站是前端 hash 一次,把结果作为用户的密码传给后端,不使用密码明文。
我用的是类似百度的方案。服务器启动时,会生成 RSA 密钥 + 公钥。公钥是公开的,前端直接请求后端 API 就能拿到。密码用公钥加密后传给后端,后端用密钥解密。
var rsaPrivateKey *rsa.PrivateKey
var rsaPublicKey *rsa.PublicKey
var rsaPublicKeyB64Str string
func GenerateRSAKeys() error {
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
return err
}
rsaPrivateKey = privateKey
rsaPublicKey = &privateKey.PublicKey
derPkcs1 := x509.MarshalPKCS1PublicKey(rsaPublicKey)
rsaPublicKeyB64Str = base64.StdEncoding.EncodeToString(derPkcs1)
return nil
}
func GetRSAPublicKey() string {
return rsaPublicKeyB64Str
}
func DecryptStringRSA(str string) (string, error) {
data, err := base64.StdEncoding.DecodeString(str)
if err != nil {
return "", err
}
buf, err := rsa.DecryptPKCS1v15(rand.Reader, rsaPrivateKey, data)
if err != nil {
return "", err
}
return string(buf), nil
}
在 Unity 里,C# 部分解析 RSA 公钥的方法是不能用的,会报 PlatformNotSupportedException
,原因不明。可以用开源的 Bouncy Castle 来解析公钥:
public RSA CreateRSA()
{
var provider = new RSACryptoServiceProvider();
provider.ImportParameters(ParsePKCS1DERPublicKey(Data));
return provider;
}
// 解析 DER 格式的 PKCS#1 公钥
private static RSAParameters ParsePKCS1DERPublicKey(string derPublicKey)
{
// 将 DER 编码的公钥转换为字节数组
byte[] derBytes = Convert.FromBase64String(derPublicKey);
// 使用 BouncyCastle 进行 DER 解码
Asn1Object obj = Asn1Object.FromByteArray(derBytes);
RsaPublicKeyStructure rsaPubKey = RsaPublicKeyStructure.GetInstance(obj);
return new RSAParameters
{
Modulus = rsaPubKey.Modulus.ToByteArrayUnsigned(),
Exponent = rsaPubKey.PublicExponent.ToByteArrayUnsigned()
};
}
private static void EncryptStringRSA(RSA rsa, ref string str)
{
byte[] data = Encoding.UTF8.GetBytes(str);
data = rsa.Encrypt(data, RSAEncryptionPadding.Pkcs1);
str = Convert.ToBase64String(data, Base64FormattingOptions.None);
}
保存密码时,不能用可逆加密,更不能直接存明文。2011 年中国网站用户信息泄露事件 中 CSDN 就因此泄露了大量密码。
现在一般都是给密码加盐(salt)再 hash,然后存进数据库。加盐就是给密码加上一个长长长长的随机字符串,每个用户都不一样。这样相当于提高了密码强度,而且相同密码的不同用户的 hash 值是不一样的。黑客就很难再建立彩虹表(Rainbow table)逆向 hash。
Bcrypt 算法¶
golang 内置了 bcrypt 算法。
-
生成 Hash
// bcrypt 算法将盐和 hash 结果拼在一起存进 passwordHash 里 passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { panic(err) } // 把 passwordHash 存进数据库里
-
检查密码
// 从数据库里取出 passwordHash if bcrypt.CompareHashAndPassword(passwordHash, []byte(password)) != nil { // 密码错误 }
Token 鉴权¶
每次请求需要鉴权的 API 时都带上账号密码很麻烦,一般会用 Token 代替。为了安全,Token 是有过期时间的,每次登录时会刷新时间。常用 JSON Web Token,阮一峰的博客 里讲得很清楚。
可以用开源的 jwt-go 生成 Token:
func generateJWTToken(userName string, secret []byte) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userName,
"exp": time.Now().Add(3 * 24 * time.Hour).Unix(),
})
return token.SignedString(secret)
}
鉴权功能一般用 gin 的中间件来实现:
func parseJWTToken(tokenString string, secret []byte) (userName string, err error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil {
return "", err
}
if !token.Valid {
return "", fmt.Errorf("jwt token is invalid")
}
return token.Claims.GetSubject()
}
func JWTAuth(jwtSecret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
userName, err := parseJWTToken(c.GetHeader("Authorization"), jwtSecret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.CommonRsp{
ReturnCode: model.ReturnCodeInvalidAuthToken,
Message: "Invalid auth token",
Data: nil,
})
return
}
// 查询用户信息
var user db.UserData
err = db.GetUserAccounts().FindOne(context.TODO(), bson.D{
{"user_name", userName},
}).Decode(&user)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.CommonRsp{
ReturnCode: model.ReturnCodeInvalidAuthToken,
Message: "Invalid auth token",
Data: nil,
})
return
}
// 之后可以直接取用 user 信息
c.Set("UserData", user)
c.Next()
}
}
JWT 的缺点是它只保存在前端,后端不能随意废弃某一个 JWT。如果对安全性要求很高,可以自己生成 uuid 作为 Token,然后存在数据库里。还可以把用户的登录设备、IP 和 Token 关联起来,存进数据库,实现将某设备踢下线的功能。