Skip to content

Commit

Permalink
Add login auth endpoint (#277)
Browse files Browse the repository at this point in the history
  • Loading branch information
AmirAgassi authored Dec 21, 2024
2 parents fdc73b4 + 8979f6d commit 1eb969b
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 2 deletions.
3 changes: 3 additions & 0 deletions backend/.sqlc/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ 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;

-- name: UpdateUserEmailVerifiedStatus :exec
UPDATE users SET email_verified = $1 WHERE id = $2;
20 changes: 20 additions & 0 deletions backend/db/users.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

221 changes: 221 additions & 0 deletions backend/internal/tests/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
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"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"golang.org/x/crypto/bcrypt"
)

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 = middleware.NewRequestValidator()

// Create test user
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("testpassword123"), bcrypt.DefaultCost)
userID := uuid.New()
testUser := db.User{
ID: userID.String(),
Email: "[email protected]",
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: "[email protected]",
Password: "testpassword123",
},
expectedStatus: http.StatusOK,
checkResponse: true,
},
{
name: "Invalid Password",
payload: v1_auth.LoginRequest{
Email: "[email protected]",
Password: "wrongpassword",
},
expectedStatus: http.StatusUnauthorized,
checkResponse: false,
},
{
name: "Invalid Email",
payload: v1_auth.LoginRequest{
Email: "[email protected]",
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.Equal(t, "/api/v1/auth/verify", cookie.Path)
}
}
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)
}
}
60 changes: 60 additions & 0 deletions backend/internal/v1/v1_auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ import (
"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.
*/
Expand All @@ -25,6 +32,59 @@ 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.Path = "/api/v1/auth/verify"
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,
},
})
}

/*
This route is responsible in handling incoming requests to verify a user's email.
The link must have an email token as query parameter. The email token is a normal JWT as 'token'.
Expand Down
4 changes: 3 additions & 1 deletion backend/internal/v1/v1_auth/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import (
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),
)
e.GET("/auth/verify-email", h.handleVerifyEmail)
}
26 changes: 25 additions & 1 deletion backend/internal/v1/v1_auth/types.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -15,3 +18,24 @@ Response body for route /auth/ami-verified and /auth/verify-email
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"`
}

0 comments on commit 1eb969b

Please sign in to comment.