From f761900a3ebd2c5f181ea751099ceb9895bdb4d2 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 13 Mar 2024 09:37:12 +0100 Subject: [PATCH 01/11] Add initial code for oidc authentication support --- go.mod | 16 +++++----- go.sum | 19 +++++++++++ internal/auth/auth.go | 19 ++++++----- internal/auth/oidc.go | 73 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 internal/auth/oidc.go diff --git a/go.mod b/go.mod index facfa00..4de12ec 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/swaggo/http-swagger v1.3.3 github.com/swaggo/swag v1.16.2 github.com/vektah/gqlparser/v2 v2.5.10 - golang.org/x/crypto v0.16.0 + golang.org/x/crypto v0.21.0 golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea ) @@ -37,15 +37,17 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/containerd/containerd v1.6.18 // indirect + github.com/coreos/go-oidc/v3 v3.9.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/deepmap/oapi-codegen v1.12.4 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect + github.com/go-jose/go-jose/v3 v3.0.1 // indirect github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/spec v0.20.9 // indirect github.com/go-openapi/swag v0.22.4 // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/google/uuid v1.4.0 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/websocket v1.5.0 // indirect @@ -76,13 +78,13 @@ require ( github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.uber.org/atomic v1.10.0 // indirect golang.org/x/mod v0.14.0 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/oauth2 v0.5.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/net v0.22.0 // indirect + golang.org/x/oauth2 v0.18.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.16.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.30.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/go.sum b/go.sum index abd7f71..2658092 100644 --- a/go.sum +++ b/go.sum @@ -339,6 +339,8 @@ github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmeka github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo= +github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -451,6 +453,8 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= +github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -588,6 +592,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -1288,6 +1294,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1411,6 +1419,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1431,6 +1441,8 @@ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s= golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= +golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1570,6 +1582,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1585,6 +1599,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= @@ -1732,6 +1747,8 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -1854,6 +1871,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e8f0db4..fe3edad 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -27,18 +27,18 @@ type Authenticator interface { } type Authentication struct { - sessionStore *sessions.CookieStore - SessionMaxAge time.Duration - - authenticators []Authenticator + sessionStore *sessions.CookieStore LdapAuth *LdapAuthenticator JwtAuth *JWTAuthenticator LocalAuth *LocalAuthenticator + authenticators []Authenticator + SessionMaxAge time.Duration } func (auth *Authentication) AuthViaSession( rw http.ResponseWriter, - r *http.Request) (*schema.User, error) { + r *http.Request, +) (*schema.User, error) { session, err := auth.sessionStore.Get(r, "session") if err != nil { log.Error("Error while getting session store") @@ -131,8 +131,8 @@ func Init() (*Authentication, error) { func (auth *Authentication) Login( onsuccess http.Handler, - onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) 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 := r.FormValue("username") var dbUser *schema.User @@ -193,10 +193,9 @@ func (auth *Authentication) Login( func (auth *Authentication) Auth( onsuccess http.Handler, - onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) 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.JwtAuth.AuthViaJWT(rw, r) if err != nil { log.Infof("authentication failed: %s", err.Error()) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..480b212 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,73 @@ +// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package auth + +import ( + "context" + "log" + "net/http" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/mux" + "golang.org/x/oauth2" +) + +type OIDC struct { + client *oauth2.Config + provider *oidc.Provider + state string + codeVerifier string +} + +func (oa *OIDC) Init(r *mux.Router) error { + oa.client = &oauth2.Config{ + ClientID: "YOUR_CLIENT_ID", + ClientSecret: "YOUR_CLIENT_SECRET", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://provider.com/o/oauth2/auth", + TokenURL: "https://provider.com/o/oauth2/token", + }, + } + + provider, err := oidc.NewProvider(context.Background(), "https://provider") + if err != nil { + log.Fatal(err) + } + + oa.provider = provider + + r.HandleFunc("/oidc-login", oa.OAuth2Login) + r.HandleFunc("/oidc-callback", oa.OAuth2Callback) + + return nil +} + +func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + state := r.Form.Get("state") + if state != oa.state { + http.Error(rw, "State invalid", http.StatusBadRequest) + return + } + code := r.Form.Get("code") + if code == "" { + http.Error(rw, "Code not found", http.StatusBadRequest) + return + } + token, err := oa.client.Exchange(context.Background(), code, oauth2.VerifierOption(oa.codeVerifier)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) { + // use PKCE to protect against CSRF attacks + oa.codeVerifier = oauth2.GenerateVerifier() + + // Redirect user to consent page to ask for permission + url := oa.client.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oa.codeVerifier)) + http.Redirect(rw, r, url, http.StatusFound) +} From e92e727279b8cf006047b87031c733f46abc6438 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 13 Mar 2024 17:09:36 +0100 Subject: [PATCH 02/11] Extend oidc auth provider --- internal/auth/ldap.go | 11 +++--- internal/auth/oidc.go | 84 ++++++++++++++++++++++++++++++++----------- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index b800ca7..d9888ca 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -21,7 +21,7 @@ import ( type LdapAuthenticator struct { syncPassword string - UserAttr string + UserAttr string } var _ Authenticator = (*LdapAuthenticator)(nil) @@ -74,8 +74,8 @@ func (la *LdapAuthenticator) CanLogin( user *schema.User, username string, rw http.ResponseWriter, - r *http.Request) (*schema.User, bool) { - + r *http.Request, +) (*schema.User, bool) { lc := config.Keys.LdapConfig if user != nil { @@ -138,8 +138,8 @@ func (la *LdapAuthenticator) CanLogin( func (la *LdapAuthenticator) Login( user *schema.User, rw http.ResponseWriter, - r *http.Request) (*schema.User, error) { - + r *http.Request, +) (*schema.User, error) { l, err := la.getLdapConnection(false) if err != nil { log.Warn("Error while getting ldap connection") @@ -238,7 +238,6 @@ func (la *LdapAuthenticator) Sync() error { } func (la *LdapAuthenticator) getLdapConnection(admin bool) (*ldap.Conn, error) { - lc := config.Keys.LdapConfig conn, err := ldap.DialURL(lc.Url) if err != nil { diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 480b212..cfcf5b6 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -6,38 +6,59 @@ package auth import ( "context" + "crypto/rand" + "encoding/base64" + "io" "log" "net/http" + "strings" + "time" + "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/coreos/go-oidc/v3/oidc" "github.com/gorilla/mux" "golang.org/x/oauth2" ) type OIDC struct { - client *oauth2.Config - provider *oidc.Provider - state string - codeVerifier string + client *oauth2.Config + provider *oidc.Provider +} + +func randString(nByte int) (string, error) { + b := make([]byte, nByte) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) { + c := &http.Cookie{ + Name: name, + Value: value, + MaxAge: int(time.Hour.Seconds()), + Secure: r.TLS != nil, + HttpOnly: true, + } + http.SetCookie(w, c) } func (oa *OIDC) Init(r *mux.Router) error { - oa.client = &oauth2.Config{ - ClientID: "YOUR_CLIENT_ID", - ClientSecret: "YOUR_CLIENT_SECRET", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://provider.com/o/oauth2/auth", - TokenURL: "https://provider.com/o/oauth2/token", - }, - } - provider, err := oidc.NewProvider(context.Background(), "https://provider") if err != nil { log.Fatal(err) } - oa.provider = provider + oa.client = &oauth2.Config{ + ClientID: "YOUR_CLIENT_ID", + ClientSecret: "YOUR_CLIENT_SECRET", + Endpoint: provider.Endpoint(), + RedirectURL: "https://" + config.Keys.Addr + "/oidc-callback", + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + r.HandleFunc("/oidc-login", oa.OAuth2Login) r.HandleFunc("/oidc-callback", oa.OAuth2Callback) @@ -45,9 +66,18 @@ func (oa *OIDC) Init(r *mux.Router) error { } func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { + c, err := r.Cookie("state") + if err != nil { + http.Error(rw, "state not found", http.StatusBadRequest) + return + } + + str := strings.Split(c.Value, " ") + state := str[0] + codeVerifier := str[1] + _ = r.ParseForm() - state := r.Form.Get("state") - if state != oa.state { + if r.Form.Get("state") != state { http.Error(rw, "State invalid", http.StatusBadRequest) return } @@ -56,18 +86,32 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { http.Error(rw, "Code not found", http.StatusBadRequest) return } - token, err := oa.client.Exchange(context.Background(), code, oauth2.VerifierOption(oa.codeVerifier)) + token, err := oa.client.Exchange(context.Background(), code, oauth2.VerifierOption(codeVerifier)) if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) + http.Error(rw, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) + return + } + + userInfo, err := oa.provider.UserInfo(context.Background(), oauth2.StaticTokenSource(token)) + if err != nil { + http.Error(rw, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError) return } } func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) { + state, err := randString(16) + if err != nil { + http.Error(rw, "Internal error", http.StatusInternalServerError) + return + } + // use PKCE to protect against CSRF attacks - oa.codeVerifier = oauth2.GenerateVerifier() + codeVerifier := oauth2.GenerateVerifier() + + setCallbackCookie(rw, r, "state", strings.Join([]string{state, codeVerifier}, " ")) // Redirect user to consent page to ask for permission - url := oa.client.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oa.codeVerifier)) + url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(codeVerifier)) http.Redirect(rw, r, url, http.StatusFound) } From b9b452f043397cab1ee3a4839f754bedf0e25701 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Tue, 26 Mar 2024 15:56:07 +0100 Subject: [PATCH 03/11] feat: prototype infinite scroll implementation --- api/schema.graphqls | 1 + internal/config/config.go | 1 + internal/graph/generated/generated.go | 142 +++++++++++------------- internal/graph/model/models_gen.go | 15 ++- internal/graph/schema.resolvers.go | 17 ++- web/frontend/src/joblist/JobList.svelte | 87 ++++++++++----- 6 files changed, 152 insertions(+), 111 deletions(-) diff --git a/api/schema.graphqls b/api/schema.graphqls index aa6aea2..73140b9 100644 --- a/api/schema.graphqls +++ b/api/schema.graphqls @@ -278,6 +278,7 @@ type JobResultList { offset: Int limit: Int count: Int + hasNextPage: Boolean! } type JobLinkResultList { diff --git a/internal/config/config.go b/internal/config/config.go index 76fd62a..60c7da3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,7 @@ var Keys schema.ProgramConfig = schema.ProgramConfig{ "job_view_polarPlotMetrics": []string{"flops_any", "mem_bw", "mem_used"}, "job_view_selectedMetrics": []string{"flops_any", "mem_bw", "mem_used"}, "job_view_showFootprint": true, + "job_list_usePaging": true, "plot_general_colorBackground": true, "plot_general_colorscheme": []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"}, "plot_general_lineWidth": 3, diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index d84f043..1ea41f9 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -139,10 +139,11 @@ type ComplexityRoot struct { } JobResultList struct { - Count func(childComplexity int) int - Items func(childComplexity int) int - Limit func(childComplexity int) int - Offset func(childComplexity int) int + Count func(childComplexity int) int + HasNextPage func(childComplexity int) int + Items func(childComplexity int) int + Limit func(childComplexity int) int + Offset func(childComplexity int) int } JobsStatistics struct { @@ -755,6 +756,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.JobResultList.Count(childComplexity), true + case "JobResultList.hasNextPage": + if e.complexity.JobResultList.HasNextPage == nil { + break + } + + return e.complexity.JobResultList.HasNextPage(childComplexity), true + case "JobResultList.items": if e.complexity.JobResultList.Items == nil { break @@ -1987,6 +1995,7 @@ type JobResultList { offset: Int limit: Int count: Int + hasNextPage: Boolean! } type JobLinkResultList { @@ -5221,6 +5230,50 @@ func (ec *executionContext) fieldContext_JobResultList_count(ctx context.Context return fc, nil } +func (ec *executionContext) _JobResultList_hasNextPage(ctx context.Context, field graphql.CollectedField, obj *model.JobResultList) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_JobResultList_hasNextPage(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.HasNextPage, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_JobResultList_hasNextPage(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "JobResultList", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _JobsStatistics_id(ctx context.Context, field graphql.CollectedField, obj *model.JobsStatistics) (ret graphql.Marshaler) { fc, err := ec.fieldContext_JobsStatistics_id(ctx, field) if err != nil { @@ -8017,6 +8070,8 @@ func (ec *executionContext) fieldContext_Query_jobs(ctx context.Context, field g return ec.fieldContext_JobResultList_limit(ctx, field) case "count": return ec.fieldContext_JobResultList_count(ctx, field) + case "hasNextPage": + return ec.fieldContext_JobResultList_hasNextPage(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type JobResultList", field.Name) }, @@ -12226,8 +12281,6 @@ func (ec *executionContext) unmarshalInputFloatRange(ctx context.Context, obj in } switch k { case "from": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("from")) data, err := ec.unmarshalNFloat2float64(ctx, v) if err != nil { @@ -12235,8 +12288,6 @@ func (ec *executionContext) unmarshalInputFloatRange(ctx context.Context, obj in } it.From = data case "to": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("to")) data, err := ec.unmarshalNFloat2float64(ctx, v) if err != nil { @@ -12264,8 +12315,6 @@ func (ec *executionContext) unmarshalInputIntRange(ctx context.Context, obj inte } switch k { case "from": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("from")) data, err := ec.unmarshalNInt2int(ctx, v) if err != nil { @@ -12273,8 +12322,6 @@ func (ec *executionContext) unmarshalInputIntRange(ctx context.Context, obj inte } it.From = data case "to": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("to")) data, err := ec.unmarshalNInt2int(ctx, v) if err != nil { @@ -12302,8 +12349,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } switch k { case "tags": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("tags")) data, err := ec.unmarshalOID2ᚕstringᚄ(ctx, v) if err != nil { @@ -12311,8 +12356,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.Tags = data case "jobId": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("jobId")) data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v) if err != nil { @@ -12320,8 +12363,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.JobID = data case "arrayJobId": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("arrayJobId")) data, err := ec.unmarshalOInt2ᚖint(ctx, v) if err != nil { @@ -12329,8 +12370,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.ArrayJobID = data case "user": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("user")) data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v) if err != nil { @@ -12338,8 +12377,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.User = data case "project": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("project")) data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v) if err != nil { @@ -12347,8 +12384,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.Project = data case "jobName": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("jobName")) data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v) if err != nil { @@ -12356,8 +12391,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.JobName = data case "cluster": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("cluster")) data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v) if err != nil { @@ -12365,8 +12398,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.Cluster = data case "partition": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("partition")) data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v) if err != nil { @@ -12374,8 +12405,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.Partition = data case "duration": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("duration")) data, err := ec.unmarshalOIntRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐIntRange(ctx, v) if err != nil { @@ -12383,8 +12412,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.Duration = data case "minRunningFor": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("minRunningFor")) data, err := ec.unmarshalOInt2ᚖint(ctx, v) if err != nil { @@ -12392,8 +12419,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.MinRunningFor = data case "numNodes": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("numNodes")) data, err := ec.unmarshalOIntRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐIntRange(ctx, v) if err != nil { @@ -12401,8 +12426,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.NumNodes = data case "numAccelerators": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("numAccelerators")) data, err := ec.unmarshalOIntRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐIntRange(ctx, v) if err != nil { @@ -12410,8 +12433,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.NumAccelerators = data case "numHWThreads": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("numHWThreads")) data, err := ec.unmarshalOIntRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐIntRange(ctx, v) if err != nil { @@ -12419,8 +12440,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.NumHWThreads = data case "startTime": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("startTime")) data, err := ec.unmarshalOTimeRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐTimeRange(ctx, v) if err != nil { @@ -12428,8 +12447,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.StartTime = data case "state": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("state")) data, err := ec.unmarshalOJobState2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐJobStateᚄ(ctx, v) if err != nil { @@ -12437,8 +12454,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.State = data case "flopsAnyAvg": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("flopsAnyAvg")) data, err := ec.unmarshalOFloatRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐFloatRange(ctx, v) if err != nil { @@ -12446,8 +12461,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.FlopsAnyAvg = data case "memBwAvg": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("memBwAvg")) data, err := ec.unmarshalOFloatRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐFloatRange(ctx, v) if err != nil { @@ -12455,8 +12468,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.MemBwAvg = data case "loadAvg": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("loadAvg")) data, err := ec.unmarshalOFloatRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐFloatRange(ctx, v) if err != nil { @@ -12464,8 +12475,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.LoadAvg = data case "memUsedMax": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("memUsedMax")) data, err := ec.unmarshalOFloatRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐFloatRange(ctx, v) if err != nil { @@ -12473,8 +12482,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.MemUsedMax = data case "exclusive": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("exclusive")) data, err := ec.unmarshalOInt2ᚖint(ctx, v) if err != nil { @@ -12482,8 +12489,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int } it.Exclusive = data case "node": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("node")) data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v) if err != nil { @@ -12515,8 +12520,6 @@ func (ec *executionContext) unmarshalInputOrderByInput(ctx context.Context, obj } switch k { case "field": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("field")) data, err := ec.unmarshalNString2string(ctx, v) if err != nil { @@ -12524,8 +12527,6 @@ func (ec *executionContext) unmarshalInputOrderByInput(ctx context.Context, obj } it.Field = data case "order": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("order")) data, err := ec.unmarshalNSortDirectionEnum2githubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐSortDirectionEnum(ctx, v) if err != nil { @@ -12553,8 +12554,6 @@ func (ec *executionContext) unmarshalInputPageRequest(ctx context.Context, obj i } switch k { case "itemsPerPage": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("itemsPerPage")) data, err := ec.unmarshalNInt2int(ctx, v) if err != nil { @@ -12562,8 +12561,6 @@ func (ec *executionContext) unmarshalInputPageRequest(ctx context.Context, obj i } it.ItemsPerPage = data case "page": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("page")) data, err := ec.unmarshalNInt2int(ctx, v) if err != nil { @@ -12591,8 +12588,6 @@ func (ec *executionContext) unmarshalInputStringInput(ctx context.Context, obj i } switch k { case "eq": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("eq")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) if err != nil { @@ -12600,8 +12595,6 @@ func (ec *executionContext) unmarshalInputStringInput(ctx context.Context, obj i } it.Eq = data case "neq": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("neq")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) if err != nil { @@ -12609,8 +12602,6 @@ func (ec *executionContext) unmarshalInputStringInput(ctx context.Context, obj i } it.Neq = data case "contains": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("contains")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) if err != nil { @@ -12618,8 +12609,6 @@ func (ec *executionContext) unmarshalInputStringInput(ctx context.Context, obj i } it.Contains = data case "startsWith": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("startsWith")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) if err != nil { @@ -12627,8 +12616,6 @@ func (ec *executionContext) unmarshalInputStringInput(ctx context.Context, obj i } it.StartsWith = data case "endsWith": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("endsWith")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) if err != nil { @@ -12636,8 +12623,6 @@ func (ec *executionContext) unmarshalInputStringInput(ctx context.Context, obj i } it.EndsWith = data case "in": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("in")) data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) if err != nil { @@ -12665,8 +12650,6 @@ func (ec *executionContext) unmarshalInputTimeRange(ctx context.Context, obj int } switch k { case "from": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("from")) data, err := ec.unmarshalOTime2ᚖtimeᚐTime(ctx, v) if err != nil { @@ -12674,8 +12657,6 @@ func (ec *executionContext) unmarshalInputTimeRange(ctx context.Context, obj int } it.From = data case "to": - var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("to")) data, err := ec.unmarshalOTime2ᚖtimeᚐTime(ctx, v) if err != nil { @@ -13481,6 +13462,11 @@ func (ec *executionContext) _JobResultList(ctx context.Context, sel ast.Selectio out.Values[i] = ec._JobResultList_limit(ctx, field, obj) case "count": out.Values[i] = ec._JobResultList_count(ctx, field, obj) + case "hasNextPage": + out.Values[i] = ec._JobResultList_hasNextPage(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 7b8ebd2..7aa2764 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -78,10 +78,11 @@ type JobMetricWithName struct { } type JobResultList struct { - Items []*schema.Job `json:"items"` - Offset *int `json:"offset,omitempty"` - Limit *int `json:"limit,omitempty"` - Count *int `json:"count,omitempty"` + Items []*schema.Job `json:"items"` + Offset *int `json:"offset,omitempty"` + Limit *int `json:"limit,omitempty"` + Count *int `json:"count,omitempty"` + HasNextPage bool `json:"hasNextPage"` } type JobsStatistics struct { @@ -122,6 +123,9 @@ type MetricHistoPoints struct { Data []*MetricHistoPoint `json:"data,omitempty"` } +type Mutation struct { +} + type NodeMetrics struct { Host string `json:"host"` SubCluster string `json:"subCluster"` @@ -138,6 +142,9 @@ type PageRequest struct { Page int `json:"page"` } +type Query struct { +} + type StringInput struct { Eq *string `json:"eq,omitempty"` Neq *string `json:"neq,omitempty"` diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 82bf026..c20cf1e 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -2,7 +2,7 @@ package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.40 +// Code generated by github.com/99designs/gqlgen version v0.17.45 import ( "context" @@ -234,13 +234,26 @@ func (r *queryResolver) Jobs(ctx context.Context, filter []*model.JobFilter, pag return nil, err } + hasNextPage := false + nextPage := page + nextPage.Page += 1 + + nextJobs, err := r.Repo.QueryJobs(ctx, filter, nextPage, order) + if err != nil { + log.Warn("Error while querying next jobs") + return nil, err + } + if len(nextJobs) > 0 { + hasNextPage = true + } + count, err := r.Repo.CountJobs(ctx, filter) if err != nil { log.Warn("Error while counting jobs") return nil, err } - return &model.JobResultList{Items: jobs, Count: &count}, nil + return &model.JobResultList{Items: jobs, Count: &count, HasNextPage: hasNextPage}, nil } // JobsStatistics is the resolver for the jobsStatistics field. diff --git a/web/frontend/src/joblist/JobList.svelte b/web/frontend/src/joblist/JobList.svelte index 3efe069..6311a66 100644 --- a/web/frontend/src/joblist/JobList.svelte +++ b/web/frontend/src/joblist/JobList.svelte @@ -30,7 +30,8 @@ export let metrics = ccconfig.plot_list_selectedMetrics; export let showFootprint; - let itemsPerPage = ccconfig.plot_list_jobsPerPage; + let usePaging = !!ccconfig.job_list_usePaging + let itemsPerPage = usePaging ? ccconfig.plot_list_jobsPerPage : 10; let page = 1; let paging = { itemsPerPage, page }; let filter = []; @@ -79,21 +80,27 @@ loadAvg } count + hasNextPage } } `; - $: jobs = queryStore({ + $: jobsStore = queryStore({ client: client, query: query, variables: { paging, sorting, filter }, }); - $: matchedJobs = $jobs.data != null ? $jobs.data.jobs.count : 0; + let jobs = [] + $: if ($initialized && $jobsStore.data) { + jobs = [...$jobsStore.data.jobs.items] + } + + $: matchedJobs = $jobsStore.data != null ? $jobsStore.data.jobs.count : 0; // Force refresh list with existing unchanged variables (== usually would not trigger reactivity) export function refresh() { - jobs = queryStore({ + jobsStore = queryStore({ client: client, query: query, variables: { paging, sorting, filter }, @@ -132,6 +139,7 @@ value: value, }).subscribe((res) => { if (res.fetching === false && !res.error) { + jobs = [] // Empty List paging = { itemsPerPage: value, page: page }; // Trigger reload of jobList } else if (res.fetching === false && res.error) { throw res.error; @@ -140,6 +148,25 @@ }); } + window.addEventListener('scroll', () => { + let { + scrollTop, + scrollHeight, + clientHeight + } = document.documentElement; + if (scrollTop + clientHeight >= scrollHeight && !usePaging && $jobsStore.data != null && $jobsStore.data.jobs.hasNextPage) { + fetchMore() + } + }); + + let scrollMultiplier = 1 + function fetchMore() { + let pendingPaging = { ...paging } + scrollMultiplier += 1 + pendingPaging.itemsPerPage = itemsPerPage * scrollMultiplier + paging = pendingPaging + } + let plotWidth = null; let tableWidth = null; let jobInfoColumnWidth = 250; @@ -212,22 +239,16 @@ - {#if $jobs.error} + {#if $jobsStore.error}

{$jobs.error.message}

{$jobsStore.error.message}

- {:else if $jobs.fetching || !$jobs.data} - - - - - - {:else if $jobs.data && $initialized} - {#each $jobs.data.jobs.items as job (job)} + {:else} + {#each jobs as job (job)} {:else} @@ -235,24 +256,36 @@ {/each} {/if} + {#if $jobsStore.fetching || !$jobsStore.data} + + +
+ +
+ + + {/if} - { - if (detail.itemsPerPage != itemsPerPage) { - updateConfiguration(detail.itemsPerPage.toString(), detail.page); - } else { - paging = { itemsPerPage: detail.itemsPerPage, page: detail.page }; - } - }} -/> +{#if usePaging} + { + if (detail.itemsPerPage != itemsPerPage) { + updateConfiguration(detail.itemsPerPage.toString(), detail.page); + } else { + jobs = [] + paging = { itemsPerPage: detail.itemsPerPage, page: detail.page }; + } + }} + /> +{/if}