diff --git a/bin/nestled b/bin/nestled index 478845d..7c352fa 100755 Binary files a/bin/nestled and b/bin/nestled differ diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..2dc7537 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,64 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/albertoadami/nestled/internal/errors" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Token struct { + Value string + ExpirationTime time.Time +} + +type TokenInfo struct { + UserId string + ExpirationTime time.Time +} + +func GenerateToken(userId uuid.UUID, secretKey string, expireHours int) (*Token, error) { + expirationTime := time.Now().Add(time.Hour * time.Duration(expireHours)).Local().UTC() + + claims := jwt.MapClaims{ + "userId": userId.String(), + "exp": expirationTime.Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secretKey)) + + if err != nil { + return nil, errors.ErrGeneratingToken + } + + return &Token{ + Value: signed, + ExpirationTime: expirationTime, + }, nil +} + +func ParseToken(tokenValue string, secretKey string) (*TokenInfo, error) { + token, err := jwt.Parse(tokenValue, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(secretKey), nil + }) + + if err != nil || !token.Valid { + return nil, errors.ErrInvalidToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.ErrInvalidToken + } + + return &TokenInfo{ + UserId: claims["userId"].(string), + ExpirationTime: time.Unix(int64(claims["exp"].(float64)), 0).UTC(), + }, nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..d9173a6 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,46 @@ +package auth + +import ( + "testing" + "time" + + "github.com/albertoadami/nestled/internal/errors" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestGeneratingToken(t *testing.T) { + userId := uuid.New() + secretKey := "test_secret_key" + expireHours := 6 + + token, err := GenerateToken(userId, secretKey, expireHours) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assert.NotNil(t, token.Value, "expected token value to be set, got nil") + +} + +func TestParsingInvalidToken(t *testing.T) { + token := "invalid.token.value" + + _, err := ParseToken(token, "test_secret_key") + + assert.ErrorIs(t, err, errors.ErrInvalidToken) + +} + +func TestParsingValidTokenAndExtractInfo(t *testing.T) { + userId := uuid.New() + secretKey := "test_secret_key" + + token, err := GenerateToken(userId, secretKey, 6) + assert.NoError(t, err) + + tokenInfo, err := ParseToken(token.Value, secretKey) + assert.NoError(t, err) + + assert.Equal(t, userId.String(), tokenInfo.UserId) + assert.Equal(t, token.ExpirationTime.Truncate(time.Second), tokenInfo.ExpirationTime.Truncate(time.Second)) +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go deleted file mode 100644 index 8508fb8..0000000 --- a/internal/auth/jwt.go +++ /dev/null @@ -1,35 +0,0 @@ -package auth - -import ( - "errors" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" -) - -type Token struct { - Value string - ExpirationTime time.Time -} - -func GenerateToken(userId uuid.UUID, secretKey string, expireHours int) (*Token, error) { - expirationTime := time.Now().Add(time.Hour * time.Duration(expireHours)).Local().UTC() - - claims := jwt.MapClaims{ - "userId": userId.String(), - "exp": expirationTime, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - signed, err := token.SignedString([]byte(secretKey)) - - if err != nil { - return nil, errors.New("failed to generate token") - } - - return &Token{ - Value: signed, - ExpirationTime: expirationTime, - }, nil -} diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go deleted file mode 100644 index 651a6a4..0000000 --- a/internal/auth/jwt_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package auth - -import ( - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" -) - -func TestGeneratingToken(t *testing.T) { - userId := uuid.New() - secretKey := "test_secret_key" - expireHours := 6 - - token, err := GenerateToken(userId, secretKey, expireHours) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - assert.NotNil(t, token.Value, "expected token value to be set, got nil") - -} diff --git a/internal/dto/errors.go b/internal/dto/errors.go index e8d534f..7aa26a4 100644 --- a/internal/dto/errors.go +++ b/internal/dto/errors.go @@ -15,4 +15,5 @@ func NewErrorResponse(message string, details string) *ErrorResponse { const ( ErrUsernameAlreadyExists = "username already exists" ErrEmailAlreadyExists = "email already exists" + ErrInvalidToken = "invalid token" ) diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 20a7eed..c211adb 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -7,4 +7,6 @@ var ( ErrEmailAlreadyExists = errors.New("email already exists") ErrPasswordTooWeak = errors.New("password is too weak") CredentialsInvalid = errors.New("invalid credentials") + ErrInvalidToken = errors.New("invalid token") + ErrGeneratingToken = errors.New("error generating token") ) diff --git a/internal/middleware/authentication.go b/internal/middleware/authentication.go new file mode 100644 index 0000000..2229f0a --- /dev/null +++ b/internal/middleware/authentication.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/albertoadami/nestled/internal/auth" + "github.com/albertoadami/nestled/internal/dto" + "github.com/gin-gonic/gin" +) + +func BearerAuthentication(secretKey string) gin.HandlerFunc { + + return func(c *gin.Context) { + + authHeader := c.GetHeader("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, &dto.ErrorResponse{Message: "missing or invalid Authorization header"}) + return + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + tokenInfo, err := auth.ParseToken(token, secretKey) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, &dto.ErrorResponse{Message: "invalid token"}) + return + } + + c.Set("userId", tokenInfo.UserId) + c.Next() + + } + +} diff --git a/internal/middleware/authentication_test.go b/internal/middleware/authentication_test.go new file mode 100644 index 0000000..9acb1bb --- /dev/null +++ b/internal/middleware/authentication_test.go @@ -0,0 +1,77 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/albertoadami/nestled/internal/auth" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func setupMiddlewareRouter(secretKey string) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(BearerAuthentication(secretKey)) + router.GET("/test", func(c *gin.Context) { + userId, _ := c.Get("userId") + c.JSON(http.StatusOK, gin.H{"userId": userId}) + }) + return router +} + +func TestBearerAuthentication_MissingHeader(t *testing.T) { + router := setupMiddlewareRouter("secret") + + req, _ := http.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestBearerAuthentication_InvalidToken(t *testing.T) { + router := setupMiddlewareRouter("secret") + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer invalid.token.here") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestBearerAuthentication_ValidToken(t *testing.T) { + secretKey := "secret" + userId := uuid.New() + + token, _ := auth.GenerateToken(userId, secretKey, 6) + + router := setupMiddlewareRouter(secretKey) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer "+token.Value) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestBearerAuthentication_ExpiredToken(t *testing.T) { + secretKey := "secret" + userId := uuid.New() + + // generate token already expired (-1 hour) + token, _ := auth.GenerateToken(userId, secretKey, -1) + + router := setupMiddlewareRouter(secretKey) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer "+token.Value) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +}