feat: password reset #32

This commit is contained in:
Florian Sylvain
2024-06-14 13:46:39 +02:00
parent 727418c8e6
commit e4c2230b64
16 changed files with 527 additions and 64 deletions
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h1>
<img src="{{.Host}}/static/gocms-favicon-128.png" alt="G" style="width: 2.5rem"/>
GoCMS
</h1>
<main>
<p>You or someone tried to create reset your GoCMS account with this e-mail address. If you are not responsible for
this, please do not validate the password reset.</p>
<p><b>If you want to reset your password, please <a
href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}" target="_blank">click HERE</a>.</b>
</p>
<p>If you can't click the link:</p>
<p>{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}</p>
</main>
</body>
</html>
+3 -2
View File
@@ -10,10 +10,11 @@ type User struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"unique;not null"`
Password string `gorm:"not null"`
PasswordResetCode string `gorm:"unique"`
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"`
VerificationCode string `gorm:"unique"`
VerificationExpiration time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -22,6 +22,7 @@ func mapUserToDomain(user entity.User) domain.User {
user.ID,
user.Username,
user.Password,
user.PasswordResetCode,
user.Email,
user.IsVerified,
user.VerificationCode,
@@ -88,6 +89,16 @@ func (u *UserRepository) GetByUsername(username string) (domain.User, error) {
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) GetByEmail(email string) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).Where("email = ?", email).First(&localUser).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) UpdateVerificationStatus(userId uint32, isVerified bool) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
@@ -104,4 +115,38 @@ func (u *UserRepository) UpdateVerificationStatus(userId uint32, isVerified bool
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) UpdatePassword(userId uint32, password string) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domain.User{}, err
}
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), 12)
localUser.Password = string(hashedPassword)
err = u.db.Save(&localUser).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domain.User{}, err
}
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), 12)
localUser.PasswordResetCode = string(hashedCode)
err = u.db.Save(&localUser).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
var _ gateways.IUserRepository = &UserRepository{}
@@ -46,11 +46,19 @@
</button>
</div>
</form>
<form action="register" method="get" class="mt-2 w-100">
<button class="btn btn-link w-100" type="submit">
Don't have any verified account? Register here.
</button>
</form>
<div class="d-flex gap-2 my-2 w-100">
<a href="register" class="btn btn-outline-secondary w-100" type="submit">
First setup? Register
</a>
<a href="register/reset/request" class="btn btn-outline-secondary w-100" type="submit">
Forgot password
</a>
</div>
{{ if .Success }}
<div class="alert alert-success my-4" role="alert">
{{ .Success }}
</div>
{{ end }}
</div>
</div>
@@ -63,8 +71,12 @@
return Array.from(inputs).some((input) => input.value === "")
}
function isFormValid() {
return !formFieldsEmpty() && validateEmail()
}
function setButtonDisabled() {
button.disabled = formFieldsEmpty() ? "disabled" : ""
button.disabled = isFormValid() ? "disabled" : ""
}
function setButtonLoading() {
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Password reset</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 m-auto form-container">
<div>
<h1>GoCMS</h1>
<h2>Admin account password reset</h2>
</div>
<form action="request" method="POST" class="mt-5" id="registerForm">
<div class="d-flex flex-column gap-4">
<div class="form-floating">
<input class="form-control {{ if .PageError.Email }} is-invalid {{ end }}"
id="email"
name="email"
placeholder="E-mail"
required
type="email"
value="{{.Email}}"
onblur="validateEmail()"
{{ if .Email }} disabled {{ end }}>
<label for="email">E-mail</label>
</div>
<button id="registerFormButton" class="btn btn-primary" disabled type="submit">
<span class="registerFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="registerFormButtonDefault">Send e-mail</span>
</button>
</div>
</form>
{{ if .Success }}
<div class="alert alert-success my-4" role="alert">
{{ .Success }}
</div>
{{ end }}
</div>
</div>
<script>
const docElems = {
form: document.querySelector("#registerForm"),
button: document.querySelector("#registerFormButton"),
inputs: document.querySelectorAll("input"),
email: document.querySelector("#email"),
}
function formSomeFieldsEmpty() {
return Array.from(docElems.inputs).some((input) => input.value === "")
}
function setButtonLoading() {
docElems.button.classList.add("disabled")
docElems.button.querySelector(".registerFormButtonDefault").classList.add("visually-hidden")
docElems.button.querySelector(".registerFormButtonLoading").classList.remove("visually-hidden")
}
function onRegisterFormSubmit(event) {
event.preventDefault()
setButtonLoading()
if (formSomeFieldsEmpty()) return
event.target.submit()
}
function validateEmail() {
const email = docElems.email.value.trim()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
docElems.email.classList.add("is-invalid")
return false
} else {
docElems.email.classList.remove("is-invalid")
return true
}
}
function onInput(event) {
if (event.target.tagName === "INPUT") {
docElems.button.disabled = !validateEmail() || formSomeFieldsEmpty()
}
}
docElems.form.addEventListener('submit', onRegisterFormSubmit)
window.addEventListener('input', onInput)
</script>
</body>
</html>
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Password reset</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 m-auto form-container">
<div>
<h1>GoCMS</h1>
<h2>Admin account password reset</h2>
</div>
<form action="validate" method="POST" class="mt-5" id="registerForm">
<div class="d-flex flex-column gap-4">
<div class="form-floating">
<input class="form-control"
id="code"
name="code"
placeholder="Code"
type="text"
value="{{.Code}}"
readonly>
<label for="code">Code</label>
</div>
<div class="form-floating">
<input class="form-control"
id="email"
name="email"
placeholder="E-mail"
type="email"
value="{{.Email}}"
readonly>
<label for="email">E-mail</label>
</div>
<div class="form-floating">
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
id="password"
name="password"
placeholder="Password"
required
type="password">
<label for="password">Password</label>
<div class="invalid-feedback">The password must contain at least 8 characters.</div>
</div>
<div class="form-floating">
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
id="confirmPassword"
name="confirmPassword"
placeholder="Password confirmation"
required
type="password"
onblur="validatePasswordMatch()"
oninput="validatePasswordMatch()">
<label for="confirmPassword">Password confirmation</label>
<div class="invalid-feedback">The passwords do not match.</div>
</div>
<button id="registerFormButton" class="btn btn-primary" disabled type="submit">
<span class="registerFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="registerFormButtonDefault">Confirm changes</span>
</button>
</div>
</form>
{{ if .Error.Message }}
<div class="alert alert-danger my-4" role="alert">
{{ .Error.Message }}
</div>
{{ end }}
</div>
</div>
<script>
const docElems = {
form: document.querySelector("#registerForm"),
button: document.querySelector("#registerFormButton"),
inputs: document.querySelectorAll("input"),
email: document.querySelector("#email"),
password: document.querySelector("#password"),
confirmPassword: document.querySelector("#confirmPassword"),
}
function formSomeFieldsEmpty() {
return Array.from(docElems.inputs).some((input) => input.value === "")
}
function setButtonLoading() {
docElems.button.classList.add("disabled")
docElems.button.querySelector(".registerFormButtonDefault").classList.add("visually-hidden")
docElems.button.querySelector(".registerFormButtonLoading").classList.remove("visually-hidden")
}
function onRegisterFormSubmit(event) {
event.preventDefault()
setButtonLoading()
if (formSomeFieldsEmpty()) return
event.target.submit()
}
function validateEmail() {
const email = docElems.email.value.trim()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
docElems.email.classList.add("is-invalid")
return false
} else {
docElems.email.classList.remove("is-invalid")
return true
}
}
function onInput(event) {
if (event.target.tagName === "INPUT") {
docElems.button.disabled = !validateEmail() || formSomeFieldsEmpty()
}
}
function validatePasswordMatch() {
if (docElems.password.value !== docElems.confirmPassword.value) {
docElems.confirmPassword.classList.add("is-invalid")
return false
} else {
docElems.confirmPassword.classList.remove("is-invalid")
return true
}
}
docElems.form.addEventListener('submit', onRegisterFormSubmit)
window.addEventListener('input', onInput)
</script>
</body>
</html>
+17
View File
@@ -105,30 +105,47 @@ func NewPageRouter() http.Handler {
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
r.Get("/", GetLogin)
r.Get(LoginRoute, GetLoginPageHandler(EmptyLoginPage))
r.Post(LoginRoute, GetLoginPageHandler(EmptyLoginPage))
r.Get("/register", GetRegisterPageHandler(EmptyRegisterPage))
r.Post("/register", GetRegisterPageHandler(EmptyRegisterPage))
r.Get("/logout", GetLogout)
r.Get("/register/reset/request", GetPasswordResetRequest)
r.Post("/register/reset/request", PostPasswordResetRequest)
r.Get("/register/reset/validate", GetPasswordResetValidate)
r.Post("/register/reset/validate", PostPasswordResetValidate)
r.Group(func(r chi.Router) {
r.Use(IsLoggedInMiddleware)
r.Use(IsNotVerifiedMiddleware)
r.Get("/register/pending", GetRegisterPendingPage)
r.Post("/register/pending", PostRegisterPendingPage)
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/{id}/edit", GetPostEditPage)
r.Post("/post/{id}/edit", PostPostEditPage)
r.Get("/post/{id}/delete", GetPostDeletePage)
r.Get("/post/create", GetPostCreatePage)
r.Post("/post/create", PostPostCreatePage)
r.Post("/post/{id}/image/create", postImage)
})
+4 -4
View File
@@ -2,6 +2,7 @@ package api
import (
"net/http"
"net/url"
)
const LoginRoute = "/login"
@@ -30,14 +31,13 @@ func GetLoginPageHandler(loginPage *LoginPage) http.HandlerFunc {
http.Redirect(w, r, "/register", http.StatusSeeOther)
return
}
bs, err := Container.GetPageUseCase.GetPage("login", map[string]interface{}{
success, _ := url.QueryUnescape(r.URL.Query().Get("success"))
bs, _ := Container.GetPageUseCase.GetPage("login", map[string]interface{}{
"PageError": loginPage.PageError,
"Username": loginPage.Username,
"Head": headTmpl,
"Success": success,
})
if err != nil {
_, _ = w.Write([]byte(err.Error()))
}
_, _ = w.Write(bs)
}
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"github.com/google/uuid"
"net/http"
"os"
)
type PasswordResetRequest struct {
Email string `json:"email"`
}
var successMessage = "An email has been sent to the provided email address if it exists."
func GetPasswordResetRequest(w http.ResponseWriter, r *http.Request) {
success := r.URL.Query().Get("success")
email := r.URL.Query().Get("email")
bs, _ := Container.GetPageUseCase.GetPage("passwordResetRequest", map[string]interface{}{
"Head": headTmpl,
"Email": email,
"Success": success,
})
_, _ = w.Write(bs)
}
func PostPasswordResetRequest(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
passResetReq := PasswordResetRequest{
Email: r.FormValue("email"),
}
var getRedirectUrl = "/register/reset/request?success=" + successMessage + "&email=" + passResetReq.Email
err := validate.Struct(passResetReq)
if err != nil {
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
return
}
fetchedUser, err := Container.GetUserUseCase.GetUserByEmail(passResetReq.Email)
if err != nil {
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
return
}
verificationCode := uuid.NewString()
updatedUser, _ := Container.UpdateUserUseCase.UpdatePasswordResetCode(fetchedUser.ID, verificationCode)
_ = Container.SendMailUseCase.SendMail(updatedUser.Email, "passwordReset", map[string]string{
"Host": os.Getenv("HOST"),
"VerificationCode": verificationCode,
"Email": updatedUser.Email,
})
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"golang.org/x/crypto/bcrypt"
"net/http"
)
var PasswordLinkErrorMessage = "The reset password link you used is invalid."
var PasswordLinkSuccessMessage = "Your account password was successfully updated."
func GetPasswordResetValidate(w http.ResponseWriter, r *http.Request) {
failure := r.URL.Query().Get("failure")
pageError := NewPageError("")
if failure != "" {
pageError = NewPageError(failure)
}
template, _ := Container.GetPageUseCase.GetPage("passwordResetValidate", map[string]interface{}{
"Head": headTmpl,
"Error": pageError,
"Email": r.URL.Query().Get("email"),
"Code": r.URL.Query().Get("c"),
})
_, _ = w.Write(template)
}
func PostPasswordResetValidate(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
email := r.FormValue("email")
password := r.FormValue("password")
resetCode := r.FormValue("code")
var redirectionErrorLink = "/register/reset/validate?email=" + email + "&c=" + resetCode
if len(password) < 8 {
http.Redirect(w, r, redirectionErrorLink+"&failure=Password length should be at least 8 characters.", http.StatusSeeOther)
return
}
fetchedUser, err := Container.GetUserUseCase.GetUserByEmail(email)
if err != nil {
http.Redirect(w, r, redirectionErrorLink+"&failure="+PasswordLinkErrorMessage, http.StatusSeeOther)
return
}
err = bcrypt.CompareHashAndPassword([]byte(fetchedUser.PasswordResetCode), []byte(resetCode))
if err != nil {
http.Redirect(w, r, redirectionErrorLink+"&failure="+PasswordLinkErrorMessage, http.StatusSeeOther)
return
}
_, _ = Container.UpdateUserUseCase.UpdatePassword(fetchedUser.ID, password)
_, _ = Container.UpdateUserUseCase.UpdatePasswordResetCode(fetchedUser.ID, "")
http.Redirect(w, r, "/login?success="+PasswordLinkSuccessMessage, http.StatusSeeOther)
}
+3
View File
@@ -5,8 +5,11 @@ import "GoCMS/domain/user"
type IUserRepository interface {
Get(id uint32) (user.User, error)
GetByUsername(username string) (user.User, error)
GetByEmail(email string) (user.User, error)
GetAll() []user.User
Create(user user.User) (user.User, error)
Delete(id uint32) error
UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error)
UpdatePassword(userId uint32, password string) (user.User, error)
UpdatePasswordResetCode(userId uint32, code string) (user.User, error)
}
+5 -5
View File
@@ -5,11 +5,11 @@ import (
)
type Image struct {
ID uint32
Path string
PostID uint32
CreatedAt time.Time
UpdatedAt time.Time
ID uint32 `json:"id"`
Path string `json:"path"`
PostID uint32 `json:"post_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func FromDB(id uint32, path string, postId uint32, createdAt time.Time, updatedAt time.Time) Image {
+8 -3
View File
@@ -8,10 +8,11 @@ type User struct {
ID uint32 `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
PasswordResetCode string `json:"password_reset_code"`
Email string `json:"email"`
IsVerified bool `gorm:"default=false;not null"`
VerificationCode string `gorm:"unique;not null"`
VerificationExpiration time.Time `gorm:"not null"`
IsVerified bool `json:"is_verified"`
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -19,6 +20,7 @@ type User struct {
func FromApi(
username string,
password string,
passwordResetCode string,
email string,
verificationCode string,
) User {
@@ -26,6 +28,7 @@ func FromApi(
return User{
Username: username,
Password: password,
PasswordResetCode: passwordResetCode,
Email: email,
IsVerified: false,
VerificationCode: verificationCode,
@@ -37,6 +40,7 @@ func FromDb(
id uint32,
username string,
password string,
passwordResetCode string,
email string,
isVerified bool,
verificationCode string,
@@ -48,6 +52,7 @@ func FromDb(
ID: id,
Username: username,
Password: password,
PasswordResetCode: passwordResetCode,
Email: email,
IsVerified: isVerified,
VerificationCode: verificationCode,
+1
View File
@@ -27,6 +27,7 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User,
return g.userRepository.Create(user.FromApi(
createUser.Username,
createUser.Password,
"",
createUser.Email,
createUser.VerificationCode,
))
+4
View File
@@ -23,3 +23,7 @@ func (g *GetUserUseCase) GetUser(id uint32) (user.User, error) {
func (g *GetUserUseCase) GetUserByUsername(username string) (user.User, error) {
return g.userRepository.GetByUsername(username)
}
func (g *GetUserUseCase) GetUserByEmail(email string) (user.User, error) {
return g.userRepository.GetByEmail(email)
}
+8
View File
@@ -19,3 +19,11 @@ func NewUpdateUserUseCase(db *gorm.DB) *UpdateUserUseCase {
func (g *UpdateUserUseCase) UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) {
return g.userRepository.UpdateVerificationStatus(userId, isVerified)
}
func (g *UpdateUserUseCase) UpdatePasswordResetCode(userId uint32, code string) (user.User, error) {
return g.userRepository.UpdatePasswordResetCode(userId, code)
}
func (g *UpdateUserUseCase) UpdatePassword(userId uint32, password string) (user.User, error) {
return g.userRepository.UpdatePassword(userId, password)
}