Fix: passkey login not working anymore (#32623)

Quick fix #32595, use authenticator auth flags to login

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
hiifong 2024-11-27 00:04:17 +08:00 committed by GitHub
parent 0f4b0cf892
commit 87bb5ed0bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 86 additions and 47 deletions

View File

@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
) )
@ -89,14 +90,33 @@ func (cred *WebAuthnCredential) AfterLoad() {
// WebAuthnCredentialList is a list of *WebAuthnCredential // WebAuthnCredentialList is a list of *WebAuthnCredential
type WebAuthnCredentialList []*WebAuthnCredential type WebAuthnCredentialList []*WebAuthnCredential
// newCredentialFlagsFromAuthenticatorFlags is copied from https://github.com/go-webauthn/webauthn/pull/337
// to convert protocol.AuthenticatorFlags to webauthn.CredentialFlags
func newCredentialFlagsFromAuthenticatorFlags(flags protocol.AuthenticatorFlags) webauthn.CredentialFlags {
return webauthn.CredentialFlags{
UserPresent: flags.HasUserPresent(),
UserVerified: flags.HasUserVerified(),
BackupEligible: flags.HasBackupEligible(),
BackupState: flags.HasBackupState(),
}
}
// ToCredentials will convert all WebAuthnCredentials to webauthn.Credentials // ToCredentials will convert all WebAuthnCredentials to webauthn.Credentials
func (list WebAuthnCredentialList) ToCredentials() []webauthn.Credential { func (list WebAuthnCredentialList) ToCredentials(defaultAuthFlags ...protocol.AuthenticatorFlags) []webauthn.Credential {
// TODO: at the moment, Gitea doesn't store or check the flags
// so we need to use the default flags from the authenticator to make the login validation pass
// In the future, we should:
// 1. store the flags when registering the credential
// 2. provide the stored flags when converting the credentials (for login)
// 3. for old users, still use this fallback to the default flags
defAuthFlags := util.OptionalArg(defaultAuthFlags)
creds := make([]webauthn.Credential, 0, len(list)) creds := make([]webauthn.Credential, 0, len(list))
for _, cred := range list { for _, cred := range list {
creds = append(creds, webauthn.Credential{ creds = append(creds, webauthn.Credential{
ID: cred.CredentialID, ID: cred.CredentialID,
PublicKey: cred.PublicKey, PublicKey: cred.PublicKey,
AttestationType: cred.AttestationType, AttestationType: cred.AttestationType,
Flags: newCredentialFlagsFromAuthenticatorFlags(defAuthFlags),
Authenticator: webauthn.Authenticator{ Authenticator: webauthn.Authenticator{
AAGUID: cred.AAGUID, AAGUID: cred.AAGUID,
SignCount: cred.SignCount, SignCount: cred.SignCount,

View File

@ -134,6 +134,9 @@ func SyncAllTables() error {
func InitEngine(ctx context.Context) error { func InitEngine(ctx context.Context) error {
xormEngine, err := newXORMEngine() xormEngine, err := newXORMEngine()
if err != nil { if err != nil {
if strings.Contains(err.Error(), "SQLite3 support") {
return fmt.Errorf(`sqlite3 requires: -tags sqlite,sqlite_unlock_notify%s%w`, "\n", err)
}
return fmt.Errorf("failed to connect to database: %w", err) return fmt.Errorf("failed to connect to database: %w", err)
} }

View File

@ -18,7 +18,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/testlogger" "code.gitea.io/gitea/modules/testlogger"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/require"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -33,15 +33,15 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu
ourSkip := 2 ourSkip := 2
ourSkip += skip ourSkip += skip
deferFn := testlogger.PrintCurrentTest(t, ourSkip) deferFn := testlogger.PrintCurrentTest(t, ourSkip)
assert.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath)) require.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath))
if err := deleteDB(); err != nil { if err := deleteDB(); err != nil {
t.Errorf("unable to reset database: %v", err) t.Fatalf("unable to reset database: %v", err)
return nil, deferFn return nil, deferFn
} }
x, err := newXORMEngine() x, err := newXORMEngine()
assert.NoError(t, err) require.NoError(t, err)
if x != nil { if x != nil {
oldDefer := deferFn oldDefer := deferFn
deferFn = func() { deferFn = func() {

View File

@ -4,13 +4,14 @@
package webauthn package webauthn
import ( import (
"context"
"encoding/binary" "encoding/binary"
"encoding/gob" "encoding/gob"
"code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
@ -38,40 +39,42 @@ func Init() {
} }
} }
// User represents an implementation of webauthn.User based on User model // user represents an implementation of webauthn.User based on User model
type User user_model.User type user struct {
ctx context.Context
User *user_model.User
defaultAuthFlags protocol.AuthenticatorFlags
}
var _ webauthn.User = (*user)(nil)
func NewWebAuthnUser(ctx context.Context, u *user_model.User, defaultAuthFlags ...protocol.AuthenticatorFlags) webauthn.User {
return &user{ctx: ctx, User: u, defaultAuthFlags: util.OptionalArg(defaultAuthFlags)}
}
// WebAuthnID implements the webauthn.User interface // WebAuthnID implements the webauthn.User interface
func (u *User) WebAuthnID() []byte { func (u *user) WebAuthnID() []byte {
id := make([]byte, 8) id := make([]byte, 8)
binary.PutVarint(id, u.ID) binary.PutVarint(id, u.User.ID)
return id return id
} }
// WebAuthnName implements the webauthn.User interface // WebAuthnName implements the webauthn.User interface
func (u *User) WebAuthnName() string { func (u *user) WebAuthnName() string {
if u.LoginName == "" { return util.IfZero(u.User.LoginName, u.User.Name)
return u.Name
}
return u.LoginName
} }
// WebAuthnDisplayName implements the webauthn.User interface // WebAuthnDisplayName implements the webauthn.User interface
func (u *User) WebAuthnDisplayName() string { func (u *user) WebAuthnDisplayName() string {
return (*user_model.User)(u).DisplayName() return u.User.DisplayName()
}
// WebAuthnIcon implements the webauthn.User interface
func (u *User) WebAuthnIcon() string {
return (*user_model.User)(u).AvatarLink(db.DefaultContext)
} }
// WebAuthnCredentials implements the webauthn.User interface // WebAuthnCredentials implements the webauthn.User interface
func (u *User) WebAuthnCredentials() []webauthn.Credential { func (u *user) WebAuthnCredentials() []webauthn.Credential {
dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID) dbCreds, err := auth.GetWebAuthnCredentialsByUID(u.ctx, u.User.ID)
if err != nil { if err != nil {
return nil return nil
} }
return dbCreds.ToCredentials(u.defaultAuthFlags)
return dbCreds.ToCredentials()
} }

View File

@ -76,8 +76,17 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
}() }()
// Validate the parsed response. // Validate the parsed response.
// ParseCredentialRequestResponse+ValidateDiscoverableLogin equals to FinishDiscoverableLogin, but we need to ParseCredentialRequestResponse first to get flags
var user *user_model.User var user *user_model.User
cred, err := wa.WebAuthn.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) { parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
if err != nil {
// Failed authentication attempt.
log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)
ctx.Status(http.StatusForbidden)
return
}
cred, err := wa.WebAuthn.ValidateDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
userID, n := binary.Varint(userHandle) userID, n := binary.Varint(userHandle)
if n <= 0 { if n <= 0 {
return nil, errors.New("invalid rawID") return nil, errors.New("invalid rawID")
@ -89,8 +98,8 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
return nil, err return nil, err
} }
return (*wa.User)(user), nil return wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags), nil
}, *sessionData, ctx.Req) }, *sessionData, parsedResponse)
if err != nil { if err != nil {
// Failed authentication attempt. // Failed authentication attempt.
log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err) log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err)
@ -171,7 +180,8 @@ func WebAuthnLoginAssertion(ctx *context.Context) {
return return
} }
assertion, sessionData, err := wa.WebAuthn.BeginLogin((*wa.User)(user)) webAuthnUser := wa.NewWebAuthnUser(ctx, user)
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser)
if err != nil { if err != nil {
ctx.ServerError("webauthn.BeginLogin", err) ctx.ServerError("webauthn.BeginLogin", err)
return return
@ -216,7 +226,8 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
} }
// Validate the parsed response. // Validate the parsed response.
cred, err := wa.WebAuthn.ValidateLogin((*wa.User)(user), *sessionData, parsedResponse) webAuthnUser := wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags)
cred, err := wa.WebAuthn.ValidateLogin(webAuthnUser, *sessionData, parsedResponse)
if err != nil { if err != nil {
// Failed authentication attempt. // Failed authentication attempt.
log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err) log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)

View File

@ -51,7 +51,8 @@ func WebAuthnRegister(ctx *context.Context) {
return return
} }
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer)
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired, ResidentKey: protocol.ResidentKeyRequirementRequired,
})) }))
if err != nil { if err != nil {
@ -92,7 +93,8 @@ func WebauthnRegisterPost(ctx *context.Context) {
}() }()
// Verify that the challenge succeeded // Verify that the challenge succeeded
cred, err := wa.WebAuthn.FinishRegistration((*wa.User)(ctx.Doer), *sessionData, ctx.Req) webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer)
cred, err := wa.WebAuthn.FinishRegistration(webAuthnUser, *sessionData, ctx.Req)
if err != nil { if err != nil {
if pErr, ok := err.(*protocol.Error); ok { if pErr, ok := err.(*protocol.Error); ok {
log.Error("Unable to finish registration due to error: %v\nDevInfo: %s", pErr, pErr.DevInfo) log.Error("Unable to finish registration due to error: %v\nDevInfo: %s", pErr, pErr.DevInfo)

View File

@ -40,14 +40,15 @@ async function loginPasskey() {
try { try {
const credential = await navigator.credentials.get({ const credential = await navigator.credentials.get({
publicKey: options.publicKey, publicKey: options.publicKey,
}); }) as PublicKeyCredential;
const credResp = credential.response as AuthenticatorAssertionResponse;
// Move data into Arrays in case it is super long // Move data into Arrays in case it is super long
const authData = new Uint8Array(credential.response.authenticatorData); const authData = new Uint8Array(credResp.authenticatorData);
const clientDataJSON = new Uint8Array(credential.response.clientDataJSON); const clientDataJSON = new Uint8Array(credResp.clientDataJSON);
const rawId = new Uint8Array(credential.rawId); const rawId = new Uint8Array(credential.rawId);
const sig = new Uint8Array(credential.response.signature); const sig = new Uint8Array(credResp.signature);
const userHandle = new Uint8Array(credential.response.userHandle); const userHandle = new Uint8Array(credResp.userHandle);
const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, { const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, {
data: { data: {
@ -175,7 +176,7 @@ async function webauthnRegistered(newCredential) {
window.location.reload(); window.location.reload();
} }
function webAuthnError(errorType, message) { function webAuthnError(errorType: string, message:string = '') {
const elErrorMsg = document.querySelector(`#webauthn-error-msg`); const elErrorMsg = document.querySelector(`#webauthn-error-msg`);
if (errorType === 'general') { if (errorType === 'general') {
@ -207,10 +208,9 @@ function detectWebAuthnSupport() {
} }
export function initUserAuthWebAuthnRegister() { export function initUserAuthWebAuthnRegister() {
const elRegister = document.querySelector('#register-webauthn'); const elRegister = document.querySelector<HTMLInputElement>('#register-webauthn');
if (!elRegister) { if (!elRegister) return;
return;
}
if (!detectWebAuthnSupport()) { if (!detectWebAuthnSupport()) {
elRegister.disabled = true; elRegister.disabled = true;
return; return;
@ -222,7 +222,7 @@ export function initUserAuthWebAuthnRegister() {
} }
async function webAuthnRegisterRequest() { async function webAuthnRegisterRequest() {
const elNickname = document.querySelector('#nickname'); const elNickname = document.querySelector<HTMLInputElement>('#nickname');
const formData = new FormData(); const formData = new FormData();
formData.append('name', elNickname.value); formData.append('name', elNickname.value);

View File

@ -1,5 +1,5 @@
import {isObject} from '../utils.ts'; import {isObject} from '../utils.ts';
import type {RequestData, RequestOpts} from '../types.ts'; import type {RequestOpts} from '../types.ts';
const {csrfToken} = window.config; const {csrfToken} = window.config;
@ -10,7 +10,7 @@ const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// which will automatically set an appropriate headers. For json content, only object // which will automatically set an appropriate headers. For json content, only object
// and array types are currently supported. // and array types are currently supported.
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> { export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
let body: RequestData; let body: string | FormData | URLSearchParams;
let contentType: string; let contentType: string;
if (data instanceof FormData || data instanceof URLSearchParams) { if (data instanceof FormData || data instanceof URLSearchParams) {
body = data; body = data;

View File

@ -24,7 +24,7 @@ export type Config = {
export type Intent = 'error' | 'warning' | 'info'; export type Intent = 'error' | 'warning' | 'info';
export type RequestData = string | FormData | URLSearchParams; export type RequestData = string | FormData | URLSearchParams | Record<string, any>;
export type RequestOpts = { export type RequestOpts = {
data?: RequestData, data?: RequestData,