From dd136858f1ea40ad3c94191d647487fa4f31926c Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 18 Oct 2024 20:33:49 +0200 Subject: Adding upstream version 9.0.0. Signed-off-by: Daniel Baumann --- routers/web/user/setting/security/2fa.go | 260 ++++++++++++++++++++++++++ routers/web/user/setting/security/openid.go | 126 +++++++++++++ routers/web/user/setting/security/security.go | 148 +++++++++++++++ routers/web/user/setting/security/webauthn.go | 137 ++++++++++++++ 4 files changed, 671 insertions(+) create mode 100644 routers/web/user/setting/security/2fa.go create mode 100644 routers/web/user/setting/security/openid.go create mode 100644 routers/web/user/setting/security/security.go create mode 100644 routers/web/user/setting/security/webauthn.go (limited to 'routers/web/user/setting/security') diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go new file mode 100644 index 0000000..a145867 --- /dev/null +++ b/routers/web/user/setting/security/2fa.go @@ -0,0 +1,260 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "bytes" + "encoding/base64" + "html/template" + "image/png" + "net/http" + "strings" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +// RegenerateScratchTwoFactor regenerates the user's 2FA scratch code. +func RegenerateScratchTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + if auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + return + } + + token, err := t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to GenerateScratchToken", err) + return + } + + if err = auth.UpdateTwoFactor(ctx, t); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to UpdateTwoFactor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +// DisableTwoFactor deletes the user's 2FA settings. +func DisableTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + if auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + return + } + + if err = auth.DeleteTwoFactorByID(ctx, t.ID, ctx.Doer.ID); err != nil { + if auth.IsErrTwoFactorNotEnrolled(err) { + // There is a potential DB race here - we must have been disabled by another request in the intervening period + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to DeleteTwoFactorByID", err) + return + } + + if err := mailer.SendDisabledTOTP(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendDisabledTOTP", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +func twofaGenerateSecretAndQr(ctx *context.Context) bool { + var otpKey *otp.Key + var err error + uri := ctx.Session.Get("twofaUri") + if uri != nil { + otpKey, err = otp.NewKeyFromURL(uri.(string)) + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed NewKeyFromURL: ", err) + return false + } + } + // Filter unsafe character ':' in issuer + issuer := strings.ReplaceAll(setting.AppName+" ("+setting.Domain+")", ":", "") + if otpKey == nil { + otpKey, err = totp.Generate(totp.GenerateOpts{ + SecretSize: 40, + Issuer: issuer, + AccountName: ctx.Doer.Name, + }) + if err != nil { + ctx.ServerError("SettingsTwoFactor: totpGenerate Failed", err) + return false + } + } + + ctx.Data["TwofaSecret"] = otpKey.Secret() + img, err := otpKey.Image(320, 240) + if err != nil { + ctx.ServerError("SettingsTwoFactor: otpKey image generation failed", err) + return false + } + + var imgBytes bytes.Buffer + if err = png.Encode(&imgBytes, img); err != nil { + ctx.ServerError("SettingsTwoFactor: otpKey png encoding failed", err) + return false + } + + ctx.Data["QrUri"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes())) + + if err := ctx.Session.Set("twofaSecret", otpKey.Secret()); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaSecret", err) + return false + } + + if err := ctx.Session.Set("twofaUri", otpKey.String()); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaUri", err) + return false + } + + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + return true +} + +// EnrollTwoFactor shows the page where the user can enroll into 2FA. +func EnrollTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if t != nil { + // already enrolled - we should redirect back! + log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer) + ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor: GetTwoFactorByUID", err) + return + } + + if !twofaGenerateSecretAndQr(ctx) { + return + } + + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) +} + +// EnrollTwoFactorPost handles enrolling the user into 2FA. +func EnrollTwoFactorPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if t != nil { + // already enrolled + ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor: Failed to check if already enrolled with GetTwoFactorByUID", err) + return + } + + if ctx.HasError() { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) + return + } + + secretRaw := ctx.Session.Get("twofaSecret") + if secretRaw == nil { + ctx.Flash.Error(ctx.Tr("settings.twofa_failed_get_secret")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll") + return + } + + secret := secretRaw.(string) + if !totp.Validate(form.Passcode, secret) { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.Flash.Error(ctx.Tr("settings.passcode_invalid")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll") + return + } + + t = &auth.TwoFactor{ + UID: ctx.Doer.ID, + } + err = t.SetSecret(secret) + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set secret", err) + return + } + token, err := t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to generate scratch token", err) + return + } + + // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used + // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor + if err := ctx.Session.Delete("twofaSecret"); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to delete twofaSecret from the session: Error: %v", err) + } + if err := ctx.Session.Delete("twofaUri"); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to delete twofaUri from the session: Error: %v", err) + } + if err := ctx.Session.Release(); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to save changes to the session: %v", err) + } + + if err := mailer.SendTOTPEnrolled(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendTOTPEnrolled", err) + return + } + + if err = auth.NewTwoFactor(ctx, t); err != nil { + // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us. + // If there is a unique constraint fail we should just tolerate the error + ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security/openid.go b/routers/web/user/setting/security/openid.go new file mode 100644 index 0000000..8f788e1 --- /dev/null +++ b/routers/web/user/setting/security/openid.go @@ -0,0 +1,126 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// OpenIDPost response for change user's openid +func OpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddOpenIDForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + if ctx.HasError() { + loadSecurityData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsSecurity) + return + } + + // WARNING: specifying a wrong OpenID here could lock + // a user out of her account, would be better to + // verify/confirm the new OpenID before storing it + + // Also, consider allowing for multiple OpenID URIs + + id, err := openid.Normalize(form.Openid) + if err != nil { + loadSecurityData(ctx) + + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form) + return + } + form.Openid = id + log.Trace("Normalized id: " + id) + + oids, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = oids + + // Check that the OpenID is not already used + for _, obj := range oids { + if obj.URI == id { + loadSecurityData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &form) + return + } + } + + redirectTo := setting.AppURL + "user/settings/security" + url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) + if err != nil { + loadSecurityData(ctx) + + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form) + return + } + ctx.Redirect(url) +} + +func settingsOpenIDVerify(ctx *context.Context) { + log.Trace("Incoming call to: " + ctx.Req.URL.String()) + + fullURL := setting.AppURL + ctx.Req.URL.String()[1:] + log.Trace("Full URL: " + fullURL) + + id, err := openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &forms.AddOpenIDForm{ + Openid: id, + }) + return + } + + log.Trace("Verified ID: " + id) + + oid := &user_model.UserOpenID{UID: ctx.Doer.ID, URI: id} + if err = user_model.AddUserOpenID(ctx, oid); err != nil { + if user_model.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &forms.AddOpenIDForm{Openid: id}) + return + } + ctx.ServerError("AddUserOpenID", err) + return + } + log.Trace("Associated OpenID %s to user %s", id, ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +// DeleteOpenID response for delete user's openid +func DeleteOpenID(ctx *context.Context) { + if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { + ctx.ServerError("DeleteUserOpenID", err) + return + } + log.Trace("OpenID address deleted: %s", ctx.Doer.Name) + + ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} + +// ToggleOpenIDVisibility response for toggle visibility of user's openid +func ToggleOpenIDVisibility(ctx *context.Context) { + if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id")); err != nil { + ctx.ServerError("ToggleUserOpenIDVisibility", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go new file mode 100644 index 0000000..8d6859a --- /dev/null +++ b/routers/web/user/setting/security/security.go @@ -0,0 +1,148 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "net/http" + "sort" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsSecurity base.TplName = "user/settings/security/security" + tplSettingsTwofaEnroll base.TplName = "user/settings/security/twofa_enroll" +) + +// Security render change user's password page and 2FA +func Security(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.security") + ctx.Data["PageIsSettingsSecurity"] = true + + if ctx.FormString("openid.return_to") != "" { + settingsOpenIDVerify(ctx) + return + } + + loadSecurityData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsSecurity) +} + +// DeleteAccountLink delete a single account link +func DeleteAccountLink(ctx *context.Context) { + id := ctx.FormInt64("id") + if id <= 0 { + ctx.Flash.Error("Account link id is not given") + } else { + if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil { + ctx.Flash.Error("RemoveAccountLink: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) + } + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} + +func loadSecurityData(ctx *context.Context) { + enrolled, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + ctx.Data["TOTPEnrolled"] = enrolled + + credentials, err := auth_model.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + ctx.Data["WebAuthnCredentials"] = credentials + + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + + accountLinks, err := db.Find[user_model.ExternalLoginUser](ctx, user_model.FindExternalUserOptions{ + UserID: ctx.Doer.ID, + OrderBy: "login_source_id DESC", + }) + if err != nil { + ctx.ServerError("ListAccountLinks", err) + return + } + + // map the provider display name with the AuthSource + sources := make(map[*auth_model.Source]string) + for _, externalAccount := range accountLinks { + if authSource, err := auth_model.GetSourceByID(ctx, externalAccount.LoginSourceID); err == nil { + var providerDisplayName string + + type DisplayNamed interface { + DisplayName() string + } + + type Named interface { + Name() string + } + + if displayNamed, ok := authSource.Cfg.(DisplayNamed); ok { + providerDisplayName = displayNamed.DisplayName() + } else if named, ok := authSource.Cfg.(Named); ok { + providerDisplayName = named.Name() + } else { + providerDisplayName = authSource.Name + } + sources[authSource] = providerDisplayName + } + } + ctx.Data["AccountLinks"] = sources + + authSources, err := db.Find[auth_model.Source](ctx, auth_model.FindSourcesOptions{ + IsActive: optional.None[bool](), + LoginType: auth_model.OAuth2, + }) + if err != nil { + ctx.ServerError("FindSources", err) + return + } + + var orderedOAuth2Names []string + oauth2Providers := make(map[string]oauth2.Provider) + for _, source := range authSources { + provider, err := oauth2.CreateProviderFromSource(source) + if err != nil { + ctx.ServerError("CreateProviderFromSource", err) + return + } + oauth2Providers[source.Name] = provider + if source.IsActive { + orderedOAuth2Names = append(orderedOAuth2Names, source.Name) + } + } + + sort.Strings(orderedOAuth2Names) + + ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names + ctx.Data["OAuth2Providers"] = oauth2Providers + + openid, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openid +} diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go new file mode 100644 index 0000000..bfbc06c --- /dev/null +++ b/routers/web/user/setting/security/webauthn.go @@ -0,0 +1,137 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "errors" + "net/http" + "strconv" + "time" + + "code.gitea.io/gitea/models/auth" + wa "code.gitea.io/gitea/modules/auth/webauthn" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// WebAuthnRegister initializes the webauthn registration procedure +func WebAuthnRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm) + if form.Name == "" { + // Set name to the hexadecimal of the current time + form.Name = strconv.FormatInt(time.Now().UnixNano(), 16) + } + + cred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, form.Name) + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + if cred != nil { + ctx.Error(http.StatusConflict, "Name already taken") + return + } + + _ = ctx.Session.Delete("webauthnRegistration") + if err := ctx.Session.Set("webauthnName", form.Name); err != nil { + ctx.ServerError("Unable to set session key for webauthnName", err) + return + } + + credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer)) + if err != nil { + ctx.ServerError("Unable to BeginRegistration", err) + return + } + + // Save the session data as marshaled JSON + if err = ctx.Session.Set("webauthnRegistration", sessionData); err != nil { + ctx.ServerError("Unable to set session", err) + return + } + + ctx.JSON(http.StatusOK, credentialOptions) +} + +// WebauthnRegisterPost receives the response of the security key +func WebauthnRegisterPost(ctx *context.Context) { + name, ok := ctx.Session.Get("webauthnName").(string) + if !ok || name == "" { + ctx.ServerError("Get webauthnName", errors.New("no webauthnName")) + return + } + + // Load the session data + sessionData, ok := ctx.Session.Get("webauthnRegistration").(*webauthn.SessionData) + if !ok || sessionData == nil { + ctx.ServerError("Get registration", errors.New("no registration")) + return + } + defer func() { + _ = ctx.Session.Delete("webauthnRegistration") + }() + + // Verify that the challenge succeeded + cred, err := wa.WebAuthn.FinishRegistration((*wa.User)(ctx.Doer), *sessionData, ctx.Req) + if err != nil { + if pErr, ok := err.(*protocol.Error); ok { + log.Error("Unable to finish registration due to error: %v\nDevInfo: %s", pErr, pErr.DevInfo) + } + ctx.ServerError("CreateCredential", err) + return + } + + dbCred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, name) + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + if dbCred != nil { + ctx.Error(http.StatusConflict, "Name already taken") + return + } + + // Create the credential + _, err = auth.CreateCredential(ctx, ctx.Doer.ID, name, cred) + if err != nil { + ctx.ServerError("CreateCredential", err) + return + } + _ = ctx.Session.Delete("webauthnName") + + ctx.JSON(http.StatusCreated, cred) +} + +// WebauthnDelete deletes an security key by id +func WebauthnDelete(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) + cred, err := auth.GetWebAuthnCredentialByID(ctx, form.ID) + if err != nil || cred.UserID != ctx.Doer.ID { + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + log.Error("GetWebAuthnCredentialByID: %v", err) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") + return + } + + if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("GetWebAuthnCredentialByID", err) + return + } + + if err := mailer.SendRemovedSecurityKey(ctx, ctx.Doer, cred.Name); err != nil { + ctx.ServerError("SendRemovedSecurityKey", err) + return + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} -- cgit v1.2.3