oidc: add confirmation page for node registration
Render an interstitial showing device hostname, OS, and machine-key
fingerprint before finalising OIDC registration. The user must POST
to /register/confirm/{auth_id} with a CSRF double-submit cookie.
Removes the TODO at oidc.go:201.
This commit is contained in:
parent
d5a4e6e36a
commit
d66d3a4269
@ -485,6 +485,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
|||||||
|
|
||||||
if provider, ok := h.authProvider.(*AuthProviderOIDC); ok {
|
if provider, ok := h.authProvider.(*AuthProviderOIDC); ok {
|
||||||
r.Get("/oidc/callback", provider.OIDCCallbackHandler)
|
r.Get("/oidc/callback", provider.OIDCCallbackHandler)
|
||||||
|
r.Post("/register/confirm/{auth_id}", provider.RegisterConfirmHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Get("/apple", h.AppleConfigMessage)
|
r.Get("/apple", h.AppleConfigMessage)
|
||||||
|
|||||||
@ -346,30 +346,13 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this is a registration flow, then we need to register the node.
|
// If this is a registration flow, render the confirmation
|
||||||
|
// interstitial instead of finalising the registration immediately.
|
||||||
|
// Without an explicit user click, a single GET to
|
||||||
|
// /register/{auth_id} could silently complete a registration when
|
||||||
|
// the IdP allows silent SSO.
|
||||||
if authInfo.Registration {
|
if authInfo.Registration {
|
||||||
newNode, err := a.handleRegistration(user, authInfo.AuthID, nodeExpiry)
|
a.renderRegistrationConfirmInterstitial(writer, req, authInfo.AuthID, user, nodeExpiry)
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
|
||||||
log.Debug().Caller().Str("auth_id", authInfo.AuthID.String()).Msg("registration session expired before authorization completed")
|
|
||||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", err))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
httpError(writer, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
content := renderRegistrationSuccessTemplate(user, newNode)
|
|
||||||
|
|
||||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
writer.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
if _, err := writer.Write(content.Bytes()); err != nil { //nolint:noinlineerr
|
|
||||||
util.LogErr(err, "Failed to write HTTP response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -676,6 +659,202 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
|
|||||||
return user, c, nil
|
return user, c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerConfirmCSRFCookie is the cookie name used to bind the
|
||||||
|
// /register/confirm POST handler's CSRF token to the OIDC callback that
|
||||||
|
// rendered the interstitial. It includes a per-session prefix derived
|
||||||
|
// from the auth ID so cookies for unrelated registrations on the same
|
||||||
|
// browser do not collide.
|
||||||
|
const registerConfirmCSRFCookie = "headscale_register_confirm"
|
||||||
|
|
||||||
|
// renderRegistrationConfirmInterstitial captures the resolved OIDC
|
||||||
|
// identity and node expiry into the cached AuthRequest, sets the CSRF
|
||||||
|
// cookie, and renders the confirmation page that the user must
|
||||||
|
// explicitly submit before the registration is finalised.
|
||||||
|
func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
req *http.Request,
|
||||||
|
authID types.AuthID,
|
||||||
|
user *types.User,
|
||||||
|
nodeExpiry *time.Time,
|
||||||
|
) {
|
||||||
|
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||||
|
if !ok {
|
||||||
|
log.Debug().Caller().Str("auth_id", authID.String()).Msg("registration session expired before authorization completed")
|
||||||
|
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authReq.IsRegistration() {
|
||||||
|
log.Warn().Caller().
|
||||||
|
Str("auth_id", authID.String()).
|
||||||
|
Msg("OIDC callback hit registration path with auth request that is not a node registration")
|
||||||
|
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
csrf, err := util.GenerateRandomStringURLSafe(32)
|
||||||
|
if err != nil {
|
||||||
|
httpError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authReq.SetPendingConfirmation(&types.PendingRegistrationConfirmation{
|
||||||
|
UserID: user.ID,
|
||||||
|
NodeExpiry: nodeExpiry,
|
||||||
|
CSRF: csrf,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.SetCookie(writer, &http.Cookie{
|
||||||
|
Name: registerConfirmCSRFCookie,
|
||||||
|
Value: csrf,
|
||||||
|
Path: "/register/confirm/" + authID.String(),
|
||||||
|
MaxAge: int(authCacheExpiration.Seconds()),
|
||||||
|
Secure: req.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
regData := authReq.RegistrationData()
|
||||||
|
|
||||||
|
info := templates.RegisterConfirmInfo{
|
||||||
|
FormAction: "/register/confirm/" + authID.String(),
|
||||||
|
CSRFTokenName: registerConfirmCSRFCookie,
|
||||||
|
CSRFToken: csrf,
|
||||||
|
User: user.Display(),
|
||||||
|
Hostname: regData.Hostname,
|
||||||
|
MachineKey: regData.MachineKey.ShortString(),
|
||||||
|
}
|
||||||
|
if regData.Hostinfo != nil {
|
||||||
|
info.OS = regData.Hostinfo.OS
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
if _, err := writer.Write([]byte(templates.RegisterConfirm(info).Render())); err != nil { //nolint:noinlineerr
|
||||||
|
util.LogErr(err, "Failed to write HTTP response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterConfirmHandler is the POST endpoint behind the OIDC
|
||||||
|
// registration confirmation interstitial. It validates the CSRF cookie
|
||||||
|
// against the form-submitted token, finalises the registration via
|
||||||
|
// handleRegistration, and renders the success page.
|
||||||
|
func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
req *http.Request,
|
||||||
|
) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
httpError(writer, errMethodNotAllowed)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authID, err := authIDFromRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
httpError(writer, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap the form body. The confirmation form is a single CSRF token,
|
||||||
|
// so 4 KiB is generous and prevents an unauthenticated client from
|
||||||
|
// submitting an arbitrarily large body to ParseForm.
|
||||||
|
req.Body = http.MaxBytesReader(writer, req.Body, 4*1024)
|
||||||
|
|
||||||
|
if err := req.ParseForm(); err != nil { //nolint:noinlineerr,gosec // body is bounded above
|
||||||
|
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formCSRF := req.PostFormValue(registerConfirmCSRFCookie) //nolint:gosec // body is bounded above
|
||||||
|
if formCSRF == "" {
|
||||||
|
httpError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie, err := req.Cookie(registerConfirmCSRFCookie)
|
||||||
|
if err != nil {
|
||||||
|
httpError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cookie.Value != formCSRF {
|
||||||
|
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||||
|
if !ok {
|
||||||
|
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := authReq.PendingConfirmation()
|
||||||
|
if pending == nil {
|
||||||
|
httpError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if pending.CSRF != cookie.Value {
|
||||||
|
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := a.h.state.GetUserByID(types.UserID(pending.UserID))
|
||||||
|
if err != nil {
|
||||||
|
httpError(writer, fmt.Errorf("looking up user: %w", err))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
||||||
|
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpError(writer, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the CSRF cookie now that the registration is final.
|
||||||
|
http.SetCookie(writer, &http.Cookie{
|
||||||
|
Name: registerConfirmCSRFCookie,
|
||||||
|
Value: "",
|
||||||
|
Path: "/register/confirm/" + authID.String(),
|
||||||
|
MaxAge: -1,
|
||||||
|
Secure: req.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
content := renderRegistrationSuccessTemplate(user, newNode)
|
||||||
|
|
||||||
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// renderRegistrationSuccessTemplate's output only embeds
|
||||||
|
// HTML-escaped values from a server-side template, so the gosec
|
||||||
|
// XSS warning is a false positive here.
|
||||||
|
if _, err := writer.Write(content.Bytes()); err != nil { //nolint:noinlineerr,gosec
|
||||||
|
util.LogErr(err, "Failed to write HTTP response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *AuthProviderOIDC) handleRegistration(
|
func (a *AuthProviderOIDC) handleRegistration(
|
||||||
user *types.User,
|
user *types.User,
|
||||||
registrationID types.AuthID,
|
registrationID types.AuthID,
|
||||||
|
|||||||
102
hscontrol/oidc_confirm_test.go
Normal file
102
hscontrol/oidc_confirm_test.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package hscontrol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newConfirmRequest(t *testing.T, authID types.AuthID, formCSRF, cookieCSRF string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
form := strings.NewReader(registerConfirmCSRFCookie + "=" + formCSRF)
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodPost,
|
||||||
|
"/register/confirm/"+authID.String(),
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: registerConfirmCSRFCookie,
|
||||||
|
Value: cookieCSRF,
|
||||||
|
})
|
||||||
|
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("auth_id", authID.String())
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterConfirmHandler_RejectsCSRFMismatch verifies that the
|
||||||
|
// /register/confirm POST handler refuses to finalise a pending
|
||||||
|
// registration when the form CSRF token does not match the cookie.
|
||||||
|
func TestRegisterConfirmHandler_RejectsCSRFMismatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := createTestApp(t)
|
||||||
|
provider := &AuthProviderOIDC{h: app}
|
||||||
|
|
||||||
|
// Mint a pending registration with a stashed pending-confirmation,
|
||||||
|
// as the OIDC callback would have done after resolving the user
|
||||||
|
// identity but before the user clicked the interstitial form.
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
regReq := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
|
Hostname: "phish-target",
|
||||||
|
})
|
||||||
|
regReq.SetPendingConfirmation(&types.PendingRegistrationConfirmation{
|
||||||
|
UserID: 1,
|
||||||
|
CSRF: "expected-csrf",
|
||||||
|
})
|
||||||
|
app.state.SetAuthCacheEntry(authID, regReq)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
provider.RegisterConfirmHandler(rec,
|
||||||
|
newConfirmRequest(t, authID, "wrong-csrf", "expected-csrf"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, rec.Code,
|
||||||
|
"CSRF cookie/form mismatch must be rejected with 403")
|
||||||
|
|
||||||
|
// And the registration must still be pending — the rejected POST
|
||||||
|
// must not have called handleRegistration.
|
||||||
|
cached, ok := app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, ok, "rejected POST must not evict the cached registration")
|
||||||
|
require.NotNil(t, cached.PendingConfirmation(),
|
||||||
|
"rejected POST must not clear the pending confirmation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterConfirmHandler_RejectsWithoutPending verifies that
|
||||||
|
// /register/confirm refuses to finalise a registration that did not
|
||||||
|
// first complete the OIDC interstitial. Without this check an attacker
|
||||||
|
// who knew an auth_id could POST directly to the confirm endpoint and
|
||||||
|
// claim the device.
|
||||||
|
func TestRegisterConfirmHandler_RejectsWithoutPending(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := createTestApp(t)
|
||||||
|
provider := &AuthProviderOIDC{h: app}
|
||||||
|
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
// Cached registration with NO pending confirmation set — i.e. the
|
||||||
|
// OIDC callback has not run yet.
|
||||||
|
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(
|
||||||
|
&types.RegistrationData{Hostname: "no-oidc-yet"},
|
||||||
|
))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
provider.RegisterConfirmHandler(rec,
|
||||||
|
newConfirmRequest(t, authID, "fake", "fake"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, rec.Code,
|
||||||
|
"confirm without prior OIDC pending state must be rejected with 403")
|
||||||
|
}
|
||||||
101
hscontrol/templates/register_confirm.go
Normal file
101
hscontrol/templates/register_confirm.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/chasefleming/elem-go"
|
||||||
|
"github.com/chasefleming/elem-go/attrs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterConfirmInfo carries the human-readable information shown on
|
||||||
|
// the registration confirmation interstitial that an OIDC-authenticated
|
||||||
|
// user must explicitly accept before a pending node is registered to
|
||||||
|
// their identity. The fields here intentionally include enough device
|
||||||
|
// detail (hostname, OS, machine-key fingerprint) for the user to
|
||||||
|
// recognise whether the device they are about to claim is in fact
|
||||||
|
// theirs.
|
||||||
|
type RegisterConfirmInfo struct {
|
||||||
|
// FormAction is the absolute or relative URL the confirm form
|
||||||
|
// POSTs to. Typically /register/confirm/{auth_id}.
|
||||||
|
FormAction string
|
||||||
|
|
||||||
|
// CSRFTokenName is the name of the hidden form field carrying the
|
||||||
|
// CSRF token. The corresponding cookie shares this name.
|
||||||
|
CSRFTokenName string
|
||||||
|
|
||||||
|
// CSRFToken is the per-session token that must match the value of
|
||||||
|
// the cookie set by the OIDC callback before the POST is honoured.
|
||||||
|
CSRFToken string
|
||||||
|
|
||||||
|
// User is the OIDC-authenticated identity the device will be
|
||||||
|
// registered to if the user confirms.
|
||||||
|
User string
|
||||||
|
|
||||||
|
// Hostname is the hostname the registering tailscaled instance
|
||||||
|
// reported in its RegisterRequest.
|
||||||
|
Hostname string
|
||||||
|
|
||||||
|
// OS is the operating system the registering tailscaled reported.
|
||||||
|
// May be the empty string when the client did not send Hostinfo.
|
||||||
|
OS string
|
||||||
|
|
||||||
|
// MachineKey is the short fingerprint of the registering machine
|
||||||
|
// key. The full key is intentionally not shown.
|
||||||
|
MachineKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterConfirm renders an interstitial page that asks the
|
||||||
|
// OIDC-authenticated user to explicitly confirm that they want to
|
||||||
|
// register the named device under their account. Without this
|
||||||
|
// confirmation step a single GET to /register/{auth_id} could
|
||||||
|
// silently complete a phishing-style registration when the victim's
|
||||||
|
// IdP allows silent SSO.
|
||||||
|
func RegisterConfirm(info RegisterConfirmInfo) *elem.Element {
|
||||||
|
deviceList := elem.Ul(nil,
|
||||||
|
elem.Li(nil, elem.Strong(nil, elem.Text("Hostname: ")), elem.Text(info.Hostname)),
|
||||||
|
elem.Li(nil, elem.Strong(nil, elem.Text("OS: ")), elem.Text(displayOrUnknown(info.OS))),
|
||||||
|
elem.Li(nil, elem.Strong(nil, elem.Text("Machine key: ")), Code(elem.Text(info.MachineKey))),
|
||||||
|
elem.Li(nil, elem.Strong(nil, elem.Text("Will be registered to: ")), elem.Text(info.User)),
|
||||||
|
)
|
||||||
|
|
||||||
|
form := elem.Form(
|
||||||
|
attrs.Props{
|
||||||
|
attrs.Method: "POST",
|
||||||
|
attrs.Action: info.FormAction,
|
||||||
|
},
|
||||||
|
elem.Input(attrs.Props{
|
||||||
|
attrs.Type: "hidden",
|
||||||
|
attrs.Name: info.CSRFTokenName,
|
||||||
|
attrs.Value: info.CSRFToken,
|
||||||
|
}),
|
||||||
|
elem.Button(
|
||||||
|
attrs.Props{attrs.Type: "submit"},
|
||||||
|
elem.Text("Confirm registration"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return HtmlStructure(
|
||||||
|
elem.Title(nil, elem.Text("Headscale - Confirm node registration")),
|
||||||
|
mdTypesetBody(
|
||||||
|
headscaleLogo(),
|
||||||
|
H2(elem.Text("Confirm node registration")),
|
||||||
|
P(elem.Text(
|
||||||
|
"A device is asking to be added to your tailnet. "+
|
||||||
|
"Please review the details below and confirm that this device is yours.",
|
||||||
|
)),
|
||||||
|
deviceList,
|
||||||
|
form,
|
||||||
|
P(elem.Text(
|
||||||
|
"If you do not recognise this device, close this window. "+
|
||||||
|
"The registration request will expire automatically.",
|
||||||
|
)),
|
||||||
|
pageFooter(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayOrUnknown(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "(unknown)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
@ -230,6 +230,22 @@ type SSHCheckBinding struct {
|
|||||||
DstNodeID NodeID
|
DstNodeID NodeID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PendingRegistrationConfirmation captures the server-side state needed
|
||||||
|
// to finalise a node registration after the user has confirmed it on
|
||||||
|
// the OIDC interstitial. The OIDC callback resolves the user identity
|
||||||
|
// and node expiry, stores them on the cached AuthRequest, and renders
|
||||||
|
// a confirmation page; only when the user POSTs the confirmation form
|
||||||
|
// does the actual node registration run.
|
||||||
|
//
|
||||||
|
// CSRF is a one-shot per-session token that the OIDC callback set
|
||||||
|
// both as a cookie and as a hidden form field. The confirm POST
|
||||||
|
// handler refuses to proceed unless the cookie and form values match.
|
||||||
|
type PendingRegistrationConfirmation struct {
|
||||||
|
UserID uint
|
||||||
|
NodeExpiry *time.Time
|
||||||
|
CSRF string
|
||||||
|
}
|
||||||
|
|
||||||
// AuthRequest represents a pending authentication request from a user or a
|
// AuthRequest represents a pending authentication request from a user or a
|
||||||
// node. It carries the minimum data needed to either complete a node
|
// node. It carries the minimum data needed to either complete a node
|
||||||
// registration (regData populated) or an SSH check-mode auth (sshBinding
|
// registration (regData populated) or an SSH check-mode auth (sshBinding
|
||||||
@ -257,6 +273,13 @@ type AuthRequest struct {
|
|||||||
// safely.
|
// safely.
|
||||||
sshBinding *SSHCheckBinding
|
sshBinding *SSHCheckBinding
|
||||||
|
|
||||||
|
// pendingConfirmation is populated by the OIDC callback for the
|
||||||
|
// node-registration flow once the user identity has been resolved
|
||||||
|
// but before the user has explicitly confirmed the registration on
|
||||||
|
// the interstitial. The /register/confirm POST handler reads it to
|
||||||
|
// finalise the registration without re-running the OIDC flow.
|
||||||
|
pendingConfirmation *PendingRegistrationConfirmation
|
||||||
|
|
||||||
finished chan AuthVerdict
|
finished chan AuthVerdict
|
||||||
closed *atomic.Bool
|
closed *atomic.Bool
|
||||||
}
|
}
|
||||||
@ -331,6 +354,22 @@ func (rn *AuthRequest) IsSSHCheck() bool {
|
|||||||
return rn.sshBinding != nil
|
return rn.sshBinding != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPendingConfirmation marks this AuthRequest as having an
|
||||||
|
// OIDC-resolved user that is waiting to confirm the registration on
|
||||||
|
// the interstitial. The OIDC callback should call this and then render
|
||||||
|
// the confirmation page; the /register/confirm POST handler reads the
|
||||||
|
// stored UserID/NodeExpiry to finish the registration.
|
||||||
|
func (rn *AuthRequest) SetPendingConfirmation(p *PendingRegistrationConfirmation) {
|
||||||
|
rn.pendingConfirmation = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingConfirmation returns the pending OIDC-resolved registration
|
||||||
|
// state captured by SetPendingConfirmation, or nil if no OIDC callback
|
||||||
|
// has yet resolved an identity for this AuthRequest.
|
||||||
|
func (rn *AuthRequest) PendingConfirmation() *PendingRegistrationConfirmation {
|
||||||
|
return rn.pendingConfirmation
|
||||||
|
}
|
||||||
|
|
||||||
func (rn *AuthRequest) FinishAuth(verdict AuthVerdict) {
|
func (rn *AuthRequest) FinishAuth(verdict AuthVerdict) {
|
||||||
if rn.closed.Swap(true) {
|
if rn.closed.Swap(true) {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -57,6 +57,27 @@ func TestNewAuthRequestEmptyPayload(t *testing.T) {
|
|||||||
assert.Panics(t, func() { _ = req.SSHCheckBinding() })
|
assert.Panics(t, func() { _ = req.SSHCheckBinding() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPendingRegistrationConfirmation verifies that the OIDC callback
|
||||||
|
// can stash a pending confirmation onto an AuthRequest and that the
|
||||||
|
// /register/confirm POST handler can read it back unchanged.
|
||||||
|
func TestPendingRegistrationConfirmation(t *testing.T) {
|
||||||
|
req := NewRegisterAuthRequest(&RegistrationData{Hostname: "phish-test"})
|
||||||
|
|
||||||
|
require.Nil(t, req.PendingConfirmation(),
|
||||||
|
"new AuthRequest must have no pending confirmation")
|
||||||
|
|
||||||
|
pending := &PendingRegistrationConfirmation{
|
||||||
|
UserID: 42,
|
||||||
|
CSRF: "csrf-marker",
|
||||||
|
}
|
||||||
|
req.SetPendingConfirmation(pending)
|
||||||
|
|
||||||
|
got := req.PendingConfirmation()
|
||||||
|
require.NotNil(t, got, "PendingConfirmation must return the stored value")
|
||||||
|
assert.Equal(t, uint(42), got.UserID)
|
||||||
|
assert.Equal(t, "csrf-marker", got.CSRF)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultBatcherWorkersFor(t *testing.T) {
|
func TestDefaultBatcherWorkersFor(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
cpuCount int
|
cpuCount int
|
||||||
|
|||||||
@ -1189,9 +1189,118 @@ func doLoginURLWithClient(hostname string, loginURL *url.URL, hc *http.Client, f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The OIDC registration flow now renders a confirmation interstitial
|
||||||
|
// (POST form) instead of completing immediately. Detect the form and
|
||||||
|
// auto-submit it so integration tests behave like a real browser.
|
||||||
|
if followRedirects && strings.Contains(body, `action="/register/confirm/`) {
|
||||||
|
confirmBody, confirmURL, confirmErr := submitConfirmForm(hostname, body, resp, hc)
|
||||||
|
if confirmErr != nil {
|
||||||
|
return body, redirectURL, confirmErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmBody, confirmURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
return body, redirectURL, nil
|
return body, redirectURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// submitConfirmForm parses the OIDC registration confirmation
|
||||||
|
// interstitial HTML, extracts the form action and CSRF token, and
|
||||||
|
// POSTs the form using the same HTTP client (which carries the CSRF
|
||||||
|
// cookie set by the callback).
|
||||||
|
func submitConfirmForm(
|
||||||
|
hostname string,
|
||||||
|
htmlBody string,
|
||||||
|
prevResp *http.Response,
|
||||||
|
hc *http.Client,
|
||||||
|
) (string, *url.URL, error) {
|
||||||
|
// Extract form action URL.
|
||||||
|
actionIdx := strings.Index(htmlBody, `action="`)
|
||||||
|
if actionIdx == -1 {
|
||||||
|
return "", nil, fmt.Errorf("%s confirm form: no action attribute", hostname) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
actionStart := actionIdx + len(`action="`)
|
||||||
|
|
||||||
|
actionEnd := strings.Index(htmlBody[actionStart:], `"`)
|
||||||
|
if actionEnd == -1 {
|
||||||
|
return "", nil, fmt.Errorf("%s confirm form: unterminated action attribute", hostname) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
formAction := htmlBody[actionStart : actionStart+actionEnd]
|
||||||
|
|
||||||
|
// Extract hidden CSRF input value. The rendered <input> has
|
||||||
|
// attributes in name-type-value order so we grab the whole tag.
|
||||||
|
before, _, ok := strings.Cut(htmlBody, `name="headscale_register_confirm"`)
|
||||||
|
if !ok {
|
||||||
|
return "", nil, fmt.Errorf("%s confirm form: no CSRF input", hostname) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
tagStart := strings.LastIndex(before, "<input")
|
||||||
|
if tagStart == -1 {
|
||||||
|
return "", nil, fmt.Errorf("%s confirm form: no input tag for CSRF", hostname) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
tagEnd := strings.Index(htmlBody[tagStart:], ">")
|
||||||
|
if tagEnd == -1 {
|
||||||
|
return "", nil, fmt.Errorf("%s confirm form: unterminated input tag", hostname) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
inputTag := htmlBody[tagStart : tagStart+tagEnd+1]
|
||||||
|
|
||||||
|
valIdx := strings.Index(inputTag, `value="`)
|
||||||
|
if valIdx == -1 {
|
||||||
|
return "", nil, fmt.Errorf("%s confirm form: no value in CSRF input", hostname) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
valStart := valIdx + len(`value="`)
|
||||||
|
valEnd := strings.Index(inputTag[valStart:], `"`)
|
||||||
|
csrfToken := inputTag[valStart : valStart+valEnd]
|
||||||
|
|
||||||
|
// Build the absolute POST URL from the response's request URL.
|
||||||
|
base := prevResp.Request.URL
|
||||||
|
confirmURL := &url.URL{
|
||||||
|
Scheme: base.Scheme,
|
||||||
|
Host: base.Host,
|
||||||
|
Path: formAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("%s auto-submitting confirm form: %s", hostname, confirmURL)
|
||||||
|
|
||||||
|
formData := url.Values{
|
||||||
|
"headscale_register_confirm": {csrfToken},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, confirmURL.String(), strings.NewReader(formData.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("%s creating confirm request: %w", hostname, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
confirmResp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("%s sending confirm request: %w", hostname, err)
|
||||||
|
}
|
||||||
|
defer confirmResp.Body.Close()
|
||||||
|
|
||||||
|
confirmBytes, err := io.ReadAll(confirmResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("%s reading confirm response: %w", hostname, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirmResp.StatusCode != http.StatusOK {
|
||||||
|
return string(confirmBytes), nil, fmt.Errorf( //nolint:err113
|
||||||
|
"%s confirm returned status %d: %s",
|
||||||
|
hostname, confirmResp.StatusCode, string(confirmBytes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(confirmBytes), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
var errParseAuthPage = errors.New("parsing auth page")
|
var errParseAuthPage = errors.New("parsing auth page")
|
||||||
|
|
||||||
func (s *Scenario) runHeadscaleRegister(userStr string, body string) error {
|
func (s *Scenario) runHeadscaleRegister(userStr string, body string) error {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user