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:
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)