From b224288dae880c718b771990de1a57a3dfa68194 Mon Sep 17 00:00:00 2001 From: Fendy Date: Fri, 4 Sep 2026 23:50:52 +0800 Subject: [PATCH] feat(backend): bcrypt+jwt auth package and admin seeding --- backend/go.mod | 3 +- backend/go.sum | 2 ++ backend/internal/auth/auth.go | 54 ++++++++++++++++++++++++++++++ backend/internal/auth/auth_test.go | 40 ++++++++++++++++++++++ backend/internal/seed/seed.go | 29 ++++++++++++++++ backend/internal/seed/seed_test.go | 41 +++++++++++++++++++++++ 6 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 backend/internal/auth/auth.go create mode 100644 backend/internal/auth/auth_test.go create mode 100644 backend/internal/seed/seed.go create mode 100644 backend/internal/seed/seed_test.go diff --git a/backend/go.mod b/backend/go.mod index 6be78ba..e1530db 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,7 +4,9 @@ go 1.26.0 require ( github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.56.0 ) require ( @@ -35,7 +37,6 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.56.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 418abff..1b602ed 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -27,6 +27,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 0000000..7fe8cec --- /dev/null +++ b/backend/internal/auth/auth.go @@ -0,0 +1,54 @@ +package auth + +import ( + "crypto/subtle" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var ErrToken = errors.New("invalid token") + +func HashPassword(plain string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plain), 12) + return string(b), err +} + +func CheckPassword(hash, plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} + +type Claims struct { + UID int64 `json:"uid"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func Sign(secret []byte, uid int64, role string) (string, error) { + return SignWithTTL(secret, uid, role, 72*time.Hour) +} + +func SignWithTTL(secret []byte, uid int64, role string, ttl time.Duration) (string, error) { + t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + UID: uid, Role: role, + RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl))}, + }) + return t.SignedString(secret) +} + +func Parse(secret []byte, token string) (*Claims, error) { + c := &Claims{} + parsed, err := jwt.ParseWithClaims(token, c, func(t *jwt.Token) (any, error) { + m, ok := t.Method.(*jwt.SigningMethodHMAC) + if !ok || subtle.ConstantTimeCompare([]byte(m.Alg()), []byte("HS256")) != 1 { + return nil, ErrToken + } + return secret, nil + }, jwt.WithValidMethods([]string{"HS256"})) + if err != nil || !parsed.Valid { + return nil, ErrToken + } + return c, nil +} diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go new file mode 100644 index 0000000..f5a52b2 --- /dev/null +++ b/backend/internal/auth/auth_test.go @@ -0,0 +1,40 @@ +package auth + +import ( + "strings" + "testing" + "time" +) + +func TestHashCheck(t *testing.T) { + h, err := HashPassword("hunter2") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(h, "$2a$") || !CheckPassword(h, "hunter2") || CheckPassword(h, "wrong") { + t.Fatal("bcrypt wrong") + } +} + +func TestSignParse(t *testing.T) { + secret := []byte("k") + tok, err := Sign(secret, 7, "admin") + if err != nil { + t.Fatal(err) + } + c, err := Parse(secret, tok) + if err != nil || c.UID != 7 || c.Role != "admin" { + t.Fatalf("parse: %v %+v", err, c) + } + if _, err := Parse([]byte("other"), tok); err == nil { + t.Fatal("must reject wrong secret") + } + expired, _ := SignWithTTL(secret, 1, "member", -time.Hour) + if _, err := Parse(secret, expired); err == nil { + t.Fatal("must reject expired") + } + // alg 混淆攻击:none 必须拒 + if _, err := Parse(secret, "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1aWQiOjF9."); err == nil { + t.Fatal("must reject alg=none") + } +} diff --git a/backend/internal/seed/seed.go b/backend/internal/seed/seed.go new file mode 100644 index 0000000..fb47893 --- /dev/null +++ b/backend/internal/seed/seed.go @@ -0,0 +1,29 @@ +package seed + +import ( + "context" + "log" + + "booklib/internal/auth" + "booklib/internal/store" +) + +func Admin(ctx context.Context, s *store.Store, user, pass string) error { + if user == "" || pass == "" { + return nil + } + n, err := s.CountUsers(ctx) + if err != nil || n > 0 { + return err + } + h, err := auth.HashPassword(pass) + if err != nil { + return err + } + if _, err := s.CreateUser(ctx, user, h, "admin"); err != nil { + log.Printf("seed admin %q may already exist: %v", user, err) + return nil + } + log.Printf("seeded initial admin user %q", user) + return nil +} diff --git a/backend/internal/seed/seed_test.go b/backend/internal/seed/seed_test.go new file mode 100644 index 0000000..9f7a854 --- /dev/null +++ b/backend/internal/seed/seed_test.go @@ -0,0 +1,41 @@ +package seed + +import ( + "context" + "os" + "testing" + + "booklib/internal/db" + "booklib/internal/store" +) + +func TestSeedOnlyWhenEmpty(t *testing.T) { + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL not set") + } + ctx := context.Background() + p, _ := db.Connect(ctx, url) + if err := db.Migrate(ctx, p); err != nil { + t.Fatal(err) + } + s := store.New(p) + p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users") + if err := Admin(ctx, s, "admin", "pw12345"); err != nil { + t.Fatal(err) + } + if err := Admin(ctx, s, "admin2", "pw12345"); err != nil { // 已有用户 → no-op + t.Fatal(err) + } + n, _ := s.CountUsers(ctx) + if n != 1 { + t.Fatalf("want 1 user got %d", n) + } + u, err := s.GetUserByName(ctx, "admin") + if err != nil || u.Role != "admin" { + t.Fatalf("bad admin: %+v %v", u, err) + } + if err := Admin(ctx, s, "", ""); err != nil { + t.Fatal("empty creds must no-op, got", err) + } +}