mirror of
				https://github.com/ClusterCockpit/cc-backend
				synced 2025-11-03 17:15:06 +01:00 
			
		
		
		
	authentication: roles as regular array; simplified LDAP
This commit is contained in:
		
							
								
								
									
										11
									
								
								api/rest.go
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								api/rest.go
									
									
									
									
									
								
							@@ -12,6 +12,7 @@ import (
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"sync"
 | 
			
		||||
 | 
			
		||||
	"github.com/ClusterCockpit/cc-jobarchive/auth"
 | 
			
		||||
	"github.com/ClusterCockpit/cc-jobarchive/config"
 | 
			
		||||
	"github.com/ClusterCockpit/cc-jobarchive/graph"
 | 
			
		||||
	"github.com/ClusterCockpit/cc-jobarchive/graph/model"
 | 
			
		||||
@@ -177,6 +178,11 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
 | 
			
		||||
// A new job started. The body should be in the `meta.json` format, but some fields required
 | 
			
		||||
// there are optional here (e.g. `jobState` defaults to "running").
 | 
			
		||||
func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) {
 | 
			
		||||
		http.Error(rw, "Missing 'api' role", http.StatusForbidden)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	req := schema.JobMeta{BaseJob: schema.JobDefaults}
 | 
			
		||||
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 | 
			
		||||
		http.Error(rw, err.Error(), http.StatusBadRequest)
 | 
			
		||||
@@ -246,6 +252,11 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
 | 
			
		||||
 | 
			
		||||
// A job has stopped and should be archived.
 | 
			
		||||
func (api *RestApi) stopJob(rw http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) {
 | 
			
		||||
		http.Error(rw, "Missing 'api' role", http.StatusForbidden)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	req := StopJobApiRequest{}
 | 
			
		||||
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 | 
			
		||||
		http.Error(rw, err.Error(), http.StatusBadRequest)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										78
									
								
								auth/auth.go
									
									
									
									
									
								
							
							
						
						
									
										78
									
								
								auth/auth.go
									
									
									
									
									
								
							@@ -28,12 +28,26 @@ type User struct {
 | 
			
		||||
	Username string
 | 
			
		||||
	Password string
 | 
			
		||||
	Name     string
 | 
			
		||||
	IsAdmin   bool
 | 
			
		||||
	IsAPIUser bool
 | 
			
		||||
	Roles    []string
 | 
			
		||||
	ViaLdap  bool
 | 
			
		||||
	Email    string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	RoleAdmin string = "admin"
 | 
			
		||||
	RoleApi   string = "api"
 | 
			
		||||
	RoleUser  string = "user"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func (u *User) HasRole(role string) bool {
 | 
			
		||||
	for _, r := range u.Roles {
 | 
			
		||||
		if r == role {
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ContextKey string
 | 
			
		||||
 | 
			
		||||
const ContextUserKey ContextKey = "user"
 | 
			
		||||
@@ -100,24 +114,32 @@ func Init(db *sqlx.DB, ldapConfig *LdapConfig) error {
 | 
			
		||||
// arg must be formated like this: "<username>:[admin]:<password>"
 | 
			
		||||
func AddUserToDB(db *sqlx.DB, arg string) error {
 | 
			
		||||
	parts := strings.SplitN(arg, ":", 3)
 | 
			
		||||
	if len(parts) != 3 || len(parts[0]) == 0 || len(parts[2]) == 0 || !(len(parts[1]) == 0 || parts[1] == "admin") {
 | 
			
		||||
	if len(parts) != 3 || len(parts[0]) == 0 {
 | 
			
		||||
		return errors.New("invalid argument format")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	password, err := bcrypt.GenerateFromPassword([]byte(parts[2]), bcrypt.DefaultCost)
 | 
			
		||||
	password := ""
 | 
			
		||||
	if len(parts[2]) > 0 {
 | 
			
		||||
		bytes, err := bcrypt.GenerateFromPassword([]byte(parts[2]), bcrypt.DefaultCost)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	roles := "[]"
 | 
			
		||||
	if parts[1] == "admin" {
 | 
			
		||||
		roles = "[\"ROLE_ADMIN\"]"
 | 
			
		||||
	}
 | 
			
		||||
	if parts[1] == "api" {
 | 
			
		||||
		roles = "[\"ROLE_API\"]"
 | 
			
		||||
		password = string(bytes)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = sq.Insert("user").Columns("username", "password", "roles").Values(parts[0], string(password), roles).RunWith(db).Exec()
 | 
			
		||||
	roles := []string{}
 | 
			
		||||
	for _, role := range strings.Split(parts[1], ",") {
 | 
			
		||||
		if len(role) == 0 {
 | 
			
		||||
			continue
 | 
			
		||||
		} else if role == RoleAdmin || role == RoleApi || role == RoleUser {
 | 
			
		||||
			roles = append(roles, role)
 | 
			
		||||
		} else {
 | 
			
		||||
			return fmt.Errorf("invalid user role: %#v", role)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rolesJson, _ := json.Marshal(roles)
 | 
			
		||||
	_, err := sq.Insert("user").Columns("username", "password", "roles").Values(parts[0], password, string(rolesJson)).RunWith(db).Exec()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
@@ -142,17 +164,8 @@ func FetchUserFromDB(db *sqlx.DB, username string) (*User, error) {
 | 
			
		||||
	user.Password = hashedPassword.String
 | 
			
		||||
	user.Name = name.String
 | 
			
		||||
	user.Email = email.String
 | 
			
		||||
	var roles []string
 | 
			
		||||
	if rawRoles.Valid {
 | 
			
		||||
		json.Unmarshal([]byte(rawRoles.String), &roles)
 | 
			
		||||
	}
 | 
			
		||||
	for _, role := range roles {
 | 
			
		||||
		switch role {
 | 
			
		||||
		case "ROLE_ADMIN":
 | 
			
		||||
			user.IsAdmin = true
 | 
			
		||||
		case "ROLE_API":
 | 
			
		||||
			user.IsAPIUser = true
 | 
			
		||||
		}
 | 
			
		||||
		json.Unmarshal([]byte(rawRoles.String), &user.Roles)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return user, nil
 | 
			
		||||
@@ -195,14 +208,14 @@ func Login(db *sqlx.DB) http.Handler {
 | 
			
		||||
 | 
			
		||||
		session.Options.MaxAge = 30 * 24 * 60 * 60
 | 
			
		||||
		session.Values["username"] = user.Username
 | 
			
		||||
		session.Values["is_admin"] = user.IsAdmin
 | 
			
		||||
		session.Values["roles"] = user.Roles
 | 
			
		||||
		if err := sessionStore.Save(r, rw, session); err != nil {
 | 
			
		||||
			log.Printf("session save failed: %s\n", err.Error())
 | 
			
		||||
			http.Error(rw, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Printf("login successfull: user: %#v\n", user)
 | 
			
		||||
		log.Printf("login successfull: user: %#v (roles: %v)\n", user.Username, user.Roles)
 | 
			
		||||
		http.Redirect(rw, r, "/", http.StatusTemporaryRedirect)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
@@ -240,14 +253,12 @@ func authViaToken(r *http.Request) (*User, error) {
 | 
			
		||||
 | 
			
		||||
	claims := token.Claims.(jwt.MapClaims)
 | 
			
		||||
	sub, _ := claims["sub"].(string)
 | 
			
		||||
	isAdmin, _ := claims["is_admin"].(bool)
 | 
			
		||||
	isAPIUser, _ := claims["is_api"].(bool)
 | 
			
		||||
	roles, _ := claims["roles"].([]string)
 | 
			
		||||
 | 
			
		||||
	// TODO: Check if sub is still a valid user!
 | 
			
		||||
	return &User{
 | 
			
		||||
		Username: sub,
 | 
			
		||||
		IsAdmin:   isAdmin,
 | 
			
		||||
		IsAPIUser: isAPIUser,
 | 
			
		||||
		Roles:    roles,
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -289,9 +300,11 @@ func Auth(next http.Handler) http.Handler {
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		username, _ := session.Values["username"].(string)
 | 
			
		||||
		roles, _ := session.Values["roles"].([]string)
 | 
			
		||||
		ctx := context.WithValue(r.Context(), ContextUserKey, &User{
 | 
			
		||||
			Username: session.Values["username"].(string),
 | 
			
		||||
			IsAdmin:  session.Values["is_admin"].(bool),
 | 
			
		||||
			Username: username,
 | 
			
		||||
			Roles:    roles,
 | 
			
		||||
		})
 | 
			
		||||
		next.ServeHTTP(rw, r.WithContext(ctx))
 | 
			
		||||
	})
 | 
			
		||||
@@ -300,13 +313,12 @@ func Auth(next http.Handler) http.Handler {
 | 
			
		||||
// Generate a new JWT that can be used for authentication
 | 
			
		||||
func ProvideJWT(user *User) (string, error) {
 | 
			
		||||
	if JwtPrivateKey == nil {
 | 
			
		||||
		return "", errors.New("environment variable 'JWT_PUBLIC_KEY' not set")
 | 
			
		||||
		return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{
 | 
			
		||||
		"sub":   user.Username,
 | 
			
		||||
		"is_admin": user.IsAdmin,
 | 
			
		||||
		"is_api":   user.IsAPIUser,
 | 
			
		||||
		"roles": user.Roles,
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	return tok.SignedString(JwtPrivateKey)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								auth/ldap.go
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								auth/ldap.go
									
									
									
									
									
								
							@@ -7,7 +7,6 @@ import (
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"sync"
 | 
			
		||||
 | 
			
		||||
	"github.com/go-ldap/ldap/v3"
 | 
			
		||||
	"github.com/jmoiron/sqlx"
 | 
			
		||||
@@ -37,22 +36,9 @@ func initLdap(config *LdapConfig) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var ldapConnectionsLock sync.Mutex
 | 
			
		||||
var ldapConnections []*ldap.Conn = []*ldap.Conn{}
 | 
			
		||||
 | 
			
		||||
// TODO: Add a connection pool or something like
 | 
			
		||||
// that so that connections can be reused/cached.
 | 
			
		||||
func getLdapConnection() (*ldap.Conn, error) {
 | 
			
		||||
	ldapConnectionsLock.Lock()
 | 
			
		||||
	n := len(ldapConnections)
 | 
			
		||||
	if n > 0 {
 | 
			
		||||
		conn := ldapConnections[n-1]
 | 
			
		||||
		ldapConnections = ldapConnections[:n-1]
 | 
			
		||||
		ldapConnectionsLock.Unlock()
 | 
			
		||||
		return conn, nil
 | 
			
		||||
	}
 | 
			
		||||
	ldapConnectionsLock.Unlock()
 | 
			
		||||
 | 
			
		||||
func getLdapConnection(admin bool) (*ldap.Conn, error) {
 | 
			
		||||
	conn, err := ldap.DialURL(ldapConfig.Url)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
@@ -65,35 +51,22 @@ func getLdapConnection() (*ldap.Conn, error) {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if admin {
 | 
			
		||||
		if err := conn.Bind(ldapConfig.SearchDN, ldapAdminPassword); err != nil {
 | 
			
		||||
			conn.Close()
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return conn, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func releaseConnection(conn *ldap.Conn) {
 | 
			
		||||
	// Re-bind to the user we can run queries with
 | 
			
		||||
	if err := conn.Bind(ldapConfig.SearchDN, ldapAdminPassword); err != nil {
 | 
			
		||||
	conn.Close()
 | 
			
		||||
		log.Printf("ldap error: %s", err.Error())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ldapConnectionsLock.Lock()
 | 
			
		||||
	defer ldapConnectionsLock.Unlock()
 | 
			
		||||
 | 
			
		||||
	n := len(ldapConnections)
 | 
			
		||||
	if n > 2 {
 | 
			
		||||
		conn.Close()
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ldapConnections = append(ldapConnections, conn)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func loginViaLdap(user *User, password string) error {
 | 
			
		||||
	l, err := getLdapConnection()
 | 
			
		||||
	l, err := getLdapConnection(false)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
@@ -134,7 +107,7 @@ func SyncWithLDAP(db *sqlx.DB) error {
 | 
			
		||||
		users[username] = IN_DB
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	l, err := getLdapConnection()
 | 
			
		||||
	l, err := getLdapConnection(true)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -109,7 +109,7 @@ func (r *Resolver) queryJobs(ctx context.Context, filters []*model.JobFilter, pa
 | 
			
		||||
 | 
			
		||||
func securityCheck(ctx context.Context, query sq.SelectBuilder) sq.SelectBuilder {
 | 
			
		||||
	user := auth.GetUser(ctx)
 | 
			
		||||
	if user == nil || user.IsAdmin {
 | 
			
		||||
	if user == nil || user.HasRole(auth.RoleAdmin) {
 | 
			
		||||
		return query
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -151,7 +151,7 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error)
 | 
			
		||||
	// This query is very common (mostly called through other resolvers such as JobMetrics),
 | 
			
		||||
	// so we use prepared statements here.
 | 
			
		||||
	user := auth.GetUser(ctx)
 | 
			
		||||
	if user == nil || user.IsAdmin {
 | 
			
		||||
	if user == nil || user.HasRole(auth.RoleAdmin) {
 | 
			
		||||
		return schema.ScanJob(r.findJobByIdStmt.QueryRowx(id))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -209,7 +209,7 @@ func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.Job
 | 
			
		||||
 | 
			
		||||
func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) {
 | 
			
		||||
	user := auth.GetUser(ctx)
 | 
			
		||||
	if user != nil && !user.IsAdmin {
 | 
			
		||||
	if user != nil && !user.HasRole(auth.RoleAdmin) {
 | 
			
		||||
		return nil, errors.New("you need to be an administrator for this query")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -132,7 +132,7 @@ func main() {
 | 
			
		||||
	flag.BoolVar(&flagSyncLDAP, "sync-ldap", false, "Sync the `user` table with ldap")
 | 
			
		||||
	flag.BoolVar(&flagStopImmediately, "no-server", false, "Do not start a server, stop right after initialization and argument handling")
 | 
			
		||||
	flag.StringVar(&flagConfigFile, "config", "", "Location of the config file for this server (overwrites the defaults)")
 | 
			
		||||
	flag.StringVar(&flagNewUser, "add-user", "", "Add a new user. Argument format: `<username>:[admin|api]:<password>`")
 | 
			
		||||
	flag.StringVar(&flagNewUser, "add-user", "", "Add a new user. Argument format: `<username>:[admin,api,user]:<password>`")
 | 
			
		||||
	flag.StringVar(&flagDelUser, "del-user", "", "Remove user by username")
 | 
			
		||||
	flag.StringVar(&flagGenJWT, "jwt", "", "Generate and print a JWT for the user specified by the username")
 | 
			
		||||
	flag.Parse()
 | 
			
		||||
@@ -200,7 +200,7 @@ func main() {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if !user.IsAPIUser {
 | 
			
		||||
			if !user.HasRole(auth.RoleApi) {
 | 
			
		||||
				log.Println("warning: that user does not have the API role")
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
@@ -299,7 +299,7 @@ func main() {
 | 
			
		||||
 | 
			
		||||
		if user := auth.GetUser(r.Context()); user != nil {
 | 
			
		||||
			infos["username"] = user.Username
 | 
			
		||||
			infos["admin"] = user.IsAdmin
 | 
			
		||||
			infos["admin"] = user.HasRole(auth.RoleAdmin)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		templates.Render(rw, r, "home.html", &templates.Page{
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user