Skip to content

Commit

Permalink
Feat/77/send verification email (#81)
Browse files Browse the repository at this point in the history
  • Loading branch information
juancwu authored Dec 6, 2024
2 parents 429f0a4 + 92601dc commit e24efd9
Show file tree
Hide file tree
Showing 19 changed files with 341 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS verify_email_tokens (
-- id column will be used in the standard jwt id claims.
-- which can be used to identify/select the record in the db.
id UUID PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS verify_email_tokens;
-- +goose StatementEnd
11 changes: 11 additions & 0 deletions backend/.sqlc/queries/verify_email_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- name: CreateVerifyEmailToken :one
INSERT INTO verify_email_tokens (
email,
expires_at
) VALUES (
$1, $2
) RETURNING *;

-- name: GetVerifyEmailTokenByID :one
SELECT * FROM verify_email_tokens
WHERE id = $1 LIMIT 1;
2 changes: 1 addition & 1 deletion backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ start-testdb:
@goose -dir .sqlc/migrations postgres "$(TEST_DB_URL)" up

run-tests:
@go test -v -coverprofile=coverage.out ./...
@APP_ENV=test go test -v -coverprofile=coverage.out ./...

stop-testdb:
@echo "Stopping test db..."
Expand Down
2 changes: 2 additions & 0 deletions backend/common/env_name.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ package common

const PRODUCTION_ENV = "production"
const DEVELOPMENT_ENV = "development"
const PREVIEW_ENV = "preview"
const TEST_ENV = "test"
const STAGING_ENV = "staging"
7 changes: 7 additions & 0 deletions backend/db/models.go

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

54 changes: 54 additions & 0 deletions backend/db/verify_email_tokens.sql.go

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

14 changes: 7 additions & 7 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@ module KonferCA/SPUR
go 1.23.2

require (
github.com/a-h/templ v0.2.793
github.com/aws/aws-sdk-go-v2 v1.32.6
github.com/aws/aws-sdk-go-v2/config v1.28.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0
github.com/aws/smithy-go v1.22.1
github.com/go-playground/validator/v10 v10.22.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.1
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.12.0
github.com/resend/resend-go/v2 v2.13.0
github.com/rs/zerolog v1.33.0
github.com/stretchr/testify v1.8.4
golang.org/x/crypto v0.27.0
)

require (
github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/config v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
Expand All @@ -28,11 +32,9 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
Expand All @@ -41,18 +43,16 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/resend/resend-go/v2 v2.13.0 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
Expand Down
12 changes: 6 additions & 6 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
Expand Down Expand Up @@ -54,6 +56,8 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
Expand All @@ -64,9 +68,6 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
Expand Down Expand Up @@ -106,8 +107,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand All @@ -122,7 +123,6 @@ golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
27 changes: 25 additions & 2 deletions backend/internal/jwt/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (
"time"

"KonferCA/SPUR/db"

golangJWT "github.com/golang-jwt/jwt/v5"
)

const (
ACCESS_TOKEN_TYPE = "access_token"
REFRESH_TOKEN_TYPE = "refresh_token"
ACCESS_TOKEN_TYPE = "access_token"
REFRESH_TOKEN_TYPE = "refresh_token"
VERIFY_EMAIL_TOKEN_TYPE = "verify_email_token"
)

// Generates JWT tokens for the given user. Returns the access token, refresh token and error (nil if no error)
Expand All @@ -28,6 +30,27 @@ func Generate(userID string, role db.UserRole) (string, string, error) {
return accessToken, refreshToken, nil
}

/*
GenerateVerifyEmailToken generates a new token for verifying someones email.
This method uses a different jwt secret defined by JWT_SECRET_VERIFY_EMAIL
to separate authentication related jwt with this one.
*/
func GenerateVerifyEmailToken(email string, id string, exp time.Time) (string, error) {
claims := VerifyEmailJWTClaims{
Email: email,
TokenType: VERIFY_EMAIL_TOKEN_TYPE,
RegisteredClaims: golangJWT.RegisteredClaims{
// expire in 1 week
ExpiresAt: golangJWT.NewNumericDate(exp),
IssuedAt: golangJWT.NewNumericDate(time.Now()),
ID: id,
},
}

token := golangJWT.NewWithClaims(golangJWT.SigningMethodHS256, claims)
return token.SignedString([]byte(os.Getenv("JWT_SECRET_VERIFY_EMAIL")))
}

// Private helper method to generate a token.
func generateToken(userID string, role db.UserRole, tokenType string, exp time.Time) (string, error) {
claims := JWTClaims{
Expand Down
35 changes: 35 additions & 0 deletions backend/internal/jwt/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import (
"time"

"KonferCA/SPUR/db"

golangJWT "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
)

func TestJWT(t *testing.T) {
// setup env
os.Setenv("JWT_SECRET", "secret")
os.Setenv("JWT_SECRET_VERIFY_EMAIL", "test-secret")

userID := "some-user-id"
role := db.UserRole("user")
Expand Down Expand Up @@ -77,4 +79,37 @@ func TestJWT(t *testing.T) {
_, err = VerifyToken(token)
assert.NotNil(t, err)
})

t.Run("generate verify email token", func(t *testing.T) {
email := "[email protected]"
id := "some-id"
exp := time.Now().Add(time.Second * 5)
token, err := GenerateVerifyEmailToken(email, id, exp)
assert.Nil(t, err)
claims, err := VerifyEmailToken(token)
assert.Nil(t, err)
assert.Equal(t, claims.Email, email)
assert.Equal(t, claims.ID, id)
assert.Equal(t, claims.ExpiresAt.Unix(), exp.Unix())
})

t.Run("deny expired verify email token", func(t *testing.T) {
email := "[email protected]"
id := "some-id"
exp := time.Now().Add(-1 * 5 * time.Second)
token, err := GenerateVerifyEmailToken(email, id, exp)
assert.Nil(t, err)
_, err = VerifyEmailToken(token)
assert.NotNil(t, err)
})

t.Run("deny expired verify email token", func(t *testing.T) {
email := "[email protected]"
id := "some-id"
exp := time.Now().Add(-1 * 5 * time.Second)
token, err := GenerateVerifyEmailToken(email, id, exp)
assert.Nil(t, err)
_, err = VerifyEmailToken(token)
assert.NotNil(t, err)
})
}
6 changes: 6 additions & 0 deletions backend/internal/jwt/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ type JWTClaims struct {
TokenType string `json:"token_type"`
golangJWT.RegisteredClaims
}

type VerifyEmailJWTClaims struct {
Email string `json:"email"`
TokenType string `json:"token_type"`
golangJWT.RegisteredClaims
}
21 changes: 21 additions & 0 deletions backend/internal/jwt/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,24 @@ func VerifyToken(token string) (*JWTClaims, error) {
}
return &claims, nil
}

/*
VerifyEmailToken only verifies the tokens made for email verification.
*/
func VerifyEmailToken(token string) (*VerifyEmailJWTClaims, error) {
claims := VerifyEmailJWTClaims{}
_, err := golangJWT.ParseWithClaims(token, &claims, func(t *golangJWT.Token) (interface{}, error) {
if _, ok := t.Method.(*golangJWT.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"])
}
return []byte(os.Getenv("JWT_SECRET_VERIFY_EMAIL")), nil
})
if err != nil {
return nil, err
}
if claims.TokenType != VERIFY_EMAIL_TOKEN_TYPE {
return nil, fmt.Errorf("Unexpected token type when verifying email token: %s", claims.TokenType)
}

return &claims, nil
}
31 changes: 31 additions & 0 deletions backend/internal/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import (
"context"
"net/http"
"reflect"
"time"

"KonferCA/SPUR/db"
"KonferCA/SPUR/internal/jwt"
mw "KonferCA/SPUR/internal/middleware"
"KonferCA/SPUR/internal/service"

"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
Expand Down Expand Up @@ -60,6 +63,34 @@ func (s *Server) handleSignup(c echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate token")
}

// send verification email
// db pool is passed to not lose reference to the s object once
// the function returns the response.
go func(pool *pgxpool.Pool, email string) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
q := db.New(pool)
token, err := q.CreateVerifyEmailToken(ctx, db.CreateVerifyEmailTokenParams{
Email: email,
// default expires after 30 minutes
ExpiresAt: time.Now().Add(time.Minute * 30),
})
if err != nil {
log.Error().Err(err).Str("email", email).Msg("Failed to create verify email token in db.")
return
}
tokenStr, err := jwt.GenerateVerifyEmailToken(email, token.ID, token.ExpiresAt)
if err != nil {
log.Error().Err(err).Str("email", email).Msg("Failed to generate signed verify email token.")
return
}
err = service.SendVerficationEmail(ctx, email, tokenStr)
if err != nil {
log.Error().Err(err).Str("email", email).Msg("Failed to send verification email.")
return
}
}(s.DBPool, user.Email)

return c.JSON(http.StatusCreated, AuthResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
Expand Down
Loading

0 comments on commit e24efd9

Please sign in to comment.