diff --git a/login.go b/login.go
index af883d7..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 {
@@ -58,33 +119,57 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
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]any{"error": err.Error()})
- } else {
- apiToken := AddyAPIToken(r.PostFormValue("login"))
+ 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]any{"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) {
diff --git a/main.go b/main.go
index b455f7a..32a1124 100644
--- a/main.go
+++ b/main.go
@@ -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")
}
@@ -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)
diff --git a/static.go b/static.go
index 7713995..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 'self' 'sha256-W6z8OR2iqpPyNGe72eRXH58H75H3UVJDuwHoKA6pX98=' https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com; worker-src blob:")
+
+ 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 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())
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!