summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Dockerfile.golang17
-rw-r--r--kdc.go65
-rw-r--r--ldap.go227
-rw-r--r--ldap_test.go97
-rw-r--r--main.go85
-rw-r--r--middleware.go107
-rw-r--r--nginx-test/pwman.dev.conf9
-rw-r--r--pwned.go78
-rw-r--r--static/css/main.css290
-rw-r--r--static/favicon.icobin0 -> 18094 bytes
-rw-r--r--static/images/favicon.icobin0 -> 18094 bytes
-rw-r--r--static/images/ndn-bg1.pngbin0 -> 33778 bytes
-rw-r--r--static/images/nordunet.pngbin0 -> 20823 bytes
-rw-r--r--templates/change_ssh.html32
-rw-r--r--templates/changepw.html28
-rw-r--r--templates/index.html46
-rw-r--r--templates/layout/base.html40
-rw-r--r--utils.go18
-rw-r--r--validator.go31
-rw-r--r--validator_test.go66
-rw-r--r--views.go171
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"]
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
--- /dev/null
+++ b/static/favicon.ico
Binary files differ
diff --git a/static/images/favicon.ico b/static/images/favicon.ico
new file mode 100644
index 0000000..93d215d
--- /dev/null
+++ b/static/images/favicon.ico
Binary files differ
diff --git a/static/images/ndn-bg1.png b/static/images/ndn-bg1.png
new file mode 100644
index 0000000..40c0bca
--- /dev/null
+++ b/static/images/ndn-bg1.png
Binary files differ
diff --git a/static/images/nordunet.png b/static/images/nordunet.png
new file mode 100644
index 0000000..9948f66
--- /dev/null
+++ b/static/images/nordunet.png
Binary files 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" }}
+<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="&#x274C;" 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)
+}