Skip to content

Commit

Permalink
Tn/add discoverable creds (#931)
Browse files Browse the repository at this point in the history
* enable discoverable webauthn

* make discoverable default UI

* fix UI breaking with long error message

* fix formatting

* click enter to start discoverable flow
  • Loading branch information
TylerNoblett committed Oct 2, 2023
1 parent 9487bbd commit 6a7aafa
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 47 deletions.
4 changes: 4 additions & 0 deletions backend/authschemes/auth_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ func (ah AShirtAuthBridge) GetUserFromID(userID int64) (models.User, error) {
return ah.db.RetrieveUserByID(userID)
}

func (ah AShirtAuthBridge) GetUserFromAuthnID(authnID string) (models.User, error) {
return ah.db.RetrieveUserIDByAuthnID(authnID)
}

// DeleteSession removes a user's session. Useful in situtations where authentication fails,
// and we want to treat the user as not-logged-in
func (ah AShirtAuthBridge) DeleteSession(w http.ResponseWriter, r *http.Request) error {
Expand Down
7 changes: 7 additions & 0 deletions backend/authschemes/webauthn/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,10 @@ func makeWebauthNSessionData(user webauthnUser, data *auth.SessionData) *webAuth
}
return &sessionData
}

func makeDiscoverableWebauthNSessionData(data *auth.SessionData) *webAuthNSessionData {
sessionData := webAuthNSessionData{
WebAuthNSessionData: data,
}
return &sessionData
}
146 changes: 118 additions & 28 deletions backend/authschemes/webauthn/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ func (a WebAuthn) Type() string {
return constants.Name
}

// Using DissectJSONRequest(r) on discoverable requests causes the request to be parsed twice, which causes an error
// so this function allows us to get query ars without dissecting it
func isDiscoverable(r *http.Request) bool {
parsedURL, err := url.Parse(r.URL.String())
if err != nil {
return false
}

return parsedURL.Query().Get("discoverable") == "true"
}

func (a WebAuthn) BindRoutes(r chi.Router, bridge authschemes.AShirtAuthBridge) {
remux.Route(r, "POST", "/register/begin", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remux.JSONHandler(func(r *http.Request) (interface{}, error) {
Expand Down Expand Up @@ -171,13 +182,59 @@ func (a WebAuthn) BindRoutes(r chi.Router, bridge authschemes.AShirtAuthBridge)
if !ok {
return nil, errors.New("Unable to complete login -- session not found or corrupt")
}

user := &data.UserData
cred, err := a.Web.FinishLogin(user, *data.WebAuthNSessionData, r)
if err != nil {
return nil, backend.BadAuthErr(err)
} else if cred.Authenticator.CloneWarning {
return nil, backend.WrapError("credential appears to be cloned", backend.BadAuthErr(err))
discoverable := isDiscoverable(r)

var cred *auth.Credential
var err error

if discoverable {
parsedResponse, err := protocol.ParseCredentialRequestResponse(r)
if err != nil {
return nil, backend.WrapError("error parsing credential", backend.BadAuthErr(err))
}

var webauthnUser webauthnUser
userHandler := func(_, userHandle []byte) (user auth.User, err error) {
authnID := string(userHandle)
dbUser, err := bridge.GetUserFromAuthnID(authnID)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Could not find user from authn ID", "No such user found")
}
auth, err := bridge.FindUserAuthByUserID(dbUser.ID)
if err != nil {
return nil, backend.DatabaseErr(err)
}
creds, err := a.getExistingCredentials(auth)
if err != nil {
return nil, err
}
webauthnUser = makeWebAuthnUser(dbUser.FirstName, dbUser.LastName, dbUser.Slug, dbUser.Email, dbUser.ID, userHandle, creds)
return &webauthnUser, nil
}
cred, err = a.Web.ValidateDiscoverableLogin(userHandler, *data.WebAuthNSessionData, parsedResponse)
if err != nil {
return nil, backend.BadAuthErr(err)
} else if cred.Authenticator.CloneWarning {
return nil, backend.WrapError("credential appears to be cloned", backend.BadAuthErr(err))
}

err = bridge.SetAuthSchemeSession(w, r, makeWebauthNSessionData(webauthnUser, data.WebAuthNSessionData))
if err != nil {
return nil, backend.WebauthnLoginError(err, "Unable to finish login process", "Unable to set session")
}
rawData = bridge.ReadAuthSchemeSession(r)
data, ok = rawData.(*webAuthNSessionData)
if !ok {
return nil, errors.New("Unable to finish login -- session not found or corrupt")
}
} else {
user := &data.UserData
cred, err = a.Web.FinishLogin(user, *data.WebAuthNSessionData, r)
if err != nil {
return nil, backend.BadAuthErr(err)
} else if cred.Authenticator.CloneWarning {
return nil, backend.WrapError("credential appears to be cloned", backend.BadAuthErr(err))
}
}

updateSignCount(data, cred, bridge)
Expand Down Expand Up @@ -411,18 +468,30 @@ func (a WebAuthn) beginRegistration(w http.ResponseWriter, r *http.Request, brid
)
}

discoverable := isDiscoverable(r)

credExcludeList := make([]protocol.CredentialDescriptor, len(user.Credentials))
for i, cred := range user.Credentials {
credExcludeList[i] = protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: cred.ID,
}
}

var selection protocol.AuthenticatorSelection

if discoverable {
selection = protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
}
}

registrationOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) {
credCreationOpts.CredentialExcludeList = credExcludeList
credCreationOpts.AuthenticatorSelection = selection
}

credOptions, sessionData, err := a.Web.BeginRegistration(&user, registrationOptions)
credOptions, sessionData, err := a.Web.BeginRegistration(&user, auth.WithAuthenticatorSelection(selection), registrationOptions)
if err != nil {
return nil, err
}
Expand All @@ -433,31 +502,52 @@ func (a WebAuthn) beginRegistration(w http.ResponseWriter, r *http.Request, brid
}

func (a WebAuthn) beginLogin(w http.ResponseWriter, r *http.Request, bridge authschemes.AShirtAuthBridge, username string) (interface{}, error) {
authData, err := bridge.FindUserAuth(username)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Could not validate user", "No such auth")
}
if authData.JSONData == nil {
return nil, backend.WebauthnLoginError(err, "User lacks webauthn credentials")
}
discoverable := isDiscoverable(r)

user, err := bridge.GetUserFromID(authData.UserID)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Could not validate user", "No such user")
}
var data interface{}
var options *protocol.CredentialAssertion
var sessionData *auth.SessionData
var err error

creds, err := a.getExistingCredentials(authData)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Unable to parse webauthn credentials")
}
if discoverable {
var opts = []auth.LoginOption{
auth.WithUserVerification(protocol.VerificationPreferred),
}
options, sessionData, err = a.Web.BeginDiscoverableLogin(opts...)

webauthnUser := makeWebAuthnUser(user.FirstName, user.LastName, username, user.Email, user.ID, authData.AuthnID, creds)
options, sessionData, err := a.Web.BeginLogin(&webauthnUser)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Unable to begin login process")
if err != nil {
return nil, backend.WebauthnLoginError(err, "Unable to find login credentials", "Unable to find login credentials")
}
data = makeDiscoverableWebauthNSessionData(sessionData)
} else {
authData, err := bridge.FindUserAuth(username)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Could not validate user", "No such auth")
}
if authData.JSONData == nil {
return nil, backend.WebauthnLoginError(err, "User lacks webauthn credentials")
}

user, err := bridge.GetUserFromID(authData.UserID)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Could not validate user", "No such user")
}

creds, err := a.getExistingCredentials(authData)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Unable to parse webauthn credentials")
}

webauthnUser := makeWebAuthnUser(user.FirstName, user.LastName, username, user.Email, user.ID, authData.AuthnID, creds)
options, sessionData, err = a.Web.BeginLogin(&webauthnUser)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Unable to begin login process")
}
data = makeWebauthNSessionData(webauthnUser, sessionData)
}

if err = bridge.SetAuthSchemeSession(w, r, makeWebauthNSessionData(webauthnUser, sessionData)); err != nil {
err = bridge.SetAuthSchemeSession(w, r, data)
if err != nil {
return nil, backend.WebauthnLoginError(err, "Unable to begin login process", "Unable to set session")
}

Expand Down
7 changes: 7 additions & 0 deletions backend/database/canned_queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ func (c *Connection) RetrieveUserByID(userID int64) (models.User, error) {
return rtn, err
}

// RetrieveUserIDByAuthnID retrieves a full user from a given authn ID
func (c *Connection) RetrieveUserIDByAuthnID(authn_id string) (models.User, error) {
var rtn models.User
err := c.Get(&rtn, sq.Select("u.first_name, u.last_name, u.slug, u.email, u.id").From("users u").Join("auth_scheme_data ad ON u.id = ad.user_id").Where(sq.Eq{"ad.authn_id": authn_id}))
return rtn, err
}

// RetrieveUserBySlug retrieves a full user from the users table give a user slug
func (c *Connection) RetrieveUserBySlug(slug string) (models.User, error) {
var rtn models.User
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/authschemes/webauthn/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const convertToPublicKeyCredentialRequestOptions = (input: ProvidedCreden
const output: PublicKeyCredentialRequestOptions = {
...input.publicKey,
challenge: toByteArrayFromB64URL(input.publicKey.challenge),
allowCredentials: input.publicKey.allowCredentials.map(
allowCredentials: input.publicKey.allowCredentials?.map(
listItem => ({ ...listItem, id: toByteArrayFromB64URL(listItem.id) })
)
}
Expand Down
46 changes: 35 additions & 11 deletions frontend/src/authschemes/webauthn/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the terms of the MIT. See LICENSE file in project root for terms.

import * as React from 'react'
import Button, { ButtonGroup } from 'src/components/button'
import Form from 'src/components/form'
import Input from 'src/components/input'
import Modal from 'src/components/modal'
Expand All @@ -10,26 +11,29 @@ import { useForm, useFormField } from 'src/helpers/use_form'
import { useModal, renderModals, OnRequestClose } from 'src/helpers'
import { convertToCredentialCreationOptions, convertToPublicKeyCredentialRequestOptions, encodeAsB64 } from '../helpers'
import { getResultState } from 'src/helpers/is_success_result'
import classnames from 'classnames/bind'
const cx = classnames.bind(require('./stylesheet'))


export default (props: {
query: URLSearchParams,
authFlags?: Array<string>
authFlags?: Array<string>,
}) => {
return (
<Login authFlags={props.authFlags} />
)
}

const Login = (props: {
authFlags?: Array<string>
authFlags?: Array<string>,
}) => {
const usernameField = useFormField('')
const [isDiscoverable, setIsDiscoverable] = React.useState(true)

const loginForm = useForm({
fields: [usernameField],
handleSubmit: async () => {
const protoOptions = await beginLogin({ username: usernameField.value })
const protoOptions = await beginLogin({ username: usernameField.value }, isDiscoverable)
const credOptions = convertToPublicKeyCredentialRequestOptions(protoOptions)

const cred = await navigator.credentials.get({
Expand All @@ -51,27 +55,46 @@ const Login = (props: {
signature: encodeAsB64(pubKeyResponse.signature),
userHandle: pubKeyResponse.userHandle == null ? "" : encodeAsB64(pubKeyResponse.userHandle),
}
})
}, isDiscoverable)
window.location.pathname = '/'
},
})

const registerModal = useModal<void>(modalProps => <RegisterModal {...(modalProps as OnRequestClose)} />)
const registerModal = useModal<void>(modalProps => <RegisterModal onRequestClose={() => modalProps.onRequestClose()} isDiscoverable={isDiscoverable} />)

const allowRegister = props.authFlags?.includes("open-registration") // TODO: this isn't being used

const registerProps = allowRegister
? { cancelText: "Register", onCancel: () => registerModal.show() }
: {}

const makeDiscoverable = () => setIsDiscoverable(true)
const makeNonDiscoverable = () => setIsDiscoverable(false)

return (
<div>
{window.PublicKeyCredential && (
<div style={{ minWidth: 300 }}>
<Form submitText="Login with WebAuthN" {...registerProps} {...loginForm}>
<Input label="Username" {...usernameField} />
</Form>
{renderModals(registerModal)}
<div className={cx('login-container')}>
<div className={cx('mode-buttons')}>
<ButtonGroup className={cx('row-buttons')}>
<Button active={isDiscoverable} className={cx('mode-button-right')} onClick={makeDiscoverable}>Discoverable</Button>
<Button active={!isDiscoverable} className={cx('mode-button-left')} onClick={makeNonDiscoverable}>Username</Button>
</ButtonGroup>
</div>
{isDiscoverable ? (
<div>
<Form submitText="Login" {...registerProps} {...loginForm} autoFocus={true}>
</Form>
{renderModals(registerModal)}
</div>
) : (
<div>
<Form submitText="Login" {...registerProps} {...loginForm}>
<Input label="Username" {...usernameField} />
</Form>
{renderModals(registerModal)}
</div>
)}
</div>
)}
</div>
Expand All @@ -80,6 +103,7 @@ const Login = (props: {

const RegisterModal = (props: {
onRequestClose: () => void,
isDiscoverable: boolean,
}) => {
const firstNameField = useFormField('')
const lastNameField = useFormField('')
Expand Down Expand Up @@ -107,7 +131,7 @@ const RegisterModal = (props: {
email: emailField.value,
username: usernameField.value,
credentialName: keyNameField.value,
})
}, props.isDiscoverable)
const credOptions = convertToCredentialCreationOptions(reg)

const signed = await navigator.credentials.create(credOptions)
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/authschemes/webauthn/login/stylesheet.styl
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@import '~src/vars'

.login-container
max-width: 400px

.mode-buttons
margin-bottom: 0.75rem
width: 400px

.mode-button-left
width: 50%

.mode-button-right
width: 50%
12 changes: 6 additions & 6 deletions frontend/src/authschemes/webauthn/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export async function beginRegistration(i: {
firstName: string,
lastName: string
credentialName: string
}): Promise<ProvidedCredentialCreationOptions> {
return await req('POST', '/auth/webauthn/register/begin', i)
}, discoverable: boolean): Promise<ProvidedCredentialCreationOptions> {
return await req('POST', '/auth/webauthn/register/begin', i, { discoverable })
}

export async function finishRegistration(i: WebAuthNRegisterConfirmation) {
Expand All @@ -28,12 +28,12 @@ export async function finishRegistration(i: WebAuthNRegisterConfirmation) {

export async function beginLogin(i: {
username: string,
}): Promise<ProvidedCredentialRequestOptions> {
return await req('POST', '/auth/webauthn/login/begin', i)
}, discoverable: boolean): Promise<ProvidedCredentialRequestOptions> {
return await req('POST', '/auth/webauthn/login/begin', i, { discoverable })
}

export async function finishLogin(i: CompletedLoginChallenge): Promise<void> {
return await req('POST', '/auth/webauthn/login/finish', i)
export async function finishLogin(i: CompletedLoginChallenge, discoverable: boolean): Promise<void> {
return await req('POST', '/auth/webauthn/login/finish', i, { discoverable })
}

export async function beginLink(i: {
Expand Down

0 comments on commit 6a7aafa

Please sign in to comment.