From 5041685df1abe77b02dd2a191a17e1bcf84c6f30 Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Thu, 7 Jul 2022 12:11:49 +0200 Subject: [PATCH 1/9] 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 +} From 23f6015494bc0fdd144b357e85edd30bd5a556b8 Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Thu, 7 Jul 2022 12:48:04 +0200 Subject: [PATCH 2/9] Add login via JWT --- internal/auth-v2/auth.go | 28 +++++++++++- internal/auth-v2/jwt.go | 93 ++++++++++++++++++++++++++++++++++++--- internal/auth-v2/ldap.go | 8 ++-- internal/auth-v2/local.go | 6 +-- 4 files changed, 119 insertions(+), 16 deletions(-) diff --git a/internal/auth-v2/auth.go b/internal/auth-v2/auth.go index 666b95c..76b44b5 100644 --- a/internal/auth-v2/auth.go +++ b/internal/auth-v2/auth.go @@ -1,8 +1,10 @@ package authv2 import ( + "database/sql" "encoding/json" "net/http" + "time" "github.com/ClusterCockpit/cc-backend/pkg/log" sq "github.com/Masterminds/squirrel" @@ -29,6 +31,7 @@ type User struct { Roles []string `json:"roles"` AuthSource int8 `json:"via"` Email string `json:"email"` + Expiration time.Time } func (u *User) HasRole(role string) bool { @@ -43,7 +46,7 @@ func (u *User) HasRole(role string) bool { 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 + Login(user *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) } @@ -75,7 +78,28 @@ func Init(db *sqlx.DB) (*Authentication, error) { return auth, nil } -func (auth *Authentication) RegisterUser(user *User) error { +func (auth *Authentication) GetUser(username string) (*User, error) { + user := &User{Username: username} + var hashedPassword, name, rawRoles, email sql.NullString + if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). + Where("user.username = ?", username).RunWith(auth.db). + QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email); err != nil { + return nil, err + } + + user.Password = hashedPassword.String + user.Name = name.String + user.Email = email.String + if rawRoles.Valid { + if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { + return nil, err + } + } + + return user, nil +} + +func (auth *Authentication) AddUser(user *User) error { rolesJson, _ := json.Marshal(user.Roles) cols := []string{"username", "password", "roles"} vals := []interface{}{user.Username, user.Password, string(rolesJson)} diff --git a/internal/auth-v2/jwt.go b/internal/auth-v2/jwt.go index 1e26b96..b84f984 100644 --- a/internal/auth-v2/jwt.go +++ b/internal/auth-v2/jwt.go @@ -2,12 +2,14 @@ package authv2 import ( "crypto/ed25519" + "database/sql" "encoding/base64" "encoding/json" "errors" "net/http" "os" "strings" + "time" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/golang-jwt/jwt/v4" @@ -17,6 +19,8 @@ type JWTAuthenticator struct { auth *Authentication publicKey ed25519.PublicKey privateKey ed25519.PrivateKey + + maxAge time.Duration } var _ Authenticator = (*JWTAuthenticator)(nil) @@ -47,19 +51,76 @@ func (ja *JWTAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *http 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) Login(_ *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) { + rawtoken := r.Header.Get("X-Auth-Token") + if rawtoken == "" { + rawtoken = r.Header.Get("Authorization") + rawtoken = strings.TrimPrefix("Bearer ", rawtoken) + } + + 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) + exp, _ := claims["exp"].(float64) + var roles []string + if rawroles, ok := claims["roles"].([]interface{}); ok { + for _, rr := range rawroles { + if r, ok := rr.(string); ok { + roles = append(roles, r) + } + } + } + + user, err := ja.auth.GetUser(sub) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + + if err != nil && err == sql.ErrNoRows { + user = &User{ + Username: user.Username, + Roles: roles, + AuthSource: AuthViaToken, + } + if err := ja.auth.AddUser(user); err != nil { + return nil, err + } + } + + user.Expiration = time.Unix(int64(exp), 0) + return user, 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 = strings.TrimPrefix("Bearer ", rawtoken) + } + + // Because a user can also log in via a token, the + // session cookie must be checked here as well: + if rawtoken == "" { + user, err := ja.auth.AuthViaSession(rw, r) + if err != nil { + return nil, err } - rawtoken = rawtoken[len(prefix):] + + user.AuthSource = AuthViaToken + return user, nil } token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { @@ -88,10 +149,28 @@ func (ja *JWTAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User } } - // TODO: Check if sub is still a valid user! return &User{ Username: sub, Roles: roles, AuthSource: AuthViaToken, }, nil } + +// Generate a new JWT that can be used for authentication +func (ja *JWTAuthenticator) ProvideJWT(user *User) (string, error) { + if ja.privateKey == nil { + return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set") + } + + now := time.Now() + claims := jwt.MapClaims{ + "sub": user.Username, + "roles": user.Roles, + "iat": now.Unix(), + } + if ja.maxAge != 0 { + claims["exp"] = now.Add(ja.maxAge).Unix() + } + + return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey) +} diff --git a/internal/auth-v2/ldap.go b/internal/auth-v2/ldap.go index e432ded..caa37d5 100644 --- a/internal/auth-v2/ldap.go +++ b/internal/auth-v2/ldap.go @@ -70,19 +70,19 @@ func (la *LdapAutnenticator) CanLogin(user *User, rw http.ResponseWriter, r *htt return user.AuthSource == AuthViaLDAP } -func (la *LdapAutnenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error { +func (la *LdapAutnenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) { l, err := la.getLdapConnection(false) if err != nil { - return err + return nil, 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, err } - return nil + return user, nil } func (la *LdapAutnenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { diff --git a/internal/auth-v2/local.go b/internal/auth-v2/local.go index 2040beb..605f1b4 100644 --- a/internal/auth-v2/local.go +++ b/internal/auth-v2/local.go @@ -23,12 +23,12 @@ func (la *LocalAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *ht return user.AuthSource == AuthViaLocalPassword } -func (la *LocalAuthenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error { +func (la *LocalAuthenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) (*User, 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, fmt.Errorf("user '%s' provided the wrong password (%w)", user.Username, e) } - return nil + return user, nil } func (la *LocalAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { From db86d2cf7eb800c32f7b603119773efeb4a8864e Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Thu, 7 Jul 2022 13:40:38 +0200 Subject: [PATCH 3/9] Glue authenticators together --- internal/auth-v2/auth.go | 178 ++++++++++++++++++++++++++++---------- internal/auth-v2/jwt.go | 22 ++--- internal/auth-v2/ldap.go | 14 +-- internal/auth-v2/local.go | 14 +-- internal/auth-v2/users.go | 138 +++++++++++++++++++++++++++++ 5 files changed, 285 insertions(+), 81 deletions(-) create mode 100644 internal/auth-v2/users.go diff --git a/internal/auth-v2/auth.go b/internal/auth-v2/auth.go index 76b44b5..fdfd1c9 100644 --- a/internal/auth-v2/auth.go +++ b/internal/auth-v2/auth.go @@ -1,13 +1,12 @@ package authv2 import ( - "database/sql" + "context" "encoding/json" "net/http" "time" "github.com/ClusterCockpit/cc-backend/pkg/log" - sq "github.com/Masterminds/squirrel" "github.com/gorilla/sessions" "github.com/jmoiron/sqlx" ) @@ -43,10 +42,19 @@ func (u *User) HasRole(role string) bool { return false } +func GetUser(ctx context.Context) *User { + x := ctx.Value(ContextUserKey) + if x == nil { + return nil + } + + return x.(*User) +} + 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) (*User, error) + Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) } @@ -55,12 +63,17 @@ type ContextKey string const ContextUserKey ContextKey = "user" type Authentication struct { - db *sqlx.DB - sessionStore *sessions.CookieStore + db *sqlx.DB + sessionStore *sessions.CookieStore + SessionMaxAge time.Duration + authenticators []Authenticator + LdapAuth *LdapAutnenticator + JwtAuth *JWTAuthenticator + LocalAuth *LocalAuthenticator } -func Init(db *sqlx.DB) (*Authentication, error) { +func Init(db *sqlx.DB, configs map[string]json.RawMessage) (*Authentication, error) { auth := &Authentication{} auth.db = db _, err := db.Exec(` @@ -75,49 +88,27 @@ func Init(db *sqlx.DB) (*Authentication, error) { return nil, err } - return auth, nil -} - -func (auth *Authentication) GetUser(username string) (*User, error) { - user := &User{Username: username} - var hashedPassword, name, rawRoles, email sql.NullString - if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). - Where("user.username = ?", username).RunWith(auth.db). - QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email); err != nil { + auth.LocalAuth = &LocalAuthenticator{} + if err := auth.LocalAuth.Init(auth, nil); err != nil { return nil, err } + auth.authenticators = append(auth.authenticators, auth.LocalAuth) - user.Password = hashedPassword.String - user.Name = name.String - user.Email = email.String - if rawRoles.Valid { - if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { + auth.JwtAuth = &JWTAuthenticator{} + if err := auth.JwtAuth.Init(auth, nil); err != nil { + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.JwtAuth) + + if config, ok := configs["ldap"]; ok { + auth.LdapAuth = &LdapAutnenticator{} + if err := auth.LdapAuth.Init(auth, config); err != nil { return nil, err } + auth.authenticators = append(auth.authenticators, auth.LdapAuth) } - return user, nil -} - -func (auth *Authentication) AddUser(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 + return auth, nil } func (auth *Authentication) AuthViaSession(rw http.ResponseWriter, r *http.Request) (*User, error) { @@ -133,7 +124,106 @@ func (auth *Authentication) AuthViaSession(rw http.ResponseWriter, r *http.Reque username, _ := session.Values["username"].(string) roles, _ := session.Values["roles"].([]string) return &User{ - Username: username, - Roles: roles, + Username: username, + Roles: roles, + AuthSource: -1, }, nil } + +// Handle a POST request that should log the user in, starting a new session. +func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + var err error + username := r.FormValue("username") + user := (*User)(nil) + if username != "" { + if user, _ = auth.GetUser(username); err != nil { + log.Warnf("login of unkown user %#v", username) + } + } + + for _, authenticator := range auth.authenticators { + if !authenticator.CanLogin(user, rw, r) { + continue + } + + user, err = authenticator.Login(user, rw, r) + if err != nil { + log.Warnf("login failed: %s", err.Error()) + onfailure(rw, r, err) + return + } + + session, err := auth.sessionStore.New(r, "session") + if err != nil { + log.Errorf("session creation failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + if auth.SessionMaxAge != 0 { + session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) + } + session.Values["username"] = user.Username + session.Values["roles"] = user.Roles + if err := auth.sessionStore.Save(r, rw, session); err != nil { + log.Errorf("session save failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) + ctx := context.WithValue(r.Context(), ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + } + + log.Warn("login failed: no authenticator applied") + onfailure(rw, r, err) + }) +} + +// Authenticate the user and put a User object in the +// context of the request. If authentication fails, +// do not continue but send client to the login screen. +func (auth *Authentication) Auth(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + for _, authenticator := range auth.authenticators { + user, err := authenticator.Auth(rw, r) + if err != nil { + log.Warnf("authentication failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusUnauthorized) + return + } + if user == nil { + continue + } + + ctx := context.WithValue(r.Context(), ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + } + + log.Warnf("authentication failed: %s", "no authenticator applied") + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + }) +} + +// Clears the session cookie +func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + session, err := auth.sessionStore.Get(r, "session") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + if !session.IsNew { + session.Options.MaxAge = -1 + if err := auth.sessionStore.Save(r, rw, session); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } + + onsuccess.ServeHTTP(rw, r) + }) +} diff --git a/internal/auth-v2/jwt.go b/internal/auth-v2/jwt.go index b84f984..0806fdc 100644 --- a/internal/auth-v2/jwt.go +++ b/internal/auth-v2/jwt.go @@ -2,7 +2,6 @@ package authv2 import ( "crypto/ed25519" - "database/sql" "encoding/base64" "encoding/json" "errors" @@ -48,10 +47,10 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, rawConfig json.RawMessage } func (ja *JWTAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { - return user.AuthSource == AuthViaToken || r.Header.Get("Authorization") != "" + return (user != nil && user.AuthSource == AuthViaToken) || r.Header.Get("Authorization") != "" } -func (ja *JWTAuthenticator) Login(_ *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) { +func (ja *JWTAuthenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) { rawtoken := r.Header.Get("X-Auth-Token") if rawtoken == "" { rawtoken = r.Header.Get("Authorization") @@ -84,14 +83,9 @@ func (ja *JWTAuthenticator) Login(_ *User, password string, rw http.ResponseWrit } } - user, err := ja.auth.GetUser(sub) - if err != nil && err != sql.ErrNoRows { - return nil, err - } - - if err != nil && err == sql.ErrNoRows { + if user == nil { user = &User{ - Username: user.Username, + Username: sub, Roles: roles, AuthSource: AuthViaToken, } @@ -114,13 +108,7 @@ func (ja *JWTAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User // Because a user can also log in via a token, the // session cookie must be checked here as well: if rawtoken == "" { - user, err := ja.auth.AuthViaSession(rw, r) - if err != nil { - return nil, err - } - - user.AuthSource = AuthViaToken - return user, nil + return ja.auth.AuthViaSession(rw, r) } token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { diff --git a/internal/auth-v2/ldap.go b/internal/auth-v2/ldap.go index caa37d5..c12c60b 100644 --- a/internal/auth-v2/ldap.go +++ b/internal/auth-v2/ldap.go @@ -67,10 +67,10 @@ func (la *LdapAutnenticator) Init(auth *Authentication, rawConfig json.RawMessag } func (la *LdapAutnenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { - return user.AuthSource == AuthViaLDAP + return user != nil && user.AuthSource == AuthViaLDAP } -func (la *LdapAutnenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) { +func (la *LdapAutnenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) { l, err := la.getLdapConnection(false) if err != nil { return nil, err @@ -78,7 +78,7 @@ func (la *LdapAutnenticator) Login(user *User, password string, rw http.Response defer l.Close() userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) - if err := l.Bind(userDn, password); err != nil { + if err := l.Bind(userDn, r.FormValue("password")); err != nil { return nil, err } @@ -86,13 +86,7 @@ func (la *LdapAutnenticator) Login(user *User, password string, rw http.Response } 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 + return la.auth.AuthViaSession(rw, r) } func (la *LdapAutnenticator) Sync() error { diff --git a/internal/auth-v2/local.go b/internal/auth-v2/local.go index 605f1b4..e3c904e 100644 --- a/internal/auth-v2/local.go +++ b/internal/auth-v2/local.go @@ -20,11 +20,11 @@ func (la *LocalAuthenticator) Init(auth *Authentication, rawConfig json.RawMessa } func (la *LocalAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { - return user.AuthSource == AuthViaLocalPassword + return user != nil && user.AuthSource == AuthViaLocalPassword } -func (la *LocalAuthenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) { - if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); e != nil { +func (la *LocalAuthenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) { + if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { return nil, fmt.Errorf("user '%s' provided the wrong password (%w)", user.Username, e) } @@ -32,11 +32,5 @@ func (la *LocalAuthenticator) Login(user *User, password string, rw http.Respons } 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 + return la.auth.AuthViaSession(rw, r) } diff --git a/internal/auth-v2/users.go b/internal/auth-v2/users.go new file mode 100644 index 0000000..c3d673a --- /dev/null +++ b/internal/auth-v2/users.go @@ -0,0 +1,138 @@ +package authv2 + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/ClusterCockpit/cc-backend/internal/graph/model" + "github.com/ClusterCockpit/cc-backend/pkg/log" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +func (auth *Authentication) GetUser(username string) (*User, error) { + user := &User{Username: username} + var hashedPassword, name, rawRoles, email sql.NullString + if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). + Where("user.username = ?", username).RunWith(auth.db). + QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email); err != nil { + return nil, err + } + + user.Password = hashedPassword.String + user.Name = name.String + user.Email = email.String + if rawRoles.Valid { + if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { + return nil, err + } + } + + return user, nil +} + +func (auth *Authentication) AddUser(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, auth-source: %d)", user.Username, rolesJson, user.AuthSource) + return nil +} + +func (auth *Authentication) DelUser(username string) error { + _, err := auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username) + return err +} + +func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { + q := sq.Select("username", "name", "email", "roles").From("user") + if specialsOnly { + q = q.Where("(roles != '[\"user\"]' AND roles != '[]')") + } + + rows, err := q.RunWith(auth.db).Query() + if err != nil { + return nil, err + } + + users := make([]*User, 0) + defer rows.Close() + for rows.Next() { + rawroles := "" + user := &User{} + var name, email sql.NullString + if err := rows.Scan(&user.Username, &name, &email, &rawroles); err != nil { + return nil, err + } + + if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil { + return nil, err + } + + user.Name = name.String + user.Email = email.String + users = append(users, user) + } + return users, nil +} + +func (auth *Authentication) AddRole(ctx context.Context, username string, role string) error { + user, err := auth.GetUser(username) + if err != nil { + return err + } + + if role != RoleAdmin && role != RoleApi && role != RoleUser { + return fmt.Errorf("invalid user role: %#v", role) + } + + for _, r := range user.Roles { + if r == role { + return fmt.Errorf("user %#v already has role %#v", username, role) + } + } + + roles, _ := json.Marshal(append(user.Roles, role)) + if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { + return err + } + return nil +} + +func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) { + me := GetUser(ctx) + if me != nil && !me.HasRole(RoleAdmin) && me.Username != username { + return nil, errors.New("forbidden") + } + + user := &model.User{Username: username} + var name, email sql.NullString + if err := sq.Select("name", "email").From("user").Where("user.username = ?", username). + RunWith(db).QueryRow().Scan(&name, &email); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + + return nil, err + } + + user.Name = name.String + user.Email = email.String + return user, nil +} From 04574db32ff98a69b4599d687420ebd0d5791cb8 Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Thu, 7 Jul 2022 14:08:37 +0200 Subject: [PATCH 4/9] Integrate new auth interface --- cmd/cc-backend/main.go | 43 +- internal/api/rest.go | 15 +- internal/auth-v2/auth.go | 229 ----------- internal/auth-v2/ldap.go | 176 --------- internal/auth/auth.go | 589 ++++++++-------------------- internal/{auth-v2 => auth}/jwt.go | 18 +- internal/auth/ldap.go | 103 ++--- internal/{auth-v2 => auth}/local.go | 5 +- internal/{auth-v2 => auth}/users.go | 2 +- 9 files changed, 279 insertions(+), 901 deletions(-) delete mode 100644 internal/auth-v2/auth.go delete mode 100644 internal/auth-v2/ldap.go rename internal/{auth-v2 => auth}/jwt.go (89%) rename internal/{auth-v2 => auth}/local.go (86%) rename internal/{auth-v2 => auth}/users.go (99%) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 2cc6926..d614762 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -73,13 +73,11 @@ type ProgramConfig struct { DisableArchive bool `json:"disable-archive"` // For LDAP Authentication and user synchronisation. - LdapConfig *auth.LdapConfig `json:"ldap"` + LdapConfig *auth.LdapConfig `json:"ldap"` + JwtConfig *auth.JWTAuthConfig `json:"jwts"` - // Specifies for how long a session or JWT shall be valid - // as a string parsable by time.ParseDuration(). // If 0 or empty, the session/token does not expire! SessionMaxAge string `json:"session-max-age"` - JwtMaxAge string `json:"jwt-max-age"` // If both those options are not empty, use HTTPS using those certificates. HttpsCertFile string `json:"https-cert-file"` @@ -110,7 +108,6 @@ var programConfig ProgramConfig = ProgramConfig{ DisableArchive: false, LdapConfig: nil, SessionMaxAge: "168h", - JwtMaxAge: "0", UiDefaults: map[string]interface{}{ "analysis_view_histogramMetrics": []string{"flops_any", "mem_bw", "mem_used"}, "analysis_view_scatterPlotMetrics": [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}}, @@ -190,20 +187,26 @@ func main() { var authentication *auth.Authentication if !programConfig.DisableAuthentication { - authentication = &auth.Authentication{} - if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil { - authentication.SessionMaxAge = d - } - if d, err := time.ParseDuration(programConfig.JwtMaxAge); err != nil { - authentication.JwtMaxAge = d - } - - if err := authentication.Init(db.DB, programConfig.LdapConfig); err != nil { + if authentication, err = auth.Init(db.DB, map[string]interface{}{ + "ldap": programConfig.LdapConfig, + "jwt": programConfig.JwtConfig, + }); err != nil { log.Fatal(err) } + if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil { + authentication.SessionMaxAge = d + } + if flagNewUser != "" { - if err := authentication.AddUser(flagNewUser); err != nil { + parts := strings.SplitN(flagNewUser, ":", 3) + if len(parts) != 3 || len(parts[0]) == 0 { + log.Fatal("invalid argument format for user creation") + } + + if err := authentication.AddUser(&auth.User{ + Username: parts[0], Password: parts[2], Roles: strings.Split(parts[1], ","), + }); err != nil { log.Fatal(err) } } @@ -214,13 +217,17 @@ func main() { } if flagSyncLDAP { - if err := authentication.SyncWithLDAP(true); err != nil { + if authentication.LdapAuth == nil { + log.Fatal("cannot sync: LDAP authentication is not configured") + } + + if err := authentication.LdapAuth.Sync(); err != nil { log.Fatal(err) } } if flagGenJWT != "" { - user, err := authentication.FetchUser(flagGenJWT) + user, err := authentication.GetUser(flagGenJWT) if err != nil { log.Fatal(err) } @@ -229,7 +236,7 @@ func main() { log.Warn("that user does not have the API role") } - jwt, err := authentication.ProvideJWT(user) + jwt, err := authentication.JwtAuth.ProvideJWT(user) if err != nil { log.Fatal(err) } diff --git a/internal/api/rest.go b/internal/api/rest.go index b561b18..342d8b8 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -497,13 +497,13 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { } } - user, err := api.Authentication.FetchUser(username) + user, err := api.Authentication.GetUser(username) if err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } - jwt, err := api.Authentication.ProvideJWT(user) + jwt, err := api.Authentication.JwtAuth.ProvideJWT(user) if err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return @@ -527,7 +527,12 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { return } - if err := api.Authentication.CreateUser(username, name, password, email, []string{role}); err != nil { + if err := api.Authentication.AddUser(&auth.User{ + Username: username, + Name: name, + Password: password, + Email: email, + Roles: []string{role}}); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } @@ -556,9 +561,7 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { return } - users, err := api.Authentication.FetchUsers( - r.URL.Query().Get("via-ldap") == "true", - r.URL.Query().Get("not-just-user") == "true") + users, err := api.Authentication.ListUsers(r.URL.Query().Get("not-just-user") == "true") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return diff --git a/internal/auth-v2/auth.go b/internal/auth-v2/auth.go deleted file mode 100644 index fdfd1c9..0000000 --- a/internal/auth-v2/auth.go +++ /dev/null @@ -1,229 +0,0 @@ -package authv2 - -import ( - "context" - "encoding/json" - "net/http" - "time" - - "github.com/ClusterCockpit/cc-backend/pkg/log" - "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"` - Expiration time.Time -} - -func (u *User) HasRole(role string) bool { - for _, r := range u.Roles { - if r == role { - return true - } - } - return false -} - -func GetUser(ctx context.Context) *User { - x := ctx.Value(ContextUserKey) - if x == nil { - return nil - } - - return x.(*User) -} - -type Authenticator interface { - Init(auth *Authentication, config json.RawMessage) error - CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool - Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, 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 - SessionMaxAge time.Duration - - authenticators []Authenticator - LdapAuth *LdapAutnenticator - JwtAuth *JWTAuthenticator - LocalAuth *LocalAuthenticator -} - -func Init(db *sqlx.DB, configs map[string]json.RawMessage) (*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 - } - - auth.LocalAuth = &LocalAuthenticator{} - if err := auth.LocalAuth.Init(auth, nil); err != nil { - return nil, err - } - auth.authenticators = append(auth.authenticators, auth.LocalAuth) - - auth.JwtAuth = &JWTAuthenticator{} - if err := auth.JwtAuth.Init(auth, nil); err != nil { - return nil, err - } - auth.authenticators = append(auth.authenticators, auth.JwtAuth) - - if config, ok := configs["ldap"]; ok { - auth.LdapAuth = &LdapAutnenticator{} - if err := auth.LdapAuth.Init(auth, config); err != nil { - return nil, err - } - auth.authenticators = append(auth.authenticators, auth.LdapAuth) - } - - return auth, 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, - AuthSource: -1, - }, nil -} - -// Handle a POST request that should log the user in, starting a new session. -func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - var err error - username := r.FormValue("username") - user := (*User)(nil) - if username != "" { - if user, _ = auth.GetUser(username); err != nil { - log.Warnf("login of unkown user %#v", username) - } - } - - for _, authenticator := range auth.authenticators { - if !authenticator.CanLogin(user, rw, r) { - continue - } - - user, err = authenticator.Login(user, rw, r) - if err != nil { - log.Warnf("login failed: %s", err.Error()) - onfailure(rw, r, err) - return - } - - session, err := auth.sessionStore.New(r, "session") - if err != nil { - log.Errorf("session creation failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if auth.SessionMaxAge != 0 { - session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) - } - session.Values["username"] = user.Username - session.Values["roles"] = user.Roles - if err := auth.sessionStore.Save(r, rw, session); err != nil { - log.Errorf("session save failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) - ctx := context.WithValue(r.Context(), ContextUserKey, user) - onsuccess.ServeHTTP(rw, r.WithContext(ctx)) - } - - log.Warn("login failed: no authenticator applied") - onfailure(rw, r, err) - }) -} - -// Authenticate the user and put a User object in the -// context of the request. If authentication fails, -// do not continue but send client to the login screen. -func (auth *Authentication) Auth(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - for _, authenticator := range auth.authenticators { - user, err := authenticator.Auth(rw, r) - if err != nil { - log.Warnf("authentication failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - if user == nil { - continue - } - - ctx := context.WithValue(r.Context(), ContextUserKey, user) - onsuccess.ServeHTTP(rw, r.WithContext(ctx)) - } - - log.Warnf("authentication failed: %s", "no authenticator applied") - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - }) -} - -// Clears the session cookie -func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - session, err := auth.sessionStore.Get(r, "session") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if !session.IsNew { - session.Options.MaxAge = -1 - if err := auth.sessionStore.Save(r, rw, session); err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } - - onsuccess.ServeHTTP(rw, r) - }) -} diff --git a/internal/auth-v2/ldap.go b/internal/auth-v2/ldap.go deleted file mode 100644 index c12c60b..0000000 --- a/internal/auth-v2/ldap.go +++ /dev/null @@ -1,176 +0,0 @@ -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 != nil && user.AuthSource == AuthViaLDAP -} - -func (la *LdapAutnenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) { - l, err := la.getLdapConnection(false) - if err != nil { - return nil, err - } - defer l.Close() - - userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) - if err := l.Bind(userDn, r.FormValue("password")); err != nil { - return nil, err - } - - return user, nil -} - -func (la *LdapAutnenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { - return la.auth.AuthViaSession(rw, r) -} - -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/auth.go b/internal/auth/auth.go index 3fa6f1d..df2788f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,45 +2,36 @@ package auth import ( "context" - "crypto/ed25519" - "crypto/rand" - "database/sql" - "encoding/base64" - "encoding/json" - "errors" - "fmt" "net/http" - "os" - "strings" "time" - "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/pkg/log" - sq "github.com/Masterminds/squirrel" - "github.com/golang-jwt/jwt/v4" "github.com/gorilla/sessions" "github.com/jmoiron/sqlx" - "golang.org/x/crypto/bcrypt" ) -// Only Username and Roles will always be filled in when returned by `GetUser`. -// If Name and Email is needed as well, use auth.FetchUser(), which does a database -// query for all fields. -type User struct { - Username string `json:"username"` - Password string `json:"-"` - Name string `json:"name"` - Roles []string `json:"roles"` - ViaLdap bool `json:"via-ldap"` - Email string `json:"email"` -} - 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"` + Expiration time.Time +} + func (u *User) HasRole(role string) bool { for _, r := range u.Roles { if r == role { @@ -50,398 +41,6 @@ func (u *User) HasRole(role string) bool { return false } -type ContextKey string - -const ContextUserKey ContextKey = "user" - -type Authentication struct { - db *sqlx.DB - sessionStore *sessions.CookieStore - jwtPublicKey ed25519.PublicKey - jwtPrivateKey ed25519.PrivateKey - - ldapConfig *LdapConfig - ldapSyncUserPassword string - - // If zero, tokens/sessions do not expire. - SessionMaxAge time.Duration - JwtMaxAge time.Duration -} - -func (auth *Authentication) Init(db *sqlx.DB, ldapConfig *LdapConfig) error { - 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, - name varchar(255) DEFAULT NULL, - roles varchar(255) NOT NULL DEFAULT "[]", - email varchar(255) DEFAULT NULL);`) - if err != nil { - return err - } - - sessKey := os.Getenv("SESSION_KEY") - if sessKey == "" { - log.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)") - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return err - } - auth.sessionStore = sessions.NewCookieStore(bytes) - } else { - bytes, err := base64.StdEncoding.DecodeString(sessKey) - if err != nil { - return err - } - auth.sessionStore = sessions.NewCookieStore(bytes) - } - - 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 - } - auth.jwtPublicKey = ed25519.PublicKey(bytes) - bytes, err = base64.StdEncoding.DecodeString(privKey) - if err != nil { - return err - } - auth.jwtPrivateKey = ed25519.PrivateKey(bytes) - } - - if ldapConfig != nil { - auth.ldapConfig = ldapConfig - if err := auth.initLdap(); err != nil { - return err - } - } - - return nil -} - -// arg must be formated like this: ":[admin|api|]:" -func (auth *Authentication) AddUser(arg string) error { - parts := strings.SplitN(arg, ":", 3) - if len(parts) != 3 || len(parts[0]) == 0 { - return errors.New("invalid argument format") - } - - roles := strings.Split(parts[1], ",") - return auth.CreateUser(parts[0], "", parts[2], "", roles) -} - -func (auth *Authentication) CreateUser(username, name, password, email string, roles []string) error { - for _, role := range roles { - if role != RoleAdmin && role != RoleApi && role != RoleUser { - return fmt.Errorf("invalid user role: %#v", role) - } - } - - if username == "" { - return errors.New("username should not be empty") - } - - if password != "" { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return err - } - password = string(bytes) - } - - rolesJson, _ := json.Marshal(roles) - cols := []string{"username", "password", "roles"} - vals := []interface{}{username, password, string(rolesJson)} - if name != "" { - cols = append(cols, "name") - vals = append(vals, name) - } - if email != "" { - cols = append(cols, "email") - vals = append(vals, 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)", username, roles) - return nil -} - -func (auth *Authentication) AddRole(ctx context.Context, username string, role string) error { - user, err := auth.FetchUser(username) - if err != nil { - return err - } - - if role != RoleAdmin && role != RoleApi && role != RoleUser { - return fmt.Errorf("invalid user role: %#v", role) - } - - for _, r := range user.Roles { - if r == role { - return fmt.Errorf("user %#v already has role %#v", username, role) - } - } - - roles, _ := json.Marshal(append(user.Roles, role)) - if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { - return err - } - return nil -} - -func (auth *Authentication) DelUser(username string) error { - _, err := auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username) - return err -} - -func (auth *Authentication) FetchUsers(viaLdap, notJustUser bool) ([]*User, error) { - q := sq.Select("username", "name", "email", "roles").From("user") - if !viaLdap { - if notJustUser { - q = q.Where("ldap = 0 OR (roles != '[\"user\"]' AND roles != '[]')") - } else { - q = q.Where("ldap = 0") - } - } else { - if notJustUser { - q = q.Where("ldap = 1 OR (roles != '[\"user\"]' AND roles != '[]')") - } else { - q = q.Where("ldap = 1") - } - } - - rows, err := q.RunWith(auth.db).Query() - if err != nil { - return nil, err - } - - users := make([]*User, 0) - defer rows.Close() - for rows.Next() { - rawroles := "" - user := &User{} - var name, email sql.NullString - if err := rows.Scan(&user.Username, &name, &email, &rawroles); err != nil { - return nil, err - } - - if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil { - return nil, err - } - - user.Name = name.String - user.Email = email.String - users = append(users, user) - } - return users, nil -} - -func (auth *Authentication) FetchUser(username string) (*User, error) { - user := &User{Username: username} - var hashedPassword, name, rawRoles, email sql.NullString - if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). - Where("user.username = ?", username).RunWith(auth.db). - QueryRow().Scan(&hashedPassword, &user.ViaLdap, &name, &rawRoles, &email); err != nil { - return nil, fmt.Errorf("user '%s' not found (%s)", username, err.Error()) - } - - user.Password = hashedPassword.String - user.Name = name.String - user.Email = email.String - if rawRoles.Valid { - if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { - return nil, err - } - } - - return user, nil -} - -func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) { - me := GetUser(ctx) - if me != nil && !me.HasRole(RoleAdmin) && me.Username != username { - return nil, errors.New("forbidden") - } - - user := &model.User{Username: username} - var name, email sql.NullString - if err := sq.Select("name", "email").From("user").Where("user.username = ?", username). - RunWith(db).QueryRow().Scan(&name, &email); err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - - return nil, err - } - - user.Name = name.String - user.Email = email.String - return user, nil -} - -// Handle a POST request that should log the user in, starting a new session. -func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - username, password := r.FormValue("username"), r.FormValue("password") - user, err := auth.FetchUser(username) - if err == nil && user.ViaLdap && auth.ldapConfig != nil { - err = auth.loginViaLdap(user, password) - } else if err == nil && !user.ViaLdap && user.Password != "" { - if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); e != nil { - err = fmt.Errorf("user '%s' provided the wrong password (%s)", username, e.Error()) - } - } else { - err = errors.New("could not authenticate user") - } - - if err != nil { - log.Warnf("login of user %#v failed: %s", username, err.Error()) - onfailure(rw, r, err) - return - } - - session, err := auth.sessionStore.New(r, "session") - if err != nil { - log.Errorf("session creation failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if auth.SessionMaxAge != 0 { - session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) - } - session.Values["username"] = user.Username - session.Values["roles"] = user.Roles - if err := auth.sessionStore.Save(r, rw, session); err != nil { - log.Errorf("session save failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) - ctx := context.WithValue(r.Context(), ContextUserKey, user) - onsuccess.ServeHTTP(rw, r.WithContext(ctx)) - }) -} - -var ErrTokenInvalid error = errors.New("invalid token") - -func (auth *Authentication) authViaToken(r *http.Request) (*User, error) { - if auth.jwtPublicKey == nil { - return nil, nil - } - - 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 auth.jwtPublicKey, 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, - }, nil -} - -// Authenticate the user and put a User object in the -// context of the request. If authentication fails, -// do not continue but send client to the login screen. -func (auth *Authentication) Auth(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - user, err := auth.authViaToken(r) - if err != nil { - log.Warnf("authentication failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - if user != nil { - // Successfull authentication using a token - ctx := context.WithValue(r.Context(), ContextUserKey, user) - onsuccess.ServeHTTP(rw, r.WithContext(ctx)) - return - } - - session, err := auth.sessionStore.Get(r, "session") - if err != nil { - // sessionStore.Get will return a new session if no current one is attached to this request. - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if session.IsNew { - log.Warn("authentication failed: no session or jwt found") - onfailure(rw, r, errors.New("no valid session or JWT provided")) - return - } - - username, _ := session.Values["username"].(string) - roles, _ := session.Values["roles"].([]string) - ctx := context.WithValue(r.Context(), ContextUserKey, &User{ - Username: username, - Roles: roles, - }) - onsuccess.ServeHTTP(rw, r.WithContext(ctx)) - }) -} - -// Generate a new JWT that can be used for authentication -func (auth *Authentication) ProvideJWT(user *User) (string, error) { - if auth.jwtPrivateKey == nil { - return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set") - } - - now := time.Now() - claims := jwt.MapClaims{ - "sub": user.Username, - "roles": user.Roles, - "iat": now.Unix(), - } - if auth.JwtMaxAge != 0 { - claims["exp"] = now.Add(auth.JwtMaxAge).Unix() - } - - return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(auth.jwtPrivateKey) -} - func GetUser(ctx context.Context) *User { x := ctx.Value(ContextUserKey) if x == nil { @@ -451,6 +50,162 @@ func GetUser(ctx context.Context) *User { return x.(*User) } +type Authenticator interface { + Init(auth *Authentication, config interface{}) error + CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool + Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, 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 + SessionMaxAge time.Duration + + authenticators []Authenticator + LdapAuth *LdapAutnenticator + JwtAuth *JWTAuthenticator + LocalAuth *LocalAuthenticator +} + +func Init(db *sqlx.DB, configs map[string]interface{}) (*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 + } + + auth.LocalAuth = &LocalAuthenticator{} + if err := auth.LocalAuth.Init(auth, nil); err != nil { + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.LocalAuth) + + auth.JwtAuth = &JWTAuthenticator{} + if err := auth.JwtAuth.Init(auth, configs["jwt"]); err != nil { + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.JwtAuth) + + if config, ok := configs["ldap"]; ok { + auth.LdapAuth = &LdapAutnenticator{} + if err := auth.LdapAuth.Init(auth, config); err != nil { + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.LdapAuth) + } + + return auth, 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, + AuthSource: -1, + }, nil +} + +// Handle a POST request that should log the user in, starting a new session. +func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + var err error + username := r.FormValue("username") + user := (*User)(nil) + if username != "" { + if user, _ = auth.GetUser(username); err != nil { + log.Warnf("login of unkown user %#v", username) + } + } + + for _, authenticator := range auth.authenticators { + if !authenticator.CanLogin(user, rw, r) { + continue + } + + user, err = authenticator.Login(user, rw, r) + if err != nil { + log.Warnf("login failed: %s", err.Error()) + onfailure(rw, r, err) + return + } + + session, err := auth.sessionStore.New(r, "session") + if err != nil { + log.Errorf("session creation failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + if auth.SessionMaxAge != 0 { + session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) + } + session.Values["username"] = user.Username + session.Values["roles"] = user.Roles + if err := auth.sessionStore.Save(r, rw, session); err != nil { + log.Errorf("session save failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) + ctx := context.WithValue(r.Context(), ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + } + + log.Warn("login failed: no authenticator applied") + onfailure(rw, r, err) + }) +} + +// Authenticate the user and put a User object in the +// context of the request. If authentication fails, +// do not continue but send client to the login screen. +func (auth *Authentication) Auth(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + for _, authenticator := range auth.authenticators { + user, err := authenticator.Auth(rw, r) + if err != nil { + log.Warnf("authentication failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusUnauthorized) + return + } + if user == nil { + continue + } + + ctx := context.WithValue(r.Context(), ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + } + + log.Warnf("authentication failed: %s", "no authenticator applied") + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + }) +} + // Clears the session cookie func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/auth-v2/jwt.go b/internal/auth/jwt.go similarity index 89% rename from internal/auth-v2/jwt.go rename to internal/auth/jwt.go index 0806fdc..3ac63ed 100644 --- a/internal/auth-v2/jwt.go +++ b/internal/auth/jwt.go @@ -1,9 +1,8 @@ -package authv2 +package auth import ( "crypto/ed25519" "encoding/base64" - "encoding/json" "errors" "net/http" "os" @@ -14,18 +13,25 @@ import ( "github.com/golang-jwt/jwt/v4" ) +type JWTAuthConfig struct { + // Specifies for how long a session or JWT shall be valid + // as a string parsable by time.ParseDuration(). + MaxAge int64 `json:"max-age"` +} + type JWTAuthenticator struct { auth *Authentication publicKey ed25519.PublicKey privateKey ed25519.PrivateKey - maxAge time.Duration + config *JWTAuthConfig } var _ Authenticator = (*JWTAuthenticator)(nil) -func (ja *JWTAuthenticator) Init(auth *Authentication, rawConfig json.RawMessage) error { +func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { ja.auth = auth + ja.config = conf.(*JWTAuthConfig) pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") if pubKey == "" || privKey == "" { @@ -156,8 +162,8 @@ func (ja *JWTAuthenticator) ProvideJWT(user *User) (string, error) { "roles": user.Roles, "iat": now.Unix(), } - if ja.maxAge != 0 { - claims["exp"] = now.Add(ja.maxAge).Unix() + if ja.config != nil && ja.config.MaxAge != 0 { + claims["exp"] = now.Add(time.Duration(ja.config.MaxAge)).Unix() } return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey) diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 8d1fe18..6abaad6 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "net/http" "os" "strings" "time" @@ -20,14 +21,25 @@ type LdapConfig struct { SyncDelOldUsers bool `json:"sync_del_old_users"` } -func (auth *Authentication) initLdap() error { - auth.ldapSyncUserPassword = os.Getenv("LDAP_ADMIN_PASSWORD") - if auth.ldapSyncUserPassword == "" { - log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync or authentication will not work)") +type LdapAutnenticator struct { + auth *Authentication + config *LdapConfig + syncPassword string +} + +var _ Authenticator = (*LdapAutnenticator)(nil) + +func (la *LdapAutnenticator) Init(auth *Authentication, conf interface{}) error { + la.auth = auth + la.config = conf.(*LdapConfig) + + 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 auth.ldapConfig.SyncInterval != "" { - interval, err := time.ParseDuration(auth.ldapConfig.SyncInterval) + if la.config.SyncInterval != "" { + interval, err := time.ParseDuration(la.config.SyncInterval) if err != nil { return err } @@ -40,7 +52,7 @@ func (auth *Authentication) initLdap() error { ticker := time.NewTicker(interval) for t := range ticker.C { log.Printf("LDAP sync started at %s", t.Format(time.RFC3339)) - if err := auth.SyncWithLDAP(auth.ldapConfig.SyncDelOldUsers); err != nil { + if err := la.Sync(); err != nil { log.Errorf("LDAP sync failed: %s", err.Error()) } log.Print("LDAP sync done") @@ -51,53 +63,36 @@ func (auth *Authentication) initLdap() error { return nil } -// TODO: Add a connection pool or something like -// that so that connections can be reused/cached. -func (auth *Authentication) getLdapConnection(admin bool) (*ldap.Conn, error) { - conn, err := ldap.DialURL(auth.ldapConfig.Url) +func (la *LdapAutnenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { + return user != nil && user.AuthSource == AuthViaLDAP +} + +func (la *LdapAutnenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) { + l, err := la.getLdapConnection(false) if err != nil { return nil, err } - - if admin { - if err := conn.Bind(auth.ldapConfig.SearchDN, auth.ldapSyncUserPassword); err != nil { - conn.Close() - return nil, err - } - } - - return conn, nil -} - -func (auth *Authentication) loginViaLdap(user *User, password string) error { - l, err := auth.getLdapConnection(false) - if err != nil { - return err - } defer l.Close() - userDn := strings.Replace(auth.ldapConfig.UserBind, "{username}", user.Username, -1) - if err := l.Bind(userDn, password); err != nil { - return err + userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) + if err := l.Bind(userDn, r.FormValue("password")); err != nil { + return nil, err } - user.ViaLdap = true - return nil + return user, nil } -// Delete users where user.ldap is 1 and that do not show up in the ldap search results. -// Add users to the users table that are new in the ldap search results. -func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { - if auth.ldapConfig == nil { - return errors.New("ldap not enabled") - } +func (la *LdapAutnenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { + return la.auth.AuthViaSession(rw, r) +} +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 := auth.db.Query(`SELECT username FROM user WHERE user.ldap = 1`) + rows, err := la.auth.db.Query(`SELECT username FROM user WHERE user.ldap = 1`) if err != nil { return err } @@ -111,15 +106,15 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { users[username] = IN_DB } - l, err := auth.getLdapConnection(true) + l, err := la.getLdapConnection(true) if err != nil { return err } defer l.Close() ldapResults, err := l.Search(ldap.NewSearchRequest( - auth.ldapConfig.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - auth.ldapConfig.UserFilter, []string{"dn", "uid", "gecos"}, nil)) + la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + la.config.UserFilter, []string{"dn", "uid", "gecos"}, nil)) if err != nil { return err } @@ -141,15 +136,15 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { } for username, where := range users { - if where == IN_DB && deleteOldUsers { + if where == IN_DB && la.config.SyncDelOldUsers { log.Debugf("ldap-sync: remove %#v (does not show up in LDAP anymore)", username) - if _, err := auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username); err != nil { + 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 := auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, + if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, username, 1, name, "[\""+RoleUser+"\"]"); err != nil { return err } @@ -158,3 +153,21 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { 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/local.go similarity index 86% rename from internal/auth-v2/local.go rename to internal/auth/local.go index e3c904e..280b394 100644 --- a/internal/auth-v2/local.go +++ b/internal/auth/local.go @@ -1,7 +1,6 @@ -package authv2 +package auth import ( - "encoding/json" "fmt" "net/http" @@ -14,7 +13,7 @@ type LocalAuthenticator struct { var _ Authenticator = (*LocalAuthenticator)(nil) -func (la *LocalAuthenticator) Init(auth *Authentication, rawConfig json.RawMessage) error { +func (la *LocalAuthenticator) Init(auth *Authentication, _ interface{}) error { la.auth = auth return nil } diff --git a/internal/auth-v2/users.go b/internal/auth/users.go similarity index 99% rename from internal/auth-v2/users.go rename to internal/auth/users.go index c3d673a..f21bafa 100644 --- a/internal/auth-v2/users.go +++ b/internal/auth/users.go @@ -1,4 +1,4 @@ -package authv2 +package auth import ( "context" From 187d6a1844ff54b846bf37258bba3b834854b390 Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Thu, 7 Jul 2022 14:10:12 +0200 Subject: [PATCH 5/9] Move gen-keypair helper to cmd/ --- {tools => cmd/gen-keypair}/gen-keypair.go | 0 tools/README.md => configs/README_TOKENS.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {tools => cmd/gen-keypair}/gen-keypair.go (100%) rename tools/README.md => configs/README_TOKENS.md (100%) diff --git a/tools/gen-keypair.go b/cmd/gen-keypair/gen-keypair.go similarity index 100% rename from tools/gen-keypair.go rename to cmd/gen-keypair/gen-keypair.go diff --git a/tools/README.md b/configs/README_TOKENS.md similarity index 100% rename from tools/README.md rename to configs/README_TOKENS.md From f0530f2908b1b6a9832564658ea70705b940703c Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Wed, 13 Jul 2022 15:04:11 +0200 Subject: [PATCH 6/9] Different keypair for token based login --- internal/auth/jwt.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 3ac63ed..5fab54b 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -20,9 +20,10 @@ type JWTAuthConfig struct { } type JWTAuthenticator struct { - auth *Authentication - publicKey ed25519.PublicKey - privateKey ed25519.PrivateKey + auth *Authentication + publicKey ed25519.PublicKey + loginPublicKey ed25519.PublicKey + privateKey ed25519.PrivateKey config *JWTAuthConfig } @@ -42,6 +43,7 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { return err } ja.publicKey = ed25519.PublicKey(bytes) + ja.loginPublicKey = ja.publicKey bytes, err = base64.StdEncoding.DecodeString(privKey) if err != nil { return err @@ -49,6 +51,14 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { ja.privateKey = ed25519.PrivateKey(bytes) } + if pubKey = os.Getenv("CROSS_LOGIN_JWT_PUBLIC_KEY"); pubKey != "" { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + return err + } + ja.loginPublicKey = bytes + } + return nil } From 5a22e5f32d8d937b1c343b9ce6a55867a9bb29a2 Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Mon, 25 Jul 2022 08:58:44 +0200 Subject: [PATCH 7/9] Update READMEs --- configs/README.md | 3 ++- configs/README_TOKENS.md | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/configs/README.md b/configs/README.md index 143406a..5a93a57 100644 --- a/configs/README.md +++ b/configs/README.md @@ -52,6 +52,7 @@ All security relevant configuration. e.g., keys and passwords, are set using env An example env file is found in this directory. Copy it to `.env` in the project root and adapt it for your needs. -* `JWT_PUBLIC_KEY` and `JWT_PRIVATE_KEY`: Base64 encoded Ed25519 keys used for JSON Web Token (JWT) authentication . TODO: Details! You can generate your own keypair using `go run utils/gen-keypair.go` +* `JWT_PUBLIC_KEY` and `JWT_PRIVATE_KEY`: Base64 encoded Ed25519 keys used for JSON Web Token (JWT) authentication. You can generate your own keypair using `go run utils/gen-keypair.go`. More information in [README_TOKENS.md](./README_TOKENS.md) * `SESSION_KEY`: Some random bytes used as secret for cookie-based sessions. * `LDAP_ADMIN_PASSWORD`: The LDAP admin user password (optional). +* `CROSS_LOGIN_JWT_HS512_KEY`: Used for token based logins via another authentication service. diff --git a/configs/README_TOKENS.md b/configs/README_TOKENS.md index bdb6367..be8a912 100644 --- a/configs/README_TOKENS.md +++ b/configs/README_TOKENS.md @@ -5,7 +5,9 @@ JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and s This information can be verified and trusted because it is digitally signed. In ClusterCockpit JWTs are signed using a public/private key pair using ECDSA. Because tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it. -Currently JWT tokens in ClusterCockpit not yet expire. +Expiration of the generated tokens as well as the max. length of a browser session can be configured in the `config.json` file described [here](./README.md). + +The [Ed25519](https://ed25519.cr.yp.to/) algorithm for signatures was used because it is compatible with other tools that require authentication, such as NATS.io, and because these elliptic-curve methods provide simillar security with smaller keys compared to something like RSA. They are sligthly more expensive to validate, but that effect is negligible. ## JWT Payload @@ -14,33 +16,36 @@ Currently ClusterCockpit sets the following claims: * `iat`: Issued at claim. The “iat” claim is used to identify the the time at which the JWT was issued. This claim can be used to determine the age of the JWT. * `sub`: Subject claim. Identifies the subject of the JWT, in our case this is the username. * `roles`: An array of strings specifying the roles set for the subject. +* `exp`: Expiration date of the token (only if explicitly configured) + +It is important to know that JWTs are not encrypted, only signed. This means that outsiders cannot create new JWTs or modify existing ones, but they are able to read out the username. ## Workflow 1. Create a new ECDSA Public/private keypair: ``` -$ go build ./tools/gen-keypair.go +$ go build ./cmd/gen-keypair/ $ ./gen-keypair ``` 2. Add keypair in your `.env` file. A template can be found in `./configs`. -There are two usage scenarios: -* The APIs are used during a browser session. In this case on login a JWT token is issued on login, that is used by the web frontend to authorize against the GraphQL and REST APIs. -* The REST API is used outside a browser session, e.g. by scripts. In this case you have to issue a token manually. This possible from within the configuration view or on the command line. It is recommended to issue a JWT token in this case for a special user that only has the `api` role. By using different users for different purposes a fine grained access control and access revocation management is possible. +When a user logs in via the `/login` page using a browser, a session cookie (secured using the random bytes in the `SESSION_KEY` env. variable you shoud change as well) is used for all requests after the successfull login. The JWTs make it easier to use the APIs of ClusterCockpit using scripts or other external programs. The token is specified n the `Authorization` HTTP header using the [Bearer schema](https://datatracker.ietf.org/doc/html/rfc6750) (there is an example below). Tokens can be issued to users from the configuration view in the Web-UI or the command line. In order to use the token for API endpoints such as `/api/jobs/start_job/`, the user that executes it needs to have the `api` role. Regular users can only perform read-only queries and only look at data connected to jobs they started themselves. -The token is commonly specified in the Authorization HTTP header using the Bearer schema. +## cc-metric-store + +The [cc-metric-store](https://github.com/ClusterCockpit/cc-metric-store) also uses JWTs for authentication. As it does not issue new tokens, it does not need to kown the private key. The public key of the keypair that is used to generate the JWTs that grant access to the `cc-metric-store` can be specified in its `config.json`. When configuring the `metricDataRepository` object in the `cluster.json` file, you can put a token issued by ClusterCockpit itself. ## Setup user and JWT token for REST API authorization 1. Create user: ``` -$ ./cc-backend --add-user :api: --no-server +$ ./cc-backend --add-user :api: --no-server ``` 2. Issue token for user: ``` -$ ./cc-backend -jwt -no-server +$ ./cc-backend --jwt --no-server ``` 3. Use issued token token on client side: ``` -$ curl -X GET "" -H "accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer " +$ curl -X GET "" -H "accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer " ``` From 2d57e4cfe844c641eb08ce535c55acf04cd1c05f Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Mon, 25 Jul 2022 09:03:48 +0200 Subject: [PATCH 8/9] Change to HS256 as login token alg --- internal/auth/auth_test.go | 1 + internal/auth/jwt.go | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 internal/auth/auth_test.go diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1 @@ +package auth diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 5fab54b..b37de42 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "encoding/base64" "errors" + "fmt" "net/http" "os" "strings" @@ -20,10 +21,12 @@ type JWTAuthConfig struct { } type JWTAuthenticator struct { - auth *Authentication - publicKey ed25519.PublicKey - loginPublicKey ed25519.PublicKey - privateKey ed25519.PrivateKey + auth *Authentication + + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey + + loginTokenKey []byte // HS256 key config *JWTAuthConfig } @@ -43,7 +46,6 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { return err } ja.publicKey = ed25519.PublicKey(bytes) - ja.loginPublicKey = ja.publicKey bytes, err = base64.StdEncoding.DecodeString(privKey) if err != nil { return err @@ -51,12 +53,12 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { ja.privateKey = ed25519.PrivateKey(bytes) } - if pubKey = os.Getenv("CROSS_LOGIN_JWT_PUBLIC_KEY"); pubKey != "" { + if pubKey = os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { bytes, err := base64.StdEncoding.DecodeString(pubKey) if err != nil { return err } - ja.loginPublicKey = bytes + ja.loginTokenKey = bytes } return nil @@ -74,10 +76,13 @@ func (ja *JWTAuthenticator) Login(user *User, rw http.ResponseWriter, r *http.Re } token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { - if t.Method != jwt.SigningMethodEdDSA { - return nil, errors.New("only Ed25519/EdDSA supported") + if t.Method == jwt.SigningMethodEdDSA { + return ja.publicKey, nil } - return ja.publicKey, nil + if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 { + return ja.loginTokenKey, nil + } + return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg()) }) if err != nil { return nil, err From a48e94ab3efa070796d5294967154c58eeb45d31 Mon Sep 17 00:00:00 2001 From: Lou Knauer Date: Mon, 25 Jul 2022 09:33:36 +0200 Subject: [PATCH 9/9] bugfixes in auth/ --- cmd/cc-backend/main.go | 1 + internal/auth/auth.go | 25 ++++++++++++++++++++++++- internal/auth/jwt.go | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index d614762..84bc2cc 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -224,6 +224,7 @@ func main() { if err := authentication.LdapAuth.Sync(); err != nil { log.Fatal(err) } + log.Info("LDAP sync successfull") } if flagGenJWT != "" { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index df2788f..8bb538f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,7 +2,11 @@ package auth import ( "context" + "crypto/rand" + "encoding/base64" + "errors" "net/http" + "os" "time" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -87,6 +91,22 @@ func Init(db *sqlx.DB, configs map[string]interface{}) (*Authentication, error) return nil, err } + sessKey := os.Getenv("SESSION_KEY") + if sessKey == "" { + log.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)") + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return nil, err + } + auth.sessionStore = sessions.NewCookieStore(bytes) + } else { + bytes, err := base64.StdEncoding.DecodeString(sessKey) + if err != nil { + return nil, err + } + auth.sessionStore = sessions.NewCookieStore(bytes) + } + auth.LocalAuth = &LocalAuthenticator{} if err := auth.LocalAuth.Init(auth, nil); err != nil { return nil, err @@ -174,6 +194,7 @@ func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) ctx := context.WithValue(r.Context(), ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return } log.Warn("login failed: no authenticator applied") @@ -199,10 +220,12 @@ func (auth *Authentication) Auth(onsuccess http.Handler, onfailure func(rw http. ctx := context.WithValue(r.Context(), ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return } log.Warnf("authentication failed: %s", "no authenticator applied") - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + // http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + onfailure(rw, r, errors.New("unauthorized (login first or use a token)")) }) } diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index b37de42..3943c3d 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -123,7 +123,7 @@ func (ja *JWTAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User rawtoken := r.Header.Get("X-Auth-Token") if rawtoken == "" { rawtoken = r.Header.Get("Authorization") - rawtoken = strings.TrimPrefix("Bearer ", rawtoken) + rawtoken = strings.TrimPrefix(rawtoken, "Bearer ") } // Because a user can also log in via a token, the