loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[FEAT] Add support for webauthn credential level 3

- For WebAuthn Credential level 3, the `backup_eligible` and
`backup_state` flags are checked if they are consistent with the values
given on login. Forgejo never stored this data, so add a database
migration that makes all webauthn credentials 'legacy' and on the next
first use capture the values of `backup_eligible` and `backup_state`.
As suggested in https://github.com/go-webauthn/webauthn/discussions/219#discussioncomment-10429662
- Adds unit tests.
- Add E2E test.

Gusted 63736e83 28c3f1e2

+131 -12
+20 -3
models/auth/webauthn.go
··· 40 40 } 41 41 42 42 // WebAuthnCredential represents the WebAuthn credential data for a public-key 43 - // credential conformant to WebAuthn Level 1 43 + // credential conformant to WebAuthn Level 3 44 44 type WebAuthnCredential struct { 45 45 ID int64 `xorm:"pk autoincr"` 46 46 Name string ··· 52 52 AAGUID []byte 53 53 SignCount uint32 `xorm:"BIGINT"` 54 54 CloneWarning bool 55 - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` 56 - UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` 55 + BackupEligible bool `XORM:"NOT NULL DEFAULT false"` 56 + BackupState bool `XORM:"NOT NULL DEFAULT false"` 57 + // If legacy is set to true, backup_eligible and backup_state isn't set. 58 + Legacy bool `XORM:"NOT NULL DEFAULT true"` 59 + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` 60 + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` 57 61 } 58 62 59 63 func init() { ··· 71 75 return err 72 76 } 73 77 78 + // UpdateFromLegacy update the values that aren't present on legacy credentials. 79 + func (cred *WebAuthnCredential) UpdateFromLegacy(ctx context.Context) error { 80 + _, err := db.GetEngine(ctx).ID(cred.ID).Cols("legacy", "backup_eligible", "backup_state").Update(cred) 81 + return err 82 + } 83 + 74 84 // BeforeInsert will be invoked by XORM before updating a record 75 85 func (cred *WebAuthnCredential) BeforeInsert() { 76 86 cred.LowerName = strings.ToLower(cred.Name) ··· 97 107 ID: cred.CredentialID, 98 108 PublicKey: cred.PublicKey, 99 109 AttestationType: cred.AttestationType, 110 + Flags: webauthn.CredentialFlags{ 111 + BackupEligible: cred.BackupEligible, 112 + BackupState: cred.BackupState, 113 + }, 100 114 Authenticator: webauthn.Authenticator{ 101 115 AAGUID: cred.AAGUID, 102 116 SignCount: cred.SignCount, ··· 167 181 AAGUID: cred.Authenticator.AAGUID, 168 182 SignCount: cred.Authenticator.SignCount, 169 183 CloneWarning: false, 184 + BackupEligible: cred.Flags.BackupEligible, 185 + BackupState: cred.Flags.BackupState, 186 + Legacy: false, 170 187 } 171 188 172 189 if err := db.Insert(ctx, c); err != nil {
+12 -2
models/auth/webauthn_test.go
··· 56 56 unittest.AssertExistsIf(t, true, &auth_model.WebAuthnCredential{ID: 1, SignCount: 0xffffffff}) 57 57 } 58 58 59 + func TestWebAuthenCredential_UpdateFromLegacy(t *testing.T) { 60 + require.NoError(t, unittest.PrepareTestDatabase()) 61 + cred := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{ID: 1, Legacy: true}) 62 + cred.Legacy = false 63 + cred.BackupEligible = true 64 + cred.BackupState = true 65 + require.NoError(t, cred.UpdateFromLegacy(db.DefaultContext)) 66 + unittest.AssertExistsIf(t, true, &auth_model.WebAuthnCredential{ID: 1, BackupEligible: true, BackupState: true}, "legacy = false") 67 + } 68 + 59 69 func TestCreateCredential(t *testing.T) { 60 70 require.NoError(t, unittest.PrepareTestDatabase()) 61 71 62 - res, err := auth_model.CreateCredential(db.DefaultContext, 1, "WebAuthn Created Credential", &webauthn.Credential{ID: []byte("Test")}) 72 + res, err := auth_model.CreateCredential(db.DefaultContext, 1, "WebAuthn Created Credential", &webauthn.Credential{ID: []byte("Test"), Flags: webauthn.CredentialFlags{BackupEligible: true, BackupState: true}}) 63 73 require.NoError(t, err) 64 74 assert.Equal(t, "WebAuthn Created Credential", res.Name) 65 75 assert.Equal(t, []byte("Test"), res.CredentialID) 66 76 67 - unittest.AssertExistsIf(t, true, &auth_model.WebAuthnCredential{Name: "WebAuthn Created Credential", UserID: 1}) 77 + unittest.AssertExistsIf(t, true, &auth_model.WebAuthnCredential{Name: "WebAuthn Created Credential", UserID: 1, BackupEligible: true, BackupState: true}, "legacy = false") 68 78 }
+1
models/fixtures/webauthn_credential.yml
··· 5 5 attestation_type: none 6 6 sign_count: 0 7 7 clone_warning: false 8 + legacy: true 8 9 created_unix: 946684800 9 10 updated_unix: 946684800
+2
models/forgejo_migrations/migrate.go
··· 80 80 NewMigration("Creating Quota-related tables", CreateQuotaTables), 81 81 // v21 -> v22 82 82 NewMigration("Add SSH keypair to `pull_mirror` table", AddSSHKeypairToPushMirror), 83 + // v22 -> v23 84 + NewMigration("Add `legacy` to `web_authn_credential` table", AddLegacyToWebAuthnCredential), 83 85 } 84 86 85 87 // GetCurrentDBVersion returns the current Forgejo database version.
+17
models/forgejo_migrations/v22.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgejo_migrations //nolint:revive 5 + 6 + import "xorm.io/xorm" 7 + 8 + func AddLegacyToWebAuthnCredential(x *xorm.Engine) error { 9 + type WebauthnCredential struct { 10 + ID int64 `xorm:"pk autoincr"` 11 + BackupEligible bool `xorm:"NOT NULL DEFAULT false"` 12 + BackupState bool `xorm:"NOT NULL DEFAULT false"` 13 + Legacy bool `xorm:"NOT NULL DEFAULT true"` 14 + } 15 + 16 + return x.Sync(&WebauthnCredential{}) 17 + }
+19 -7
routers/web/auth/webauthn.go
··· 116 116 return 117 117 } 118 118 119 + dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, parsedResponse.RawID) 120 + if err != nil { 121 + ctx.ServerError("GetWebAuthnCredentialByCredID", err) 122 + return 123 + } 124 + 125 + // If the credential is legacy, assume the values are correct. The 126 + // specification mandates these flags don't change. 127 + if dbCred.Legacy { 128 + dbCred.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() 129 + dbCred.BackupState = parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() 130 + dbCred.Legacy = false 131 + 132 + if err := dbCred.UpdateFromLegacy(ctx); err != nil { 133 + ctx.ServerError("UpdateFromLegacy", err) 134 + return 135 + } 136 + } 137 + 119 138 // Validate the parsed response. 120 139 cred, err := wa.WebAuthn.ValidateLogin((*wa.User)(user), *sessionData, parsedResponse) 121 140 if err != nil { ··· 130 149 if cred.Authenticator.CloneWarning { 131 150 log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr()) 132 151 ctx.Status(http.StatusForbidden) 133 - return 134 - } 135 - 136 - // Success! Get the credential and update the sign count with the new value we received. 137 - dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID) 138 - if err != nil { 139 - ctx.ServerError("GetWebAuthnCredentialByCredID", err) 140 152 return 141 153 } 142 154
+60
tests/e2e/webauthn.test.e2e.js
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + // @ts-check 4 + 5 + import {expect} from '@playwright/test'; 6 + import {test, login_user, load_logged_in_context} from './utils_e2e.js'; 7 + 8 + test.beforeAll(async ({browser}, workerInfo) => { 9 + await login_user(browser, workerInfo, 'user2'); 10 + }); 11 + 12 + test('WebAuthn register & login flow', async ({browser}, workerInfo) => { 13 + test.skip(workerInfo.project.name !== 'chromium', 'Uses Chrome protocol'); 14 + const context = await load_logged_in_context(browser, workerInfo, 'user2'); 15 + const page = await context.newPage(); 16 + 17 + // Register a security key. 18 + let response = await page.goto('/user/settings/security'); 19 + await expect(response?.status()).toBe(200); 20 + 21 + // https://github.com/microsoft/playwright/issues/7276#issuecomment-1516768428 22 + const cdpSession = await page.context().newCDPSession(page); 23 + await cdpSession.send('WebAuthn.enable'); 24 + await cdpSession.send('WebAuthn.addVirtualAuthenticator', { 25 + options: { 26 + protocol: 'ctap2', 27 + ctap2Version: 'ctap2_1', 28 + hasUserVerification: true, 29 + transport: 'usb', 30 + automaticPresenceSimulation: true, 31 + isUserVerified: true, 32 + backupEligibility: true, 33 + }, 34 + }); 35 + 36 + await page.locator('input#nickname').fill('Testing Security Key'); 37 + await page.getByText('Add security key').click(); 38 + 39 + // Logout. 40 + await page.locator('div[aria-label="Profile and settings…"]').click(); 41 + await page.getByText('Sign Out').click(); 42 + await page.waitForURL(`${workerInfo.project.use.baseURL}/`); 43 + 44 + // Login. 45 + response = await page.goto('/user/login'); 46 + await expect(response?.status()).toBe(200); 47 + 48 + await page.getByLabel('Username or email address').fill('user2'); 49 + await page.getByLabel('Password').fill('password'); 50 + await page.getByRole('button', {name: 'Sign in'}).click(); 51 + await page.waitForURL(`${workerInfo.project.use.baseURL}/user/webauthn`); 52 + await page.waitForURL(`${workerInfo.project.use.baseURL}/`); 53 + 54 + // Cleanup. 55 + response = await page.goto('/user/settings/security'); 56 + await expect(response?.status()).toBe(200); 57 + await page.getByRole('button', {name: 'Remove'}).click(); 58 + await page.getByRole('button', {name: 'Yes'}).click(); 59 + await page.waitForURL(`${workerInfo.project.use.baseURL}/user/settings/security`); 60 + });