diff --git a/backend/.sqlc/queries/email_tokens.sql b/backend/.sqlc/queries/email_tokens.sql index c6610b5c..43ec4120 100644 --- a/backend/.sqlc/queries/email_tokens.sql +++ b/backend/.sqlc/queries/email_tokens.sql @@ -5,3 +5,13 @@ WHERE id = $1; -- name: RemoveVerifyEmailTokenByID :exec DELETE FROM verify_email_tokens WHERE id = $1; + +-- name: NewVerifyEmailToken :one +INSERT INTO verify_email_tokens (user_id, expires_at) +VALUES ($1, $2) RETURNING id; + +-- name: ExistsVerifyEmailTokenByUserID :one +SELECT EXISTS(SELECT 1 FROM verify_email_tokens WHERE user_id = $1); + +-- name: RemoveVerifyEmailTokenByUserID :exec +DELETE FROM verify_email_tokens WHERE user_id = $1; diff --git a/backend/.sqlc/queries/users.sql b/backend/.sqlc/queries/users.sql index 280e4dda..172040de 100644 --- a/backend/.sqlc/queries/users.sql +++ b/backend/.sqlc/queries/users.sql @@ -6,6 +6,15 @@ WHERE id = $1; -- name: GetUserEmailVerifiedStatusByEmail :one SELECT email_verified FROM users WHERE email = $1; +-- name: UserExistsByEmail :one +SELECT EXISTS(SELECT 1 FROM users WHERE email = $1); + +-- name: NewUser :one +INSERT INTO users +(email, password, role) +VALUES +($1, $2, $3) RETURNING id, email, email_verified, role, token_salt; + -- name: GetUserByEmail :one SELECT * FROM users WHERE email = $1 LIMIT 1; diff --git a/backend/db/email_tokens.sql.go b/backend/db/email_tokens.sql.go index 479f8793..5b8c61d3 100644 --- a/backend/db/email_tokens.sql.go +++ b/backend/db/email_tokens.sql.go @@ -9,6 +9,17 @@ import ( "context" ) +const existsVerifyEmailTokenByUserID = `-- name: ExistsVerifyEmailTokenByUserID :one +SELECT EXISTS(SELECT 1 FROM verify_email_tokens WHERE user_id = $1) +` + +func (q *Queries) ExistsVerifyEmailTokenByUserID(ctx context.Context, userID string) (bool, error) { + row := q.db.QueryRow(ctx, existsVerifyEmailTokenByUserID, userID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + const getVerifyEmailTokenByID = `-- name: GetVerifyEmailTokenByID :one SELECT id, user_id, created_at, expires_at FROM verify_email_tokens @@ -27,6 +38,23 @@ func (q *Queries) GetVerifyEmailTokenByID(ctx context.Context, id string) (Verif return i, err } +const newVerifyEmailToken = `-- name: NewVerifyEmailToken :one +INSERT INTO verify_email_tokens (user_id, expires_at) +VALUES ($1, $2) RETURNING id +` + +type NewVerifyEmailTokenParams struct { + UserID string + ExpiresAt int64 +} + +func (q *Queries) NewVerifyEmailToken(ctx context.Context, arg NewVerifyEmailTokenParams) (string, error) { + row := q.db.QueryRow(ctx, newVerifyEmailToken, arg.UserID, arg.ExpiresAt) + var id string + err := row.Scan(&id) + return id, err +} + const removeVerifyEmailTokenByID = `-- name: RemoveVerifyEmailTokenByID :exec DELETE FROM verify_email_tokens WHERE id = $1 ` @@ -35,3 +63,12 @@ func (q *Queries) RemoveVerifyEmailTokenByID(ctx context.Context, id string) err _, err := q.db.Exec(ctx, removeVerifyEmailTokenByID, id) return err } + +const removeVerifyEmailTokenByUserID = `-- name: RemoveVerifyEmailTokenByUserID :exec +DELETE FROM verify_email_tokens WHERE user_id = $1 +` + +func (q *Queries) RemoveVerifyEmailTokenByUserID(ctx context.Context, userID string) error { + _, err := q.db.Exec(ctx, removeVerifyEmailTokenByUserID, userID) + return err +} diff --git a/backend/db/users.sql.go b/backend/db/users.sql.go index b5b9bd4d..64623113 100644 --- a/backend/db/users.sql.go +++ b/backend/db/users.sql.go @@ -67,6 +67,40 @@ func (q *Queries) GetUserEmailVerifiedStatusByEmail(ctx context.Context, email s return email_verified, err } +const newUser = `-- name: NewUser :one +INSERT INTO users +(email, password, role) +VALUES +($1, $2, $3) RETURNING id, email, email_verified, role, token_salt +` + +type NewUserParams struct { + Email string + Password string + Role UserRole +} + +type NewUserRow struct { + ID string + Email string + EmailVerified bool + Role UserRole + TokenSalt []byte +} + +func (q *Queries) NewUser(ctx context.Context, arg NewUserParams) (NewUserRow, error) { + row := q.db.QueryRow(ctx, newUser, arg.Email, arg.Password, arg.Role) + var i NewUserRow + err := row.Scan( + &i.ID, + &i.Email, + &i.EmailVerified, + &i.Role, + &i.TokenSalt, + ) + return i, err +} + const updateUserEmailVerifiedStatus = `-- name: UpdateUserEmailVerifiedStatus :exec UPDATE users SET email_verified = $1 WHERE id = $2 ` @@ -80,3 +114,14 @@ func (q *Queries) UpdateUserEmailVerifiedStatus(ctx context.Context, arg UpdateU _, err := q.db.Exec(ctx, updateUserEmailVerifiedStatus, arg.EmailVerified, arg.ID) return err } + +const userExistsByEmail = `-- name: UserExistsByEmail :one +SELECT EXISTS(SELECT 1 FROM users WHERE email = $1) +` + +func (q *Queries) UserExistsByEmail(ctx context.Context, email string) (bool, error) { + row := q.db.QueryRow(ctx, userExistsByEmail, email) + var exists bool + err := row.Scan(&exists) + return exists, err +} diff --git a/backend/internal/server/middleware.go b/backend/internal/server/middleware.go index a8927c0c..0d21e3b0 100644 --- a/backend/internal/server/middleware.go +++ b/backend/internal/server/middleware.go @@ -12,4 +12,5 @@ func (s *Server) setupMiddlewares() { s.Echo.Use(middleware.RequestID()) s.Echo.Use(middleware.LoggerMiddleware()) + s.Echo.Use(middleware.CORS()) } diff --git a/backend/internal/tests/auth_test.go b/backend/internal/tests/auth_test.go index b12f2305..916e1055 100644 --- a/backend/internal/tests/auth_test.go +++ b/backend/internal/tests/auth_test.go @@ -65,7 +65,7 @@ func TestAuthEndpoints(t *testing.T) { t.Run("Login Endpoint", func(t *testing.T) { tests := []struct { name string - payload v1_auth.LoginRequest + payload v1_auth.AuthRequest expectedStatus int checkResponse bool expectedError *struct { @@ -75,7 +75,7 @@ func TestAuthEndpoints(t *testing.T) { }{ { name: "Valid Login", - payload: v1_auth.LoginRequest{ + payload: v1_auth.AuthRequest{ Email: "test@example.com", Password: "testpassword123", }, @@ -84,7 +84,7 @@ func TestAuthEndpoints(t *testing.T) { }, { name: "Invalid Password", - payload: v1_auth.LoginRequest{ + payload: v1_auth.AuthRequest{ Email: "test@example.com", Password: "wrongpassword", }, @@ -99,7 +99,7 @@ func TestAuthEndpoints(t *testing.T) { }, { name: "Invalid Email", - payload: v1_auth.LoginRequest{ + payload: v1_auth.AuthRequest{ Email: "nonexistent@example.com", Password: "testpassword123", }, @@ -114,7 +114,7 @@ func TestAuthEndpoints(t *testing.T) { }, { name: "Invalid Email Format", - payload: v1_auth.LoginRequest{ + payload: v1_auth.AuthRequest{ Email: "invalid-email", Password: "testpassword123", }, @@ -141,7 +141,7 @@ func TestAuthEndpoints(t *testing.T) { assert.Equal(t, tc.expectedStatus, rec.Code) if tc.checkResponse { - var response v1_auth.LoginResponse + var response v1_auth.AuthResponse err := json.Unmarshal(rec.Body.Bytes(), &response) assert.NoError(t, err) assert.NotEmpty(t, response.AccessToken) @@ -153,7 +153,7 @@ func TestAuthEndpoints(t *testing.T) { cookies := rec.Result().Cookies() var foundRefreshToken bool for _, cookie := range cookies { - if cookie.Name == "token" { + if cookie.Name == v1_auth.COOKIE_REFRESH_TOKEN { foundRefreshToken = true assert.True(t, cookie.HttpOnly) assert.True(t, cookie.Secure) diff --git a/backend/internal/tests/logger_test.go b/backend/internal/tests/logger_test.go index 41934383..2590304b 100644 --- a/backend/internal/tests/logger_test.go +++ b/backend/internal/tests/logger_test.go @@ -1,6 +1,7 @@ package tests import ( + customMiddleware "KonferCA/SPUR/internal/middleware" "bytes" "encoding/json" "github.com/labstack/echo/v4" @@ -8,7 +9,6 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/stretchr/testify/assert" - customMiddleware "KonferCA/SPUR/internal/middleware" "net/http" "net/http/httptest" "testing" @@ -23,12 +23,14 @@ TestLogger verifies that the logger middleware: */ func TestLogger(t *testing.T) { // capture log output for testing + originalLogger := log.Logger + defer func() { log.Logger = originalLogger }() var buf bytes.Buffer log.Logger = zerolog.New(&buf) // setup echo e := echo.New() - + // setup request ID middleware with a config that ensures ID generation e.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{ Generator: func() string { @@ -60,7 +62,7 @@ func TestLogger(t *testing.T) { // verify each log entry var logEntry map[string]interface{} - + // check info log err := json.Unmarshal(logs[0], &logEntry) assert.NoError(t, err) @@ -68,14 +70,14 @@ func TestLogger(t *testing.T) { assert.Equal(t, "test info message", logEntry["message"]) assert.Equal(t, "test-request-id", logEntry["request_id"]) assert.Equal(t, "/test", logEntry["path"]) - + // check warning log err = json.Unmarshal(logs[1], &logEntry) assert.NoError(t, err) assert.Equal(t, "warn", logEntry["level"]) assert.Equal(t, "test warning", logEntry["message"]) assert.Equal(t, "test-request-id", logEntry["request_id"]) - + // check error log err = json.Unmarshal(logs[2], &logEntry) assert.NoError(t, err) @@ -90,19 +92,22 @@ TestLoggerWithoutContext verifies that GetLogger returns a default logger when called without proper context */ func TestLoggerWithoutContext(t *testing.T) { + originalLogger := log.Logger + defer func() { log.Logger = originalLogger }() var buf bytes.Buffer log.Logger = zerolog.New(&buf) e := echo.New() c := e.NewContext(nil, nil) - + logger := customMiddleware.GetLogger(c) assert.NotNil(t, logger, "should return default logger") - + logger.Info("test message") - + var logEntry map[string]interface{} err := json.Unmarshal(buf.Bytes(), &logEntry) assert.NoError(t, err) assert.Equal(t, "test message", logEntry["message"]) -} \ No newline at end of file +} + diff --git a/backend/internal/tests/server_test.go b/backend/internal/tests/server_test.go index 1c6061c5..29aeb5fa 100644 --- a/backend/internal/tests/server_test.go +++ b/backend/internal/tests/server_test.go @@ -5,7 +5,9 @@ import ( "KonferCA/SPUR/internal/jwt" "KonferCA/SPUR/internal/server" "KonferCA/SPUR/internal/v1/v1_auth" - "KonferCA/SPUR/internal/v1/v1_common" + "KonferCA/SPUR/internal/v1/v1_common" + + "bytes" "context" "encoding/json" "fmt" @@ -16,6 +18,7 @@ import ( "time" "github.com/google/uuid" + "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" ) @@ -98,6 +101,130 @@ func TestServer(t *testing.T) { } }) + t.Run("/api/v1/auth/register - 201 CREATED - successful registration", func(t *testing.T) { + url := "/api/v1/auth/register" + + // create context with timeout of 1 minute. + // tests should not hang for more than 1 minute. + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + // create request body + email := "test@mail.com" + password := "mypassword" + reqBody := map[string]string{ + "email": email, + "password": password, + } + reqBodyBytes, err := json.Marshal(reqBody) + assert.NoError(t, err) + + reader := bytes.NewReader(reqBodyBytes) + req := httptest.NewRequest(http.MethodPost, url, reader) + req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + s.Echo.ServeHTTP(rec, req) + assert.Equal(t, http.StatusCreated, rec.Code) + + // read in the response body + var resBody v1_auth.AuthResponse + err = json.Unmarshal(rec.Body.Bytes(), &resBody) + assert.NoError(t, err) + + t.Log(resBody) + + // make sure that the response body has all the expected fields + // it should have the an access token + assert.NotEmpty(t, resBody.AccessToken) + assert.NotEmpty(t, resBody.User) + + // get the token salt of newly created user + row := s.DBPool.QueryRow(ctx, "SELECT token_salt FROM users WHERE email = $1;", email) + var salt []byte + err = row.Scan(&salt) + assert.NoError(t, err) + + // make sure it generated a valid access token by verifying it + claims, err := jwt.VerifyTokenWithSalt(resBody.AccessToken, salt) + assert.NoError(t, err) + assert.Equal(t, claims.TokenType, jwt.ACCESS_TOKEN_TYPE) + + // make sure that the headers include the Set-Cookie + cookies := rec.Result().Cookies() + var refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == v1_auth.COOKIE_REFRESH_TOKEN { + refreshCookie = cookie + break + } + } + + assert.NotNil(t, refreshCookie, "Refresh token cookie should be set.") + if refreshCookie != nil { + assert.True(t, refreshCookie.HttpOnly, "Cookie should be HTTP-only") + assert.Equal(t, "/api/v1/auth/verify", refreshCookie.Path, "Cookie path should be /api") + assert.NotEmpty(t, refreshCookie.Value, "Cookie should have refresh token string as value") + // make sure the cookie value holds a valid refresh token + claims, err := jwt.VerifyTokenWithSalt(refreshCookie.Value, salt) + assert.NoError(t, err) + assert.Equal(t, claims.TokenType, jwt.REFRESH_TOKEN_TYPE) + } + + err = removeTestUser(ctx, email, s) + assert.NoError(t, err) + }) + + t.Run("/api/v1/auth/register - 400 Bad Request - existing user", func(t *testing.T) { + url := "/api/v1/auth/register" + + // create context with timeout of 1 minute. + // tests should not hang for more than 1 minute. + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + // seed with test user + _, email, password, err := createTestUser(ctx, s) + assert.NoError(t, err) + defer removeTestUser(ctx, email, s) + + reqBody := map[string]string{ + "email": email, + "password": password, + } + reqBodyBytes, err := json.Marshal(reqBody) + assert.NoError(t, err) + + reader := bytes.NewReader(reqBodyBytes) + req := httptest.NewRequest(http.MethodPost, url, reader) + rec := httptest.NewRecorder() + + s.Echo.ServeHTTP(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("/api/v1/auth/register - 400 Bad Request - invalid body", func(t *testing.T) { + url := "/api/v1/auth/register" + + // create request body + email := "test" + password := "short" + reqBody := map[string]string{ + "email": email, + "password": password, + } + reqBodyBytes, err := json.Marshal(reqBody) + assert.NoError(t, err) + + reader := bytes.NewReader(reqBodyBytes) + req := httptest.NewRequest(http.MethodPost, url, reader) + req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + s.Echo.ServeHTTP(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + t.Run("/auth/verify-email - 200 OK - valid email token", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() diff --git a/backend/internal/tests/setup.go b/backend/internal/tests/setup.go index d593b018..3e60e306 100644 --- a/backend/internal/tests/setup.go +++ b/backend/internal/tests/setup.go @@ -20,5 +20,6 @@ func setupEnv() { os.Setenv("DB_PASSWORD", "postgres") os.Setenv("DB_NAME", "postgres") os.Setenv("DB_SSLMODE", "disable") + os.Setenv("JWT_SECRET", "jwt") os.Setenv("JWT_SECRET_VERIFY_EMAIL", "verify_email") } diff --git a/backend/internal/v1/v1_auth/auth.go b/backend/internal/v1/v1_auth/auth.go index 9b1c18ea..f7a02389 100644 --- a/backend/internal/v1/v1_auth/auth.go +++ b/backend/internal/v1/v1_auth/auth.go @@ -4,22 +4,78 @@ import ( "KonferCA/SPUR/db" "KonferCA/SPUR/internal/jwt" "KonferCA/SPUR/internal/middleware" + "KonferCA/SPUR/internal/service" "KonferCA/SPUR/internal/v1/v1_common" "context" "errors" + "fmt" "net/http" "time" "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" ) +const ( + // Name of the cookie that holds the refresh token. + COOKIE_REFRESH_TOKEN string = "refresh_token" +) + // verify password hash using bcrypt func verifyPassword(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } +/* +Helper function that sends a new verification email. It can be use to send new verification emails +or for resending the verification email to the same recipient again. + +Keep in mind that the recipient must be the same user. +*/ +func sendEmailVerification(userID, email string, queries *db.Queries) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + exists, err := queries.ExistsVerifyEmailTokenByUserID(ctx, userID) + if err != nil { + log.Error().Err(err).Str("user_id", userID).Str("email", email).Msg("Failed to send verification email.") + return + } + + if exists { + // remove existing one + err := queries.RemoveVerifyEmailTokenByUserID(ctx, userID) + if err != nil { + log.Error().Err(err).Str("user_id", userID).Str("email", email).Msg("Failed to send verification email.") + return + } + } + + exp := time.Now().Add(time.Minute * 30).UTC() + tokenID, err := queries.NewVerifyEmailToken(ctx, db.NewVerifyEmailTokenParams{ + UserID: userID, + ExpiresAt: exp.Unix(), + }) + if err != nil { + log.Error().Err(err).Str("user_id", userID).Str("email", email).Msg("Failed to send verification email.") + return + } + + emailToken, err := jwt.GenerateVerifyEmailToken(email, tokenID, exp) + if err != nil { + log.Error().Err(err).Str("user_id", userID).Str("email", email).Msg("Failed to send verification email.") + return + } + + err = service.SendVerficationEmail(ctx, email, emailToken) + if err != nil { + log.Error().Err(err).Str("user_id", userID).Str("email", email).Msg("Failed to send verification email.") + return + } +} + /* Simple route handler that just returns whether the email has been verified or not in JSON body. */ @@ -32,6 +88,78 @@ func (h *Handler) handleEmailVerificationStatus(c echo.Context) error { return c.JSON(http.StatusOK, EmailVerifiedStatusResponse{Verified: user.EmailVerified}) } +/* +Route handles incoming requests to register/create a new account. +- Allow if the email is valid +- Allow if no other user has the same email already +- Responds with the access token and basic user information upon success +- HTTP-only cookie is also set with the refresh token value +*/ +func (h *Handler) handleRegister(c echo.Context) error { + logger := middleware.GetLogger(c) + + var reqBody AuthRequest + if err := c.Bind(&reqBody); err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "Invalid request body", err) + } + if err := c.Validate(&reqBody); err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "Invalid request body", err) + } + + ctx, cancel := context.WithTimeout(c.Request().Context(), time.Minute) + defer cancel() + + q := h.server.GetQueries() + + exists, err := q.UserExistsByEmail(ctx, reqBody.Email) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "", err) + } + + if exists { + return v1_common.Fail(c, http.StatusBadRequest, "Email has already been occupied.", nil) + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(reqBody.Password), bcrypt.DefaultCost) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "", err) + } + + newUser, err := q.NewUser(ctx, db.NewUserParams{ + Email: reqBody.Email, + Password: string(passwordHash), + Role: db.UserRoleStartupOwner, + }) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "", err) + } + + // send verification email using goroutine to not block + // at this point, the user has successfully been created + // so sending the verification email now is safe. + go sendEmailVerification(newUser.ID, newUser.Email, h.server.GetQueries()) + + // generate new access and refresh tokens + accessToken, refreshToken, err := jwt.GenerateWithSalt(newUser.ID, newUser.Role, newUser.TokenSalt) + if err != nil { + return v1_common.Fail(c, http.StatusCreated, "Registration complete but failed to sign in. Please sign in manually.", err) + } + + // set the refresh token cookie + setRefreshTokenCookie(c, refreshToken) + + logger.Info(fmt.Sprintf("New user created with email: %s", newUser.Email)) + + return c.JSON(http.StatusCreated, AuthResponse{ + AccessToken: accessToken, + User: UserResponse{ + Email: newUser.Email, + EmailVerified: newUser.EmailVerified, + Role: newUser.Role, + }, + }) +} + /* * Handles user login flow: * 1. Validates email/password @@ -40,13 +168,13 @@ func (h *Handler) handleEmailVerificationStatus(c echo.Context) error { * 4. Returns access token and user info */ func (h *Handler) handleLogin(c echo.Context) error { - var req LoginRequest + var req AuthRequest 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 { + if err := c.Validate(&req); err != nil { return v1_common.Fail(c, http.StatusBadRequest, "Validation failed", err) } @@ -65,22 +193,14 @@ func (h *Handler) handleLogin(c echo.Context) error { 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) + setRefreshTokenCookie(c, refreshToken) - return c.JSON(http.StatusOK, LoginResponse{ + return c.JSON(http.StatusOK, AuthResponse{ AccessToken: accessToken, User: UserResponse{ Email: user.Email, EmailVerified: user.EmailVerified, - Role: user.Role, + Role: user.Role, }, }) } diff --git a/backend/internal/v1/v1_auth/cookies.go b/backend/internal/v1/v1_auth/cookies.go new file mode 100644 index 00000000..92ecaa6d --- /dev/null +++ b/backend/internal/v1/v1_auth/cookies.go @@ -0,0 +1,40 @@ +package v1_auth + +import ( + "KonferCA/SPUR/common" + "net/http" + "os" + + "github.com/labstack/echo/v4" +) + +/* +Helper function that abstract away the different cookie configuration needed +based on the app environment for the refresh token cookie. +*/ +func getRefreshTokenCookieConfig() *http.Cookie { + cookie := &http.Cookie{ + Name: COOKIE_REFRESH_TOKEN, + // this is a static path, that it should only be allowed in + Path: "/api/v1/auth/verify", + Domain: os.Getenv("URL_DOMAIN"), + Secure: os.Getenv("APP_ENV") != common.DEVELOPMENT_ENV, + SameSite: http.SameSiteStrictMode, + HttpOnly: true, + } + + if os.Getenv("APP_ENV") == common.DEVELOPMENT_ENV { + cookie.SameSite = http.SameSiteLaxMode + } + + return cookie +} + +/* +Sets the refresh token cookie in the given context with the value. +*/ +func setRefreshTokenCookie(c echo.Context, value string) { + cookie := getRefreshTokenCookieConfig() + cookie.Value = value + c.SetCookie(cookie) +} diff --git a/backend/internal/v1/v1_auth/routes.go b/backend/internal/v1/v1_auth/routes.go index 3c77b99a..ebb0d4c7 100644 --- a/backend/internal/v1/v1_auth/routes.go +++ b/backend/internal/v1/v1_auth/routes.go @@ -1,9 +1,12 @@ package v1_auth import ( + "KonferCA/SPUR/common" "KonferCA/SPUR/db" "KonferCA/SPUR/internal/interfaces" "KonferCA/SPUR/internal/middleware" + "os" + "time" "github.com/labstack/echo/v4" ) @@ -14,11 +17,26 @@ Sets up the V1 auth routes. func SetupAuthRoutes(e *echo.Group, s interfaces.CoreServer) { h := Handler{server: s} + + // 5 request per minute, get block for 15 minutes, and ban up to 1 hour after four blocks. + maxRequests := 5 + if os.Getenv("APP_ENV") == common.TEST_ENV { + maxRequests = 5000 + } + + authLimiter := middleware.NewRateLimiter(&middleware.RateLimiterConfig{ + Requests: maxRequests, + Window: time.Minute, + BlockPeriod: time.Minute * 15, + MaxBlocks: 4, + }) + e.POST("/auth/login", h.handleLogin) e.GET( "/auth/ami-verified", h.handleEmailVerificationStatus, middleware.Auth(s.GetDB(), db.UserRoleStartupOwner, db.UserRoleAdmin), ) - e.GET("/auth/verify-email", h.handleVerifyEmail) + e.GET("/auth/verify-email", h.handleVerifyEmail, authLimiter.RateLimit()) + e.POST("/auth/register", h.handleRegister, authLimiter.RateLimit()) } diff --git a/backend/internal/v1/v1_auth/types.go b/backend/internal/v1/v1_auth/types.go index 5e9f080e..ad205a31 100644 --- a/backend/internal/v1/v1_auth/types.go +++ b/backend/internal/v1/v1_auth/types.go @@ -23,19 +23,18 @@ type EmailVerifiedStatusResponse struct { * 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 AuthRequest 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 { +type AuthResponse 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"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` Role db.UserRole `json:"role"` } diff --git a/bruno/Register.bru b/bruno/Register.bru index cef9bbc6..e0d3ef30 100644 --- a/bruno/Register.bru +++ b/bruno/Register.bru @@ -5,14 +5,15 @@ meta { } post { - url: {{URL}}/auth/signup + url: http://localhost:3000/user body: json auth: none } body:json { { - "email": "", + "email": "my@mail.com", + "username": "name", "password": "mysecurepassword", "role": "startup_owner" } diff --git a/bruno/Verify Cookie.bru b/bruno/Verify Cookie.bru new file mode 100644 index 00000000..3196599a --- /dev/null +++ b/bruno/Verify Cookie.bru @@ -0,0 +1,19 @@ +meta { + name: Verify Cookie + type: http + seq: 3 +} + +get { + url: {{URL}}/auth/verify + body: none + auth: none +} + +script:pre-request { + const cookie = bru.getVar("cookie"); + + if(cookie) { + req.setHeader("Cookie", cookie) + } +}