mirror of
https://github.com/1f349/tulip.git
synced 2024-12-22 16:24:10 +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"`
|
Sub uuid.UUID `json:"sub"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
|
||||||
Picture NullStringScanner `json:"picture,omitempty"`
|
Picture NullStringScanner `json:"picture,omitempty"`
|
||||||
Website NullStringScanner `json:"website,omitempty"`
|
Website NullStringScanner `json:"website,omitempty"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@ -104,6 +103,11 @@ func (u *UserPatch) ParseFromForm(v url.Values) (safeErrs []error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientInfoDbOutput struct {
|
||||||
|
Sub, Name, Secret, Domain, Owner string
|
||||||
|
SSO, Active bool
|
||||||
|
}
|
||||||
|
|
||||||
var _ oauth2.ClientInfo = &ClientInfoDbOutput{}
|
var _ oauth2.ClientInfo = &ClientInfoDbOutput{}
|
||||||
|
|
||||||
func (c *ClientInfoDbOutput) GetID() string { return c.Sub }
|
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) {
|
func (t *Tx) CheckLogin(un, pw string) (*User, bool, bool, error) {
|
||||||
var u User
|
var u User
|
||||||
|
var pwHash password.HashString
|
||||||
var hasOtp, hasVerify bool
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, false, false, err
|
return nil, false, false, err
|
||||||
}
|
}
|
||||||
err = password.CheckPasswordHash(u.Password, pw)
|
err = password.CheckPasswordHash(pwHash, pw)
|
||||||
return &u, hasOtp, hasVerify, err
|
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) {
|
func (t *Tx) GetUser(sub uuid.UUID) (*User, error) {
|
||||||
var u User
|
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())
|
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.Password, &u.Picture, &u.Website, &u.Email, &u.EmailVerified, &u.Pronouns, &u.Birthdate, &u.ZoneInfo, &u.Locale, &u.UpdatedAt, &u.Active)
|
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
|
u.Sub = sub
|
||||||
return &u, err
|
return &u, err
|
||||||
}
|
}
|
||||||
@ -94,7 +95,7 @@ func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var pwHash string
|
var pwHash password.HashString
|
||||||
if q.Next() {
|
if q.Next() {
|
||||||
err = q.Scan(&pwHash)
|
err = q.Scan(&pwHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -275,7 +276,27 @@ func (t *Tx) VerifyUserEmail(sub uuid.UUID) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientInfoDbOutput struct {
|
func (t *Tx) UserResetPassword(sub uuid.UUID, pw string) error {
|
||||||
Sub, Name, Secret, Domain, Owner string
|
hashPassword, err := password.HashPassword(pw)
|
||||||
SSO, Active bool
|
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")
|
query, err := d.db.Query(`SELECT password FROM users WHERE subject = ? AND username = ?`, u.String(), "test")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, query.Next())
|
assert.True(t, query.Next())
|
||||||
var oldPw string
|
var oldPw password.HashString
|
||||||
assert.NoError(t, query.Scan(&oldPw))
|
assert.NoError(t, query.Scan(&oldPw))
|
||||||
assert.NoError(t, password.CheckPasswordHash(oldPw, "new"))
|
assert.NoError(t, password.CheckPasswordHash(oldPw, "new"))
|
||||||
assert.NoError(t, query.Err())
|
assert.NoError(t, query.Err())
|
||||||
|
@ -26,6 +26,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</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>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
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)
|
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))
|
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)
|
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 (
|
import (
|
||||||
"github.com/1f349/tulip/database"
|
"github.com/1f349/tulip/database"
|
||||||
|
"github.com/1f349/tulip/pages"
|
||||||
"github.com/emersion/go-message/mail"
|
"github.com/emersion/go-message/mail"
|
||||||
|
"github.com/go-session/session"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"net/http"
|
"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) {
|
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) {
|
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
|
// mail codes
|
||||||
r.GET("/mail/verify/:code", hs.MailVerify)
|
r.GET("/mail/verify/:code", hs.MailVerify)
|
||||||
r.GET("/mail/password/:code", hs.MailPassword)
|
r.GET("/mail/password/:code", hs.MailPassword)
|
||||||
|
r.POST("/mail/password", hs.MailPasswordPost)
|
||||||
r.GET("/mail/delete/:code", hs.MailDelete)
|
r.GET("/mail/delete/:code", hs.MailDelete)
|
||||||
|
|
||||||
// edit profile pages
|
// edit profile pages
|
||||||
|
Loading…
Reference in New Issue
Block a user