Forward For WS

This commit is contained in:
mr
2026-04-01 17:16:18 +02:00
parent 744caf9a5e
commit 284667e95c
10 changed files with 570 additions and 66 deletions

View File

@@ -5,12 +5,14 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"oc-auth/conf"
"oc-auth/infrastructure"
auth_connectors "oc-auth/infrastructure/auth_connector"
"regexp"
"strconv"
"strings"
"sync"
"time"
oclib "cloud.o-forge.io/core/oc-lib"
@@ -20,6 +22,48 @@ import (
beego "github.com/beego/beego/v2/server/web"
)
var selfPeerCache struct {
sync.RWMutex
peer *model.Peer
fetchedAt time.Time
}
const selfPeerCacheTTL = 60 * time.Second
func getCachedSelfPeer() *model.Peer {
selfPeerCache.RLock()
if selfPeerCache.peer != nil && time.Since(selfPeerCache.fetchedAt) < selfPeerCacheTTL {
p := selfPeerCache.peer
selfPeerCache.RUnlock()
return p
}
selfPeerCache.RUnlock()
pp := oclib.NewRequestAdmin(oclib.LibDataEnum(oclib.PEER), nil).Search(
&dbs.Filters{
Or: map[string][]dbs.Filter{
"relation": {{Operator: dbs.EQUAL.String(), Value: peer.SELF}},
},
}, strconv.Itoa(peer.SELF.EnumIndex()), false)
if len(pp.Data) == 0 || pp.Code >= 300 || pp.Err != "" {
return nil
}
p := pp.Data[0].(*model.Peer)
selfPeerCache.Lock()
selfPeerCache.peer = p
selfPeerCache.fetchedAt = time.Now()
selfPeerCache.Unlock()
return p
}
// InvalidateSelfPeerCache forces the next call to getCachedSelfPeer to re-fetch from DB.
func InvalidateSelfPeerCache() {
selfPeerCache.Lock()
selfPeerCache.peer = nil
selfPeerCache.Unlock()
}
// OAuthController handles OAuth2 login/consent provider endpoints
type OAuthController struct {
beego.Controller
@@ -28,6 +72,7 @@ type OAuthController struct {
// @Title GetLogin
// @Description Hydra redirects here with a login_challenge. Returns challenge info or auto-accepts if session exists.
// @Param login_challenge query string true "The login challenge from Hydra"
// @Param client_id query string true "The targetted client_id from Hydra"
// @Param redirect query string true "explicit redirect by passed"
// @Success 200 {object} auth_connectors.LoginChallenge
@@ -37,9 +82,27 @@ type OAuthController struct {
func (o *OAuthController) GetLogin() {
logger := oclib.GetLogger()
challenge := o.Ctx.Input.Query("login_challenge")
clientID := o.Ctx.Input.Query("client_id")
if challenge == "" {
o.Ctx.ResponseWriter.WriteHeader(400)
o.Data["json"] = map[string]string{"error": "missing login_challenge parameter"}
// No challenge yet — initiate the OAuth2 flow server-side to get one from Hydra.
// This supports thick clients that cannot follow browser redirects.
freshChallenge, err := infrastructure.GetAuthConnector().InitiateLogin(clientID, "")
if err != nil {
logger.Error().Msg("Failed to initiate login: " + err.Error())
o.Ctx.ResponseWriter.WriteHeader(500)
o.Data["json"] = map[string]string{"error": err.Error()}
o.ServeJSON()
return
}
loginChallenge, err := infrastructure.GetAuthConnector().GetLoginChallenge(freshChallenge)
if err != nil {
logger.Error().Msg("Failed to get fresh login challenge: " + err.Error())
o.Ctx.ResponseWriter.WriteHeader(500)
o.Data["json"] = map[string]string{"error": err.Error()}
o.ServeJSON()
return
}
o.Data["json"] = loginChallenge
o.ServeJSON()
return
}
@@ -76,8 +139,6 @@ func (o *OAuthController) GetLogin() {
o.Data["json"] = redirect
o.ServeJSON()
return
return
}
// Return challenge info so frontend can render login form
o.Data["json"] = loginChallenge
@@ -86,7 +147,7 @@ func (o *OAuthController) GetLogin() {
// @Title PostLogin
// @Description Authenticate user via LDAP and accept Hydra login challenge
// @Param redirect query string true "explicit redirect by passed"
// @Param return_mode query string false "Return mode: 'redirect' (default, 303), 'json' (full object), 'token' (access token string)"
// @Param body body auth_connectors.LoginRequest true "Login credentials and challenge"
// @Success 200 {object} auth_connectors.Redirect
@@ -95,7 +156,10 @@ func (o *OAuthController) GetLogin() {
// @router /login [post]
func (o *OAuthController) Login() {
logger := oclib.GetLogger()
red := o.Ctx.Input.Query("redirect")
returnMode := o.Ctx.Input.Query("return_mode")
if returnMode == "" {
returnMode = "redirect"
}
var req auth_connectors.LoginRequest
if err := json.Unmarshal(o.Ctx.Input.CopyBody(10000000), &req); err != nil {
@@ -112,6 +176,13 @@ func (o *OAuthController) Login() {
return
}
if req.LoginChallenge == "" {
o.Ctx.ResponseWriter.WriteHeader(400)
o.Data["json"] = map[string]string{"error": "login_challenge is required in non-local mode"}
o.ServeJSON()
return
}
// Authenticate via LDAP
ldap := auth_connectors.New()
found, err := ldap.Authenticate(o.Ctx.Request.Context(), req.Username, req.Password)
@@ -140,6 +211,12 @@ func (o *OAuthController) Login() {
ExpiresIn: 3600,
AccessToken: "localtoken." + base64.StdEncoding.EncodeToString(b),
}
if returnMode == "token" {
o.Ctx.ResponseWriter.Header().Set("Content-Type", "text/plain")
o.Ctx.ResponseWriter.WriteHeader(200)
o.Ctx.ResponseWriter.Write([]byte(token.AccessToken))
return
}
o.Data["json"] = token
} else {
o.Ctx.ResponseWriter.WriteHeader(401)
@@ -149,13 +226,6 @@ func (o *OAuthController) Login() {
return
}
if req.LoginChallenge == "" {
o.Ctx.ResponseWriter.WriteHeader(400)
o.Data["json"] = map[string]string{"error": "login_challenge is required in non-local mode"}
o.ServeJSON()
return
}
// Accept the login challenge with Hydra
redirect, err := infrastructure.GetAuthConnector().AcceptLogin(req.LoginChallenge, req.Username)
if err != nil {
@@ -166,13 +236,28 @@ func (o *OAuthController) Login() {
return
}
// Return redirect_to so the frontend follows the OAuth2 flow
if red == "false" {
o.Data["json"] = redirect
// Return according to requested mode
switch returnMode {
case "token", "json":
tokenResp, err := completeFlowToToken(redirect.RedirectTo, req.Username, req.LoginChallenge)
if err != nil {
logger.Error().Msg("Failed to complete OAuth2 flow: " + err.Error())
o.Ctx.ResponseWriter.WriteHeader(500)
o.Data["json"] = map[string]string{"error": err.Error()}
o.ServeJSON()
return
}
if returnMode == "token" {
o.Ctx.ResponseWriter.Header().Set("Content-Type", "text/plain")
o.Ctx.ResponseWriter.WriteHeader(200)
o.Ctx.ResponseWriter.Write([]byte(tokenResp.AccessToken))
return
}
o.Data["json"] = tokenResp
o.ServeJSON()
return
default: // "redirect"
o.Redirect(redirect.RedirectTo, 303)
}
o.Redirect(redirect.RedirectTo, 303)
}
// @Title Consent
@@ -210,7 +295,6 @@ func (o *OAuthController) Consent() {
"relation": {{Operator: dbs.EQUAL.String(), Value: peer.SELF}},
},
}, strconv.Itoa(peer.SELF.EnumIndex()), false)
fmt.Println(pp.Err, pp.Data)
if len(pp.Data) == 0 || pp.Code >= 300 || pp.Err != "" {
logger.Error().Msg("Self peer not found")
o.Ctx.ResponseWriter.WriteHeader(500)
@@ -427,7 +511,10 @@ var whitelist = []string{
// @router /forward [get]
func (o *OAuthController) InternalAuthForward() {
fmt.Println("InternalAuthForward")
uri := o.Ctx.Request.Header.Get("X-Forwarded-Uri")
uri := o.Ctx.Request.Header.Get("X-Replaced-Path")
if uri == "" {
uri = o.Ctx.Request.Header.Get("X-Forwarded-Uri")
}
for _, w := range whitelist {
if strings.Contains(uri, w) {
fmt.Println("WHITELIST", w)
@@ -439,6 +526,13 @@ func (o *OAuthController) InternalAuthForward() {
origin, publicKey, external := o.extractOrigin(o.Ctx.Request)
reqToken := o.Ctx.Request.Header.Get("Authorization")
if reqToken == "" {
// WebSocket upgrade: the browser cannot send custom headers, so the token
// is passed as the Sec-WebSocket-Protocol subprotocol value instead.
if proto := o.Ctx.Request.Header.Get("Sec-WebSocket-Protocol"); proto != "" {
reqToken = "Bearer " + proto
}
}
if reqToken == "" {
// Step 1: no token — allow oc-auth's own challenge endpoints (no token needed).
// No token and not a whitelisted path → restart OAuth2 flow.
@@ -456,23 +550,33 @@ func (o *OAuthController) InternalAuthForward() {
}
reqToken = splitToken[1]
// Step 3: resolve the calling peer — only our own peer (SELF) is authorized.
// A non-SELF or unknown peer is a network/config issue, not a login problem → 401.
if external || origin == "" || publicKey == "" {
fmt.Println("Unauthorized", external, origin, publicKey)
// Step 3: verify the token belongs to our self peer.
// Decode the JWT payload and extract ext.peer_id, then compare against the cached self peer UUID.
// A mismatch means the request comes from a foreign peer → 401 (not a login problem).
tokenPeerID := extractPeerIDFromToken(reqToken)
selfPeer := getCachedSelfPeer()
fmt.Println("TOKEN", tokenPeerID, selfPeer.UUID)
if selfPeer == nil || tokenPeerID != selfPeer.UUID {
o.Ctx.ResponseWriter.WriteHeader(http.StatusUnauthorized)
return
}
// Step 4: introspect via Hydra then check permissions via Keto.
// 401 → token inactive/invalid, user must re-authenticate → restart OAuth2 flow.
// 403 → token valid, but permissions denied → forbidden.
// 200 → all good, let Traefik forward to the target route.
switch infrastructure.GetAuthConnector().CheckAuthForward(
introspection, permissionKey, code := infrastructure.GetAuthConnector().CheckAuthForward(
reqToken, publicKey, origin,
o.Ctx.Request.Header.Get("X-Forwarded-Method"),
uri, external) {
uri, external)
switch code {
case http.StatusOK:
user, _, _ := oclib.ExtractTokenInfo(*o.Ctx.Request)
claims := infrastructure.GetClaims().BuildConsentSession(conf.GetConfig().OAuth2ClientID, user, selfPeer)
if !claims.EqualClaims(introspection, permissionKey) {
fmt.Println("Token is not fresh or compromised")
o.Ctx.ResponseWriter.WriteHeader(http.StatusConflict)
return
}
fmt.Println("OK")
o.Ctx.ResponseWriter.WriteHeader(http.StatusOK)
case http.StatusForbidden:
@@ -486,17 +590,27 @@ func (o *OAuthController) InternalAuthForward() {
}
// redirectToLogin redirects the client to Hydra's authorization endpoint to start a fresh
// OAuth2 flow. The original request URL is passed as the state parameter so the frontend
// can redirect back after successful authentication.
// OAuth2 flow. Hydra will generate a login_challenge and redirect to the configured login URL.
func (o *OAuthController) redirectToLogin(origin string) {
cfg := conf.GetConfig()
var clientID, redirectURI string
if strings.Contains(origin, cfg.AdminOrigin) {
o.Ctx.ResponseWriter.Header().Set("Location", cfg.OAdminAuthRedirectURI)
clientID = cfg.OAuth2AdminClientID
redirectURI = cfg.OAdminAuthRedirectURI
} else {
o.Ctx.ResponseWriter.Header().Set("Location", cfg.OAuthRedirectURI)
clientID = cfg.OAuth2ClientID
redirectURI = cfg.OAuthRedirectURI
}
hydraAuthURL := fmt.Sprintf("http://%s:%d/oauth2/auth?client_id=%s&response_type=code&redirect_uri=%s&scope=openid",
cfg.AuthConnectPublicHost,
cfg.AuthConnectorPort,
url.QueryEscape(clientID),
url.QueryEscape(redirectURI),
)
o.Ctx.ResponseWriter.Header().Set("Location", hydraAuthURL)
o.Ctx.ResponseWriter.WriteHeader(http.StatusFound)
}
@@ -589,6 +703,87 @@ func ExtractClient(request http.Request) string {
return ""
}
// completeFlowToToken drives the server-side OAuth2 flow after AcceptLogin.
// It follows Hydra's redirect to grab the consent_challenge, accepts it,
// then exchanges the resulting auth code for a token.
func completeFlowToToken(loginRedirectTo string, subject string, loginChallenge string) (*auth_connectors.TokenResponse, error) {
connector := infrastructure.GetAuthConnector()
// Step 1: follow the login redirect to get the consent_challenge (uses CSRF cookie from InitiateLogin)
consentChallenge, err := connector.FollowToConsentChallenge(loginRedirectTo, loginChallenge)
if err != nil {
return nil, fmt.Errorf("consent challenge: %w", err)
}
// Step 2: fetch consent challenge details (scopes + client_id)
consentDetails, err := connector.GetConsentChallenge(consentChallenge)
if err != nil {
return nil, fmt.Errorf("get consent challenge: %w", err)
}
clientID := ""
if consentDetails.Client != nil {
if cid, ok := consentDetails.Client["client_id"].(string); ok {
clientID = cid
}
}
// Step 3: get self peer for claims
pp := oclib.NewRequestAdmin(oclib.LibDataEnum(oclib.PEER), nil).Search(
&dbs.Filters{
Or: map[string][]dbs.Filter{
"relation": {{Operator: dbs.EQUAL.String(), Value: peer.SELF}},
},
}, strconv.Itoa(peer.SELF.EnumIndex()), false)
if len(pp.Data) == 0 || pp.Code >= 300 || pp.Err != "" {
return nil, fmt.Errorf("self peer not found")
}
p := pp.Data[0].(*model.Peer)
// Step 4: accept consent
session := infrastructure.GetClaims().BuildConsentSession(clientID, subject, p)
consentRedirect, err := connector.AcceptConsent(consentChallenge, consentDetails.RequestedScope, session)
if err != nil {
return nil, fmt.Errorf("accept consent: %w", err)
}
// Step 5: follow consent redirect to exchange auth code for token (uses CSRF cookie, cleans up jar)
token, err := connector.ExchangeCodeForToken(consentRedirect.RedirectTo, clientID, loginChallenge)
if err != nil {
return nil, fmt.Errorf("exchange code: %w", err)
}
return token, nil
}
// extractPeerIDFromToken decodes the JWT payload and returns ext.peer_id.
func extractPeerIDFromToken(token string) string {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return ""
}
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
b, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return ""
}
var claims map[string]interface{}
if err := json.Unmarshal(b, &claims); err != nil {
return ""
}
ext, ok := claims["ext"].(map[string]interface{})
if !ok {
return ""
}
peerID, _ := ext["peer_id"].(string)
return peerID
}
// extractBearerToken extracts the token from the Authorization header
func extractBearerToken(r *http.Request) string {
reqToken := r.Header.Get("Authorization")