mirror of
https://github.com/TwiN/gatus.git
synced 2024-12-22 14:41:01 +01:00
01131755bc
* fix(logging): Replace log-level parameter by GATUS_LOG_LEVEL env var * Improve log message if GATUS_LOG_LEVEL isn't set
143 lines
4.5 KiB
Go
143 lines
4.5 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/TwiN/logr"
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// OIDCConfig is the configuration for OIDC authentication
|
|
type OIDCConfig struct {
|
|
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
|
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
|
ClientID string `yaml:"client-id"`
|
|
ClientSecret string `yaml:"client-secret"`
|
|
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
|
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
|
|
|
oauth2Config oauth2.Config
|
|
verifier *oidc.IDTokenVerifier
|
|
}
|
|
|
|
// isValid returns whether the basic security configuration is valid or not
|
|
func (c *OIDCConfig) isValid() bool {
|
|
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
|
}
|
|
|
|
func (c *OIDCConfig) initialize() error {
|
|
provider, err := oidc.NewProvider(context.Background(), c.IssuerURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.verifier = provider.Verifier(&oidc.Config{ClientID: c.ClientID})
|
|
// Configure an OpenID Connect aware OAuth2 client.
|
|
c.oauth2Config = oauth2.Config{
|
|
ClientID: c.ClientID,
|
|
ClientSecret: c.ClientSecret,
|
|
Scopes: c.Scopes,
|
|
RedirectURL: c.RedirectURL,
|
|
Endpoint: provider.Endpoint(),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *OIDCConfig) loginHandler(ctx *fiber.Ctx) error {
|
|
state, nonce := uuid.NewString(), uuid.NewString()
|
|
ctx.Cookie(&fiber.Cookie{
|
|
Name: cookieNameState,
|
|
Value: state,
|
|
Path: "/",
|
|
MaxAge: int(time.Hour.Seconds()),
|
|
SameSite: "lax",
|
|
HTTPOnly: true,
|
|
})
|
|
ctx.Cookie(&fiber.Cookie{
|
|
Name: cookieNameNonce,
|
|
Value: nonce,
|
|
Path: "/",
|
|
MaxAge: int(time.Hour.Seconds()),
|
|
SameSite: "lax",
|
|
HTTPOnly: true,
|
|
})
|
|
return ctx.Redirect(c.oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
|
|
}
|
|
|
|
func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { // TODO: Migrate to a native fiber handler
|
|
// Check if there's an error
|
|
if len(r.URL.Query().Get("error")) > 0 {
|
|
http.Error(w, r.URL.Query().Get("error")+": "+r.URL.Query().Get("error_description"), http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Ensure that the state has the expected value
|
|
state, err := r.Cookie(cookieNameState)
|
|
if err != nil {
|
|
http.Error(w, "state not found", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if r.URL.Query().Get("state") != state.Value {
|
|
http.Error(w, "state did not match", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Validate token
|
|
oauth2Token, err := c.oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"))
|
|
if err != nil {
|
|
http.Error(w, "Error exchanging token: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
|
if !ok {
|
|
http.Error(w, "Missing 'id_token' in oauth2 token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
idToken, err := c.verifier.Verify(r.Context(), rawIDToken)
|
|
if err != nil {
|
|
http.Error(w, "Failed to verify id_token: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Validate nonce
|
|
nonce, err := r.Cookie(cookieNameNonce)
|
|
if err != nil {
|
|
http.Error(w, "nonce not found", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if idToken.Nonce != nonce.Value {
|
|
http.Error(w, "nonce did not match", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(c.AllowedSubjects) == 0 {
|
|
// If there's no allowed subjects, all subjects are allowed.
|
|
c.setSessionCookie(w, idToken)
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
for _, subject := range c.AllowedSubjects {
|
|
if strings.ToLower(subject) == strings.ToLower(idToken.Subject) {
|
|
c.setSessionCookie(w, idToken)
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
logr.Debugf("[security.callbackHandler] Subject %s is not in the list of allowed subjects", idToken.Subject)
|
|
http.Redirect(w, r, "/?error=access_denied", http.StatusFound)
|
|
}
|
|
|
|
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
|
|
// At this point, the user has been confirmed. All that's left to do is create a session.
|
|
sessionID := uuid.NewString()
|
|
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: cookieNameSession,
|
|
Value: sessionID,
|
|
Path: "/",
|
|
MaxAge: int(time.Hour.Seconds()),
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|