cerca

lean forum software (pmc local branch)
Log | Files | Refs | README | LICENSE

commit dc184167d3ed9cbfd29fd7b4688462cd58f56159
parent 7100d3b9af27dc6166e032852247f2c56f76e1eb
Author: cblgh <cblgh@cblgh.org>
Date:   Mon, 24 Oct 2022 11:48:48 +0200

merge i18n and change password branches

Diffstat:
Acmd/admin-reset/main.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrypto/crypto.go | 19+++++++++++++++++++
Mdatabase/database.go | 31++++++++++++++++++++++++++++++-
Ahtml/change-password-success.html | 12++++++++++++
Ahtml/change-password.html | 22++++++++++++++++++++++
Mhtml/head.html | 30+++++++++++++++---------------
Mhtml/thread.html | 2+-
Mi18n/i18n.go | 12++++++++++++
Mserver/server.go | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
9 files changed, 315 insertions(+), 27 deletions(-)

diff --git a/cmd/admin-reset/main.go b/cmd/admin-reset/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "cerca/crypto" + "cerca/database" + "cerca/util" + "flag" + "fmt" + "os" +) + +func inform(msg string, args ...interface{}) { + if len(args) > 0 { + fmt.Printf("admin-reset: %s\n", fmt.Sprintf(msg, args...)) + } else { + fmt.Printf("admin-reset: %s\n", msg) + } +} + +func complain(msg string, args ...interface{}) { + if len(args) > 0 { + inform(msg, args) + } else { + inform(msg) + } + os.Exit(0) +} + +func main() { + var keypairFlag bool + var passwordFlag bool + var username string + var dbPath string + flag.StringVar(&username, "username", "", "username whose credentials should be reset") + flag.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db") + flag.BoolVar(&keypairFlag, "keypair", false, "reset the keypair") + flag.BoolVar(&passwordFlag, "password", false, "reset the password. if true generates a random new password") + flag.Parse() + + usage := `usage + admin-reset --database ./data/forum.db --username <username to reset> [--keypair, --password] + admin-reset --help for more information + + examples: + # only reset the keypair, leaving the password intact + ./admin-reset --database ../../testdata/forum.db --username bambas --keypair + + # reset password only + ./admin-reset --database ../../testdata/forum.db --username bambas --password + + # reset both password and keypair + ./admin-reset --database ../../testdata/forum.db --username bambas --password --keypair + ` + + if username == "" { + complain(usage) + } + if !keypairFlag && !passwordFlag { + complain("nothing to reset, exiting") + } + + // check if database exists! we dont wanna create a new db in this case ':) + if !database.CheckExists(dbPath) { + complain("couldn't find database at %s", dbPath) + } + + db := database.InitDB(dbPath) + ed := util.Describe("admin reset") + + userid, err := db.GetUserID(username) + if err != nil { + complain("username %s not in database", username) + } + + // generate new password for user and set it in the database + if passwordFlag { + newPassword := crypto.GeneratePassword() + passwordHash, err := crypto.HashPassword(newPassword) + ed.Check(err, "hash new password") + db.UpdateUserPasswordHash(userid, passwordHash) + + inform("successfully updated %s's password hash", username) + inform("new temporary password %s", newPassword) + } + + // generate a new keypair for user and update user's pubkey record with new pubkey + if keypairFlag { + kp, err := crypto.GenerateKeypair() + ed.Check(err, "generate keypair") + kpBytes, err := kp.Marshal() + ed.Check(err, "marshal keypair") + pubkey, err := kp.PublicString() + ed.Check(err, "get pubkey string") + err = db.SetPubkey(userid, pubkey) + ed.Check(err, "set new pubkey in database") + + inform("successfully changed %s's stored public key", username) + inform("new keypair\n%s", string(kpBytes)) + } +} diff --git a/crypto/crypto.go b/crypto/crypto.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "github.com/synacor/argon2id" + "math/big" rand "math/rand" "os" "strings" @@ -163,6 +164,24 @@ func GenerateNonce() string { return fmt.Sprintf("%d%d", time.Now().Unix(), rnd.Intn(MaxInt)) } +// used for generating a random reset password +const characterSet = "abcdedfghijklmnopqrstABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +const pwlength = 20 + +func GeneratePassword() string { + var password strings.Builder + const maxChar = int64(len(characterSet)) + + for i := 0; i < pwlength; i++ { + max := big.NewInt(maxChar) + bigN, err := crand.Int(crand.Reader, max) + util.Check(err, "randomly generate int") + n := bigN.Int64() + password.WriteString(string(characterSet[n])) + } + return password.String() +} + type cryptoSource struct{} func (s cryptoSource) Seed(seed int64) {} diff --git a/database/database.go b/database/database.go @@ -20,8 +20,16 @@ type DB struct { db *sql.DB } -func InitDB(filepath string) DB { +func CheckExists(filepath string) bool { if _, err := os.Stat(filepath); errors.Is(err, os.ErrNotExist) { + return false + } + return true +} + +func InitDB(filepath string) DB { + exists := CheckExists(filepath) + if !exists { file, err := os.Create(filepath) if err != nil { log.Fatal(err) @@ -340,6 +348,16 @@ func (d DB) GetUserID(name string) (int, error) { return userid, nil } +func (d DB) GetUsername(uid int) (string, error) { + stmt := `SELECT name FROM users where id = ?` + var username string + err := d.db.QueryRow(stmt, uid).Scan(&username) + if err != nil { + return "", util.Eout(err, "get username") + } + return username, nil +} + func (d DB) GetPasswordHash(username string) (string, int, error) { stmt := `SELECT passwordhash, id FROM users where name = ?` var hash string @@ -405,6 +423,17 @@ func (d DB) AddPubkey(userid int, pubkey string) error { return nil } +func (d DB) SetPubkey(userid int, pubkey string) error { + ed := util.Describe("set pubkey") + // TODO (2022-09-27): the insertion order is still wrong >.< + stmt := `UPDATE pubkeys SET pubkey = ? WHERE userid = ? ` + _, err := d.Exec(stmt, userid, pubkey) + if err = ed.Eout(err, "updating record"); err != nil { + return err + } + return nil +} + func (d DB) GetPubkey(userid int) (pubkey string, err error) { ed := util.Describe("get pubkey") // due to a mishap in the query in AddPubkey the column `pubkey` contains the userid diff --git a/html/change-password-success.html b/html/change-password-success.html @@ -0,0 +1,12 @@ +{{ template "head" . }} +<h1>{{ "ChangePassword" | translate | capitalize }}</h1> +<p>{{ "PasswordResetSuccessMessage" | translate }}</p> + +{{ if ne .Data.Keypair "" }} +<p> {{ "RegisterKeypairWarning" | translate }}.</p> +<pre class="selectable"> +{{ .Data.Keypair }} +</pre> +{{ end }} +<p> {{ "RegisterLinkMessage" | translate }} <a href="/">{{ "Index" | translate }}</a>.</p> +{{ template "footer" . }} diff --git a/html/change-password.html b/html/change-password.html @@ -0,0 +1,22 @@ +{{ template "head" . }} +<h1> {{ "ChangePassword" | translate | capitalize }}</h1> +<p>{{ "ChangePasswordDescription" | translate }}</p> +<form method="post" action="{{.Data.Action}}"> + <div> + <label type="text" for="password-old">{{ "Current" | translate | capitalize }} {{ "Password" | translate }}:</label> + <input type="password" minlength="9" required id="password-old" name="password-old" aria-describedby="password-help"> + </div> + <div> + <label type="text" for="password-new">{{ "New" | translate | capitalize }} {{ "Password" | translate }}:</label> + <input type="password" style="margin-bottom: 0;" minlength="9" required id="password-new" name="password-new" aria-describedby="password-help"> + <div><small id="password-help">{{ "PasswordMin" | translate }}.</small></div> + </div> + <div> + <input type="checkbox" value="true" name ="reset-keypair" id="reset-keypair"> + <label for="reset-keypair" style="display: inline-block;">{{ "GenerateNewKeypair" | translate }}</label> + </div> + <div> + <input type="submit" value="Submit"> + </div> +</form> +{{ template "footer" . }} diff --git a/html/head.html b/html/head.html @@ -27,6 +27,9 @@ label { display: block; } + header svg { + fill: #ff8000; + } article h1, article h2, article h3 { /* normalize post titles */ font-size: 1rem; @@ -46,19 +49,19 @@ li { margin-bottom: 0rem; } ul { padding-left: 1rem; } h1, h2 { margin-bottom: 1rem; } - p { margin-bottom: 1rem; } - blockquote { padding-left: 1rem; border-left: 3px solid black } + p { margin-bottom: 1rem; color: #f2f2f2; } + blockquote { padding-left: 1rem; border-left: 3px solid wheat; } div { margin-bottom: 2rem; } - textarea { min-height: 10rem; } + textarea { min-height: 10rem; background: black; color: wheat; } article > section { margin-bottom: 0.5rem; } article { margin-bottom: 2rem; } form div, label { margin-bottom: 0; } h1 a:visited, a:not([class]) { - color: black; + color: wheat; } a:visited { - color: #666; /* for lack of better imagination */ + color: gray; /* #666; /* for lack of better imagination */ } .selectable { -webkit-touch-callout: all; @@ -83,7 +86,7 @@ width: 100%; } - body { padding: 2rem; background: wheat; } + body { padding: 2rem; background: #111; color: #ff8000; /*wheat;*/ } * { margin-bottom: 1rem; } .visually-hidden { @@ -135,7 +138,7 @@ margin-bottom: unset; } header details a:visited { - color: black; + color: #ff8000; } header details ul { position: absolute; @@ -144,6 +147,11 @@ display: block; } + /* post author name */ + span > b { + color: #ff8000; + } + @supports (display: flex) { header > a { background-image: none; @@ -184,14 +192,6 @@ <header> <a style="margin-bottom: 0; height: 48px;" href="/" aria-label='{{ "AriaHome" | translate }}'> {{ dumpLogo }} - <!-- - <svg aria-hidden="true" style="margin-bottom: 0;" width="48" height="48" fill="black" stroke="none" viewBox="60 60 200 200" xmlns="http://www.w3.org/2000/svg"> - <g> - <path d="M185,65 A60,60 0 0,0 125,125 L185,125 Z M125,245 A60,60 0 0,0 185,185 L125,185 Z M95,125 A30,30 0 0,1 125,155 A30,30 0 0,1 95,185 A30,30 0 0,1 65,155 A30,30 0 0,1 95,125 Z M155,125 A30,30 0 0,1 185,155 A30,30 0 0,1 155,185 A30,30 0 0,1 125,155 A30,30 0 0,1 155,125 Z M215,125 A30,30 0 0,1 245,155 A30,30 0 0,1 215,185 A30,30 0 0,1 185,155 A30,30 0 0,1 215,125 "/> - <path d="M125,65 A60,60 0 0,1 185,125 L125,125 Z M185,245 A60,60 0 0,1 125,185 L185,185 Z M65,65 A60,60 0 0,1 125,125 L65,125 Z M65,245 A60,60 0 0,0 125,185 L65,185 Z M245,65 A60,60 0 0,0 185,125 L245,125 Z M245,245 A60,60 0 0,1 185,185 L245,185 Z"/> - </g> - </svg> - --> </a> <nav> <ul type="menu"> diff --git a/html/thread.html b/html/thread.html @@ -20,7 +20,7 @@ <span class="visually-hidden"> {{ "Responded" | translate }}:</span> </span> <a href="#{{ $post.ID }}"> - <span style="margin-left: 0.5rem; font-style: italic;"> + <span style="margin-left: 0.5rem;"> <time datetime="{{ $post.Publish | formatDate }}">{{ $post.Publish | formatDateRelative }}</time> </span> </a> diff --git a/i18n/i18n.go b/i18n/i18n.go @@ -31,6 +31,10 @@ var English = map[string]string{ "LoginAlreadyLoggedIn": `You are already logged in. Would you like to <a href="/logout">log out</a>?`, "Username": "username", + "Current": "current", + "New": "new", + "GenerateNewKeypair": "I also want to generate a new keypair", + "ChangePasswordDescription": "Use this page to change your password. If needed, you can also regenerate your password reset keypair—used to reset a forgotten password without admin help.", "Password": "password", "PasswordMin": "Must be at least 9 characters long", "PasswordForgot": "Forgot your password?", @@ -132,6 +136,10 @@ var Swedish = map[string]string{ "LoginAlreadyLoggedIn": `Du är redan inloggad. Vill du <a href="/logout">logga ut</a>?`, "Username": "användarnamn", + "Current": "nuvarande", + "New": "nytt", + "GenerateNewKeypair": "Jag vill också generera ett nytt nyckelpar", + "ChangePasswordDescription": "På den här sidan kan du ändra ditt lösenord. Vid behov kan du också regenerera ditt nyckelpar—används för att nollställa ditt lösenord utan att be admin om hjälp.", "Password": "lösenord", "PasswordMin": "Måste vara minst 9 karaktärer långt", "PasswordForgot": "Glömt lösenordet?", @@ -233,6 +241,10 @@ var EspanolMexicano = map[string]string{ "LoginAlreadyLoggedIn": `You are already logged in. Would you like to <a href="/logout">log out</a>?`, "Username": "usuarie", + "Current": "current", + "New": "new", + "GenerateNewKeypair": "I also want to generate a new keypair", + "ChangePasswordDescription": "Use this page to change your password. If needed, you can also regenerate your password reset keypair—used to reset a forgotten password without admin help.", "Password": "contraseña", "PasswordMin": "Debe tener por lo menos 9 caracteres.", "PasswordForgot": "Olvidaste tu contraseña?", diff --git a/server/server.go b/server/server.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "syscall" "time" @@ -45,6 +46,11 @@ type PasswordResetData struct { Payload string } +type ChangePasswordData struct { + Action string + Keypair string +} + type IndexData struct { Threads []database.Thread } @@ -183,6 +189,8 @@ func generateTemplates(config types.Config, translator i18n.Translator) (*templa "register-success", "thread", "password-reset", + "change-password", + "change-password-success", } rootTemplate := template.New("root") @@ -254,9 +262,15 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) // TODO (2022-01-07): // * handle error thread := h.db.GetThread(threadid) + pattern := regexp.MustCompile("<img") // markdownize content (but not title) for i, post := range thread { - thread[i].Content = util.Markup(post.Content) + content := []byte(util.Markup(post.Content)) + // make sure images are lazy loaded + if pattern.Match(content) { + content = pattern.ReplaceAll(content, []byte(`<img loading="lazy"`)) + } + thread[i].Content = template.HTML(content) } data := ThreadData{Posts: thread, ThreadURL: req.URL.Path} view := TemplateData{Data: &data, QuickNav: loggedIn, LoggedIn: loggedIn, LoggedInID: userid} @@ -353,19 +367,99 @@ func hasVerificationCode(link, verification string) bool { return strings.Contains(strings.TrimSpace(linkBody), strings.TrimSpace(verification)) } +func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http.Request) { + // TODO (2022-10-24): add translations for change password view + title := h.translator.Translate("ChangePassword") + renderErr := func(errFmt string, args ...interface{}) { + errMessage := fmt.Sprintf(errFmt, args...) + fmt.Println(errMessage) + data := GenericMessageData{ + Title: title, + Message: errMessage, + Link: "/reset", + LinkText: h.translator.Translate("GoBack"), + } + h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + } + _, uid := h.IsLoggedIn(req) + + ed := util.Describe("change password") + switch req.Method { + case "GET": + switch req.URL.Path { + default: + h.renderView(res, "change-password", TemplateData{LoggedIn: true, Data: ChangePasswordData{Action: "/reset/submit"}}) + } + case "POST": + switch req.URL.Path { + case "/reset/submit": + oldPassword := req.PostFormValue("password-old") + newPassword := req.PostFormValue("password-new") + resetKeypair := (req.PostFormValue("reset-keypair") == "true") + var keypairString string + + // check if we're resetting keypair + if resetKeypair { + // if so: generate new keypair + kp, err := crypto.GenerateKeypair() + ed.Check(err, "generate keypair") + kpBytes, err := kp.Marshal() + ed.Check(err, "marshal keypair") + pubkey, err := kp.PublicString() + ed.Check(err, "get pubkey string") + // and set it in db + err = h.db.SetPubkey(uid, pubkey) + ed.Check(err, "set new pubkey in database") + keypairString = string(kpBytes) + } + + // check that the submitted, old password is valid + username, err := h.db.GetUsername(uid) + if err != nil { + dump(ed.Eout(err, "get username")) + return + } + + pwhashOld, _, err := h.db.GetPasswordHash(username) + if err != nil { + dump(ed.Eout(err, "get old password hash")) + return + } + + oldPasswordValid := crypto.ValidatePasswordHash(oldPassword, pwhashOld) + if !oldPasswordValid { + renderErr("old password did not match what was in database; not changing password") + return + } + + // let's set the new password in the database. first, hash it + pwhashNew, err := crypto.HashPassword(newPassword) + if err != nil { + dump(ed.Eout(err, "hash new password")) + return + } + // then save the hash + h.db.UpdateUserPasswordHash(uid, pwhashNew) + // render a success message & show a link to the login page :') + h.renderView(res, "change-password-success", TemplateData{LoggedIn: true, Data: ChangePasswordData{Keypair: keypairString}}) + default: + fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) + IndexRedirect(res, req) + } + default: + fmt.Println("non get/post method, redirecting to index") + IndexRedirect(res, req) + } +} + func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Request) { ed := util.Describe("password proof route") loggedIn, _ := h.IsLoggedIn(req) title := util.Capitalize(h.translator.Translate("PasswordReset")) + // change password functionality, handle this in another function if loggedIn { - data := GenericMessageData{ - Title: title, - Message: h.translator.Translate("PasswordResetMessage"), - Link: "/logout", - LinkText: util.Capitalize(h.translator.Translate("Logout")), - } - h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn, Title: title}) + h.handleChangePassword(res, req) return } @@ -378,7 +472,7 @@ func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Re Link: "/reset", LinkText: h.translator.Translate("GoBack"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: h.translator.Translate("PasswordReset")}) + h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) } switch req.Method { @@ -474,7 +568,7 @@ func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Re LinkMessage: h.translator.Translate("PasswordResetSuccessLinkMessage"), LinkText: h.translator.Translate("Login"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: h.translator.Translate("PasswordReset")}) + h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) default: fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) IndexRedirect(res, req)