From fc2455cdbf8c64c98a8f7104ae7e7acdcff1337c Mon Sep 17 00:00:00 2001 From: Markus Krogh Date: Fri, 8 Jun 2018 08:01:06 +0200 Subject: Go impl of pwman, first draft --- Dockerfile.golang | 17 +++ kdc.go | 65 ++++++++++ ldap.go | 227 +++++++++++++++++++++++++++++++++++ ldap_test.go | 97 +++++++++++++++ main.go | 85 +++++++++++++ middleware.go | 107 +++++++++++++++++ nginx-test/pwman.dev.conf | 9 +- pwned.go | 78 ++++++++++++ static/css/main.css | 290 +++++++++++++++++++++++++++++++++++++++++++++ static/favicon.ico | Bin 0 -> 18094 bytes static/images/favicon.ico | Bin 0 -> 18094 bytes static/images/ndn-bg1.png | Bin 0 -> 33778 bytes static/images/nordunet.png | Bin 0 -> 20823 bytes templates/change_ssh.html | 32 +++++ templates/changepw.html | 28 +++++ templates/index.html | 46 +++++++ templates/layout/base.html | 40 +++++++ utils.go | 18 +++ validator.go | 31 +++++ validator_test.go | 66 +++++++++++ views.go | 171 ++++++++++++++++++++++++++ 21 files changed, 1404 insertions(+), 3 deletions(-) create mode 100644 Dockerfile.golang create mode 100644 kdc.go create mode 100644 ldap.go create mode 100644 ldap_test.go create mode 100644 main.go create mode 100644 middleware.go create mode 100644 pwned.go create mode 100644 static/css/main.css create mode 100644 static/favicon.ico create mode 100644 static/images/favicon.ico create mode 100644 static/images/ndn-bg1.png create mode 100644 static/images/nordunet.png create mode 100644 templates/change_ssh.html create mode 100644 templates/changepw.html create mode 100644 templates/index.html create mode 100644 templates/layout/base.html create mode 100644 utils.go create mode 100644 validator.go create mode 100644 validator_test.go create mode 100644 views.go diff --git a/Dockerfile.golang b/Dockerfile.golang new file mode 100644 index 0000000..df1bf9d --- /dev/null +++ b/Dockerfile.golang @@ -0,0 +1,17 @@ +FROM golang:1.10 as build +WORKDIR /go/src/pwman +RUN go get -d -v gopkg.in/ldap.v2 github.com/gorilla/csrf gopkg.in/jcmturner/gokrb5.v5/client gopkg.in/jcmturner/gokrb5.v5/config +COPY *.go ./ + +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o pwman . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /opt +COPY --from=build /go/src/pwman/pwman /usr/local/bin/ +COPY create-kdc-principal.pl . +COPY krb5.conf . +COPY static static +COPY templates templates + +CMD ["pwman"] diff --git a/kdc.go b/kdc.go new file mode 100644 index 0000000..ebb1c04 --- /dev/null +++ b/kdc.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "gopkg.in/jcmturner/gokrb5.v5/client" + "gopkg.in/jcmturner/gokrb5.v5/config" + "os/exec" + "strings" +) + +var suffixMap map[string]string = map[string]string{ + "SSO": "", + "EDUROAM": "/ppp", + "TACACS": "/net", +} + +func CheckDuplicatePw(username, password string) error { + for suffix, _ := range suffixMap { + err := checkKerberosDuplicatePw(suffix, username, password) + if err != nil { + return err + } + } + return nil +} + +func checkKerberosDuplicatePw(suffix, username, password string) error { + principal := username + suffixMap[suffix] + + config, err := config.Load(pwman.Krb5Conf) + kclient := client.NewClientWithPassword(principal, "NORDU.NET", password) + kclient.WithConfig(config) + err = kclient.Login() + if err != nil { + // error either means bad password or no connection etc. + if strings.Contains(err.Error(), "KDC_ERR_PREAUTH_REQUIRED") { + // Password did not match + return nil + } + fmt.Println("ERROR", err) + return err + } + return fmt.Errorf("Password already used with: %s account", suffix) +} + +func ChangeKerberosPw(suffix, username, new_password string) error { + kerberos_uid := fmt.Sprintf("%s%s", username, suffixMap[suffix]) + // call script + cmd := exec.Command(pwman.ChangePwScript) + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("Unable to open pipe for kerberos script: %v", err) + } + go func() { + defer stdin.Close() + fmt.Fprintf(stdin, "%s@NORDU.NET %s", kerberos_uid, new_password) + }() + + err = cmd.Run() + if err != nil { + return fmt.Errorf("Error running change password script, got error: %v", err) + } + + return nil +} diff --git a/ldap.go b/ldap.go new file mode 100644 index 0000000..e8a72ed --- /dev/null +++ b/ldap.go @@ -0,0 +1,227 @@ +package main + +import ( + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "fmt" + "gopkg.in/ldap.v2" + "log" + "strings" +) + +// Search ldap for keys... +type LdapInfo struct { + Server string + Port int + User string + Password string + SSLSkipVerify bool + UserDNFmt string +} + +func (i *LdapInfo) LdapConnect() (*ldap.Conn, error) { + var tlsConf *tls.Config + if i.SSLSkipVerify { + tlsConf = &tls.Config{InsecureSkipVerify: true} + } else { + tlsConf = &tls.Config{ServerName: i.Server} + } + l, err := ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", i.Server, i.Port), tlsConf) + if err != nil { + return nil, fmt.Errorf("LDAP Unable to connect to %s on port %d. Got error: %s", i.Server, i.Port, err) + } + + return l, nil +} + +func (i *LdapInfo) LdapConnectBind() (*ldap.Conn, error) { + // check if we have LDAP credentials + if i.User == "" || i.Password == "" { + return nil, fmt.Errorf("LDAP Bind user and/or password missing") + } + + l, err := i.LdapConnect() + if err != nil { + return nil, err + } + + err = l.Bind(i.User, i.Password) + if err != nil { + l.Close() + return nil, err + } + return l, nil +} + +func (i *LdapInfo) UserDN(username string) string { + if i.UserDNFmt != "" { + return fmt.Sprintf(i.UserDNFmt, username) + } else { + return fmt.Sprintf("uid=%s,ou=People,dc=nordu,dc=net", username) + } +} + +type SSHPubKey struct { + Format string + Key string + Comment string + Fingerprint string +} + +func NewSSHPubKey(ssh_key string) SSHPubKey { + key_parts := strings.SplitN(ssh_key, " ", 3) + comment := "" + if len(key_parts) > 2 { + comment = key_parts[2] + } + return SSHPubKey{key_parts[0], key_parts[1], comment, calculateFingerprint(key_parts[1])} +} + +func (k SSHPubKey) String() string { + return fmt.Sprintf("%s %s %s", k.Format, k.Key, k.Comment) +} + +func (k SSHPubKey) KeyEnd() string { + i := len(k.Key) - 8 + if i < 0 { + i = 0 + } + return k.Key[i:] +} + +// Get SSH keys +// Preferably keyformat, fingerprint, comment, but full key is how it is now +func (i *LdapInfo) GetSSHKeys(username string) ([]SSHPubKey, error) { + l, err := i.LdapConnect() + if err != nil { + return nil, err + } + defer l.Close() + + searchRequest := ldap.NewSearchRequest( + i.UserDN(username), + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + "(objectClass=person)", + []string{"sshPublicKey"}, + nil, + ) + + sr, err := l.Search(searchRequest) + if err != nil { + return nil, fmt.Errorf("LDAP Search for user '%s' failed: %s", username, err) + } + + if len(sr.Entries) < 1 { + return nil, fmt.Errorf("LDAP User %s does not exist", username) + } else if len(sr.Entries) > 1 { + return nil, fmt.Errorf("LDAP User %s returned more than one enty (Results: %d)", username, len(sr.Entries)) + } + + pubKeys := make([]SSHPubKey, len(sr.Entries[0].GetAttributeValues("sshPublicKey"))) + for i, key := range sr.Entries[0].GetAttributeValues("sshPublicKey") { + pubKeys[i] = NewSSHPubKey(key) + } + return pubKeys, nil + +} + +// Add ssh key +func (i *LdapInfo) AddSSHKey(username string, ssh_keys []string) error { + l, err := i.LdapConnectBind() + if err != nil { + return err + } + defer l.Close() + + dn := i.UserDN(username) + // Add objectClass ldapPublicKey if missing + mod := ldap.NewModifyRequest(dn) + mod.Add("objectClass", []string{"ldapPublicKey"}) + err = l.Modify(mod) + if err != nil { + // check err if type or value exist + } + + // Add keys + // One mod req per key, to handle errors for existing keys + for _, key := range ssh_keys { + if err = validateSSHkey(key); err != nil { + log.Println(err) + } else { + mod = ldap.NewModifyRequest(dn) + mod.Add("sshPublicKey", []string{key}) + err = l.Modify(mod) + if err != nil { + // Ignore Attribute or value exists errors + if !strings.Contains(err.Error(), "Code 20") { + return err + } + } + } + } + + return nil +} + +// Delete ssh key +// Use fingerprint, or full key to delete +func (i *LdapInfo) DeleteSSHKey(username, ssh_key string) error { + l, err := i.LdapConnectBind() + if err != nil { + return err + } + defer l.Close() + + del := ldap.NewModifyRequest(i.UserDN(username)) + del.Delete("sshPublicKey", []string{ssh_key}) + err = l.Modify(del) + if err != nil { + // Ignore error about No such attribute + if !strings.Contains(err.Error(), "Code 16") { + return err + } + } + return nil +} + +// Sanity checks on a ssh key. +// Checks if key has 2-3 parts (key_format, key, comment) +// Checks if key_format is: ssh-rsa or ssh-ed25519 +// If ssh-rsa check that key is at least 2048 bit +// Check that key can be base64 decoded +func validateSSHkey(ssh_key string) error { + key_parts := strings.SplitN(ssh_key, " ", 3) + if len(key_parts) < 2 || len(key_parts) > 3 { + return fmt.Errorf("SSH key is invalid. Expected 2-3 parts, got %d. Key was: '%s'", len(key_parts), ssh_key) + } + // Check base64 + decoded_key, err := base64.StdEncoding.DecodeString(key_parts[1]) + if err != nil { + return fmt.Errorf("SSH key is not properly base64 encoded. Key was: %s", ssh_key) + } + // Check keyformat: ssh-rsa, ssh-ed25519 + switch key_parts[0] { + case "ssh-rsa": + padding := strings.Count(key_parts[1], "=") + key_length := (len(decoded_key) - padding) * 8 + if key_length < 2048 { + return fmt.Errorf("SSH rsa key should at least be a 2048 bit key. Was: %d", key_length) + } + case "ssh-ed25519": + // nothing to check + default: + return fmt.Errorf("SSH key is not an acceptable format (ssh-rsa or ssh-ed25519). Key was: %s", ssh_key) + } + // Key looks ok + return nil +} + +func calculateFingerprint(ssh_key string) string { + key, _ := base64.StdEncoding.DecodeString(ssh_key) + fingerprint := sha256.Sum256(key) + return fmt.Sprintf("SHA256:%s", base64.StdEncoding.EncodeToString(fingerprint[:])) + //return fmt.Sprintf("SHA256:%x", fingerprint) +} + +//// set_nordunet_ldap_pw_sasl used on sso pw set if change pw fail? diff --git a/ldap_test.go b/ldap_test.go new file mode 100644 index 0000000..e685e38 --- /dev/null +++ b/ldap_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "strings" + "testing" +) + +func TestVerifySSHKeyOk(t *testing.T) { + ok_key_keys := []string{ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLQlYF3LXI/CMX/yPWRboNiUI6qj+K6/kD6tu+di9zRwtN5jzGh5DTJ2ZaQeDIS8cED62jW7KJySoeMMWRA0W//rp8aRKL7cHWVWEkd2maEmwzdUKx18OoDMqT8wNRd9K66lxUv4lHX9mbM1gd1f3uwgUZMSiIq6p/wh2n/GozFocvasq8Bugl2epLxncnKoDqJIUMUpQUmTI9G7b2pLpI8OCKkoF7VKVrH1nt0yvboZ/4sQ/EYoKj/9/Surqnx/VTs3pfs/gKxw53bMVLN6W4i2FjW4EfN8Cs0zjaddjVaCYRnDmCQQZUckS9/E+rhJGAaD6xNxpP93dwkgqQyj2t markus@comment", + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLQlYF3LXI/CMX/yPWRboNiUI6qj+K6/kD6tu+di9zRwtN5jzGh5DTJ2ZaQeDIS8cED62jW7KJySoeMMWRA0W//rp8aRKL7cHWVWEkd2maEmwzdUKx18OoDMqT8wNRd9K66lxUv4lHX9mbM1gd1f3uwgUZMSiIq6p/wh2n/GozFocvasq8Bugl2epLxncnKoDqJIUMUpQUmTI9G7b2pLpI8OCKkoF7VKVrH1nt0yvboZ/4sQ/EYoKj/9/Surqnx/VTs3pfs/gKxw53bMVLN6W4i2FjW4EfN8Cs0zjaddjVaCYRnDmCQQZUckS9/E+rhJGAaD6xNxpP93dwkgqQyj2t", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKuZUxgv5fOU/HXi9NQDcqec06ut+6CTItzlPmgJHZm+ markus@test", + } + + var err error + for _, key := range ok_key_keys { + err = validateSSHkey(key) + if err != nil { + t.Error(err) + } + + } +} + +func TestVerifySSHKeyNoSpaces(t *testing.T) { + err := validateSSHkey("badkey") + if err == nil { + t.Error("Key 'badkey' should fail validation") + } + + if !strings.Contains(err.Error(), "invalid") { + t.Errorf("Error message should include invalid, but was '%s'", err.Error()) + } +} + +func TestVerifySSHKeyNotBase64(t *testing.T) { + b64_missing_padding := "ssh-rsa dGVzdAo" + err := validateSSHkey(b64_missing_padding) + if err == nil { + t.Errorf("'%s' should fail b64 validation", b64_missing_padding) + } + + if !strings.Contains(err.Error(), "base64") { + t.Errorf("Error message should include base64, but was '%s'", err.Error()) + } +} + +func TestVerifySSHKeyWrongFormatDSS(t *testing.T) { + it := "ssh-dss dGVzdAo=" + err := validateSSHkey(it) + if err == nil { + t.Errorf("'%s' should fail key format validation", it) + } + + if !strings.Contains(err.Error(), "format") { + t.Errorf("Error message should include format, but was '%s'", err.Error()) + } +} + +func TestVerifySSHKeyWrongFormatECDSA(t *testing.T) { + it := "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAHeiQG8vUVsIjQdN0O/ovg/NTERdT+KA0JQTNDSNh65Q+XFuw8j0MhbTLHk/yXWJqBp7Vn6eiuPYXJac75P2BJjiQGi0UlfNXpTeYEG48Sdeo4pfguEwbyfnWMDWj4f86k/UjD2bUJBpXVQNs82j0weOG4+SqkA7cFz/E6e7eEfkATVaA== markus@test" + err := validateSSHkey(it) + if err == nil { + t.Errorf("'%s' should fail key format validation", it) + } + + if !strings.Contains(err.Error(), "format") { + t.Errorf("Error message should include format, but was '%s'", err.Error()) + } +} + +func TestVerifySSHKeyRSAKeyToSmall(t *testing.T) { + short_rsa := "ssh-rsa dGVzdAo=" + err := validateSSHkey(short_rsa) + if err == nil { + t.Errorf("'%s' should fail bit length validation", short_rsa) + } + + if !strings.Contains(err.Error(), "2048 bit") { + t.Errorf("Error message should include 2048 bit, but was '%s'", err.Error()) + } + + if !strings.Contains(err.Error(), "Was: 32") { + t.Errorf("Error message should include original bit length (32), but was '%s'", err.Error()) + } +} + +func TestCalcFingerprint(t *testing.T) { + key := "AAAAC3NzaC1lZDI1NTE5AAAAIKuZUxgv5fOU/HXi9NQDcqec06ut+6CTItzlPmgJHZm+" + real_fingerprint := "SHA256:Rw71nETy5eL5J7ZK2QZfCZmp6e940ljBesD2COTG4Us=" + + fingerprint := calculateFingerprint(key) + + if fingerprint != real_fingerprint { + t.Errorf("Fingerprint is calculated wrong. Expected: %s, Got: %s", real_fingerprint, fingerprint) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ea6a60c --- /dev/null +++ b/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "github.com/gorilla/csrf" + "log" + "net/http" + "time" +) + +type PwmanServer struct { + LdapInfo *LdapInfo + PwnedDBFile string + Krb5Conf string + ChangePwScript string + RemoteUserHeader string +} + +var pwman *PwmanServer + +func main() { + + ldapInfo := &LdapInfo{Server: "localhost", Port: 6636, SSLSkipVerify: true, User: "cn=admin,dc=nordu,dc=net", Password: "secretpw"} + + pwman = &PwmanServer{ + LdapInfo: ldapInfo, + PwnedDBFile: "/Users/markus/Downloads/pwned-passwords-ordered-2.0.txt", + Krb5Conf: "./krb5.conf", + ChangePwScript: "./create-kdc-principal.pl", + RemoteUserHeader: "X-Remote-User", + } + + base_path := "/sso" + v := Views() + + mux := http.NewServeMux() + mux.Handle(base_path+"/", FlashMessage(RemoteUser(v.Index()))) + mux.Handle(base_path+"/sso", FlashMessage(RemoteUser(v.ChangePassword("SSO")))) + mux.Handle(base_path+"/tacacs", FlashMessage(RemoteUser(v.ChangePassword("TACACS")))) + mux.Handle(base_path+"/eduroam", FlashMessage(RemoteUser(v.ChangePassword("eduroam")))) + mux.Handle(base_path+"/pubkeys", FlashMessage(RemoteUser(v.ChangeSSHKeys()))) + + mux.Handle(base_path+"/static/", http.StripPrefix(base_path+"/static", http.FileServer(http.Dir("static")))) + + CSRF := csrf.Protect([]byte("f3b4ON3nQkmNPNP.hiyp7Z5DBAMsXo7c_"), csrf.Secure(false)) + + server := &http.Server{ + Addr: ":3000", + Handler: CSRF(mux), + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + } + log.Println("Listening on: http://0.0.0.0:3000") + log.Fatal(server.ListenAndServe()) +} + +//type CustomMux struct { +// base_path string +// mux *http.ServeMux +//} +// +//func NewCustomMux(base_path string) *CustomMux { +// return &CustomMux{base_path, http.NewServeMux()} +//} +// +//func (m *CustomMux) Handle(path string, h http.Handler) { +// m.mux.Handle(path, h) +//} +// +//func (m *CustomMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { +// clean_path := filepath.Clean(r.URL.Path) +// log.Println(clean_path) +// if !strings.HasPrefix(clean_path, m.base_path) { +// http.NotFound(w, r) +// return +// } +// r.URL.Path = clean_path[len(m.base_path):] +// log.Println(clean_path[len(m.base_path):]) +// m.mux.ServeHTTP(w, r) +//} + +//type RemoteUserMux map[string] http.Handler +// +//func (m RemoteUserMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { +// handler, ok := m[r.URL.Path +//} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..33aeae0 --- /dev/null +++ b/middleware.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "net/http" + "strings" + "time" +) + +type User struct { + UserId string + UserName string + DisplayName string + Email string + Active bool + Staff bool +} + +func GetUser(req *http.Request) (*User, error) { + if user_header, ok := req.Header[pwman.RemoteUserHeader]; ok { + // If mre than one header abort + if len(user_header) != 1 { + return nil, fmt.Errorf("Expected one user, but got multiple") + } + // Got user lets go + userid := user_header[0] + //utf8 decode? + first_name := first(req.Header["Givenname"]) + last_name := first(req.Header["Sn"]) + email := first(req.Header["Mail"]) + affiliations := req.Header["Affiliation"] + is_staff := contains(affiliations, "employee@nordu.net") + is_active := is_staff || contains(affiliations, "member@nordu.net") + username := strings.Split(userid, "@")[0] + + return &User{ + userid, + username, + fmt.Sprintf("%v %v", first_name, last_name), + email, + is_active, + is_staff}, nil + } + return nil, fmt.Errorf("No user found") +} + +func RemoteUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + user, err := GetUser(req) + if err != nil { + log.Println("ERROR:", err) + http.Error(w, "Please log in", http.StatusUnauthorized) + return + } + // consider redirect to login with next + + ctx := req.Context() + ctx = context.WithValue(ctx, "user", user) + + next.ServeHTTP(w, req.WithContext(ctx)) + }) +} + +func FlashMessage(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + clear := &http.Cookie{Name: "flashmsg", MaxAge: -1, Expires: time.Unix(1, 0)} + // Get flash from cookie + cookie, err := req.Cookie("flashmsg") + if err != nil { + next.ServeHTTP(w, req) + return + } + + msgB, err := base64.URLEncoding.DecodeString(cookie.Value) + if err != nil { + //unset flash message + http.SetCookie(w, clear) + next.ServeHTTP(w, req) + return + } + + msg := string(msgB) + msg_parts := strings.Split(msg, ";_;") + flash_class := "info" + if len(msg_parts) == 2 { + if msg_parts[1] != "" { + flash_class = msg_parts[1] + } + msg = msg_parts[0] + } + ctx := req.Context() + ctx = context.WithValue(ctx, "flash", msg) + ctx = context.WithValue(ctx, "flash_class", flash_class) + http.SetCookie(w, clear) + next.ServeHTTP(w, req.WithContext(ctx)) + }) +} + +func SetFlashMessage(w http.ResponseWriter, msg, class string) { + enc_message := base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%s;_;%s", msg, class))) + flash_cookie := &http.Cookie{Name: "flashmsg", Value: enc_message} + http.SetCookie(w, flash_cookie) +} diff --git a/nginx-test/pwman.dev.conf b/nginx-test/pwman.dev.conf index 944c1b4..927fcd7 100644 --- a/nginx-test/pwman.dev.conf +++ b/nginx-test/pwman.dev.conf @@ -33,21 +33,24 @@ server { server_name uwsgi.pwman.test; - location /sso/ { + location / { include uwsgi_params; uwsgi_pass pwman:8000; } - location /sso/accounts/login-federated/ { + location /accounts/login-federated/ { include uwsgi_params; uwsgi_pass pwman:8000; - uwsgi_param HTTP_X_REMOTE_USER 'markus@nordu.net'; + uwsgi_param REMOTE_USER 'markus@nordu.net'; uwsgi_param HTTP_GIVENNAME 'Markus'; uwsgi_param HTTP_SN 'Krogh'; uwsgi_param HTTP_MAIL 'markus@nordu.net'; uwsgi_param HTTP_AFFILIATION 'employee@nordu.net'; } + location /static/ { + alias /opt/pwman/; + } location /sso/static/ { alias /opt/pwman/; } diff --git a/pwned.go b/pwned.go new file mode 100644 index 0000000..a965c4d --- /dev/null +++ b/pwned.go @@ -0,0 +1,78 @@ +package main + +// Adapted from https://github.com/lenartj/pwned-passwords +import ( + "crypto/sha1" + "fmt" + "io" + "log" + "os" + "sort" + "strings" +) + +type pwdb struct { + f *os.File + n int + rs int + hash_length int +} + +func NewPWDB(fn string) (*pwdb, error) { + f, err := os.Open(fn) + if err != nil { + return nil, err + } + + stat, err := f.Stat() + if err != nil { + return nil, err + } + + const rs = 63 // V2 has fixed width of 63 bytes + if stat.Size()%rs != 0 { + return nil, fmt.Errorf("Unexpected password file format (must be a text file with 63 char width starting with sha1)") + } + const hash_length = 40 // sha1 is 40 chars + + return &pwdb{f, int(stat.Size() / rs), rs, hash_length}, nil +} + +func (db *pwdb) record(i int) string { + b := make([]byte, db.hash_length) + db.f.ReadAt(b, int64(i*db.rs)) + return string(b) +} + +func (db *pwdb) SearchHash(hash string) bool { + if len(hash) != db.hash_length { + return false + } + needle := strings.ToUpper(hash) + + i := sort.Search(db.n, func(i int) bool { + return db.record(i) >= needle + }) + return i < db.n && db.record(i) == needle +} + +func (db *pwdb) SearchPassword(password string) bool { + h := sha1.New() + io.WriteString(h, password) + return db.SearchHash(fmt.Sprintf("%x", h.Sum(nil))) +} + +func (db *pwdb) Close() { + db.f.Close() +} + +func IsPasswordCompromised(password string) bool { + pwndDB, err := NewPWDB(pwman.PwnedDBFile) + if err != nil { + log.Println("ERROR", "pwnedb", err) + return false + } + defer pwndDB.Close() + + return pwndDB.SearchPassword(password) +} diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..31caa19 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,290 @@ +* { + margin: 0; + padding: 0; +} + +h1, h2, h3, h4 { + margin: 10px 0; +} +header, footer, section, nav { + display: block; +} +html, body { + height: 100%; +} +body { + font-family:Verdana, Geneva, sans-serif; + font-size: 12px; + line-height: 1.5; + color: #717171; +} +a:link, +a:visited { + color: #717171; + text-decoration: none; +} +a:hover { + color: #1BAAD7; +} +img { + max-width: 100%; + margin-bottom: 12px; +} + +header { + background-color: #00a8d9; + width: 100%; +} + +header img { + margin: 0; +} + +form { + padding-bottom: 21px; +} +form label { /* labels are hidden */ + font-weight: bold; +} +form legend { + font-size:1.2em; + margin-bottom: 12px; +} +.form-element-wrapper { + margin-bottom: 12px; + display: block; +} +.form-element { + width: 100%; + padding: 13px 12px; + box-sizing: border-box; + border: none; + font-size: 14px; + border-radius: 4px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; +} +.form-field { + color: #B7B7B7; + border: 1px solid #B7B7B7; +} +.form-field-focus, +.form-field:focus, +input[type="text"]:focus { + color: #333333; + border-color: #333; +} +.form-button { + background: #00a8d9; + color: #ffffff; + cursor: pointer; +} +.form-button:hover { + background: #00baf1; +} + +.form-error { + padding: 0; + color: #B61601; +} + +.list-help, .unstyled { + list-style: none; +} +.list-help-item a { + display: block; + color: #4F4E4E; + text-decoration: none; +} + +.list-title { + font-weight: bold; +} + +.list-title, .list-help-item { + padding: 2px 24px; +} + +.item-marker { + color: #00a7d9; +} + +.indented { + padding-left: 40px; +} + +footer { + color: #ffffff; + font-size: 11px; + background: #717171; + position: fixed; + bottom: 0; + width: 100%; + padding: 12px 20px; +} + +.container.flex-group { + /* make space for floating footer */ + margin: 24px 0 80px +} + +body::after { + content: ""; + background: transparent url('../images/ndn-bg1.png') repeat-y; + opacity: 0.5; + top: 0; + left: 0; + bottom: 0; + right: 0; + position: absolute; + z-index: -1; +} + + +.flex-group { + display: flex; + flex-flow: row wrap-reverse; +} + +.flex-container { + display: flex; + flex-flow: row wrap; + flex: 1 1 400px; +} + + +.column { + flex: 1 1 400px; + max-width: 400px; + margin: 0 24px; +} + +.column.full { + width: 100%; + max-width: 100%; + flex-basis: auto; +} + +nav { + flex: 0 0 200px; +} + +@media only screen and (max-width: 599px) { + nav { + max-width: 400px; + flex-grow: 1; + } + body::after { + content: none; + } + .container.flex-group { + margin: 0 10px 80px 10px; + } + + .list-help-item a { + line-height: 18px; + } + .column { + margin: 0; + } +} + +/* Support non layout converted pages */ +.wrapper .content { + margin-left: 200px; +} + +.content.flex-container { + margin-left: 0; +} +/* end layout backport */ + +dt { + width: 33%; + font-weight: bold; +} +dd { + width: 66%; +} + +.sshkey { + word-wrap: break-word; + padding: 2px; + border: 1px solid rgba(150,150,150,0.3); + border-radius: 2px; + margin: 10px 0; +} + +.sshkey:hover { + background: rgba(230,230,230, 0.3); +} + +.sshkey form { + padding: 0; + display: flex; +} + +.sshfingerprint, .fullkey { + min-width: 50px; +} + + +.fullkey, .keyend { + cursor: pointer; +} + +.fullkey, .showfull .keyend { + display: none +} +.showfull .fullkey { + display: block; +} + +textarea { + width: 100%; + min-height: 250px; + border-radius: 4px; + border-color: #ccc; +} + +.btn-delete { + padding: 0; + border: none; + cursor: pointer; + background: transparent; +} + +.hidden { + display: none; +} + +:checked + .fullkey { + display: block; +} + +.alert { + padding: 10px 20px; + border: 1px solid #b8daff; + border-radius: 4px; + color: #004085; + background-color: #cce5ff; + max-width: 848px; +} + +.alert-error { + color: #721c24; + background-color: #f8d7da; + border-color: #f5c6cb; +} + +.alert-success { + color: #155724; + background-color: #d4edda; + border-color: #c3e6cb; +} + +.alert-warning { + color: #856404; + background-color: #fff3cd; + border-color: #ffeeba; +} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..93d215d Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/images/favicon.ico b/static/images/favicon.ico new file mode 100644 index 0000000..93d215d Binary files /dev/null and b/static/images/favicon.ico differ diff --git a/static/images/ndn-bg1.png b/static/images/ndn-bg1.png new file mode 100644 index 0000000..40c0bca Binary files /dev/null and b/static/images/ndn-bg1.png differ diff --git a/static/images/nordunet.png b/static/images/nordunet.png new file mode 100644 index 0000000..9948f66 Binary files /dev/null and b/static/images/nordunet.png differ diff --git a/templates/change_ssh.html b/templates/change_ssh.html new file mode 100644 index 0000000..96231a7 --- /dev/null +++ b/templates/change_ssh.html @@ -0,0 +1,32 @@ + +{{ define "content" }} +
+

Your existing SSH keys

+ +
+
+ {{ $csrf }} +

Paste your SSH public keys (one key per line):

+ + +
+ +
+ Back +
+{{ end }} diff --git a/templates/changepw.html b/templates/changepw.html new file mode 100644 index 0000000..a7c1dc6 --- /dev/null +++ b/templates/changepw.html @@ -0,0 +1,28 @@ +{{ define "title" }}Change {{.Pwtype}} password{{ end }} + +{{ define "content" }} +
+

Change {{ .Pwtype }} password

+

When thinking of a new password you need to remember to use:

+ +
+ {{ .CsrfField }} + + +
+ +
+
+ +
+ Back +
+{{ end }} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..218ffe2 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,46 @@ +{{ define "content" }} +
+

SSO Password Manager

+{{with .User}} +

+ Hello {{.DisplayName}},
+ Welcome to the single sign on password manager site. +

+

Your usernames

+
+
SSO:
+
{{ .UserName }}
+
eduroam:
+
{{ .UserName }}/ppp@NORDU.NET
+
+ +

Available actions

+ +

+ Log out +

+{{ end }} +
+{{ end }} diff --git a/templates/layout/base.html b/templates/layout/base.html new file mode 100644 index 0000000..f041321 --- /dev/null +++ b/templates/layout/base.html @@ -0,0 +1,40 @@ +{{define "base"}} + + + + + + {{block "title" .}}SSO Password Manager{{end}} + + + + +
+
+
+ NORDUnet Nordic Gateway for Research and Education +
+
+
+ +
+ {{if .Flash }} +
+
+ {{ .Flash }} +
+
+ {{ end }} + {{block "content" .}} {{ end }} +
+
+ +
+ + +{{end}} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..6379beb --- /dev/null +++ b/utils.go @@ -0,0 +1,18 @@ +package main + +func first(list []string) string { + if len(list) > 0 { + return list[0] + } else { + return "" + } +} + +func contains(list []string, what string) bool { + for _, value := range list { + if value == what { + return true + } + } + return false +} diff --git a/validator.go b/validator.go new file mode 100644 index 0000000..d6a4bb5 --- /dev/null +++ b/validator.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "regexp" +) + +var specialsRxp *regexp.Regexp = regexp.MustCompile("[,.\\[\\]!@#$%^&*?_\\(\\)-]") +var numbersRxp *regexp.Regexp = regexp.MustCompile("[0-9]") + +func validatePassword(password string) error { + if len(password) < 10 { + return fmt.Errorf("Your password needs to be at least 10 characters long. But was %d characters.", len(password)) + } + // check uppercase and lowercase + if match, err := regexp.MatchString("[a-z]", password); err != nil || !match { + return fmt.Errorf("Your password needs at least one lowercase character") + } + if match, err := regexp.MatchString("[A-Z]", password); err != nil || !match { + return fmt.Errorf("Your password needs at least one uppercase character") + } + // check specials + numbers (at least 3) + specials := specialsRxp.FindAllString(password, -1) + numbers := numbersRxp.FindAllString(password, -1) + if len(specials)+len(numbers) < 3 { + return fmt.Errorf("Your password needs at least three special characters or numbers") + } + + // call out to password hash check service... + return nil +} diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 0000000..618692f --- /dev/null +++ b/validator_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "strings" + "testing" +) + +func TestValidatePasswordOk(t *testing.T) { + err := validatePassword("Testp4ssw0r_d") + if err != nil { + t.Error("Test password should pass validation") + } +} + +func TestValidatePasswordLength(t *testing.T) { + err := validatePassword("test") + if err == nil { + t.Error("Password should fail validation") + } + + if !strings.Contains(err.Error(), "least 10 characters") { + t.Error("Error should tell that the password is too short") + } + if !strings.Contains(err.Error(), "was 4 characters") { + t.Error("Error should contain the current password length") + } +} + +func TestValidatePasswordUpperAndLower(t *testing.T) { + + err := validatePassword("testtestfest") + if err == nil { + t.Error("All lowercase should fail uppercase validation") + } + if !strings.Contains(err.Error(), "uppercase") { + t.Error("Error message should contain uppercase") + } + + err = validatePassword("TESTTESTFEST") + if err == nil { + t.Error("All uppercase should fail lowercase validation") + } + if !strings.Contains(err.Error(), "lowercase") { + t.Error("Error message should contain lowercase") + } +} + +func TestValidatePasswordSpecialAndNumbers(t *testing.T) { + base_pass := "testTestFest" + bad_passwords := []string{"", "2", "#3"} + + var err error + for _, chr := range bad_passwords { + + err = validatePassword(base_pass + chr) + if err == nil { + t.Errorf("Password %s should fail 3 numbers and/or special", base_pass+chr) + } + if !strings.Contains(err.Error(), "special characters") { + t.Errorf("Error message should contain special characters: %s", base_pass+chr) + } + if !strings.Contains(err.Error(), "numbers") { + t.Errorf("Error message should contain 'numbers': %s", base_pass+chr) + } + } +} diff --git a/views.go b/views.go new file mode 100644 index 0000000..7a15739 --- /dev/null +++ b/views.go @@ -0,0 +1,171 @@ +package main + +import ( + "fmt" + "github.com/gorilla/csrf" + "html/template" + "log" + "net/http" + "strings" +) + +type views struct { + templates map[string]*template.Template +} + +func Views() *views { + templates := map[string]*template.Template{ + "index": template.Must(template.ParseFiles("templates/layout/base.html", "templates/index.html")), + "change_password": template.Must(template.ParseFiles("templates/layout/base.html", "templates/changepw.html")), + "change_ssh": template.Must(template.ParseFiles("templates/layout/base.html", "templates/change_ssh.html")), + } + + return &views{templates} +} + +func (v *views) Index() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if _, ok := req.Context().Value("user").(*User); ok { + err := v.templates["index"].ExecuteTemplate(w, "base", NewPageCtx(req)) + if err != nil { + log.Println("ERROR", err) + } + } else { + // no user + fmt.Fprintf(w, "No user found :/") + } + }) +} + +func (v *views) ChangePassword(what string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + pageCtx := NewPageCtx(req) + if req.Method == "GET" { + pageCtx["Pwtype"] = what + err := v.templates["change_password"].ExecuteTemplate(w, "base", pageCtx) + if err != nil { + log.Println("ERROR", err) + } + } else if req.Method == "POST" { + // Parse Post + err := req.ParseForm() + if err != nil { + log.Println("ERROR", err) + redirectSameFlash(w, req, err.Error(), "error") + return + } + new_password := req.PostFormValue("new_password") + new_password_again := req.PostFormValue("new_password_again") + if new_password != new_password_again { + redirectSameFlash(w, req, "Passwords did not match", "error") + return + } + err = validatePassword(new_password) + if err != nil { + redirectSameFlash(w, req, err.Error(), "error") + return + } + // check if password is on the bad list + if IsPasswordCompromised(new_password) { + redirectSameFlash(w, req, "The password specified is considered compromised", "error") + return + } + // check that password is not already in use for other suffix + username := pageCtx["User"].(*User).UserName + err = CheckDuplicatePw(username, new_password) + if err != nil { + redirectSameFlash(w, req, err.Error(), "error") + return + } + + // save that password + err = ChangeKerberosPw(strings.ToUpper(what), username, new_password) + if err != nil { + redirectSameFlash(w, req, err.Error(), "error") + return + } + + redirectSameFlash(w, req, fmt.Sprintf("Password %s successfully updated", what), "success") + } + }) +} + +type PageCtx map[string]interface{} + +func NewPageCtx(req *http.Request) PageCtx { + flash_msg := req.Context().Value("flash") + flash_class := req.Context().Value("flash_class") + user, _ := req.Context().Value("user").(*User) + return PageCtx{ + "Flash": flash_msg, + "FlashClass": flash_class, + "User": user, + "CsrfField": csrf.TemplateField(req), + } +} + +func (v *views) ChangeSSHKeys() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if user, ok := req.Context().Value("user").(*User); ok { + if req.Method == "GET" { + ssh_keys, err := pwman.LdapInfo.GetSSHKeys(user.UserName) + if err != nil { + log.Println("ERROR", err) + ssh_keys = []SSHPubKey{} + } + + pageCtx := NewPageCtx(req) + pageCtx["SSHKeys"] = ssh_keys + err = v.templates["change_ssh"].ExecuteTemplate(w, "base", pageCtx) + if err != nil { + log.Println("ERROR", err) + } + } else if req.Method == "POST" { + err := req.ParseForm() + if err != nil { + log.Println("ERROR", err) + redirectSame(w, req) + return + } + delete_sshkey := req.PostFormValue("sshkey") + if req.PostFormValue("delete") != "" && delete_sshkey != "" { + log.Println("INFO", "Delete request for", delete_sshkey) + err = pwman.LdapInfo.DeleteSSHKey(user.UserName, delete_sshkey) + if err != nil { + log.Println("ERROR", err) + SetFlashMessage(w, err.Error(), "error") + } else { + SetFlashMessage(w, "Deleted ssh key", "warning") + } + redirectSame(w, req) + return + } + + if req.PostFormValue("add_key") != "" { + keys := strings.Split(req.PostFormValue("ssh_keys"), "\n") + err = pwman.LdapInfo.AddSSHKey(user.UserName, keys) + if err != nil { + log.Println("ERROR", err) + SetFlashMessage(w, err.Error(), "error") + } else { + SetFlashMessage(w, "Successfully added ssh key", "success") + } + + redirectSame(w, req) + return + } + + log.Println("ERROR", "Bad post view state") + redirectSame(w, req) + } + } + }) +} + +func redirectSameFlash(w http.ResponseWriter, req *http.Request, message, flash_class string) { + SetFlashMessage(w, message, flash_class) + http.Redirect(w, req, req.URL.Path, 302) +} +func redirectSame(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, req.URL.Path, 302) +} -- cgit v1.1