后端基础层:模块定义、配置解析、SQLite 存储层、Argon2id 认证与 Markdown 渲染

- internal/config:serve/init/backup/gc 子命令参数解析(--dev 强制 loopback 守卫)
- internal/store:user_version 版本化迁移(仅追加式 + 越界拒启 + --allow-newer)、
  笔记/图片/引用/会话/设置 DAO、并集可见性查询、VACUUM INTO 在线备份、
  回收站 30 天 + 孤儿图 7 天宽限 gc
- internal/auth:Argon2id PHC 串(m=19456,t=2,p=1)、256bit token 与 SHA-256 摘要
- internal/markdown:goldmark(默认转义)+ bluemonday 双保险,摘要纯文本提取
- internal/middleware:安全头(CSP/HSTS/nosniff 等)、错误日志、
  有界令牌桶限流(per-IP 桶上限 + TTL 逐出)、Origin/Referer 同源校验
This commit is contained in:
2026-09-08 08:14:12 +08:00
parent 685f628e26
commit 247e88c4fb
17 changed files with 1786 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
// Package auth 提供 Argon2id 口令哈希(PHC 串)、随机 token 与 token 摘要。
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// OWASP 现行推荐 Argon2id 参数:m=19MiB、t=2、p=1(设计 §3.1/§7.3)。
const (
ArgonTime = 2
ArgonMemory = 19456
ArgonThreads = 1
SaltLen = 16
KeyLen = 32
// MinPasswordLength 新口令最小长度(§7.1 改密)。
MinPasswordLength = 12
)
// HashPassword 生成 PHC 格式串:
// $argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>(参数随哈希走)。
func HashPassword(password string) (string, error) {
salt := make([]byte, SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("生成盐失败: %w", err)
}
key := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, KeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
ArgonMemory, ArgonTime, ArgonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// VerifyPassword 按 PHC 串内参数重派生并常量时间比较。任何解析失败均返回 false。
func VerifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash]
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" {
return false
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version {
return false
}
var m uint32
var t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// ErrWeakPassword 新口令不满足强度要求。
var ErrWeakPassword = errors.New("密码长度至少 12 个字符")
// CheckPasswordStrength 校验新口令强度。
func CheckPasswordStrength(password string) error {
if len(password) < MinPasswordLength {
return ErrWeakPassword
}
return nil
}
// NewToken 生成 256bit 随机 token(base64url,43 字符)。
func NewToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// HashToken token 明文的 SHA-256 十六进制摘要(库中只存摘要)。
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
+54
View File
@@ -0,0 +1,54 @@
package auth
import "testing"
func TestPasswordHashRoundtrip(t *testing.T) {
hash, err := HashPassword("correct-horse-12")
if err != nil {
t.Fatal(err)
}
// PHC 串格式与参数(§7.3-1)
if len(hash) < len("$argon2id$v=19$m=19456,t=2,p=1$") ||
hash[:len("$argon2id$v=19$m=19456,t=2,p=1$")] != "$argon2id$v=19$m=19456,t=2,p=1$" {
t.Errorf("PHC 前缀/参数错误: %s", hash)
}
if !VerifyPassword(hash, "correct-horse-12") {
t.Error("正确口令应通过校验")
}
if VerifyPassword(hash, "wrong-password") {
t.Error("错误口令不应通过")
}
// 盐随机:同口令两次哈希不同
hash2, _ := HashPassword("correct-horse-12")
if hash == hash2 {
t.Error("同口令两次哈希应不同(随机盐)")
}
// 非法串安全返回 false
for _, bad := range []string{"", "$argon2id$", "$bcrypt$x$y$z", "$argon2id$v=19$m=1,t=1,p=1$!!$!!"} {
if VerifyPassword(bad, "x") {
t.Errorf("非法串 %q 不应通过", bad)
}
}
}
func TestTokenAndStrength(t *testing.T) {
tok, err := NewToken()
if err != nil {
t.Fatal(err)
}
if len(tok) != 43 {
t.Errorf("256bit base64url token 应为 43 字符,实际 %d", len(tok))
}
if HashToken(tok) == tok {
t.Error("token 摘要不应等于明文")
}
if HashToken(tok) != HashToken(tok) {
t.Error("摘要应确定")
}
if err := CheckPasswordStrength("short12"); err == nil {
t.Error("弱口令应被拒绝")
}
if err := CheckPasswordStrength("strong-enough-12"); err != nil {
t.Errorf("合格口令不应被拒绝: %v", err)
}
}