diff --git a/addy.go b/addy.go
index c2878e1..7a01e9a 100644
--- a/addy.go
+++ b/addy.go
@@ -12,6 +12,7 @@ import (
"log"
"net/http"
"os"
+ "slices"
"strings"
)
@@ -134,14 +135,7 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Alias creation is not configured", http.StatusServiceUnavailable)
return
}
- domainAllowed := false
- for _, d := range allowedAliasDomains {
- if body.Domain == d {
- domainAllowed = true
- break
- }
- }
- if !domainAllowed {
+ if !slices.Contains(allowedAliasDomains, body.Domain) {
http.Error(w, "Domain not allowed", http.StatusBadRequest)
return
}
diff --git a/change.go b/change.go
index 0a9e7e6..d915aba 100644
--- a/change.go
+++ b/change.go
@@ -33,7 +33,7 @@ func checkPasswdConstraint(password string) error {
func changePassword(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) {
csrfToken, _ := setCSRFToken(w)
- displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken})
+ displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]any{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken})
return
}
@@ -43,25 +43,25 @@ func changePassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
- displayTmpl(w, "change.html", map[string]interface{}{"csrf_token": csrfToken})
+ displayTmpl(w, "change.html", map[string]any{"csrf_token": csrfToken})
return
}
if !validateCSRF(r) {
csrfToken, _ := setCSRFToken(w)
- displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken})
+ displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken})
return
}
if !validateAltcha(r) {
csrfToken, _ := setCSRFToken(w)
- displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken})
+ displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken})
return
}
renderError := func(status int, msg string) {
csrfToken, _ := setCSRFToken(w)
- displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken})
+ displayTmplError(w, status, "change.html", map[string]any{"error": msg, "csrf_token": csrfToken})
}
// Check the two new passwords are identical
diff --git a/login.go b/login.go
index 44ffe66..63af618 100644
--- a/login.go
+++ b/login.go
@@ -2,8 +2,6 @@ package main
import (
"fmt"
- "html"
- "html/template"
"log"
"net/http"
"net/url"
@@ -12,6 +10,69 @@ import (
"github.com/go-ldap/ldap/v3"
)
+type profileField struct {
+ Name string
+ Label string
+ Value string
+}
+
+type profileAlias struct {
+ Value string
+ URLSafe string
+ ElemID string
+ Token string
+}
+
+var ldapLabels = map[string]string{
+ "cn": "Full name",
+ "uid": "Username",
+ "givenName": "First name",
+ "sn": "Last name",
+ "displayName": "Display name",
+ "telephoneNumber": "Phone",
+ "mobile": "Mobile",
+ "employeeNumber": "Employee ID",
+ "o": "Organization",
+ "ou": "Department",
+ "title": "Title",
+ "description": "Description",
+ "labeledURI": "Website",
+}
+
+// ldapSkip lists attributes that should never be shown to the user.
+var ldapSkip = map[string]bool{
+ "userPassword": true,
+ "krbPrincipalKey": true,
+ "objectClass": true,
+ "entryUUID": true,
+ "entryDN": true,
+ "structuralObjectClass": true,
+ "hasSubordinates": true,
+ "krbExtraData": true,
+}
+
+// isGeneratedAlias returns true for auto-generated alias local parts:
+// exactly 10 characters and containing at least one digit or uppercase letter,
+// which distinguishes them from plain words like "postmaster" or "abonnement".
+func isGeneratedAlias(local string) bool {
+ if len(local) != 10 {
+ return false
+ }
+ for _, c := range local {
+ if c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' {
+ return true
+ }
+ }
+ return false
+}
+
+func ldapLabel(name string) string {
+ if l, ok := ldapLabels[name]; ok {
+ return l
+ }
+ return name
+}
+
func login(login string, password string) ([]*ldap.EntryAttribute, error) {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
@@ -44,47 +105,71 @@ func login(login string, password string) ([]*ldap.EntryAttribute, error) {
func tryLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
- displayTmpl(w, "login.html", map[string]interface{}{})
+ displayTmpl(w, "login.html", map[string]any{})
return
}
if !authLimiter.Allow(remoteIP(r)) {
- displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]interface{}{"error": "Too many login attempts. Please try again later."})
+ displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]any{"error": "Too many login attempts. Please try again later."})
return
}
if !validateAltcha(r) {
- displayTmplError(w, http.StatusForbidden, "login.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."})
+ displayTmplError(w, http.StatusForbidden, "login.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."})
return
}
- if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil {
+ loginName := r.PostFormValue("login")
+ entries, err := login(loginName, r.PostFormValue("password"))
+ if err != nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()})
- } else {
- apiToken := AddyAPIToken(r.PostFormValue("login"))
+ displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]any{"error": err.Error()})
+ return
+ }
- cnt := "
"
- for _, e := range entries {
- for i, v := range e.Values {
- safeName := html.EscapeString(e.Name)
- safeVal := html.EscapeString(v)
- elemID := fmt.Sprintf("mailAlias-%d", i)
- if e.Name == "userPassword" || e.Name == "krbPrincipalKey" {
- cnt += "- " + safeName + ": [...]
"
- } else if e.Name == "mailAlias" && len(strings.SplitN(v, "@", 2)[0]) == 10 {
- safeURL := url.PathEscape(v)
- safeToken := html.EscapeString(apiToken)
- safeElemID := html.EscapeString(elemID)
- cnt += `- ` + safeName + `: ` + safeVal +
- `
`
- } else {
- cnt += "- " + safeName + ": " + safeVal + "
"
- }
+ apiToken := AddyAPIToken(loginName)
+ var fields []profileField
+ var emails []string
+ var aliases []profileAlias
+ aliasIdx := 0
+
+ for _, e := range entries {
+ if ldapSkip[e.Name] {
+ continue
+ }
+ for _, v := range e.Values {
+ switch {
+ case e.Name == "mail":
+ emails = append(emails, v)
+ case e.Name == "mailAlias" && isGeneratedAlias(strings.SplitN(v, "@", 2)[0]):
+ elemID := fmt.Sprintf("alias-%d", aliasIdx)
+ aliasIdx++
+ aliases = append(aliases, profileAlias{
+ Value: v,
+ URLSafe: url.PathEscape(v),
+ ElemID: elemID,
+ Token: apiToken,
+ })
+ case e.Name == "mailAlias":
+ emails = append(emails, v)
+ default:
+ fields = append(fields, profileField{
+ Name: e.Name,
+ Label: ldapLabel(e.Name),
+ Value: v,
+ })
}
}
- displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok
Here are the information we have about you:` + cnt + "
To use our Addy.io compatible API, use the following token: " + html.EscapeString(apiToken) + "
")})
}
+
+ displayTmpl(w, "profile.html", map[string]any{
+ "login": loginName,
+ "fields": fields,
+ "emails": emails,
+ "aliases": aliases,
+ "api_token": apiToken,
+ "card_wide": true,
+ })
}
func httpBasicAuth(w http.ResponseWriter, r *http.Request) {
@@ -107,7 +192,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) {
for _, e := range entries {
for _, v := range e.Values {
if e.Name != "userPassword" {
- w.Write([]byte(fmt.Sprintf("%s: %s", e.Name, v)))
+ fmt.Fprintf(w, "%s: %s", e.Name, v)
}
}
}
diff --git a/lost.go b/lost.go
index 1e4c9d3..7afe093 100644
--- a/lost.go
+++ b/lost.go
@@ -88,7 +88,7 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) {
func lostPassword(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && !lostLimiter.Allow(remoteIP(r)) {
- displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]interface{}{"error": "Too many requests. Please try again later."})
+ displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]any{"error": "Too many requests. Please try again later."})
return
}
@@ -98,17 +98,17 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
- displayTmpl(w, "lost.html", map[string]interface{}{"csrf_token": csrfToken})
+ displayTmpl(w, "lost.html", map[string]any{"csrf_token": csrfToken})
return
}
if !validateCSRF(r) {
- displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again."})
+ displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again."})
return
}
if !validateAltcha(r) {
- displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."})
+ displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."})
return
}
@@ -116,7 +116,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to process your request. Please try again later."})
+ displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to process your request. Please try again later."})
return
}
@@ -167,7 +167,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
s, err = d.Dial()
if err != nil {
log.Println("Unable to connect to email server: " + err.Error())
- displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."})
+ displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to send password recovery email. Please try again later."})
return
}
} else {
@@ -202,7 +202,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
if err := gomail.Send(s, m); err != nil {
log.Println("Unable to send email: " + err.Error())
- displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."})
+ displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to send password recovery email. Please try again later."})
return
}
diff --git a/main.go b/main.go
index 36ab61d..32a1124 100644
--- a/main.go
+++ b/main.go
@@ -5,7 +5,7 @@ import (
"encoding/json"
"flag"
"fmt"
- "io/ioutil"
+ "io"
"log"
"net/http"
"net/url"
@@ -19,6 +19,8 @@ import (
var myPublicURL = "https://ldap.nemunai.re"
var devMode bool
+var brandName = "chldapasswd"
+var brandLogo = ""
// dockerRegistrySecret is required for X-Special-Auth anonymous access.
// If empty, the feature is disabled.
@@ -82,10 +84,20 @@ func main() {
var configfile = flag.String("config", "", "path to the configuration file")
var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails")
var dev = flag.Bool("dev", false, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing")
+ var bname = flag.String("brand-name", "chldapasswd", "Brand name displayed in the UI")
+ var blogo = flag.String("brand-logo", "", "URL of brand logo displayed in the UI (added to CSP img-src)")
flag.Parse()
myPublicURL = *publicURL
devMode = *dev
+ brandName = *bname
+ brandLogo = *blogo
+ if val, ok := os.LookupEnv("BRAND_NAME"); ok {
+ brandName = val
+ }
+ if val, ok := os.LookupEnv("BRAND_LOGO"); ok {
+ brandLogo = val
+ }
if devMode {
log.Println("WARNING: running in development mode — security features relaxed, do not use in production")
}
@@ -104,7 +116,7 @@ func main() {
if configfile != nil && *configfile != "" {
if fd, err := os.Open(*configfile); err != nil {
log.Fatal(err)
- } else if cnt, err := ioutil.ReadAll(fd); err != nil {
+ } else if cnt, err := io.ReadAll(fd); err != nil {
log.Fatal(err)
} else if err := json.Unmarshal(cnt, &myLDAP); err != nil {
log.Fatal(err)
@@ -137,7 +149,7 @@ func main() {
if val, ok := os.LookupEnv("LDAP_SERVICE_PASSWORD_FILE"); ok {
if fd, err := os.Open(val); err != nil {
log.Fatal(err)
- } else if cnt, err := ioutil.ReadAll(fd); err != nil {
+ } else if cnt, err := io.ReadAll(fd); err != nil {
log.Fatal(err)
} else {
myLDAP.ServicePassword = string(cnt)
@@ -220,6 +232,7 @@ func main() {
// Register handlers
http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", *baseURL), serveAltchaJS)
+ http.HandleFunc(fmt.Sprintf("GET %s/style.css", *baseURL), serveStyleCSS)
http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", *baseURL), serveAltchaChallenge)
http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword)
http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI)
@@ -239,7 +252,8 @@ func main() {
go func() {
log.Fatal(srv.ListenAndServe())
}()
- log.Println(fmt.Sprintf("Ready, listening on %s", *bind))
+ log.Printf("Using LDAP server at %s:%d (baseDN: %s)", myLDAP.Host, myLDAP.Port, myLDAP.BaseDN)
+ log.Printf("Ready, listening on %s", *bind)
// Wait shutdown signal
<-interrupt
diff --git a/reset.go b/reset.go
index a37f99d..a65c27c 100644
--- a/reset.go
+++ b/reset.go
@@ -16,7 +16,7 @@ func resetPassword(w http.ResponseWriter, r *http.Request) {
return
}
- base := map[string]interface{}{
+ base := map[string]any{
"login": r.URL.Query().Get("l"),
"token": r.URL.Query().Get("t"),
}
diff --git a/static.go b/static.go
index 4946083..6c5f316 100644
--- a/static.go
+++ b/static.go
@@ -5,6 +5,8 @@ import (
"html/template"
"log"
"net/http"
+ "net/url"
+ "strings"
)
func securityHeaders(next http.Handler) http.Handler {
@@ -12,7 +14,16 @@ func securityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
- w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com")
+
+ imgSrc := "'self' data:"
+ if strings.HasPrefix(brandLogo, "http://") || strings.HasPrefix(brandLogo, "https://") {
+ if u, err := url.Parse(brandLogo); err == nil {
+ imgSrc += " " + u.Scheme + "://" + u.Host
+ }
+ }
+ csp := "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline'; style-src 'self' 'sha256-W6z8OR2iqpPyNGe72eRXH58H75H3UVJDuwHoKA6pX98='; img-src " + imgSrc + "; worker-src blob:"
+ w.Header().Set("Content-Security-Policy", csp)
+
if !devMode {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
@@ -23,7 +34,26 @@ func securityHeaders(next http.Handler) http.Handler {
//go:embed all:static
var assets embed.FS
-func displayTmpl(w http.ResponseWriter, page string, vars map[string]interface{}) {
+func serveStyleCSS(w http.ResponseWriter, r *http.Request) {
+ data, err := assets.ReadFile("static/style.css")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(data)
+}
+
+func displayTmpl(w http.ResponseWriter, page string, vars map[string]any) {
+ if vars == nil {
+ vars = map[string]any{}
+ }
+ vars["brand_name"] = brandName
+ if brandLogo != "" {
+ vars["brand_logo"] = brandLogo
+ }
+
data, err := assets.ReadFile("static/" + page)
if err != nil {
log.Fatalf("Unable to find %q: %s", page, err.Error())
@@ -45,7 +75,7 @@ func displayTmpl(w http.ResponseWriter, page string, vars map[string]interface{}
tpl.ExecuteTemplate(w, "page", vars)
}
-func displayTmplError(w http.ResponseWriter, statusCode int, page string, vars map[string]interface{}) {
+func displayTmplError(w http.ResponseWriter, statusCode int, page string, vars map[string]any) {
w.WriteHeader(statusCode)
displayTmpl(w, page, vars)
}
@@ -58,5 +88,5 @@ func displayMsg(w http.ResponseWriter, msg string, statusCode int) {
label = "message"
}
- displayTmpl(w, "message.html", map[string]interface{}{label: msg})
+ displayTmpl(w, "message.html", map[string]any{label: msg})
}
diff --git a/static/change.html b/static/change.html
index 424b171..5b6fcaf 100644
--- a/static/change.html
+++ b/static/change.html
@@ -1,49 +1,46 @@
-{{template "header"}}
- Change your password Fill the following fields!
+{{template "header" .}}
+ Change your password
+ Fill the following fields!