diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dea99b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM alpine:3.7 + +WORKDIR /app + +ADD ldap-pass-webui /app/ldap-pass-webui +ADD static /app/static +ADD templates /app/templates +RUN chmod +x /app/ldap-pass-webui + +EXPOSE 8080 + +ENTRYPOINT [ "/app/ldap-pass-webui" ] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..98e92d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +REPO=npenkov/docker-ldap-passwd-webui +VER=1.0 + +.PHONY: all build push + +all: build docker push clean + +build: + GOOS=linux go build -o ldap-pass-webui main.go + +docker: + @echo "Building docker image" + docker build -t ${REPO}:${VER} -t ${REPO}:latest . + +push: + @echo "Pushing to dockerhub" + docker push ${REPO}:${VER} + docker push ${REPO}:latest + +clean: + rm -rf ldap-pass-webui \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b19a63f --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Web UI for LDAP changing password + +WebUI Client capable of connecting to backend LDAP server and changing the users password. + +![Screenshot](screenshots/index.png) + +## Running in docker container + +```sh +docker run -d -p 8080:8080 --name ldap-passwd-webui \ + -e LPW_TITLE="Change your global password for example.org" \ + -e LPW_HOST="your_ldap_host" \ + -e LPW_PORT="636" \ + -e LPW_ENCRYPTED="true" \ + -e LPW_START_TLS="false" \ + -e LPW_SSL_SKIP_VERIFY="true" \ + -e LPW_USER_DN="uid=%s,ou=people,dc=example,dc=org" \ + -e LPW_USER_BASE="ou=people,dc=example,dc=org" \ + npenkov/docker-ldap-passwd-webui:latest +``` + +## Building and tagging + +```sh +make +``` + +## Credits + + * [Web UI for changing LDAP password - python](https://github.com/jirutka/ldap-passwd-webui) + * [Gitea](https://github.com/go-gitea/gitea) \ No newline at end of file diff --git a/app/ldap.go b/app/ldap.go new file mode 100644 index 0000000..e45abd8 --- /dev/null +++ b/app/ldap.go @@ -0,0 +1,130 @@ +package app + +import ( + "crypto/tls" + "fmt" + "log" + "strings" + + "gopkg.in/ldap.v2" +) + +// SecurityProtocol protocol type +type SecurityProtocol int + +// Note: new type must be added at the end of list to maintain compatibility. +const ( + SecurityProtocolUnencrypted SecurityProtocol = iota + SecurityProtocolLDAPS + SecurityProtocolStartTLS +) + +// LDAPClient Basic LDAP authentication service +type LDAPClient struct { + Name string // canonical name (ie. corporate.ad) + Host string // LDAP host + Port int // port number + SecurityProtocol SecurityProtocol + SkipVerify bool + UserBase string // Base search path for users + UserDN string // Template for the DN of the user for simple auth + Enabled bool // if this LDAPClient is disabled +} + +func bindUser(l *ldap.Conn, userDN, passwd string) error { + log.Printf("\nBinding with userDN: %s", userDN) + err := l.Bind(userDN, passwd) + if err != nil { + log.Printf("\nLDAP auth. failed for %s, reason: %v", userDN, err) + return err + } + log.Printf("\nBound successfully with userDN: %s", userDN) + return err +} + +func (ls *LDAPClient) sanitizedUserDN(username string) (string, bool) { + // See http://tools.ietf.org/search/rfc4514: "special characters" + badCharacters := "\x00()*\\,='\"#+;<>" + if strings.ContainsAny(username, badCharacters) { + log.Printf("\n'%s' contains invalid DN characters. Aborting.", username) + return "", false + } + + return fmt.Sprintf(ls.UserDN, username), true +} + +func dial(ls *LDAPClient) (*ldap.Conn, error) { + log.Printf("\nDialing LDAP with security protocol (%v) without verifying: %v", ls.SecurityProtocol, ls.SkipVerify) + + tlsCfg := &tls.Config{ + ServerName: ls.Host, + InsecureSkipVerify: ls.SkipVerify, + } + if ls.SecurityProtocol == SecurityProtocolLDAPS { + return ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", ls.Host, ls.Port), tlsCfg) + } + + conn, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", ls.Host, ls.Port)) + if err != nil { + return nil, fmt.Errorf("Dial: %v", err) + } + + if ls.SecurityProtocol == SecurityProtocolStartTLS { + if err = conn.StartTLS(tlsCfg); err != nil { + conn.Close() + return nil, fmt.Errorf("StartTLS: %v", err) + } + } + + return conn, nil +} + +// ModifyPassword : modify user's password +func (ls *LDAPClient) ModifyPassword(name, passwd, newPassword string) error { + if len(passwd) == 0 { + return fmt.Errorf("Auth. failed for %s, password cannot be empty", name) + } + l, err := dial(ls) + if err != nil { + ls.Enabled = false + return fmt.Errorf("LDAP Connect error, %s:%v", ls.Host, err) + } + defer l.Close() + + var userDN string + log.Printf("\nLDAP will bind directly via UserDN template: %s", ls.UserDN) + + var ok bool + userDN, ok = ls.sanitizedUserDN(name) + if !ok { + return fmt.Errorf("Error sanitizing name %s", name) + } + bindUser(l, userDN, passwd) + + log.Printf("\nLDAP will execute password change on: %s", userDN) + req := ldap.NewPasswordModifyRequest(userDN, passwd, newPassword) + _, err = l.PasswordModify(req) + + return err +} + +// NewLDAPClient : Creates new LDAPClient capable of binding and changing passwords +func NewLDAPClient() *LDAPClient { + + securityProtocol := SecurityProtocolUnencrypted + if envBool("LPW_ENCRYPTED", true) { + securityProtocol = SecurityProtocolLDAPS + if envBool("LPW_START_TLS", false) { + securityProtocol = SecurityProtocolStartTLS + } + } + + return &LDAPClient{ + Host: envStr("LPW_HOST", ""), + Port: envInt("LPW_PORT", 636), // 389 + SecurityProtocol: securityProtocol, + SkipVerify: envBool("LPW_SSL_SKIP_VERIFY", false), + UserDN: envStr("LPW_USER_DN", "uid=%s,ou=people,dc=example,dc=org"), + UserBase: envStr("LPW_USER_BASE", "ou=people,dc=example,dc=org"), + } +} diff --git a/app/util.go b/app/util.go new file mode 100644 index 0000000..187ef06 --- /dev/null +++ b/app/util.go @@ -0,0 +1,42 @@ +package app + +import ( + "os" + "strconv" +) + +func getTitle() string { + return envStr("LPW_TITLE", "Change your password on example.org") +} + +func envStr(key, defaultValue string) string { + val := os.Getenv(key) + if val != "" { + return val + } + return defaultValue +} + +func envInt(key string, defaultValue int) int { + val := os.Getenv(key) + if val != "" { + i, err := strconv.Atoi(val) + if err != nil { + return defaultValue + } + return i + } + return defaultValue +} + +func envBool(key string, defaultValue bool) bool { + val := os.Getenv(key) + if val != "" { + b, err := strconv.ParseBool(val) + if err != nil { + return defaultValue + } + return b + } + return defaultValue +} diff --git a/app/web.go b/app/web.go new file mode 100644 index 0000000..c8a2ae0 --- /dev/null +++ b/app/web.go @@ -0,0 +1,113 @@ +package app + +import ( + "fmt" + "log" + "path" + "strings" + + "html/template" + + "regexp" + + "net/http" +) + +type route struct { + pattern *regexp.Regexp + verb string + handler http.Handler +} + +type RegexpHandler struct { + routes []*route +} + +func (h *RegexpHandler) Handler(pattern *regexp.Regexp, verb string, handler http.Handler) { + h.routes = append(h.routes, &route{pattern, verb, handler}) +} + +func (h *RegexpHandler) HandleFunc(r string, v string, handler func(http.ResponseWriter, *http.Request)) { + re := regexp.MustCompile(r) + h.routes = append(h.routes, &route{re, v, http.HandlerFunc(handler)}) +} + +func (h *RegexpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + for _, route := range h.routes { + if route.pattern.MatchString(r.URL.Path) && route.verb == r.Method { + route.handler.ServeHTTP(w, r) + return + } + } + http.NotFound(w, r) +} + +type pageData struct { + Title string + Username string + Alerts map[string]string +} + +// ServeAssets : Serves the static assets +func ServeAssets(w http.ResponseWriter, req *http.Request) { + http.ServeFile(w, req, path.Join("static", req.URL.Path[1:])) +} + +// ServeIndex : Serves index page on GET request +func ServeIndex(w http.ResponseWriter, req *http.Request) { + p := &pageData{Title: getTitle()} + t, e := template.ParseFiles(path.Join("templates", "index.html")) + if e != nil { + log.Printf("Error parsing file %v\n", e) + } else { + t.Execute(w, p) + } +} + +// ChangePassword : Serves index page on POST request - executes the change +func ChangePassword(w http.ResponseWriter, req *http.Request) { + req.ParseForm() + un := "" + username := req.Form["username"] + oldPassword := req.Form["old-password"] + newPassword := req.Form["new-password"] + confirmPassword := req.Form["confirm-password"] + + alerts := map[string]string{} + + if len(username) < 1 || username[0] == "" { + alerts["error"] = "Username not specified.
" + } else { + un = username[0] + } + if len(oldPassword) < 1 || oldPassword[0] == "" { + alerts["error"] = alerts["error"] + "Old password not specified.
" + } + if len(newPassword) < 1 || newPassword[0] == "" { + alerts["error"] = alerts["error"] + "New password not specified.
" + } + if len(confirmPassword) < 1 || confirmPassword[0] == "" { + alerts["error"] = alerts["error"] + "Confirmation password not specified.
" + } + + if len(confirmPassword) >= 1 && len(newPassword) >= 1 && strings.Compare(newPassword[0], confirmPassword[0]) != 0 { + alerts["error"] = alerts["error"] + "New and confirmation passwords does not match.
" + } + if len(alerts) == 0 { + client := NewLDAPClient() + if err := client.ModifyPassword(un, oldPassword[0], newPassword[0]); err != nil { + alerts["error"] = fmt.Sprintf("%v", err) + } else { + alerts["success"] = "Password successfuly changed" + } + } + + p := &pageData{Title: getTitle(), Alerts: alerts, Username: un} + + t, e := template.ParseFiles(path.Join("templates", "index.html")) + if e != nil { + log.Printf("Error parsing file %v\n", e) + } else { + t.Execute(w, p) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3babe99 --- /dev/null +++ b/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "github.com/npenkov/ldap-passwd-webui/app" + "net/http" +) + +func main() { + reHandler := new(app.RegexpHandler) + + reHandler.HandleFunc(".*.[js|css|png|eof|svg|ttf|woff]", "GET", app.ServeAssets) + reHandler.HandleFunc("/", "GET", app.ServeIndex) + reHandler.HandleFunc("/", "POST", app.ChangePassword) + + fmt.Println("Starting server on port 8080") + http.ListenAndServe(":8080", reHandler) +} diff --git a/screenshots/index.png b/screenshots/index.png new file mode 100644 index 0000000..a4fd472 Binary files /dev/null and b/screenshots/index.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..83b0889 --- /dev/null +++ b/static/style.css @@ -0,0 +1,112 @@ +/* TODO make it cooler! */ + +body { + font-family: sans-serif; + color: #333; + } + + main { + margin: 0 auto; + } + + h1 { + font-size: 2em; + margin-bottom: 2.5em; + margin-top: 2em; + text-align: center; + } + + form { + border-radius: 0.2rem; + border: 1px solid #CCC; + margin: 0 auto; + max-width: 16rem; + padding: 2rem 2.5rem 1.5rem 2.5rem; + } + + input { + background-color: #FAFAFA; + border-radius: 0.2rem; + border: 1px solid #CCC; + box-shadow: inset 0 1px 3px #DDD; + box-sizing: border-box; + display: block; + font-size: 1em; + padding: 0.4em 0.6em; + vertical-align: middle; + width: 100%; + } + + input:focus { + background-color: #FFF; + border-color: #51A7E8; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075) inset, 0 0 5px rgba(81, 167, 232, 0.5); + outline: 0; + } + + label { + color: #666; + display: block; + font-size: 0.9em; + font-weight: bold; + margin: 1em 0 0.25em 0; + } + + button { + background-color: #60B044; + background-image: linear-gradient(#8ADD6D, #60B044); + border-radius: 0.2rem; + border: 1px solid #5CA941; + box-sizing: border-box; + color: #fff; + cursor: pointer; + display: block; + font-size: 0.9em; + font-weight: bold; + margin: 2em 0 0.5em 0; + padding: 0.5em 0.7em; + text-align: center; + text-decoration: none; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3); + user-select: none; + vertical-align: middle; + white-space: nowrap; + } + + button:focus, + button:hover { + background-color: #569E3D; + background-image: linear-gradient(#79D858, #569E3D); + border-color: #4A993E; + } + + .alerts { + margin: 2rem auto 0 auto; + max-width: 30rem; + } + + .alert { + border-radius: 0.2rem; + border: 1px solid; + color: #fff; + padding: 0.7em 1.5em; + } + + .alert.error { + background-color: #E74C3C; + border-color: #C0392B; + } + + .alert.success { + background-color: #60B044; + border-color: #5CA941; + } + + + @media only screen and (max-width: 480px) { + + form { + border: 0; + } + } + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c9509ae --- /dev/null +++ b/templates/index.html @@ -0,0 +1,43 @@ + + + + + + + + + {{.Title}} + + + + + +
+

{{.Title}}

+ +
+ + + + + + + + + + + + + +
+ +
+ {{ range $key, $value := .Alerts }} +
{{ $value }}
+ {{ end }} +
+
+ +