From 5041685df1abe77b02dd2a191a17e1bcf84c6f30 Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Thu, 7 Jul 2022 12:11:49 +0200 Subject: [PATCH] Refactor authentication --- internal/auth-v2/auth.go | 115 ++++++++++++++++++++++++ internal/auth-v2/jwt.go | 97 ++++++++++++++++++++ internal/auth-v2/ldap.go | 182 ++++++++++++++++++++++++++++++++++++++ internal/auth-v2/local.go | 42 +++++++++ 4 files changed, 436 insertions(+) create mode 100644 internal/auth-v2/auth.go create mode 100644 internal/auth-v2/jwt.go create mode 100644 internal/auth-v2/ldap.go create mode 100644 internal/auth-v2/local.go diff --git a/internal/auth-v2/auth.go b/internal/auth-v2/auth.go new file mode 100644 index 0000000..666b95c --- /dev/null +++ b/internal/auth-v2/auth.go @@ -0,0 +1,115 @@ +package authv2 + +import ( + "encoding/json" + "net/http" + + "github.com/ClusterCockpit/cc-backend/pkg/log" + sq "github.com/Masterminds/squirrel" + "github.com/gorilla/sessions" + "github.com/jmoiron/sqlx" +) + +const ( + RoleAdmin string = "admin" + RoleApi string = "api" + RoleUser string = "user" +) + +const ( + AuthViaLocalPassword int8 = 0 + AuthViaLDAP int8 = 1 + AuthViaToken int8 = 2 +) + +type User struct { + Username string `json:"username"` + Password string `json:"-"` + Name string `json:"name"` + Roles []string `json:"roles"` + AuthSource int8 `json:"via"` + Email string `json:"email"` +} + +func (u *User) HasRole(role string) bool { + for _, r := range u.Roles { + if r == role { + return true + } + } + return false +} + +type Authenticator interface { + Init(auth *Authentication, config json.RawMessage) error + CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool + Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error + Auth(rw http.ResponseWriter, r *http.Request) (*User, error) +} + +type ContextKey string + +const ContextUserKey ContextKey = "user" + +type Authentication struct { + db *sqlx.DB + sessionStore *sessions.CookieStore + authenticators []Authenticator +} + +func Init(db *sqlx.DB) (*Authentication, error) { + auth := &Authentication{} + auth.db = db + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS user ( + username varchar(255) PRIMARY KEY NOT NULL, + password varchar(255) DEFAULT NULL, + ldap tinyint NOT NULL DEFAULT 0, /* col called "ldap" for historic reasons, fills the "AuthSource" */ + name varchar(255) DEFAULT NULL, + roles varchar(255) NOT NULL DEFAULT "[]", + email varchar(255) DEFAULT NULL);`) + if err != nil { + return nil, err + } + + return auth, nil +} + +func (auth *Authentication) RegisterUser(user *User) error { + rolesJson, _ := json.Marshal(user.Roles) + cols := []string{"username", "password", "roles"} + vals := []interface{}{user.Username, user.Password, string(rolesJson)} + if user.Name != "" { + cols = append(cols, "name") + vals = append(vals, user.Name) + } + if user.Email != "" { + cols = append(cols, "email") + vals = append(vals, user.Email) + } + + if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(auth.db).Exec(); err != nil { + return err + } + + log.Infof("new user %#v created (roles: %s)", user.Username, rolesJson) + return nil +} + +func (auth *Authentication) AuthViaSession(rw http.ResponseWriter, r *http.Request) (*User, error) { + session, err := auth.sessionStore.Get(r, "session") + if err != nil { + return nil, err + } + + if session.IsNew { + return nil, nil + } + + username, _ := session.Values["username"].(string) + roles, _ := session.Values["roles"].([]string) + return &User{ + Username: username, + Roles: roles, + }, nil +} diff --git a/internal/auth-v2/jwt.go b/internal/auth-v2/jwt.go new file mode 100644 index 0000000..1e26b96 --- /dev/null +++ b/internal/auth-v2/jwt.go @@ -0,0 +1,97 @@ +package authv2 + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "os" + "strings" + + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/golang-jwt/jwt/v4" +) + +type JWTAuthenticator struct { + auth *Authentication + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey +} + +var _ Authenticator = (*JWTAuthenticator)(nil) + +func (ja *JWTAuthenticator) Init(auth *Authentication, rawConfig json.RawMessage) error { + ja.auth = auth + + pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") + if pubKey == "" || privKey == "" { + log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") + } else { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + return err + } + ja.publicKey = ed25519.PublicKey(bytes) + bytes, err = base64.StdEncoding.DecodeString(privKey) + if err != nil { + return err + } + ja.privateKey = ed25519.PrivateKey(bytes) + } + + return nil +} + +func (ja *JWTAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { + return user.AuthSource == AuthViaToken || r.Header.Get("Authorization") != "" +} + +func (ja *JWTAuthenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error { + return nil +} + +func (ja *JWTAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { + rawtoken := r.Header.Get("X-Auth-Token") + if rawtoken == "" { + rawtoken = r.Header.Get("Authorization") + prefix := "Bearer " + if !strings.HasPrefix(rawtoken, prefix) { + return nil, nil + } + rawtoken = rawtoken[len(prefix):] + } + + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodEdDSA { + return nil, errors.New("only Ed25519/EdDSA supported") + } + return ja.publicKey, nil + }) + if err != nil { + return nil, err + } + + if err := token.Claims.Valid(); err != nil { + return nil, err + } + + claims := token.Claims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + + var roles []string + if rawroles, ok := claims["roles"].([]interface{}); ok { + for _, rr := range rawroles { + if r, ok := rr.(string); ok { + roles = append(roles, r) + } + } + } + + // TODO: Check if sub is still a valid user! + return &User{ + Username: sub, + Roles: roles, + AuthSource: AuthViaToken, + }, nil +} diff --git a/internal/auth-v2/ldap.go b/internal/auth-v2/ldap.go new file mode 100644 index 0000000..e432ded --- /dev/null +++ b/internal/auth-v2/ldap.go @@ -0,0 +1,182 @@ +package authv2 + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "time" + + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/go-ldap/ldap/v3" +) + +type LdapConfig struct { + Url string `json:"url"` + UserBase string `json:"user_base"` + SearchDN string `json:"search_dn"` + UserBind string `json:"user_bind"` + UserFilter string `json:"user_filter"` + SyncInterval string `json:"sync_interval"` // Parsed using time.ParseDuration. + SyncDelOldUsers bool `json:"sync_del_old_users"` +} + +type LdapAutnenticator struct { + auth *Authentication + config *LdapConfig + syncPassword string +} + +var _ Authenticator = (*LdapAutnenticator)(nil) + +func (la *LdapAutnenticator) Init(auth *Authentication, rawConfig json.RawMessage) error { + la.auth = auth + if err := json.Unmarshal(rawConfig, &la.config); err != nil { + return err + } + + la.syncPassword = os.Getenv("LDAP_ADMIN_PASSWORD") + if la.syncPassword == "" { + log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync will not work)") + } + + if la.config.SyncInterval != "" { + interval, err := time.ParseDuration(la.config.SyncInterval) + if err != nil { + return err + } + + if interval == 0 { + return nil + } + + go func() { + ticker := time.NewTicker(interval) + for t := range ticker.C { + log.Printf("LDAP sync started at %s", t.Format(time.RFC3339)) + if err := la.Sync(); err != nil { + log.Errorf("LDAP sync failed: %s", err.Error()) + } + log.Print("LDAP sync done") + } + }() + } + + return nil +} + +func (la *LdapAutnenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { + return user.AuthSource == AuthViaLDAP +} + +func (la *LdapAutnenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error { + l, err := la.getLdapConnection(false) + if err != nil { + return err + } + defer l.Close() + + userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) + if err := l.Bind(userDn, password); err != nil { + return err + } + + return nil +} + +func (la *LdapAutnenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { + user, err := la.auth.AuthViaSession(rw, r) + if err != nil { + return nil, err + } + + user.AuthSource = AuthViaLDAP + return user, nil +} + +func (la *LdapAutnenticator) Sync() error { + const IN_DB int = 1 + const IN_LDAP int = 2 + const IN_BOTH int = 3 + + users := map[string]int{} + rows, err := la.auth.db.Query(`SELECT username FROM user WHERE user.ldap = 1`) + if err != nil { + return err + } + + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return err + } + + users[username] = IN_DB + } + + l, err := la.getLdapConnection(true) + if err != nil { + return err + } + defer l.Close() + + ldapResults, err := l.Search(ldap.NewSearchRequest( + la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + la.config.UserFilter, []string{"dn", "uid", "gecos"}, nil)) + if err != nil { + return err + } + + newnames := map[string]string{} + for _, entry := range ldapResults.Entries { + username := entry.GetAttributeValue("uid") + if username == "" { + return errors.New("no attribute 'uid'") + } + + _, ok := users[username] + if !ok { + users[username] = IN_LDAP + newnames[username] = entry.GetAttributeValue("gecos") + } else { + users[username] = IN_BOTH + } + } + + for username, where := range users { + if where == IN_DB && la.config.SyncDelOldUsers { + log.Debugf("ldap-sync: remove %#v (does not show up in LDAP anymore)", username) + if _, err := la.auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username); err != nil { + return err + } + } else if where == IN_LDAP { + name := newnames[username] + log.Debugf("ldap-sync: add %#v (name: %#v, roles: [user], ldap: true)", username, name) + if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, + username, 1, name, "[\""+RoleUser+"\"]"); err != nil { + return err + } + } + } + + return nil +} + +// TODO: Add a connection pool or something like +// that so that connections can be reused/cached. +func (la *LdapAutnenticator) getLdapConnection(admin bool) (*ldap.Conn, error) { + conn, err := ldap.DialURL(la.config.Url) + if err != nil { + return nil, err + } + + if admin { + if err := conn.Bind(la.config.SearchDN, la.syncPassword); err != nil { + conn.Close() + return nil, err + } + } + + return conn, nil +} diff --git a/internal/auth-v2/local.go b/internal/auth-v2/local.go new file mode 100644 index 0000000..2040beb --- /dev/null +++ b/internal/auth-v2/local.go @@ -0,0 +1,42 @@ +package authv2 + +import ( + "encoding/json" + "fmt" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +type LocalAuthenticator struct { + auth *Authentication +} + +var _ Authenticator = (*LocalAuthenticator)(nil) + +func (la *LocalAuthenticator) Init(auth *Authentication, rawConfig json.RawMessage) error { + la.auth = auth + return nil +} + +func (la *LocalAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { + return user.AuthSource == AuthViaLocalPassword +} + +func (la *LocalAuthenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error { + if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); e != nil { + return fmt.Errorf("user '%s' provided the wrong password (%s)", user.Username, e.Error()) + } + + return nil +} + +func (la *LocalAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { + user, err := la.auth.AuthViaSession(rw, r) + if err != nil { + return nil, err + } + + user.AuthSource = AuthViaLocalPassword + return user, nil +}