WebAuthnLoginControllerTest.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. <?php
  2. namespace Tests\Feature\Http\Auth;
  3. use App\Extensions\WebauthnTwoFAuthUserProvider;
  4. use App\Http\Controllers\Auth\WebAuthnLoginController;
  5. use App\Models\User;
  6. use Illuminate\Support\Facades\Config;
  7. use Illuminate\Support\Facades\DB;
  8. use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
  9. use Laragear\WebAuthn\WebAuthn;
  10. use PHPUnit\Framework\Attributes\CoversClass;
  11. use Tests\FeatureTestCase;
  12. /**
  13. * WebAuthnLoginControllerTest test class
  14. */
  15. #[CoversClass(WebAuthnLoginController::class)]
  16. #[CoversClass(User::class)]
  17. #[CoversClass(WebauthnTwoFAuthUserProvider::class)]
  18. class WebAuthnLoginControllerTest extends FeatureTestCase
  19. {
  20. /**
  21. * @var \App\Models\User
  22. */
  23. protected $user;
  24. const CREDENTIAL_ID = 's06aG41wsIYh5X1YUhB-SlH8y3F2RzdJZVse8iXRXOCd3oqQdEyCOsBawzxrYBtJRQA2azAMEN_q19TUp6iMgg';
  25. const CREDENTIAL_ID_ALT = '-VOLFKPY-_FuMI_sJ7gMllK76L3VoRUINj6lL_Z3qDg';
  26. const CREDENTIAL_ID_ALT_RAW = '+VOLFKPY+/FuMI/sJ7gMllK76L3VoRUINj6lL/Z3qDg=';
  27. const PUBLIC_KEY = 'eyJpdiI6ImYyUHlJOEJML0pwTXJ2UDkveTQwZFE9PSIsInZhbHVlIjoiQWFSYi9LVEszazlBRUZsWHp0cGNRNktGeEQ3aTBsbU9zZ1g5MEgrWFJJNmgraElsNU9hV0VsRVlWc3NoUVVHUjRRdlcxTS9pVklnOWtVYWY5TFJQTTFhR1Rxb1ZzTFkxTWE4VUVvK1lyU3pYQ1M3VlBMWWxZcDVaYWFnK25iaXVyWGR6ZFRmMFVoSmdPZ3UvSnptbVZER0FYdEEyYmNYcW43RkV5aTVqSjNwZEFsUjhUYSs0YjU2Z2V2bUJXa0E0aVB1VC8xSjdJZ2llRGlHY2RwOGk3MmNPTyt6eDFDWUs1dVBOSWp1ZUFSeUlkclgwRW16RE9sUUpDSWV6Sk50TSIsIm1hYyI6IjI3ODQ5NzcxZGY1MzMwYTNiZjAwZmEwMDJkZjYzMGU4N2UzZjZlOGM0ZWE3NDkyYWMxMThhNmE5NWZiMTVjNGEiLCJ0YWciOiIifQ==';
  28. const USER_ID = '3b758ac868b74307a7e96e69ae187339';
  29. const USER_ID_ALT = 'e8af6f703f8042aa91c30cf72289aa07';
  30. const EMAIL = 'john.doe@example.com';
  31. const ASSERTION_RESPONSE = [
  32. 'id' => self::CREDENTIAL_ID_ALT,
  33. 'rawId' => self::CREDENTIAL_ID_ALT_RAW,
  34. 'type' => 'public-key',
  35. 'response' => [
  36. 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
  37. 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
  38. 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
  39. 'userHandle' => self::USER_ID_ALT,
  40. ],
  41. 'email' => self::EMAIL,
  42. ];
  43. const ASSERTION_RESPONSE_NO_HANDLE = [
  44. 'id' => self::CREDENTIAL_ID_ALT,
  45. 'rawId' => self::CREDENTIAL_ID_ALT_RAW,
  46. 'type' => 'public-key',
  47. 'response' => [
  48. 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
  49. 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
  50. 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
  51. 'userHandle' => null,
  52. ],
  53. 'email' => self::EMAIL,
  54. ];
  55. const ASSERTION_RESPONSE_INVALID = [
  56. 'id' => self::CREDENTIAL_ID_ALT,
  57. 'rawId' => self::CREDENTIAL_ID_ALT_RAW,
  58. 'type' => 'public-key',
  59. 'response' => [
  60. 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
  61. 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
  62. 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
  63. 'userHandle' => self::USER_ID_ALT,
  64. ],
  65. 'email' => self::EMAIL,
  66. ];
  67. const ASSERTION_CHALLENGE = 'iXozmynKi+YD2iRvKNbSPA==';
  68. /**
  69. * @test
  70. */
  71. public function setUp() : void
  72. {
  73. parent::setUp();
  74. DB::table('users')->delete();
  75. }
  76. /**
  77. * @test
  78. */
  79. public function test_webauthn_login_returns_success()
  80. {
  81. $this->user = User::factory()->create(['email' => self::EMAIL]);
  82. DB::table('webauthn_credentials')->insert([
  83. 'id' => self::CREDENTIAL_ID_ALT,
  84. 'authenticatable_type' => \App\Models\User::class,
  85. 'authenticatable_id' => $this->user->id,
  86. 'user_id' => self::USER_ID_ALT,
  87. 'counter' => 0,
  88. 'rp_id' => 'http://localhost',
  89. 'origin' => 'http://localhost',
  90. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  91. 'attestation_format' => 'none',
  92. 'public_key' => self::PUBLIC_KEY,
  93. 'updated_at' => now(),
  94. 'created_at' => now(),
  95. ]);
  96. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  97. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  98. 60,
  99. false,
  100. )]);
  101. $this->mock(AssertionValidator::class)
  102. ->expects('send->thenReturn')
  103. ->andReturn();
  104. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
  105. ->assertOk()
  106. ->assertJsonFragment([
  107. 'message' => 'authenticated',
  108. 'name' => $this->user->name,
  109. ])
  110. ->assertJsonStructure([
  111. 'message',
  112. 'name',
  113. 'preferences',
  114. ]);
  115. }
  116. /**
  117. * @test
  118. */
  119. public function test_webauthn_login_merge_handle_if_missing()
  120. {
  121. $this->user = User::factory()->create(['email' => self::EMAIL]);
  122. DB::table('webauthn_credentials')->insert([
  123. 'id' => self::CREDENTIAL_ID_ALT,
  124. 'authenticatable_type' => \App\Models\User::class,
  125. 'authenticatable_id' => $this->user->id,
  126. 'user_id' => self::USER_ID_ALT,
  127. 'counter' => 0,
  128. 'rp_id' => 'http://localhost',
  129. 'origin' => 'http://localhost',
  130. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  131. 'attestation_format' => 'none',
  132. 'public_key' => self::PUBLIC_KEY,
  133. 'updated_at' => now(),
  134. 'created_at' => now(),
  135. ]);
  136. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  137. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  138. 60,
  139. false,
  140. )]);
  141. $this->mock(AssertionValidator::class)
  142. ->expects('send->thenReturn')
  143. ->andReturn();
  144. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_NO_HANDLE)
  145. ->assertOk()
  146. ->assertJsonFragment([
  147. 'message' => 'authenticated',
  148. 'name' => $this->user->name,
  149. ])
  150. ->assertJsonStructure([
  151. 'message',
  152. 'name',
  153. 'preferences',
  154. ]);
  155. }
  156. /**
  157. * @test
  158. */
  159. public function test_legacy_login_is_rejected_when_webauthn_only_is_enable()
  160. {
  161. $this->user = User::factory()->create([
  162. 'email' => self::EMAIL,
  163. ]);
  164. // Set to webauthn only
  165. $this->user['preferences->useWebauthnOnly'] = true;
  166. $this->user->save();
  167. $response = $this->json('POST', '/user/login', [
  168. 'email' => self::EMAIL,
  169. 'password' => 'password',
  170. ])
  171. ->assertUnauthorized();
  172. }
  173. /**
  174. * @test
  175. *
  176. * @covers \App\Http\Middleware\SkipIfAuthenticated
  177. */
  178. public function test_webauthn_login_already_authenticated_is_rejected()
  179. {
  180. $this->user = User::factory()->create(['email' => self::EMAIL]);
  181. DB::table('webauthn_credentials')->insert([
  182. 'id' => self::CREDENTIAL_ID_ALT,
  183. 'authenticatable_type' => \App\Models\User::class,
  184. 'authenticatable_id' => $this->user->id,
  185. 'user_id' => self::USER_ID_ALT,
  186. 'counter' => 0,
  187. 'rp_id' => 'http://localhost',
  188. 'origin' => 'http://localhost',
  189. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  190. 'attestation_format' => 'none',
  191. 'public_key' => self::PUBLIC_KEY,
  192. 'updated_at' => now(),
  193. 'created_at' => now(),
  194. ]);
  195. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  196. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  197. 60,
  198. false,
  199. )]);
  200. $this->mock(AssertionValidator::class)
  201. ->expects('send->thenReturn')
  202. ->andReturn();
  203. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
  204. ->assertOk();
  205. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
  206. ->assertStatus(400)
  207. ->assertJsonStructure([
  208. 'message',
  209. ]);
  210. }
  211. /**
  212. * @test
  213. */
  214. public function test_webauthn_login_with_missing_data_returns_validation_error()
  215. {
  216. $this->user = User::factory()->create(['email' => self::EMAIL]);
  217. $data = [
  218. 'id' => '',
  219. 'rawId' => '',
  220. 'type' => '',
  221. 'response' => [
  222. 'authenticatorData' => '',
  223. 'clientDataJSON' => '',
  224. 'signature' => '',
  225. 'userHandle' => null,
  226. ],
  227. ];
  228. $response = $this->json('POST', '/webauthn/login', $data)
  229. ->assertStatus(422)
  230. ->assertJsonValidationErrors([
  231. 'id',
  232. 'rawId',
  233. 'type',
  234. 'response.authenticatorData',
  235. 'response.clientDataJSON',
  236. 'response.signature',
  237. ]);
  238. }
  239. /**
  240. * @test
  241. */
  242. public function test_webauthn_invalid_login_returns_unauthorized()
  243. {
  244. $this->user = User::factory()->create(['email' => self::EMAIL]);
  245. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  246. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  247. 60,
  248. false,
  249. )]);
  250. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID)
  251. ->assertUnauthorized();
  252. }
  253. /**
  254. * @test
  255. */
  256. public function test_too_many_invalid_login_attempts_returns_too_many_request_error()
  257. {
  258. $throttle = 8;
  259. Config::set('auth.throttle.login', $throttle);
  260. $this->user = User::factory()->create(['email' => self::EMAIL]);
  261. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  262. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  263. 60,
  264. false,
  265. )]);
  266. for ($i = 0; $i < $throttle - 1; $i++) {
  267. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
  268. }
  269. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID)
  270. ->assertUnauthorized();
  271. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID)
  272. ->assertStatus(429);
  273. }
  274. /**
  275. * @test
  276. */
  277. public function test_get_options_returns_success()
  278. {
  279. Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_PREFERRED);
  280. $this->user = User::factory()->create(['email' => self::EMAIL]);
  281. DB::table('webauthn_credentials')->insert([
  282. 'id' => self::CREDENTIAL_ID,
  283. 'authenticatable_type' => \App\Models\User::class,
  284. 'authenticatable_id' => $this->user->id,
  285. 'user_id' => self::USER_ID,
  286. 'counter' => 0,
  287. 'rp_id' => 'http://localhost',
  288. 'origin' => 'http://localhost',
  289. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  290. 'attestation_format' => 'none',
  291. 'public_key' => self::PUBLIC_KEY,
  292. 'updated_at' => now(),
  293. 'created_at' => now(),
  294. ]);
  295. $response = $this->json('POST', '/webauthn/login/options', [
  296. 'email' => $this->user->email,
  297. ])
  298. ->assertOk()
  299. ->assertJsonStructure([
  300. 'challenge',
  301. 'timeout',
  302. ])
  303. ->assertJsonFragment([
  304. 'allowCredentials' => [[
  305. 'id' => self::CREDENTIAL_ID,
  306. 'type' => 'public-key',
  307. ]],
  308. ]);
  309. }
  310. /**
  311. * @test
  312. */
  313. public function test_get_options_for_securelogin_returns_required_userVerification()
  314. {
  315. Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_REQUIRED);
  316. $this->user = User::factory()->create(['email' => self::EMAIL]);
  317. DB::table('webauthn_credentials')->insert([
  318. 'id' => self::CREDENTIAL_ID,
  319. 'authenticatable_type' => \App\Models\User::class,
  320. 'authenticatable_id' => $this->user->id,
  321. 'user_id' => self::USER_ID,
  322. 'counter' => 0,
  323. 'rp_id' => 'http://localhost',
  324. 'origin' => 'http://localhost',
  325. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  326. 'attestation_format' => 'none',
  327. 'public_key' => self::PUBLIC_KEY,
  328. 'updated_at' => now(),
  329. 'created_at' => now(),
  330. ]);
  331. $response = $this->json('POST', '/webauthn/login/options', [
  332. 'email' => $this->user->email,
  333. ])
  334. ->assertOk()
  335. ->assertJsonStructure([
  336. 'challenge',
  337. 'userVerification',
  338. 'timeout',
  339. ])
  340. ->assertJsonFragment([
  341. 'userVerification' => 'required',
  342. 'allowCredentials' => [[
  343. 'id' => self::CREDENTIAL_ID,
  344. 'type' => 'public-key',
  345. ]],
  346. ]);
  347. }
  348. /**
  349. * @test
  350. */
  351. public function test_get_options_for_fastlogin_returns_discouraged_userVerification()
  352. {
  353. Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_DISCOURAGED);
  354. $this->user = User::factory()->create(['email' => self::EMAIL]);
  355. DB::table('webauthn_credentials')->insert([
  356. 'id' => self::CREDENTIAL_ID,
  357. 'authenticatable_type' => \App\Models\User::class,
  358. 'authenticatable_id' => $this->user->id,
  359. 'user_id' => self::USER_ID,
  360. 'counter' => 0,
  361. 'rp_id' => 'http://localhost',
  362. 'origin' => 'http://localhost',
  363. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  364. 'attestation_format' => 'none',
  365. 'public_key' => self::PUBLIC_KEY,
  366. 'updated_at' => now(),
  367. 'created_at' => now(),
  368. ]);
  369. $response = $this->json('POST', '/webauthn/login/options', [
  370. 'email' => $this->user->email,
  371. ])
  372. ->assertOk()
  373. ->assertJsonStructure([
  374. 'challenge',
  375. 'userVerification',
  376. 'timeout',
  377. ])
  378. ->assertJsonFragment([
  379. 'userVerification' => 'discouraged',
  380. 'allowCredentials' => [[
  381. 'id' => self::CREDENTIAL_ID,
  382. 'type' => 'public-key',
  383. ]],
  384. ]);
  385. }
  386. /**
  387. * @test
  388. */
  389. public function test_get_options_with_capitalized_email_returns_success()
  390. {
  391. $this->user = User::factory()->create(['email' => self::EMAIL]);
  392. $this->json('POST', '/webauthn/login/options', [
  393. 'email' => strtoupper($this->user->email),
  394. ])
  395. ->assertOk();
  396. }
  397. /**
  398. * @test
  399. */
  400. public function test_get_options_with_missing_email_returns_validation_errors()
  401. {
  402. $this->json('POST', '/webauthn/login/options', [
  403. 'email' => null,
  404. ])
  405. ->assertStatus(422)
  406. ->assertJsonValidationErrors([
  407. 'email',
  408. ]);
  409. }
  410. /**
  411. * @test
  412. */
  413. public function test_get_options_with_invalid_email_returns_validation_errors()
  414. {
  415. $this->json('POST', '/webauthn/login/options', [
  416. 'email' => 'invalid',
  417. ])
  418. ->assertStatus(422)
  419. ->assertJsonValidationErrors([
  420. 'email',
  421. ]);
  422. }
  423. /**
  424. * @test
  425. */
  426. public function test_get_options_with_unknown_email_returns_validation_errors()
  427. {
  428. $this->json('POST', '/webauthn/login/options', [
  429. 'email' => 'john@example.com',
  430. ])
  431. ->assertStatus(422)
  432. ->assertJsonValidationErrors([
  433. 'email',
  434. ]);
  435. }
  436. }