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