405 lines
14 KiB
Go
405 lines
14 KiB
Go
package httpapi
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
"github.com/go-webauthn/webauthn/protocol"
|
||
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
||
|
|
|
||
|
|
"github.com/kristianwind/verdande/internal/auth"
|
||
|
|
"github.com/kristianwind/verdande/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Passkeys: a key on a device instead of a secret in a head.
|
||
|
|
//
|
||
|
|
// Four endpoints, in two pairs. Registration is done by somebody already signed in
|
||
|
|
// — you cannot be handed a way into an account you have not proved you own.
|
||
|
|
// Logging in is done by somebody who is not, and is the one place here that is
|
||
|
|
// reachable without a session.
|
||
|
|
//
|
||
|
|
// Each ceremony is two requests because the browser has to talk to the
|
||
|
|
// authenticator in between: the server issues a challenge, the device signs it,
|
||
|
|
// the server checks the signature against what it remembers issuing. The challenge
|
||
|
|
// lives in the database rather than in a cookie, because the whole point of it is
|
||
|
|
// that the server chose it and remembers choosing it.
|
||
|
|
|
||
|
|
const challengeTTL = 5 * time.Minute
|
||
|
|
|
||
|
|
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
|
||
|
|
// built. 404 would read as "this version does not have passkeys"; the truth is
|
||
|
|
// that this deployment cannot offer them, which is a different thing to fix.
|
||
|
|
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
|
||
|
|
if s.passkeys != nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||
|
|
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
type passkeyJSON struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
// AAGUID identifies the authenticator model, which is what lets the list say
|
||
|
|
// something recognisable beside a key somebody named three months ago.
|
||
|
|
AAGUID string `json:"aaguid,omitempty"`
|
||
|
|
Discoverable bool `json:"discoverable"`
|
||
|
|
UserVerified bool `json:"user_verified"`
|
||
|
|
CreatedAt string `json:"created_at"`
|
||
|
|
LastUsedAt string `json:"last_used_at,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
|
||
|
|
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "list passkeys", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
out := make([]passkeyJSON, 0, len(keys))
|
||
|
|
for _, k := range keys {
|
||
|
|
j := passkeyJSON{
|
||
|
|
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
|
||
|
|
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
|
||
|
|
CreatedAt: k.CreatedAt.Format(time.RFC3339),
|
||
|
|
}
|
||
|
|
if !k.LastUsedAt.IsZero() {
|
||
|
|
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
|
||
|
|
}
|
||
|
|
out = append(out, j)
|
||
|
|
}
|
||
|
|
// Whether this deployment can offer them at all, alongside the ones that exist.
|
||
|
|
// The interface asks one question rather than discovering the answer from a 503
|
||
|
|
// it triggered by offering a button — an address an authenticator refuses is a
|
||
|
|
// deployment fact, not an error somebody caused.
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"passkeys": out, "available": s.passkeys != nil,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- registering a key ------------------------------------------------------------
|
||
|
|
|
||
|
|
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !s.passkeyAvailable(w, r) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
user := userFrom(r.Context())
|
||
|
|
|
||
|
|
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "load passkeys", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resident keys, and the authenticator asked to verify its owner. Both are
|
||
|
|
// what make this a *passkey* rather than a second factor: only a discoverable
|
||
|
|
// credential can start a login with no email typed, and only a verified one
|
||
|
|
// stands in for the password rather than beside it.
|
||
|
|
creation, session, err := s.passkeys.BeginRegistration(account,
|
||
|
|
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||
|
|
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||
|
|
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
||
|
|
UserVerification: protocol.VerificationPreferred,
|
||
|
|
}),
|
||
|
|
// Excluding what they already have, so a second registration on the same
|
||
|
|
// device replaces nothing and reports itself instead of silently making a
|
||
|
|
// duplicate.
|
||
|
|
webauthn.WithExclusions(account.excludeList()),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "begin passkey registration", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.issueChallenge(w, r, user.ID, "register", session, creation)
|
||
|
|
}
|
||
|
|
|
||
|
|
type finishRegistrationRequest struct {
|
||
|
|
ChallengeID string `json:"challenge_id"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
Credential json.RawMessage `json:"credential"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !s.passkeyAvailable(w, r) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var req finishRegistrationRequest
|
||
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
user := userFrom(r.Context())
|
||
|
|
|
||
|
|
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "load passkeys", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
name := strings.TrimSpace(req.Name)
|
||
|
|
if name == "" {
|
||
|
|
name = "Passkey"
|
||
|
|
}
|
||
|
|
key := &store.Passkey{
|
||
|
|
UserID: user.ID,
|
||
|
|
CredentialID: auth.EncodeCredentialID(credential.ID),
|
||
|
|
PublicKey: credential.PublicKey,
|
||
|
|
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
|
||
|
|
SignCount: credential.Authenticator.SignCount,
|
||
|
|
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
|
||
|
|
UserVerified: credential.Flags.UserVerified,
|
||
|
|
Name: name,
|
||
|
|
}
|
||
|
|
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
|
||
|
|
s.internal(w, r, "store passkey", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusCreated, passkeyJSON{
|
||
|
|
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
|
||
|
|
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
|
||
|
|
CreatedAt: key.CreatedAt.Format(time.RFC3339),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- signing in with a key ---------------------------------------------------------
|
||
|
|
|
||
|
|
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !s.passkeyAvailable(w, r) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Discoverable: nobody has said who they are yet, and that is the point. The
|
||
|
|
// device knows which account its key belongs to, so there is no email to type
|
||
|
|
// and no list of accounts for anybody to probe.
|
||
|
|
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "begin passkey login", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.issueChallenge(w, r, "", "login", session, assertion)
|
||
|
|
}
|
||
|
|
|
||
|
|
type finishLoginRequest struct {
|
||
|
|
ChallengeID string `json:"challenge_id"`
|
||
|
|
Credential json.RawMessage `json:"credential"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !s.passkeyAvailable(w, r) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var req finishLoginRequest
|
||
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Which account, resolved from the credential the device signed with. The
|
||
|
|
// handler is given the raw id and returns the owner; the library checks the
|
||
|
|
// signature against that owner's stored public key.
|
||
|
|
var matched *store.Passkey
|
||
|
|
credential, err := s.passkeys.ValidateDiscoverableLogin(
|
||
|
|
func(rawID, userHandle []byte) (webauthn.User, error) {
|
||
|
|
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
matched = key
|
||
|
|
owner, err := s.db.UserByID(r.Context(), key.UserID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
|
||
|
|
}, *session, parsed)
|
||
|
|
if err != nil || matched == nil {
|
||
|
|
// One message, as with a wrong password: telling "no such key" apart from
|
||
|
|
// "bad signature" is a way to learn which credentials exist here.
|
||
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// A counter that goes backwards means the credential has been cloned. Not
|
||
|
|
// every authenticator keeps one, so zero on both sides is silence rather than
|
||
|
|
// suspicion — but a real counter that has gone down is the one signal this
|
||
|
|
// design gives, and it is refused rather than logged.
|
||
|
|
if credential.Authenticator.CloneWarning {
|
||
|
|
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
|
||
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
owner, err := s.db.UserByID(r.Context(), matched.UserID)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "load account", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
|
||
|
|
s.log.Warn("record passkey use", "err", err)
|
||
|
|
}
|
||
|
|
s.loginLimiter.reset(clientIP(r))
|
||
|
|
|
||
|
|
// A verified key is both factors at once: the device proved possession and the
|
||
|
|
// person proved they were there, which is exactly what a password plus a code
|
||
|
|
// is for. One that only proved possession still stops at the code.
|
||
|
|
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
|
||
|
|
ttl := s.cfg.SessionTTL
|
||
|
|
if pending {
|
||
|
|
ttl = 10 * time.Minute
|
||
|
|
}
|
||
|
|
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "create session", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.setSessionCookie(w, token, ttl)
|
||
|
|
|
||
|
|
if pending {
|
||
|
|
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
uj := toUserJSON(owner)
|
||
|
|
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- managing them ------------------------------------------------------------------
|
||
|
|
|
||
|
|
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var req struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
}
|
||
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
name := strings.TrimSpace(req.Name)
|
||
|
|
if name == "" {
|
||
|
|
writeFieldErrors(w, map[string]string{"name": "required"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||
|
|
userFrom(r.Context()).ID, name); err != nil {
|
||
|
|
s.storeError(w, r, "rename passkey", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||
|
|
userFrom(r.Context()).ID); err != nil {
|
||
|
|
s.storeError(w, r, "delete passkey", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- shared plumbing -----------------------------------------------------------------
|
||
|
|
|
||
|
|
// passkeyAccount is the library's view of an account, plus the rows behind it.
|
||
|
|
type passkeyAccount struct {
|
||
|
|
*auth.PasskeyUser
|
||
|
|
stored []store.Passkey
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
|
||
|
|
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
|
||
|
|
for _, c := range a.PasskeyUser.Credentials {
|
||
|
|
out = append(out, c.Descriptor())
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
|
||
|
|
keys, err := s.db.ListPasskeys(r.Context(), userID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
credentials := make([]webauthn.Credential, 0, len(keys))
|
||
|
|
for _, k := range keys {
|
||
|
|
raw, err := auth.DecodeCredentialID(k.CredentialID)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
credentials = append(credentials, webauthn.Credential{
|
||
|
|
ID: raw,
|
||
|
|
PublicKey: k.PublicKey,
|
||
|
|
Authenticator: webauthn.Authenticator{
|
||
|
|
SignCount: k.SignCount,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return &passkeyAccount{
|
||
|
|
PasskeyUser: &auth.PasskeyUser{
|
||
|
|
ID: userID, Email: email, Name: name, Credentials: credentials,
|
||
|
|
},
|
||
|
|
stored: keys,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// issueChallenge stores the server's side of a ceremony and hands the browser the
|
||
|
|
// options plus the id it will answer with.
|
||
|
|
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
|
||
|
|
encoded, err := json.Marshal(session)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "encode webauthn session", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, r, "store challenge", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
|
||
|
|
}
|
||
|
|
|
||
|
|
// takeChallenge reads a challenge once and checks it belongs where it is being
|
||
|
|
// used. A registration challenge answered by a login — or one account's answered
|
||
|
|
// by another — is refused rather than merely failing later.
|
||
|
|
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
|
||
|
|
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||
|
|
"that attempt has expired or was already used; start again")
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
if userID != "" && owner != userID {
|
||
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
var session webauthn.SessionData
|
||
|
|
if err := json.Unmarshal(raw, &session); err != nil {
|
||
|
|
s.internal(w, r, "decode webauthn session", err)
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
return &session, true
|
||
|
|
}
|