From 437c070e4599e99544e3855ef3f04508d038dea0 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 18 Jul 2026 15:55:55 +0800 Subject: [PATCH] feat: add private auth foundation --- backend/go.mod | 2 +- backend/internal/auth/middleware.go | 28 ++++++++++ backend/internal/auth/service.go | 74 +++++++++++++++++++++++++++ backend/internal/auth/service_test.go | 57 +++++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 backend/internal/auth/middleware.go create mode 100644 backend/internal/auth/service.go create mode 100644 backend/internal/auth/service_test.go diff --git a/backend/go.mod b/backend/go.mod index 744ec6d..e426fe2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.48.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.2 ) @@ -47,7 +48,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.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..446de54 --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,28 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +const CurrentUserIDKey = "currentUserID" + +func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + token := strings.TrimPrefix(header, "Bearer ") + if token == "" || token == header { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) + return + } + userID, err := tokenVerifier(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid bearer token"}) + return + } + c.Set(CurrentUserIDKey, userID) + c.Next() + } +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..7fc0a8b --- /dev/null +++ b/backend/internal/auth/service.go @@ -0,0 +1,74 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +type Service struct { + db *gorm.DB + secret string +} + +func NewService(database *gorm.DB, secret string) *Service { + return &Service{db: database, secret: secret} +} + +func (s *Service) CreateInvite(adminID uint, email string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(email)) + if normalized == "" { + return "", errors.New("email is required") + } + return normalized + "." + s.sign(normalized), nil +} + +func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*domain.User, error) { + separator := strings.LastIndex(token, ".") + if separator < 1 || separator == len(token)-1 { + return nil, errors.New("invalid invite token") + } + inviteEmail := token[:separator] + signature := token[separator+1:] + if !hmac.Equal([]byte(signature), []byte(s.sign(inviteEmail))) { + return nil, errors.New("invalid invite signature") + } + if inviteEmail != strings.ToLower(strings.TrimSpace(email)) { + return nil, errors.New("invite email mismatch") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + user := &domain.User{ + Email: inviteEmail, + DisplayName: strings.TrimSpace(displayName), + PasswordHash: string(hash), + Role: "user", + } + return user, s.db.Create(user).Error +} + +func (s *Service) Login(email, password string) (string, error) { + var user domain.User + if err := s.db.Where("email = ?", strings.ToLower(strings.TrimSpace(email))).First(&user).Error; err != nil { + return "", errors.New("invalid credentials") + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return "", errors.New("invalid credentials") + } + return fmt.Sprintf("user:%d", user.ID), nil +} + +func (s *Service) sign(value string) string { + mac := hmac.New(sha256.New, []byte(s.secret)) + mac.Write([]byte(value)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/backend/internal/auth/service_test.go b/backend/internal/auth/service_test.go new file mode 100644 index 0000000..902e57d --- /dev/null +++ b/backend/internal/auth/service_test.go @@ -0,0 +1,57 @@ +package auth + +import ( + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +func TestRegisterWithInviteCreatesUser(t *testing.T) { + database := newTestDB(t) + service := NewService(database, "test-secret") + token, err := service.CreateInvite(1, "lead@example.com") + require.NoError(t, err) + + user, err := service.RegisterWithInvite(token, "lead@example.com", "Lead", "password123") + + require.NoError(t, err) + require.Equal(t, "lead@example.com", user.Email) + require.NotEmpty(t, user.PasswordHash) + require.NotEqual(t, "password123", user.PasswordHash) +} + +func TestRegisterWithInviteRejectsWrongEmail(t *testing.T) { + database := newTestDB(t) + service := NewService(database, "test-secret") + token, err := service.CreateInvite(1, "lead@example.com") + require.NoError(t, err) + + _, err = service.RegisterWithInvite(token, "other@example.com", "Other", "password123") + + require.ErrorContains(t, err, "invite email mismatch") +} + +func TestLoginRejectsInvalidPassword(t *testing.T) { + database := newTestDB(t) + service := NewService(database, "test-secret") + token, err := service.CreateInvite(1, "lead@example.com") + require.NoError(t, err) + _, err = service.RegisterWithInvite(token, "lead@example.com", "Lead", "password123") + require.NoError(t, err) + + _, err = service.Login("lead@example.com", "wrong-password") + + require.ErrorContains(t, err, "invalid credentials") +} + +func newTestDB(t *testing.T) *gorm.DB { + t.Helper() + database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, domain.AutoMigrate(database)) + return database +}