Files
hanko/backend/handler/webauthn_test.go
2025-09-25 19:15:20 +02:00

320 lines
12 KiB
Go

package handler
import (
"encoding/base64"
"encoding/json"
"github.com/go-webauthn/webauthn/protocol"
"github.com/gofrs/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/suite"
"github.com/teamhanko/hanko/backend/v2/persistence/models"
"github.com/teamhanko/hanko/backend/v2/test"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWebauthnSuite(t *testing.T) {
t.Parallel()
suite.Run(t, new(webauthnSuite))
}
type webauthnSuite struct {
test.Suite
}
func (s *webauthnSuite) TestWebauthnHandler_NewHandler() {
if testing.Short() {
s.T().Skip("skipping test in short mode")
}
manager := getDefaultSessionManager(s.Storage)
handler, err := NewWebauthnHandler(&test.DefaultConfig, s.Storage, manager, test.NewAuditLogger(), nil)
s.NoError(err)
s.NotEmpty(handler)
}
func (s *webauthnSuite) TestWebauthnHandler_BeginRegistration() {
if testing.Short() {
s.T().Skip("skipping test in short mode")
}
err := s.LoadFixtures("../test/fixtures/webauthn")
s.Require().NoError(err)
userId := uuid.FromStringOrNil("ec4ef049-5b88-4321-a173-21b0eff06a04")
e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil)
cookie, err := generateSessionCookie(s.Storage, userId)
s.Require().NoError(err)
req := httptest.NewRequest(http.MethodPost, "/webauthn/registration/initialize", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if s.Equal(http.StatusOK, rec.Code) {
creationOptions := protocol.CredentialCreation{}
err = json.Unmarshal(rec.Body.Bytes(), &creationOptions)
s.NoError(err)
uId, err := base64.RawURLEncoding.DecodeString(creationOptions.Response.User.ID.(string))
s.Require().NoError(err)
s.NotEmpty(creationOptions.Response.Challenge)
s.Equal(uuid.FromStringOrNil(userId.String()).Bytes(), uId)
s.Equal(test.DefaultConfig.Webauthn.RelyingParty.Id, creationOptions.Response.RelyingParty.ID)
s.Equal(protocol.ResidentKeyRequirementRequired, creationOptions.Response.AuthenticatorSelection.ResidentKey)
s.Equal(protocol.VerificationPreferred, creationOptions.Response.AuthenticatorSelection.UserVerification)
s.True(*creationOptions.Response.AuthenticatorSelection.RequireResidentKey)
}
}
func (s *webauthnSuite) TestWebauthnHandler_FinalizeRegistration() {
if testing.Short() {
s.T().Skip("skipping test in short mode")
}
err := s.LoadFixtures("../test/fixtures/webauthn_registration")
s.Require().NoError(err)
userId := uuid.FromStringOrNil("ec4ef049-5b88-4321-a173-21b0eff06a04")
e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil)
cookie, err := generateSessionCookie(s.Storage, userId)
s.Require().NoError(err)
body := `{
"id": "AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH",
"rawId": "AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH",
"type": "public-key",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjeSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFYmehnq3OAAI1vMYKZIsLJfHwVQMAWgGhXZHA-Erj4xfo8FKEcB_PmR7mOUVuOn7GZhLwV-kTSh2hrVc6QE7NOikFYXiDo2M_mJ3huHJkDnnc5dHtIxfedbpMdex5fY3hoFs-fwymQjtdqdvti5c4x6UBAgMmIAEhWCDxvVrRgK4vpnr6JxTx-KfpSNyQUtvc47ryryZmj-P5kSJYIDox8N9bHQBrxN-b5kXqfmj3GwAJW7nNCh8UPbus3B6I",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoidE9yTkRDRDJ4UWY0ekZqRWp3eGFQOGZPRXJQM3p6MDhyTW9UbEpHdG5LVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0"
}
}`
req := httptest.NewRequest(http.MethodPost, "/webauthn/registration/finalize", strings.NewReader(body))
req.AddCookie(cookie)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if s.Equal(http.StatusOK, rec.Code) {
s.Equal(`{"credential_id":"AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH","user_id":"ec4ef049-5b88-4321-a173-21b0eff06a04"}`, strings.TrimSpace(rec.Body.String()))
}
req2 := httptest.NewRequest(http.MethodPost, "/webauthn/registration/finalize", strings.NewReader(body))
req2.AddCookie(cookie)
rec2 := httptest.NewRecorder()
e.ServeHTTP(rec2, req2)
s.Equal(http.StatusBadRequest, rec2.Code)
}
func (s *webauthnSuite) TestWebauthnHandler_FinalizeRegistration_SessionDataExpired() {
if testing.Short() {
s.T().Skip("skipping test in short mode")
}
err := s.LoadFixtures("../test/fixtures/webauthn_registration")
s.Require().NoError(err)
userId := uuid.FromStringOrNil("ec4ef049-5b88-4321-a173-21b0eff06a04")
e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil)
cookie, err := generateSessionCookie(s.Storage, userId)
s.Require().NoError(err)
body := `{
"id": "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM",
"rawId": "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM",
"type": "public-key",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAQECAwQFBgcIAQIDBAUGBwgAIOIlWRhTf45LVyZsJgZmkqtEK-E-tk9I1-z0u13IAFpTpQECAyYgASFYIAeA_nt5TQ8c7bc8hN9_3zqzp3coXO5aplEeHMOQG0hrIlggf_KVxZI_nIedc1XMrwwOMaYNd0qxVpFK7vU79fGBoxY",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiRmVNYzdzUjlFbGVod0VVNVR0RVdGaTdyUFAzLWtkWlhnbndMdGxiM0NoWSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODg4OCIsImNyb3NzT3JpZ2luIjpmYWxzZX0"
}
}`
req := httptest.NewRequest(http.MethodPost, "/webauthn/registration/finalize", strings.NewReader(body))
req.AddCookie(cookie)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
s.Equal(http.StatusBadRequest, rec.Code)
}
func (s *webauthnSuite) TestWebauthnHandler_BeginAuthentication() {
if testing.Short() {
s.T().Skip("skipping test in short mode")
}
err := s.LoadFixtures("../test/fixtures/webauthn")
s.Require().NoError(err)
e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/webauthn/login/initialize", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if s.Equal(http.StatusOK, rec.Code) {
assertionOptions := protocol.CredentialAssertion{}
err := json.Unmarshal(rec.Body.Bytes(), &assertionOptions)
s.Require().NoError(err)
s.NotEmpty(assertionOptions.Response.Challenge)
s.Equal(assertionOptions.Response.UserVerification, protocol.VerificationPreferred)
s.Equal(test.DefaultConfig.Webauthn.RelyingParty.Id, assertionOptions.Response.RelyingPartyID)
}
}
func (s *webauthnSuite) TestWebauthnHandler_FinalizeAuthentication() {
if testing.Short() {
s.T().Skip("skipping test in short mode")
}
err := s.LoadFixtures("../test/fixtures/webauthn")
s.Require().NoError(err)
e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil)
body := `{
"id": "AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH",
"rawId": "AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH",
"type": "public-key",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFYmezOw",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiZ0tKS21oOTB2T3BZTzU1b0hwcWFIWF9vTUNxNG9UWnQtRDBiNnRlSXpyRSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"signature": "MEYCIQDi2vYVspG6pf38I4GyQCPOojGbvX4nwSPXCi0hm80twAIhAO3EWjhAnj0UpjU_l0AH5sEh3zq4LDvkvo3AUqaqfGYD",
"userHandle": "7E7wSVuIQyGhcyGw7_BqBA"
}
}`
req := httptest.NewRequest(http.MethodPost, "/webauthn/login/finalize", strings.NewReader(body))
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if s.Equal(http.StatusOK, rec.Code) {
s.Empty(rec.Header().Get("X-Auth-Token"))
cookies := rec.Result().Cookies()
if s.NotEmpty(cookies) {
for _, cookie := range cookies {
if cookie.Name == "hanko" {
s.Regexp(".*\\..*\\..*", cookie.Value) // check if cookie contains a jwt
}
}
}
s.Equal(`{"credential_id":"AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH","user_id":"ec4ef049-5b88-4321-a173-21b0eff06a04"}`, strings.TrimSpace(rec.Body.String()))
}
req2 := httptest.NewRequest(http.MethodPost, "/webauthn/login/finalize", strings.NewReader(body))
rec2 := httptest.NewRecorder()
e.ServeHTTP(rec2, req2)
if s.Equal(http.StatusUnauthorized, rec2.Code) {
httpError := echo.HTTPError{}
err = json.Unmarshal(rec2.Body.Bytes(), &httpError)
s.NoError(err)
s.Equal("Stored challenge and received challenge do not match", httpError.Message)
}
}
func (s *webauthnSuite) TestWebauthnHandler_FinalizeAuthentication_SessionDataExpired() {
if testing.Short() {
s.T().Skip("skipping test in short mode")
}
err := s.LoadFixtures("../test/fixtures/webauthn")
s.Require().NoError(err)
e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil)
body := `{
"id": "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM",
"rawId": "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM",
"type": "public-key",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAABA",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiQVVnTmtWOG5tVnd2LXJsOC1hSzFaRVg1RmxlbllDc2FUWTh2ZEVjSktKUSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODg4OCIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3RoZXJfa2V5c19jYW5fYmVfYWRkZWRfaGVyZSI6ImRvIG5vdCBjb21wYXJlIGNsaWVudERhdGFKU09OIGFnYWluc3QgYSB0ZW1wbGF0ZS4gU2VlIGh0dHBzOi8vZ29vLmdsL3lhYlBleCJ9",
"signature": "MEUCIQD46qneg2W9izFrJ-houyPia_QIcFR_9ZZIoWAqMywRmgIgMed7NAgcXkgCSWtVNknb_D70sn8b-fGwQQBlBCgIJ-c",
"userHandle": "RmJoNvLbTsCHUoWLVEy8eA"
}
}`
req := httptest.NewRequest(http.MethodPost, "/webauthn/login/finalize", strings.NewReader(body))
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
s.Equal(http.StatusUnauthorized, rec.Code)
}
func (s *webauthnSuite) TestWebauthnHandler_FinalizeAuthentication_TokenInHeader() {
if testing.Short() {
s.T().Skip("skipping test in short mode")
}
err := s.LoadFixtures("../test/fixtures/webauthn")
s.Require().NoError(err)
cfg := test.DefaultConfig
cfg.Session.EnableAuthTokenHeader = true
e := NewPublicRouter(&cfg, s.Storage, nil, nil)
body := `{
"id": "AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH",
"rawId": "AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH",
"type": "public-key",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFYmezOw",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiZ0tKS21oOTB2T3BZTzU1b0hwcWFIWF9vTUNxNG9UWnQtRDBiNnRlSXpyRSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"signature": "MEYCIQDi2vYVspG6pf38I4GyQCPOojGbvX4nwSPXCi0hm80twAIhAO3EWjhAnj0UpjU_l0AH5sEh3zq4LDvkvo3AUqaqfGYD",
"userHandle": "7E7wSVuIQyGhcyGw7_BqBA"
}
}`
req := httptest.NewRequest(http.MethodPost, "/webauthn/login/finalize", strings.NewReader(body))
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if s.Equal(http.StatusOK, rec.Code) {
s.Empty(rec.Result().Cookies())
token := rec.Header().Get("X-Auth-Token")
s.NotEmpty(token)
s.Regexp(".*\\..*\\..*", token)
s.Equal(`{"credential_id":"AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH","user_id":"ec4ef049-5b88-4321-a173-21b0eff06a04"}`, strings.TrimSpace(rec.Body.String()))
}
req2 := httptest.NewRequest(http.MethodPost, "/webauthn/login/finalize", strings.NewReader(body))
rec2 := httptest.NewRecorder()
e.ServeHTTP(rec2, req2)
if s.Equal(http.StatusUnauthorized, rec2.Code) {
httpError := echo.HTTPError{}
err = json.Unmarshal(rec2.Body.Bytes(), &httpError)
s.NoError(err)
s.Equal("Stored challenge and received challenge do not match", httpError.Message)
}
}
var userId = "ec4ef049-5b88-4321-a173-21b0eff06a04"
var uId, _ = uuid.FromString(userId)
var emails = []models.Email{
{
ID: uId,
Address: "john.doe@example.com",
PrimaryEmail: &models.PrimaryEmail{ID: uId},
},
}