From 0e7a06c271c1d56e7380d278edd20ae3efadaf7b Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 19 Dec 2024 22:10:37 -0500 Subject: [PATCH 1/7] Add login auth endpoint --- backend/.sqlc/queries/users.sql | 3 + backend/db/users.sql.go | 20 +++ backend/internal/tests/auth_test.go | 232 ++++++++++++++++++++++++++ backend/internal/v1/v1_auth/auth.go | 61 +++++++ backend/internal/v1/v1_auth/routes.go | 3 +- backend/internal/v1/v1_auth/types.go | 26 ++- 6 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 backend/internal/tests/auth_test.go diff --git a/backend/.sqlc/queries/users.sql b/backend/.sqlc/queries/users.sql index 548f39ec..369028a3 100644 --- a/backend/.sqlc/queries/users.sql +++ b/backend/.sqlc/queries/users.sql @@ -5,3 +5,6 @@ WHERE id = $1; -- name: GetUserEmailVerifiedStatusByEmail :one SELECT email_verified FROM users WHERE email = $1; + +-- name: GetUserByEmail :one +SELECT * FROM users WHERE email = $1 LIMIT 1; \ No newline at end of file diff --git a/backend/db/users.sql.go b/backend/db/users.sql.go index 29578600..dd5221f8 100644 --- a/backend/db/users.sql.go +++ b/backend/db/users.sql.go @@ -9,6 +9,26 @@ import ( "context" ) +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password, role, email_verified, created_at, updated_at, token_salt FROM users WHERE email = $1 LIMIT 1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Password, + &i.Role, + &i.EmailVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.TokenSalt, + ) + return i, err +} + const getUserByID = `-- name: GetUserByID :one SELECT id, email, role, email_verified, token_salt FROM users diff --git a/backend/internal/tests/auth_test.go b/backend/internal/tests/auth_test.go new file mode 100644 index 00000000..71ca5e47 --- /dev/null +++ b/backend/internal/tests/auth_test.go @@ -0,0 +1,232 @@ +package tests + +import ( + "KonferCA/SPUR/db" + "KonferCA/SPUR/internal/jwt" + "KonferCA/SPUR/internal/server" + "KonferCA/SPUR/internal/v1/v1_auth" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" +) + +// Custom validator +type CustomValidator struct { + validator *validator.Validate +} + +func (cv *CustomValidator) Validate(i interface{}) error { + if err := cv.validator.Struct(i); err != nil { + return err + } + return nil +} + +func TestAuthEndpoints(t *testing.T) { + // Setup test environment + setupEnv() + + // Additional required env vars + os.Setenv("JWT_SECRET", "test-secret-key") + + // Initialize server + s, err := server.New() + if err != nil { + t.Fatalf("Failed to create server: %v", err) + } + + // Set up validator + s.GetEcho().Validator = &CustomValidator{validator: validator.New()} + + // Create test user + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("testpassword123"), bcrypt.DefaultCost) + userID := uuid.New() + testUser := db.User{ + ID: userID.String(), + Email: "test@example.com", + Password: string(hashedPassword), + Role: db.UserRoleStartupOwner, + EmailVerified: true, + CreatedAt: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + TokenSalt: []byte("test-salt"), + } + + // Insert test user + _, err = s.GetDB().Exec(context.Background(), ` + INSERT INTO users (id, email, password, role, email_verified, created_at, updated_at, token_salt) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, testUser.ID, testUser.Email, testUser.Password, testUser.Role, testUser.EmailVerified, + testUser.CreatedAt, testUser.UpdatedAt, testUser.TokenSalt) + if err != nil { + t.Fatalf("Failed to create test user: %v", err) + } + + t.Run("Login Endpoint", func(t *testing.T) { + tests := []struct { + name string + payload v1_auth.LoginRequest + expectedStatus int + checkResponse bool + }{ + { + name: "Valid Login", + payload: v1_auth.LoginRequest{ + Email: "test@example.com", + Password: "testpassword123", + }, + expectedStatus: http.StatusOK, + checkResponse: true, + }, + { + name: "Invalid Password", + payload: v1_auth.LoginRequest{ + Email: "test@example.com", + Password: "wrongpassword", + }, + expectedStatus: http.StatusUnauthorized, + checkResponse: false, + }, + { + name: "Invalid Email", + payload: v1_auth.LoginRequest{ + Email: "nonexistent@example.com", + Password: "testpassword123", + }, + expectedStatus: http.StatusUnauthorized, + checkResponse: false, + }, + { + name: "Invalid Email Format", + payload: v1_auth.LoginRequest{ + Email: "invalid-email", + Password: "testpassword123", + }, + expectedStatus: http.StatusBadRequest, + checkResponse: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create request + jsonBody, _ := json.Marshal(tc.payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Send request through the server + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, tc.expectedStatus, rec.Code) + + if tc.checkResponse { + var response v1_auth.LoginResponse + err := json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + assert.NotEmpty(t, response.AccessToken) + assert.Equal(t, tc.payload.Email, response.User.Email) + assert.True(t, response.User.EmailVerified) + assert.Equal(t, db.UserRoleStartupOwner, response.User.Role) + + // Verify cookie + cookies := rec.Result().Cookies() + var foundRefreshToken bool + for _, cookie := range cookies { + if cookie.Name == "token" { + foundRefreshToken = true + assert.True(t, cookie.HttpOnly) + assert.True(t, cookie.Secure) + assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite) + } + } + assert.True(t, foundRefreshToken) + } + }) + } + }) + + t.Run("Email Verification Status Endpoint", func(t *testing.T) { + // Get user with token salt from DB + user, err := db.New(s.GetDB()).GetUserByID(context.Background(), testUser.ID) + assert.NoError(t, err) + + // Generate valid token for test user + accessToken, _, err := jwt.GenerateWithSalt(user.ID, user.Role, user.TokenSalt) + assert.NoError(t, err) + + tests := []struct { + name string + setupAuth func(req *http.Request) + expectedStatus int + expectedBody map[string]interface{} + }{ + { + name: "Valid Token", + setupAuth: func(req *http.Request) { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + }, + expectedStatus: http.StatusOK, + expectedBody: map[string]interface{}{ + "verified": true, + }, + }, + { + name: "No Token", + setupAuth: func(req *http.Request) { + // No auth header + }, + expectedStatus: http.StatusUnauthorized, + expectedBody: nil, + }, + { + name: "Invalid Token", + setupAuth: func(req *http.Request) { + req.Header.Set("Authorization", "Bearer invalid-token") + }, + expectedStatus: http.StatusUnauthorized, + expectedBody: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/ami-verified", nil) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + // Send request through the server + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, tc.expectedStatus, rec.Code) + + if tc.expectedBody != nil { + resBodyBytes, err := io.ReadAll(rec.Body) + assert.NoError(t, err) + var response map[string]interface{} + err = json.Unmarshal(resBodyBytes, &response) + assert.NoError(t, err) + assert.Equal(t, tc.expectedBody, response) + } + }) + } + }) + + // Cleanup + _, err = s.GetDB().Exec(context.Background(), "DELETE FROM users WHERE id = $1", testUser.ID) + if err != nil { + t.Fatalf("Failed to cleanup test user: %v", err) + } +} diff --git a/backend/internal/v1/v1_auth/auth.go b/backend/internal/v1/v1_auth/auth.go index aef97f0c..5edc8579 100644 --- a/backend/internal/v1/v1_auth/auth.go +++ b/backend/internal/v1/v1_auth/auth.go @@ -2,13 +2,22 @@ package v1_auth import ( "KonferCA/SPUR/db" + "KonferCA/SPUR/internal/jwt" "KonferCA/SPUR/internal/v1/v1_common" "errors" "net/http" + "time" "github.com/labstack/echo/v4" + "golang.org/x/crypto/bcrypt" ) +// verify password hash using bcrypt +func verifyPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + /* Simple route handler that just returns whether the email has been verified or not in JSON body. */ @@ -20,3 +29,55 @@ func (h *Handler) handleEmailVerificationStatus(c echo.Context) error { return c.JSON(http.StatusOK, EmailVerifiedStatusResponse{Verified: user.EmailVerified}) } + +/* + * Handles user login flow: + * 1. Validates email/password + * 2. Generates access/refresh tokens + * 3. Sets HTTP-only cookie with refresh token + * 4. Returns access token and user info + */ +func (h *Handler) handleLogin(c echo.Context) error { + var req LoginRequest + if err := c.Bind(&req); err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "Invalid request format", err) + } + + // validate request + if err := c.Validate(req); err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "Validation failed", err) + } + + queries := db.New(h.server.GetDB()) + user, err := queries.GetUserByEmail(c.Request().Context(), req.Email) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Invalid email or password", nil) + } + + if !verifyPassword(req.Password, user.Password) { + return v1_common.Fail(c, http.StatusUnauthorized, "Invalid email or password", nil) + } + + accessToken, refreshToken, err := jwt.GenerateWithSalt(user.ID, user.Role, user.TokenSalt) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to generate tokens", err) + } + + cookie := new(http.Cookie) + cookie.Name = "token" + cookie.Value = refreshToken + cookie.Expires = time.Now().Add(24 * 7 * time.Hour) + cookie.HttpOnly = true + cookie.Secure = true + cookie.SameSite = http.SameSiteStrictMode + c.SetCookie(cookie) + + return c.JSON(http.StatusOK, LoginResponse{ + AccessToken: accessToken, + User: UserResponse{ + Email: user.Email, + EmailVerified: user.EmailVerified, + Role: user.Role, + }, + }) +} diff --git a/backend/internal/v1/v1_auth/routes.go b/backend/internal/v1/v1_auth/routes.go index 40bdde95..a2f3d494 100644 --- a/backend/internal/v1/v1_auth/routes.go +++ b/backend/internal/v1/v1_auth/routes.go @@ -13,9 +13,10 @@ Sets up the V1 auth routes. */ func SetupAuthRoutes(e *echo.Group, s interfaces.CoreServer) { h := Handler{server: s} + e.POST("/auth/login", h.handleLogin) e.GET( "/auth/ami-verified", h.handleEmailVerificationStatus, - middleware.Auth(s.GetDB(), db.UserRoleStartupOwner, db.UserRoleAdmin, db.UserRoleStartupOwner), + middleware.Auth(s.GetDB(), db.UserRoleStartupOwner, db.UserRoleAdmin), ) } diff --git a/backend/internal/v1/v1_auth/types.go b/backend/internal/v1/v1_auth/types.go index 7a2770ad..c13c995f 100644 --- a/backend/internal/v1/v1_auth/types.go +++ b/backend/internal/v1/v1_auth/types.go @@ -1,6 +1,9 @@ package v1_auth -import "KonferCA/SPUR/internal/interfaces" +import ( + "KonferCA/SPUR/db" + "KonferCA/SPUR/internal/interfaces" +) /* Main Handler struct for V1 auth routes. @@ -15,3 +18,24 @@ Response body for route /auth/ami-verified type EmailVerifiedStatusResponse struct { Verified bool `json:"verified"` } + +/* + * Request/response types for authentication endpoints. + * These define the API contract for auth-related operations. + */ + +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` // user's email + Password string `json:"password" validate:"required,min=8"` // user's password +} + +type LoginResponse struct { + AccessToken string `json:"access_token"` // jwt access token + User UserResponse `json:"user"` // user info +} + +type UserResponse struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Role db.UserRole `json:"role"` +} From f2f4841187c448cc7a462cc39c1f1cd4d8775982 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 21 Dec 2024 15:52:55 -0500 Subject: [PATCH 2/7] restrict refresh token cookie to verify endpoint path --- backend/internal/v1/v1_auth/auth.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/v1/v1_auth/auth.go b/backend/internal/v1/v1_auth/auth.go index be38538d..9b1c18ea 100644 --- a/backend/internal/v1/v1_auth/auth.go +++ b/backend/internal/v1/v1_auth/auth.go @@ -68,6 +68,7 @@ func (h *Handler) handleLogin(c echo.Context) error { cookie := new(http.Cookie) cookie.Name = "token" cookie.Value = refreshToken + cookie.Path = "/api/v1/auth/verify" cookie.Expires = time.Now().Add(24 * 7 * time.Hour) cookie.HttpOnly = true cookie.Secure = true From b99062ce2a3bd459c099f2790313ba42652e1a03 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 21 Dec 2024 15:57:31 -0500 Subject: [PATCH 3/7] double check cookie.Path in auth test --- backend/internal/tests/auth_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/tests/auth_test.go b/backend/internal/tests/auth_test.go index 71ca5e47..50d6753c 100644 --- a/backend/internal/tests/auth_test.go +++ b/backend/internal/tests/auth_test.go @@ -150,6 +150,7 @@ func TestAuthEndpoints(t *testing.T) { assert.True(t, cookie.HttpOnly) assert.True(t, cookie.Secure) assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite) + assert.Equal(t, "/api/v1/auth/verify", cookie.Path) } } assert.True(t, foundRefreshToken) From 6afb5775b7a595e221ced55a742adf796334f7d0 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 21 Dec 2024 18:11:31 -0500 Subject: [PATCH 4/7] Add request validator middleware --- backend/internal/v1/v1_auth/routes.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/internal/v1/v1_auth/routes.go b/backend/internal/v1/v1_auth/routes.go index 5c19cfde..96c99770 100644 --- a/backend/internal/v1/v1_auth/routes.go +++ b/backend/internal/v1/v1_auth/routes.go @@ -12,6 +12,10 @@ import ( Sets up the V1 auth routes. */ func SetupAuthRoutes(e *echo.Group, s interfaces.CoreServer) { + + validator := middleware.NewRequestValidator() + e.Validator = validator + h := Handler{server: s} e.POST("/auth/login", h.handleLogin) e.GET( From 45653dfe83c249fa6b71e8631e6cca2617b47aac Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 21 Dec 2024 18:14:53 -0500 Subject: [PATCH 5/7] remove old custom validator in auth_test --- backend/internal/tests/auth_test.go | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/backend/internal/tests/auth_test.go b/backend/internal/tests/auth_test.go index 50d6753c..e1c7c6fc 100644 --- a/backend/internal/tests/auth_test.go +++ b/backend/internal/tests/auth_test.go @@ -20,20 +20,9 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "golang.org/x/crypto/bcrypt" + "KonferCA/SPUR/internal/middleware" ) -// Custom validator -type CustomValidator struct { - validator *validator.Validate -} - -func (cv *CustomValidator) Validate(i interface{}) error { - if err := cv.validator.Struct(i); err != nil { - return err - } - return nil -} - func TestAuthEndpoints(t *testing.T) { // Setup test environment setupEnv() @@ -48,7 +37,7 @@ func TestAuthEndpoints(t *testing.T) { } // Set up validator - s.GetEcho().Validator = &CustomValidator{validator: validator.New()} + s.GetEcho().Validator = middleware.NewRequestValidator() // Create test user hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("testpassword123"), bcrypt.DefaultCost) From be5ca84be2f19a1f54384b918efe64a782a240e9 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 21 Dec 2024 18:21:41 -0500 Subject: [PATCH 6/7] Update routes.go --- backend/internal/v1/v1_auth/routes.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/internal/v1/v1_auth/routes.go b/backend/internal/v1/v1_auth/routes.go index 96c99770..3c77b99a 100644 --- a/backend/internal/v1/v1_auth/routes.go +++ b/backend/internal/v1/v1_auth/routes.go @@ -12,9 +12,6 @@ import ( Sets up the V1 auth routes. */ func SetupAuthRoutes(e *echo.Group, s interfaces.CoreServer) { - - validator := middleware.NewRequestValidator() - e.Validator = validator h := Handler{server: s} e.POST("/auth/login", h.handleLogin) From 8979f6d0f4ddd7ee9b98ef6ca24d9f748e50d8e5 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 21 Dec 2024 18:26:33 -0500 Subject: [PATCH 7/7] Remove validator import --- backend/internal/tests/auth_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/internal/tests/auth_test.go b/backend/internal/tests/auth_test.go index e1c7c6fc..79568698 100644 --- a/backend/internal/tests/auth_test.go +++ b/backend/internal/tests/auth_test.go @@ -3,6 +3,7 @@ package tests import ( "KonferCA/SPUR/db" "KonferCA/SPUR/internal/jwt" + "KonferCA/SPUR/internal/middleware" "KonferCA/SPUR/internal/server" "KonferCA/SPUR/internal/v1/v1_auth" "bytes" @@ -16,11 +17,9 @@ import ( "testing" "time" - "github.com/go-playground/validator/v10" "github.com/google/uuid" "github.com/stretchr/testify/assert" "golang.org/x/crypto/bcrypt" - "KonferCA/SPUR/internal/middleware" ) func TestAuthEndpoints(t *testing.T) {