From d05edfba524b27b58eb98f7ca533bf6cd2c40862 Mon Sep 17 00:00:00 2001 From: Bobby <30593201+luciferreeves@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:01:00 +0530 Subject: feat: add email verification functionality with SMTP support --- shrine/config/config.go | 5 +++ shrine/config/env.go | 9 +++++ shrine/controllers/auth.go | 71 ++++++++++++++++++++++++++++++------- shrine/enums/verification.go | 9 +++++ shrine/go.mod | 2 ++ shrine/go.sum | 10 ++++++ shrine/models/user.go | 26 ++++++++++++-- shrine/repositories/user.go | 11 ++++++ shrine/router/auth.go | 1 + shrine/templates/activation.html | 15 ++++++++ shrine/templates/layouts/email.html | 32 +++++++++++++++++ shrine/types/request.go | 4 +++ shrine/utils/emails/emails.go | 52 +++++++++++++++++++++++++++ 13 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 shrine/enums/verification.go create mode 100644 shrine/templates/activation.html create mode 100644 shrine/templates/layouts/email.html create mode 100644 shrine/utils/emails/emails.go diff --git a/shrine/config/config.go b/shrine/config/config.go index cc223ca..e07f1c6 100644 --- a/shrine/config/config.go +++ b/shrine/config/config.go @@ -10,6 +10,7 @@ import ( var ( Server server Database database + SMTP smtp ) func init() { @@ -27,6 +28,10 @@ func init() { logger.Fatalf("Config", "Failed to parse database config: %v", err) } + if err := env.Parse(&SMTP); err != nil { + logger.Fatalf("Config", "Failed to parse SMTP config: %v", err) + } + if Server.Debug { logger.SetDebug(true) } diff --git a/shrine/config/env.go b/shrine/config/env.go index 931775b..5d803d4 100644 --- a/shrine/config/env.go +++ b/shrine/config/env.go @@ -15,3 +15,12 @@ type database struct { Driver string `env:"DB_DRIVER" default:"sqlite"` DSN string `env:"DSN" default:"pagoda.db"` } + +type smtp struct { + Host string `env:"SMTP_HOST" default:"localhost"` + Port int `env:"SMTP_PORT" default:"587"` + Username string `env:"SMTP_USERNAME" default:""` + Password string `env:"SMTP_PASSWORD" default:""` + From string `env:"SMTP_FROM" default:"noreply@pagoda.local"` + FrontendURL string `env:"FRONTEND_URL" default:"http://localhost:5173"` +} diff --git a/shrine/controllers/auth.go b/shrine/controllers/auth.go index c6c2b7a..8eedb38 100644 --- a/shrine/controllers/auth.go +++ b/shrine/controllers/auth.go @@ -2,11 +2,15 @@ package controllers import ( "errors" + "shrine/enums" "shrine/models" "shrine/repositories" "shrine/types" "shrine/utils/auth" + "shrine/utils/emails" + "shrine/utils/logger" "shrine/utils/meta" + "time" "github.com/gofiber/fiber/v2" ) @@ -14,7 +18,7 @@ import ( func RegisterController(context *fiber.Ctx) error { body, err := meta.Body[types.RegisterRequest](context) if err != nil { - return BadRequest(context, errors.New("invalid request body")) + return BadRequest(context, errors.New("Invalid request body.")) } user := models.User{ @@ -31,39 +35,52 @@ func RegisterController(context *fiber.Ctx) error { return BadRequest(context, err) } - token, err := auth.IssueToken(context, user.ID) + token, err := auth.GenerateToken() if err != nil { - return InternalServerError(context, errors.New("failed to create session")) + return InternalServerError(context, errors.New("Failed to generate verification token.")) } - return Created(context, types.AuthResponse{ - Token: token, - User: user.ToResponse(), + user.SetVerification(auth.HashToken(token), time.Now().Add(24*time.Hour), enums.Activation) + + if err := repositories.UpdateUser(&user); err != nil { + return InternalServerError(context, errors.New("Failed to store verification token.")) + } + + if err := emails.SendActivation(user.Email, user.Username, token); err != nil { + logger.Errorf("Auth", "Failed to send verification email to %s: %v", user.Email, err) + } + + return Created(context, types.MessageResponse{ + Message: "Your account has been created. Please check your email to verify your account.", }) } func LoginController(context *fiber.Ctx) error { body, err := meta.Body[types.LoginRequest](context) if err != nil { - return BadRequest(context, errors.New("invalid request body")) + return BadRequest(context, errors.New("Invalid request body.")) } user, err := repositories.FindUserByUsername(body.Username) if err != nil { - return Unauthorized(context, errors.New("invalid credentials")) + return Unauthorized(context, errors.New("Invalid username or password.")) } if !user.CanAuthenticate() { - return Forbidden(context, errors.New("account is not eligible for authentication")) + return Forbidden(context, errors.New("Your account has been banned or disabled.")) } if !user.CheckPassword(body.Password) { - return Unauthorized(context, errors.New("invalid credentials")) + return Unauthorized(context, errors.New("Invalid username or password.")) + } + + if !user.IsVerified() { + return Forbidden(context, errors.New("Your email address has not been verified. Please check your inbox.")) } token, err := auth.IssueToken(context, user.ID) if err != nil { - return InternalServerError(context, errors.New("failed to create session")) + return InternalServerError(context, errors.New("Failed to create session.")) } return Success(context, types.AuthResponse{ @@ -72,14 +89,42 @@ func LoginController(context *fiber.Ctx) error { }) } +func VerifyController(context *fiber.Ctx) error { + body, err := meta.Body[types.VerifyRequest](context) + if err != nil { + return BadRequest(context, errors.New("Invalid request body.")) + } + + if body.Token == "" { + return BadRequest(context, errors.New("Verification token is required.")) + } + + tokenHash := auth.HashToken(body.Token) + + user, err := repositories.FindUserByVerification(tokenHash, enums.Activation) + if err != nil { + return BadRequest(context, errors.New("Your verification link is invalid or has expired.")) + } + + user.VerifyEmail() + + if err := repositories.UpdateUser(user); err != nil { + return InternalServerError(context, errors.New("Failed to verify your account.")) + } + + return Success(context, types.MessageResponse{ + Message: "Your email has been verified successfully. You can now log in.", + }) +} + func LogoutController(context *fiber.Ctx) error { tokenHash := auth.GetTokenHash(context) if err := repositories.DeleteToken(tokenHash); err != nil { - return InternalServerError(context, errors.New("failed to end session")) + return InternalServerError(context, errors.New("Failed to end your session.")) } return Success(context, types.MessageResponse{ - Message: "logged out", + Message: "You have been logged out successfully.", }) } diff --git a/shrine/enums/verification.go b/shrine/enums/verification.go new file mode 100644 index 0000000..19f5221 --- /dev/null +++ b/shrine/enums/verification.go @@ -0,0 +1,9 @@ +package enums + +type VerificationType string + +const ( + Activation VerificationType = "activation" + EmailChange VerificationType = "email_change" + PasswordReset VerificationType = "password_reset" +) \ No newline at end of file diff --git a/shrine/go.mod b/shrine/go.mod index d61e853..f72fca3 100644 --- a/shrine/go.mod +++ b/shrine/go.mod @@ -3,6 +3,7 @@ module shrine go 1.25.5 require ( + github.com/flosch/pongo2/v6 v6.0.0 github.com/gofiber/fiber/v2 v2.52.12 github.com/joho/godotenv v1.5.1 go.uber.org/zap v1.27.1 @@ -27,6 +28,7 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stretchr/testify v1.9.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect diff --git a/shrine/go.sum b/shrine/go.sum index 6b475c9..0ccc7fa 100644 --- a/shrine/go.sum +++ b/shrine/go.sum @@ -3,6 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= +github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -23,6 +25,10 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -36,6 +42,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -64,6 +72,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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.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= diff --git a/shrine/models/user.go b/shrine/models/user.go index 85bd0bf..fa4a2f9 100644 --- a/shrine/models/user.go +++ b/shrine/models/user.go @@ -27,8 +27,11 @@ type User struct { Pronouns string `gorm:"size:50"` Signature string `gorm:"size:500"` Role enums.UserRole `gorm:"size:20;not null;default:member"` - EmailVerified bool `gorm:"not null;default:false"` - AccountBanned bool `gorm:"not null;default:false"` + EmailVerified bool `gorm:"not null;default:false"` + VerificationHash string `gorm:"size:64"` + VerificationExpiry *time.Time + VerificationType enums.VerificationType `gorm:"size:20"` + AccountBanned bool `gorm:"not null;default:false"` BannedAt *time.Time BannedReason string `gorm:"size:500"` BannedBy *uint `gorm:"index"` @@ -80,7 +83,24 @@ func (user *User) IsDisabled() bool { } func (user *User) CanAuthenticate() bool { - return !user.AccountBanned && !user.AccountDisabled && user.EmailVerified + return !user.AccountBanned && !user.AccountDisabled +} + +func (user *User) IsVerified() bool { + return user.EmailVerified +} + +func (user *User) SetVerification(hash string, expiry time.Time, verificationType enums.VerificationType) { + user.VerificationHash = hash + user.VerificationExpiry = &expiry + user.VerificationType = verificationType +} + +func (user *User) VerifyEmail() { + user.EmailVerified = true + user.VerificationHash = "" + user.VerificationExpiry = nil + user.VerificationType = "" } func (user *User) ToResponse() types.UserResponse { diff --git a/shrine/repositories/user.go b/shrine/repositories/user.go index 93205a0..697c5df 100644 --- a/shrine/repositories/user.go +++ b/shrine/repositories/user.go @@ -4,6 +4,7 @@ import ( "shrine/database" "shrine/enums" "shrine/models" + "time" ) func CreateUser(user *models.User) error { @@ -23,4 +24,14 @@ func FindUserByUsername(username string) (*models.User, error) { var user models.User err := database.DB.Where("username = ?", username).First(&user).Error return &user, err +} + +func UpdateUser(user *models.User) error { + return database.DB.Save(user).Error +} + +func FindUserByVerification(hash string, verificationType enums.VerificationType) (*models.User, error) { + var user models.User + err := database.DB.Where("verification_hash = ? AND verification_type = ? AND verification_expiry > ?", hash, verificationType, time.Now()).First(&user).Error + return &user, err } \ No newline at end of file diff --git a/shrine/router/auth.go b/shrine/router/auth.go index 660ba7f..7c566eb 100644 --- a/shrine/router/auth.go +++ b/shrine/router/auth.go @@ -12,6 +12,7 @@ func init() { urls.Path(types.POST, "/register", controllers.RegisterController, "register") urls.Path(types.POST, "/login", controllers.LoginController, "login") + urls.Path(types.POST, "/verify", controllers.VerifyController, "verify") urls.Path(types.POST, "/logout", auth.RequireAuthentication(controllers.LogoutController), "logout") urls.Path(types.GET, "/me", auth.RequireAuthentication(controllers.MeController), "me") } \ No newline at end of file diff --git a/shrine/templates/activation.html b/shrine/templates/activation.html new file mode 100644 index 0000000..467ea8f --- /dev/null +++ b/shrine/templates/activation.html @@ -0,0 +1,15 @@ +{% extends "layouts/email.html" %} +{% block content %} +

Welcome to Pagoda, {{ username }}!

+

Please verify your email address to activate your account.

+ + + + +
+ Verify Email +
+

Or copy this link into your browser:

+

{{ verification_link }}

+

This link expires in 24 hours.

+{% endblock %} \ No newline at end of file diff --git a/shrine/templates/layouts/email.html b/shrine/templates/layouts/email.html new file mode 100644 index 0000000..26440bd --- /dev/null +++ b/shrine/templates/layouts/email.html @@ -0,0 +1,32 @@ + + + + + + + + + + + +
+ + + + + + + + + + +
+ PAGODA +
+ {% block content %}{% endblock %} +
+ This email was sent by Pagoda. If you did not expect this, you can safely ignore it. +
+
+ + \ No newline at end of file diff --git a/shrine/types/request.go b/shrine/types/request.go index 096c23e..32e81a9 100644 --- a/shrine/types/request.go +++ b/shrine/types/request.go @@ -10,4 +10,8 @@ type RegisterRequest struct { type LoginRequest struct { Username string `json:"username"` Password string `json:"password"` +} + +type VerifyRequest struct { + Token string `json:"token"` } \ No newline at end of file diff --git a/shrine/utils/emails/emails.go b/shrine/utils/emails/emails.go new file mode 100644 index 0000000..7dae0bb --- /dev/null +++ b/shrine/utils/emails/emails.go @@ -0,0 +1,52 @@ +package emails + +import ( + "fmt" + "net/smtp" + "shrine/config" + "strconv" + + "github.com/flosch/pongo2/v6" +) + +var activationTemplate *pongo2.Template + +func init() { + var err error + activationTemplate, err = pongo2.FromFile("templates/activation.html") + if err != nil { + panic(fmt.Sprintf("failed to load activation template: %v", err)) + } +} + +func Send(to string, subject string, html string) error { + addr := config.SMTP.Host + ":" + strconv.Itoa(config.SMTP.Port) + + headers := fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n", + config.SMTP.From, + to, + subject, + ) + + var auth smtp.Auth + if config.SMTP.Username != "" { + auth = smtp.PlainAuth("", config.SMTP.Username, config.SMTP.Password, config.SMTP.Host) + } + + return smtp.SendMail(addr, auth, config.SMTP.From, []string{to}, []byte(headers+html)) +} + +func SendActivation(to string, username string, token string) error { + link := config.SMTP.FrontendURL + "/account/verify?token=" + token + + html, err := activationTemplate.Execute(pongo2.Context{ + "username": username, + "verification_link": link, + }) + if err != nil { + return err + } + + return Send(to, "Verify your Pagoda account", html) +} \ No newline at end of file -- cgit v1.2.3