Files
pocket-id/backend/internal/devicelogin/service_test.go
T

611 lines
22 KiB
Go

//go:build exclude_frontend && unit
package devicelogin
import (
"context"
"errors"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
const (
testSessionDuration = time.Hour
)
type fakeReauthenticationTokenConsumer struct {
expectedValue string
createdAt time.Time
}
func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) {
if token != f.expectedValue {
return time.Time{}, apperror.ReauthenticationRequired()
}
if !f.createdAt.IsZero() {
return f.createdAt, nil
}
return time.Now(), nil
}
type fakeTokenService struct {
mu sync.Mutex
userID string
authenticationMethod string
sessionDuration time.Duration
generated int
err error
}
func (f *fakeTokenService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.userID = user.ID
f.authenticationMethod = authenticationMethod
f.sessionDuration = sessionDuration
f.generated++
if f.err != nil {
return "", f.err
}
return "device-login-access-token", nil
}
func (f *fakeTokenService) generatedToken() (string, string, time.Duration, int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.userID, f.authenticationMethod, f.sessionDuration, f.generated
}
type auditEntry struct {
event auditlogs.Event
ipAddress string
userAgent string
userID string
}
type fakeAuditLogger struct {
mu sync.Mutex
entries []auditEntry
notifications []auditlogs.SignInResult
}
type fakeIPLocationResolver struct {
country string
city string
err error
}
func (f *fakeIPLocationResolver) GetLocationByIP(context.Context, string) (string, string, error) {
return f.country, f.city, f.err
}
func (f *fakeAuditLogger) Create(_ context.Context, event auditlogs.Event, ipAddress, userAgent, userID string, _ auditlogs.Data, _ *gorm.DB) (auditlogs.AuditLog, bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.entries = append(f.entries, auditEntry{event: event, ipAddress: ipAddress, userAgent: userAgent, userID: userID})
return auditlogs.AuditLog{}, true
}
func (f *fakeAuditLogger) DeviceStringFromUserAgent(userAgent string) string {
return "Parsed " + userAgent
}
func (f *fakeAuditLogger) lastEntry() auditEntry {
f.mu.Lock()
defer f.mu.Unlock()
return f.entries[len(f.entries)-1]
}
func (f *fakeAuditLogger) entryCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.entries)
}
type serviceFixture struct {
service *Service
actors *actor.Service
signer *fakeTokenService
auditLog *fakeAuditLogger
reauth *fakeReauthenticationTokenConsumer
}
func TestRequestLifecycle(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fixture := newServiceFixture(t, db)
user := model.User{
Base: model.Base{ID: "device-login-user"},
Username: "device-login-user",
}
require.NoError(t, db.Create(&user).Error)
request, deviceToken, err := fixture.service.Create(t.Context(), "192.0.2.10", "Mozilla/5.0 Chrome/125.0.0.0")
require.NoError(t, err)
require.Regexp(t, `^P[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]{7}$`, request.Code)
require.Equal(t, request.Code, request.ID)
require.Equal(t, RequestStatusPending, request.Status)
state := getRequestActorState(t, fixture.actors, request.ID)
require.Equal(t, utils.CreateSha256Hash(deviceToken), state.DeviceTokenHash)
require.NotEqual(t, deviceToken, state.DeviceTokenHash)
require.Equal(t, RequestStatusPending, state.Status)
info, err := fixture.service.Inspect(t.Context(), strings.ToLower(request.Code))
require.NoError(t, err)
require.Equal(t, request.Code, info.UserCode)
require.Equal(t, "192.0.2.10", info.IPAddress)
require.Equal(t, "Parsed Mozilla/5.0 Chrome/125.0.0.0", info.Device)
require.Equal(t, "Switzerland", info.Country)
require.Equal(t, "Zurich", info.City)
err = fixture.service.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof")
require.NoError(t, err)
exchangedUser, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent", "", testSessionDuration, appconfig.LoginNotificationBrowserRecognition)
require.NoError(t, err)
require.Equal(t, RequestStatusApproved, status)
require.Equal(t, user.ID, exchangedUser.ID)
require.Equal(t, "device-login-access-token", accessToken.AccessToken)
require.Len(t, fixture.auditLog.notifications, 1)
require.True(t, fixture.auditLog.notifications[0].Notify)
require.Equal(t, auditlogs.EventRemoteSignIn, fixture.auditLog.notifications[0].AuditLog.Event)
signedUserID, authenticationMethod, sessionDuration, generated := fixture.signer.generatedToken()
require.Equal(t, user.ID, signedUserID)
require.Equal(t, authenticationMethodOneTimePassword, authenticationMethod)
require.Equal(t, testSessionDuration, sessionDuration)
require.Equal(t, 1, generated)
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
entry := fixture.auditLog.lastEntry()
require.Equal(t, auditlogs.EventRemoteSignIn, entry.event)
require.Equal(t, "198.51.100.20", entry.ipAddress)
require.Equal(t, "target-agent", entry.userAgent)
require.Equal(t, user.ID, entry.userID)
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
_, _, _, generated = fixture.signer.generatedToken()
require.Equal(t, 1, generated)
require.Equal(t, 1, fixture.auditLog.entryCount())
}
func TestPendingAndDeniedRequests(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fixture := newServiceFixture(t, db)
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
result, err := fixture.service.peek(t.Context(), request.ID, requestActorMethodPoll, requestActorPollInput{
DeviceTokenHash: utils.CreateSha256Hash(deviceToken),
})
require.NoError(t, err)
require.Equal(t, RequestStatusPending, result.Status)
err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
require.NoError(t, err)
user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
require.Equal(t, RequestStatusDenied, status)
require.Empty(t, user.ID)
require.Empty(t, accessToken)
}
func TestPendingExchangeObservesDecisionDuringLongPoll(t *testing.T) {
db := testutils.NewConcurrentDatabaseForTest(t)
fixture := newServiceFixture(t, db)
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
type exchangeOutcome struct {
status RequestStatus
err error
}
result := make(chan exchangeOutcome, 1)
go func() {
_, _, status, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
result <- exchangeOutcome{status: status, err: exchangeErr}
}()
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
select {
case outcome := <-result:
require.True(t, apperror.IsCode(outcome.err, apperror.CodeDeviceLoginDenied))
require.Equal(t, RequestStatusDenied, outcome.status)
case <-time.After(2 * time.Second):
t.Fatal("exchange did not observe the actor decision")
}
}
func TestRejectsInvalidAndExpiredRequestsWhileActorIsActive(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fixture := newServiceFixture(t, db)
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
unknownRequestID := strings.Repeat("a", 64)
_, _, _, err = fixture.service.Exchange(t.Context(), unknownRequestID, "device-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
_, err = fixture.service.Inspect(t.Context(), unknownRequestID)
assertInvalidRequestError(t, err)
err = fixture.service.Decide(t.Context(), unknownRequestID, "deny", "device-login-user", "")
assertInvalidRequestError(t, err)
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
state := getRequestActorState(t, fixture.actors, request.ID)
state.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, fixture.actors.Halt(requestActorType, request.ID))
require.NoError(t, fixture.actors.SetState(t.Context(), requestActorType, request.ID, state, nil))
_, err = fixture.service.Inspect(t.Context(), request.Code)
assertInvalidRequestError(t, err)
err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
assertInvalidRequestError(t, err)
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
}
func TestRejectsDisabledUserAtExchange(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fixture := newServiceFixture(t, db)
user := model.User{
Base: model.Base{ID: "disabled-device-login-user"},
Username: "disabled-device-login-user",
Disabled: true,
}
require.NoError(t, db.Create(&user).Error)
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
_, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
require.Empty(t, accessToken)
require.Equal(t, RequestStatusApproved, getRequestActorState(t, fixture.actors, request.ID).Status)
}
func TestFailedTokenGenerationConsumesApprovedRequest(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fixture := newServiceFixture(t, db)
fixture.signer.err = errors.New("token generation failed")
user := model.User{
Base: model.Base{ID: "token-failure-device-login-user"},
Username: "token-failure-device-login-user",
}
require.NoError(t, db.Create(&user).Error)
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
_, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.EqualError(t, err, "token generation failed")
require.Empty(t, accessToken)
require.Equal(t, RequestStatusApproved, status)
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
require.Equal(t, 0, fixture.auditLog.entryCount())
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
}
func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fixture := newServiceFixture(t, db)
request, _, err := fixture.service.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "")
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
fixture.reauth.expectedValue = "stale-proof"
fixture.reauth.createdAt = time.Now().Add(-2 * time.Minute)
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof")
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
require.Equal(t, RequestStatusPending, getRequestActorState(t, fixture.actors, request.ID).Status)
}
func TestNormalizeUserCodeAliases(t *testing.T) {
require.Equal(t, "P100-110", normalizeUserCode(" piO0-i1o "))
}
func TestConcurrentExchangeAllowsOnlyOneSuccess(t *testing.T) {
db := testutils.NewConcurrentDatabaseForTest(t)
fixture := newServiceFixture(t, db)
user := model.User{
Base: model.Base{ID: "single-use-device-login-user"},
Username: "single-use-device-login-user",
}
require.NoError(t, db.Create(&user).Error)
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
var waitGroup sync.WaitGroup
type exchangeResult struct {
token string
err error
}
results := make(chan exchangeResult, 2)
for range 2 {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
_, token, _, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
results <- exchangeResult{token: token.AccessToken, err: exchangeErr}
}()
}
waitGroup.Wait()
close(results)
var successfulTokens []string
var invalidExchanges int
for result := range results {
if result.err == nil {
successfulTokens = append(successfulTokens, result.token)
continue
}
require.True(t, apperror.IsCode(result.err, apperror.CodeDeviceLoginExpired))
invalidExchanges++
}
require.Equal(t, []string{"device-login-access-token"}, successfulTokens)
require.Equal(t, 1, invalidExchanges)
require.Equal(t, 1, fixture.auditLog.entryCount())
_, _, _, generated := fixture.signer.generatedToken()
require.Equal(t, 1, generated)
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
}
func TestCreateCollisionPreservesOriginalActorState(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fixture := newServiceFixture(t, db)
request, deviceToken, err := fixture.service.Create(t.Context(), "192.0.2.1", "original-agent")
require.NoError(t, err)
original := getRequestActorState(t, fixture.actors, request.ID)
result, err := fixture.service.invoke(t.Context(), request.ID, requestActorMethodCreate, requestActorCreateInput{
Code: request.Code,
DeviceTokenHash: utils.CreateSha256Hash("different-token"),
IPAddress: "198.51.100.1",
UserAgent: "replacement-agent",
})
require.NoError(t, err)
require.Equal(t, requestActorResultCollision, result.Code)
require.Equal(t, original, getRequestActorState(t, fixture.actors, request.ID))
require.NotEqual(t, deviceToken, original.DeviceTokenHash)
}
func TestRequestStateSurvivesActorHostRestart(t *testing.T) {
db := testutils.NewConcurrentDatabaseForTest(t)
deps := persistentTestDependencies(db)
firstModule, stopFirst := startPersistentDeviceLoginHost(t, db, deps)
request, deviceToken, err := firstModule.service.Create(t.Context(), "192.0.2.1", "persistent-agent")
require.NoError(t, err)
stopFirst()
secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps)
defer stopSecond()
info, err := secondModule.service.Inspect(t.Context(), request.Code)
require.NoError(t, err)
require.Equal(t, "persistent-agent", strings.TrimPrefix(info.Device, "Parsed "))
require.NoError(t, secondModule.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
_, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
require.Equal(t, RequestStatusDenied, status)
}
func TestCompletedExchangeIsInvalidAfterActorHostRestart(t *testing.T) {
db := testutils.NewConcurrentDatabaseForTest(t)
deps := persistentTestDependencies(db)
user := model.User{
Base: model.Base{ID: "completed-exchange-user"},
Username: "completed-exchange-user",
}
require.NoError(t, db.Create(&user).Error)
firstModule, stopFirst := startPersistentDeviceLoginHost(t, db, deps)
request, deviceToken, err := firstModule.service.Create(t.Context(), "", "persistent-agent")
require.NoError(t, err)
require.NoError(t, firstModule.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
_, _, firstStatus, err := firstModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.NoError(t, err)
require.Equal(t, RequestStatusApproved, firstStatus)
stopFirst()
secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps)
defer stopSecond()
_, _, _, err = secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
_, _, _, generated := deps.Signer.(*fakeTokenService).generatedToken()
require.Equal(t, 1, generated)
require.Equal(t, 1, deps.AuditLog.(*fakeAuditLogger).entryCount())
}
func TestPendingExchangeStopsWhenRequestIsCanceled(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fixture := newServiceFixture(t, db)
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
ctx, cancel := context.WithCancel(t.Context())
started := make(chan struct{})
result := make(chan error, 1)
go func() {
close(started)
_, _, _, exchangeErr := fixture.service.Exchange(ctx, request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
result <- exchangeErr
}()
<-started
cancel()
select {
case exchangeErr := <-result:
require.ErrorIs(t, exchangeErr, context.Canceled)
case <-time.After(time.Second):
t.Fatal("canceled exchange did not stop")
}
}
func newServiceFixture(t *testing.T, db *gorm.DB) serviceFixture {
t.Helper()
signer := &fakeTokenService{}
auditLog := &fakeAuditLogger{}
ipLocator := &fakeIPLocationResolver{country: "Switzerland", city: "Zurich"}
reauth := &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"}
var module *Module
host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
var err error
module, err = New(Dependencies{
DB: db,
Actors: host,
Signer: signer,
AuditLog: auditLog,
IPLocator: ipLocator,
Reauth: reauth,
})
require.NoError(t, err)
})
return serviceFixture{
service: module.service,
actors: host.Service(),
signer: signer,
auditLog: auditLog,
reauth: reauth,
}
}
func getRequestActorState(t *testing.T, actors *actor.Service, actorID string) requestActorState {
t.Helper()
var state requestActorState
require.NoError(t, actors.GetState(t.Context(), requestActorType, actorID, &state))
return state
}
func requireRequestActorStateDeleted(t *testing.T, actors *actor.Service, actorID string) {
t.Helper()
var state requestActorState
require.ErrorIs(t, actors.GetState(t.Context(), requestActorType, actorID, &state), actor.ErrStateNotFound)
}
func assertInvalidRequestError(t *testing.T, err error) {
t.Helper()
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginExpired))
}
func persistentTestDependencies(db *gorm.DB) Dependencies {
return Dependencies{
DB: db,
Signer: &fakeTokenService{},
AuditLog: &fakeAuditLogger{},
IPLocator: &fakeIPLocationResolver{},
Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"},
}
}
func startPersistentDeviceLoginHost(t *testing.T, db *gorm.DB, deps Dependencies) (*Module, func()) {
t.Helper()
sqlDB, err := db.DB()
require.NoError(t, err)
_, err = sqlDB.Exec("PRAGMA foreign_keys = ON")
require.NoError(t, err)
host, err := local.NewHost(
local.WithAddress(freeLoopbackAddress(t)),
local.WithRuntimePSKs([]byte("pocket-id-device-login-test-host-psk")),
local.WithSQLiteProvider(local.SQLiteProviderOptions{DB: sqlDB}),
local.WithShutdownGracePeriod(time.Second),
)
require.NoError(t, err)
deps.Actors = host
module, err := New(deps)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- host.Run(ctx)
}()
select {
case <-host.Ready():
case runErr := <-errCh:
t.Fatalf("persistent actor host stopped before becoming ready: %v", runErr)
case <-time.After(10 * time.Second):
t.Fatal("timed out waiting for persistent actor host")
}
var stopOnce sync.Once
stop := func() {
stopOnce.Do(func() {
cancel()
runErr := <-errCh
if runErr != nil && !errors.Is(runErr, context.Canceled) {
require.NoError(t, runErr)
}
})
}
t.Cleanup(stop)
return module, stop
}
func freeLoopbackAddress(t *testing.T) string {
t.Helper()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
address := listener.Addr().String()
require.NoError(t, listener.Close())
return address
}
func (f *fakeAuditLogger) CreateSignIn(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, mode appconfig.AppConfigValue) auditlogs.SignInResult {
entry, created := f.Create(ctx, event, ipAddress, userAgent, userID, auditlogs.Data{}, tx)
entry.Event = event
return auditlogs.SignInResult{AuditLog: entry, Created: created, Notify: mode != appconfig.LoginNotificationDisabled, KnownBrowserToken: "recognized-browser"}
}
func (f *fakeAuditLogger) SendSignInNotification(_ context.Context, result auditlogs.SignInResult) {
f.mu.Lock()
defer f.mu.Unlock()
f.notifications = append(f.notifications, result)
}