mirror of
https://github.com/pomerium/pomerium.git
synced 2025-08-06 10:21:05 +02:00
authenticate: add support for webauthn (#2688)
* authenticate: add support for webauthn * remove rfc4648 library due to missing LICENSE * fix test * put state function in separate function
This commit is contained in:
parent
3051ad77e0
commit
1162585471
12 changed files with 855 additions and 18 deletions
|
@ -7,6 +7,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
|
||||
"github.com/pomerium/pomerium/config"
|
||||
"github.com/pomerium/pomerium/internal/frontend"
|
||||
|
@ -125,3 +126,36 @@ func (a *Authenticate) updateProvider(cfg *config.Config) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Authenticate) getWebAuthnURL(values url.Values) (*url.URL, error) {
|
||||
uri, err := a.options.Load().GetAuthenticateURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uri = uri.ResolveReference(&url.URL{
|
||||
Path: "/.pomerium/webauthn",
|
||||
RawQuery: buildURLValues(values, url.Values{
|
||||
urlutil.QueryDeviceType: {"default"},
|
||||
urlutil.QueryEnrollmentToken: nil,
|
||||
urlutil.QueryRedirectURI: {uri.ResolveReference(&url.URL{
|
||||
Path: "/.pomerium/",
|
||||
}).String()},
|
||||
}).Encode(),
|
||||
})
|
||||
return urlutil.NewSignedURL(a.state.Load().sharedKey, uri).Sign(), nil
|
||||
}
|
||||
|
||||
// buildURLValues creates a new url.Values map by traversing the keys in `defaults` and using the values
|
||||
// from `values` if they exist, otherwise the provided defaults
|
||||
func buildURLValues(values, defaults url.Values) url.Values {
|
||||
result := make(url.Values)
|
||||
for k, vs := range defaults {
|
||||
if values.Has(k) {
|
||||
result[k] = values[k]
|
||||
} else if vs != nil {
|
||||
result[k] = vs
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import (
|
|||
"golang.org/x/oauth2"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
|
||||
"github.com/pomerium/pomerium/internal/httputil"
|
||||
"github.com/pomerium/pomerium/internal/identity"
|
||||
"github.com/pomerium/pomerium/internal/identity/manager"
|
||||
|
@ -28,6 +29,7 @@ import (
|
|||
"github.com/pomerium/pomerium/internal/telemetry/trace"
|
||||
"github.com/pomerium/pomerium/internal/urlutil"
|
||||
"github.com/pomerium/pomerium/pkg/cryptutil"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/directory"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/session"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||
|
@ -93,6 +95,7 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
|
|||
sr.Path("/").Handler(a.requireValidSignatureOnRedirect(a.userInfo))
|
||||
sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn))
|
||||
sr.Path("/sign_out").Handler(a.requireValidSignature(a.SignOut))
|
||||
sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState))
|
||||
}
|
||||
|
||||
func (a *Authenticate) mountWellKnown(r *mux.Router) {
|
||||
|
@ -451,25 +454,20 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
|
|||
s.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
isImpersonated := false
|
||||
pbSession, err := session.Get(ctx, state.dataBrokerClient, s.ID)
|
||||
if pbSession.GetImpersonateSessionId() != "" {
|
||||
pbSession, err = session.Get(ctx, state.dataBrokerClient, pbSession.GetImpersonateSessionId())
|
||||
isImpersonated = true
|
||||
}
|
||||
pbSession, isImpersonated, err := a.getCurrentSession(ctx)
|
||||
if err != nil {
|
||||
pbSession = &session.Session{
|
||||
Id: s.ID,
|
||||
}
|
||||
}
|
||||
|
||||
pbUser, err := user.Get(ctx, state.dataBrokerClient, pbSession.GetUserId())
|
||||
pbUser, err := a.getUser(ctx, pbSession.GetUserId())
|
||||
if err != nil {
|
||||
pbUser = &user.User{
|
||||
Id: pbSession.GetUserId(),
|
||||
}
|
||||
}
|
||||
pbDirectoryUser, err := directory.GetUser(ctx, state.dataBrokerClient, pbSession.GetUserId())
|
||||
pbDirectoryUser, err := a.getDirectoryUser(ctx, pbSession.GetUserId())
|
||||
if err != nil {
|
||||
pbDirectoryUser = &directory.User{
|
||||
Id: pbSession.GetUserId(),
|
||||
|
@ -493,15 +491,29 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
|
|||
return fmt.Errorf("invalid signout url: %w", err)
|
||||
}
|
||||
|
||||
webAuthnURL, err := a.getWebAuthnURL(r.URL.Query())
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid webauthn url: %w", err)
|
||||
}
|
||||
|
||||
var deviceCredentials []*device.Credential
|
||||
for _, id := range pbUser.GetDeviceCredentialIds() {
|
||||
deviceCredentials = append(deviceCredentials, &device.Credential{
|
||||
Id: id,
|
||||
})
|
||||
}
|
||||
|
||||
input := map[string]interface{}{
|
||||
"IsImpersonated": isImpersonated,
|
||||
"State": s, // local session state (cookie, header, etc)
|
||||
"Session": pbSession, // current access, refresh, id token
|
||||
"User": pbUser, // user details inferred from oidc id_token
|
||||
"DeviceCredentials": deviceCredentials,
|
||||
"DirectoryUser": pbDirectoryUser, // user details inferred from idp directory
|
||||
"DirectoryGroups": groups, // user's groups inferred from idp directory
|
||||
"csrfField": csrf.TemplateField(r),
|
||||
"SignOutURL": signoutURL,
|
||||
"WebAuthnURL": webAuthnURL,
|
||||
}
|
||||
return a.templates.ExecuteTemplate(w, "userInfo.html", input)
|
||||
}
|
||||
|
@ -614,3 +626,49 @@ func (a *Authenticate) getSignOutURL(r *http.Request) (*url.URL, error) {
|
|||
}
|
||||
return urlutil.NewSignedURL(a.state.Load().sharedKey, uri).Sign(), nil
|
||||
}
|
||||
|
||||
func (a *Authenticate) getCurrentSession(ctx context.Context) (s *session.Session, isImpersonated bool, err error) {
|
||||
client := a.state.Load().dataBrokerClient
|
||||
|
||||
sessionState, err := a.getSessionFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
isImpersonated = false
|
||||
s, err = session.Get(ctx, client, sessionState.ID)
|
||||
if s.GetImpersonateSessionId() != "" {
|
||||
s, err = session.Get(ctx, client, s.GetImpersonateSessionId())
|
||||
isImpersonated = true
|
||||
}
|
||||
|
||||
return s, isImpersonated, err
|
||||
}
|
||||
|
||||
func (a *Authenticate) getUser(ctx context.Context, userID string) (*user.User, error) {
|
||||
client := a.state.Load().dataBrokerClient
|
||||
|
||||
return user.Get(ctx, client, userID)
|
||||
}
|
||||
|
||||
func (a *Authenticate) getDirectoryUser(ctx context.Context, userID string) (*directory.User, error) {
|
||||
client := a.state.Load().dataBrokerClient
|
||||
|
||||
return directory.GetUser(ctx, client, userID)
|
||||
}
|
||||
|
||||
func (a *Authenticate) getWebauthnState(ctx context.Context) (*webauthn.State, error) {
|
||||
state := a.state.Load()
|
||||
|
||||
s, _, err := a.getCurrentSession(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &webauthn.State{
|
||||
SharedKey: state.sharedKey,
|
||||
Client: state.dataBrokerClient,
|
||||
Session: s,
|
||||
RelyingParty: state.webauthnRelyingParty,
|
||||
}, nil
|
||||
}
|
||||
|
|
409
authenticate/handlers/webauthn/webauthn.go
Normal file
409
authenticate/handlers/webauthn/webauthn.go
Normal file
|
@ -0,0 +1,409 @@
|
|||
// Package webauthn contains handlers for the WebAuthn flow in authenticate.
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pomerium/csrf"
|
||||
"github.com/pomerium/webauthn"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/frontend"
|
||||
"github.com/pomerium/pomerium/internal/httputil"
|
||||
"github.com/pomerium/pomerium/internal/middleware"
|
||||
"github.com/pomerium/pomerium/internal/urlutil"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/session"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||
"github.com/pomerium/pomerium/pkg/webauthnutil"
|
||||
)
|
||||
|
||||
const maxAuthenticateResponses = 5
|
||||
|
||||
var (
|
||||
errMissingDeviceType = httputil.NewError(http.StatusBadRequest, errors.New("device_type is a required parameter"))
|
||||
errMissingRedirectURI = httputil.NewError(http.StatusBadRequest, errors.New("pomerium_redirect_uri is a required parameter"))
|
||||
)
|
||||
|
||||
// State is the state needed by the Handler to handle requests.
|
||||
type State struct {
|
||||
SharedKey []byte
|
||||
Client databroker.DataBrokerServiceClient
|
||||
Session *session.Session
|
||||
RelyingParty *webauthn.RelyingParty
|
||||
}
|
||||
|
||||
// A StateProvider provides state for the handler.
|
||||
type StateProvider = func(context.Context) (*State, error)
|
||||
|
||||
// Handler is the WebAuthn device handler.
|
||||
type Handler struct {
|
||||
getState StateProvider
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// New creates a new Handler.
|
||||
func New(getState StateProvider) *Handler {
|
||||
return &Handler{
|
||||
getState: getState,
|
||||
templates: template.Must(frontend.NewTemplates()),
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP serves the HTTP handler.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
httputil.HandlerFunc(h.handle).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) handle(w http.ResponseWriter, r *http.Request) error {
|
||||
s, err := h.getState(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = middleware.ValidateRequestURL(r, s.SharedKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
return h.handleView(w, r, s)
|
||||
case r.FormValue("action") == "authenticate":
|
||||
return h.handleAuthenticate(w, r, s)
|
||||
case r.FormValue("action") == "register":
|
||||
return h.handleRegister(w, r, s)
|
||||
}
|
||||
|
||||
return httputil.NewError(http.StatusNotFound, errors.New(http.StatusText(http.StatusNotFound)))
|
||||
}
|
||||
|
||||
func (h *Handler) handleAuthenticate(w http.ResponseWriter, r *http.Request, state *State) error {
|
||||
ctx := r.Context()
|
||||
|
||||
deviceTypeParam := r.FormValue(urlutil.QueryDeviceType)
|
||||
if deviceTypeParam == "" {
|
||||
return errMissingDeviceType
|
||||
}
|
||||
|
||||
redirectURIParam := r.FormValue(urlutil.QueryRedirectURI)
|
||||
if redirectURIParam == "" {
|
||||
return errMissingRedirectURI
|
||||
}
|
||||
|
||||
responseParam := r.FormValue("authenticate_response")
|
||||
var credential webauthn.PublicKeyAssertionCredential
|
||||
err := json.Unmarshal([]byte(responseParam), &credential)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, errors.New("invalid authenticate response"))
|
||||
}
|
||||
credentialJSON, err := json.Marshal(credential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the user information
|
||||
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the stored device type
|
||||
deviceType, err := device.GetType(ctx, state.Client, deviceTypeParam)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the device credentials
|
||||
knownDeviceCredentials, err := getKnownDeviceCredentials(ctx, state.Client, u.GetDeviceCredentialIds()...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestOptions, err := webauthnutil.GetRequestOptionsForCredential(
|
||||
state.SharedKey,
|
||||
deviceType,
|
||||
knownDeviceCredentials,
|
||||
&credential,
|
||||
)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, fmt.Errorf("invalid register options: %w", err))
|
||||
}
|
||||
|
||||
serverCredential, err := state.RelyingParty.VerifyAuthenticationCeremony(
|
||||
ctx,
|
||||
requestOptions,
|
||||
&credential,
|
||||
)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, fmt.Errorf("error verifying registration: %w", err))
|
||||
}
|
||||
|
||||
// store the authenticate response
|
||||
for _, deviceCredential := range knownDeviceCredentials {
|
||||
webauthnCredential := deviceCredential.GetWebauthn()
|
||||
if webauthnCredential == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !bytes.Equal(webauthnCredential.Id, serverCredential.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// add the response to the list and cap it, removing the oldest responses
|
||||
webauthnCredential.AuthenticateResponse = append(webauthnCredential.AuthenticateResponse, credentialJSON)
|
||||
for len(webauthnCredential.AuthenticateResponse) > maxAuthenticateResponses {
|
||||
webauthnCredential.AuthenticateResponse = webauthnCredential.AuthenticateResponse[1:]
|
||||
}
|
||||
|
||||
// store the updated device credential
|
||||
err = device.PutCredential(ctx, state.Client, deviceCredential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// save the session
|
||||
state.Session.DeviceCredentialId = webauthnutil.GetDeviceCredentialID(serverCredential.ID)
|
||||
_, err = session.Put(ctx, state.Client, state.Session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// redirect
|
||||
httputil.Redirect(w, r, redirectURIParam, http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleRegister(w http.ResponseWriter, r *http.Request, state *State) error {
|
||||
ctx := r.Context()
|
||||
|
||||
deviceTypeParam := r.FormValue(urlutil.QueryDeviceType)
|
||||
if deviceTypeParam == "" {
|
||||
return errMissingDeviceType
|
||||
}
|
||||
|
||||
redirectURIParam := r.FormValue(urlutil.QueryRedirectURI)
|
||||
if redirectURIParam == "" {
|
||||
return errMissingRedirectURI
|
||||
}
|
||||
|
||||
responseParam := r.FormValue("register_response")
|
||||
var credential webauthn.PublicKeyCreationCredential
|
||||
err := json.Unmarshal([]byte(responseParam), &credential)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, errors.New("invalid register response"))
|
||||
}
|
||||
credentialJSON, err := json.Marshal(credential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the user information
|
||||
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the stored device type
|
||||
deviceType, err := webauthnutil.GetDeviceType(ctx, state.Client, deviceTypeParam)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
creationOptions, err := webauthnutil.GetCreationOptionsForCredential(
|
||||
state.SharedKey,
|
||||
deviceType,
|
||||
u,
|
||||
&credential,
|
||||
)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, fmt.Errorf("invalid register options: %w", err))
|
||||
}
|
||||
creationOptionsJSON, err := json.Marshal(creationOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serverCredential, err := state.RelyingParty.VerifyRegistrationCeremony(
|
||||
ctx,
|
||||
creationOptions,
|
||||
&credential,
|
||||
)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, fmt.Errorf("error verifying registration: %w", err))
|
||||
}
|
||||
|
||||
deviceEnrollment, err := getOrCreateDeviceEnrollment(ctx, r, state, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// save the credential
|
||||
deviceCredential := &device.Credential{
|
||||
Id: webauthnutil.GetDeviceCredentialID(serverCredential.ID),
|
||||
TypeId: deviceType.GetId(),
|
||||
EnrollmentId: deviceEnrollment.GetId(),
|
||||
UserId: u.GetId(),
|
||||
Specifier: &device.Credential_Webauthn{
|
||||
Webauthn: &device.Credential_WebAuthn{
|
||||
Id: serverCredential.ID,
|
||||
PublicKey: serverCredential.PublicKey,
|
||||
|
||||
RegisterOptions: creationOptionsJSON,
|
||||
RegisterResponse: credentialJSON,
|
||||
},
|
||||
},
|
||||
}
|
||||
err = device.PutCredential(ctx, state.Client, deviceCredential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// save the user
|
||||
u.DeviceCredentialIds = append(u.DeviceCredentialIds, deviceCredential.GetId())
|
||||
_, err = user.Put(ctx, state.Client, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// save the session
|
||||
state.Session.DeviceCredentialId = deviceCredential.GetId()
|
||||
_, err = session.Put(ctx, state.Client, state.Session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// redirect
|
||||
httputil.Redirect(w, r, redirectURIParam, http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleView(w http.ResponseWriter, r *http.Request, state *State) error {
|
||||
ctx := r.Context()
|
||||
|
||||
deviceTypeParam := r.FormValue(urlutil.QueryDeviceType)
|
||||
if deviceTypeParam == "" {
|
||||
return errMissingDeviceType
|
||||
}
|
||||
|
||||
// get the user information
|
||||
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the device credentials
|
||||
knownDeviceCredentials, err := getKnownDeviceCredentials(ctx, state.Client, u.GetDeviceCredentialIds()...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the stored device type
|
||||
deviceType, err := webauthnutil.GetDeviceType(ctx, state.Client, deviceTypeParam)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
creationOptions := webauthnutil.GenerateCreationOptions(state.SharedKey, deviceType, u)
|
||||
requestOptions := webauthnutil.GenerateRequestOptions(state.SharedKey, deviceType, knownDeviceCredentials)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = h.templates.ExecuteTemplate(&buf, "webauthn.html", map[string]interface{}{
|
||||
"csrfField": csrf.TemplateField(r),
|
||||
"Data": map[string]interface{}{
|
||||
"creationOptions": creationOptions,
|
||||
"requestOptions": requestOptions,
|
||||
},
|
||||
"SelfURL": r.URL.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = io.Copy(w, &buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func getKnownDeviceCredentials(
|
||||
ctx context.Context,
|
||||
client databroker.DataBrokerServiceClient,
|
||||
deviceCredentialIDs ...string,
|
||||
) ([]*device.Credential, error) {
|
||||
var knownDeviceCredentials []*device.Credential
|
||||
for _, deviceCredentialID := range deviceCredentialIDs {
|
||||
deviceCredential, err := device.GetCredential(ctx, client, deviceCredentialID)
|
||||
if status.Code(err) == codes.NotFound {
|
||||
// ignore missing devices
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, httputil.NewError(http.StatusInternalServerError,
|
||||
fmt.Errorf("error retrieving device credential: %w", err))
|
||||
}
|
||||
knownDeviceCredentials = append(knownDeviceCredentials, deviceCredential)
|
||||
}
|
||||
return knownDeviceCredentials, nil
|
||||
}
|
||||
|
||||
func getOrCreateDeviceEnrollment(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
state *State,
|
||||
u *user.User,
|
||||
) (*device.Enrollment, error) {
|
||||
var deviceEnrollment *device.Enrollment
|
||||
|
||||
enrollmentTokenParam := r.FormValue(urlutil.QueryEnrollmentToken)
|
||||
if enrollmentTokenParam == "" {
|
||||
// create a new enrollment
|
||||
deviceEnrollment = &device.Enrollment{
|
||||
Id: uuid.New().String(),
|
||||
UserId: u.GetId(),
|
||||
}
|
||||
} else {
|
||||
// use an existing enrollment
|
||||
deviceEnrollmentID, err := webauthnutil.ParseAndVerifyEnrollmentToken(state.SharedKey, enrollmentTokenParam)
|
||||
if err != nil {
|
||||
return nil, httputil.NewError(http.StatusBadRequest, fmt.Errorf("invalid enrollment token: %w", err))
|
||||
}
|
||||
|
||||
deviceEnrollment, err = device.GetEnrollment(ctx, state.Client, deviceEnrollmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if deviceEnrollment.GetUserId() != u.GetId() {
|
||||
return nil, httputil.NewError(http.StatusForbidden, fmt.Errorf("invalid enrollment token: wrong user id"))
|
||||
}
|
||||
|
||||
if deviceEnrollment.GetEnrolledAt().IsValid() {
|
||||
return nil, httputil.NewError(http.StatusForbidden, fmt.Errorf("invalid enrollment token: already used for existing credential"))
|
||||
}
|
||||
}
|
||||
|
||||
deviceEnrollment.EnrolledAt = timestamppb.Now()
|
||||
deviceEnrollment.UserAgent = r.UserAgent()
|
||||
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
deviceEnrollment.IpAddress = ip
|
||||
}
|
||||
|
||||
err := device.PutEnrollment(ctx, state.Client, deviceEnrollment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deviceEnrollment, nil
|
||||
}
|
|
@ -9,6 +9,7 @@ import (
|
|||
"sync/atomic"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/pomerium/webauthn"
|
||||
|
||||
"github.com/pomerium/pomerium/config"
|
||||
"github.com/pomerium/pomerium/internal/encoding"
|
||||
|
@ -23,6 +24,7 @@ import (
|
|||
"github.com/pomerium/pomerium/pkg/grpc"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/directory"
|
||||
"github.com/pomerium/pomerium/pkg/webauthnutil"
|
||||
)
|
||||
|
||||
var outboundGRPCConnection = new(grpc.CachedOutboundGRPClientConn)
|
||||
|
@ -52,6 +54,8 @@ type authenticateState struct {
|
|||
|
||||
dataBrokerClient databroker.DataBrokerServiceClient
|
||||
directoryClient directory.DirectoryServiceClient
|
||||
|
||||
webauthnRelyingParty *webauthn.RelyingParty
|
||||
}
|
||||
|
||||
func newAuthenticateState() *authenticateState {
|
||||
|
@ -158,6 +162,11 @@ func newAuthenticateStateFromConfig(cfg *config.Config) (*authenticateState, err
|
|||
state.dataBrokerClient = databroker.NewDataBrokerServiceClient(dataBrokerConn)
|
||||
state.directoryClient = directory.NewDirectoryServiceClient(dataBrokerConn)
|
||||
|
||||
state.webauthnRelyingParty = webauthn.NewRelyingParty(
|
||||
authenticateURL.String(),
|
||||
webauthnutil.NewCredentialStorage(state.dataBrokerClient),
|
||||
)
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -177,6 +177,39 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category white box">
|
||||
<div class="messages">
|
||||
<div class="box-inner">
|
||||
<div class="category-header clearfix">
|
||||
<span class="category-title">Device Credentials</span>
|
||||
</div>
|
||||
</ul>
|
||||
{{if .DeviceCredentials}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .DeviceCredentials}}
|
||||
<tr>
|
||||
<td>{{.Id}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
No device credentials found!
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="category-link">
|
||||
Register device with <a href="{{.WebAuthnURL}}">WebAuthn</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="footer">
|
||||
<ul>
|
||||
<li><a href="https://pomerium.com/">Home</a></li>
|
||||
|
|
67
internal/frontend/assets/html/webauthn.go.html
Normal file
67
internal/frontend/assets/html/webauthn.go.html
Normal file
|
@ -0,0 +1,67 @@
|
|||
{{define "webauthn.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" charset="utf-8">
|
||||
<head>
|
||||
{{template "header.html"}}
|
||||
<title>WebAuthn</title>
|
||||
<script>
|
||||
window.PomeriumData = {{.Data}};
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="inner">
|
||||
<div class="header clearfix">
|
||||
<div class="heading"></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="white box">
|
||||
<div class="largestatus">
|
||||
<div class="title-wrapper">
|
||||
<span class="title">WebAuthn Registration</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="category white box webauthn">
|
||||
<div class="messages">
|
||||
<div class="box-inner">
|
||||
<form action="{{.SelfURL}}" method="post">
|
||||
{{.csrfField}}
|
||||
<input type="hidden" name="action" value="register" />
|
||||
<input
|
||||
type="hidden"
|
||||
id="register_response"
|
||||
name="register_response"
|
||||
/>
|
||||
<input
|
||||
class="button"
|
||||
type="submit"
|
||||
id="register_button"
|
||||
value="Register New Device"
|
||||
/>
|
||||
</form>
|
||||
<form action="{{.SelfURL}}" method="post">
|
||||
{{.csrfField}}
|
||||
<input type="hidden" name="action" value="authenticate" />
|
||||
<input
|
||||
type="hidden"
|
||||
id="authenticate_response"
|
||||
name="authenticate_response"
|
||||
/>
|
||||
<input
|
||||
class="button"
|
||||
type="submit"
|
||||
id="authenticate_button"
|
||||
value="Authenticate Existing Device"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/.pomerium/assets/js/webauthn.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
92
internal/frontend/assets/js/base64.mjs
Normal file
92
internal/frontend/assets/js/base64.mjs
Normal file
|
@ -0,0 +1,92 @@
|
|||
// The MIT License (MIT)
|
||||
|
||||
// Copyright (c) 2020 Blake Embrey (hello@blakeembrey.com)
|
||||
// Copyright (c) 2012 Niklas von Hertzen (https://github.com/niklasvh/base64-arraybuffer)
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
/**
|
||||
* Original source: https://github.com/niklasvh/base64-arraybuffer.
|
||||
*/
|
||||
const base64Chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const base64UrlChars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
const base64Lookup = new Uint8Array(256);
|
||||
for (let i = 0; i < base64Chars.length; i++) {
|
||||
base64Lookup[base64Chars.charCodeAt(i)] = i;
|
||||
}
|
||||
// Support base64url.
|
||||
base64Lookup[45 /* - */] = 62;
|
||||
base64Lookup[95 /* _ */] = 63;
|
||||
/**
|
||||
* Encode an `ArrayBuffer` to base64.
|
||||
*/
|
||||
function encode(buffer, chars = base64Chars, padding = "=") {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const length = bytes.length;
|
||||
let base64 = "";
|
||||
for (let i = 0; i < length; i += 3) {
|
||||
base64 += chars[bytes[i] >> 2];
|
||||
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
|
||||
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
|
||||
base64 += chars[bytes[i + 2] & 63];
|
||||
}
|
||||
if (length % 3 === 2) {
|
||||
base64 = base64.slice(0, base64.length - 1) + padding;
|
||||
} else if (length % 3 === 1) {
|
||||
base64 = base64.slice(0, base64.length - 2) + padding + padding;
|
||||
}
|
||||
return base64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode using the base64url variant.
|
||||
*/
|
||||
function encodeUrl(buffer) {
|
||||
return encode(buffer, base64UrlChars, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 encoded string.
|
||||
*/
|
||||
function decode(base64, lookup = base64Lookup) {
|
||||
const length = base64.length;
|
||||
let bufferLength = Math.floor(base64.length * 0.75);
|
||||
let p = 0;
|
||||
if (base64[length - 1] === "=") {
|
||||
bufferLength--;
|
||||
if (base64[length - 2] === "=") {
|
||||
bufferLength--;
|
||||
}
|
||||
}
|
||||
const bytes = new Uint8Array(bufferLength);
|
||||
for (let i = 0; i < length; i += 4) {
|
||||
const encoded1 = lookup[base64.charCodeAt(i)];
|
||||
const encoded2 = lookup[base64.charCodeAt(i + 1)];
|
||||
const encoded3 = lookup[base64.charCodeAt(i + 2)];
|
||||
const encoded4 = lookup[base64.charCodeAt(i + 3)];
|
||||
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
|
||||
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
||||
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export { encode, encodeUrl, decode };
|
119
internal/frontend/assets/js/webauthn.mjs
Normal file
119
internal/frontend/assets/js/webauthn.mjs
Normal file
|
@ -0,0 +1,119 @@
|
|||
import {
|
||||
encodeUrl as base64encode,
|
||||
decode as base64decode,
|
||||
} from "./base64.mjs";
|
||||
|
||||
function decode(raw) {
|
||||
return base64decode(raw);
|
||||
}
|
||||
|
||||
function encode(raw) {
|
||||
return base64encode(raw);
|
||||
}
|
||||
|
||||
async function authenticate(requestOptions) {
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
allowCredentials: requestOptions.allowCredentials.map((c) => ({
|
||||
type: c.type,
|
||||
id: decode(c.id),
|
||||
})),
|
||||
challenge: decode(requestOptions.challenge),
|
||||
timeout: requestOptions.timeout,
|
||||
userVerification: requestOptions.userVerification,
|
||||
},
|
||||
});
|
||||
const inputEl = document.getElementById("authenticate_response");
|
||||
inputEl.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encode(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encode(credential.response.authenticatorData),
|
||||
clientDataJSON: encode(credential.response.clientDataJSON),
|
||||
signature: encode(credential.response.signature),
|
||||
userHandle: encode(credential.response.userHandle),
|
||||
},
|
||||
});
|
||||
inputEl.parentElement.submit();
|
||||
}
|
||||
|
||||
async function register(creationOptions) {
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
attestation: creationOptions.attestation || undefined,
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment:
|
||||
creationOptions.authenticatorSelection.authenticatorAttachment ||
|
||||
undefined,
|
||||
requireResidentKey:
|
||||
creationOptions.authenticatorSelection.requireResidentKey ||
|
||||
undefined,
|
||||
residentKey: creationOptions.authenticatorSelection.residentKey,
|
||||
userVerification:
|
||||
creationOptions.authenticatorSelection.userVerification || undefined,
|
||||
},
|
||||
challenge: decode(creationOptions.challenge),
|
||||
pubKeyCredParams: creationOptions.pubKeyCredParams.map((p) => ({
|
||||
type: p.type,
|
||||
alg: p.alg,
|
||||
})),
|
||||
rp: {
|
||||
name: creationOptions.rp.name,
|
||||
},
|
||||
timeout: creationOptions.timeout,
|
||||
user: {
|
||||
id: decode(creationOptions.user.id),
|
||||
name: creationOptions.user.name,
|
||||
displayName: creationOptions.user.displayName,
|
||||
},
|
||||
},
|
||||
});
|
||||
const inputEl = document.getElementById("register_response");
|
||||
inputEl.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encode(credential.rawId),
|
||||
response: {
|
||||
attestationObject: encode(credential.response.attestationObject),
|
||||
clientDataJSON: encode(credential.response.clientDataJSON),
|
||||
},
|
||||
});
|
||||
inputEl.parentElement.submit();
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!("PomeriumData" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestOptions = window.PomeriumData.requestOptions;
|
||||
const authenticateButton = document.getElementById("authenticate_button");
|
||||
if (authenticateButton) {
|
||||
if (
|
||||
requestOptions.allowCredentials &&
|
||||
requestOptions.allowCredentials.length > 0
|
||||
) {
|
||||
authenticateButton.addEventListener("click", function(evt) {
|
||||
evt.preventDefault();
|
||||
authenticate(requestOptions);
|
||||
});
|
||||
} else {
|
||||
authenticateButton.addEventListener("click", function(evt) {
|
||||
evt.preventDefault();
|
||||
});
|
||||
authenticateButton.setAttribute("disabled", "DISABLED");
|
||||
}
|
||||
}
|
||||
|
||||
const creationOptions = window.PomeriumData.creationOptions;
|
||||
const registerButton = document.getElementById("register_button");
|
||||
if (registerButton) {
|
||||
registerButton.addEventListener("click", function(evt) {
|
||||
evt.preventDefault();
|
||||
register(creationOptions);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
|
@ -476,3 +476,17 @@ a.button {
|
|||
text-transform: none;
|
||||
transition: box-shadow 150ms ease-in-out;
|
||||
}
|
||||
|
||||
button:disabled, input:disabled {
|
||||
cursor: default;
|
||||
background:#cccccc;
|
||||
color: #838383;
|
||||
}
|
||||
|
||||
.webauthn .box-inner {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.webauthn form {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -31,7 +31,7 @@ const (
|
|||
// by default includes profile photo exceptions for supported identity providers.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
|
||||
var HeadersContentSecurityPolicy = map[string]string{
|
||||
"Content-Security-Policy": "default-src 'none'; style-src 'self' data:; img-src * data:;",
|
||||
"Content-Security-Policy": "default-src 'none'; style-src 'self' data:; img-src * data:; script-src 'self' 'unsafe-inline'",
|
||||
"Referrer-Policy": "Same-origin",
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ package urlutil
|
|||
// conjunction with a HMAC to ensure authenticity.
|
||||
const (
|
||||
QueryCallbackURI = "pomerium_callback_uri"
|
||||
QueryDeviceType = "pomerium_device_type"
|
||||
QueryEnrollmentToken = "pomerium_enrollment_token" //nolint
|
||||
QueryIsProgrammatic = "pomerium_programmatic"
|
||||
QueryForwardAuth = "pomerium_forward_auth"
|
||||
QueryPomeriumJWT = "pomerium_jwt"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue