做统一认证登录

This commit is contained in:
2026-09-19 21:44:04 +08:00
parent 1a4471e34d
commit fa281363fb
78 changed files with 8127 additions and 1442 deletions
+149 -55
View File
@@ -1,55 +1,149 @@
package passwordutil
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
)
const (
saltBytes = 16
separator = "$"
hashLength = 64 // sha256 hex length
)
// Hash 生成 salt+hash 的存储串,格式:salt$hash(均为 hex)
func Hash(plain string) (string, error) {
plain = strings.TrimSpace(plain)
if plain == "" {
return "", errors.New("password 不能为空")
}
salt := make([]byte, saltBytes)
if _, err := rand.Read(salt); err != nil {
return "", err
}
saltHex := hex.EncodeToString(salt)
hashHex := hashHex(saltHex, plain)
return saltHex + separator + hashHex, nil
}
// Verify 校验存储串(salt$hash)是否匹配输入明文密码。
func Verify(stored, plain string) bool {
stored = strings.TrimSpace(stored)
plain = strings.TrimSpace(plain)
if stored == "" || plain == "" {
return false
}
parts := strings.Split(stored, separator)
if len(parts) != 2 {
return false
}
saltHex := strings.TrimSpace(parts[0])
hashHexStored := strings.TrimSpace(parts[1])
if saltHex == "" || len(hashHexStored) != hashLength {
return false
}
return hashHex(saltHex, plain) == strings.ToLower(hashHexStored)
}
func hashHex(saltHex, plain string) string {
sum := sha256.Sum256([]byte(saltHex + plain))
return hex.EncodeToString(sum[:])
}
package passwordutil
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// 算法标识。
// 历史数据用 legacy(sha256 单轮,无迭代拉伸,抗 GPU 爆破能力弱);
// 新密码统一用 argon2id,登录成功检测到 legacy 时应重新哈希升级。
const (
AlgoArgon2id = "argon2id"
AlgoLegacy = "legacy"
)
// argon2id 参数(OWASP 推荐起步配置:64MB / 3 轮 / 并行 2)
const (
argonTime = 3
argonMemory = 64 * 1024 // 单位 KB,即 64MB
argonThreads = 2
argonKeyLen = 32
argonSaltLen = 16
)
// legacy 兼容参数
const (
saltBytes = 16
separator = "$"
hashLength = 64 // sha256 hex length
)
// Hash 使用当前默认算法(argon2id)生成密码存储串。
//
// 返回 PHC 标准格式(盐与参数内联,无需独立 salt 列):
//
// $argon2id$v=19$m=65536,t=3,p=2$<base64(salt)>$<base64(hash)>
//
// 注意:函数签名与旧版一致,全部调用点无需改动即可切换到新算法。
func Hash(plain string) (string, error) {
plain = strings.TrimSpace(plain)
if plain == "" {
return "", errors.New("password 不能为空")
}
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// Verify 校验明文密码是否匹配存储串,自动识别算法:
// - $argon2id$... → argon2id(新)
// - <salt>$<hash> → legacy sha256(旧,兼容)
func Verify(stored, plain string) bool {
stored = strings.TrimSpace(stored)
plain = strings.TrimSpace(plain)
if stored == "" || plain == "" {
return false
}
if strings.HasPrefix(stored, "$argon2id$") {
return verifyArgon2id(stored, plain)
}
return verifyLegacy(stored, plain)
}
// NeedsRehash 判断已存储的密码是否需要用当前算法重新哈希。
// 登录成功后调用:返回 true 时应拿当次登录的明文重新 Hash 并落库,
// 实现旧算法用户「首次登录自动升级」,无需强制全员重置密码。
func NeedsRehash(stored string) bool {
return !strings.HasPrefix(strings.TrimSpace(stored), "$argon2id$")
}
// AlgoOf 返回存储串使用的算法标识,用于审计/统计旧算法存量。
func AlgoOf(stored string) string {
if strings.HasPrefix(strings.TrimSpace(stored), "$argon2id$") {
return AlgoArgon2id
}
return AlgoLegacy
}
// verifyArgon2id 解析 PHC 串并用相同参数重算比对(恒定时间比较)。
func verifyArgon2id(stored, plain string) bool {
parts := strings.Split(stored, "$")
// ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash]
if len(parts) != 6 {
return false
}
var memory, iterations, parallelism uint32
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &parallelism); err != nil {
return false
}
if memory == 0 || iterations == 0 || parallelism == 0 || parallelism > 255 {
return false
}
threads := uint8(parallelism)
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil || len(salt) == 0 {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(want) == 0 {
return false
}
got := argon2.IDKey([]byte(plain), salt, iterations, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// verifyLegacy 校验旧格式:salt$hash(均为 hex),sha256(salt+plain)。
// 仅用于兼容历史数据,不再用于新密码。
func verifyLegacy(stored, plain string) bool {
stored = strings.TrimSpace(stored)
plain = strings.TrimSpace(plain)
if stored == "" || plain == "" {
return false
}
parts := strings.Split(stored, separator)
if len(parts) != 2 {
return false
}
saltHex := strings.TrimSpace(parts[0])
hashHexStored := strings.TrimSpace(parts[1])
if saltHex == "" || len(hashHexStored) != hashLength {
return false
}
if _, err := hex.DecodeString(saltHex); err != nil {
return false
}
return hashHex(saltHex, plain) == strings.ToLower(hashHexStored)
}
// hashHex 旧算法:sha256(saltHex + plain),供 verifyLegacy 使用。
func hashHex(saltHex, plain string) string {
sum := sha256.Sum256([]byte(saltHex + plain))
return hex.EncodeToString(sum[:])
}
+69
View File
@@ -0,0 +1,69 @@
package passwordutil
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
// TestHashVerifyArgon2id 新算法:哈希后可校验,错误密码拒绝
func TestHashVerifyArgon2id(t *testing.T) {
stored, err := Hash("Passw0rd@2026")
if err != nil {
t.Fatalf("Hash 失败: %v", err)
}
if !verifyArgon2id(stored, "Passw0rd@2026") {
t.Fatal("正确密码应校验通过")
}
if verifyArgon2id(stored, "wrong-password") {
t.Fatal("错误密码不应通过")
}
if NeedsRehash(stored) {
t.Fatal("argon2id 不应需要重新哈希")
}
if AlgoOf(stored) != AlgoArgon2id {
t.Fatalf("算法标识应为 argon2id,实际 %s", AlgoOf(stored))
}
}
// TestLegacyCompat 旧算法兼容:历史 salt$sha256 串仍可登录,且被标记为需要升级
func TestLegacyCompat(t *testing.T) {
saltHex := "0123456789abcdef0123456789abcdef"
sum := sha256.Sum256([]byte(saltHex + "oldpass"))
legacy := saltHex + "$" + hex.EncodeToString(sum[:])
if !Verify(legacy, "oldpass") {
t.Fatal("历史密码应校验通过(兼容)")
}
if Verify(legacy, "other") {
t.Fatal("错误密码不应通过")
}
if !NeedsRehash(legacy) {
t.Fatal("历史密码应标记为需要重新哈希")
}
if AlgoOf(legacy) != AlgoLegacy {
t.Fatalf("算法标识应为 legacy,实际 %s", AlgoOf(legacy))
}
}
// TestDifferentSaltSamePassword 相同明文两次哈希结果必须不同(盐随机)
func TestDifferentSaltSamePassword(t *testing.T) {
a, _ := Hash("same-password")
b, _ := Hash("same-password")
if a == b {
t.Fatal("相同明文两次哈希不应相同(盐必须随机)")
}
if !Verify(a, "same-password") || !Verify(b, "same-password") {
t.Fatal("两份哈希都应能通过校验")
}
}
// TestEmptyPassword 空密码边界
func TestEmptyPassword(t *testing.T) {
if _, err := Hash(" "); err == nil {
t.Fatal("空密码应返回错误")
}
if Verify("", "x") || Verify("x", "") {
t.Fatal("空输入不应通过校验")
}
}