Integrate new auth interface

This commit is contained in:
Lou Knauer 2022-07-07 14:08:37 +02:00
parent db86d2cf7e
commit 04574db32f
9 changed files with 279 additions and 901 deletions

View File

@ -74,12 +74,10 @@ type ProgramConfig struct {
// For LDAP Authentication and user synchronisation. // 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! // If 0 or empty, the session/token does not expire!
SessionMaxAge string `json:"session-max-age"` SessionMaxAge string `json:"session-max-age"`
JwtMaxAge string `json:"jwt-max-age"`
// If both those options are not empty, use HTTPS using those certificates. // If both those options are not empty, use HTTPS using those certificates.
HttpsCertFile string `json:"https-cert-file"` HttpsCertFile string `json:"https-cert-file"`
@ -110,7 +108,6 @@ var programConfig ProgramConfig = ProgramConfig{
DisableArchive: false, DisableArchive: false,
LdapConfig: nil, LdapConfig: nil,
SessionMaxAge: "168h", SessionMaxAge: "168h",
JwtMaxAge: "0",
UiDefaults: map[string]interface{}{ UiDefaults: map[string]interface{}{
"analysis_view_histogramMetrics": []string{"flops_any", "mem_bw", "mem_used"}, "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"}}, "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 var authentication *auth.Authentication
if !programConfig.DisableAuthentication { if !programConfig.DisableAuthentication {
authentication = &auth.Authentication{} if authentication, err = auth.Init(db.DB, map[string]interface{}{
if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil { "ldap": programConfig.LdapConfig,
authentication.SessionMaxAge = d "jwt": programConfig.JwtConfig,
} }); err != nil {
if d, err := time.ParseDuration(programConfig.JwtMaxAge); err != nil {
authentication.JwtMaxAge = d
}
if err := authentication.Init(db.DB, programConfig.LdapConfig); err != nil {
log.Fatal(err) log.Fatal(err)
} }
if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil {
authentication.SessionMaxAge = d
}
if flagNewUser != "" { 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) log.Fatal(err)
} }
} }
@ -214,13 +217,17 @@ func main() {
} }
if flagSyncLDAP { 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) log.Fatal(err)
} }
} }
if flagGenJWT != "" { if flagGenJWT != "" {
user, err := authentication.FetchUser(flagGenJWT) user, err := authentication.GetUser(flagGenJWT)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -229,7 +236,7 @@ func main() {
log.Warn("that user does not have the API role") log.Warn("that user does not have the API role")
} }
jwt, err := authentication.ProvideJWT(user) jwt, err := authentication.JwtAuth.ProvideJWT(user)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -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 { if err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }
jwt, err := api.Authentication.ProvideJWT(user) jwt, err := api.Authentication.JwtAuth.ProvideJWT(user)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
@ -527,7 +527,12 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
return 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) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }
@ -556,9 +561,7 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) {
return return
} }
users, err := api.Authentication.FetchUsers( users, err := api.Authentication.ListUsers(r.URL.Query().Get("not-just-user") == "true")
r.URL.Query().Get("via-ldap") == "true",
r.URL.Query().Get("not-just-user") == "true")
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return return

View File

@ -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)
})
}

View File

@ -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
}

View File

@ -2,45 +2,36 @@ package auth
import ( import (
"context" "context"
"crypto/ed25519"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"os"
"strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
sq "github.com/Masterminds/squirrel"
"github.com/golang-jwt/jwt/v4"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/jmoiron/sqlx" "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 ( const (
RoleAdmin string = "admin" RoleAdmin string = "admin"
RoleApi string = "api" RoleApi string = "api"
RoleUser string = "user" 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 { func (u *User) HasRole(role string) bool {
for _, r := range u.Roles { for _, r := range u.Roles {
if r == role { if r == role {
@ -50,6 +41,22 @@ func (u *User) HasRole(role string) bool {
return false 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 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 type ContextKey string
const ContextUserKey ContextKey = "user" const ContextUserKey ContextKey = "user"
@ -57,253 +64,91 @@ const ContextUserKey ContextKey = "user"
type Authentication struct { type Authentication struct {
db *sqlx.DB db *sqlx.DB
sessionStore *sessions.CookieStore sessionStore *sessions.CookieStore
jwtPublicKey ed25519.PublicKey
jwtPrivateKey ed25519.PrivateKey
ldapConfig *LdapConfig
ldapSyncUserPassword string
// If zero, tokens/sessions do not expire.
SessionMaxAge time.Duration SessionMaxAge time.Duration
JwtMaxAge time.Duration
authenticators []Authenticator
LdapAuth *LdapAutnenticator
JwtAuth *JWTAuthenticator
LocalAuth *LocalAuthenticator
} }
func (auth *Authentication) Init(db *sqlx.DB, ldapConfig *LdapConfig) error { func Init(db *sqlx.DB, configs map[string]interface{}) (*Authentication, error) {
auth := &Authentication{}
auth.db = db auth.db = db
_, err := db.Exec(` _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS user (
username varchar(255) PRIMARY KEY NOT NULL, username varchar(255) PRIMARY KEY NOT NULL,
password varchar(255) DEFAULT NULL, password varchar(255) DEFAULT NULL,
ldap tinyint NOT NULL DEFAULT 0, ldap tinyint NOT NULL DEFAULT 0, /* col called "ldap" for historic reasons, fills the "AuthSource" */
name varchar(255) DEFAULT NULL, name varchar(255) DEFAULT NULL,
roles varchar(255) NOT NULL DEFAULT "[]", roles varchar(255) NOT NULL DEFAULT "[]",
email varchar(255) DEFAULT NULL);`) email varchar(255) DEFAULT NULL);`)
if err != nil { if err != nil {
return err return nil, err
} }
sessKey := os.Getenv("SESSION_KEY") auth.LocalAuth = &LocalAuthenticator{}
if sessKey == "" { if err := auth.LocalAuth.Init(auth, nil); err != nil {
log.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)") return nil, err
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return err
} }
auth.sessionStore = sessions.NewCookieStore(bytes) auth.authenticators = append(auth.authenticators, auth.LocalAuth)
} else {
bytes, err := base64.StdEncoding.DecodeString(sessKey) auth.JwtAuth = &JWTAuthenticator{}
if err != nil { if err := auth.JwtAuth.Init(auth, configs["jwt"]); err != nil {
return err return nil, err
} }
auth.sessionStore = sessions.NewCookieStore(bytes) 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)
} }
pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") return auth, nil
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: "<username>:[admin|api|]:<password>" func (auth *Authentication) AuthViaSession(rw http.ResponseWriter, r *http.Request) (*User, error) {
func (auth *Authentication) AddUser(arg string) error { session, err := auth.sessionStore.Get(r, "session")
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 { if err != nil {
return nil, err return nil, err
} }
users := make([]*User, 0) if session.IsNew {
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, nil
} }
return nil, err username, _ := session.Values["username"].(string)
} roles, _ := session.Values["roles"].([]string)
return &User{
user.Name = name.String Username: username,
user.Email = email.String Roles: roles,
return user, nil AuthSource: -1,
}, nil
} }
// Handle a POST request that should log the user in, starting a new session. // 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 { 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) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
username, password := r.FormValue("username"), r.FormValue("password") var err error
user, err := auth.FetchUser(username) username := r.FormValue("username")
if err == nil && user.ViaLdap && auth.ldapConfig != nil { user := (*User)(nil)
err = auth.loginViaLdap(user, password) if username != "" {
} else if err == nil && !user.ViaLdap && user.Password != "" { if user, _ = auth.GetUser(username); err != nil {
if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); e != nil { log.Warnf("login of unkown user %#v", username)
err = fmt.Errorf("user '%s' provided the wrong password (%s)", username, e.Error())
} }
} else {
err = errors.New("could not authenticate user")
} }
for _, authenticator := range auth.authenticators {
if !authenticator.CanLogin(user, rw, r) {
continue
}
user, err = authenticator.Login(user, rw, r)
if err != nil { if err != nil {
log.Warnf("login of user %#v failed: %s", username, err.Error()) log.Warnf("login failed: %s", err.Error())
onfailure(rw, r, err) onfailure(rw, r, err)
return return
} }
@ -329,126 +174,36 @@ func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http
log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles)
ctx := context.WithValue(r.Context(), ContextUserKey, user) ctx := context.WithValue(r.Context(), ContextUserKey, user)
onsuccess.ServeHTTP(rw, r.WithContext(ctx)) onsuccess.ServeHTTP(rw, r.WithContext(ctx))
}
log.Warn("login failed: no authenticator applied")
onfailure(rw, r, err)
}) })
} }
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 // Authenticate the user and put a User object in the
// context of the request. If authentication fails, // context of the request. If authentication fails,
// do not continue but send client to the login screen. // 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 { 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) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
user, err := auth.authViaToken(r) for _, authenticator := range auth.authenticators {
user, err := authenticator.Auth(rw, r)
if err != nil { if err != nil {
log.Warnf("authentication failed: %s", err.Error()) log.Warnf("authentication failed: %s", err.Error())
http.Error(rw, err.Error(), http.StatusUnauthorized) http.Error(rw, err.Error(), http.StatusUnauthorized)
return return
} }
if user != nil { if user == nil {
// Successfull authentication using a token continue
}
ctx := context.WithValue(r.Context(), ContextUserKey, user) ctx := context.WithValue(r.Context(), ContextUserKey, user)
onsuccess.ServeHTTP(rw, r.WithContext(ctx)) onsuccess.ServeHTTP(rw, r.WithContext(ctx))
return
} }
session, err := auth.sessionStore.Get(r, "session") log.Warnf("authentication failed: %s", "no authenticator applied")
if err != nil { http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
// 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 {
return nil
}
return x.(*User)
} }
// Clears the session cookie // Clears the session cookie

View File

@ -1,9 +1,8 @@
package authv2 package auth
import ( import (
"crypto/ed25519" "crypto/ed25519"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"os" "os"
@ -14,18 +13,25 @@ import (
"github.com/golang-jwt/jwt/v4" "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 { type JWTAuthenticator struct {
auth *Authentication auth *Authentication
publicKey ed25519.PublicKey publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey privateKey ed25519.PrivateKey
maxAge time.Duration config *JWTAuthConfig
} }
var _ Authenticator = (*JWTAuthenticator)(nil) 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.auth = auth
ja.config = conf.(*JWTAuthConfig)
pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY")
if pubKey == "" || privKey == "" { if pubKey == "" || privKey == "" {
@ -156,8 +162,8 @@ func (ja *JWTAuthenticator) ProvideJWT(user *User) (string, error) {
"roles": user.Roles, "roles": user.Roles,
"iat": now.Unix(), "iat": now.Unix(),
} }
if ja.maxAge != 0 { if ja.config != nil && ja.config.MaxAge != 0 {
claims["exp"] = now.Add(ja.maxAge).Unix() claims["exp"] = now.Add(time.Duration(ja.config.MaxAge)).Unix()
} }
return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey) return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey)

View File

@ -2,6 +2,7 @@ package auth
import ( import (
"errors" "errors"
"net/http"
"os" "os"
"strings" "strings"
"time" "time"
@ -20,14 +21,25 @@ type LdapConfig struct {
SyncDelOldUsers bool `json:"sync_del_old_users"` SyncDelOldUsers bool `json:"sync_del_old_users"`
} }
func (auth *Authentication) initLdap() error { type LdapAutnenticator struct {
auth.ldapSyncUserPassword = os.Getenv("LDAP_ADMIN_PASSWORD") auth *Authentication
if auth.ldapSyncUserPassword == "" { config *LdapConfig
log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync or authentication will not work)") 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 != "" { if la.config.SyncInterval != "" {
interval, err := time.ParseDuration(auth.ldapConfig.SyncInterval) interval, err := time.ParseDuration(la.config.SyncInterval)
if err != nil { if err != nil {
return err return err
} }
@ -40,7 +52,7 @@ func (auth *Authentication) initLdap() error {
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
for t := range ticker.C { for t := range ticker.C {
log.Printf("LDAP sync started at %s", t.Format(time.RFC3339)) 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.Errorf("LDAP sync failed: %s", err.Error())
} }
log.Print("LDAP sync done") log.Print("LDAP sync done")
@ -51,53 +63,36 @@ func (auth *Authentication) initLdap() error {
return nil return nil
} }
// TODO: Add a connection pool or something like func (la *LdapAutnenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool {
// that so that connections can be reused/cached. return user != nil && user.AuthSource == AuthViaLDAP
func (auth *Authentication) getLdapConnection(admin bool) (*ldap.Conn, error) {
conn, err := ldap.DialURL(auth.ldapConfig.Url)
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 { func (la *LdapAutnenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) {
l, err := auth.getLdapConnection(false) l, err := la.getLdapConnection(false)
if err != nil { if err != nil {
return err return nil, err
} }
defer l.Close() defer l.Close()
userDn := strings.Replace(auth.ldapConfig.UserBind, "{username}", user.Username, -1) 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 err return nil, err
} }
user.ViaLdap = true return user, nil
return nil
} }
// Delete users where user.ldap is 1 and that do not show up in the ldap search results. func (la *LdapAutnenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) {
// Add users to the users table that are new in the ldap search results. return la.auth.AuthViaSession(rw, r)
func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { }
if auth.ldapConfig == nil {
return errors.New("ldap not enabled")
}
func (la *LdapAutnenticator) Sync() error {
const IN_DB int = 1 const IN_DB int = 1
const IN_LDAP int = 2 const IN_LDAP int = 2
const IN_BOTH int = 3 const IN_BOTH int = 3
users := map[string]int{} 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 { if err != nil {
return err return err
} }
@ -111,15 +106,15 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error {
users[username] = IN_DB users[username] = IN_DB
} }
l, err := auth.getLdapConnection(true) l, err := la.getLdapConnection(true)
if err != nil { if err != nil {
return err return err
} }
defer l.Close() defer l.Close()
ldapResults, err := l.Search(ldap.NewSearchRequest( ldapResults, err := l.Search(ldap.NewSearchRequest(
auth.ldapConfig.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
auth.ldapConfig.UserFilter, []string{"dn", "uid", "gecos"}, nil)) la.config.UserFilter, []string{"dn", "uid", "gecos"}, nil))
if err != nil { if err != nil {
return err return err
} }
@ -141,15 +136,15 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error {
} }
for username, where := range users { 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) 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 return err
} }
} else if where == IN_LDAP { } else if where == IN_LDAP {
name := newnames[username] name := newnames[username]
log.Debugf("ldap-sync: add %#v (name: %#v, roles: [user], ldap: true)", username, name) 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 { username, 1, name, "[\""+RoleUser+"\"]"); err != nil {
return err return err
} }
@ -158,3 +153,21 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error {
return nil 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
}

View File

@ -1,7 +1,6 @@
package authv2 package auth
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -14,7 +13,7 @@ type LocalAuthenticator struct {
var _ Authenticator = (*LocalAuthenticator)(nil) 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 la.auth = auth
return nil return nil
} }

View File

@ -1,4 +1,4 @@
package authv2 package auth
import ( import (
"context" "context"