WebAuthnLoginControllerTest.php 18 KB

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