Add OpenID Connect authentication

Fixes #236
Template conditional not yet working
Needs more testing
This commit is contained in:
Jan Eitzinger 2024-03-28 12:01:13 +01:00
parent e92e727279
commit e8fb5a0030
6 changed files with 138 additions and 39 deletions

View File

@ -286,6 +286,7 @@ func main() {
fmt.Printf("MAIN > JWT for '%s': %s\n", user.Username, jwt) fmt.Printf("MAIN > JWT for '%s': %s\n", user.Username, jwt)
} }
} else if flagNewUser != "" || flagDelUser != "" { } else if flagNewUser != "" || flagDelUser != "" {
log.Fatal("arguments --add-user and --del-user can only be used if authentication is enabled") 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() r := mux.NewRouter()
buildInfo := web.Build{Version: version, Hash: commit, Buildtime: date} 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) { r.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") 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) }).Methods(http.MethodGet)
r.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) { r.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")

View File

@ -129,6 +129,29 @@ func Init() (*Authentication, error) {
return auth, nil 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( func (auth *Authentication) Login(
onsuccess http.Handler, onsuccess http.Handler,
onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error), onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error),
@ -161,22 +184,7 @@ func (auth *Authentication) Login(
return return
} }
session, err := auth.sessionStore.New(r, "session") if err := auth.SaveSession(rw, r, user); err != nil {
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)
return return
} }

View File

@ -9,20 +9,24 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"io" "io"
"log"
"net/http" "net/http"
"strings" "os"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config" "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/coreos/go-oidc/v3/oidc"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
type OIDC struct { type OIDC struct {
client *oauth2.Config client *oauth2.Config
provider *oidc.Provider provider *oidc.Provider
authentication *Authentication
clientID string
} }
func randString(nByte int) (string, error) { 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) http.SetCookie(w, c)
} }
func (oa *OIDC) Init(r *mux.Router) error { func NewOIDC(a *Authentication) *OIDC {
provider, err := oidc.NewProvider(context.Background(), "https://provider") provider, err := oidc.NewProvider(context.Background(), config.Keys.OpenIDProvider)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
oa.provider = provider clientID := os.Getenv("OID_CLIENT_ID")
if clientID == "" {
oa.client = &oauth2.Config{ log.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)")
ClientID: "YOUR_CLIENT_ID", }
ClientSecret: "YOUR_CLIENT_SECRET", 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(), Endpoint: provider.Endpoint(),
RedirectURL: "https://" + config.Keys.Addr + "/oidc-callback", RedirectURL: redirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, 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-login", oa.OAuth2Login)
r.HandleFunc("/oidc-callback", oa.OAuth2Callback) r.HandleFunc("/oidc-callback", oa.OAuth2Callback)
return nil
} }
func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("state") c, err := r.Cookie("state")
if err != nil { if err != nil {
http.Error(rw, "state not found", http.StatusBadRequest) http.Error(rw, "state cookie not found", http.StatusBadRequest)
return return
} }
state := c.Value
str := strings.Split(c.Value, " ") c, err = r.Cookie("verifier")
state := str[0] codeVerifier := c.Value
codeVerifier := str[1]
_ = r.ParseForm() _ = r.ParseForm()
if r.Form.Get("state") != state { 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) http.Error(rw, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError)
return 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) { 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) http.Error(rw, "Internal error", http.StatusInternalServerError)
return return
} }
setCallbackCookie(rw, r, "state", state)
// use PKCE to protect against CSRF attacks // use PKCE to protect against CSRF attacks
codeVerifier := oauth2.GenerateVerifier() codeVerifier := oauth2.GenerateVerifier()
setCallbackCookie(rw, r, "verifier", codeVerifier)
setCallbackCookie(rw, r, "state", strings.Join([]string{state, codeVerifier}, " "))
// Redirect user to consent page to ask for permission // Redirect user to consent page to ask for permission
url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(codeVerifier)) url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(codeVerifier))

View File

@ -65,10 +65,10 @@ type ClusterConfig struct {
} }
type Retention struct { type Retention struct {
Age int `json:"age"`
IncludeDB bool `json:"includeDB"`
Policy string `json:"policy"` Policy string `json:"policy"`
Location string `json:"location"` Location string `json:"location"`
Age int `json:"age"`
IncludeDB bool `json:"includeDB"`
} }
// Format of the configuration (file). See below for the defaults. // Format of the configuration (file). See below for the defaults.
@ -112,6 +112,9 @@ type ProgramConfig struct {
LdapConfig *LdapConfig `json:"ldap"` LdapConfig *LdapConfig `json:"ldap"`
JwtConfig *JWTAuthConfig `json:"jwts"` JwtConfig *JWTAuthConfig `json:"jwts"`
// Enable OpenID connect Authentication
OpenIDProvider string `json:"openIDProvider"`
// If 0 or empty, the session does not expire! // If 0 or empty, the session does not expire!
SessionMaxAge string `json:"session-max-age"` SessionMaxAge string `json:"session-max-age"`

View File

@ -27,6 +27,7 @@ const (
AuthViaLocalPassword AuthSource = iota AuthViaLocalPassword AuthSource = iota
AuthViaLDAP AuthViaLDAP
AuthViaToken AuthViaToken
AuthViaOIDC
AuthViaAll AuthViaAll
) )

View File

@ -38,7 +38,14 @@
<input class="form-control" type="password" id="password" name="password" required/> <input class="form-control" type="password" id="password" name="password" required/>
</div> </div>
<button type="submit" class="btn btn-success">Submit</button> <button type="submit" class="btn btn-success">Submit</button>
<a class="btn btn-primary" href="/oidc-login">OpenID Connect Login</a>
</form> </form>
{{ range $key, $value := .Infos }}
<strong>{{ $key }}</strong>: {{ $value }},
{{ end }}
{{if .Infos.hasOpenIDConnect }}
<a class="btn btn-primary" href="/oidc-login">OpenID Connect Login</a>
{{end}}
</div> </div>
</div> </div>
</div> </div>