feat: WIP mail verification flow

This commit is contained in:
Florian Sylvain
2024-05-14 19:37:44 +02:00
parent 8015639db5
commit ec14eee439
22 changed files with 265 additions and 143 deletions
+2
View File
@@ -1,5 +1,7 @@
ENVIRONMENT=development
HOST=example.com
PORT=8080
JWT_SECRET=abc123
CORS_ALLOWED_ORIGINS=*
DB_FILE=./gohcms.db
DOCKER_DB_FOLDER=./data
+13 -11
View File
@@ -39,17 +39,19 @@ of course required, but not necessarily via the `.env` file.
### Environment variables
| Name | Type | Description | Comment |
|----------------------|--------|:------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
| ENVIRONMENT | string | environment the API is running in | required, `development` or `production` |
| PORT | int | port the API will use | required |
| CORS_ALLOWED_ORIGINS | string | allowed origins for CORS | required, semicolon separated list |
| DB_FILE | string | path to the sqlite db file | required, can be at the root but name still required (e.g. `./gohcms.db`) ; have to end up with `.db` |
| DOCKER_DB_FOLDER | string | path to the sqlite db file's folder | required with docker, this will basically be the host machine folder (e.g. `./data`) that contains the sqlite db file |
| SMTP_EMAIL | string | sender email | required |
| SMTP_PASSWORD | string | smtp account password | required |
| SMTP_HOST | string | smtp server address | required |
| SMTP_PORT | int | smtp server port | required |
| Name | Type | Description | Comment |
|----------------------|--------|:----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
| ENVIRONMENT | string | environment the API is running in | required, `development` or `production` |
| HOST | string | the host domain name for emails and callbacks | required |
| PORT | int | port the API will use | required |
| JWT_SECRET | string | secret for the jwt auth | required |
| CORS_ALLOWED_ORIGINS | string | allowed origins for CORS | required, semicolon separated list |
| DB_FILE | string | path to the sqlite db file | required, can be at the root but name still required (e.g. `./gohcms.db`) ; have to end up with `.db` |
| DOCKER_DB_FOLDER | string | path to the sqlite db file's folder | required with docker, this will basically be the host machine folder (e.g. `./data`) that contains the sqlite db file |
| SMTP_EMAIL | string | sender email | required |
| SMTP_PASSWORD | string | smtp account password | required |
| SMTP_HOST | string | smtp server address | required |
| SMTP_PORT | int | smtp server port | required |
## API Usage
@@ -1,6 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<body>
<p>salut bebou</p>
<h1>
<img src="{{.Host}}/static/gohcms-favicon-128.png" alt="G" style="width: 2.5rem"/>
GohCMS
</h1>
<main>
<p>You or someone tried to create a GohCMS admin account with this e-mail address. If you are not responsible for
this, please do not validate the account creation.</p>
<p><b>If you want to validate the account creation, please <a href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank">click HERE</a>.</b>
</p>
</main>
</body>
</html>
@@ -38,12 +38,14 @@ func (m MailRepository) Send(receiverAddress string, templateName string, data i
}
msg := gomail.NewMessage()
msg.SetHeader("From", "GohCMS <"+from+">")
msg.SetHeader("To", receiverAddress)
msg.SetHeader("MIME-version", "1.0")
msg.SetHeader("Content-Type", "text/html")
msg.SetHeader("charset", "UTF-8")
msg.SetHeader("Subject", "GohCMS | Action required")
msg.SetHeaders(map[string][]string{
"From": {"GohCMS <" + from + ">"},
"To": {receiverAddress},
"MIME-version": {"1.0"},
"Content-Type": {"text/html"},
"charset": {"UTF-8"},
"Subject": {"GohCMS | Action required"},
})
msg.SetBody("text/html", body.String())
if err := d.DialAndSend(msg); err != nil {
+9 -6
View File
@@ -7,10 +7,13 @@ import (
type User struct {
gorm.Model
ID uint32 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"unique;not null"`
Password string `gorm:"not null"`
Email string `gorm:"unique;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ID uint32 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"unique;not null"`
Password string `gorm:"not null"`
Email string `gorm:"unique;not null"`
IsVerified bool `gorm:"default=false;not null"`
VerificationCode string `gorm:"unique;not null"`
VerificationExpiration time.Time `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
+15 -4
View File
@@ -17,7 +17,16 @@ func NewUserRepository(db *gorm.DB) *UserRepository {
}
func mapUserToDomain(user entity.User) domain.User {
return domain.FromDb(user.ID, user.Username, user.Password, user.Email, user.CreatedAt, user.UpdatedAt)
return domain.FromDb(
user.ID,
user.Username,
user.Password,
user.Email,
user.IsVerified,
user.VerificationCode,
user.VerificationExpiration,
user.CreatedAt, user.UpdatedAt,
)
}
func (u *UserRepository) Get(id uint32) (domain.User, error) {
@@ -32,11 +41,13 @@ func (u *UserRepository) Get(id uint32) (domain.User, error) {
func (u *UserRepository) Create(user domain.User) (domain.User, error) {
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(user.Password), 14)
hashedVerificationCode, _ := bcrypt.GenerateFromPassword([]byte(user.VerificationCode), 14)
creationResult := u.db.Create(&entity.User{
Username: user.Username,
Password: string(hashedPassword),
Email: user.Email,
Username: user.Username,
Password: string(hashedPassword),
Email: user.Email,
VerificationCode: string(hashedVerificationCode),
})
if creationResult.Error != nil {
return domain.User{}, creationResult.Error
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GohCMS | Setup</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column gap-5 m-auto form-container">
<div>
<h1>GohCMS</h1>
<h2>Verify your email</h2>
</div>
<p>An e-mail with the validation link was sent to the address you just registered.</p>
<button id="finalSetupFormButton" class="btn btn-outline-primary" type="button">
<span class="finalSetupFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="finalSetupFormButtonDefault">Cancel</span>
</button>
</div>
</div>
<script>
</script>
</body>
</html>
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GohCMS | Setup</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column gap-5 m-auto form-container">
<div>
<h1>GohCMS</h1>
<h2>E-mail verification</h2>
</div>
<p>Verifying your e-mail, please wait...</p>
</div>
</div>
<script>
</script>
</body>
</html>
@@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GohCMS | Setup</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column gap-5 m-auto form-container">
<div>
<h1>GohCMS</h1>
<h2>Setup done!</h2>
</div>
<p>GohCMS is now ready.</p>
<button id="finalSetupFormButton" class="btn btn-primary" type="button">
<span class="finalSetupFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="finalSetupFormButtonDefault">Finish</span>
</button>
</div>
</div>
<script>
const button = document.querySelector("#finalSetupFormButton")
function setButtonLoading() {
button.classList.add("disabled")
button.querySelector(".finalSetupFormButtonDefault").classList.add("visually-hidden")
button.querySelector(".finalSetupFormButtonLoading").classList.remove("visually-hidden")
}
function onFinalSetupFormSubmit() {
setButtonLoading()
window.location.replace("/home")
}
button.addEventListener('click', onFinalSetupFormSubmit)
</script>
</body>
</html>
+23 -5
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"net/http"
"os"
@@ -61,6 +62,13 @@ func IsLoggedIn(r *http.Request) bool {
return token != nil && err == nil
}
func IsVerified(r *http.Request) bool {
_, claims, _ := jwtauth.FromContext(r.Context())
userIDClaim, _ := claims["user_id"].(uint32)
currentUser, _ := Container.GetUserUseCase.GetUser(userIDClaim)
return currentUser.IsVerified
}
func getUserFromCredentials(credentials LoginCredentials) (user.User, error) {
dbUser, err := Container.GetUserUseCase.GetUserByUsername(credentials.Username)
if err != nil {
@@ -75,6 +83,19 @@ func getUserFromCredentials(credentials LoginCredentials) (user.User, error) {
return dbUser, nil
}
func getNewUser(newUserCredentials RegisterCredentials, verificationCode string) (user.User, error) {
createdUser, err := Container.CreateUserUseCase.CreateUser(useCases.CreateUserCommand{
Username: newUserCredentials.Username,
Password: newUserCredentials.Password,
Email: newUserCredentials.Email,
VerificationCode: verificationCode,
})
if err != nil {
return user.User{}, err
}
return createdUser, nil
}
func login(w http.ResponseWriter, r *http.Request) {
var credentials LoginCredentials
@@ -120,11 +141,8 @@ func register(w http.ResponseWriter, r *http.Request) {
return
}
createdUser, err := Container.CreateUserUseCase.CreateUser(useCases.CreateUserCommand{
Username: credentials.Username,
Password: credentials.Password,
Email: credentials.Email,
})
verificationCode := uuid.NewString()
createdUser, err := getNewUser(credentials, verificationCode)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
+17 -1
View File
@@ -47,6 +47,16 @@ func IsLoggedInMiddleware(next http.Handler) http.Handler {
})
}
func IsVerifiedMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !IsVerified(r) {
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func GetLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, LoginRoute, http.StatusPermanentRedirect)
}
@@ -91,7 +101,13 @@ func NewPageRouter() http.Handler {
r.Group(func(r chi.Router) {
r.Use(IsLoggedInMiddleware)
r.Get("/register-confirm", GetRegisterConfirmPage)
r.Get("/register/pending", GetRegisterPendingPage)
r.Get("/register/validate", GetRegisterValidatePage)
})
r.Group(func(r chi.Router) {
r.Use(IsLoggedInMiddleware)
r.Use(IsVerifiedMiddleware)
r.Get("/home", GetHomePage)
r.Get("/post", GetPostsPage)
r.Get("/post/edit", GetPostEditPage)
+19 -19
View File
@@ -1,8 +1,7 @@
package api
import (
"bytes"
"encoding/json"
"github.com/google/uuid"
"net/http"
"os"
)
@@ -29,7 +28,7 @@ func GetRegisterPageHandler(registerPage *RegisterPage) http.HandlerFunc {
PostRegisterPage(w, r)
return
}
bs, err := Container.GetPageUseCase.GetPage("setup1", map[string]interface{}{
bs, err := Container.GetPageUseCase.GetPage("register", map[string]interface{}{
"PageError": registerPage.PageError,
"Username": registerPage.Username,
"Email": registerPage.Email,
@@ -45,11 +44,12 @@ func GetRegisterPageHandler(registerPage *RegisterPage) http.HandlerFunc {
func PostRegisterPage(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
credentials, err := json.Marshal(&RegisterCredentials{
credentials := RegisterCredentials{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
Email: r.FormValue("email"),
})
}
err := validate.Struct(credentials)
if err != nil {
r.Method = http.MethodGet
GetRegisterPageHandler(&RegisterPage{
@@ -60,22 +60,22 @@ func PostRegisterPage(w http.ResponseWriter, r *http.Request) {
return
}
response, err := http.Post(
"http://localhost:"+os.Getenv("PORT")+"/v1/auth/register",
"application/json",
bytes.NewBuffer(credentials))
if err != nil || response.StatusCode != http.StatusOK {
r.Method = http.MethodGet
GetRegisterPageHandler(&RegisterPage{
PageError: NewPageError("Username should be between 3 and 20 characters long, password should be between 8 and 20 characters long, and email should be a valid email address."),
Username: r.FormValue("username"),
Email: r.FormValue("email"),
})(w, r)
verificationCode := uuid.NewString()
createdUser, err := getNewUser(credentials, verificationCode)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Set-Cookie", response.Header.Get("Set-Cookie"))
err = Container.SendMailUseCase.SendMail(createdUser.Email, "mailValidation", map[string]string{
"Host": os.Getenv("HOST"),
"VerificationCode": verificationCode,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
http.Redirect(w, r, "/register-confirm", http.StatusSeeOther)
_ = SetJwtCookie(&w, createdUser.ID)
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
}
-12
View File
@@ -1,12 +0,0 @@
package api
import (
"net/http"
)
func GetRegisterConfirmPage(w http.ResponseWriter, _ *http.Request) {
registerConfirmTmpl, _ := Container.GetPageUseCase.GetPage("setup2", map[string]interface{}{
"Head": headTmpl,
})
_, _ = w.Write(registerConfirmTmpl)
}
+10
View File
@@ -0,0 +1,10 @@
package api
import "net/http"
func GetRegisterPendingPage(w http.ResponseWriter, _ *http.Request) {
registerPendingTmpl, _ := Container.GetPageUseCase.GetPage("registerPending", map[string]interface{}{
"Head": headTmpl,
})
_, _ = w.Write(registerPendingTmpl)
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"github.com/go-chi/jwtauth/v5"
"net/http"
"time"
)
func GetRegisterValidatePage(w http.ResponseWriter, r *http.Request) {
queryVerificationCode := r.URL.Query().Get("c")
if queryVerificationCode == "" {
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
return
}
_, claims, _ := jwtauth.FromContext(r.Context())
userId, _ := claims["user_id"].(uint32)
user, _ := Container.GetUserUseCase.GetUser(userId)
errorMessage := ""
if user.VerificationCode != queryVerificationCode || user.VerificationExpiration.Before(time.Now()) {
errorMessage = "Verification link is incorrect or has expired."
} else {
// TODO New usecase "UpdateUserUseCase" to update its verification status
}
registerValidateTmpl, _ := Container.GetPageUseCase.GetPage("registerValidate", map[string]interface{}{
"Head": headTmpl,
"PageError": NewPageError(errorMessage),
})
_, _ = w.Write(registerValidateTmpl)
}
+31 -16
View File
@@ -1,25 +1,34 @@
package user
import "time"
import (
"time"
)
type User struct {
ID uint32 `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint32 `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
IsVerified bool `gorm:"default=false;not null"`
VerificationCode string `gorm:"unique;not null"`
VerificationExpiration time.Time `gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func FromApi(
username string,
password string,
email string,
verificationCode string,
) User {
return User{
Username: username,
Password: password,
Email: email,
Username: username,
Password: password,
Email: email,
IsVerified: false,
VerificationCode: verificationCode,
VerificationExpiration: time.Now().Add(2 * time.Hour),
}
}
@@ -28,15 +37,21 @@ func FromDb(
username string,
password string,
email string,
isVerified bool,
verificationCode string,
verificationExpiration time.Time,
createdAt time.Time,
updatedAt time.Time,
) User {
return User{
ID: id,
Username: username,
Password: password,
Email: email,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
ID: id,
Username: username,
Password: password,
Email: email,
IsVerified: isVerified,
VerificationCode: verificationCode,
VerificationExpiration: verificationExpiration,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ require (
require (
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/beevik/guid v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -27,7 +28,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/text v0.2.0 // indirect
+4
View File
@@ -2,6 +2,8 @@ github.com/MadAppGang/httplog v1.3.0 h1:1XU54TO8kiqTeO+7oZLKAM3RP/cJ7SadzslRcKsp
github.com/MadAppGang/httplog v1.3.0/go.mod h1:gpYEdkjh/Cda6YxtDy4AB7KY+fR7mb3SqBZw74A5hJ4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/beevik/guid v1.0.0 h1:XhTlrl9h5+TlkB7MB3SBwAm2+ZdFE62O0D+g7LDFqqI=
github.com/beevik/guid v1.0.0/go.mod h1:FyB4y08P/8c0J0xhRHR6xVjdXIpGDwpMXzmGV6vWDj4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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=
@@ -36,6 +38,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+1 -11
View File
@@ -3,7 +3,6 @@ package route
import (
"GohCMS2/api"
"encoding/json"
"fmt"
"github.com/MadAppGang/httplog"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
@@ -30,19 +29,10 @@ func HtmlContentTypeMiddleware(next http.Handler) http.Handler {
}
func InitJwt() {
api.TokenAuth = jwtauth.New("HS256", []byte("secret"), nil)
api.TokenAuth = jwtauth.New("HS256", []byte(os.Getenv("JWT_SECRET")), nil)
}
func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
err := api.Container.SendMailUseCase.SendMail(
"floriansylvainpro@gmail.com",
"mailValidation",
struct{}{},
)
if err != nil {
fmt.Println(err)
}
msg, _ := json.Marshal(map[string]string{"message": "Hello World"})
_, _ = w.Write(msg)
}
+2
View File
@@ -12,7 +12,9 @@ import (
var possibleEnvFileLocations = []string{".env", "../.env"}
var envVarsToLoad = []string{
"HOST",
"PORT",
"JWT_SECRET",
"ENVIRONMENT",
"CORS_ALLOWED_ORIGINS",
"DB_FILE",
+5 -3
View File
@@ -11,9 +11,10 @@ type CreateUserUseCase struct {
}
type CreateUserCommand struct {
Username string
Password string
Email string
Username string
Password string
Email string
VerificationCode string
}
func NewCreateUserUseCase(db *gorm.DB) *CreateUserUseCase {
@@ -27,5 +28,6 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User,
createUser.Username,
createUser.Password,
createUser.Email,
createUser.VerificationCode,
))
}