diff --git a/database/user.go b/database/user.go index 5a1ddfd..0519b29 100644 --- a/database/user.go +++ b/database/user.go @@ -52,6 +52,20 @@ func AmNewPasswordChangeRequest(uid int32, username string, email string) *Passw return &rc } +/* AmGetPasswordChangeRequest retrieves the password change request for a UID. + * Parameters: + * uid - The UID to retrieve the request for. + * Returns: + * The PasswordChangeRequest pointer, or nil. + */ +func AmGetPasswordChangeRequest(uid int32) *PasswordChangeRequest { + rc := passwordRequests[uid] + if rc != nil { + delete(passwordRequests, uid) + } + return rc +} + // User represents a user in the Amsterdam database. type User struct { Mutex sync.RWMutex @@ -167,13 +181,31 @@ func (u *User) NewEmailConfirmationNumber() error { u.Mutex.Lock() defer u.Mutex.Unlock() newnum := util.GenerateRandomConfirmationNumber() - _, err := amdb.Exec("UPDATE user SET email_confnum = ? WHERE uid = ?", newnum, u.Uid) + _, err := amdb.Exec("UPDATE users SET email_confnum = ? WHERE uid = ?", newnum, u.Uid) if err != nil { u.EmailConfNum = newnum } return err } +// ChangePassword resets a user's password. +func (u *User) ChangePassword(password string, remoteIP string) error { + var ar *AuditRecord = nil + defer func() { + AmStoreAudit(ar) + }() + + u.Mutex.Lock() + defer u.Mutex.Unlock() + pval := hashPassword(password) + _, err := amdb.Exec("UPDATE users SET passhash = ? WHERE uid = ?", pval, u.Uid) + if err == nil { + u.Passhash = pval + ar = AmNewAudit(AuditChangePassword, u.Uid, remoteIP, "via password change request") + } + return err +} + /* AmGetUser returns a reference to the specified user. * Parameters: * uid - The UID of the user. diff --git a/login.go b/login.go index ea9df52..13c9c6a 100644 --- a/login.go +++ b/login.go @@ -13,10 +13,12 @@ import ( "errors" "fmt" "net/url" + "time" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/email" "git.erbosoft.com/amy/amsterdam/ui" + "git.erbosoft.com/amy/amsterdam/util" "github.com/labstack/gommon/log" ) @@ -394,3 +396,44 @@ func NewAccount(ctxt ui.AmContext) (string, any, error) { } return ui.ErrorPage(ctxt, err) } + +func PasswordRecovery(ctxt ui.AmContext) (string, any, error) { + var emailaddy string + uid, err := ctxt.URLParamInt("uid") + if err == nil { + auth, err := ctxt.URLParamInt("auth") + if err == nil { + pchange := database.AmGetPasswordChangeRequest(int32(uid)) + if pchange == nil { + return ui.ErrorPage(ctxt, errors.New("password change request not found")) + } + if auth != int(pchange.Authentication) { + return ui.ErrorPage(ctxt, errors.New("invalid password change request")) + } + if time.Now().Compare(pchange.Expires) > 0 { + return ui.ErrorPage(ctxt, errors.New("password change request has expired")) + } + emailaddy = pchange.Email + } + } + + if err == nil { + user, err := database.AmGetUser(int32(uid)) + if err == nil { + newpass := util.GenerateRandomPassword() + err = user.ChangePassword(newpass, ctxt.RemoteIP()) + if err == nil { + // send the password change message + msg := email.AmNewEmailMessage(user.Uid, ctxt.RemoteIP()) + msg.AddTo(emailaddy, "") + msg.SetTemplate("pass_change.jet") + msg.AddVariable("username", user.Username) + msg.AddVariable("password", newpass) + msg.Send() + ctxt.VarMap().Set("amsterdam_pageTitle", "Your Password Has Been Changed") + return "framed_template", "password_changed.jet", nil + } + } + } + return ui.ErrorPage(ctxt, err) +} diff --git a/main.go b/main.go index e43531e..8b44178 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,7 @@ func setupEcho() *echo.Echo { e.GET("/newacct2", ui.AmWrap(NewAccountForm)) e.GET("/verify", ui.AmWrap(VerifyEmailForm)) e.POST("/verify", ui.AmWrap(VerifyEMail)) + e.GET("/passrecovery/:uid/:auth", ui.AmWrap(PasswordRecovery)) return e } diff --git a/ui/amcontext.go b/ui/amcontext.go index 39665b1..1134614 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -47,6 +47,8 @@ type AmContext interface { SetRC(int) GetScratch(string) any SetScratch(string, any) + URLParam(string) string + URLParamInt(string) (int, error) URLPath() string VarMap() jet.VarMap } @@ -228,6 +230,7 @@ func (c *amContext) SetRC(rc int) { c.httprc = rc } +// GetScratch returns a value in the per-request scratchpad. func (c *amContext) GetScratch(name string) any { if c.scratchpad == nil { return nil @@ -235,6 +238,7 @@ func (c *amContext) GetScratch(name string) any { return c.scratchpad[name] } +// SetScratch sets a value in the per-request scratchpad. func (c *amContext) SetScratch(name string, val any) { if c.scratchpad == nil { c.scratchpad = make(map[string]any) @@ -242,6 +246,16 @@ func (c *amContext) SetScratch(name string, val any) { c.scratchpad[name] = val } +// URLParam returns the value of a URL parameter. +func (c *amContext) URLParam(name string) string { + return c.echoContext.Param(name) +} + +// URLParamINt returns the value of a URL parameter parsed as an integer. +func (c *amContext) URLParamInt(name string) (int, error) { + return strconv.Atoi(c.echoContext.Param(name)) +} + // URLPath returns the path component of the request URL. func (c *amContext) URLPath() string { return c.echoContext.Request().URL.Path diff --git a/ui/views/password_changed.jet b/ui/views/password_changed.jet new file mode 100644 index 0000000..e1c37fb --- /dev/null +++ b/ui/views/password_changed.jet @@ -0,0 +1,22 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *} +
+
+
+

Your Password Has Been Changed

+
+ The password for your account has been changed, and a new password has been E-mailed to you at + your account's defined E-mail address. (Check your E-mail again!). After getting the new password, + please log in and change your password (yes, again!) as soon as possible. +

+ Click here to return to the home page. +

+
+
+
diff --git a/util/random.go b/util/random.go index 9842abb..c5b6801 100644 --- a/util/random.go +++ b/util/random.go @@ -14,6 +14,7 @@ import ( crand "crypto/rand" "io" mrand "math/rand" + "strings" ) // authAlphabet is the set of characters from which we generate auth strings. @@ -22,6 +23,32 @@ const authAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv // authStringLen is the standard lengtth of authentication strings. const authStringLen = 32 +// syllabary is used to generate random passwords. +var syllabary = [...]string{ + "ba", + "be", + "bi", "bo", "bu", + "da", "de", "di", "do", "du", + "cha", "chi", "cho", "chu", + "fa", "fe", "fi", "fo", "fu", + "ga", "ge", "gi", "go", "gu", + "ha", "he", "hi", "ho", "hu", + "ja", "je", "ji", "jo", "ju", + "ka", "ke", "ki", "ko", "ku", + "la", "le", "li", "lo", "lu", + "ma", "me", "mi", "mo", "mu", + "na", "ne", "ni", "no", "nu", + "pa", "pe", "pi", "po", "pu", + "ra", "re", "ri", "ro", "ru", + "sa", "se", "si", "so", "su", + "sha", "she", "sho", "shu", + "ta", "te", "ti", "to", "tu", + "va", "ve", "vi", "vo", "vu", + "wa", "we", "wi", "wo", "wu", + "ya", "ye", "yi", "yo", "yu", + "za", "ze", "zi", "zo", "zu", +} + // GenerateRandomAuthString generates a random authentication string. func GenerateRandomAuthString() string { b := make([]byte, authStringLen) @@ -39,3 +66,20 @@ func GenerateRandomAuthString() string { func GenerateRandomConfirmationNumber() int32 { return mrand.Int31n(9000000) + 1000000 } + +// GenerateRandomPassword generates a random password string. +func GenerateRandomPassword() string { + var b strings.Builder + rd := make([]byte, 7) + if _, err := io.ReadFull(crand.Reader, rd); err != nil { + // can't happen (at least on a modern OS) + panic("failed to read random: " + err.Error()) + } + for i := 0; i < 4; i++ { // add random syllables + b.WriteString(syllabary[int(rd[i])%len(syllabary)]) + } + for i := 4; i < 7; i++ { // add random digits + b.WriteByte(byte('0' + int(rd[i])%10)) + } + return b.String() +}