Skip to content

add code docs for functions in webauthn.go and user.go #93

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

Merged
merged 1 commit into from
Apr 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 46 additions & 14 deletions user.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
)

const (
UserContextKey = "user"
WebAuthnTablePK = "uuid"
LegacyU2FCredID = "u2f"
)
// UserContextKey is the context key that points to the authenticated user
const UserContextKey = "user"

// WebAuthnTablePK is the primary key in the WebAuthn DynamoDB table
const WebAuthnTablePK = "uuid"

// LegacyU2FCredID is a special case credential ID for legacy U2F support. At most one credential for each user may
// have this in its ID field.
const LegacyU2FCredID = "u2f"

// DynamoUser holds user data from DynamoDB, in both encrypted and unencrypted form. It also holds a Webauthn client
// and Webauthn API data.
type DynamoUser struct {
// Shared fields between U2F and WebAuthn
ID string `dynamodbav:"uuid" json:"uuid"`
Expand Down Expand Up @@ -53,6 +59,7 @@ type DynamoUser struct {
Icon string `dynamodbav:"-" json:"-"`
}

// NewDynamoUser creates a new DynamoUser from API input data, a storage client and a Webauthn client.
func NewDynamoUser(apiConfig ApiMeta, storage *Storage, apiKey ApiKey, webAuthnClient *webauthn.WebAuthn) DynamoUser {
u := DynamoUser{
ID: apiConfig.UserUUID,
Expand All @@ -76,6 +83,8 @@ func NewDynamoUser(apiConfig ApiMeta, storage *Storage, apiKey ApiKey, webAuthnC
return u
}

// RemoveU2F clears U2F fields in the user struct. To be used when a user has requested removal of their legacy U2F key.
// Should be followed by a database store operation.
func (u *DynamoUser) RemoveU2F() {
u.AppId = ""
u.EncryptedAppId = ""
Expand All @@ -85,11 +94,14 @@ func (u *DynamoUser) RemoveU2F() {
u.EncryptedPublicKey = ""
}

// unsetSessionData clears the encrypted session data from a user and stores the updated record in the database.
func (u *DynamoUser) unsetSessionData() error {
u.EncryptedSessionData = nil
return u.Store.Store(envConfig.WebauthnTable, u)
}

// saveSessionData encrypts the user's session data and updates the database record.
// CAUTION: user data is refreshed from the database by this function. Any unsaved data will be lost.
func (u *DynamoUser) saveSessionData(sessionData webauthn.SessionData) error {
// load to be sure working with latest data
err := u.Load()
Expand All @@ -112,6 +124,9 @@ func (u *DynamoUser) saveSessionData(sessionData webauthn.SessionData) error {
return u.Store.Store(envConfig.WebauthnTable, u)
}

// saveNewCredential appends a new credential to the user's credential list, encrypts the list, and updates the
// database record.
// CAUTION: user data is refreshed from the database by this function. Any unsaved data will be lost.
func (u *DynamoUser) saveNewCredential(credential webauthn.Credential) error {
// load to be sure working with latest data
err := u.Load()
Expand All @@ -135,8 +150,9 @@ func (u *DynamoUser) saveNewCredential(credential webauthn.Credential) error {

// DeleteCredential expects a hashed-encoded credential id. It finds a matching credential for that user and saves the
// user without that credential included. Alternatively, if the given credential id indicates that a legacy U2F key
// should be removed (e.g. by matching the string "u2f") then that user is saved with all of its legacy u2f fields
// should be removed (i.e. by matching the string "u2f") then that user is saved with all of its legacy u2f fields
// blanked out.
// CAUTION: user data is refreshed from the database by this function. Any unsaved data will be lost.
func (u *DynamoUser) DeleteCredential(credIDHash string) (int, error) {
// load to be sure working with the latest data
err := u.Load()
Expand Down Expand Up @@ -180,6 +196,7 @@ func (u *DynamoUser) DeleteCredential(credIDHash string) (int, error) {
return http.StatusNoContent, nil
}

// encryptAndStoreCredentials encrypts the user's credential list and updates the database record
func (u *DynamoUser) encryptAndStoreCredentials() error {
js, err := json.Marshal(u.Credentials)
if err != nil {
Expand All @@ -195,6 +212,7 @@ func (u *DynamoUser) encryptAndStoreCredentials() error {
return u.Store.Store(envConfig.WebauthnTable, u)
}

// Load refreshes a user object from the database record and decrypts the session data and credential list
func (u *DynamoUser) Load() error {
err := u.Store.Load(envConfig.WebauthnTable, WebAuthnTablePK, u.ID, u)
if err != nil {
Expand Down Expand Up @@ -243,10 +261,13 @@ func (u *DynamoUser) Load() error {
return nil
}

// Delete removes the user from the database
func (u *DynamoUser) Delete() error {
return u.Store.Delete(envConfig.WebauthnTable, WebAuthnTablePK, u.ID)
}

// BeginRegistration processes the first half of the Webauthn Registration flow for the user and returns the
// CredentialCreation data to pass back to the client. User session data is saved in the database.
func (u *DynamoUser) BeginRegistration() (*protocol.CredentialCreation, error) {
if u.WebAuthnClient == nil {
return nil, fmt.Errorf("dynamoUser, %s, missing WebAuthClient in BeginRegistration", u.Name)
Expand All @@ -271,6 +292,9 @@ func (u *DynamoUser) BeginRegistration() (*protocol.CredentialCreation, error) {
return options, nil
}

// FinishRegistration processes the last half of the Webauthn Registration flow for the user and returns the
// key_handle_hash to pass back to the client. The client should store this value for later use. User session data is
// cleared from the database.
func (u *DynamoUser) FinishRegistration(r *http.Request) (string, error) {
if r.Body == nil {
return "", fmt.Errorf("request Body may not be nil in FinishRegistration")
Expand Down Expand Up @@ -304,6 +328,8 @@ func (u *DynamoUser) FinishRegistration(r *http.Request) (string, error) {
return keyHandleHash, u.unsetSessionData()
}

// BeginLogin processes the first half of the Webauthn Authentication flow for the user and returns the
// CredentialAssertion data to pass back to the client. User session data is saved in the database.
func (u *DynamoUser) BeginLogin() (*protocol.CredentialAssertion, error) {
extensions := protocol.AuthenticationExtensions{}
if u.EncryptedAppId != "" {
Expand All @@ -328,6 +354,8 @@ func (u *DynamoUser) BeginLogin() (*protocol.CredentialAssertion, error) {
return options, nil
}

// FinishLogin processes the last half of the Webauthn Authentication flow for the user and returns the
// Credential data to pass back to the client. User session data is untouched by this function.
func (u *DynamoUser) FinishLogin(r *http.Request) (*webauthn.Credential, error) {
if r.Body == nil {
return nil, fmt.Errorf("request Body may not be nil in FinishLogin")
Expand Down Expand Up @@ -363,7 +391,7 @@ func (u *DynamoUser) FinishLogin(r *http.Request) (*webauthn.Credential, error)
}

// there is an issue with URLEncodeBase64.UnmarshalJSON and null values
// see https://github.com/go-webauthn/webauthn/issues/69
// see https://github.com/duo-labs/webauthn/issues/69
// null byte sequence is []byte{158,233,101}
if isNullByteSlice(parsedResponse.Response.UserHandle) {
parsedResponse.Response.UserHandle = nil
Expand All @@ -378,27 +406,27 @@ func (u *DynamoUser) FinishLogin(r *http.Request) (*webauthn.Credential, error)
return credential, nil
}

// User ID according to the Relying Party
// WebAuthnID returns the user's ID according to the Relying Party
func (u *DynamoUser) WebAuthnID() []byte {
return []byte(u.ID)
}

// User Name according to the Relying Party
// WebAuthnName returns the user's name according to the Relying Party
func (u *DynamoUser) WebAuthnName() string {
return u.Name
}

// Display Name of the user
// WebAuthnDisplayName returns the display name of the user
func (u *DynamoUser) WebAuthnDisplayName() string {
return u.DisplayName
}

// User's icon url
// WebAuthnIcon returns the user's icon URL
func (u *DynamoUser) WebAuthnIcon() string {
return u.Icon
}

// WebAuthnCredentials returns an array of credentials plus a U2F cred if present
// WebAuthnCredentials returns an array of credentials (passkeys) plus a U2F credential if present
func (u *DynamoUser) WebAuthnCredentials() []webauthn.Credential {
creds := u.Credentials

Expand Down Expand Up @@ -466,7 +494,8 @@ func (u *DynamoUser) WebAuthnCredentials() []webauthn.Credential {
return creds
}

// isNullByteSlice works around a bug in json unmarshalling for a urlencoded base64 string
// isNullByteSlice works around a bug in JSON unmarshalling for a URL-encoded Base64 string
// (see https://github.com/duo-labs/webauthn/issues/69)
func isNullByteSlice(slice []byte) bool {
if len(slice) != 3 {
return false
Expand All @@ -477,12 +506,15 @@ func isNullByteSlice(slice []byte) bool {
return false
}

// hashAndEncodeKeyHandle returns the Base64 URL-encoded SHA256 hash of a byte slice to provide a hash of a key
// handle to the client.
func hashAndEncodeKeyHandle(id []byte) string {
hash := sha256.Sum256(id)
return base64.RawURLEncoding.EncodeToString(hash[:])
}

// logProtocolError logs a detailed message if the given error is an Error from go-webauthn/webauthn/protocol
// logProtocolError logs an error and includes additional detail if the given error is an Error from
// go-webauthn/webauthn/protocol
func logProtocolError(msg string, err error) {
var protocolError *protocol.Error
if errors.As(err, &protocolError) {
Expand Down
31 changes: 30 additions & 1 deletion webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,19 @@ type beginRegistrationResponse struct {
protocol.CredentialCreation
}

// finishRegistrationResponse contains the response data for the FinishRegistration endpoint
type finishRegistrationResponse struct {
KeyHandleHash string `json:"key_handle_hash"`
}

// finishLoginResponse contains the response data for the FinishLogin endpoint
type finishLoginResponse struct {
CredentialID string `json:"credentialId"` // DEPRECATED, use KeyHandleHash instead
KeyHandleHash string `json:"key_handle_hash"`
}

// BeginRegistration processes the first half of the Webauthn Registration flow. It is the handler for the
// "POST /webauthn/register" endpoint, initiated by the client when creation of a new passkey is requested.
func BeginRegistration(w http.ResponseWriter, r *http.Request) {
user, err := getUserFromContext(r)
if err != nil {
Expand All @@ -72,6 +76,8 @@ func BeginRegistration(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, response, http.StatusOK)
}

// FinishRegistration processes the last half of the Webauthn Registration flow. It is the handler for the
// "PUT /webauthn/register" endpoint, initiated by the client with information encrypted by the new private key.
func FinishRegistration(w http.ResponseWriter, r *http.Request) {
user, err := getUserFromContext(r)
if err != nil {
Expand All @@ -92,6 +98,8 @@ func FinishRegistration(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, response, http.StatusOK) // Handle next steps
}

// BeginLogin processes the first half of the Webauthn Authentication flow. It is the handler for the
// "POST /webauthn/login" endpoint, initiated by the client at the beginning of a login request.
func BeginLogin(w http.ResponseWriter, r *http.Request) {
user, err := getUserFromContext(r)
if err != nil {
Expand All @@ -110,6 +118,8 @@ func BeginLogin(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, options, http.StatusOK)
}

// FinishLogin processes the second half of the Webauthn Authentication flow. It is the handler for the
// "PUT /webauthn/login" endpoint, initiated by the client with login data signed with the private key.
func FinishLogin(w http.ResponseWriter, r *http.Request) {
user, err := getUserFromContext(r)
if err != nil {
Expand All @@ -133,6 +143,8 @@ func FinishLogin(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, resp, http.StatusOK)
}

// DeleteUser is the handler for the "DELETE /webauthn/user" endpoint. It removes a user and any stored passkeys owned
// by the user.
func DeleteUser(w http.ResponseWriter, r *http.Request) {
user, err := getUserFromContext(r)
if err != nil {
Expand All @@ -150,6 +162,9 @@ func DeleteUser(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, nil, http.StatusNoContent)
}

// DeleteCredential is the handler for the "DELETE /webauthn/credential/{credID}" endpoint. It removes a single
// passkey identified by "credID", which is the key_handle_hash returned by the FinishRegistration endpoint, or "u2f"
// if it is a legacy U2F credential.
func DeleteCredential(w http.ResponseWriter, r *http.Request) {
user, err := getUserFromContext(r)
if err != nil {
Expand All @@ -175,14 +190,18 @@ func DeleteCredential(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, err, status)
}

// simpleError is a custom error type that can be JSON-encoded for API responses
type simpleError struct {
Error string `json:"error"`
}

// newSimpleError creates a new simpleError from the given error
func newSimpleError(err error) simpleError {
return simpleError{Error: err.Error()}
}

// jsonResponse encodes a body as JSON and writes it to the response. It sets the response Content-Type header to
// "application/json".
func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
var data interface{}
switch b := body.(type) {
Expand All @@ -208,22 +227,26 @@ func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
w.WriteHeader(status)
_, err = w.Write(jBody)
if err != nil {
log.Printf("faild to write response in jsonResponse: %s\n", err)
log.Printf("failed to write response in jsonResponse: %s\n", err)
}
}

// fixStringEncoding converts a string from standard Base64 to Base64-URL
func fixStringEncoding(content string) string {
content = strings.ReplaceAll(content, "+", "-")
content = strings.ReplaceAll(content, "/", "_")
content = strings.ReplaceAll(content, "=", "")
return content
}

// fixEncoding converts a string from standard Base64 to Base64-URL as an io.Reader
func fixEncoding(content []byte) io.Reader {
allStr := string(content)
return bytes.NewReader([]byte(fixStringEncoding(allStr)))
}

// getWebAuthnFromApiMeta creates a new WebAuthn object from the metadata provided in a web request. Typically used in
// the API authentication phase, early in the handler or in a middleware.
func getWebAuthnFromApiMeta(meta ApiMeta) (*webauthn.WebAuthn, error) {
web, err := webauthn.New(&webauthn.Config{
RPDisplayName: meta.RPDisplayName, // Display Name for your site
Expand All @@ -238,6 +261,8 @@ func getWebAuthnFromApiMeta(meta ApiMeta) (*webauthn.WebAuthn, error) {
return web, nil
}

// getApiMetaFromRequest creates an ApiMeta object from request headers, including basic validation checks. Used during
// API authentication.
func getApiMetaFromRequest(r *http.Request) (ApiMeta, error) {
meta := ApiMeta{
RPDisplayName: r.Header.Get("x-mfa-RPDisplayName"),
Expand Down Expand Up @@ -271,6 +296,8 @@ func getApiMetaFromRequest(r *http.Request) (ApiMeta, error) {
return meta, nil
}

// getUserFromContext returns the authenticated DynamoUser from the request context. The authentication middleware or
// early handler processing inserts the authenticated user into the context for retrieval by this function.
func getUserFromContext(r *http.Request) (*DynamoUser, error) {
user, ok := r.Context().Value(UserContextKey).(*DynamoUser)
if !ok {
Expand All @@ -280,6 +307,8 @@ func getUserFromContext(r *http.Request) (*DynamoUser, error) {
return user, nil
}

// AuthenticateRequest checks the provided API key against the keys stored in the database. If the key is active and
// valid, a Webauthn client and DynamoUser are created and stored in the request context.
func AuthenticateRequest(r *http.Request) (*DynamoUser, error) {
// get key and secret from headers
key := r.Header.Get("x-mfa-apikey")
Expand Down