webui: select password hash, mask inputs, hover help on user forms

Add a Hash dropdown to the add-user and change-password forms — yescrypt
(default), SHA-512, SHA-256, MD5 — passed through to mkpasswd. These are
exactly the four hashes the infix-system:crypt-hash YANG type accepts;
unknown values fall back to the default. Password inputs switch to the
CSS-masked type=text used by keystore secrets, which keeps the browser
password manager from offering to save device credentials. Each add-user
field gets a YANG description as a hover tooltip.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-17 10:32:46 +02:00
parent bbc01a5e54
commit 322811d712
3 changed files with 128 additions and 28 deletions
+17 -3
View File
@@ -66,6 +66,8 @@ type cfgUsersPageData struct {
Groups []cfgGroupDisplay
Error string
ShellOptions []schema.IdentityOption
CryptMethods []CryptMethod
Desc map[string]string // YANG field descriptions for hover tooltips
}
// ─── Handler ─────────────────────────────────────────────────────────────────
@@ -101,7 +103,9 @@ func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request)
data.Error = "Could not read user configuration"
}
}
const shellPath = "/ietf-system:system/authentication/user/infix-system:shell"
const userPath = "/ietf-system:system/authentication/user"
const shellPath = userPath + "/infix-system:shell"
data.CryptMethods = CryptMethods
mgr := h.Schema.Manager()
data.Loading = mgr == nil
var defaultShell string
@@ -113,6 +117,16 @@ func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request)
break
}
}
data.Desc = map[string]string{
"username": schema.DescriptionOf(mgr, userPath+"/name"),
"password": schema.DescriptionOf(mgr, userPath+"/password"),
"shell": schema.DescriptionOf(mgr, shellPath),
// No YANG leaf models the hash algorithm (it's a property of the
// stored crypt-hash); describe the offered set, verified against
// the infix-system:crypt-hash typedef.
"crypt": "Password hashing algorithm. yescrypt (recommended) is the strongest; " +
"SHA-512, SHA-256, and MD5 are offered for compatibility.",
}
}
for _, u := range raw.System.Auth.Users {
// Effective shell: the user's explicit shell, else the YANG default.
@@ -185,7 +199,7 @@ func (h *ConfigureUsersHandler) AddUser(w http.ResponseWriter, r *http.Request)
return
}
hash, err := HashPassword(password)
hash, err := HashPassword(password, r.FormValue("crypt"))
if err != nil {
log.Printf("configure users add: hash: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -259,7 +273,7 @@ func (h *ConfigureUsersHandler) ChangePassword(w http.ResponseWriter, r *http.Re
return
}
hash, err := HashPassword(password)
hash, err := HashPassword(password, r.FormValue("crypt"))
if err != nil {
log.Printf("configure users password %q: hash: %v", name, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
+42 -5
View File
@@ -8,15 +8,52 @@ import (
"strings"
)
// HashPassword returns a yescrypt crypt hash of password using mkpasswd(1).
// mkpasswd is available on Infix target systems and uses the system's libcrypt,
// so the output matches whatever the device expects (default: yescrypt $y$).
func HashPassword(password string) (string, error) {
// CryptMethod is a password-hashing algorithm offered in the UI.
type CryptMethod struct {
Value string // form value and mkpasswd --method argument
Label string
Default bool
}
// CryptMethods is the single source of truth for the hashes the UI offers,
// ordered strongest first. Only the four the infix-system:crypt-hash YANG type
// accepts ($y$/$6$/$5$/$1$) are listed — mkpasswd supports more (bcrypt,
// scrypt, …), but the password leaf's pattern would reject those on save.
var CryptMethods = []CryptMethod{
{"yescrypt", "yescrypt (recommended)", true},
{"sha512crypt", "SHA-512", false},
{"sha256crypt", "SHA-256", false},
{"md5crypt", "MD5", false},
}
// normalizeCryptMethod returns method if it's one we offer, otherwise the
// default (the entry flagged Default). This guards an empty or tampered form
// value from reaching mkpasswd --method.
func normalizeCryptMethod(method string) string {
def := CryptMethods[0].Value
for _, m := range CryptMethods {
if m.Default {
def = m.Value
}
if m.Value == method {
return method
}
}
return def
}
// HashPassword returns a crypt hash of password using mkpasswd(1) with the
// given method. mkpasswd is available on Infix target systems and uses the
// system's libcrypt. An empty or unrecognised method (e.g. one the YANG type
// would reject) falls back to the default, so callers can pass the raw form
// value safely.
func HashPassword(password, method string) (string, error) {
method = normalizeCryptMethod(method)
path, err := exec.LookPath("mkpasswd")
if err != nil {
return "", fmt.Errorf("mkpasswd not found: %w", err)
}
cmd := exec.Command(path, "--method=yescrypt", "--password-fd=0")
cmd := exec.Command(path, "--method="+method, "--password-fd=0")
cmd.Stdin = strings.NewReader(password)
out, err := cmd.Output()
if err != nil {
+69 -20
View File
@@ -74,15 +74,32 @@
</div>
</form>
{{/* Change password */}}
<form hx-post="/configure/users/{{.Name}}/password" hx-swap="none">
{{/* Change password. type=text + CSS mask (cfg-input-mask)
dodges the browser password-manager save prompt, same as
the keystore secret fields. */}}
<form hx-post="/configure/users/{{.Name}}/password" hx-swap="none" autocomplete="off">
<div class="section-subtitle">Change Password</div>
<table class="info-table">
<tr>
<th>New password</th>
<td><input class="cfg-input" type="password" name="password"
autocomplete="new-password"
placeholder="Leave blank to keep current"></td>
<th>New password{{template "field-info" (index $.Desc "password")}}</th>
<td>
<span class="cfg-secret-wrap">
<input class="cfg-input cfg-input-mask" type="text" name="password"
id="chpw-{{.Name}}" placeholder="Leave blank to keep current"
autocomplete="off" spellcheck="false"
data-1p-ignore="true" data-lpignore="true"
data-bwignore="true" data-form-type="other">
<button type="button" class="btn-icon cfg-secret-toggle"
data-toggle-mask="chpw-{{.Name}}"
aria-label="Show / hide password">👁</button>
</span>
</td>
</tr>
<tr>
<th>Hash{{template "field-info" (index $.Desc "crypt")}}</th>
<td><select class="cfg-input" name="crypt">
{{range $.CryptMethods}}<option value="{{.Value}}" {{if .Default}}selected{{end}}>{{.Label}}</option>{{end}}
</select></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
@@ -147,23 +164,55 @@
</tr>
{{end}}
{{/* Hidden add-user row — revealed by the + Add user button */}}
{{/* Hidden add-user row — revealed by the + Add user button. The
password is a CSS-masked type=text (cfg-input-mask) to dodge the
browser password-manager save prompt, same as keystore secrets. */}}
<tr id="add-user-row" hidden>
<td colspan="4" class="key-detail-cell">
<form hx-post="/configure/users" hx-swap="none" class="user-add-inline"
autocomplete="off">
<input class="cfg-input" type="text" name="username" required
autocomplete="off"
pattern="[a-zA-Z0-9_][a-zA-Z0-9_.-]*" placeholder="Username">
<input class="cfg-input" type="password" name="password" required
autocomplete="new-password" placeholder="Password">
<select class="cfg-input" name="shell">
{{range .ShellOptions}}<option value="{{.Value}}" {{if .IsDefault}}selected{{end}}>{{.Label}}{{if .IsDefault}} (default){{end}}</option>{{end}}
</select>
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-user-row">Cancel</button>
<span class="cfg-save-status"></span>
<div class="key-detail-body">
<form hx-post="/configure/users" hx-swap="none" autocomplete="off">
<div class="section-subtitle">Add User</div>
<table class="info-table">
<tr>
<th>Username{{template "field-info" (index $.Desc "username")}}</th>
<td><input class="cfg-input" type="text" name="username" required autocomplete="off"
pattern="[a-zA-Z0-9_][a-zA-Z0-9_.-]*" placeholder="Username"></td>
</tr>
<tr>
<th>Password{{template "field-info" (index $.Desc "password")}}</th>
<td>
<span class="cfg-secret-wrap">
<input class="cfg-input cfg-input-mask" type="text" name="password"
id="add-user-password" required placeholder="Password"
autocomplete="off" spellcheck="false"
data-1p-ignore="true" data-lpignore="true"
data-bwignore="true" data-form-type="other">
<button type="button" class="btn-icon cfg-secret-toggle"
data-toggle-mask="add-user-password"
aria-label="Show / hide password">👁</button>
</span>
</td>
</tr>
<tr>
<th>Hash{{template "field-info" (index $.Desc "crypt")}}</th>
<td><select class="cfg-input" name="crypt">
{{range $.CryptMethods}}<option value="{{.Value}}" {{if .Default}}selected{{end}}>{{.Label}}</option>{{end}}
</select></td>
</tr>
<tr>
<th>Shell{{template "field-info" (index $.Desc "shell")}}</th>
<td><select class="cfg-input" name="shell">
{{range $.ShellOptions}}<option value="{{.Value}}" {{if .IsDefault}}selected{{end}}>{{.Label}}{{if .IsDefault}} (default){{end}}</option>{{end}}
</select></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-user-row">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</td>
</tr>