mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-14 09:00:04 +00:00
758a01ab74
The earlier SSRF work covered the provider, custom-tool, webhook, and OIDC outbound calls but missed the knowledge base URL import path, which still used http.DefaultClient. An admin importing a URL could reach loopback, link-local, or RFC1918 hosts. Renamed the Manager's toolClient to httpClient (it is a general SSRF-guarded outbound client now) and pointed fetchURL at it. The guard sits on the dialer's Control, so it also runs on redirect targets, not just the first hop. Also fixed the OIDC discovery client. Its hand-built transport dropped two defaults that http.DefaultTransport sets. Added Proxy: http.ProxyFromEnvironment so it honors HTTP_PROXY/HTTPS_PROXY (SSO discovery now works behind an egress proxy) and ForceAttemptHTTP2 so HTTP/2 is negotiated.
462 lines
12 KiB
Go
462 lines
12 KiB
Go
// Package auth implements OIDC multi-provider authentication and session management
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
|
"github.com/abhinavxd/libredesk/internal/ssrf"
|
|
"github.com/abhinavxd/libredesk/internal/stringutil"
|
|
"github.com/abhinavxd/libredesk/internal/user/models"
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/knadh/go-i18n"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/volatiletech/null/v9"
|
|
"github.com/zerodha/fastglue"
|
|
"github.com/zerodha/logf"
|
|
sessredisstore "github.com/zerodha/simplesessions/stores/redis/v3"
|
|
"github.com/zerodha/simplesessions/v3"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// OIDCclaim holds OIDC token claims data
|
|
type OIDCclaim struct {
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
Sub string `json:"sub"`
|
|
Picture string `json:"picture"`
|
|
}
|
|
|
|
// Provider defines an OIDC provider configuration
|
|
type Provider struct {
|
|
ID int
|
|
Provider string
|
|
ProviderURL string
|
|
RedirectURL string
|
|
ClientID string
|
|
ClientSecret string
|
|
}
|
|
|
|
// Config holds OIDC providers and cookies security settings
|
|
type Config struct {
|
|
Providers []Provider
|
|
SecureCookies bool
|
|
SessionLifetime time.Duration
|
|
}
|
|
|
|
// defaultSessionLifetime is used when Config.SessionLifetime is unset or non-positive.
|
|
const defaultSessionLifetime = 9 * time.Hour
|
|
|
|
// Auth is the auth service it manages OIDC authentication and sessions
|
|
type Auth struct {
|
|
mu sync.RWMutex
|
|
cfg Config
|
|
i18n *i18n.I18n
|
|
oauthCfgs map[int]oauth2.Config
|
|
verifiers map[int]*oidc.IDTokenVerifier
|
|
sess *simplesessions.Manager
|
|
logger *logf.Logger
|
|
rd *redis.Client
|
|
oidcClient *http.Client
|
|
}
|
|
|
|
// New creates an Auth service with configured OIDC providers.
|
|
func New(cfg Config, i18n *i18n.I18n, rd *redis.Client, logger *logf.Logger, dialControl ssrf.Control) (*Auth, error) {
|
|
oauthCfgs := make(map[int]oauth2.Config)
|
|
verifiers := make(map[int]*oidc.IDTokenVerifier)
|
|
|
|
oidcClient := newOIDCClient(dialControl)
|
|
|
|
for _, provider := range cfg.Providers {
|
|
oidcProv, err := oidc.NewProvider(oidc.ClientContext(context.Background(), oidcClient), provider.ProviderURL)
|
|
if err != nil {
|
|
logger.Error("error initializing oidc provider", "error", err, "provider", provider.Provider)
|
|
continue
|
|
}
|
|
|
|
oauthCfg := oauth2.Config{
|
|
ClientID: provider.ClientID,
|
|
ClientSecret: provider.ClientSecret,
|
|
Endpoint: oidcProv.Endpoint(),
|
|
RedirectURL: provider.RedirectURL,
|
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
|
}
|
|
|
|
verifier := oidcProv.Verifier(&oidc.Config{ClientID: provider.ClientID})
|
|
|
|
oauthCfgs[provider.ID] = oauthCfg
|
|
verifiers[provider.ID] = verifier
|
|
}
|
|
|
|
lifetime := cfg.SessionLifetime
|
|
if lifetime <= 0 {
|
|
lifetime = defaultSessionLifetime
|
|
}
|
|
|
|
sess := simplesessions.New(simplesessions.Options{
|
|
EnableAutoCreate: true,
|
|
SessionIDLength: 64,
|
|
Cookie: simplesessions.CookieOptions{
|
|
Name: "libredesk_session",
|
|
IsHTTPOnly: true,
|
|
IsSecure: cfg.SecureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: lifetime,
|
|
},
|
|
})
|
|
|
|
st := sessredisstore.New(context.TODO(), rd)
|
|
st.SetTTL(lifetime, false)
|
|
sess.UseStore(st)
|
|
sess.SetCookieHooks(simpleSessGetCookieCB, simpleSessSetCookieCB)
|
|
|
|
return &Auth{
|
|
cfg: cfg,
|
|
i18n: i18n,
|
|
oauthCfgs: oauthCfgs,
|
|
verifiers: verifiers,
|
|
sess: sess,
|
|
logger: logger,
|
|
rd: rd,
|
|
oidcClient: oidcClient,
|
|
}, nil
|
|
}
|
|
|
|
// newOIDCClient builds the HTTP client used for OIDC discovery.
|
|
func newOIDCClient(dialControl ssrf.Control) *http.Client {
|
|
return &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 3 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
Control: dialControl,
|
|
}).DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
ResponseHeaderTimeout: 5 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestProvider tests the OIDC provider url by doing a discovery on it.
|
|
func (a *Auth) TestProvider(url string) error {
|
|
_, err := oidc.NewProvider(oidc.ClientContext(context.Background(), a.oidcClient), url)
|
|
if err != nil {
|
|
a.logger.Error("error testing oidc provider", "provider_url", url, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, err.Error(), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Reload reloads the auth configuration.
|
|
func (a *Auth) Reload(cfg Config) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
oauthCfgs := make(map[int]oauth2.Config)
|
|
verifiers := make(map[int]*oidc.IDTokenVerifier)
|
|
|
|
for _, provider := range cfg.Providers {
|
|
oidcProv, err := oidc.NewProvider(oidc.ClientContext(context.Background(), a.oidcClient), provider.ProviderURL)
|
|
if err != nil {
|
|
a.logger.Error("error initializing oidc provider", "provider", provider.Provider, "provider_url", provider.ProviderURL, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, err.Error(), nil)
|
|
}
|
|
|
|
oauthCfg := oauth2.Config{
|
|
ClientID: provider.ClientID,
|
|
ClientSecret: provider.ClientSecret,
|
|
Endpoint: oidcProv.Endpoint(),
|
|
RedirectURL: provider.RedirectURL,
|
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
|
}
|
|
|
|
verifier := oidcProv.Verifier(&oidc.Config{ClientID: provider.ClientID})
|
|
|
|
oauthCfgs[provider.ID] = oauthCfg
|
|
verifiers[provider.ID] = verifier
|
|
}
|
|
|
|
a.cfg = cfg
|
|
a.oauthCfgs = oauthCfgs
|
|
a.verifiers = verifiers
|
|
|
|
return nil
|
|
}
|
|
|
|
// LoginURL returns the login URL for the given provider.
|
|
func (a *Auth) LoginURL(providerID int, state string) (string, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
oauthCfg, ok := a.oauthCfgs[providerID]
|
|
if !ok {
|
|
return "", envelope.NewError(envelope.InputError, a.i18n.T("validation.notFoundProvider"), nil)
|
|
}
|
|
return oauthCfg.AuthCodeURL(state), nil
|
|
}
|
|
|
|
// ExchangeOIDCToken takes an OIDC authorization code, validates it, and returns an OIDC token for subsequent auth.
|
|
func (a *Auth) ExchangeOIDCToken(ctx context.Context, providerID int, code string) (string, OIDCclaim, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
|
|
oauthCfg, ok := a.oauthCfgs[providerID]
|
|
if !ok {
|
|
return "", OIDCclaim{}, fmt.Errorf("invalid provider ID: %d", providerID)
|
|
}
|
|
|
|
verifier, ok := a.verifiers[providerID]
|
|
if !ok {
|
|
return "", OIDCclaim{}, fmt.Errorf("invalid provider ID: %d", providerID)
|
|
}
|
|
|
|
tk, err := oauthCfg.Exchange(ctx, code)
|
|
if err != nil {
|
|
return "", OIDCclaim{}, fmt.Errorf("error exchanging token: %v", err)
|
|
}
|
|
|
|
// Extract the ID Token from OAuth2 token.
|
|
rawIDTk, ok := tk.Extra("id_token").(string)
|
|
if !ok {
|
|
return "", OIDCclaim{}, errors.New("id_token missing")
|
|
}
|
|
|
|
// Parse and verify ID Token payload.
|
|
idTk, err := verifier.Verify(ctx, rawIDTk)
|
|
if err != nil {
|
|
return "", OIDCclaim{}, fmt.Errorf("error verifying ID token: %v", err)
|
|
}
|
|
|
|
var claims OIDCclaim
|
|
if err := idTk.Claims(&claims); err != nil {
|
|
return "", OIDCclaim{}, errors.New("error getting user from OIDC")
|
|
}
|
|
return rawIDTk, claims, nil
|
|
}
|
|
|
|
// SaveSession creates and sets a session (post successful login/auth).
|
|
func (a *Auth) SaveSession(user amodels.User, r *fastglue.Request) error {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
|
|
sess, err := a.sess.NewSession(r, r)
|
|
if err != nil {
|
|
a.logger.Error("error creating login session", "error", err)
|
|
return err
|
|
}
|
|
|
|
if err := sess.SetMulti(map[string]interface{}{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"first_name": user.FirstName,
|
|
"last_name": user.LastName,
|
|
}); err != nil {
|
|
a.logger.Error("error setting login session", "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetSessionValues sets passed values in the session.
|
|
func (a *Auth) SetSessionValues(r *fastglue.Request, values map[string]interface{}) error {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
|
|
sess, err := a.sess.Acquire(r.RequestCtx, r, r)
|
|
if err != nil {
|
|
a.logger.Error("error acquiring session", "error", err)
|
|
return err
|
|
}
|
|
|
|
if err := sess.SetMulti(values); err != nil {
|
|
a.logger.Error("error setting session values", "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetSessionValue returns the value for the given key from the session.
|
|
func (a *Auth) GetSessionValue(r *fastglue.Request, key string) (any, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
|
|
sess, err := a.sess.Acquire(r.RequestCtx, r, r)
|
|
if err != nil {
|
|
a.logger.Error("error acquiring session", "error", err)
|
|
return "", err
|
|
}
|
|
|
|
val, err := sess.Get(key)
|
|
if err != nil {
|
|
a.logger.Error("error fetching session value", "error", err)
|
|
return "", err
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
// SetCSRFCookie sets the CSRF token in the response cookie if not already set.
|
|
func (a *Auth) SetCSRFCookie(r *fastglue.Request) error {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
|
|
cookie := r.RequestCtx.Request.Header.Cookie("csrf_token")
|
|
if cookie == nil {
|
|
token, err := generateCSRFToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var csrfCookie fasthttp.Cookie
|
|
csrfCookie.SetKey("csrf_token")
|
|
csrfCookie.SetValue(token)
|
|
csrfCookie.SetPath("/")
|
|
csrfCookie.SetSecure(a.cfg.SecureCookies)
|
|
csrfCookie.SetHTTPOnly(false)
|
|
r.RequestCtx.Response.Header.SetCookie(&csrfCookie)
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateSession validates the session and returns the user.
|
|
func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
|
|
sess, err := a.sess.Acquire(r.RequestCtx, r, r)
|
|
if err != nil {
|
|
a.logger.Error("error acquiring session", "error", err)
|
|
return models.User{}, err
|
|
}
|
|
|
|
sessVals, err := sess.GetMulti("id", "email", "first_name", "last_name")
|
|
if err != nil {
|
|
a.logger.Error("error fetching session variables", "error", err)
|
|
return models.User{}, err
|
|
}
|
|
|
|
var (
|
|
userID, _ = sess.Int(sessVals["id"], nil)
|
|
email, _ = sess.String(sessVals["email"], nil)
|
|
firstName, _ = sess.String(sessVals["first_name"], nil)
|
|
lastName, _ = sess.String(sessVals["last_name"], nil)
|
|
)
|
|
|
|
return models.User{
|
|
ID: userID,
|
|
Email: null.NewString(email, email != ""),
|
|
FirstName: firstName,
|
|
LastName: lastName,
|
|
}, nil
|
|
}
|
|
|
|
// DestroySession destroys session
|
|
func (a *Auth) DestroySession(r *fastglue.Request) error {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
|
|
sess, err := a.sess.Acquire(r.RequestCtx, r, r)
|
|
if err != nil {
|
|
a.logger.Error("error acquiring session", "error", err)
|
|
return err
|
|
}
|
|
if err := sess.Destroy(); err != nil {
|
|
a.logger.Error("error clearing session", "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// generateCSRFToken creates a random base64 encoded str.
|
|
func generateCSRFToken() (string, error) {
|
|
b, err := stringutil.RandomAlphanumeric(32)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString([]byte(b)), nil
|
|
}
|
|
|
|
// getRequestCookie returns fashttp.Cookie for the given name.
|
|
func getRequestCookie(name string, r *fastglue.Request) (*fasthttp.Cookie, error) {
|
|
val := r.RequestCtx.Request.Header.Cookie(name)
|
|
if len(val) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
c := fasthttp.AcquireCookie()
|
|
if err := c.ParseBytes(val); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// simpleSessGetCookieCB is the simplessesions callback for retrieving the session cookie
|
|
// from a fastglue request.
|
|
func simpleSessGetCookieCB(name string, r interface{}) (*http.Cookie, error) {
|
|
req, ok := r.(*fastglue.Request)
|
|
if !ok {
|
|
return nil, errors.New("session callback doesn't have fastglue.Request")
|
|
}
|
|
|
|
// Create fast http cookie and parse it from cookie bytes.
|
|
c, err := getRequestCookie(name, req)
|
|
if c == nil {
|
|
if err == nil {
|
|
return nil, http.ErrNoCookie
|
|
} else {
|
|
return nil, err
|
|
}
|
|
|
|
}
|
|
|
|
// Convert fasthttp cookie to net http cookie.
|
|
return &http.Cookie{
|
|
Name: name,
|
|
Value: string(c.Value()),
|
|
Path: string(c.Path()),
|
|
Domain: string(c.Domain()),
|
|
Expires: c.Expire(),
|
|
MaxAge: c.MaxAge(),
|
|
Secure: c.Secure(),
|
|
HttpOnly: c.HTTPOnly(),
|
|
SameSite: http.SameSite(c.SameSite()),
|
|
}, nil
|
|
}
|
|
|
|
// simpleSessSetCookieCB is the simplessesions callback for setting the session cookie
|
|
// to a fastglue request.
|
|
func simpleSessSetCookieCB(c *http.Cookie, w interface{}) error {
|
|
req, ok := w.(*fastglue.Request)
|
|
if !ok {
|
|
return errors.New("session callback doesn't have fastglue.Request")
|
|
}
|
|
|
|
fc := fasthttp.AcquireCookie()
|
|
defer fasthttp.ReleaseCookie(fc)
|
|
|
|
fc.SetKey(c.Name)
|
|
fc.SetValue(c.Value)
|
|
fc.SetPath(c.Path)
|
|
fc.SetDomain(c.Domain)
|
|
fc.SetExpire(c.Expires)
|
|
fc.SetMaxAge(int(c.MaxAge))
|
|
fc.SetSecure(c.Secure)
|
|
fc.SetHTTPOnly(c.HttpOnly)
|
|
fc.SetSameSite(fasthttp.CookieSameSite(c.SameSite))
|
|
|
|
req.RequestCtx.Response.Header.SetCookie(fc)
|
|
return nil
|
|
}
|