Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: mfa (webauthn) poc #865

Closed
wants to merge 11 commits into from
1 change: 1 addition & 0 deletions api/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const (
oauthTokenKey = contextKey("oauth_token") // for OAuth1.0, also known as request token
oauthVerifierKey = contextKey("oauth_verifier")
ssoProviderKey = contextKey("sso_provider")
webAuthnConfigKey = contextKey("webauthn_config")
)

// withToken adds the JWT token to the context.
Expand Down
2 changes: 2 additions & 0 deletions api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
// See https://workos.com/docs/reference/sso/authorize/get
authUrlParams = append(authUrlParams, oauth2.SetAuthURLParam("provider", query.Get(key)))
} else {
// Joel - What happens if we include additional params here?
authUrlParams = append(authUrlParams, oauth2.SetAuthURLParam(key, query.Get(key)))
}
}
Expand All @@ -102,6 +103,7 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
return internalServerError("Error storing request token in session").WithInternalError(err)
}
default:
// Grant Type
authURL = p.AuthCodeURL(tokenString, authUrlParams...)
}

Expand Down
250 changes: 250 additions & 0 deletions api/mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"

Expand All @@ -11,12 +12,15 @@ import (
"github.com/aaronarduino/goqrsvg"
svg "github.com/ajstarks/svgo"
"github.com/boombuler/barcode/qr"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gofrs/uuid"
"github.com/netlify/gotrue/metering"
"github.com/netlify/gotrue/models"
"github.com/netlify/gotrue/storage"
"github.com/netlify/gotrue/utilities"
"github.com/pquerna/otp/totp"
"github.com/mitchellh/mapstructure"
)

const DefaultQRSize = 3
Expand Down Expand Up @@ -53,6 +57,8 @@ type UnenrollFactorResponse struct {
ID uuid.UUID `json:"id"`
}



func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
user := getUser(ctx)
Expand All @@ -74,6 +80,9 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
}

factorType := params.FactorType
if factorType == "webauthn" {
return a.EnrollWebAuthnFactor(w, r)
}
if factorType != models.TOTP {
return badRequestError("factor_type needs to be totp")
}
Expand Down Expand Up @@ -156,6 +165,93 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
})
}

func (a *API) EnrollWebAuthnFactor(w http.ResponseWriter, r *http.Request) error {
// Initialize webauthn object and set it on the global context
ctx := r.Context()
user := getUser(ctx)
session := getSession(ctx)

web, err := webauthn.New(&webauthn.Config{
RPDisplayName: "Go Webauthn", // Display Name for your site
RPID: "2175-203-116-4-74.ap.ngrok.io", // Generally the FQDN for your site
RPOrigin: "https://2175-203-116-4-74.ap.ngrok.io", // The origin URL for WebAuthn requests
RPIcon: "https://go-webauthn.local/logo.png", // Optional icon URL for your site
})
if err != nil {
return err
}

params := &EnrollFactorParams{}
config := a.config
// issuer := ""
body, err := getBodyBytes(r)
if err != nil {
return internalServerError("Could not read body").WithInternalError(err)
}

if err := json.Unmarshal(body, params); err != nil {
return badRequestError("invalid body: unable to parse JSON").WithInternalError(err)
}

// TODO(Joel): Factor this check into a function
// if params.Issuer == "" {
// u, err := url.ParseRequestURI(config.SiteURL)
// if err != nil {
// return internalServerError("site url is improperly formatted")
// }
// issuer = u.Host
// } else {
// issuer = params.Issuer
// }

// Read from DB for certainty
factors, err := models.FindFactorsByUser(a.db, user)
if err != nil {
return internalServerError("error validating number of factors in system").WithInternalError(err)
}

if len(factors) >= int(config.MFA.MaxEnrolledFactors) {
return forbiddenError("Enrolled factors exceed allowed limit, unenroll to continue")
}
numVerifiedFactors := 0

// TODO: Remove this at v2
for _, factor := range factors {
if factor.Status == models.FactorStateVerified.String() {
numVerifiedFactors += 1
}

}
if numVerifiedFactors >= 1 {
return forbiddenError("number of enrolled factors exceeds the allowed value, unenroll to continue")

}
// TODO (Joel): Properly populate the secret field
factor, err := models.NewFactor(user, params.FriendlyName, params.FactorType, models.FactorStateUnverified, "")
if err != nil {
return internalServerError("database error creating factor").WithInternalError(err)
}
err = a.db.Transaction(func(tx *storage.Connection) error {
if terr := tx.Create(factor); terr != nil {
return terr
}
if terr := session.UpdateWebauthnConfiguration(tx, web); terr != nil {
return terr
}
if terr := models.NewAuditLogEntry(r, tx, user, models.EnrollFactorAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
}); terr != nil {
return terr
}
return nil
})
if err != nil {
return err
}

return sendJSON(w, http.StatusOK, factor)
}

func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
config := a.config
Expand All @@ -168,10 +264,18 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error {
return internalServerError("Database error creating challenge").WithInternalError(err)
}

// TODO(Joel): replace hardcoded string with actual value
if factor.FactorType == "webauthn" {
return a.ChallengeWebAuthnFactor(w, r)


}

err = a.db.Transaction(func(tx *storage.Connection) error {
if terr := tx.Create(challenge); terr != nil {
return terr
}

if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"factor_status": factor.Status,
Expand All @@ -192,6 +296,80 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error {
})
}

func (a *API) ChallengeWebAuthnFactor(w http.ResponseWriter, r *http.Request) error {
// Returns the public key and related information
ctx := r.Context()
user := getUser(ctx)
session := getSession(ctx)
factor := getFactor(ctx)
ipAddress := utilities.GetIPAddress(r)
challenge, err := models.NewChallenge(factor, ipAddress)
web := &webauthn.WebAuthn{}

// TODO(Joel): Substitute this with a webauthn config read from the db
webMarshaled := session.WebauthnConfiguration

err = mapstructure.Decode(webMarshaled, web)
if err != nil {
return err
}

// Registration session
registrationSession := session.WebauthnRegistrationSession
// TODO(Joel) - Properly check if registrationSession is empty,
if registrationSession == nil {
// Registration has been initiated
options, sessionData, err := web.BeginLogin(user)
if err != nil {
return err
}
err = a.db.Transaction(func(tx *storage.Connection) error {
if terr := session.UpdateWebauthnLoginSession(tx, sessionData); terr != nil {
return terr
}
return nil
})
return sendJSON(w, http.StatusOK, options)

} else {

options, sessionData, err := web.BeginRegistration(user)
if err != nil {
return err
}

err = a.db.Transaction(func(tx *storage.Connection) error {
if terr := session.UpdateWebauthnRegistrationSession(tx, sessionData); terr != nil {
return terr
}
return nil
})

// Registration case
err = a.db.Transaction(func(tx *storage.Connection) error {
if terr := tx.Create(challenge); terr != nil {
return terr
}

if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"factor_status": factor.Status,
}); terr != nil {
return terr
}
return nil
})
if err != nil {
return err
}
fmt.Printf("reached\n")
fmt.Printf("%+v\n", options)

return sendJSON(w, http.StatusOK,*options)
}

}

func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
var err error
ctx := r.Context()
Expand Down Expand Up @@ -241,6 +419,9 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
}
return badRequestError("%v has expired, verify against another challenge or create a new challenge.", challenge.ID)
}
if factor.FactorType == "webauthn" {
return a.VerifyWebAuthnFactor(w, r)
}

if valid := totp.Validate(params.Code, factor.Secret); !valid {
return badRequestError("Invalid TOTP code entered")
Expand Down Expand Up @@ -293,6 +474,75 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {

}

func (a *API) VerifyWebAuthnFactor(w http.ResponseWriter, r *http.Request) error {
sessionData := &webauthn.SessionData{}
ctx := r.Context()
user := getUser(ctx)
session := getSession(ctx)

web := &webauthn.WebAuthn{}
webMarshaled := session.WebauthnConfiguration

err := mapstructure.Decode(webMarshaled, web)
if err != nil {
return err
}


body, err := getBodyBytes(r)
if err != nil {
return internalServerError("Could not read body").WithInternalError(err)
}
params := &protocol.ParsedCredentialCreationData{}

if err := json.Unmarshal(body, params); err != nil {
return badRequestError("invalid body: unable to parse JSON").WithInternalError(err)
}
fmt.Println(params)
// Login Session:
loginSession := session.WebauthnLoginSession
registrationSession := session.WebauthnRegistrationSession

parsedResponse, err := protocol.ParseCredentialCreationResponseBody(r.Body)
credential, err := web.CreateCredential(user, *sessionData, parsedResponse)
fmt.Println(credential)
/**
type ParsedCredentialCreationData struct {
ParsedPublicKeyCredential
Response ParsedAttestationResponse
Raw CredentialCreationResponse
}
**/

if registrationSession != nil {
parsedResponse, err := protocol.ParseCredentialCreationResponseBody(r.Body)
if err != nil {
return err
}
// Decision 1: Generic methods for login/registration sessions or separate ones?
credential, err := web.CreateCredential(user, *sessionData, parsedResponse)
if err != nil {
return err
}
fmt.Println(credential)
} else if loginSession != nil {
parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body)
if err != nil {
return err
}
credential, err := web.ValidateLogin(user, *sessionData, parsedResponse)
fmt.Println(credential)
} else {
return internalServerError("Please initiate a webauthn session")
}

// if err != nil {
// Store the credential object
// }

return sendJSON(w, http.StatusOK, "")
}

func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
var err error
ctx := r.Context()
Expand Down
Loading