Start adding a reset password system

This commit is contained in:
Melon 2023-09-29 16:37:23 +01:00
parent ed1b3a7b1c
commit a72e659f88
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
9 changed files with 195 additions and 15 deletions

View File

@ -15,7 +15,6 @@ type User struct {
Sub uuid.UUID `json:"sub"`
Name string `json:"name,omitempty"`
Username string `json:"username"`
Password string `json:"password"`
Picture NullStringScanner `json:"picture,omitempty"`
Website NullStringScanner `json:"website,omitempty"`
Email string `json:"email"`
@ -104,6 +103,11 @@ func (u *UserPatch) ParseFromForm(v url.Values) (safeErrs []error) {
return
}
type ClientInfoDbOutput struct {
Sub, Name, Secret, Domain, Owner string
SSO, Active bool
}
var _ oauth2.ClientInfo = &ClientInfoDbOutput{}
func (c *ClientInfoDbOutput) GetID() string { return c.Sub }

View File

@ -49,13 +49,14 @@ func (t *Tx) InsertUser(name, un, pw, email string, role UserRole, active bool)
func (t *Tx) CheckLogin(un, pw string) (*User, bool, bool, error) {
var u User
var pwHash password.HashString
var hasOtp, hasVerify bool
row := t.tx.QueryRow(`SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject), email, email_verified FROM users WHERE username = ?`, un)
err := row.Scan(&u.Sub, &u.Password, &hasOtp, &u.Email, &hasVerify)
err := row.Scan(&u.Sub, &pwHash, &hasOtp, &u.Email, &hasVerify)
if err != nil {
return nil, false, false, err
}
err = password.CheckPasswordHash(u.Password, pw)
err = password.CheckPasswordHash(pwHash, pw)
return &u, hasOtp, hasVerify, err
}
@ -76,8 +77,8 @@ func (t *Tx) GetUserRole(sub uuid.UUID) (UserRole, error) {
func (t *Tx) GetUser(sub uuid.UUID) (*User, error) {
var u User
row := t.tx.QueryRow(`SELECT name, username, password, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, updated_at, active FROM users WHERE subject = ?`, sub.String())
err := row.Scan(&u.Name, &u.Username, &u.Password, &u.Picture, &u.Website, &u.Email, &u.EmailVerified, &u.Pronouns, &u.Birthdate, &u.ZoneInfo, &u.Locale, &u.UpdatedAt, &u.Active)
row := t.tx.QueryRow(`SELECT name, username, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, updated_at, active FROM users WHERE subject = ?`, sub.String())
err := row.Scan(&u.Name, &u.Username, &u.Picture, &u.Website, &u.Email, &u.EmailVerified, &u.Pronouns, &u.Birthdate, &u.ZoneInfo, &u.Locale, &u.UpdatedAt, &u.Active)
u.Sub = sub
return &u, err
}
@ -94,7 +95,7 @@ func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error {
if err != nil {
return err
}
var pwHash string
var pwHash password.HashString
if q.Next() {
err = q.Scan(&pwHash)
if err != nil {
@ -275,7 +276,27 @@ func (t *Tx) VerifyUserEmail(sub uuid.UUID) error {
return err
}
type ClientInfoDbOutput struct {
Sub, Name, Secret, Domain, Owner string
SSO, Active bool
func (t *Tx) UserResetPassword(sub uuid.UUID, pw string) error {
hashPassword, err := password.HashPassword(pw)
if err != nil {
return err
}
exec, err := t.tx.Exec(`UPDATE users SET password = ?, updated_at = ? WHERE subject = ?`, hashPassword, updatedAt(), sub.String())
if err != nil {
return err
}
affected, err := exec.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("row wasn't updated")
}
return nil
}
func (t *Tx) UserEmailExists(email string) (exists bool, err error) {
row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = ? and email_verified = 1)`, email)
err = row.Scan(&exists)
return
}

View File

@ -26,7 +26,7 @@ func TestTx_ChangeUserPassword(t *testing.T) {
query, err := d.db.Query(`SELECT password FROM users WHERE subject = ? AND username = ?`, u.String(), "test")
assert.NoError(t, err)
assert.True(t, query.Next())
var oldPw string
var oldPw password.HashString
assert.NoError(t, query.Scan(&oldPw))
assert.NoError(t, password.CheckPasswordHash(oldPw, "new"))
assert.NoError(t, query.Err())

View File

@ -26,6 +26,16 @@
</div>
<button type="submit">Login</button>
</form>
<form method="POST" action="/reset-password">
<p>Enter your email address below to receive an email with instructions on how to reset your password.</p>
<p>Please note this only works if your email address is already verified.</p>
<div>
<label for="field_email">Email:</label>
<input type="email" name="email" id="field_email" required/>
</div>
<button type="submit">Send Reset Password Email</button>
</form>
</main>
</body>
</html>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main>
<form method="POST" action="/mail/password">
<div>
<label for="field_new_password">New Password:</label>
<input type="password"
name="new_password"
id="field_new_password"
autocomplete="new_password"
required/>
</div>
<div>
<label for="field_confirm_password">Confirm Password:</label>
<input type="password"
name="confirm_password"
id="field_confirm_password"
autocomplete="confirm_password"
required/>
</div>
<button type="submit">Login</button>
</form>
</main>
</body>
</html>

View File

@ -1,12 +1,17 @@
package password
import "golang.org/x/crypto/bcrypt"
import (
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
// HashString is used to represent a string containing a password hash
type HashString string
func HashPassword(password string) (HashString, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
return HashString(bytes), err
}
func CheckPasswordHash(hash, password string) error {
func CheckPasswordHash(hash HashString, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}

View File

@ -121,3 +121,28 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http
h.SafeRedirect(rw, req)
}
func (h *HttpServer) LoginResetPasswordPost(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
email := req.PostFormValue("email")
address, err := mail.ParseAddress(email)
if err != nil || address.Name != "" {
http.Error(rw, "Invalid email address format", http.StatusBadRequest)
return
}
var emailExists bool
if h.DbTx(rw, func(tx *database.Tx) (err error) {
emailExists, err = tx.UserEmailExists(email)
return err
}) {
return
}
go h.possiblySendPasswordResetEmail(email, emailExists)
http.Error(rw, "An email will be send to your inbox if an account with that email address is found", http.StatusOK)
}
func (h *HttpServer) possiblySendPasswordResetEmail(email string, exists bool) {
// TODO(Melon): Send reset password email template
}

View File

@ -2,7 +2,9 @@ package server
import (
"github.com/1f349/tulip/database"
"github.com/1f349/tulip/pages"
"github.com/emersion/go-message/mail"
"github.com/go-session/session"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"net/http"
@ -35,7 +37,87 @@ func (h *HttpServer) MailVerify(rw http.ResponseWriter, req *http.Request, param
}
func (h *HttpServer) MailPassword(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
http.Error(rw, "Reset password is not functional yet", http.StatusNotImplemented)
code := params.ByName("code")
parse, err := uuid.Parse(code)
if err != nil {
http.Error(rw, "Invalid password reset code", http.StatusBadRequest)
return
}
k := mailLinkKey{mailLinkResetPassword, parse}
userSub, ok := h.mailLinkCache.Get(k)
if !ok {
http.Error(rw, "Invalid password reset code", http.StatusBadRequest)
return
}
h.mailLinkCache.Delete(k)
ss, err := session.Start(req.Context(), rw, req)
if err != nil {
http.Error(rw, "Error loading session", http.StatusInternalServerError)
return
}
ss.Set("mail-reset-password-user", userSub)
err = ss.Save()
if err != nil {
http.Error(rw, "Error saving session", http.StatusInternalServerError)
return
}
pages.RenderPageTemplate(rw, "reset-password", map[string]any{
"ServiceName": h.serviceName,
})
}
func (h *HttpServer) MailPasswordPost(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
pw := req.PostFormValue("new_password")
rpw := req.PostFormValue("confirm_password")
// reverse passwords are possible
if len(pw) == 0 {
http.Error(rw, "Cannot set an empty password", http.StatusBadRequest)
return
}
// bcrypt only allows up to 72 bytes anyway
if len(pw) > 64 {
http.Error(rw, "Security by extremely long password is a weird flex", http.StatusBadRequest)
return
}
if rpw != pw {
http.Error(rw, "Passwords do not match", http.StatusBadRequest)
return
}
// start session
ss, err := session.Start(req.Context(), rw, req)
if err != nil {
http.Error(rw, "Error loading session", http.StatusInternalServerError)
return
}
// get user to reset password for from session
userRaw, found := ss.Get("mail-reset-password-user")
if !found {
http.Error(rw, "Invalid password reset code", http.StatusBadRequest)
return
}
userSub, ok := userRaw.(uuid.UUID)
if !ok {
http.Error(rw, "Invalid password reset code", http.StatusBadRequest)
return
}
// reset password database call
if h.DbTx(rw, func(tx *database.Tx) error {
return tx.UserResetPassword(userSub, pw)
}) {
return
}
http.Error(rw, "Reset password successfully, you can login now.", http.StatusOK)
}
func (h *HttpServer) MailDelete(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {

View File

@ -170,6 +170,7 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, mailer mail.Ma
// mail codes
r.GET("/mail/verify/:code", hs.MailVerify)
r.GET("/mail/password/:code", hs.MailPassword)
r.POST("/mail/password", hs.MailPasswordPost)
r.GET("/mail/delete/:code", hs.MailDelete)
// edit profile pages