PlugGPGVerify behaviour (plug_gpg_verify v0.1.1)
plug_gpg_verify does the work of verifing a public key by generating a random challenge, sending that challenge to the user and expecting the response, having the client sign the challenge and send it back, then verifying the signature.
This makes a couple of assumptions:
- GPG is setup and working correctly on your system.
- this uses gpgmex which requires the rust toolchain installed and working
- The public key we are validating has already been imported
What this is NOT
- This is NOT a way to authenticate. Authentication is left as an excersise to the user of the library
- This does NOT in any way import PGP keys, or verify that the email associated with the public_key is valid.
example-usage
Example Usage
Put this plug somewhere in your router
scope "/verify" do
pipe_through(:api)
forward "/", PlugGPGVerify, adapter: MyProject.GPGVerificationPlug
end
Create a module that implements the PlugGPGVerify behaviour
defmodule MyProject.GPGVerificationPlug do
use PlugGPGVerify
@impl true
def find_user_by_email(email) do
case Repo.get_by(User, :email, email) do
nil -> {:error, :not_found}
user -> {:ok, %{id: user.id, email: user.email}}
end
end
@impl true
def challenge_created(user, challenge) do
changeset = User.changeset(
%User{id: user.id, email: user.email},
%{
challenge: plain_text_challenge,
challenge_expiration: DateTime.add(DateTime.utc_now(), 1, :hour)
}
)
Repo.update(changeset)
end
@impl true
def find_user_by_id(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user ->
# verify expiration
{:ok, %{id: user.id, email: user.email, challenge: challenge}}
end
end
@impl true
def gpg_verified(conn, user) do
# do whatever you want with the connection
token = Phoenix.Token.sign(MyAppWeb.Endpoint, "user auth", user.id)
conn
|> put_status(200)
|> Controller.json(%{token: token})
end
end
Your application accepts two new requests at /verify
(or whatever route you defined):
- GET /verify?email="user@email.com"
- POST /verify
get-verify
GET /verify
If a user is found (via the find_user_by_email/1
callback),
AND they have a public_key configured on the system, a new challenge is generated and encrypted.
A 201 is sent back with a JSON response of:
{
challenge: string,
user_id: string
}
post-verify
POST /verify
This accepts a json body of:
{
challenge_response: string,
user_id: string
}
where challenge_response is the signed challenge
flow-diagram
Flow Diagram
sequenceDiagram
Client->>Server: GET /verify?email=example@email.com
Server->>Client: {user_id: 1234, challenge: "challenge string"}
Client->>Server: POST /verify {user_id: 1234, challenge_response: "-----BEGIN PGP MESSAGE----- ..."}
Server->>Client: 200
Link to this section Summary
Callbacks
Called when the challenge is successfully created.
Find a user based on the email sent in the GET request
Find a user based on the id sent back via the POST request.
Called when the GPG public key has been verified because the challenge matches.
Link to this section Types
challenge()
@type challenge() :: binary()
user()
@type user() :: %{id: id(), email: email(), challenge: challenge()}
The entity passed between the plug and the callbacks
Link to this section Callbacks
challenge_created(user, challenge)
Called when the challenge is successfully created.
It is expected that the implementation stores the challenge somewhere to be recalled when
find_user_by_id/1
is called during the POST request.
It is also recommended to store an expiration date with the challenge.
find_user_by_email(email)
Find a user based on the email sent in the GET request
Typically this is would call into the database to find a user and that user should have a public_key.
Then it is mapped to a PlugGPGVerify.user/0
and returned in an :ok
tuple.
If this returns an error, a 406 is sent back to the client.
find_user_by_id(id)
Find a user based on the id sent back via the POST request.
When the client sends the POST request, the are required to send the id of the user back
instead of relying on the email. This callback is called to get the PlugGPGVerify.user/0
including the orginial PlugGPGVerify.challenge/0
created.
Most implementations will also verify that the challenge hasn't expired based on their own rules.
If an {:ok, user}
is returned, verification continues
If an {:error, reason}
is returned, a 401 is sent back to the client
gpg_verified(t, user)
@callback gpg_verified(Plug.Conn.t(), user()) :: Plug.Conn.t()
Called when the GPG public key has been verified because the challenge matches.
This is the final step in the happy path of verification.
This will return the Plug.Conn
and it's up to the implementation to handle next steps.
Most implementations will likely generate a token, return it to the user and store it in the session/db.