From e8fb5a00302c54c6930772a27e70b2e75716ccad Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 28 Mar 2024 12:01:13 +0100 Subject: [PATCH] Add OpenID Connect authentication Fixes #236 Template conditional not yet working Needs more testing --- cmd/cc-backend/main.go | 12 ++++- internal/auth/auth.go | 40 ++++++++------ internal/auth/oidc.go | 110 ++++++++++++++++++++++++++++++++------- pkg/schema/config.go | 7 ++- pkg/schema/user.go | 1 + web/templates/login.tmpl | 7 +++ 6 files changed, 138 insertions(+), 39 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 991fe6b..bcbc273 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -286,6 +286,7 @@ func main() { fmt.Printf("MAIN > JWT for '%s': %s\n", user.Username, jwt) } + } else if flagNewUser != "" || flagDelUser != "" { log.Fatal("arguments --add-user and --del-user can only be used if authentication is enabled") } @@ -343,9 +344,18 @@ func main() { r := mux.NewRouter() buildInfo := web.Build{Version: version, Hash: commit, Buildtime: date} + info := map[string]interface{}{} + info["hasOpenIDConnect"] = "false" + + if config.Keys.OpenIDProvider != "" { + openIDConnect := auth.NewOIDC(authentication) + openIDConnect.RegisterEndpoints(r) + info["hasOpenIDConnect"] = "true" + } + r.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") - web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo}) + web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info}) }).Methods(http.MethodGet) r.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") diff --git a/internal/auth/auth.go b/internal/auth/auth.go index fe3edad..9bca62e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -129,6 +129,29 @@ func Init() (*Authentication, error) { return auth, nil } +func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error { + 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 err + } + + if auth.SessionMaxAge != 0 { + session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) + } + session.Values["username"] = user.Username + session.Values["projects"] = user.Projects + session.Values["roles"] = user.Roles + if err := auth.sessionStore.Save(r, rw, session); err != nil { + log.Warnf("session save failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return err + } + + return nil +} + func (auth *Authentication) Login( onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error), @@ -161,22 +184,7 @@ func (auth *Authentication) Login( 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["projects"] = user.Projects - session.Values["roles"] = user.Roles - if err := auth.sessionStore.Save(r, rw, session); err != nil { - log.Warnf("session save failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) + if err := auth.SaveSession(rw, r, user); err != nil { return } diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index cfcf5b6..d29cfde 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -9,20 +9,24 @@ import ( "crypto/rand" "encoding/base64" "io" - "log" "net/http" - "strings" + "os" "time" "github.com/ClusterCockpit/cc-backend/internal/config" + "github.com/ClusterCockpit/cc-backend/internal/repository" + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/coreos/go-oidc/v3/oidc" "github.com/gorilla/mux" "golang.org/x/oauth2" ) type OIDC struct { - client *oauth2.Config - provider *oidc.Provider + client *oauth2.Config + provider *oidc.Provider + authentication *Authentication + clientID string } func randString(nByte int) (string, error) { @@ -44,37 +48,48 @@ func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value strin http.SetCookie(w, c) } -func (oa *OIDC) Init(r *mux.Router) error { - provider, err := oidc.NewProvider(context.Background(), "https://provider") +func NewOIDC(a *Authentication) *OIDC { + provider, err := oidc.NewProvider(context.Background(), config.Keys.OpenIDProvider) if err != nil { log.Fatal(err) } - oa.provider = provider - - oa.client = &oauth2.Config{ - ClientID: "YOUR_CLIENT_ID", - ClientSecret: "YOUR_CLIENT_SECRET", + clientID := os.Getenv("OID_CLIENT_ID") + if clientID == "" { + log.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)") + } + clientSecret := os.Getenv("OID_CLIENT_SECRET") + if clientSecret == "" { + log.Warn("environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)") + } + redirectURL := "oidc-callback" + client := &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, Endpoint: provider.Endpoint(), - RedirectURL: "https://" + config.Keys.Addr + "/oidc-callback", + RedirectURL: redirectURL, Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } + oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a} + + return oa +} + +func (oa *OIDC) RegisterEndpoints(r *mux.Router) { r.HandleFunc("/oidc-login", oa.OAuth2Login) r.HandleFunc("/oidc-callback", oa.OAuth2Callback) - - return nil } 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) + http.Error(rw, "state cookie not found", http.StatusBadRequest) return } + state := c.Value - str := strings.Split(c.Value, " ") - state := str[0] - codeVerifier := str[1] + c, err = r.Cookie("verifier") + codeVerifier := c.Value _ = r.ParseForm() if r.Form.Get("state") != state { @@ -97,6 +112,61 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { http.Error(rw, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError) return } + + // // Extract the ID Token from OAuth2 token. + // rawIDToken, ok := token.Extra("id_token").(string) + // if !ok { + // http.Error(rw, "Cannot access idToken", http.StatusInternalServerError) + // } + // + // verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID}) + // // Parse and verify ID Token payload. + // idToken, err := verifier.Verify(context.Background(), rawIDToken) + // if err != nil { + // http.Error(rw, "Failed to extract idToken: "+err.Error(), http.StatusInternalServerError) + // } + + projects := make([]string, 0) + + // Extract custom claims + var claims struct { + Username string `json:"preferred_username"` + Name string `json:"name"` + Profile struct { + Client struct { + Roles []string `json:"roles"` + } `json:"clustercockpit"` + } `json:"resource_access"` + } + if err := userInfo.Claims(&claims); err != nil { + http.Error(rw, "Failed to extract Claims: "+err.Error(), http.StatusInternalServerError) + } + + var roles []string + for _, r := range claims.Profile.Client.Roles { + switch r { + case "user": + roles = append(roles, schema.GetRoleString(schema.RoleUser)) + case "admin": + roles = append(roles, schema.GetRoleString(schema.RoleAdmin)) + } + } + + if len(claims.Profile.Client.Roles) == 0 { + roles = append(roles, schema.GetRoleString(schema.RoleUser)) + } + + user := &schema.User{ + Username: claims.Username, + Name: claims.Name, + Roles: roles, + Projects: projects, + AuthSource: schema.AuthViaOIDC, + } + oa.authentication.SaveSession(rw, r, user) + log.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects) + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) + http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(ctx)) } func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) { @@ -105,11 +175,11 @@ func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) { http.Error(rw, "Internal error", http.StatusInternalServerError) return } + setCallbackCookie(rw, r, "state", state) // use PKCE to protect against CSRF attacks codeVerifier := oauth2.GenerateVerifier() - - setCallbackCookie(rw, r, "state", strings.Join([]string{state, codeVerifier}, " ")) + setCallbackCookie(rw, r, "verifier", codeVerifier) // Redirect user to consent page to ask for permission url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(codeVerifier)) diff --git a/pkg/schema/config.go b/pkg/schema/config.go index 5f43fb7..b3b6afb 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -65,10 +65,10 @@ type ClusterConfig struct { } type Retention struct { - Age int `json:"age"` - IncludeDB bool `json:"includeDB"` Policy string `json:"policy"` Location string `json:"location"` + Age int `json:"age"` + IncludeDB bool `json:"includeDB"` } // Format of the configuration (file). See below for the defaults. @@ -112,6 +112,9 @@ type ProgramConfig struct { LdapConfig *LdapConfig `json:"ldap"` JwtConfig *JWTAuthConfig `json:"jwts"` + // Enable OpenID connect Authentication + OpenIDProvider string `json:"openIDProvider"` + // If 0 or empty, the session does not expire! SessionMaxAge string `json:"session-max-age"` diff --git a/pkg/schema/user.go b/pkg/schema/user.go index 047f617..a227bdb 100644 --- a/pkg/schema/user.go +++ b/pkg/schema/user.go @@ -27,6 +27,7 @@ const ( AuthViaLocalPassword AuthSource = iota AuthViaLDAP AuthViaToken + AuthViaOIDC AuthViaAll ) diff --git a/web/templates/login.tmpl b/web/templates/login.tmpl index 304a96f..47413a6 100644 --- a/web/templates/login.tmpl +++ b/web/templates/login.tmpl @@ -38,7 +38,14 @@ + OpenID Connect Login + {{ range $key, $value := .Infos }} + {{ $key }}: {{ $value }}, + {{ end }} + {{if .Infos.hasOpenIDConnect }} + OpenID Connect Login + {{end}}