diff --git a/lib/api/api.go b/lib/api/api.go index ff3bc15385c..e065b569234 100644 --- a/lib/api/api.go +++ b/lib/api/api.go @@ -92,8 +92,6 @@ type service struct { listenerAddr net.Addr exitChan chan *svcutil.FatalErr miscDB *db.NamespacedKV - tokenCookieManager tokenCookieManager - webauthnService webauthnService shutdownTimeout time.Duration guiErrors logger.Recorder @@ -108,14 +106,7 @@ type Service interface { WaitForStart() error } -func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonName string, m model.Model, defaultSub, diskSub events.BufferedSubscription, evLogger events.Logger, discoverer discover.Manager, connectionsService connections.Service, urService *ur.Service, fss model.FolderSummaryService, errors, systemLog logger.Recorder, noUpgrade bool, miscDB *db.NamespacedKV) (Service, error) { - - tokenCookieManager := newTokenCookieManager(id.Short().String(), cfg.GUI(), evLogger, miscDB) - webauthnService, err := newWebauthnService(tokenCookieManager, cfg, evLogger, miscDB, "webauthn") - if err != nil { - return nil, err - } - +func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonName string, m model.Model, defaultSub, diskSub events.BufferedSubscription, evLogger events.Logger, discoverer discover.Manager, connectionsService connections.Service, urService *ur.Service, fss model.FolderSummaryService, errors, systemLog logger.Recorder, noUpgrade bool, miscDB *db.NamespacedKV) Service { return &service{ id: id, cfg: cfg, @@ -139,10 +130,8 @@ func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonNam startedOnce: make(chan struct{}), exitChan: make(chan *svcutil.FatalErr, 1), miscDB: miscDB, - tokenCookieManager: *tokenCookieManager, - webauthnService: webauthnService, shutdownTimeout: 100 * time.Millisecond, - }, nil + } } func (s *service) WaitForStart() error { @@ -226,7 +215,8 @@ func sendJSON(w http.ResponseWriter, jsonObject interface{}) { } func (s *service) Serve(ctx context.Context) error { - listener, err := s.getListener(s.cfg.GUI()) + guiCfg := s.cfg.GUI() + listener, err := s.getListener(guiCfg) if err != nil { select { case <-s.startedOnce: @@ -258,6 +248,11 @@ func (s *service) Serve(ctx context.Context) error { restMux := httprouter.New() + webauthnService, err := newWebauthnService(guiCfg, s.id.Short().String(), s.evLogger, s.miscDB, "webauthn") + if err != nil { + return err + } + // The GET handlers restMux.HandlerFunc(http.MethodGet, "/rest/cluster/pending/devices", s.getPendingDevices) // - restMux.HandlerFunc(http.MethodGet, "/rest/cluster/pending/folders", s.getPendingFolders) // [device] @@ -293,7 +288,7 @@ func (s *service) Serve(ctx context.Context) error { restMux.HandlerFunc(http.MethodGet, "/rest/system/debug", s.getSystemDebug) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/log", s.getSystemLog) // [since] restMux.HandlerFunc(http.MethodGet, "/rest/system/log.txt", s.getSystemLogTxt) // [since] - restMux.HandlerFunc(http.MethodGet, "/rest/webauthn/state", s.webauthnService.getConfigLikeState) + restMux.HandlerFunc(http.MethodGet, "/rest/webauthn/state", webauthnService.getConfigLikeState) // The POST handlers restMux.HandlerFunc(http.MethodPost, "/rest/db/prio", s.postDBPrio) // folder file @@ -313,9 +308,9 @@ func (s *service) Serve(ctx context.Context) error { restMux.HandlerFunc(http.MethodPost, "/rest/system/resume", s.makeDevicePauseHandler(false)) // [device] restMux.HandlerFunc(http.MethodPost, "/rest/system/debug", s.postSystemDebug) // [enable] [disable] - restMux.HandlerFunc(http.MethodPost, "/rest/webauthn/register-start", s.webauthnService.startWebauthnRegistration) - restMux.HandlerFunc(http.MethodPost, "/rest/webauthn/register-finish", s.webauthnService.finishWebauthnRegistration) - restMux.HandlerFunc(http.MethodPost, "/rest/webauthn/state", s.webauthnService.updateConfigLikeState) + restMux.HandlerFunc(http.MethodPost, "/rest/webauthn/register-start", webauthnService.startWebauthnRegistration(guiCfg)) + restMux.HandlerFunc(http.MethodPost, "/rest/webauthn/register-finish", webauthnService.finishWebauthnRegistration(guiCfg)) + restMux.HandlerFunc(http.MethodPost, "/rest/webauthn/state", webauthnService.updateConfigLikeState) // The DELETE handlers restMux.HandlerFunc(http.MethodDelete, "/rest/cluster/pending/devices", s.deletePendingDevices) // device @@ -324,10 +319,9 @@ func (s *service) Serve(ctx context.Context) error { // Config endpoints configBuilder := &configMuxBuilder{ - Router: restMux, - id: s.id, - cfg: s.cfg, - webauthnService: &s.webauthnService, + Router: restMux, + id: s.id, + cfg: s.cfg, } configBuilder.registerConfig("/rest/config") @@ -376,8 +370,6 @@ func (s *service) Serve(ctx context.Context) error { promHttpHandler := promhttp.Handler() mux.Handle("/metrics", promHttpHandler) - guiCfg := s.cfg.GUI() - // Wrap everything in CSRF protection. The /rest prefix should be // protected, other requests will grant cookies. var handler http.Handler = newCsrfManager(s.id.Short().String(), "/rest", guiCfg, mux, s.miscDB) @@ -386,13 +378,14 @@ func (s *service) Serve(ctx context.Context) error { handler = withDetailsMiddleware(s.id, handler) // Wrap everything in auth, if user/password is set or WebAuthn is enabled. - if s.IsAuthEnabled() { - authMW := newBasicAuthAndSessionMiddleware(&s.tokenCookieManager, guiCfg, s.cfg.LDAP(), handler, s.evLogger) + if isAuthEnabled(&webauthnService, guiCfg) { + tokenCookieManager := newTokenCookieManager(s.id.Short().String(), guiCfg, s.evLogger, s.miscDB) + authMW := newBasicAuthAndSessionMiddleware(tokenCookieManager, guiCfg, s.cfg.LDAP(), handler, s.evLogger) handler = authMW restMux.Handler(http.MethodPost, "/rest/noauth/auth/password", http.HandlerFunc(authMW.passwordAuthHandler)) - restMux.HandlerFunc(http.MethodPost, "/rest/noauth/auth/webauthn-start", s.webauthnService.startWebauthnAuthentication) - restMux.HandlerFunc(http.MethodPost, "/rest/noauth/auth/webauthn-finish", s.webauthnService.finishWebauthnAuthentication) + restMux.HandlerFunc(http.MethodPost, "/rest/noauth/auth/webauthn-start", webauthnService.startWebauthnAuthentication(guiCfg)) + restMux.HandlerFunc(http.MethodPost, "/rest/noauth/auth/webauthn-finish", webauthnService.finishWebauthnAuthentication(tokenCookieManager, guiCfg)) // Logout is a no-op without a valid session cookie, so /noauth/ is fine here restMux.Handler(http.MethodPost, "/rest/noauth/auth/logout", http.HandlerFunc(authMW.handleLogout)) @@ -519,10 +512,9 @@ func (s *service) CommitConfiguration(from, to config.Configuration) bool { return true } -func (s *service) IsAuthEnabled() bool { +func isAuthEnabled(webauthnService *webauthnService, guiCfg config.GUIConfiguration) bool { // This function should match isAuthEnabled() in syncthingController.js - guiCfg := s.cfg.GUI() - webauthnReady, err := s.webauthnService.IsAuthReady() + webauthnReady, err := webauthnService.IsAuthReady(guiCfg) if err != nil { webauthnReady = false } diff --git a/lib/api/api_auth_webauthn.go b/lib/api/api_auth_webauthn.go index abbf4c6ead0..1e21536a126 100644 --- a/lib/api/api_auth_webauthn.go +++ b/lib/api/api_auth_webauthn.go @@ -11,7 +11,6 @@ import ( "encoding/base64" "fmt" "net/http" - "reflect" "slices" "time" @@ -23,53 +22,23 @@ import ( "github.com/syncthing/syncthing/lib/sliceutil" ) -func newWebauthnEngine(cfg config.Wrapper) (*webauthnLib.WebAuthn, error) { - guiCfg := cfg.GUI() - +func newWebauthnEngine(guiCfg config.GUIConfiguration, deviceName string) (*webauthnLib.WebAuthn, error) { displayName := "Syncthing" - if dev, ok := cfg.Device(cfg.MyID()); ok && dev.Name != "" { - displayName = "Syncthing @ " + dev.Name - } - - rpId := guiCfg.WebauthnRpId - if rpId == "" { - guiCfgStruct := reflect.TypeOf(guiCfg) - field, found := guiCfgStruct.FieldByName("WebauthnRpId") - if !found { - return nil, fmt.Errorf(`Field "WebauthnRpId" not found in struct GUIConfiguration`) - } - rpId = field.Tag.Get("default") - if rpId == "" { - return nil, fmt.Errorf(`Default tag not found on field "WebauthnRpId" in struct GUIConfiguration`) - } - } - - origin := guiCfg.WebauthnOrigin - if origin == "" { - guiCfgStruct := reflect.TypeOf(guiCfg) - field, found := guiCfgStruct.FieldByName("WebauthnOrigin") - if !found { - return nil, fmt.Errorf(`Field "WebauthnOrigin" not found in struct GUIConfiguration`) - } - origin = field.Tag.Get("default") - if origin == "" { - return nil, fmt.Errorf(`Default tag not found on field "WebauthnOrigin" in struct GUIConfiguration`) - } + if deviceName != "" { + displayName = "Syncthing @ " + deviceName } return webauthnLib.New(&webauthnLib.Config{ RPDisplayName: displayName, - RPID: rpId, - RPOrigins: []string{origin}, + RPID: guiCfg.WebauthnRpId, + RPOrigins: []string{guiCfg.WebauthnOrigin}, }) } type webauthnService struct { - tokenCookieManager *tokenCookieManager miscDB *db.NamespacedKV miscDBKey string engine *webauthnLib.WebAuthn - cfg config.Wrapper evLogger events.Logger userHandle []byte registrationState webauthnLib.SessionData @@ -77,25 +46,23 @@ type webauthnService struct { credentialsPendingRegistration []config.WebauthnCredential } -func newWebauthnService(tokenCookieManager *tokenCookieManager, cfg config.Wrapper, evLogger events.Logger, miscDB *db.NamespacedKV, miscDBKey string) (webauthnService, error) { - engine, err := newWebauthnEngine(cfg) +func newWebauthnService(guiCfg config.GUIConfiguration, deviceName string, evLogger events.Logger, miscDB *db.NamespacedKV, miscDBKey string) (webauthnService, error) { + engine, err := newWebauthnEngine(guiCfg, deviceName) if err != nil { return webauthnService{}, err } - userHandle, err := base64.URLEncoding.DecodeString(cfg.GUI().WebauthnUserId) + userHandle, err := base64.URLEncoding.DecodeString(guiCfg.WebauthnUserId) if err != nil { return webauthnService{}, err } return webauthnService{ - tokenCookieManager: tokenCookieManager, - miscDB: miscDB, - miscDBKey: miscDBKey, - engine: engine, - cfg: cfg, - evLogger: evLogger, - userHandle: userHandle, + miscDB: miscDB, + miscDBKey: miscDBKey, + engine: engine, + evLogger: evLogger, + userHandle: userHandle, }, nil } @@ -126,55 +93,33 @@ func (s *webauthnService) storeState(state config.WebauthnState) error { return s.miscDB.PutBytes(s.miscDBKey, stateBytes) } -func (s *webauthnService) WebAuthnID() []byte { - return s.userHandle +func (s *webauthnService) user(guiCfg config.GUIConfiguration) webauthnLibUser { + return webauthnLibUser{ + service: s, + guiCfg: guiCfg, + } } -func (s *webauthnService) WebAuthnName() string { - return s.cfg.GUI().User +type webauthnLibUser struct { + service *webauthnService + guiCfg config.GUIConfiguration } -func (s *webauthnService) WebAuthnDisplayName() string { - return s.cfg.GUI().User +func (u webauthnLibUser) WebAuthnID() []byte { + return u.service.userHandle } - -func (*webauthnService) WebAuthnIcon() string { - return "" +func (u webauthnLibUser) WebAuthnName() string { + return u.guiCfg.User } - -func (s *webauthnService) IsAuthReady() (bool, error) { - eligibleCredentials, err := s.EligibleWebAuthnCredentials() - if err != nil { - return false, err - } - return s.cfg.GUI().UseTLS() && len(eligibleCredentials) > 0, nil +func (u webauthnLibUser) WebAuthnDisplayName() string { + return u.guiCfg.User } - -func (s *webauthnService) EligibleWebAuthnCredentials() ([]config.WebauthnCredential, error) { - state, err := s.loadState() - if err != nil { - return nil, err - } - - guiCfg := s.cfg.GUI() - rpId := guiCfg.WebauthnRpId - if rpId == "" { - rpId = "localhost" - } - - var result []config.WebauthnCredential - for _, cred := range state.Credentials { - if cred.RpId == rpId { - result = append(result, cred) - } - } - return result, nil +func (webauthnLibUser) WebAuthnIcon() string { + return "" } - -// Defined by webauthnLib.User, cannot return error -func (s *webauthnService) WebAuthnCredentials() []webauthnLib.Credential { +func (u webauthnLibUser) WebAuthnCredentials() []webauthnLib.Credential { var result []webauthnLib.Credential - eligibleCredentials, err := s.EligibleWebAuthnCredentials() + eligibleCredentials, err := u.service.EligibleWebAuthnCredentials(u.guiCfg) if err != nil { return make([]webauthnLib.Credential, 0) } @@ -209,195 +154,225 @@ func (s *webauthnService) WebAuthnCredentials() []webauthnLib.Credential { return result } -func (s *webauthnService) startWebauthnRegistration(w http.ResponseWriter, _ *http.Request) { - options, sessionData, err := s.engine.BeginRegistration(s) +func (s *webauthnService) IsAuthReady(guiCfg config.GUIConfiguration) (bool, error) { + eligibleCredentials, err := s.EligibleWebAuthnCredentials(guiCfg) if err != nil { - l.Warnln("Failed to initiate WebAuthn registration:", err) - internalServerError(w) - return + return false, err } - - s.registrationState = *sessionData - - sendJSON(w, options) + return guiCfg.UseTLS() && len(eligibleCredentials) > 0, nil } -func (s *webauthnService) finishWebauthnRegistration(w http.ResponseWriter, r *http.Request) { - state := s.registrationState - s.registrationState = webauthnLib.SessionData{} // Allow only one attempt per challenge - - credential, err := s.engine.FinishRegistration(s, state, r) - if err != nil { - l.Infoln("Failed to register WebAuthn credential:", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - persistentState, err := s.loadState() +func (s *webauthnService) EligibleWebAuthnCredentials(guiCfg config.GUIConfiguration) ([]config.WebauthnCredential, error) { + state, err := s.loadState() if err != nil { - l.Warnln("Failed to load persistent WebAuthn state", err) - http.Error(w, "Failed to load persistent WebAuthn state", http.StatusInternalServerError) - return + return nil, err } - for _, existingCred := range persistentState.Credentials { - existId, err := base64.URLEncoding.DecodeString(existingCred.ID) - if err == nil && bytes.Equal(credential.ID, existId) { - l.Infof("Cannot register WebAuthn credential with duplicate credential ID: %s", existingCred.ID) - http.Error(w, fmt.Sprintf("Cannot register WebAuthn credential with duplicate credential ID: %s", existingCred.ID), http.StatusBadRequest) - return + var result []config.WebauthnCredential + for _, cred := range state.Credentials { + if cred.RpId == guiCfg.WebauthnRpId { + result = append(result, cred) } } - for _, existingCred := range s.credentialsPendingRegistration { - existId, err := base64.URLEncoding.DecodeString(existingCred.ID) - if err == nil && bytes.Equal(credential.ID, existId) { - l.Infof("Cannot register WebAuthn credential with duplicate credential ID: %s", existingCred.ID) - http.Error(w, fmt.Sprintf("Cannot register WebAuthn credential with duplicate credential ID: %s", existingCred.ID), http.StatusBadRequest) + return result, nil +} + +func (s *webauthnService) startWebauthnRegistration(guiCfg config.GUIConfiguration) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + options, sessionData, err := s.engine.BeginRegistration(s.user(guiCfg)) + if err != nil { + l.Warnln("Failed to initiate WebAuthn registration:", err) + internalServerError(w) return } - } - transports := make([]string, len(credential.Transport)) - for i, t := range credential.Transport { - transports[i] = string(t) - } + s.registrationState = *sessionData - now := time.Now().Truncate(time.Second).UTC() - configCred := config.WebauthnCredential{ - ID: base64.URLEncoding.EncodeToString(credential.ID), - RpId: s.engine.Config.RPID, - PublicKeyCose: base64.URLEncoding.EncodeToString(credential.PublicKey), - SignCount: credential.Authenticator.SignCount, - Transports: transports, - CreateTime: now, - LastUseTime: now, + sendJSON(w, options) } - s.credentialsPendingRegistration = append(s.credentialsPendingRegistration, configCred) - - sendJSON(w, configCred) } -func (s *webauthnService) startWebauthnAuthentication(w http.ResponseWriter, _ *http.Request) { - persistentState, err := s.loadState() - if err != nil { - l.Warnln("Failed to load persistent WebAuthn state", err) - http.Error(w, "Failed to load persistent WebAuthn state", http.StatusInternalServerError) - return - } +func (s *webauthnService) finishWebauthnRegistration(guiCfg config.GUIConfiguration) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + state := s.registrationState + s.registrationState = webauthnLib.SessionData{} // Allow only one attempt per challenge - allRequireUv := true - someRequiresUv := false - for _, cred := range persistentState.Credentials { - if cred.RequireUv { - someRequiresUv = true - } else { - allRequireUv = false + credential, err := s.engine.FinishRegistration(s.user(guiCfg), state, r) + if err != nil { + l.Infoln("Failed to register WebAuthn credential:", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return } - } - uv := webauthnProtocol.VerificationDiscouraged - if allRequireUv { - uv = webauthnProtocol.VerificationRequired - } else if someRequiresUv { - uv = webauthnProtocol.VerificationPreferred - } - options, sessionData, err := s.engine.BeginLogin(s, webauthnLib.WithUserVerification(uv)) - if err != nil { - badRequest, ok := err.(*webauthnProtocol.Error) - if ok && badRequest.Type == "invalid_request" && badRequest.Details == "Found no credentials for user" { - sendJSON(w, make(map[string]string)) - } else { - l.Warnln("Failed to initialize WebAuthn login", err) + persistentState, err := s.loadState() + if err != nil { + l.Warnln("Failed to load persistent WebAuthn state", err) + http.Error(w, "Failed to load persistent WebAuthn state", http.StatusInternalServerError) + return } - return - } - s.authenticationState = *sessionData - - sendJSON(w, options) -} + for _, existingCred := range persistentState.Credentials { + existId, err := base64.URLEncoding.DecodeString(existingCred.ID) + if err == nil && bytes.Equal(credential.ID, existId) { + l.Infof("Cannot register WebAuthn credential with duplicate credential ID: %s", existingCred.ID) + http.Error(w, fmt.Sprintf("Cannot register WebAuthn credential with duplicate credential ID: %s", existingCred.ID), http.StatusBadRequest) + return + } + } + for _, existingCred := range s.credentialsPendingRegistration { + existId, err := base64.URLEncoding.DecodeString(existingCred.ID) + if err == nil && bytes.Equal(credential.ID, existId) { + l.Infof("Cannot register WebAuthn credential with duplicate credential ID: %s", existingCred.ID) + http.Error(w, fmt.Sprintf("Cannot register WebAuthn credential with duplicate credential ID: %s", existingCred.ID), http.StatusBadRequest) + return + } + } -func (s *webauthnService) finishWebauthnAuthentication(w http.ResponseWriter, r *http.Request) { - state := s.authenticationState - s.authenticationState = webauthnLib.SessionData{} // Allow only one attempt per challenge + transports := make([]string, len(credential.Transport)) + for i, t := range credential.Transport { + transports[i] = string(t) + } - var req struct { - StayLoggedIn bool - Credential webauthnProtocol.CredentialAssertionResponse - } + now := time.Now().Truncate(time.Second).UTC() + configCred := config.WebauthnCredential{ + ID: base64.URLEncoding.EncodeToString(credential.ID), + RpId: s.engine.Config.RPID, + PublicKeyCose: base64.URLEncoding.EncodeToString(credential.PublicKey), + SignCount: credential.Authenticator.SignCount, + Transports: transports, + CreateTime: now, + LastUseTime: now, + } + s.credentialsPendingRegistration = append(s.credentialsPendingRegistration, configCred) - if err := unmarshalTo(r.Body, &req); err != nil { - l.Debugln("Failed to parse response:", err) - http.Error(w, "Failed to parse response.", http.StatusBadRequest) - return + sendJSON(w, configCred) } +} - parsedResponse, err := req.Credential.Parse() - if err != nil { - l.Debugln("Failed to parse WebAuthn authentication response", err) - badRequest(w) - return - } +func (s *webauthnService) startWebauthnAuthentication(guiCfg config.GUIConfiguration) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + persistentState, err := s.loadState() + if err != nil { + l.Warnln("Failed to load persistent WebAuthn state", err) + http.Error(w, "Failed to load persistent WebAuthn state", http.StatusInternalServerError) + return + } - updatedCred, err := s.engine.ValidateLogin(s, state, parsedResponse) - if err != nil { - l.Infoln("WebAuthn authentication failed", err) + allRequireUv := true + someRequiresUv := false + for _, cred := range persistentState.Credentials { + if cred.RequireUv { + someRequiresUv = true + } else { + allRequireUv = false + } + } + uv := webauthnProtocol.VerificationDiscouraged + if allRequireUv { + uv = webauthnProtocol.VerificationRequired + } else if someRequiresUv { + uv = webauthnProtocol.VerificationPreferred + } - if state.UserVerification == webauthnProtocol.VerificationRequired { - antiBruteForceSleep() - http.Error(w, "Conflict", http.StatusConflict) + options, sessionData, err := s.engine.BeginLogin(s.user(guiCfg), webauthnLib.WithUserVerification(uv)) + if err != nil { + badRequest, ok := err.(*webauthnProtocol.Error) + if ok && badRequest.Type == "invalid_request" && badRequest.Details == "Found no credentials for user" { + sendJSON(w, make(map[string]string)) + } else { + l.Warnln("Failed to initialize WebAuthn login", err) + } return } - forbidden(w) - return + s.authenticationState = *sessionData + + sendJSON(w, options) } +} - authenticatedCredId := base64.URLEncoding.EncodeToString(updatedCred.ID) +func (s *webauthnService) finishWebauthnAuthentication(tokenCookieManager *tokenCookieManager, guiCfg config.GUIConfiguration) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + state := s.authenticationState + s.authenticationState = webauthnLib.SessionData{} // Allow only one attempt per challenge - persistentState, err := s.loadState() - if err != nil { - l.Warnln("Failed to load persistent WebAuthn state", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + var req struct { + StayLoggedIn bool + Credential webauthnProtocol.CredentialAssertionResponse + } - for _, cred := range persistentState.Credentials { - if cred.ID == authenticatedCredId { - if cred.RequireUv && !updatedCred.Flags.UserVerified { + if err := unmarshalTo(r.Body, &req); err != nil { + l.Debugln("Failed to parse response:", err) + http.Error(w, "Failed to parse response.", http.StatusBadRequest) + return + } + + parsedResponse, err := req.Credential.Parse() + if err != nil { + l.Debugln("Failed to parse WebAuthn authentication response", err) + badRequest(w) + return + } + + updatedCred, err := s.engine.ValidateLogin(s.user(guiCfg), state, parsedResponse) + if err != nil { + l.Infoln("WebAuthn authentication failed", err) + + if state.UserVerification == webauthnProtocol.VerificationRequired { antiBruteForceSleep() http.Error(w, "Conflict", http.StatusConflict) return } - break + + forbidden(w) + return } - } - authenticatedCredName := authenticatedCredId - var signCountBefore uint32 = 0 - - updateCredIndex := slices.IndexFunc(persistentState.Credentials, func(cred config.WebauthnCredential) bool { return cred.ID == authenticatedCredId }) - if updateCredIndex != -1 { - updateCred := &persistentState.Credentials[updateCredIndex] - signCountBefore = updateCred.SignCount - authenticatedCredName = updateCred.NicknameOrID() - updateCred.SignCount = updatedCred.Authenticator.SignCount - updateCred.LastUseTime = time.Now().Truncate(time.Second).UTC() - err = s.storeState(persistentState) + authenticatedCredId := base64.URLEncoding.EncodeToString(updatedCred.ID) + + persistentState, err := s.loadState() if err != nil { - l.Warnln("Failed to update authenticated WebAuthn credential", err) + l.Warnln("Failed to load persistent WebAuthn state", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - } - if updatedCred.Authenticator.CloneWarning && signCountBefore != 0 { - l.Warnln(fmt.Sprintf("Invalid WebAuthn signature count for credential %q: expected > %d, was: %d. The credential may have been cloned.", authenticatedCredName, signCountBefore, parsedResponse.Response.AuthenticatorData.Counter)) - } + for _, cred := range persistentState.Credentials { + if cred.ID == authenticatedCredId { + if cred.RequireUv && !updatedCred.Flags.UserVerified { + antiBruteForceSleep() + http.Error(w, "Conflict", http.StatusConflict) + return + } + break + } + } + + authenticatedCredName := authenticatedCredId + var signCountBefore uint32 = 0 + + updateCredIndex := slices.IndexFunc(persistentState.Credentials, func(cred config.WebauthnCredential) bool { return cred.ID == authenticatedCredId }) + if updateCredIndex != -1 { + updateCred := &persistentState.Credentials[updateCredIndex] + signCountBefore = updateCred.SignCount + authenticatedCredName = updateCred.NicknameOrID() + updateCred.SignCount = updatedCred.Authenticator.SignCount + updateCred.LastUseTime = time.Now().Truncate(time.Second).UTC() + err = s.storeState(persistentState) + if err != nil { + l.Warnln("Failed to update authenticated WebAuthn credential", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + + if updatedCred.Authenticator.CloneWarning && signCountBefore != 0 { + l.Warnln(fmt.Sprintf("Invalid WebAuthn signature count for credential %q: expected > %d, was: %d. The credential may have been cloned.", authenticatedCredName, signCountBefore, parsedResponse.Response.AuthenticatorData.Counter)) + } - guiCfg := s.cfg.GUI() - s.tokenCookieManager.createSession(guiCfg.User, req.StayLoggedIn, w, r) - w.WriteHeader(http.StatusNoContent) + tokenCookieManager.createSession(guiCfg.User, req.StayLoggedIn, w, r) + w.WriteHeader(http.StatusNoContent) + } } func (s *webauthnService) getConfigLikeState(w http.ResponseWriter, _ *http.Request) { diff --git a/lib/api/api_test.go b/lib/api/api_test.go index 241a31b2401..529d6a26aa8 100644 --- a/lib/api/api_test.go +++ b/lib/api/api_test.go @@ -53,6 +53,7 @@ import ( modelmocks "github.com/syncthing/syncthing/lib/model/mocks" "github.com/syncthing/syncthing/lib/protocol" "github.com/syncthing/syncthing/lib/rand" + "github.com/syncthing/syncthing/lib/structutil" "github.com/syncthing/syncthing/lib/svcutil" "github.com/syncthing/syncthing/lib/sync" "github.com/syncthing/syncthing/lib/testutil" @@ -68,9 +69,22 @@ var ( testAPIKey = "foobarbaz" ) +func withTestDefaults(guiCfg config.GUIConfiguration) config.GUIConfiguration { + defaultGuiCfg := structutil.WithDefaults(config.GUIConfiguration{}) + + if guiCfg.WebauthnRpId == "" { + guiCfg.WebauthnRpId = defaultGuiCfg.WebauthnRpId + } + if guiCfg.WebauthnOrigin == "" { + guiCfg.WebauthnOrigin = defaultGuiCfg.WebauthnOrigin + } + + return guiCfg +} + func init() { dev1, _ = protocol.DeviceIDFromString("AIR6LPZ-7K4PTTV-UXQSMUU-CPQ5YWH-OEDFIIQ-JUG777G-2YQXXR5-YD6AWQR") - apiCfg.GUIReturns(config.GUIConfiguration{APIKey: testAPIKey, RawAddress: "127.0.0.1:0"}) + apiCfg.GUIReturns(withTestDefaults(config.GUIConfiguration{APIKey: testAPIKey, RawAddress: "127.0.0.1:0"})) } func TestMain(m *testing.M) { @@ -88,19 +102,16 @@ func TestStopAfterBrokenConfig(t *testing.T) { t.Parallel() cfg := config.Configuration{ - GUI: config.GUIConfiguration{ + GUI: withTestDefaults(config.GUIConfiguration{ RawAddress: "127.0.0.1:0", RawUseTLS: false, - }, + }), } w := config.Wrap("/dev/null", cfg, protocol.LocalDeviceID, events.NoopLogger) mdb, _ := db.NewLowlevel(backend.OpenMemory(), events.NoopLogger) kdb := db.NewMiscDataNamespace(mdb) - srvAbstract, err := New(protocol.LocalDeviceID, w, "", "syncthing", nil, nil, nil, events.NoopLogger, nil, nil, nil, nil, nil, nil, false, kdb) - if err != nil { - t.Fatal("Failed to create server instance", err) - } + srvAbstract := New(protocol.LocalDeviceID, w, "", "syncthing", nil, nil, nil, events.NoopLogger, nil, nil, nil, nil, nil, nil, false, kdb) srv := srvAbstract.(*service) srv.started = make(chan string) @@ -611,13 +622,13 @@ func TestHTTPLogin(t *testing.T) { testWith := func(sendBasicAuthPrompt bool, expectedOkStatus int, expectedFailStatus int, path string) { cfg := newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{ + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{ User: "üser", Password: "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq", // bcrypt of "räksmörgås" in UTF-8 RawAddress: "127.0.0.1:0", APIKey: testAPIKey, SendBasicAuthPrompt: sendBasicAuthPrompt, - }) + })) baseURL, cancel, _, err := startHTTP(cfg) if err != nil { t.Fatal(err) @@ -778,11 +789,11 @@ func TestHtmlFormLogin(t *testing.T) { t.Parallel() cfg := newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{ + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{ User: "üser", Password: "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq", // bcrypt of "räksmörgås" in UTF-8 SendBasicAuthPrompt: false, - }) + })) baseURL, cancel, _, err := startHTTP(cfg) if err != nil { t.Fatal(err) @@ -924,10 +935,10 @@ func TestApiCache(t *testing.T) { t.Parallel() cfg := newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{ + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{ RawAddress: "127.0.0.1:0", APIKey: testAPIKey, - }) + })) baseURL, cancel, _, err := startHTTP(cfg) if err != nil { t.Fatal(err) @@ -957,19 +968,19 @@ func TestApiCache(t *testing.T) { }) } -func startHTTP(cfg config.Wrapper) (string, context.CancelFunc, *service, error) { +func startHTTP(cfg config.Wrapper) (string, context.CancelFunc, *webauthnService, error) { return startHTTPWithWebauthnStateAndShutdownTimeout(cfg, nil, 0) } -func startHTTPWithWebauthnState(cfg config.Wrapper, webauthnState *config.WebauthnState) (string, context.CancelFunc, *service, error) { +func startHTTPWithWebauthnState(cfg config.Wrapper, webauthnState *config.WebauthnState) (string, context.CancelFunc, *webauthnService, error) { return startHTTPWithWebauthnStateAndShutdownTimeout(cfg, webauthnState, 0) } -func startHTTPWithShutdownTimeout(cfg config.Wrapper, shutdownTimeout time.Duration) (string, context.CancelFunc, *service, error) { +func startHTTPWithShutdownTimeout(cfg config.Wrapper, shutdownTimeout time.Duration) (string, context.CancelFunc, *webauthnService, error) { return startHTTPWithWebauthnStateAndShutdownTimeout(cfg, nil, shutdownTimeout) } -func startHTTPWithWebauthnStateAndShutdownTimeout(cfg config.Wrapper, webauthnState *config.WebauthnState, shutdownTimeout time.Duration) (string, context.CancelFunc, *service, error) { +func startHTTPWithWebauthnStateAndShutdownTimeout(cfg config.Wrapper, webauthnState *config.WebauthnState, shutdownTimeout time.Duration) (string, context.CancelFunc, *webauthnService, error) { m := new(modelmocks.Model) assetDir := "../../gui" eventSub := new(eventmocks.BufferedSubscription) @@ -994,14 +1005,17 @@ func startHTTPWithWebauthnStateAndShutdownTimeout(cfg config.Wrapper, webauthnSt urService := ur.New(cfg, m, connections, false) mdb, _ := db.NewLowlevel(backend.OpenMemory(), events.NoopLogger) kdb := db.NewMiscDataNamespace(mdb) - svcAbstract, err := New(protocol.LocalDeviceID, cfg, assetDir, "syncthing", m, eventSub, diskEventSub, events.NoopLogger, discoverer, connections, urService, mockedSummary, errorLog, systemLog, false, kdb) + svcAbstract := New(protocol.LocalDeviceID, cfg, assetDir, "syncthing", m, eventSub, diskEventSub, events.NoopLogger, discoverer, connections, urService, mockedSummary, errorLog, systemLog, false, kdb) + svc := svcAbstract.(*service) + + webauthnService, err := newWebauthnService(cfg.GUI(), "test", events.NoopLogger, kdb, "webauthn") if err != nil { return "", nil, nil, err } - svc := svcAbstract.(*service) if webauthnState != nil { - svc.webauthnService.storeState(*webauthnState) + webauthnService.storeState(*webauthnState) } + svc.started = addrChan if shutdownTimeout > 0*time.Millisecond { @@ -1030,7 +1044,7 @@ func startHTTPWithWebauthnStateAndShutdownTimeout(cfg config.Wrapper, webauthnSt } baseURL := fmt.Sprintf("http://%s", net.JoinHostPort(host, strconv.Itoa(tcpAddr.Port))) - return baseURL, cancel, svc, nil + return baseURL, cancel, &webauthnService, nil } func TestCSRFRequired(t *testing.T) { @@ -1268,7 +1282,7 @@ func TestHostCheck(t *testing.T) { // An API service bound to localhost should reject non-localhost host Headers cfg := newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{RawAddress: "127.0.0.1:0"}) + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{RawAddress: "127.0.0.1:0"})) baseURL, cancel, _, err := startHTTP(cfg) if err != nil { t.Fatal(err) @@ -1328,10 +1342,10 @@ func TestHostCheck(t *testing.T) { // A server with InsecureSkipHostCheck set behaves differently cfg = newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{ + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{ RawAddress: "127.0.0.1:0", InsecureSkipHostCheck: true, - }) + })) baseURL, cancel, _, err = startHTTP(cfg) if err != nil { t.Fatal(err) @@ -1386,9 +1400,9 @@ func TestHostCheck(t *testing.T) { } cfg = newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{ + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{ RawAddress: "[::1]:0", - }) + })) baseURL, cancel, _, err = startHTTP(cfg) if err != nil { t.Fatal(err) @@ -1547,10 +1561,7 @@ func TestEventMasks(t *testing.T) { diskSub := new(eventmocks.BufferedSubscription) mdb, _ := db.NewLowlevel(backend.OpenMemory(), events.NoopLogger) kdb := db.NewMiscDataNamespace(mdb) - svcAbstract, err := New(protocol.LocalDeviceID, cfg, "", "syncthing", nil, defSub, diskSub, events.NoopLogger, nil, nil, nil, nil, nil, nil, false, kdb) - if err != nil { - t.Fatal("Failed to create server instance", err) - } + svcAbstract := New(protocol.LocalDeviceID, cfg, "", "syncthing", nil, defSub, diskSub, events.NoopLogger, nil, nil, nil, nil, nil, nil, false, kdb) svc := svcAbstract.(*service) if mask := svc.getEventMask(""); mask != DefaultEventMask { @@ -1681,14 +1692,14 @@ func TestConfigChanges(t *testing.T) { const testAPIKey = "foobarbaz" cfg := config.Configuration{ - GUI: config.GUIConfiguration{ + GUI: withTestDefaults(config.GUIConfiguration{ RawAddress: "127.0.0.1:0", RawUseTLS: false, APIKey: testAPIKey, // Needed because GUIConfiguration.prepare() assigns this a random value if empty WebauthnUserId: "AAAA", - }, + }), } tmpFile, err := os.CreateTemp("", "syncthing-testConfig-") @@ -1978,13 +1989,13 @@ func TestWebauthnRegistration(t *testing.T) { publicKeyCose, err := encodeCosePublicKey((privateKey.Public()).(*ecdsa.PublicKey)) testutil.FatalIfErr(t, err) - startServer := func(t *testing.T, credentials []config.WebauthnCredential) (string, string, string, *service, func(t *testing.T) webauthnProtocol.CredentialCreation) { + startServer := func(t *testing.T, credentials []config.WebauthnCredential) (string, string, string, *webauthnService, func(t *testing.T) webauthnProtocol.CredentialCreation, config.Wrapper) { cfg := newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{ + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{ User: "user", RawAddress: "127.0.0.1:0", - }) - baseURL, cancel, service, err := startHTTPWithWebauthnState(cfg, &config.WebauthnState{Credentials: credentials}) + })) + baseURL, cancel, webauthnService, err := startHTTPWithWebauthnState(cfg, &config.WebauthnState{Credentials: credentials}) if err != nil { t.Fatal(err) } @@ -2022,12 +2033,12 @@ func TestWebauthnRegistration(t *testing.T) { return options } - return baseURL, csrfTokenName, csrfTokenValue, service, getCreateOptions + return baseURL, csrfTokenName, csrfTokenValue, webauthnService, getCreateOptions, cfg } t.Run("Can register a new WebAuthn credential", func(t *testing.T) { t.Parallel() - baseURL, csrfTokenName, csrfTokenValue, service, getCreateOptions := startServer(t, nil) + baseURL, csrfTokenName, csrfTokenValue, webauthnService, getCreateOptions, cfg := startServer(t, nil) options := getCreateOptions(t) transports := []string{"transportA", "transportB"} @@ -2059,7 +2070,7 @@ func TestWebauthnRegistration(t *testing.T) { testutil.AssertEqual(t, t.Fatalf, getConfResp.StatusCode, http.StatusOK, "Failed to fetch config after WebAuthn registration: status %d", getConfResp.StatusCode) testutil.FatalIfErr(t, unmarshalTo(getConfResp.Body, &conf)) - eligibleCredentials, err := service.webauthnService.EligibleWebAuthnCredentials() + eligibleCredentials, err := webauthnService.EligibleWebAuthnCredentials(cfg.GUI()) testutil.FatalIfErr(t, err, "Failed to retrieve registered WebAuthn credentials") testutil.AssertEqual(t, t.Errorf, 0, len(eligibleCredentials), "Expected newly registered WebAuthn credential to not yet be committed to config") @@ -2067,7 +2078,7 @@ func TestWebauthnRegistration(t *testing.T) { t.Run("WebAuthn registration fails with wrong challenge", func(t *testing.T) { t.Parallel() - baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions := startServer(t, nil) + baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions, _ := startServer(t, nil) options := getCreateOptions(t) cryptoRand.Reader.Read(options.Response.Challenge) @@ -2080,7 +2091,7 @@ func TestWebauthnRegistration(t *testing.T) { t.Run("WebAuthn registration fails with wrong origin", func(t *testing.T) { t.Parallel() - baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions := startServer(t, nil) + baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions, _ := startServer(t, nil) options := getCreateOptions(t) cred := createWebauthnRegistrationResponse(options, []byte{1, 2, 3, 4}, publicKeyCose, "https://localhost", 0, nil, t) @@ -2092,7 +2103,7 @@ func TestWebauthnRegistration(t *testing.T) { t.Run("WebAuthn registration fails without user presence flag set", func(t *testing.T) { t.Parallel() - baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions := startServer(t, nil) + baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions, _ := startServer(t, nil) options := getCreateOptions(t) cred := createWebauthnRegistrationResponse(options, []byte{1, 2, 3, 4}, publicKeyCose, "https://localhost:8384", 0, nil, t) @@ -2116,7 +2127,7 @@ func TestWebauthnRegistration(t *testing.T) { t.Run("WebAuthn registration fails with malformed public key", func(t *testing.T) { t.Parallel() - baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions := startServer(t, nil) + baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions, _ := startServer(t, nil) options := getCreateOptions(t) corruptPublicKeyCose := bytes.Clone(publicKeyCose) corruptPublicKeyCose[7] ^= 0xff @@ -2129,7 +2140,7 @@ func TestWebauthnRegistration(t *testing.T) { t.Run("WebAuthn registration fails with credential ID duplicated in config", func(t *testing.T) { t.Parallel() - baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions := startServer(t, + baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions, _ := startServer(t, []config.WebauthnCredential{ { ID: base64.URLEncoding.EncodeToString([]byte{1, 2, 3, 4}), @@ -2149,7 +2160,7 @@ func TestWebauthnRegistration(t *testing.T) { t.Run("WebAuthn registration fails with credential ID duplicated in pending credentials", func(t *testing.T) { t.Parallel() - baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions := startServer(t, nil) + baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions, _ := startServer(t, nil) options := getCreateOptions(t) cred := createWebauthnRegistrationResponse(options, []byte{1, 2, 3, 4}, publicKeyCose, "https://localhost:8384", 0, nil, t) finishResp := httpPostCsrf(baseURL+"/rest/webauthn/register-finish", cred, csrfTokenName, csrfTokenValue, t) @@ -2166,7 +2177,7 @@ func TestWebauthnRegistration(t *testing.T) { t.Run("WebAuthn registration can only be attempted once per challenge", func(t *testing.T) { t.Parallel() - baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions := startServer(t, nil) + baseURL, csrfTokenName, csrfTokenValue, _, getCreateOptions, _ := startServer(t, nil) options := getCreateOptions(t) cred := createWebauthnRegistrationResponse(options, []byte{1, 2, 3, 4}, publicKeyCose, "https://localhost", 0, nil, t) finishResp := httpPostCsrf(baseURL+"/rest/webauthn/register-finish", cred, csrfTokenName, csrfTokenValue, t) @@ -2192,13 +2203,13 @@ func TestWebauthnAuthentication(t *testing.T) { startServer := func(t *testing.T, rpId, origin string, credentials []config.WebauthnCredential) (func(string, string, string) *http.Response, func(string, any) *http.Response, func() webauthnProtocol.CredentialAssertion) { t.Helper() cfg := newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{ + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{ User: "user", RawAddress: "localhost:0", WebauthnRpId: rpId, WebauthnOrigin: origin, RawUseTLS: true, - }) + })) baseURL, cancel, _, err := startHTTPWithWebauthnState(cfg, &config.WebauthnState{Credentials: credentials}) testutil.FatalIfErr(t, err, "Failed to start HTTP server") t.Cleanup(cancel) @@ -2692,7 +2703,7 @@ func TestPasswordOrWebauthnAuthentication(t *testing.T) { password := "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq" // bcrypt of "räksmörgås" in UTF-8 cfg := newMockedConfig() - cfg.GUIReturns(config.GUIConfiguration{ + cfg.GUIReturns(withTestDefaults(config.GUIConfiguration{ User: "user", Password: password, RawAddress: "localhost:0", @@ -2700,14 +2711,14 @@ func TestPasswordOrWebauthnAuthentication(t *testing.T) { // Don't need TLS in this test because the password enables the auth middleware, // and there's no browser to prevent us from generating a WebAuthn response without HTTPS RawUseTLS: false, - }) - baseURL, cancel, service, err := startHTTP(cfg) + })) + baseURL, cancel, webauthnService, err := startHTTP(cfg) testutil.FatalIfErr(t, err, "Failed to start HTTP server") t.Cleanup(cancel) testutil.FatalIfErr( t, - service.webauthnService.storeState(config.WebauthnState{ + webauthnService.storeState(config.WebauthnState{ Credentials: []config.WebauthnCredential{ { ID: base64.URLEncoding.EncodeToString([]byte{1, 2, 3, 4}), @@ -2813,12 +2824,12 @@ func TestWebauthnConfigChanges(t *testing.T) { shutdownTimeout := testutil.IfNotCI(0, 1000*time.Millisecond) const testAPIKey = "foobarbaz" - initialGuiCfg := config.GUIConfiguration{ + initialGuiCfg := withTestDefaults(config.GUIConfiguration{ RawAddress: "127.0.0.1:0", RawUseTLS: false, APIKey: testAPIKey, WebauthnUserId: "AAAA", - } + }) initTest := func(t *testing.T) (config.Configuration, func(*testing.T) (func(string) *http.Response, func(string, string, any))) { cfg := config.Configuration{ @@ -2923,12 +2934,12 @@ func TestWebauthnStateChanges(t *testing.T) { initTest := func(t *testing.T) (config.WebauthnState, func(*testing.T) (func(string) *http.Response, func(string, string, any))) { cfg := config.Configuration{ - GUI: config.GUIConfiguration{ + GUI: withTestDefaults(config.GUIConfiguration{ RawAddress: "127.0.0.1:0", RawUseTLS: false, APIKey: testAPIKey, WebauthnUserId: "AAAA", - }, + }), } tmpFile, err := os.CreateTemp("", "syncthing-testConfig-Webauthn-*") @@ -2959,11 +2970,11 @@ func TestWebauthnStateChanges(t *testing.T) { } startHttpServer := func(t *testing.T) (func(string) *http.Response, func(string, string, any)) { - baseURL, _, service, err := startHTTPWithWebauthnState(w, &initialWebauthnState) + baseURL, _, webauthnService, err := startHTTPWithWebauthnState(w, &initialWebauthnState) testutil.FatalIfErr(t, err) testutil.FatalIfErr(t, - service.webauthnService.storeState(initialWebauthnState), "Failed to set initial WebAuthn state") + webauthnService.storeState(initialWebauthnState), "Failed to set initial WebAuthn state") cli := &http.Client{ Timeout: 60 * time.Second, diff --git a/lib/api/confighandler.go b/lib/api/confighandler.go index 647fd7b5c2a..b6eafe1075b 100644 --- a/lib/api/confighandler.go +++ b/lib/api/confighandler.go @@ -20,9 +20,8 @@ import ( type configMuxBuilder struct { *httprouter.Router - id protocol.DeviceID - cfg config.Wrapper - webauthnService *webauthnService + id protocol.DeviceID + cfg config.Wrapper } func (c *configMuxBuilder) registerConfig(path string) { @@ -335,9 +334,7 @@ func (c *configMuxBuilder) adjustConfig(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) return } - if c.finish(w, waiter) { - c.webauthnService.credentialsPendingRegistration = make([]config.WebauthnCredential, 0) - } + c.finish(w, waiter) } func (c *configMuxBuilder) adjustFolder(w http.ResponseWriter, r *http.Request, folder config.FolderConfiguration, defaults bool) { diff --git a/lib/config/migrations.go b/lib/config/migrations.go index ee29f816858..9d8b9560269 100644 --- a/lib/config/migrations.go +++ b/lib/config/migrations.go @@ -18,6 +18,7 @@ import ( "github.com/syncthing/syncthing/lib/build" "github.com/syncthing/syncthing/lib/fs" "github.com/syncthing/syncthing/lib/netutil" + "github.com/syncthing/syncthing/lib/structutil" "github.com/syncthing/syncthing/lib/upgrade" ) @@ -104,6 +105,15 @@ func migrateToConfigV38(cfg *Configuration) { cfg.Options.UnackedNotificationIDs[i] = "guiAuthentication" } } + + // New required settings + defaultGuiCfg := structutil.WithDefaults(GUIConfiguration{}) + if cfg.GUI.WebauthnRpId == "" { + cfg.GUI.WebauthnRpId = defaultGuiCfg.WebauthnRpId + } + if cfg.GUI.WebauthnOrigin == "" { + cfg.GUI.WebauthnOrigin = defaultGuiCfg.WebauthnOrigin + } } func migrateToConfigV37(cfg *Configuration) { diff --git a/lib/structutil/structutil.go b/lib/structutil/structutil.go index e60a440f20e..a6ee8ac8ec1 100644 --- a/lib/structutil/structutil.go +++ b/lib/structutil/structutil.go @@ -16,7 +16,8 @@ type defaultParser interface { ParseDefault(string) error } -// SetDefaults sets default values on a struct, based on the default annotation. +// Set _all_ fields (not just fields with a zero value) with a `default` tag +// to the value of the `default` tag. func SetDefaults(data any) { s := reflect.ValueOf(data).Elem() t := s.Type() @@ -82,6 +83,12 @@ func SetDefaults(data any) { } } +// Expression-oriented variant of [SetDefaults]. +func WithDefaults[T any](data T) T { + SetDefaults(&data) + return data +} + func FillNilExceptDeprecated(data any) { fillNil(data, true) } diff --git a/lib/structutil/structutil_test.go b/lib/structutil/structutil_test.go index 0ded3b40c6d..15c849dc3a0 100644 --- a/lib/structutil/structutil_test.go +++ b/lib/structutil/structutil_test.go @@ -55,6 +55,58 @@ func TestSetDefaults(t *testing.T) { } } +func TestWithDefaults(t *testing.T) { + x := struct { + A string `default:"string"` + B int `default:"2"` + C float64 `default:"2.2"` + D bool `default:"true"` + E Defaulter `default:"defaulter"` + }{} + + if x.A != "" { + t.Error("string failed") + } else if x.B != 0 { + t.Error("int failed") + } else if x.C != 0 { + t.Errorf("float failed") + } else if x.D { + t.Errorf("bool failed") + } else if x.E.Value != "" { + t.Errorf("defaulter failed") + } + + y := WithDefaults(x) + + if x.A != "" { + t.Error("string failed") + } else if x.B != 0 { + t.Error("int failed") + } else if x.C != 0 { + t.Errorf("float failed") + } else if x.D { + t.Errorf("bool failed") + } else if x.E.Value != "" { + t.Errorf("defaulter failed") + } + + if y.A != "string" { + t.Error("string failed") + } + if y.B != 2 { + t.Error("int failed") + } + if y.C != 2.2 { + t.Errorf("float failed") + } + if !y.D { + t.Errorf("bool failed") + } + if y.E.Value != "defaulter" { + t.Errorf("defaulter failed") + } +} + func TestFillNillSlices(t *testing.T) { // Nil x := &struct { diff --git a/lib/syncthing/syncthing.go b/lib/syncthing/syncthing.go index abf0d8a87e5..7bbde2daa69 100644 --- a/lib/syncthing/syncthing.go +++ b/lib/syncthing/syncthing.go @@ -421,10 +421,7 @@ func (a *App) setupGUI(m model.Model, defaultSub, diskSub events.BufferedSubscri summaryService := model.NewFolderSummaryService(a.cfg, m, a.myID, a.evLogger) a.mainService.Add(summaryService) - apiSvc, err := api.New(a.myID, a.cfg, locations.Get(locations.GUIAssets), tlsDefaultCommonName, m, defaultSub, diskSub, a.evLogger, discoverer, connectionsService, urService, summaryService, errors, systemLog, a.opts.NoUpgrade, miscDB) - if err != nil { - return err - } + apiSvc := api.New(a.myID, a.cfg, locations.Get(locations.GUIAssets), tlsDefaultCommonName, m, defaultSub, diskSub, a.evLogger, discoverer, connectionsService, urService, summaryService, errors, systemLog, a.opts.NoUpgrade, miscDB) a.mainService.Add(apiSvc)