Skip to content

Commit

Permalink
Verify cookie route (#284)
Browse files Browse the repository at this point in the history
  • Loading branch information
juancwu authored Dec 23, 2024
2 parents 971716b + 5fb916d commit 411372b
Show file tree
Hide file tree
Showing 7 changed files with 313 additions and 19 deletions.
10 changes: 10 additions & 0 deletions backend/internal/tests/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ func createTestUser(ctx context.Context, s *server.Server) (string, string, stri
return userID, email, password, err
}

/*
Helper function that queries the token_salt of a test user with email.
*/
func getTestUserTokenSalt(ctx context.Context, email string, s *server.Server) ([]byte, error) {
row := s.DBPool.QueryRow(ctx, "SELECT token_salt FROM users WHERE email = $1;", email)
var salt []byte
err := row.Scan(&salt)
return salt, err
}

/*
Simple wrapper with SQL to remove a user from the database. Ideally, you want to use this
only for the test user created by the function createTestUser()
Expand Down
1 change: 0 additions & 1 deletion backend/internal/tests/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,3 @@ func TestLoggerWithoutContext(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "test message", logEntry["message"])
}

191 changes: 173 additions & 18 deletions backend/internal/tests/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

golangJWT "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -56,7 +58,7 @@ func TestServer(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()

// create test user
// create testuser
userID := uuid.New()
_, err = s.DBPool.Exec(ctx, `
INSERT INTO users (
Expand Down Expand Up @@ -175,19 +177,12 @@ func TestServer(t *testing.T) {
assert.NoError(t, err)
})

t.Run("/api/v1/auth/register - 400 Bad Request - existing user", func(t *testing.T) {
t.Run("/api/v1/auth/register - 400 Bad Request - invalid body", 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)

// create request body
email := "test"
password := "short"
reqBody := map[string]string{
"email": email,
"password": password,
Expand All @@ -197,32 +192,192 @@ func TestServer(t *testing.T) {

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("/api/v1/auth/register - 400 Bad Request - invalid body", func(t *testing.T) {
url := "/api/v1/auth/register"
t.Run("/api/v1/auth/verify - 200 OK - valid cookie value", func(t *testing.T) {
// 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"
password := "short"
email := "[email protected]"
password := "mypassword"

reqBody := map[string]string{
"email": email,
"password": password,
}
reqBodyBytes, err := json.Marshal(reqBody)
assert.NoError(t, err)

// get cookie by normal means
reader := bytes.NewReader(reqBodyBytes)
req := httptest.NewRequest(http.MethodPost, url, reader)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", reader)
req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()

s.Echo.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, http.StatusCreated, rec.Code)

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)
assert.Equal(t, refreshCookie.Name, v1_auth.COOKIE_REFRESH_TOKEN)

// now we send a request to the actual route being tested
// to see if the verification of the cookie works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify", nil)
req.AddCookie(refreshCookie)
rec = httptest.NewRecorder()

s.Echo.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)

// read in the response body
resBodyBytes, err := io.ReadAll(rec.Body)
assert.NoError(t, err)
var resBody v1_auth.AuthResponse
err = json.Unmarshal(resBodyBytes, &resBody)
assert.NoError(t, err)

// the response body should include a new access token upon success
assert.NotEmpty(t, resBody.AccessToken)
assert.NotNil(t, resBody.User)

// a new cookie value shouldn't be set since the refresh token hasn't expired yet
cookies = rec.Result().Cookies()
includesNewCookie := false
for _, cookie := range cookies {
if cookie.Name == v1_auth.COOKIE_REFRESH_TOKEN {
includesNewCookie = true
break
}
}
assert.False(t, includesNewCookie)

err = removeTestUser(ctx, email, s)
assert.NoError(t, err)
})

t.Run("/api/v1/auth/verify - 200 OK - about to expired valid cookie", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

// create new user to generate jwt
userID, email, _, err := createTestUser(ctx, s)
defer removeTestUser(ctx, email, s)

salt, err := getTestUserTokenSalt(ctx, email, s)
assert.NoError(t, err)

// this way we can control the exp since the inner function is not exported
// set the time to expired in two days
exp := time.Now().UTC().Add(2 * 24 * time.Hour)
claims := jwt.JWTClaims{
UserID: userID,
Role: db.UserRoleStartupOwner,
TokenType: jwt.REFRESH_TOKEN_TYPE,
RegisteredClaims: golangJWT.RegisteredClaims{
ExpiresAt: golangJWT.NewNumericDate(exp),
IssuedAt: golangJWT.NewNumericDate(time.Now()),
},
}

token := golangJWT.NewWithClaims(golangJWT.SigningMethodHS256, claims)
// combine base secret with user's salt
secret := append([]byte(os.Getenv("JWT_SECRET")), salt...)
signed, err := token.SignedString(secret)
assert.NoError(t, err)

cookie := http.Cookie{
Name: v1_auth.COOKIE_REFRESH_TOKEN,
Value: signed,
}

req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify", nil)
req.AddCookie(&cookie)
rec := httptest.NewRecorder()
s.Echo.ServeHTTP(rec, req)

assert.Equal(t, http.StatusOK, rec.Code)

// should return an access token
var resBody v1_auth.AuthResponse
err = json.Unmarshal(rec.Body.Bytes(), &resBody)
assert.NoError(t, err)

assert.NotEmpty(t, resBody.AccessToken)

// should include a new refresh token 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)
assert.Equal(t, refreshCookie.Name, v1_auth.COOKIE_REFRESH_TOKEN)
})

t.Run("/api/v1/auth/verify - 401 UNAUTHORIZED - missing cookie in request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify", nil)
rec := httptest.NewRecorder()

s.Echo.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})

t.Run("/api/v1/auth/verify - 401 UNAUTHORIZED - invalid cookie value", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

// create new user to generate jwt
userID, email, _, err := createTestUser(ctx, s)
defer removeTestUser(ctx, email, s)

// the refresh token will be invalid since the salt is different
_, refreshToken, err := jwt.GenerateWithSalt(userID, db.UserRoleStartupOwner, []byte{0, 1, 2})
assert.NoError(t, err)

cookie := http.Cookie{
Name: v1_auth.COOKIE_REFRESH_TOKEN,
Value: refreshToken,
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify", nil)
req.AddCookie(&cookie)
rec := httptest.NewRecorder()

s.Echo.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})

t.Run("/api/v1/auth/verify - 401 UNAUTHORIZED - invalid cookie", func(t *testing.T) {
cookie := http.Cookie{
Name: v1_auth.COOKIE_REFRESH_TOKEN,
Value: "invalid-refresh-token",
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify", nil)
req.AddCookie(&cookie)
rec := httptest.NewRecorder()

s.Echo.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})

t.Run("/auth/verify-email - 200 OK - valid email token", func(t *testing.T) {
Expand Down
57 changes: 57 additions & 0 deletions backend/internal/v1/v1_auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,60 @@ func (h *Handler) handleVerifyEmail(c echo.Context) error {

return c.JSON(http.StatusOK, EmailVerifiedStatusResponse{Verified: true})
}

/*
Handle incoming requests to verify the refresh token saved in a HTTP-only cookie.
This route is used for a form to verify persistant authentication and generate
new access tokens for clients to use.
This route will also respond with the same type of body as register/login because
it is essentially a passwordless login for the user given that the refresh token
in the cookie is valid.
*/
func (h *Handler) handleVerifyCookie(c echo.Context) error {
cookie, err := c.Cookie(COOKIE_REFRESH_TOKEN)
if err != nil {
return v1_common.Fail(c, http.StatusUnauthorized, "Missing refresh token cookie in request", err)
}

// verify the refresh token in the cookie
refreshToken := cookie.Value
claims, err := jwt.ParseUnverifiedClaims(refreshToken)
if err != nil {
return v1_common.Fail(c, http.StatusUnauthorized, "Cookie has invalid value.", err)
}

ctx, cancel := context.WithTimeout(c.Request().Context(), time.Minute)
defer cancel()

// get salt
user, err := h.server.GetQueries().GetUserByID(ctx, claims.UserID)
if err != nil {
return v1_common.Fail(c, http.StatusUnauthorized, "Cookie has invalid value.", err)
}

claims, err = jwt.VerifyTokenWithSalt(refreshToken, user.TokenSalt)
if err != nil {
return v1_common.Fail(c, http.StatusUnauthorized, "Cookie is not valid.", err)
}

accessToken, refreshToken, err := jwt.GenerateWithSalt(user.ID, user.Role, user.TokenSalt)
if err != nil {
return v1_common.Fail(c, http.StatusInternalServerError, "Oops, something went wrong.", err)
}

if time.Until(claims.ExpiresAt.Time) < 3*24*time.Hour {
// refresh token is about to expired in less than 3 days
// set the new generated refresh token in the cookie
setRefreshTokenCookie(c, refreshToken)
}

return c.JSON(http.StatusOK, map[string]any{
"access_token": accessToken,
"user": map[string]any{
"email": user.Email,
"email_verified": user.EmailVerified,
"role": string(user.Role),
},
})
}
5 changes: 5 additions & 0 deletions backend/internal/v1/v1_auth/cookies.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"KonferCA/SPUR/common"
"net/http"
"os"
"time"

"github.com/labstack/echo/v4"
)
Expand All @@ -13,6 +14,7 @@ Helper function that abstract away the different cookie configuration needed
based on the app environment for the refresh token cookie.
*/
func getRefreshTokenCookieConfig() *http.Cookie {
exp := time.Now().UTC().Add(24 * 7 * time.Hour)
cookie := &http.Cookie{
Name: COOKIE_REFRESH_TOKEN,
// this is a static path, that it should only be allowed in
Expand All @@ -21,6 +23,9 @@ func getRefreshTokenCookieConfig() *http.Cookie {
Secure: os.Getenv("APP_ENV") != common.DEVELOPMENT_ENV,
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Expires: exp,
// Max-Age set to 7 days in seconds
MaxAge: 7 * 24 * 60 * 60,
}

if os.Getenv("APP_ENV") == common.DEVELOPMENT_ENV {
Expand Down
Loading

0 comments on commit 411372b

Please sign in to comment.