chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。 - internal/feishu: 飞书通讯录客户端、快照与重试 - internal/ldaplib: LDAP 读写与批量操作 - internal/mapping: 飞书部门/用户到 LDAP 条目的映射 - internal/syncer: 同步计划生成、执行与调度 - internal/store: SQLite 台账 - internal/server + internal/webui: 管理后台与内嵌前端产物 - internal/password: SSHA 口令处理 - config.example.yml / Dockerfile / Makefile / web 前端源码 真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// Package password 提供 OpenLDAP userPassword 的哈希编码。
|
||||
//
|
||||
// 为什么需要它:slapd 对客户端通过普通 Modify 写入的 userPassword **不做**
|
||||
// 自动哈希(除非启用 ppolicy 的 hash_cleartext),因此如果直接写明文,
|
||||
// 目录里就会以明文形式保存密码。这里在写入前自行编码成 {SSHA},
|
||||
// 不依赖服务端配置。
|
||||
package password
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// EncodeSSHA 把明文密码编码成 OpenLDAP 的 {SSHA} 形式。
|
||||
//
|
||||
// SSHA = base64( SHA1(密码 + 盐) + 盐 )。附带随机盐可以避免彩虹表,
|
||||
// 并让相同密码产生不同哈希。
|
||||
func EncodeSSHA(plain string) (string, error) {
|
||||
salt := make([]byte, 8)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("生成盐失败: %w", err)
|
||||
}
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(plain))
|
||||
h.Write(salt)
|
||||
sum := h.Sum(nil)
|
||||
|
||||
// 哈希后拼接盐,一并 base64
|
||||
out := append(sum, salt...)
|
||||
return "{SSHA}" + base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// VerifySSHA 校验明文密码与 {SSHA} 哈希是否匹配。
|
||||
//
|
||||
// 主要用于测试,也可供将来的密码校验功能复用。
|
||||
func VerifySSHA(encoded, plain string) bool {
|
||||
const prefix = "{SSHA}"
|
||||
if len(encoded) <= len(prefix) || encoded[:len(prefix)] != prefix {
|
||||
return false
|
||||
}
|
||||
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded[len(prefix):])
|
||||
if err != nil || len(raw) < sha1.Size {
|
||||
return false
|
||||
}
|
||||
|
||||
sum := raw[:sha1.Size]
|
||||
salt := raw[sha1.Size:]
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(plain))
|
||||
h.Write(salt)
|
||||
want := h.Sum(nil)
|
||||
|
||||
if len(want) != len(sum) {
|
||||
return false
|
||||
}
|
||||
// 恒定时间比较,避免时序侧信道(虽然此处主要用于测试,
|
||||
// 保持与生产代码一致的比较方式更不易被误用)。
|
||||
var diff byte
|
||||
for i := range want {
|
||||
diff |= want[i] ^ sum[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
// Random 生成一个用户不可知的随机初始密码。
|
||||
//
|
||||
// 用户不需要知道它:Authelia 的密码重置流程不需要旧密码,
|
||||
// 因此新账号首次登录应走“忘记密码”。这比为所有人设同一个初始密码安全得多。
|
||||
func Random() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("生成随机密码失败: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package password
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeSSHA(t *testing.T) {
|
||||
const plain = "correct horse battery staple"
|
||||
|
||||
encoded, err := EncodeSSHA(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("编码失败: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(encoded, "{SSHA}") {
|
||||
t.Errorf("结果应以 {SSHA} 开头,实际: %q", encoded)
|
||||
}
|
||||
|
||||
// 关键性质:明文绝不能出现在结果里。
|
||||
// slapd 不会自动哈希 Modify 写入的 userPassword,因此这一步是
|
||||
// 密码不落明文存储的唯一保障。
|
||||
if strings.Contains(encoded, plain) {
|
||||
t.Errorf("编码结果中出现了明文密码: %q", encoded)
|
||||
}
|
||||
|
||||
if !VerifySSHA(encoded, plain) {
|
||||
t.Errorf("正确密码校验失败: %q", encoded)
|
||||
}
|
||||
if VerifySSHA(encoded, plain+"x") {
|
||||
t.Errorf("错误密码不应通过校验")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSSHASaltIsRandom(t *testing.T) {
|
||||
const plain = "same-password"
|
||||
|
||||
a, err := EncodeSSHA(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("编码失败: %v", err)
|
||||
}
|
||||
b, err := EncodeSSHA(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("编码失败: %v", err)
|
||||
}
|
||||
|
||||
// 随机盐应让同一密码产生不同哈希,避免相同密码在目录中可被关联识别。
|
||||
if a == b {
|
||||
t.Errorf("同一密码两次编码结果相同,说明盐未随机化: %q", a)
|
||||
}
|
||||
if !VerifySSHA(a, plain) || !VerifySSHA(b, plain) {
|
||||
t.Errorf("带随机盐的两个哈希都应能校验通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySSHARejectsMalformed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
encoded string
|
||||
}{
|
||||
{"空字符串", ""},
|
||||
{"缺少前缀", "abcdef"},
|
||||
{"前缀但非法 base64", "{SSHA}!!!not-base64!!!"},
|
||||
{"长度不足以容纳哈希", "{SSHA}YWJj"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if VerifySSHA(c.encoded, "whatever") {
|
||||
t.Errorf("畸形输入 %q 不应校验通过", c.encoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandom(t *testing.T) {
|
||||
a, err := Random()
|
||||
if err != nil {
|
||||
t.Fatalf("生成失败: %v", err)
|
||||
}
|
||||
b, err := Random()
|
||||
if err != nil {
|
||||
t.Fatalf("生成失败: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("两次生成的随机密码相同: %q", a)
|
||||
}
|
||||
if len(a) < 20 {
|
||||
t.Errorf("随机密码过短(%d 字符),强度不足", len(a))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user