mirror of
https://github.com/1f349/tulip.git
synced 2024-12-22 00:04:09 +00:00
Start adding a reset password system
This commit is contained in:
parent
ed1b3a7b1c
commit
a72e659f88
@ -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 }
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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>
|
||||
|
32
pages/reset-password.go.html
Normal file
32
pages/reset-password.go.html
Normal 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>
|
@ -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))
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user