feat(backend): bcrypt+jwt auth package and admin seeding

This commit is contained in:
2026-09-04 23:50:52 +08:00
parent c1761d0f3e
commit b224288dae
6 changed files with 168 additions and 1 deletions
+2 -1
View File
@@ -4,7 +4,9 @@ go 1.26.0
require ( require (
github.com/gin-gonic/gin v1.12.0 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 github.com/jackc/pgx/v5 v5.10.0
golang.org/x/crypto v0.56.0
) )
require ( require (
@@ -35,7 +37,6 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.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/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
+2
View File
@@ -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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+54
View File
@@ -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
}
+40
View File
@@ -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")
}
}
+29
View File
@@ -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
}
+41
View File
@@ -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)
}
}