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 @@
+
+
+