access-list: Hash basic-auth passwords and redact API reads (#18)
Closes #17. - bcrypt-hash access-list item passwords on create/update - redact password fields on API reads - legacy plaintext still authenticates until re-saved - gofmt api.go and engine.go
This commit was merged in pull request #18.
This commit is contained in:
+63
-4
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"helix-proxy/internal/auth"
|
"helix-proxy/internal/auth"
|
||||||
"helix-proxy/internal/certificate"
|
"helix-proxy/internal/certificate"
|
||||||
@@ -107,7 +108,7 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
|||||||
}
|
}
|
||||||
_ = claims
|
_ = claims
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(st.GetAccessLists())
|
_ = json.NewEncoder(w).Encode(redactAccessLists(st.GetAccessLists()))
|
||||||
})
|
})
|
||||||
r.Post("/access-lists", func(w http.ResponseWriter, r *http.Request) {
|
r.Post("/access-lists", func(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, ok := auth.RequireAuth(w, r)
|
claims, ok := auth.RequireAuth(w, r)
|
||||||
@@ -122,13 +123,17 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
|||||||
if payload.OwnerUserID == 0 {
|
if payload.OwnerUserID == 0 {
|
||||||
payload.OwnerUserID = claims.UserID
|
payload.OwnerUserID = claims.UserID
|
||||||
}
|
}
|
||||||
|
if err := hashAccessListPasswords(&payload); err != nil {
|
||||||
|
http.Error(w, "password hash failed", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
created, err := st.CreateAccessList(payload)
|
created, err := st.CreateAccessList(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_ = json.NewEncoder(w).Encode(created)
|
_ = json.NewEncoder(w).Encode(redactAccessList(created))
|
||||||
eng.ReloadFromStore()
|
eng.ReloadFromStore()
|
||||||
_, _ = st.AddAuditLog(store.AuditLog{
|
_, _ = st.AddAuditLog(store.AuditLog{
|
||||||
UserID: claims.UserID,
|
UserID: claims.UserID,
|
||||||
@@ -153,7 +158,7 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if al, ok := st.GetAccessList(id); ok {
|
if al, ok := st.GetAccessList(id); ok {
|
||||||
_ = json.NewEncoder(w).Encode(al)
|
_ = json.NewEncoder(w).Encode(redactAccessList(al))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "not found", 404)
|
http.Error(w, "not found", 404)
|
||||||
@@ -187,13 +192,29 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
|||||||
payload.OwnerUserID = existing.OwnerUserID
|
payload.OwnerUserID = existing.OwnerUserID
|
||||||
// preserve CreatedOn on update (server-set on create; do not lose on edit)
|
// preserve CreatedOn on update (server-set on create; do not lose on edit)
|
||||||
payload.CreatedOn = existing.CreatedOn
|
payload.CreatedOn = existing.CreatedOn
|
||||||
|
// Empty password on an item means "keep existing" when username matches.
|
||||||
|
for i := range payload.Items {
|
||||||
|
if payload.Items[i].Password != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, prev := range existing.Items {
|
||||||
|
if prev.Username == payload.Items[i].Username && prev.Password != "" {
|
||||||
|
payload.Items[i].Password = prev.Password
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := hashAccessListPasswords(&payload); err != nil {
|
||||||
|
http.Error(w, "password hash failed", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
updated, err := st.UpdateAccessList(payload)
|
updated, err := st.UpdateAccessList(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
eng.ReloadFromStore()
|
eng.ReloadFromStore()
|
||||||
_ = json.NewEncoder(w).Encode(updated)
|
_ = json.NewEncoder(w).Encode(redactAccessList(updated))
|
||||||
_, _ = st.AddAuditLog(store.AuditLog{
|
_, _ = st.AddAuditLog(store.AuditLog{
|
||||||
UserID: claims.UserID,
|
UserID: claims.UserID,
|
||||||
ObjectType: "access-list",
|
ObjectType: "access-list",
|
||||||
@@ -1281,3 +1302,41 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hashAccessListPasswords(al *store.AccessList) error {
|
||||||
|
for i := range al.Items {
|
||||||
|
pw := al.Items[i].Password
|
||||||
|
if pw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(pw, "$2a$") || strings.HasPrefix(pw, "$2b$") || strings.HasPrefix(pw, "$2y$") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
al.Items[i].Password = string(h)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactAccessList(al store.AccessList) store.AccessList {
|
||||||
|
out := al
|
||||||
|
out.Items = make([]store.AccessItem, len(al.Items))
|
||||||
|
copy(out.Items, al.Items)
|
||||||
|
for i := range out.Items {
|
||||||
|
if out.Items[i].Password != "" {
|
||||||
|
out.Items[i].Password = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactAccessLists(list []store.AccessList) []store.AccessList {
|
||||||
|
out := make([]store.AccessList, len(list))
|
||||||
|
for i, al := range list {
|
||||||
|
out[i] = redactAccessList(al)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -349,7 +350,7 @@ func (e *Engine) Handler() http.Handler {
|
|||||||
user, pass := creds[0], creds[1]
|
user, pass := creds[0], creds[1]
|
||||||
authed := false
|
authed := false
|
||||||
for _, item := range hcfg.AccessItems {
|
for _, item := range hcfg.AccessItems {
|
||||||
if item.Username == user && item.Password == pass { // demo: plain compare; real would hash
|
if item.Username == user && accessListPasswordOK(item.Password, pass) {
|
||||||
authed = true
|
authed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1250,3 +1251,14 @@ func parseAdvancedConfig(adv string) (reqSets map[string]string, respAdds map[st
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func accessListPasswordOK(stored, provided string) bool {
|
||||||
|
if stored == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(provided)) == nil
|
||||||
|
}
|
||||||
|
// Legacy plaintext rows until re-saved via admin API.
|
||||||
|
return stored == provided
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user