Skip to content

Commit ba5e552

Browse files
authored
Merge pull request #140 from sil-org/develop
Release 2.6.0 -- add TOTP endpoints
2 parents 59d1fe4 + 3f40446 commit ba5e552

31 files changed

+1137
-543
lines changed

.air-cdk.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
root = "."
2+
tmp_dir = "tmp"
3+
4+
[build]
5+
bin = ""
6+
cmd = './build.sh'
7+
delay = 100
8+
exclude_dir = ["tmp", "cdk"]
9+
full_bin = "sam local start-api --port 8160 --template cdk/cdk.out/twosv-api-dev.template.json --env-vars cdk/env.json"
10+
include_ext = ["go"]
11+
kill_delay = "0s"

.dockerignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
*
33

44
# Whitelist required files
5-
!.env.encrypted
6-
!scripts/*
75
!lambda/*
6+
!router/*
87
!server/*
98
!u2fsimulator/*
109
!u2fserver/*

.github/CODEOWNERS

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
* @silinternational/developers
2-
*.tf @silinternational/tf-devs
3-
*.go @silinternational/go-devs
4-
go.* @silinternational/go-devs
1+
* @sil-org/developers
2+
*.tf @sil-org/tf-devs
3+
*.go @sil-org/go-devs
4+
go.* @sil-org/go-devs

Dockerfile

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,16 @@
1-
FROM node:22
1+
FROM golang:1.24
22

3-
ENV GO_VERSION=1.24.4
4-
5-
ADD https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip .
6-
ADD https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz .
7-
8-
RUN <<EOF
9-
unzip awscli-exe-linux-x86_64.zip
10-
rm awscli-exe-linux-x86_64.zip
11-
./aws/install
12-
rm -rf ./aws
13-
14-
tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz
15-
rm go${GO_VERSION}.linux-amd64.tar.gz
16-
ln -s /usr/local/go/bin/go /usr/local/bin/go
17-
18-
npm install --ignore-scripts --global aws-cdk
19-
20-
adduser user
21-
EOF
3+
RUN adduser user
224

235
WORKDIR /src
246

257
RUN curl -sSfL --proto "=https" https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | \
26-
sh -s -- -b $(go env GOPATH)/bin
8+
sh -s -- -b /usr/local/bin && \
9+
git config --global --add safe.directory /src
2710

2811
COPY ./ .
29-
RUN go get ./...
12+
RUN go get ./... && \
13+
git config --global --add safe.directory /src
3014

3115
EXPOSE 8080
3216

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ test: clean db
1818
db:
1919
docker compose up -d dynamo
2020

21-
dbinit: db wait createwebauthntable createapikeytable
21+
dbinit: db wait createwebauthntable createapikeytable createtotptable
2222

2323
wait:
2424
sleep 5

README.md

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,56 @@
1-
# A Serverless MFA API with support for WebAuthn
1+
# A Serverless MFA API with support for TOTP and WebAuthn
22

3-
This project provides a semi-generic backend API for supporting WebAuthn credential registration and authentication.
4-
It is intended to be run in a manner as to be shared between multiple consuming applications. It uses an API key
5-
and secret to authenticate requests, and further uses that secret as the encryption key. Loss of the API secret
6-
would mean loss of all WebAuthn credentials stored.
3+
This project provides a semi-generic backend API for supporting Time-based One Time Passcode (TOTP) and WebAuthn
4+
Passkey registration and authentication. It is intended to be run in a manner as to be shared between multiple consuming
5+
applications. It uses an API key and secret to authenticate requests, and further uses that secret as the encryption
6+
key. Loss of the API secret would mean loss of all credentials stored.
77

88
This application can be run in two ways:
99
1. As a standalone server using the builtin webserver available in the `server/` folder
10-
2. As a AWS Lambda function using the `lambda/` implementation. This implementation can also use
10+
2. As an AWS Lambda function using the `lambda/` implementation. This implementation can also use
1111
[AWS CDK](https://aws.amazon.com/cdk/) to help automate build/deployment. It should also be
1212
noted that the `lambda` format depends on some resources already existing in AWS. There is a `lambda/terraform/`
1313
folder with the Terraform configurations needed to provision them.
1414

15-
## The API
15+
# API definition
16+
17+
The full definition of the API is found in the openapi.yaml file. A brief summary follows.
18+
19+
## The APIKey API
20+
21+
### Create APIKey
22+
23+
`POST /api-key`
24+
25+
### Activate APIKey
26+
27+
`POST /api-key/activate`
28+
29+
### Rotate APIKey (experimental)
30+
31+
This endpoint has not yet been proven in production use. Proceed at your own risk.
32+
33+
`POST /api-key/rotate`
34+
35+
## The TOTP API
36+
37+
### Required Headers
38+
1. `x-mfa-apikey` - The API Key
39+
2. `x-mfa-apisecret` - The API Key Secret
40+
41+
### Create TOTP Passcode
42+
43+
`POST /totp`
44+
45+
### Delete TOTP Passcode
46+
47+
`DELETE /totp/{uuid}`
48+
49+
### Validate TOTP Passcode
50+
51+
`POST /totp/{uuid}/validate`
52+
53+
## The Webauthn API
1654
Yes, as you'll see below this API makes heavy use of custom headers for things that seem like they could go into
1755
the request body. We chose to use headers though so that what is sent in the body can be handed off directly
1856
to the WebAuthn library and fit the structures it was expecting without causing any conflicts, etc.
@@ -52,3 +90,60 @@ to do with WebAuthn, but is the primary key for finding the right records in Dyn
5290

5391
### Delete one of the user's Webauthn credentials
5492
`DELETE /webauthn/credential`
93+
94+
# Development
95+
96+
## Unit tests
97+
98+
To run unit tests, simply run "make test". It will spin up a Docker Compose environment and run the tests using
99+
Docker containers for the API and for DynamoDB.
100+
101+
## Manual testing
102+
103+
Unit tests can be run individually, either on the command line or through your IDE. It is also possible to
104+
test the server and Lambda implementations locally.
105+
106+
### Server
107+
108+
#### HTTP
109+
110+
If HTTPS is not needed, simply start the `app` container and exercise the API using localhost and the Docker port
111+
defined in docker-compose.yml (currently 8161).
112+
113+
#### HTTPS
114+
115+
To use a "demo UI" that can interact with the API using HTTPS, use Traefik proxy, which is defined in the Docker
116+
Compose environment. Traefik is a proxy that creates a Let's Encrypt certificate and routes traffic to the local
117+
container via a registered DNS record. To configure this, define the following variables in `local.env`:
118+
119+
- DNS_PROVIDER=cloudflare
120+
- CLOUDFLARE_DNS_API_TOKEN=<insert a valid Cloudflare token that has DNS write permission on the domain defined below>
121+
- LETS_ENCRYPT_EMAIL=<insert your actual email address here>
122+
- LETS_ENCRYPT_CA=production
123+
- TLD=<your DNS domain>
124+
- SANS=mfa-ui.<your domain>,mfa-app.<your domain>
125+
- BACKEND1_URL=http://ui:80
126+
- FRONTEND1_DOMAIN=mfa-ui.<your domain>
127+
- BACKEND2_URL=http://app:8080
128+
- FRONTEND2_DOMAIN=mfa-app.<your domain>
129+
130+
Create DNS A records (without Cloudflare proxy enabled) for the values defined in `FRONTEND1_DOMAIN` and
131+
`FRONTEND2_DOMAIN` pointing to 127.0.0.1 and wait for DNS propagation. Once all of the above configuration is in place,
132+
run `make demo`. The first time will take several minutes for all the initialization. You can watch Docker logs on the
133+
proxy container to keep tabs on the progress.
134+
135+
### Lambda
136+
137+
To exercise the API as it would be used in AWS Lambda, run this command: `air -c .air-cdk.toml`. This will run a
138+
file watcher that will rebuild the app code and the CDK stack, then run `sam local start-api` using the generated
139+
Cloudformation template. This will listen on port 8160. Any code changes will trigger a rebuild and SAM will restart
140+
using the new code.
141+
142+
Implementation notes:
143+
144+
- SAM uses Docker internally, which would make it complicated to run with Docker Compose.
145+
- You will need to install CDK and SAM on your computer for this to work.
146+
- It can use the DynamoDB container in Docker Compose, which can be started using `make dbinit`.
147+
- The `make dbinit` command creates an APIKey (key: `EC7C2E16-5028-432F-8AF2-A79A64CF3BC1`
148+
secret: `1ED18444-7238-410B-A536-D6C15A3C`)
149+
- Some unit tests will delete the APIKey created by `make dbinit`.

api.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,32 @@ package mfa
22

33
import (
44
"encoding/json"
5+
"errors"
56
"log"
67
"net/http"
8+
"strings"
9+
10+
"github.com/google/uuid"
711
)
812

9-
const IDParam = "id"
13+
const (
14+
IDParam = "id"
15+
UUIDParam = "uuid"
16+
)
1017

1118
// simpleError is a custom error type that can be JSON-encoded for API responses
1219
type simpleError struct {
13-
Error string `json:"error"`
20+
Err string `json:"error"`
1421
}
1522

1623
// newSimpleError creates a new simpleError from the given error
17-
func newSimpleError(err error) simpleError {
18-
return simpleError{Error: err.Error()}
19-
}
24+
func newSimpleError(err error) simpleError { return simpleError{Err: err.Error()} }
25+
26+
// Error satisfies the error interface.
27+
func (s simpleError) Error() string { return s.Err }
28+
29+
// Is returns true if the error strings are equal.
30+
func (s simpleError) Is(err error) bool { return s.Err == err.Error() || errors.Is(err, simpleError{}) }
2031

2132
// jsonResponse encodes a body as JSON and writes it to the response. It sets the response Content-Type header to
2233
// "application/json".
@@ -25,6 +36,8 @@ func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
2536
switch b := body.(type) {
2637
case error:
2738
data = newSimpleError(b)
39+
case string:
40+
data = newSimpleError(errors.New(b))
2841
default:
2942
data = body
3043
}
@@ -34,7 +47,12 @@ func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
3447
if data != nil {
3548
jBody, err = json.Marshal(data)
3649
if err != nil {
37-
log.Printf("failed to marshal response body to json: %s", err)
50+
51+
// SonarQube flagged this as vulnerable to injection attacks. Rather than exhaustively search for places
52+
// where user input is inserted into the error message, I'll just sanitize it as recommended.
53+
sanitizedError := strings.ReplaceAll(strings.ReplaceAll(err.Error(), "\n", "_"), "\r", "_")
54+
55+
log.Printf("failed to marshal response body to json: %s", sanitizedError)
3856
w.WriteHeader(http.StatusInternalServerError)
3957
_, _ = w.Write([]byte("failed to marshal response body to json"))
4058
return
@@ -45,6 +63,15 @@ func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
4563
w.WriteHeader(status)
4664
_, err = w.Write(jBody)
4765
if err != nil {
48-
log.Printf("failed to write response in jsonResponse: %s\n", err)
66+
log.Printf("failed to write response in jsonResponse: %s", err)
67+
}
68+
}
69+
70+
// NewUUID returns a new V4 UUID value as a text string
71+
func NewUUID() string {
72+
u, err := uuid.NewRandom()
73+
if err != nil {
74+
panic("failed to generate uuid: " + err.Error())
4975
}
76+
return u.String()
5077
}

0 commit comments

Comments
 (0)