Inicial
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
const bcryptCost = 12
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func VerifyPassword(password, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/techxcar/backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestHashPassword_isNotPlaintext(t *testing.T) {
|
||||
hash, err := auth.HashPassword("mysecret")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, "mysecret", hash)
|
||||
assert.NotEmpty(t, hash)
|
||||
}
|
||||
|
||||
func TestVerifyPassword_correct(t *testing.T) {
|
||||
hash, err := auth.HashPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, auth.VerifyPassword("correctpassword", hash))
|
||||
}
|
||||
|
||||
func TestVerifyPassword_wrong(t *testing.T) {
|
||||
hash, err := auth.HashPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, auth.VerifyPassword("wrongpassword", hash))
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/techxcar/backend/internal/config"
|
||||
redispkg "github.com/techxcar/backend/pkg/redis"
|
||||
)
|
||||
|
||||
// LoginRepository is a subset of tenant.Repository used by auth handlers.
|
||||
// Uses local types to avoid a circular import with the tenant package.
|
||||
type LoginRepository interface {
|
||||
GetSuperAdminByEmail(ctx context.Context, email string) (*LoginSuperAdmin, error)
|
||||
GetTenantBySlug(ctx context.Context, slug string) (*LoginTenant, error)
|
||||
GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*LoginUser, error)
|
||||
}
|
||||
|
||||
type LoginSuperAdmin struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
type LoginTenant struct {
|
||||
ID string
|
||||
Status string
|
||||
}
|
||||
|
||||
type LoginUser struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Name string
|
||||
Active bool
|
||||
}
|
||||
|
||||
const refreshCookieName = "refresh_token"
|
||||
const refreshCookieTTL = 30 * 24 * time.Hour
|
||||
|
||||
func SetRefreshCookie(c *fiber.Ctx, token string, cfg *config.Config) {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: refreshCookieName,
|
||||
Value: token,
|
||||
MaxAge: int(refreshCookieTTL.Seconds()),
|
||||
HTTPOnly: true,
|
||||
Secure: cfg.AppEnv == "production",
|
||||
SameSite: "Strict",
|
||||
Path: "/api/v1/auth",
|
||||
})
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
}
|
||||
|
||||
func LoginHandler(repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req loginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return fiber.NewError(400, "corpo do pedido inválido")
|
||||
}
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
if req.Email == "" || req.Password == "" {
|
||||
return fiber.NewError(400, "email e password são obrigatórios")
|
||||
}
|
||||
|
||||
var userID, tenantID, role string
|
||||
userPayload := fiber.Map{}
|
||||
|
||||
if req.TenantSlug == "" {
|
||||
admin, err := repo.GetSuperAdminByEmail(c.Context(), req.Email)
|
||||
if err != nil || admin == nil || !VerifyPassword(req.Password, admin.PasswordHash) {
|
||||
return fiber.NewError(401, "credenciais inválidas")
|
||||
}
|
||||
userID, tenantID, role = admin.ID, "", "super_admin"
|
||||
userPayload = fiber.Map{
|
||||
"id": admin.ID, "email": admin.Email,
|
||||
"name": admin.Email, "role": "super_admin",
|
||||
}
|
||||
} else {
|
||||
ten, err := repo.GetTenantBySlug(c.Context(), req.TenantSlug)
|
||||
if err != nil || ten == nil || ten.Status != "active" {
|
||||
return fiber.NewError(401, "credenciais inválidas")
|
||||
}
|
||||
user, err := repo.GetTenantUserByEmail(c.Context(), ten.ID, req.Email)
|
||||
if err != nil || user == nil || !user.Active || !VerifyPassword(req.Password, user.PasswordHash) {
|
||||
return fiber.NewError(401, "credenciais inválidas")
|
||||
}
|
||||
userID, tenantID, role = user.ID, ten.ID, user.Role
|
||||
userPayload = fiber.Map{
|
||||
"id": user.ID, "email": user.Email,
|
||||
"name": user.Name, "role": user.Role,
|
||||
"tenantId": ten.ID,
|
||||
}
|
||||
}
|
||||
|
||||
access, err := GenerateAccessToken(userID, tenantID, role, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar token")
|
||||
}
|
||||
refresh, err := GenerateRefreshToken(userID, tenantID, role, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar token")
|
||||
}
|
||||
|
||||
if rdb != nil {
|
||||
ctx := c.Context()
|
||||
rdb.Client.Set(ctx, "refresh:"+userID, refresh, refreshCookieTTL)
|
||||
}
|
||||
|
||||
SetRefreshCookie(c, refresh, cfg)
|
||||
return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access, "user": userPayload}, "error": nil})
|
||||
}
|
||||
}
|
||||
|
||||
func RefreshHandler(rdb *redispkg.Redis, cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
refreshToken := c.Cookies(refreshCookieName)
|
||||
if refreshToken == "" {
|
||||
return fiber.NewError(401, "refresh token em falta")
|
||||
}
|
||||
claims, err := ValidateToken(refreshToken, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return fiber.NewError(401, "refresh token inválido ou expirado")
|
||||
}
|
||||
|
||||
if rdb != nil {
|
||||
stored, err := rdb.Client.Get(c.Context(), "refresh:"+claims.UserID).Result()
|
||||
if err == goredis.Nil || stored != refreshToken {
|
||||
return fiber.NewError(401, "sessão inválida")
|
||||
}
|
||||
}
|
||||
|
||||
access, err := GenerateAccessToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao renovar token")
|
||||
}
|
||||
newRefresh, err := GenerateRefreshToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao renovar token")
|
||||
}
|
||||
|
||||
if rdb != nil {
|
||||
rdb.Client.Set(c.Context(), "refresh:"+claims.UserID, newRefresh, refreshCookieTTL)
|
||||
}
|
||||
SetRefreshCookie(c, newRefresh, cfg)
|
||||
return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access}, "error": nil})
|
||||
}
|
||||
}
|
||||
|
||||
func LogoutHandler(rdb *redispkg.Redis) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: refreshCookieName,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
HTTPOnly: true,
|
||||
Path: "/api/v1/auth",
|
||||
})
|
||||
return c.JSON(fiber.Map{"data": nil, "error": nil})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/techxcar/backend/internal/auth"
|
||||
"github.com/techxcar/backend/internal/config"
|
||||
)
|
||||
|
||||
// stubRepo is a minimal in-memory stub for testing auth handlers without a real DB.
|
||||
type stubRepo struct {
|
||||
superAdmins map[string]*auth.LoginSuperAdmin
|
||||
tenants map[string]*auth.LoginTenant
|
||||
users map[string]*auth.LoginUser
|
||||
}
|
||||
|
||||
func (s *stubRepo) GetSuperAdminByEmail(_ context.Context, email string) (*auth.LoginSuperAdmin, error) {
|
||||
a, _ := s.superAdmins[email]
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *stubRepo) GetTenantBySlug(_ context.Context, slug string) (*auth.LoginTenant, error) {
|
||||
t, _ := s.tenants[slug]
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *stubRepo) GetTenantUserByEmail(_ context.Context, tenantID, email string) (*auth.LoginUser, error) {
|
||||
key := tenantID + ":" + email
|
||||
u, _ := s.users[key]
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func newTestApp(repo auth.LoginRepository, cfg *config.Config) *fiber.App {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()})
|
||||
}})
|
||||
app.Post("/api/v1/auth/login", auth.LoginHandler(repo, nil, cfg))
|
||||
app.Post("/api/v1/auth/refresh", auth.RefreshHandler(nil, cfg))
|
||||
app.Post("/api/v1/auth/logout", auth.LogoutHandler(nil))
|
||||
return app
|
||||
}
|
||||
|
||||
func TestLogin_superAdmin_success(t *testing.T) {
|
||||
hash, _ := auth.HashPassword("secret123")
|
||||
repo := &stubRepo{
|
||||
superAdmins: map[string]*auth.LoginSuperAdmin{
|
||||
"admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash},
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{JWTSecret: testSecret}
|
||||
app := newTestApp(repo, cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "secret123"})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
data := result["data"].(map[string]any)
|
||||
assert.NotEmpty(t, data["access_token"])
|
||||
assert.NotNil(t, data["user"])
|
||||
}
|
||||
|
||||
func TestLogin_wrongPassword(t *testing.T) {
|
||||
hash, _ := auth.HashPassword("secret123")
|
||||
repo := &stubRepo{
|
||||
superAdmins: map[string]*auth.LoginSuperAdmin{
|
||||
"admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash},
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{JWTSecret: testSecret}
|
||||
app := newTestApp(repo, cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "wrongpass"})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 401, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestLogin_tenantUser_success(t *testing.T) {
|
||||
hash, _ := auth.HashPassword("tenantpass")
|
||||
repo := &stubRepo{
|
||||
tenants: map[string]*auth.LoginTenant{
|
||||
"my-workshop": {ID: "11111111-1111-1111-1111-111111111111", Status: "active"},
|
||||
},
|
||||
users: map[string]*auth.LoginUser{
|
||||
"11111111-1111-1111-1111-111111111111:user@workshop.com": {
|
||||
ID: "u-1", Email: "user@workshop.com", PasswordHash: hash,
|
||||
Role: "tenant_admin", Name: "User", Active: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{JWTSecret: testSecret}
|
||||
app := newTestApp(repo, cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"email": "user@workshop.com", "password": "tenantpass", "tenant_slug": "my-workshop",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
data := result["data"].(map[string]any)
|
||||
token := data["access_token"].(string)
|
||||
|
||||
claims, err := auth.ValidateToken(token, testSecret)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "11111111-1111-1111-1111-111111111111", claims.TenantID)
|
||||
assert.Equal(t, "tenant_admin", claims.Role)
|
||||
_ = time.Now()
|
||||
}
|
||||
|
||||
func TestLogin_unknownTenant(t *testing.T) {
|
||||
repo := &stubRepo{tenants: map[string]*auth.LoginTenant{}}
|
||||
cfg := &config.Config{JWTSecret: testSecret}
|
||||
app := newTestApp(repo, cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"email": "user@workshop.com", "password": "pass", "tenant_slug": "nonexistent",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 401, resp.StatusCode)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateAccessToken(userID, tenantID, role, secret string) (string, error) {
|
||||
return generateToken(userID, tenantID, role, secret, 15*time.Minute)
|
||||
}
|
||||
|
||||
func GenerateRefreshToken(userID, tenantID, role, secret string) (string, error) {
|
||||
return generateToken(userID, tenantID, role, secret, 30*24*time.Hour)
|
||||
}
|
||||
|
||||
func generateToken(userID, tenantID, role, secret string, ttl time.Duration) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ValidateToken(tokenStr, secret string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("método de assinatura inesperado: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("token inválido")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/techxcar/backend/internal/auth"
|
||||
)
|
||||
|
||||
const testSecret = "test-secret-32-chars-minimum-ok!"
|
||||
|
||||
func TestGenerateAndValidateAccessToken(t *testing.T) {
|
||||
token, err := auth.GenerateAccessToken("user-1", "tenant-1", "tenant_admin", testSecret)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
claims, err := auth.ValidateToken(token, testSecret)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-1", claims.UserID)
|
||||
assert.Equal(t, "tenant-1", claims.TenantID)
|
||||
assert.Equal(t, "tenant_admin", claims.Role)
|
||||
assert.True(t, claims.ExpiresAt.After(time.Now()))
|
||||
assert.True(t, claims.ExpiresAt.Before(time.Now().Add(16*time.Minute)))
|
||||
}
|
||||
|
||||
func TestGenerateRefreshToken_longerExpiry(t *testing.T) {
|
||||
token, err := auth.GenerateRefreshToken("user-1", "", "super_admin", testSecret)
|
||||
require.NoError(t, err)
|
||||
|
||||
claims, err := auth.ValidateToken(token, testSecret)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, claims.TenantID)
|
||||
assert.Equal(t, "super_admin", claims.Role)
|
||||
assert.True(t, claims.ExpiresAt.After(time.Now().Add(29*24*time.Hour)))
|
||||
}
|
||||
|
||||
func TestValidateToken_wrongSecret(t *testing.T) {
|
||||
token, err := auth.GenerateAccessToken("user-1", "t-1", "manager", testSecret)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = auth.ValidateToken(token, "different-secret-32chars-minimumx")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateToken_malformed(t *testing.T) {
|
||||
_, err := auth.ValidateToken("not.a.jwt", testSecret)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/techxcar/backend/pkg/database"
|
||||
)
|
||||
|
||||
func RequireAuth(secret string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return fiber.NewError(401, "autenticação necessária")
|
||||
}
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return fiber.NewError(401, "formato de autorização inválido")
|
||||
}
|
||||
claims, err := ValidateToken(parts[1], secret)
|
||||
if err != nil {
|
||||
return fiber.NewError(401, "token inválido ou expirado")
|
||||
}
|
||||
c.Locals("claims", claims)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireRole(roles ...string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
claims, ok := c.Locals("claims").(*Claims)
|
||||
if !ok {
|
||||
return fiber.NewError(401, "autenticação necessária")
|
||||
}
|
||||
for _, role := range roles {
|
||||
if claims.Role == role {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
return fiber.NewError(403, "acesso não autorizado")
|
||||
}
|
||||
}
|
||||
|
||||
// TenantMiddleware acquires a dedicated pgxpool connection per request,
|
||||
// sets the tenant search_path, stores the connection in c.Locals("conn"),
|
||||
// and releases the connection after the handler chain completes.
|
||||
func TenantMiddleware(db *database.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
claims, ok := c.Locals("claims").(*Claims)
|
||||
if !ok || claims.TenantID == "" {
|
||||
return c.Next()
|
||||
}
|
||||
conn, err := db.Pool.Acquire(c.Context())
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro interno ao adquirir conexão")
|
||||
}
|
||||
schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"`
|
||||
if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil {
|
||||
conn.Release()
|
||||
return fiber.NewError(500, "erro interno ao definir schema")
|
||||
}
|
||||
c.Locals("conn", conn)
|
||||
err = c.Next()
|
||||
conn.Release()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// GetConn returns the tenant-scoped connection stored by TenantMiddleware.
|
||||
func GetConn(c *fiber.Ctx) *pgxpool.Conn {
|
||||
conn, _ := c.Locals("conn").(*pgxpool.Conn)
|
||||
return conn
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/techxcar/backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestRequireAuth_missingHeader(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 401, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireAuth_validToken(t *testing.T) {
|
||||
token, _ := auth.GenerateAccessToken("user-1", "tenant-1", "manager", testSecret)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
|
||||
claims := c.Locals("claims").(*auth.Claims)
|
||||
return c.SendString(claims.UserID)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireAuth_invalidToken(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid.token.here")
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 401, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireRole_allowed(t *testing.T) {
|
||||
token, _ := auth.GenerateAccessToken("user-1", "", "super_admin", testSecret)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/admin",
|
||||
auth.RequireAuth(testSecret),
|
||||
auth.RequireRole("super_admin"),
|
||||
func(c *fiber.Ctx) error { return c.SendString("ok") },
|
||||
)
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireRole_forbidden(t *testing.T) {
|
||||
token, _ := auth.GenerateAccessToken("user-1", "t-1", "technician", testSecret)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/admin",
|
||||
auth.RequireAuth(testSecret),
|
||||
auth.RequireRole("super_admin", "tenant_admin"),
|
||||
func(c *fiber.Ctx) error { return c.SendString("ok") },
|
||||
)
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 403, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireRole_allowsMatchingRole(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("claims", &auth.Claims{Role: "tenant_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/test", auth.RequireRole("tenant_admin"), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireRole_rejectsMismatch(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("claims", &auth.Claims{Role: "technician"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/test", auth.RequireRole("super_admin"), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 403, resp.StatusCode)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type redisStorage struct {
|
||||
client *goredis.Client
|
||||
}
|
||||
|
||||
func NewRedisStorage(client *goredis.Client) *redisStorage {
|
||||
return &redisStorage{client: client}
|
||||
}
|
||||
|
||||
func (s *redisStorage) Get(key string) ([]byte, error) {
|
||||
val, err := s.client.Get(context.Background(), key).Bytes()
|
||||
if err == goredis.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
return val, err
|
||||
}
|
||||
|
||||
func (s *redisStorage) Set(key string, val []byte, exp time.Duration) error {
|
||||
return s.client.Set(context.Background(), key, val, exp).Err()
|
||||
}
|
||||
|
||||
func (s *redisStorage) Delete(key string) error {
|
||||
return s.client.Del(context.Background(), key).Err()
|
||||
}
|
||||
|
||||
func (s *redisStorage) Reset() error {
|
||||
return s.client.FlushDB(context.Background()).Err()
|
||||
}
|
||||
|
||||
func (s *redisStorage) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func RateLimiter(storage *redisStorage) fiber.Handler {
|
||||
return limiter.New(limiter.Config{
|
||||
Max: 10,
|
||||
Expiration: 1 * time.Minute,
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
return "ratelimit:auth:" + c.IP()
|
||||
},
|
||||
Storage: storage,
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return fiber.NewError(429, "muitas tentativas, tente novamente em 1 minuto")
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/techxcar/backend/internal/config"
|
||||
redispkg "github.com/techxcar/backend/pkg/redis"
|
||||
)
|
||||
|
||||
func RegisterRoutes(app *fiber.App, repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) {
|
||||
authGroup := app.Group("/api/v1/auth")
|
||||
|
||||
if rdb != nil {
|
||||
storage := NewRedisStorage(rdb.Client)
|
||||
rateLimiter := RateLimiter(storage)
|
||||
authGroup.Post("/login", rateLimiter, LoginHandler(repo, rdb, cfg))
|
||||
authGroup.Post("/refresh", rateLimiter, RefreshHandler(rdb, cfg))
|
||||
} else {
|
||||
authGroup.Post("/login", LoginHandler(repo, rdb, cfg))
|
||||
authGroup.Post("/refresh", RefreshHandler(rdb, cfg))
|
||||
}
|
||||
|
||||
authGroup.Post("/logout", LogoutHandler(rdb))
|
||||
}
|
||||
Reference in New Issue
Block a user