summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-05 13:01:00 +0530
committerBobby <[email protected]>2026-03-05 13:01:00 +0530
commitd05edfba524b27b58eb98f7ca533bf6cd2c40862 (patch)
treea6b34492f78039ce361150a800949c1222638f24
parentc0a03e523b4d46a4070ceb6a5d9bad8797ba376a (diff)
downloadpagoda-d05edfba524b27b58eb98f7ca533bf6cd2c40862.tar.xz
pagoda-d05edfba524b27b58eb98f7ca533bf6cd2c40862.zip
feat: add email verification functionality with SMTP support
-rw-r--r--shrine/config/config.go5
-rw-r--r--shrine/config/env.go9
-rw-r--r--shrine/controllers/auth.go71
-rw-r--r--shrine/enums/verification.go9
-rw-r--r--shrine/go.mod2
-rw-r--r--shrine/go.sum10
-rw-r--r--shrine/models/user.go26
-rw-r--r--shrine/repositories/user.go11
-rw-r--r--shrine/router/auth.go1
-rw-r--r--shrine/templates/activation.html15
-rw-r--r--shrine/templates/layouts/email.html32
-rw-r--r--shrine/types/request.go4
-rw-r--r--shrine/utils/emails/emails.go52
13 files changed, 231 insertions, 16 deletions
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:"[email protected]"`
+ 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 %}
+<h2 style="margin: 0 0 16px 0; font-size: 20px; color: #e8e8f0;">Welcome to Pagoda, {{ username }}!</h2>
+<p style="margin: 0 0 24px 0; line-height: 1.6; color: #c8c8d8;">Please verify your email address to activate your account.</p>
+<table cellpadding="0" cellspacing="0" style="margin: 0 0 24px 0;">
+ <tr>
+ <td style="background-color: #9b6dff; border-radius: 6px; padding: 12px 28px;">
+ <a href="{{ verification_link }}" style="color: #ffffff; text-decoration: none; font-weight: 600; font-size: 14px;">Verify Email</a>
+ </td>
+ </tr>
+</table>
+<p style="margin: 0 0 8px 0; font-size: 13px; color: #707088;">Or copy this link into your browser:</p>
+<p style="margin: 0 0 24px 0; font-size: 13px; word-break: break-all;"><a href="{{ verification_link }}" style="color: #9b6dff;">{{ verification_link }}</a></p>
+<p style="margin: 0; font-size: 13px; color: #707088;">This link expires in 24 hours.</p>
+{% 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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="margin: 0; padding: 0; background-color: #08080f; font-family: 'IBM Plex Sans', 'Segoe UI', Arial, sans-serif; color: #c8c8d8;">
+ <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #08080f; padding: 40px 0;">
+ <tr>
+ <td align="center">
+ <table width="560" cellpadding="0" cellspacing="0" style="background-color: #14142a; border: 1px solid #2a2a3e; border-radius: 8px;">
+ <tr>
+ <td style="background-color: #1c1c38; padding: 24px 32px; border-radius: 8px 8px 0 0; border-bottom: 1px solid #2a2a3e;">
+ <span style="font-size: 22px; font-weight: 700; color: #9b6dff; letter-spacing: 1px;">PAGODA</span>
+ </td>
+ </tr>
+ <tr>
+ <td style="padding: 32px;">
+ {% block content %}{% endblock %}
+ </td>
+ </tr>
+ <tr>
+ <td style="padding: 20px 32px; border-top: 1px solid #2a2a3e; text-align: center;">
+ <span style="font-size: 12px; color: #707088;">This email was sent by Pagoda. If you did not expect this, you can safely ignore it.</span>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+</body>
+</html> \ 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