Skip to content
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
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ This endpoint has not yet been proven in production use. Proceed at your own ris

`DELETE /totp/{uuid}`

Coming soon.

### Validate TOTP Passcode

`POST /totp/{uuid}/validate`
Expand Down
13 changes: 11 additions & 2 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
)

const IDParam = "id"
const (
IDParam = "id"
UUIDParam = "uuid"
)

// simpleError is a custom error type that can be JSON-encoded for API responses
type simpleError struct {
Expand Down Expand Up @@ -34,7 +38,12 @@ func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
if data != nil {
jBody, err = json.Marshal(data)
if err != nil {
log.Printf("failed to marshal response body to json: %s", err)

// SonarQube flagged this as vulnerable to injection attacks. Rather than exhaustively search for places
// where user input is inserted into the error message, I'll just sanitize it as recommended.
sanitizedError := strings.ReplaceAll(strings.ReplaceAll(err.Error(), "\n", "_"), "\r", "_")

log.Printf("failed to marshal response body to json: %s", sanitizedError)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("failed to marshal response body to json"))
return
Expand Down
14 changes: 2 additions & 12 deletions apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"testing"
"time"

uuid "github.com/satori/go.uuid"
"golang.org/x/crypto/bcrypt"
)

Expand Down Expand Up @@ -346,12 +345,7 @@ func (ms *MfaSuite) TestAppRotateApiKey() {
key := user.ApiKey
must(db.Store(config.ApiKeyTable, key))

totp := TOTP{
UUID: uuid.NewV4().String(),
ApiKey: key.Key,
EncryptedTotpKey: mustEncryptLegacy(key, "plain text TOTP key"),
}
must(db.Store(ms.app.GetConfig().TotpTable, totp))
totp := ms.newPasscode(key)

newKey := newTestKey()
must(db.Store(config.ApiKeyTable, newKey))
Expand Down Expand Up @@ -513,11 +507,7 @@ func (ms *MfaSuite) TestApiKey_ReEncryptTOTPs() {
must(newKey.Activate())
must(ms.app.GetDB().Store(ms.app.GetConfig().ApiKeyTable, newKey))

must(storage.Store(ms.app.GetConfig().TotpTable, TOTP{
UUID: uuid.NewV4().String(),
ApiKey: oldKey.Key,
EncryptedTotpKey: mustEncryptLegacy(oldKey, "plain text TOTP key"),
}))
_ = ms.newPasscode(oldKey)

complete, incomplete, err := newKey.ReEncryptTOTPs(storage, oldKey)
ms.NoError(err)
Expand Down
18 changes: 18 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -687,3 +687,21 @@ paths:
example: "otpauth://totp/idp:john_smith?secret=0123456789ABCDEF0123456789ABCDEF&issuer=SIL%20IdP"
401:
$ref: "#/components/responses/UnauthorizedError"
/totp/{uuid}:
delete:
summary: Delete a passcode (TOTP)
parameters:
- in: path
name: uuid
schema:
type: string
format: uuid
required: true
description: The unique identifier for the passcode.
responses:
"204":
description: Success
"404":
description: Not found
"401":
$ref: "#/components/responses/UnauthorizedError"
12 changes: 8 additions & 4 deletions router/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ func getRoutes(app *mfa.App) []route {
Pattern: "POST /api-key",
HandlerFunc: app.CreateApiKey,
},
{
Pattern: "POST /totp",
HandlerFunc: app.CreateTOTP,
},
{
Pattern: "DELETE /totp/{" + mfa.UUIDParam + "}",
HandlerFunc: app.DeleteTOTP,
},
{
Pattern: "POST /webauthn/register",
HandlerFunc: app.BeginRegistration,
Expand Down Expand Up @@ -65,9 +73,5 @@ func getRoutes(app *mfa.App) []route {
Pattern: "DELETE /webauthn/credential/{" + mfa.IDParam + "}/",
HandlerFunc: app.DeleteCredential,
},
{
Pattern: "POST /totp",
HandlerFunc: app.CreateTOTP,
},
}
}
51 changes: 51 additions & 0 deletions totp.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ import (
"fmt"
"image/png"
"io"
"log"
"net/http"
"strings"

"github.com/google/uuid"
"github.com/pquerna/otp/totp"
)

// TOTPTablePK is the primary key in the TOTP DynamoDB table
const TOTPTablePK = "uuid"

// TOTP contains data to represent a Time-based One-Time Passcode (token). The ID and encrypted fields are persisted in
// DynamoDB. The others are non-encrypted and are short-lived.
type TOTP struct {
Expand All @@ -39,6 +44,11 @@ type TOTP struct {
OTPAuthURL string `dynamodbav:"-" json:"-"`
}

// debugString is used by the debugger to show useful TOTP information in watched variables
func (t TOTP) debugString() string {
return fmt.Sprintf("UUID: %s, Key: %s, ApiKey: %s", t.UUID, t.Key, t.ApiKey)
}

// CreateTOTPRequestBody defines the JSON request body for the CreateTOTP endpoint
type CreateTOTPRequestBody struct {
Issuer string `json:"issuer"`
Expand Down Expand Up @@ -151,6 +161,47 @@ func newTOTP(db *Storage, apiKey ApiKey, issuer, name string) (TOTP, error) {
return t, nil
}

// DeleteTOTP is the http handler to delete a passcode.
func (a *App) DeleteTOTP(w http.ResponseWriter, r *http.Request) {
const notFound = "TOTP not found"
const internalServerError = "Internal server error"

id := r.PathValue(UUIDParam)

key, err := getAPIKey(r)
if err != nil {
log.Printf("API Key not found in request context: %s", err)
jsonResponse(w, internalServerError, http.StatusInternalServerError)
return
}

var t TOTP
err = a.db.Load(envConfig.TotpTable, TOTPTablePK, id, &t)
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
jsonResponse(w, notFound, http.StatusNotFound)
} else {
log.Printf("error loading TOTP: %s", err)
jsonResponse(w, internalServerError, http.StatusInternalServerError)
}
return
}

if key.Key != t.ApiKey {
jsonResponse(w, notFound, http.StatusNotFound)
return
}

err = a.db.Delete(envConfig.TotpTable, TOTPTablePK, id)
if err != nil {
log.Printf("Failed to delete TOTP: %s", err)
jsonResponse(w, "Failed to delete TOTP", http.StatusInternalServerError)
return
}

jsonResponse(w, nil, http.StatusNoContent)
}

// authTOTP is a just a placeholder for TOTP. It takes the verified API Key and returns it as an authenticated User
// for later use.
func authTOTP(apiKey ApiKey) (User, error) {
Expand Down
71 changes: 70 additions & 1 deletion totp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"

uuid "github.com/satori/go.uuid"
)

func (ms *MfaSuite) TestAppCreateTOTP() {
Expand Down Expand Up @@ -36,7 +39,7 @@ func (ms *MfaSuite) TestAppCreateTOTP() {
ms.Run(tt.name, func() {
response := httptest.NewRecorder()
ms.app.CreateTOTP(response, tt.request)
ms.Equalf(tt.wantStatus, response.Code, "incorrect http status, body %s", response.Body.String())
ms.Equalf(tt.wantStatus, response.Code, "incorrect http status, response body: %s", response.Body.String())

if tt.wantStatus == http.StatusOK {
var responseBody CreateTOTPResponseBody
Expand Down Expand Up @@ -111,3 +114,69 @@ func (ms *MfaSuite) TestNewTOTP() {
ms.NoError(err)
ms.Equal(got.Key, plainText, "EncryptedTotpKey isn't correct")
}

func (ms *MfaSuite) TestAppDeleteTOTP() {
key := newTestKey()
otherKey := newTestKey()
totp := ms.newPasscode(key)

ctxWithAPIKey := context.WithValue(context.Background(), UserContextKey, key)
ctxWithOtherAPIKey := context.WithValue(context.Background(), UserContextKey, otherKey)

requestWithCorrectID := &http.Request{
Method: http.MethodDelete,
URL: &url.URL{Path: "/totp/" + totp.UUID},
}
requestWithCorrectID = requestWithCorrectID.WithContext(ctxWithAPIKey)

requestWithWrongKey := requestWithCorrectID.WithContext(ctxWithOtherAPIKey)

requestWithWrongUUID := &http.Request{
Method: http.MethodDelete,
URL: &url.URL{Path: "/totp/" + uuid.NewV4().String()},
}
requestWithWrongUUID = requestWithWrongUUID.WithContext(ctxWithAPIKey)

mux := &http.ServeMux{}
mux.HandleFunc("DELETE /totp/{"+UUIDParam+"}", ms.app.DeleteTOTP)

tests := []struct {
name string
request *http.Request
wantStatus int
}{
{
name: "wrong UUID",
request: requestWithWrongUUID,
wantStatus: http.StatusNotFound,
},
{
name: "correct UUID, wrong key",
request: requestWithWrongKey,
wantStatus: http.StatusNotFound,
},
{
name: "correct UUID, correct key",
request: requestWithCorrectID,
wantStatus: http.StatusNoContent,
},
}
for _, tt := range tests {
ms.Run(tt.name, func() {
response := httptest.NewRecorder()
mux.ServeHTTP(response, tt.request)

ms.Equalf(tt.wantStatus, response.Code, "incorrect http status, response body: %s", response.Body.String())
})
}
}

func (ms *MfaSuite) newPasscode(key ApiKey) TOTP {
totp := TOTP{
UUID: uuid.NewV4().String(),
ApiKey: key.Key,
EncryptedTotpKey: mustEncryptLegacy(key, "plain text TOTP key"),
}
must(ms.app.db.Store(ms.app.GetConfig().TotpTable, totp))
return totp
}