feat: add private auth foundation
This commit is contained in:
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
@@ -47,7 +48,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.48.0 // indirect
|
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
|||||||
28
backend/internal/auth/middleware.go
Normal file
28
backend/internal/auth/middleware.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backend/internal/auth/service.go
Normal file
74
backend/internal/auth/service.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
57
backend/internal/auth/service_test.go
Normal file
57
backend/internal/auth/service_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user