first release
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
iterations = 210_000
|
||||
keyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
|
||||
// HashPassword 使用 PBKDF2-SHA256 生成口令摘要。
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dk, err := pbkdf2.Key(sha256.New, password, salt, iterations, keyLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("pbkdf2$sha256$%d$%s$%s", iterations, b64(salt), b64(dk)), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 5 || parts[0] != "pbkdf2" {
|
||||
return false
|
||||
}
|
||||
iter, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
salt, err := unb64(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := unb64(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawStdEncoding.EncodeToString(b) }
|
||||
func unb64(s string) ([]byte, error) { return base64.RawStdEncoding.DecodeString(s) }
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
DisplayName string
|
||||
Hash string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
Secret []byte
|
||||
Days int
|
||||
}
|
||||
|
||||
// Authenticate 校验用户口令。
|
||||
func (s *Store) Authenticate(ctx context.Context, username, password string) (*User, error) {
|
||||
var u User
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id, username, COALESCE(display_name,''), password_hash
|
||||
FROM users WHERE username=$1`, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !VerifyPassword(u.Hash, password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, `UPDATE users SET last_login_at=now() WHERE id=$1`, u.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// EnsureDefaultUser 在没有任何用户时创建初始管理员。
|
||||
func (s *Store) EnsureDefaultUser(ctx context.Context, username, password string) (bool, error) {
|
||||
var n int
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n > 0 {
|
||||
return false, nil
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO users (username, password_hash, display_name)
|
||||
VALUES ($1,$2,$3)`, username, hash, "站长"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) ChangePassword(ctx context.Context, userID int64, current, next string) error {
|
||||
var hash string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id=$1`, userID).Scan(&hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
nh, err := HashPassword(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.DB.ExecContext(ctx, `UPDATE users SET password_hash=$1 WHERE id=$2`, nh, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrUsernameTaken 表示新用户名已被占用。
|
||||
var ErrUsernameTaken = errors.New("用户名已被占用")
|
||||
|
||||
// ChangeUsername 校验口令后改名。
|
||||
// 会话 cookie 认的是 id 与口令摘要(都不含用户名),所以改名不会把当前登录踢掉。
|
||||
func (s *Store) ChangeUsername(ctx context.Context, userID int64, current, name string) error {
|
||||
var hash string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id=$1`, userID).Scan(&hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
var taken bool
|
||||
if err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM users WHERE username=$1 AND id<>$2)`, name, userID).Scan(&taken); err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET username=$1 WHERE id=$2`, name, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserByID 读取用户;用于每次请求还原登录态。
|
||||
func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) {
|
||||
var u User
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id, username, COALESCE(display_name,''), password_hash
|
||||
FROM users WHERE id=$1`, id).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// 会话令牌 = expiry|uid|hmac(uid|expiry|口令摘要前缀),口令变更即自动失效。
|
||||
func (s *Store) SignToken(userID int64, hash string) string {
|
||||
exp := time.Now().AddDate(0, 0, s.Days).Unix()
|
||||
payload := fmt.Sprintf("%d|%d", userID, exp)
|
||||
return payload + "|" + s.mac(payload, hash)
|
||||
}
|
||||
|
||||
func (s *Store) ParseToken(token, hash string) (int64, bool) {
|
||||
parts := strings.Split(token, "|")
|
||||
if len(parts) != 3 {
|
||||
return 0, false
|
||||
}
|
||||
uid, err1 := strconv.ParseInt(parts[0], 10, 64)
|
||||
exp, err2 := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err1 != nil || err2 != nil || time.Now().Unix() > exp {
|
||||
return 0, false
|
||||
}
|
||||
if !hmac.Equal([]byte(parts[2]), []byte(s.mac(parts[0]+"|"+parts[1], hash))) {
|
||||
return 0, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
|
||||
func (s *Store) mac(payload, hash string) string {
|
||||
m := hmac.New(sha256.New, s.Secret)
|
||||
m.Write([]byte(payload))
|
||||
// 混入口令摘要,改密后旧 cookie 立即作废
|
||||
m.Write([]byte("|" + hash[:min(len(hash), 16)]))
|
||||
return b64(m.Sum(nil))
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// TestChangeUsername 覆盖改名的正向路径、口令校验与占用冲突。
|
||||
// 会临时建一个用户并在结束时删掉,所以需要一个可连的库:用 ROND_DSN 指过来,
|
||||
// 未设置时跳过(与项目里其它按环境变量门控的测试一致)。
|
||||
func TestChangeUsername(t *testing.T) {
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("设置 ROND_DSN 后运行改名检查")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("连接数据库: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
name := fmt.Sprintf("tmp_rename_%d", time.Now().UnixNano())
|
||||
hash, err := HashPassword("tmp-pass-123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var id int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ($1,$2) RETURNING id`, name, hash).Scan(&id); err != nil {
|
||||
t.Fatalf("建临时用户: %v", err)
|
||||
}
|
||||
defer db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, id)
|
||||
|
||||
st := &Store{DB: db, Secret: []byte("test-secret"), Days: 7}
|
||||
// 改名不该影响已登录会话:cookie 认的是 id 与口令摘要,都不含用户名
|
||||
tok := st.SignToken(id, hash)
|
||||
|
||||
next := name + "_new"
|
||||
if err := st.ChangeUsername(ctx, id, "wrong-password", next); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("口令错误应返回 ErrInvalidCredentials,实际 %v", err)
|
||||
}
|
||||
|
||||
// 换成一个已存在的用户名(库里除自己之外随便取一个)
|
||||
var other string
|
||||
if err := db.QueryRowContext(ctx, `SELECT username FROM users WHERE id<>$1 LIMIT 1`, id).Scan(&other); err == nil {
|
||||
if err := st.ChangeUsername(ctx, id, "tmp-pass-123456", other); !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Errorf("占用他人用户名应返回 ErrUsernameTaken,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := st.ChangeUsername(ctx, id, "tmp-pass-123456", next); err != nil {
|
||||
t.Fatalf("正常改名失败: %v", err)
|
||||
}
|
||||
u, err := st.UserByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("回读用户: %v", err)
|
||||
}
|
||||
if u.Username != next {
|
||||
t.Errorf("用户名应为 %q,实际 %q", next, u.Username)
|
||||
}
|
||||
if got, ok := st.ParseToken(tok, hash); !ok || got != id {
|
||||
t.Errorf("改名后旧会话应仍有效,实际 ok=%v id=%d", ok, got)
|
||||
}
|
||||
// 密码没被动过:改完还能用原口令登录
|
||||
if _, err := st.Authenticate(ctx, next, "tmp-pass-123456"); err != nil {
|
||||
t.Errorf("改名后原口令应仍可登录: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TOTP 实现(RFC 6238,30 秒步长、6 位数字、HMAC-SHA1),
|
||||
// 与 Google Authenticator / 1Password / Microsoft Authenticator 兼容。
|
||||
|
||||
const (
|
||||
totpStep = 30 * time.Second
|
||||
totpStepS = 30 // 步长(秒),计数器 = unix秒 / 30
|
||||
totpWindow = 1 // 允许前后偏差的步数
|
||||
)
|
||||
|
||||
// counterOf 把时刻换算成 TOTP 计数器。
|
||||
func counterOf(unixSec int64) uint64 {
|
||||
if unixSec < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(unixSec / totpStepS)
|
||||
}
|
||||
|
||||
var ErrTOTPNotEnabled = errors.New("未开启两步验证")
|
||||
|
||||
// GenerateTOTPSecret 生成 20 字节随机秘钥的 base32 编码(无填充)。
|
||||
func GenerateTOTPSecret() (string, error) {
|
||||
raw := make([]byte, 20)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func totpCode(secret string, counter uint64) (string, error) {
|
||||
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(
|
||||
strings.ToUpper(strings.ReplaceAll(secret, " ", "")))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("秘钥格式无效: %w", err)
|
||||
}
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], counter)
|
||||
m := hmac.New(sha1.New, key)
|
||||
m.Write(msg[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
code := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1_000_000
|
||||
return fmt.Sprintf("%06d", code), nil
|
||||
}
|
||||
|
||||
// TOTPCode 返回指定时刻的验证码(用于展示与测试)。
|
||||
func TOTPCode(secret string, t time.Time) (string, error) {
|
||||
return totpCode(secret, counterOf(t.Unix()))
|
||||
}
|
||||
|
||||
// VerifyTOTP 校验验证码,允许前后各一个步长的时钟偏差。
|
||||
func VerifyTOTP(secret, code string) bool {
|
||||
now := counterOf(time.Now().Unix())
|
||||
for _, c := range []uint64{now - totpWindow, now, now + totpWindow} {
|
||||
want, err := totpCode(secret, c)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(strings.TrimSpace(code))) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OTPAuthURL 生成验证器 App 扫码用的 URI。
|
||||
func OTPAuthURL(username, secret string) string {
|
||||
label := urlEscape("rond-with-you:" + username)
|
||||
return "otpauth://totp/" + label + "?secret=" + secret + "&issuer=" + urlEscape("rond-with-you") +
|
||||
"&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
|
||||
func urlEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '~', r == ':':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
fmt.Fprintf(&b, "%%%02X", r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---------- 用户秘钥存取 ----------
|
||||
|
||||
// TOTPState 返回用户的两步验证状态。
|
||||
func (s *Store) TOTPState(ctx context.Context, userID int64) (secret string, enabled bool, err error) {
|
||||
err = s.DB.QueryRowContext(ctx, `SELECT COALESCE(totp_secret,''), totp_enabled
|
||||
FROM users WHERE id=$1`, userID).Scan(&secret, &enabled)
|
||||
return
|
||||
}
|
||||
|
||||
// SetTOTPSecret 保存(或覆盖)秘钥;enabled 保持不变,供「先展示二维码再确认」流程。
|
||||
func (s *Store) SetTOTPSecret(ctx context.Context, userID int64, secret string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_secret=$1 WHERE id=$2`, secret, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnableTOTP 确认启用两步验证。
|
||||
func (s *Store) EnableTOTP(ctx context.Context, userID int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_enabled=TRUE WHERE id=$1 AND totp_secret<>''`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DisableTOTP 关闭两步验证并清除秘钥。
|
||||
func (s *Store) DisableTOTP(ctx context.Context, userID int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_enabled=FALSE, totp_secret='' WHERE id=$1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 两步登录的中间凭据 ----------
|
||||
|
||||
// SignPending2FA 在口令校验通过但未过两步验证时签发短时中间令牌,
|
||||
// 绑定口令摘要(改密即失效),有效期 5 分钟。
|
||||
func (s *Store) SignPending2FA(userID int64, hash string) string {
|
||||
exp := time.Now().Add(5 * time.Minute).Unix()
|
||||
payload := fmt.Sprintf("2fa|%d|%d", userID, exp)
|
||||
return payload + "|" + s.mac("p"+payload, hash)
|
||||
}
|
||||
|
||||
// ParsePending2FA 还原中间令牌。
|
||||
func (s *Store) ParsePending2FA(token, hash string) (int64, bool) {
|
||||
parts := strings.Split(token, "|")
|
||||
if len(parts) != 4 || parts[0] != "2fa" {
|
||||
return 0, false
|
||||
}
|
||||
var uid, exp int64
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &uid); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[2], "%d", &exp); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if time.Now().Unix() > exp {
|
||||
return 0, false
|
||||
}
|
||||
payload := "2fa|" + parts[1] + "|" + parts[2]
|
||||
if !hmac.Equal([]byte(parts[3]), []byte(s.mac("p"+payload, hash))) {
|
||||
return 0, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
Reference in New Issue
Block a user