Initial version.

This commit is contained in:
Nick Penkov
2018-01-26 12:38:19 +01:00
parent 29be16c897
commit 173d059c82
10 changed files with 522 additions and 0 deletions

12
Dockerfile Normal file
View File

@ -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" ]

21
Makefile Normal file
View File

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

31
README.md Normal file
View File

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

130
app/ldap.go Normal file
View File

@ -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"),
}
}

42
app/util.go Normal file
View File

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

113
app/web.go Normal file
View File

@ -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.<br/>"
} else {
un = username[0]
}
if len(oldPassword) < 1 || oldPassword[0] == "" {
alerts["error"] = alerts["error"] + "Old password not specified.<br/>"
}
if len(newPassword) < 1 || newPassword[0] == "" {
alerts["error"] = alerts["error"] + "New password not specified.<br/>"
}
if len(confirmPassword) < 1 || confirmPassword[0] == "" {
alerts["error"] = alerts["error"] + "Confirmation password not specified.<br/>"
}
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.<br/>"
}
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)
}
}

18
main.go Normal file
View File

@ -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)
}

BIN
screenshots/index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

112
static/style.css Normal file
View File

@ -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;
}
}

43
templates/index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<h1>{{.Title}}</h1>
<form method="post">
<label for="username">Username</label>
<input id="username" name="username" value="{{.Username}}" type="text" required autofocus>
<label for="old-password">Old password</label>
<input id="old-password" name="old-password" type="password" required>
<label for="new-password">New password</label>
<input id="new-password" name="new-password" type="password"
pattern=".{8,}" x-moz-errormessage="Password must be at least 8 characters long." required>
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" name="confirm-password" type="password"
pattern=".{8,}" x-moz-errormessage="Password must be at least 8 characters long." required>
<button type="submit">Update password</button>
</form>
<div class="alerts">
{{ range $key, $value := .Alerts }}
<div class="alert {{ $key }}">{{ $value }}</div>
{{ end }}
</div>
</main>
</body>
</html>