Trog-Auth-totp-mandatory.t 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. #!/usr/bin/env perl
  2. # TOTP is not optional here. These tests are about the window between "logged
  3. # in" and "enrolled", which exists because a login is what gets you the QR --
  4. # and about the fact that there is no longer any way to turn 2fa back off.
  5. use 5.040;
  6. use re '/aasx';
  7. use warnings FATAL => 'all';
  8. use FindBin;
  9. use FindBin::libs;
  10. use Test::More;
  11. use Test::MockModule qw{strict};
  12. our ( $REPO, $ROOT, $OLDCWD );
  13. BEGIN {
  14. require Cwd;
  15. require File::Copy;
  16. require File::Path;
  17. require File::Temp;
  18. $REPO = Cwd::abs_path("$FindBin::Bin/..");
  19. $OLDCWD = Cwd::getcwd();
  20. $ROOT = File::Temp::tempdir( 'tcms-totp-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
  21. # Every path in this app is cwd relative, the auth db included, so the tests
  22. # stand in an instance root of their own rather than in the checkout. This
  23. # is a BEGIN because TCMS.pm builds its routing table at file scope, against
  24. # whatever cwd is current when it loads.
  25. #
  26. # Text::Xslate works out its cache directory from HOME once, at load time.
  27. $ENV{HOME} = $ROOT;
  28. File::Path::make_path( map { "$ROOT/$_" } qw{config schema data/files logs totp www/statics www/themes} );
  29. # Hardcoded paths in Auth.pm, TagIndex.pm, the data models and Log.pm.
  30. File::Copy::copy( "$REPO/schema/$_", "$ROOT/schema/$_" ) or die $! for qw{auth.schema flatfile.schema log.schema};
  31. open( my $fh, '>', "$ROOT/config/default.cfg" ) or die $!;
  32. print {$fh} "[general]\n data_model=FlatFile\n hostname=totp.example.com\n";
  33. close($fh) or die $!;
  34. # Otherwise login() reads the instance as a fresh install and registers
  35. # whoever knocks first as the admin, which is a different test.
  36. open( my $flag, '>', "$ROOT/config/has_users" ) or die $!;
  37. close($flag) or die $!;
  38. chdir($ROOT) or die "could not chdir to the sandbox: $!";
  39. }
  40. END {
  41. chdir($OLDCWD) if $OLDCWD;
  42. }
  43. require_ok('Trog::Auth') or BAIL_OUT("Can't find SUT");
  44. require_ok('TCMS') or BAIL_OUT("Can't load the dispatcher");
  45. require_ok('Trog::Routes::HTML');
  46. Trog::Auth::useradd( 'bob', 'Bob Bobson', 'hunter2', ['admin'], 'bob@example.com' )
  47. or BAIL_OUT('could not make a user to test with');
  48. subtest 'a user starts out unenrolled' => sub {
  49. is( Trog::Auth::has_totp('bob'), 0, 'a fresh user has no second factor' );
  50. is( Trog::Auth::has_totp('nobody-at-all'), 0, 'nor does somebody who does not exist' );
  51. # Which is the only reason logging in without a token works at all: it is
  52. # how you get to the page that gives you one.
  53. ok( Trog::Auth::mksession( 'bob', 'hunter2', '', '127.0.0.1' ), 'and can still log in, with no token' );
  54. };
  55. subtest 'and cannot go anywhere but /totp until they enrol' => sub {
  56. my %page = ( auth => 1 );
  57. my %public;
  58. my %enrol = ( auth => 1, totp_exempt => 1 );
  59. is( TCMS::needs_enrolment( 'bob', \%page ), 1, 'a page behind a login is refused' );
  60. is( TCMS::needs_enrolment( 'bob', \%enrol ), 0, 'the enrolment page is not, or there would be nowhere to go' );
  61. is( TCMS::needs_enrolment( 'bob', \%public ), 0, 'and a public page is nobody\'s business' );
  62. # Nothing to enrol, and nothing to protect.
  63. is( TCMS::needs_enrolment( '', \%page ), 0, 'a logged out visitor is left alone' );
  64. my ( $uri, undef, undef, $message ) = Trog::Auth::totp( 'bob', 'totp.example.com' );
  65. ok( $uri, 'enrolling gives them a URI' ) or diag($message);
  66. is( Trog::Auth::has_totp('bob'), 1, 'and they are enrolled' );
  67. is( TCMS::needs_enrolment( 'bob', \%page ), 0, 'after which every page opens up' );
  68. };
  69. subtest 'the enrolment does not change' => sub {
  70. # A user who lost their authenticator scans the same code into the next one,
  71. # which is what makes having no way to turn 2fa off a supportable position.
  72. my ( $uri, $qr, $failure ) = Trog::Auth::totp( 'bob', 'totp.example.com' );
  73. my ( $again, $qr2 ) = Trog::Auth::totp( 'bob', 'totp.example.com' );
  74. is( $again, $uri, 'asking again gives back the enrolment they already have' );
  75. is( $qr2, $qr, 'and the same QR' );
  76. is( $failure, -1, 'reported as nothing new having been generated' );
  77. };
  78. subtest 'and there is no way to turn it off' => sub {
  79. ok( !Trog::Auth->can('clear_totp'), 'Trog::Auth cannot clear a secret' );
  80. ok( !Trog::Routes::HTML->can('do_totp_clear'), 'nor is there a route handler to ask it to' );
  81. ok( !exists $Trog::Routes::HTML::routes{'/request_totp_clear'}, 'nor a route' );
  82. # The two the gate lets through, which is the whole of the exemption.
  83. ok( $Trog::Routes::HTML::routes{'/totp'}{totp_exempt}, 'the enrolment page says it is exempt' );
  84. ok( $Trog::Routes::HTML::routes{'/totp_qr/(.*)'}{totp_exempt}, 'and so does the QR on it' );
  85. my @exempt = grep { $Trog::Routes::HTML::routes{$_}{totp_exempt} } keys(%Trog::Routes::HTML::routes);
  86. is( scalar(@exempt), 2, 'and nothing else is' );
  87. };
  88. subtest 'a login without an enrolment is sent to enrol' => sub {
  89. my $tpsgi = bless {}, 'FakeTPSGI';
  90. {
  91. no warnings qw{once};
  92. *FakeTPSGI::see_also = sub { return [ 303, [ Location => $_[1] ], [''] ] };
  93. }
  94. # login() renders rather than redirects on success -- the redirect is the
  95. # 'to' the page then sends the browser to -- so that is what to look at.
  96. my $rendered;
  97. my $renderer = Test::MockModule->new('Trog::Renderer');
  98. $renderer->redefine( render => sub { my ( $class, %args ) = @_; $rendered = $args{data}; return [ 200, [], [''] ] } );
  99. my $auth = Test::MockModule->new('Trog::Auth');
  100. $auth->redefine( mksession => sub { return 'a-session' } );
  101. $auth->redefine( has_totp => sub { return 0 } );
  102. Trog::Routes::HTML::login( { username => 'bob', password => 'hunter2', to => '/config', tpsgi => $tpsgi, scheme => 'http', route => '/auth' } );
  103. is( $rendered->{to}, '/totp', 'a login with nothing enrolled goes to enrol, not where it was headed' );
  104. $auth->redefine( has_totp => sub { return 1 } );
  105. Trog::Routes::HTML::login( { username => 'bob', password => 'hunter2', to => '/config', tpsgi => $tpsgi, scheme => 'http', route => '/auth' } );
  106. is( $rendered->{to}, '/config', 'and one with a second factor goes where it was headed' );
  107. };
  108. done_testing();