diff options
-rw-r--r-- | Dockerfile.golang | 17 | ||||
-rw-r--r-- | kdc.go | 65 | ||||
-rw-r--r-- | ldap.go | 227 | ||||
-rw-r--r-- | ldap_test.go | 97 | ||||
-rw-r--r-- | main.go | 85 | ||||
-rw-r--r-- | middleware.go | 107 | ||||
-rw-r--r-- | nginx-test/pwman.dev.conf | 9 | ||||
-rw-r--r-- | pwned.go | 78 | ||||
-rw-r--r-- | static/css/main.css | 290 | ||||
-rw-r--r-- | static/favicon.ico | bin | 0 -> 18094 bytes | |||
-rw-r--r-- | static/images/favicon.ico | bin | 0 -> 18094 bytes | |||
-rw-r--r-- | static/images/ndn-bg1.png | bin | 0 -> 33778 bytes | |||
-rw-r--r-- | static/images/nordunet.png | bin | 0 -> 20823 bytes | |||
-rw-r--r-- | templates/change_ssh.html | 32 | ||||
-rw-r--r-- | templates/changepw.html | 28 | ||||
-rw-r--r-- | templates/index.html | 46 | ||||
-rw-r--r-- | templates/layout/base.html | 40 | ||||
-rw-r--r-- | utils.go | 18 | ||||
-rw-r--r-- | validator.go | 31 | ||||
-rw-r--r-- | validator_test.go | 66 | ||||
-rw-r--r-- | views.go | 171 |
21 files changed, 1404 insertions, 3 deletions
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"] @@ -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 +} @@ -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) + } +} @@ -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 Binary files differnew file mode 100644 index 0000000..93d215d --- /dev/null +++ b/static/favicon.ico diff --git a/static/images/favicon.ico b/static/images/favicon.ico Binary files differnew file mode 100644 index 0000000..93d215d --- /dev/null +++ b/static/images/favicon.ico diff --git a/static/images/ndn-bg1.png b/static/images/ndn-bg1.png Binary files differnew file mode 100644 index 0000000..40c0bca --- /dev/null +++ b/static/images/ndn-bg1.png diff --git a/static/images/nordunet.png b/static/images/nordunet.png Binary files differnew file mode 100644 index 0000000..9948f66 --- /dev/null +++ b/static/images/nordunet.png 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" }} +<div class="column"> + <h2>Your existing SSH keys</h2> + <ul class="unstyled"> + {{ $csrf := .CsrfField }} + {{ range .SSHKeys }} + <li class="sshkey"> + <form method="post" action=""> + <span class="sshfingerprint">{{ .Fingerprint }} {{ .Comment }}</span> + {{ $csrf }} + <input type="hidden" name="sshkey" value="{{ . }}"> + <input type="submit" name="delete" value="❌" class="btn-delete"> + </form> + <label for="show_full_{{.KeyEnd}}" class="keyend" title="Click for full key">Key ends in: {{ .KeyEnd }}</label> + <input id="show_full_{{.KeyEnd}}" type="radio" name="showfull" value="full" class="hidden"> + <label class="fullkey">{{ . }} <input type="radio" name="showfull" value="end" class="hidden"></label> + </li> + {{ end }} + </ul> +</div> +<form method="post" autocomplete="off" class="column"> + {{ $csrf }} + <p>Paste your SSH public keys (one key per line):</p> + <textarea name="ssh_keys"></textarea> + <input name="add_key" type="submit" class="form-element form-button" /> +</form> + +<div class="column full"> + <a href=".">Back</a> +</div> +{{ 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" }} +<form method="post" autocomplete="off" class="column"> + <h2>Change {{ .Pwtype }} password</h2> + <p>When thinking of a new password you need to remember to use:</p> + <ul class="indented"> + <li>no fewer than ten characters</li> + <li>at least one upper case and one lower case letter</li> + <li>three or more numbers or special characters i.e. <pre>,.][!@#$%^&*?_()-</pre></li> + </ul> + <br> + {{ .CsrfField }} + <label class="form-element-wrapper">New password + <input type="password" name="new_password" class="form-element form-field" /> + </label> + <label class="form-element-wrapper">Repeat password + <input type="password" name="new_password_again" class="form-element form-field" /> + </label> + <div class="form-element-wrapper"> + <input type="submit" value="Change password" class="form-element form-button" /> + </div> +</form> + + <div class="column full"> + <a href=".">Back</a> + </div> +{{ 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" }} +<div class="column"> +<h2>SSO Password Manager</h2> +{{with .User}} + <p> + Hello {{.DisplayName}},<br> + Welcome to the single sign on password manager site. + </p> + <h3>Your usernames</h3> + <dl class="flex-container"> + <dt>SSO:</dt> + <dd>{{ .UserName }}</dd> + <dt>eduroam:</dt> + <dd>{{ .UserName }}/ppp@NORDU.NET</dd> + </dl> + + <h3>Available actions</h3> + <ul class="unstyled"> + <li> + <a href="sso"><span class="item-marker">›</span> Change single sign on (SSO) password</a> + </li> + {{ if .Staff }} + <li> + <a href="tacacs"><span class="item-marker">›</span> Change TACACS password</a> + </li> + {{ end }} + {{ if .Active }} + <li> + <a href="eduroam"><span class="item-marker">›</span> Change eduroam password</a> + </li> + {{ end }} + {{ if .Staff }} + <li> + <a href="pubkeys"><span class="item-marker">›</span> Update your public SSH keys</a> + </li> + <li> + <a href="ideviceconf" rel="external"><span class="item-marker">›</span> Configure eduroam on your iDevice</a> + </li> + {{ end }} + </ul> + <p> + <a href="/Shibboleth.sso/Logout">Log out</a> + </p> +{{ end }} +</div> +{{ 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"}} +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width,initial-scale=1.0"> + <title>{{block "title" .}}SSO Password Manager{{end}}</title> + <link rel="stylesheet" type="text/css" href="static/css/main.css"> + <link href="static/images/favicon.ico" rel="shortcut icon" type="image/vnd.microsoft.icon"> + </head> + <body> + <div class="wrapper"> + <header> + <div class="container"> + <img src="static/images/nordunet.png" alt="NORDUnet Nordic Gateway for Research and Education"> + </div> + </header> + <div class="container flex-group"> + <nav> + </nav> + <div class="content flex-container"> + {{if .Flash }} + <div class="column full "> + <div class="alert alert-{{ .FlashClass }}"> + {{ .Flash }} + </div> + </div> + {{ end }} + {{block "content" .}} {{ end }} + </div> + </div> + <footer> + <div class="container container-footer"> + <p class="footer-text">NORDUnet A/S | Kastruplundgade 22 | DK-2770 Kastrup | DENMARK | Phone +45 32 46 25 00 | Fax +45 45 76 23 66 | info@nordu.net</p> + </div> + </footer> + </div> + </body> +</html> +{{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) +} |