diff --git a/authz/authz.go b/authz/authz.go index bbae3e2a..607a0c3c 100644 --- a/authz/authz.go +++ b/authz/authz.go @@ -105,6 +105,7 @@ p, *, *, GET, /api/get-saml-login, *, * p, *, *, POST, /api/acs, *, * p, *, *, GET, /api/saml/metadata, *, * p, *, *, *, /cas, *, * +p, *, *, *, /api/webauthn, *, * ` sa := stringadapter.NewAdapter(ruleText) diff --git a/controllers/webauthn.go b/controllers/webauthn.go new file mode 100644 index 00000000..474436e2 --- /dev/null +++ b/controllers/webauthn.go @@ -0,0 +1,138 @@ +// Copyright 2022 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controllers + +import ( + "bytes" + "io/ioutil" + + "github.com/casdoor/casdoor/object" + "github.com/casdoor/casdoor/util" + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" +) + +// @Title WebAuthnSignupBegin +// @Tag User API +// @Description WebAuthn Registration Flow 1st stage +// @Success 200 {object} protocol.CredentialCreation The CredentialCreationOptions object +// @router /webauthn/signup/begin [get] +func (c *ApiController) WebAuthnSignupBegin() { + webauthnObj := object.GetWebAuthnObject(c.Ctx.Request.Host) + user := c.getCurrentUser() + if user == nil { + c.ResponseError("Please login first.") + return + } + + registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) { + credCreationOpts.CredentialExcludeList = user.CredentialExcludeList() + } + options, sessionData, err := webauthnObj.BeginRegistration( + user, + registerOptions, + ) + if err != nil { + c.ResponseError(err.Error()) + return + } + c.SetSession("registration", *sessionData) + c.Data["json"] = options + c.ServeJSON() +} + +// @Title WebAuthnSignupFinish +// @Tag User API +// @Description WebAuthn Registration Flow 2nd stage +// @Param body body protocol.CredentialCreationResponse true "authenticator attestation Response" +// @Success 200 {object} Response "The Response object" +// @router /webauthn/signup/finish [post] +func (c *ApiController) WebAuthnSignupFinish() { + webauthnObj := object.GetWebAuthnObject(c.Ctx.Request.Host) + user := c.getCurrentUser() + if user == nil { + c.ResponseError("Please login first.") + return + } + sessionObj := c.GetSession("registration") + sessionData, ok := sessionObj.(webauthn.SessionData) + if !ok { + c.ResponseError("Please call WebAuthnSignupBegin first") + return + } + c.Ctx.Request.Body = ioutil.NopCloser(bytes.NewBuffer(c.Ctx.Input.RequestBody)) + + credential, err := webauthnObj.FinishRegistration(user, sessionData, c.Ctx.Request) + if err != nil { + c.ResponseError(err.Error()) + return + } + isGlobalAdmin := c.IsGlobalAdmin() + user.AddCredentials(*credential, isGlobalAdmin) + c.ResponseOk() +} + +// @Title WebAuthnSigninBegin +// @Tag Login API +// @Description WebAuthn Login Flow 1st stage +// @Param owner query string true "owner" +// @Param name query string true "name" +// @Success 200 {object} protocol.CredentialAssertion The CredentialAssertion object +// @router /webauthn/signin/begin [get] +func (c *ApiController) WebAuthnSigninBegin() { + webauthnObj := object.GetWebAuthnObject(c.Ctx.Request.Host) + userOwner := c.Input().Get("owner") + userName := c.Input().Get("name") + user := object.GetUserByFields(userOwner, userName) + if user == nil { + c.ResponseError("Please Giveout Owner and Username.") + return + } + options, sessionData, err := webauthnObj.BeginLogin(user) + if err != nil { + c.ResponseError(err.Error()) + return + } + c.SetSession("authentication", *sessionData) + c.Data["json"] = options + c.ServeJSON() +} + +// @Title WebAuthnSigninBegin +// @Tag Login API +// @Description WebAuthn Login Flow 2nd stage +// @Param body body protocol.CredentialAssertionResponse true "authenticator assertion Response" +// @Success 200 {object} Response "The Response object" +// @router /webauthn/signin/finish [post] +func (c *ApiController) WebAuthnSigninFinish() { + webauthnObj := object.GetWebAuthnObject(c.Ctx.Request.Host) + sessionObj := c.GetSession("authentication") + sessionData, ok := sessionObj.(webauthn.SessionData) + if !ok { + c.ResponseError("Please call WebAuthnSigninBegin first") + return + } + c.Ctx.Request.Body = ioutil.NopCloser(bytes.NewBuffer(c.Ctx.Input.RequestBody)) + userId := string(sessionData.UserID) + user := object.GetUser(userId) + _, err := webauthnObj.FinishLogin(user, sessionData, c.Ctx.Request) + if err != nil { + c.ResponseError(err.Error()) + return + } + c.SetSessionUsername(userId) + util.LogInfo(c.Ctx, "API: [%s] signed in", userId) + c.ResponseOk(userId) +} diff --git a/go.mod b/go.mod index 0c169385..00d2ed6c 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/casdoor/goth v1.69.0-FIX2 github.com/casdoor/oss v1.2.0 github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f + github.com/duo-labs/webauthn v0.0.0-20211221191814-a22482edaa3b github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df github.com/go-ldap/ldap/v3 v3.3.0 github.com/go-pay/gopay v1.5.72 diff --git a/go.sum b/go.sum index f06f0013..bbca77da 100644 --- a/go.sum +++ b/go.sum @@ -111,6 +111,8 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/cfssl v0.0.0-20190726000631-633726f6bcb7 h1:Puu1hUwfps3+1CUzYdAZXijuvLuRMirgiXdf3zsM2Ig= +github.com/cloudflare/cfssl v0.0.0-20190726000631-633726f6bcb7/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/couchbase/go-couchbase v0.0.0-20200519150804-63f3cdb75e0d/go.mod h1:TWI8EKQMs5u5jLKW/tsb9VwauIrMIxQG1r5fMsswK5U= @@ -124,6 +126,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f h1:q/DpyjJjZs94bziQ7YkBmIlpqbVP7yw179rnzoNVX1M= github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f/go.mod h1:QGrK8vMWWHQYQ3QU9bw9Y9OPNfxccGzfb41qjvVeXtY= github.com/denisenkom/go-mssqldb v0.0.0-20200428022330-06a60b6afbbc/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/duo-labs/webauthn v0.0.0-20211221191814-a22482edaa3b h1:L63RATZFZuFMXy6ixnKmv3eNAXwYQF6HW1vd4IYsQqQ= +github.com/duo-labs/webauthn v0.0.0-20211221191814-a22482edaa3b/go.mod h1:EYSpSkwoEcryMmQGfhol2IiB3IMN9IIIaNd/wcAQMGQ= github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/elastic/go-elasticsearch/v6 v6.8.5/go.mod h1:UwaDJsD3rWLM5rKNFzv9hgox93HoX8utj1kxD9aFUcI= github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk= @@ -135,6 +139,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= +github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/glendc/gopher-json v0.0.0-20170414221815-dc4743023d0c/go.mod h1:Gja1A+xZ9BoviGJNA2E9vFkPjjsl+CoJxSXiQM1UXtw= github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8= github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= @@ -164,6 +170,7 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= +github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= @@ -201,6 +208,8 @@ github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNu github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/certificate-transparency-go v1.0.21 h1:Yf1aXowfZ2nuboBsg7iYGLmwsOARdV86pfH3g95wXmE= +github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -298,6 +307,8 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJK github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -397,6 +408,8 @@ github.com/ugorji/go v0.0.0-20171122102828-84cb69a8af83/go.mod h1:hnLbHMwcvSihnD github.com/volcengine/volc-sdk-golang v1.0.19 h1:jJp+aJgK0e//rZ9I0K2Y7ufJwvuZRo/AQsYDynXMNgA= github.com/volcengine/volc-sdk-golang v1.0.19/go.mod h1:+GGi447k4p1I5PNdbpG2GLaF0Ui9vIInTojMM0IfSS4= github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -413,6 +426,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/object/application.go b/object/application.go index fa4f6f78..d2e3a2d1 100644 --- a/object/application.go +++ b/object/application.go @@ -47,6 +47,7 @@ type Application struct { EnableSigninSession bool `json:"enableSigninSession"` EnableCodeSignin bool `json:"enableCodeSignin"` EnableSamlCompress bool `json:"enableSamlCompress"` + EnableWebAuthn bool `json:"enableWebAuthn"` Providers []*ProviderItem `xorm:"mediumtext" json:"providers"` SignupItems []*SignupItem `xorm:"varchar(1000)" json:"signupItems"` GrantTypes []string `xorm:"varchar(1000)" json:"grantTypes"` diff --git a/object/init.go b/object/init.go index 14c3365c..1e25acd7 100644 --- a/object/init.go +++ b/object/init.go @@ -15,9 +15,11 @@ package object import ( + "encoding/gob" "io/ioutil" "github.com/casdoor/casdoor/util" + "github.com/duo-labs/webauthn/webauthn" ) func InitDb() { @@ -29,6 +31,8 @@ func InitDb() { initBuiltInCert() initBuiltInLdap() } + + initWebAuthn() } func initBuiltInOrganization() bool { @@ -72,6 +76,7 @@ func initBuiltInOrganization() bool { {Name: "Is global admin", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"}, {Name: "Is forbidden", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"}, {Name: "Is deleted", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"}, + {Name: "WebAuthn credentials", Visible: true, ViewRule: "Self", ModifyRule: "Self"}, }, } AddOrganization(organization) @@ -221,3 +226,7 @@ func initBuiltInProvider() { } AddProvider(provider) } + +func initWebAuthn() { + gob.Register(webauthn.SessionData{}) +} diff --git a/object/user.go b/object/user.go index 2ddd2b9b..1d08b86f 100644 --- a/object/user.go +++ b/object/user.go @@ -20,6 +20,7 @@ import ( "github.com/casdoor/casdoor/conf" "github.com/casdoor/casdoor/util" + "github.com/duo-labs/webauthn/webauthn" "xorm.io/core" ) @@ -99,6 +100,8 @@ type User struct { Douyin string `xorm:"douyin vachar(100)" json:"douyin"` Custom string `xorm:"custom varchar(100)" json:"custom"` + WebauthnCredentials []webauthn.Credential `xorm:"webauthnCredentials blob" json:"webauthnCredentials"` + Ldap string `xorm:"ldap varchar(100)" json:"ldap"` Properties map[string]string `json:"properties"` } @@ -328,7 +331,7 @@ func UpdateUser(id string, user *User, columns []string, isGlobalAdmin bool) boo if len(columns) == 0 { columns = []string{"owner", "display_name", "avatar", "location", "address", "region", "language", "affiliation", "title", "homepage", "bio", "score", "tag", "signup_application", - "is_admin", "is_global_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties"} + "is_admin", "is_global_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials"} } if isGlobalAdmin { columns = append(columns, "name", "email", "phone") diff --git a/object/user_webauthn.go b/object/user_webauthn.go new file mode 100644 index 00000000..e3c25821 --- /dev/null +++ b/object/user_webauthn.go @@ -0,0 +1,102 @@ +// Copyright 2022 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package object + +import ( + "encoding/base64" + "net/url" + "strings" + + "github.com/astaxie/beego" + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" +) + +func GetWebAuthnObject(host string) *webauthn.WebAuthn { + var err error + + origin := beego.AppConfig.String("origin") + if origin == "" { + _, origin = getOriginFromHost(host) + } + + localUrl, err := url.Parse(origin) + if err != nil { + panic("error when parsing origin:" + err.Error()) + } + + webAuthn, err := webauthn.New(&webauthn.Config{ + RPDisplayName: beego.AppConfig.String("appname"), // Display Name for your site + RPID: strings.Split(localUrl.Host, ":")[0], // Generally the domain name for your site, it's ok because splits cannot return empty array + RPOrigin: origin, // The origin URL for WebAuthn requests + // RPIcon: "https://duo.com/logo.png", // Optional icon URL for your site + }) + if err != nil { + panic(err) + } + + return webAuthn +} + +// implementation of webauthn.User interface +func (u *User) WebAuthnID() []byte { + return []byte(u.GetId()) +} + +func (u *User) WebAuthnName() string { + return u.Name +} + +func (u *User) WebAuthnDisplayName() string { + return u.DisplayName +} + +func (u *User) WebAuthnCredentials() []webauthn.Credential { + return u.WebauthnCredentials +} + +func (u *User) WebAuthnIcon() string { + return u.Avatar +} + +// CredentialExcludeList returns a CredentialDescriptor array filled with all the user's credentials +func (u *User) CredentialExcludeList() []protocol.CredentialDescriptor { + credentials := u.WebAuthnCredentials() + credentialExcludeList := []protocol.CredentialDescriptor{} + for _, cred := range credentials { + descriptor := protocol.CredentialDescriptor{ + Type: protocol.PublicKeyCredentialType, + CredentialID: cred.ID, + } + credentialExcludeList = append(credentialExcludeList, descriptor) + } + + return credentialExcludeList +} + +func (u *User) AddCredentials(credential webauthn.Credential, isGlobalAdmin bool) bool { + u.WebauthnCredentials = append(u.WebauthnCredentials, credential) + return UpdateUser(u.GetId(), u, []string{"webauthnCredentials"}, isGlobalAdmin) +} + +func (u *User) DeleteCredentials(credentialIdBase64 string) bool { + for i, credential := range u.WebauthnCredentials { + if base64.StdEncoding.EncodeToString(credential.ID) == credentialIdBase64 { + u.WebauthnCredentials = append(u.WebauthnCredentials[0:i], u.WebauthnCredentials[i+1:]...) + return UpdateUserForAllFields(u.GetId(), u) + } + } + return false +} diff --git a/routers/authz_filter.go b/routers/authz_filter.go index c6d3a935..4070ae38 100644 --- a/routers/authz_filter.go +++ b/routers/authz_filter.go @@ -109,6 +109,10 @@ func getUrlPath(urlPath string) string { return "/api/login/oauth" } + if strings.HasPrefix(urlPath, "/api/webauthn") { + return "/api/webauthn" + } + return urlPath } diff --git a/routers/router.go b/routers/router.go index 509742ad..bcc55cbc 100644 --- a/routers/router.go +++ b/routers/router.go @@ -191,4 +191,9 @@ func initAPI() { beego.Router("/cas/:organization/:application/p3/proxyValidate", &controllers.RootController{}, "GET:CasP3ServiceAndProxyValidate") beego.Router("/cas/:organization/:application/samlValidate", &controllers.RootController{}, "POST:SamlValidate") + beego.Router("/api/webauthn/signup/begin", &controllers.ApiController{}, "Get:WebAuthnSignupBegin") + beego.Router("/api/webauthn/signup/finish", &controllers.ApiController{}, "Post:WebAuthnSignupFinish") + beego.Router("/api/webauthn/signin/begin", &controllers.ApiController{}, "Get:WebAuthnSigninBegin") + beego.Router("/api/webauthn/signin/finish", &controllers.ApiController{}, "Post:WebAuthnSigninFinish") + } diff --git a/web/src/AccountTable.js b/web/src/AccountTable.js index 93502982..58da2648 100644 --- a/web/src/AccountTable.js +++ b/web/src/AccountTable.js @@ -92,6 +92,7 @@ class AccountTable extends React.Component { {name: "Is global admin", displayName: i18next.t("user:Is global admin")}, {name: "Is forbidden", displayName: i18next.t("user:Is forbidden")}, {name: "Is deleted", displayName: i18next.t("user:Is deleted")}, + {name: "WebAuthn credentials", displayName: i18next.t("user:WebAuthn credentials")}, ]; const getItemDisplayName = (text) => { diff --git a/web/src/ApplicationEditPage.js b/web/src/ApplicationEditPage.js index c49f948d..84fe13d8 100644 --- a/web/src/ApplicationEditPage.js +++ b/web/src/ApplicationEditPage.js @@ -353,6 +353,16 @@ class ApplicationEditPage extends React.Component { }} /> + + + {Setting.getLabel(i18next.t("application:Enable WebAuthn signin"), i18next.t("application:Enable WebAuthn signin - Tooltip"))} : + + + { + this.updateApplicationField("enableWebAuthn", checked); + }} /> + + {Setting.getLabel(i18next.t("general:Signup URL"), i18next.t("general:Signup URL - Tooltip"))} : diff --git a/web/src/UserEditPage.js b/web/src/UserEditPage.js index fb9b8e26..cda592d9 100644 --- a/web/src/UserEditPage.js +++ b/web/src/UserEditPage.js @@ -15,6 +15,7 @@ import React from "react"; import {Button, Card, Col, Input, Result, Row, Select, Spin, Switch} from "antd"; import * as UserBackend from "./backend/UserBackend"; +import * as UserWebauthnBackend from "./backend/UserWebauthnBackend"; import * as OrganizationBackend from "./backend/OrganizationBackend"; import * as Setting from "./Setting"; import {LinkOutlined} from "@ant-design/icons"; @@ -27,6 +28,7 @@ import AffiliationSelect from "./common/AffiliationSelect"; import OAuthWidget from "./common/OAuthWidget"; import SamlWidget from "./common/SamlWidget"; import SelectRegionBox from "./SelectRegionBox"; +import WebAuthnCredentialTable from "./WebauthnCredentialTable"; import {Controlled as CodeMirror} from "react-codemirror2"; import "codemirror/lib/codemirror.css"; @@ -515,6 +517,17 @@ class UserEditPage extends React.Component { }} /> + ) + } else if(accountItem.name === "WebAuthn credentials") { + return ( + + + {Setting.getLabel(i18next.t("user:WebAuthn credentials"), i18next.t("user:WebAuthn credentials"))} : + + + {this.updateUserField('webauthnCredentials',table)}} refresh={this.getUser.bind(this)}/> + + ); } } diff --git a/web/src/WebauthnCredentialTable.js b/web/src/WebauthnCredentialTable.js new file mode 100644 index 00000000..48c28802 --- /dev/null +++ b/web/src/WebauthnCredentialTable.js @@ -0,0 +1,72 @@ +// Copyright 2022 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from "react"; +import {Button, Table} from 'antd'; +import i18next from "i18next"; +import * as UserWebauthnBackend from "./backend/UserWebauthnBackend"; +import * as Setting from "./Setting"; + +class WebAuthnCredentialTable extends React.Component { + render() { + const columns = [ + { + title: i18next.t("user:WebAuthn credentials"), + dataIndex: 'ID', + key: 'ID', + }, + { + title: i18next.t("general:Action"), + key: 'action', + render: (text, record, index) => { + return ( + + ) + } + } + ] + return ( + ( +
+ {i18next.t("user:WebAuthn credentials")}     + +
+ )} + />) + } + + deleteRow(table, i) { + table = Setting.deleteRow(table, i); + this.props.updateTable(table); + } + + registerWebAuthn() { + UserWebauthnBackend.registerWebauthnCredential().then((res) => { + if (res.msg === "") { + Setting.showMessage("success", `Successfully added webauthn credentials`); + } else { + Setting.showMessage("error", res.msg); + } + + this.props.refresh(); + }).catch(error => { + Setting.showMessage("error", `Failed to connect to server: ${error}`); + }); + } +} + +export default WebAuthnCredentialTable; diff --git a/web/src/auth/LoginPage.js b/web/src/auth/LoginPage.js index c420fef0..135151f8 100644 --- a/web/src/auth/LoginPage.js +++ b/web/src/auth/LoginPage.js @@ -14,8 +14,9 @@ import React from "react"; import {Link} from "react-router-dom"; -import {Button, Checkbox, Col, Form, Input, Result, Row, Spin} from "antd"; +import {Button, Checkbox, Col, Form, Input, Result, Row, Spin, Tabs} from "antd"; import {LockOutlined, UserOutlined} from "@ant-design/icons"; +import * as UserWebauthnBackend from "../backend/UserWebauthnBackend"; import * as AuthBackend from "./AuthBackend"; import * as ApplicationBackend from "../backend/ApplicationBackend"; import * as Provider from "./Provider"; @@ -49,6 +50,8 @@ import CustomGithubCorner from "../CustomGithubCorner"; import {CountDownInput} from "../common/CountDownInput"; import BilibiliLoginButton from "./BilibiliLoginButton"; +const { TabPane } = Tabs; + class LoginPage extends React.Component { constructor(props) { super(props); @@ -65,7 +68,9 @@ class LoginPage extends React.Component { validEmailOrPhone: false, validEmail: false, validPhone: false, + loginMethod: "password" }; + if (this.state.type === "cas" && props.match?.params.casApplicationName !== undefined) { this.state.owner = props.match?.params.owner; this.state.applicationName = props.match?.params.casApplicationName; @@ -407,6 +412,7 @@ class LoginPage extends React.Component { ]} > + {this.renderMethodChoiceBox()} { - this.state.isCodeSignin ? ( - - - - ) : ( - - } - type="password" - placeholder={i18next.t("login:Password")} - disabled={!application.enablePassword} - /> - - ) + this.renderPasswordOrCodeInput() } @@ -483,14 +469,24 @@ class LoginPage extends React.Component { - + { + this.state.loginMethod === "password" ? + ( + + ) : + ( + + ) + } { !application.enableSignUp ? null : this.renderFooter(application) } @@ -624,6 +620,113 @@ class LoginPage extends React.Component { ); } + signInWithWebAuthn() { + if (this.state.username === null || this.state.username === "") { + Setting.showMessage("error", "username is required for webauthn login"); + return; + } + + let application = this.getApplicationObj(); + return fetch(`${Setting.ServerUrl}/api/webauthn/signin/begin?owner=${application.organization}&name=${this.state.username}`, { + method: "GET", + credentials: "include" + }) + .then(res => res.json()) + .then((credentialRequestOptions) => { + if ("status" in credentialRequestOptions) { + Setting.showMessage("error", credentialRequestOptions.msg); + throw credentialRequestOptions.status.msg; + } + + credentialRequestOptions.publicKey.challenge = UserWebauthnBackend.webAuthnBufferDecode(credentialRequestOptions.publicKey.challenge); + credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem) { + listItem.id = UserWebauthnBackend.webAuthnBufferDecode(listItem.id); + }); + + return navigator.credentials.get({ + publicKey: credentialRequestOptions.publicKey + }) + }) + .then((assertion) => { + let authData = assertion.response.authenticatorData; + let clientDataJSON = assertion.response.clientDataJSON; + let rawId = assertion.rawId; + let sig = assertion.response.signature; + let userHandle = assertion.response.userHandle; + return fetch(`${Setting.ServerUrl}/api/webauthn/signin/finish`, { + method: "POST", + credentials: "include", + body: JSON.stringify({ + id: assertion.id, + rawId: UserWebauthnBackend.webAuthnBufferEncode(rawId), + type: assertion.type, + response: { + authenticatorData: UserWebauthnBackend.webAuthnBufferEncode(authData), + clientDataJSON: UserWebauthnBackend.webAuthnBufferEncode(clientDataJSON), + signature: UserWebauthnBackend.webAuthnBufferEncode(sig), + userHandle: UserWebauthnBackend.webAuthnBufferEncode(userHandle), + }, + }) + }) + .then(res => res.json()).then((res) => { + if (res.msg === "") { + Setting.showMessage("success", `Successfully logged in with webauthn credentials`); + Setting.goToLink("/"); + } else { + Setting.showMessage("error", res.msg); + } + }) + .catch(error => { + Setting.showMessage("error", `Failed to connect to server: ${error}`); + }); + }) + } + + renderPasswordOrCodeInput() { + let application = this.getApplicationObj(); + if (this.state.loginMethod === "password") { + return this.state.isCodeSignin ? ( + + + + ) : ( + + } + type="password" + placeholder={i18next.t("login:Password")} + disabled={!application.enablePassword} + /> + + ) + } + } + + renderMethodChoiceBox(){ + let application = this.getApplicationObj(); + if (application.enableWebAuthn) { + return ( +
+ {this.setState({loginMethod: key})}} centered> + + + + + +
+ ) + } + } + render() { const application = this.getApplicationObj(); if (application === null) { diff --git a/web/src/backend/UserWebauthnBackend.js b/web/src/backend/UserWebauthnBackend.js new file mode 100644 index 00000000..3d9ea2b1 --- /dev/null +++ b/web/src/backend/UserWebauthnBackend.js @@ -0,0 +1,78 @@ +// Copyright 2022 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as Setting from "../Setting"; + +export function registerWebauthnCredential() { + return fetch(`${Setting.ServerUrl}/api/webauthn/signup/begin`, { + method: "GET", + credentials: "include" + }) + .then(res => res.json()) + .then((credentialCreationOptions) => { + credentialCreationOptions.publicKey.challenge = webAuthnBufferDecode(credentialCreationOptions.publicKey.challenge); + credentialCreationOptions.publicKey.user.id = webAuthnBufferDecode(credentialCreationOptions.publicKey.user.id); + if (credentialCreationOptions.publicKey.excludeCredentials) { + for (var i = 0; i < credentialCreationOptions.publicKey.excludeCredentials.length; i++) { + credentialCreationOptions.publicKey.excludeCredentials[i].id = webAuthnBufferDecode(credentialCreationOptions.publicKey.excludeCredentials[i].id); + } + } + return navigator.credentials.create({ + publicKey: credentialCreationOptions.publicKey + }) + }) + .then((credential) => { + let attestationObject = credential.response.attestationObject; + let clientDataJSON = credential.response.clientDataJSON; + let rawId = credential.rawId; + return fetch(`${Setting.ServerUrl}/api/webauthn/signup/finish`, { + method: "POST", + credentials: "include", + body: JSON.stringify({ + id: credential.id, + rawId: webAuthnBufferEncode(rawId), + type: credential.type, + response: { + attestationObject: webAuthnBufferEncode(attestationObject), + clientDataJSON: webAuthnBufferEncode(clientDataJSON), + }, + }) + }) + .then(res => res.json()); + }) +} + +export function deleteUserWebAuthnCredential(credentialID) { + let form = new FormData() + form.append("credentialID", credentialID) + return fetch(`${Setting.ServerUrl}/api/webauthn/delete-credential`, { + method: "POST", + credentials: "include", + body: form, + dataType: "text" + }).then(res => res.json()) +} + +// Base64 to ArrayBuffer +export function webAuthnBufferDecode(value) { + return Uint8Array.from(atob(value), c => c.charCodeAt(0)); +} + +// ArrayBuffer to URLBase64 +export function webAuthnBufferEncode(value) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(value))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, "");; +} diff --git a/web/src/locales/de/data.json b/web/src/locales/de/data.json index 85d580d2..e3fa6a24 100644 --- a/web/src/locales/de/data.json +++ b/web/src/locales/de/data.json @@ -13,6 +13,8 @@ "Edit Application": "Anwendung bearbeiten", "Enable SAML compress": "Enable SAML compress", "Enable SAML compress - Tooltip": "Enable SAML compress - Tooltip", + "Enable WebAuthn signin": "Enable WebAuthn signin", + "Enable WebAuthn signin - Tooltip": "Enable WebAuthn signin - Tooltip", "Enable code signin": "Code-Anmeldung aktivieren", "Enable code signin - Tooltip": "Aktiviere Codeanmeldung - Tooltip", "Enable signin session - Tooltip": "Aktiviere Anmeldesession - Tooltip", @@ -248,6 +250,7 @@ "Please input your password, at least 6 characters!": "Bitte geben Sie Ihr Passwort ein, mindestens 6 Zeichen!", "Please input your username, Email or phone!": "Bitte geben Sie Ihren Benutzernamen, E-Mail oder Telefon ein!", "Sign In": "Anmelden", + "Sign in with WebAuthn": "Sign in with WebAuthn", "Sign in with code": "Mit Code anmelden", "Sign in with password": "Mit Passwort anmelden", "Sign in with {type}": "Mit {type} anmelden", @@ -639,6 +642,7 @@ "Unlink": "Link aufheben", "Upload (.xlsx)": "Upload (.xlsx)", "Upload a photo": "Foto hochladen", + "WebAuthn credentials": "WebAuthn credentials", "input password": "Passwort eingeben" }, "webhook": { diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index 470a0d3d..1a9ac00c 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -13,6 +13,8 @@ "Edit Application": "Edit Application", "Enable SAML compress": "Enable SAML compress", "Enable SAML compress - Tooltip": "Enable SAML compress - Tooltip", + "Enable WebAuthn signin": "Enable WebAuthn signin", + "Enable WebAuthn signin - Tooltip": "Enable WebAuthn signin - Tooltip", "Enable code signin": "Enable code signin", "Enable code signin - Tooltip": "Enable code signin - Tooltip", "Enable signin session - Tooltip": "Enable signin session - Tooltip", @@ -248,6 +250,7 @@ "Please input your password, at least 6 characters!": "Please input your password, at least 6 characters!", "Please input your username, Email or phone!": "Please input your username, Email or phone!", "Sign In": "Sign In", + "Sign in with WebAuthn": "Sign in with WebAuthn", "Sign in with code": "Sign in with code", "Sign in with password": "Sign in with password", "Sign in with {type}": "Sign in with {type}", @@ -639,6 +642,7 @@ "Unlink": "Unlink", "Upload (.xlsx)": "Upload (.xlsx)", "Upload a photo": "Upload a photo", + "WebAuthn credentials": "WebAuthn credentials", "input password": "input password" }, "webhook": { diff --git a/web/src/locales/fr/data.json b/web/src/locales/fr/data.json index 0970bbe8..02a996f8 100644 --- a/web/src/locales/fr/data.json +++ b/web/src/locales/fr/data.json @@ -13,6 +13,8 @@ "Edit Application": "Modifier l'application", "Enable SAML compress": "Enable SAML compress", "Enable SAML compress - Tooltip": "Enable SAML compress - Tooltip", + "Enable WebAuthn signin": "Enable WebAuthn signin", + "Enable WebAuthn signin - Tooltip": "Enable WebAuthn signin - Tooltip", "Enable code signin": "Activer la connexion au code", "Enable code signin - Tooltip": "Activer la connexion au code - infobulle", "Enable signin session - Tooltip": "Activer la session de connexion - infobulle", @@ -248,6 +250,7 @@ "Please input your password, at least 6 characters!": "Veuillez entrer votre mot de passe, au moins 6 caractères !", "Please input your username, Email or phone!": "Veuillez entrer votre nom d'utilisateur, votre e-mail ou votre téléphone!", "Sign In": "Se connecter", + "Sign in with WebAuthn": "Sign in with WebAuthn", "Sign in with code": "Se connecter avec le code", "Sign in with password": "Se connecter avec le mot de passe", "Sign in with {type}": "Se connecter avec {type}", @@ -639,6 +642,7 @@ "Unlink": "Délier", "Upload (.xlsx)": "Télécharger (.xlsx)", "Upload a photo": "Télécharger une photo", + "WebAuthn credentials": "WebAuthn credentials", "input password": "saisir le mot de passe" }, "webhook": { diff --git a/web/src/locales/ja/data.json b/web/src/locales/ja/data.json index 54e0aae3..b97783f4 100644 --- a/web/src/locales/ja/data.json +++ b/web/src/locales/ja/data.json @@ -13,6 +13,8 @@ "Edit Application": "アプリケーションを編集", "Enable SAML compress": "Enable SAML compress", "Enable SAML compress - Tooltip": "Enable SAML compress - Tooltip", + "Enable WebAuthn signin": "Enable WebAuthn signin", + "Enable WebAuthn signin - Tooltip": "Enable WebAuthn signin - Tooltip", "Enable code signin": "コードサインインを有効にする", "Enable code signin - Tooltip": "Enable code signin - Tooltip", "Enable signin session - Tooltip": "Enable signin session - Tooltip", @@ -248,6 +250,7 @@ "Please input your password, at least 6 characters!": "6文字以上でパスワードを入力してください!", "Please input your username, Email or phone!": "ユーザー名、メールアドレスまたは電話番号を入力してください。", "Sign In": "サインイン", + "Sign in with WebAuthn": "Sign in with WebAuthn", "Sign in with code": "コードでサインイン", "Sign in with password": "パスワードでサインイン", "Sign in with {type}": "{type} でサインイン", @@ -639,6 +642,7 @@ "Unlink": "リンクを解除", "Upload (.xlsx)": "アップロード (.xlsx)", "Upload a photo": "写真をアップロード", + "WebAuthn credentials": "WebAuthn credentials", "input password": "パスワードを入力" }, "webhook": { diff --git a/web/src/locales/ko/data.json b/web/src/locales/ko/data.json index 0ef76acb..f50b56c0 100644 --- a/web/src/locales/ko/data.json +++ b/web/src/locales/ko/data.json @@ -13,6 +13,8 @@ "Edit Application": "Edit Application", "Enable SAML compress": "Enable SAML compress", "Enable SAML compress - Tooltip": "Enable SAML compress - Tooltip", + "Enable WebAuthn signin": "Enable WebAuthn signin", + "Enable WebAuthn signin - Tooltip": "Enable WebAuthn signin - Tooltip", "Enable code signin": "Enable code signin", "Enable code signin - Tooltip": "Enable code signin - Tooltip", "Enable signin session - Tooltip": "Enable signin session - Tooltip", @@ -248,6 +250,7 @@ "Please input your password, at least 6 characters!": "Please input your password, at least 6 characters!", "Please input your username, Email or phone!": "Please input your username, Email or phone!", "Sign In": "Sign In", + "Sign in with WebAuthn": "Sign in with WebAuthn", "Sign in with code": "Sign in with code", "Sign in with password": "Sign in with password", "Sign in with {type}": "Sign in with {type}", @@ -639,6 +642,7 @@ "Unlink": "Unlink", "Upload (.xlsx)": "Upload (.xlsx)", "Upload a photo": "Upload a photo", + "WebAuthn credentials": "WebAuthn credentials", "input password": "input password" }, "webhook": { diff --git a/web/src/locales/ru/data.json b/web/src/locales/ru/data.json index ff22093b..bbdd923e 100644 --- a/web/src/locales/ru/data.json +++ b/web/src/locales/ru/data.json @@ -13,6 +13,8 @@ "Edit Application": "Изменить приложение", "Enable SAML compress": "Enable SAML compress", "Enable SAML compress - Tooltip": "Enable SAML compress - Tooltip", + "Enable WebAuthn signin": "Enable WebAuthn signin", + "Enable WebAuthn signin - Tooltip": "Enable WebAuthn signin - Tooltip", "Enable code signin": "Включить кодовый вход", "Enable code signin - Tooltip": "Включить вход с кодом - Tooltip", "Enable signin session - Tooltip": "Включить сеанс входа - Подсказка", @@ -248,6 +250,7 @@ "Please input your password, at least 6 characters!": "Пожалуйста, введите ваш пароль, по крайней мере 6 символов!", "Please input your username, Email or phone!": "Пожалуйста, введите ваше имя пользователя, адрес электронной почты или телефон!", "Sign In": "Войти", + "Sign in with WebAuthn": "Sign in with WebAuthn", "Sign in with code": "Войти с помощью кода", "Sign in with password": "Войти с помощью пароля", "Sign in with {type}": "Войти с помощью {type}", @@ -639,6 +642,7 @@ "Unlink": "Отвязать", "Upload (.xlsx)": "Загрузить (.xlsx)", "Upload a photo": "Загрузить фото", + "WebAuthn credentials": "WebAuthn credentials", "input password": "пароль для ввода" }, "webhook": { diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index 04549f29..32c6e695 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -13,6 +13,8 @@ "Edit Application": "编辑应用", "Enable SAML compress": "压缩SAML响应", "Enable SAML compress - Tooltip": "Casdoor作为SAML idp时,是否压缩SAML响应信息", + "Enable WebAuthn signin": "Enable WebAuthn signin", + "Enable WebAuthn signin - Tooltip": "Enable WebAuthn signin - Tooltip", "Enable code signin": "启用验证码登录", "Enable code signin - Tooltip": "是否允许用手机或邮箱验证码登录", "Enable signin session - Tooltip": "从应用登录Casdoor后,Casdoor是否保持会话", @@ -248,6 +250,7 @@ "Please input your password, at least 6 characters!": "请输入您的密码,不少于6位", "Please input your username, Email or phone!": "请输入您的用户名、Email或手机号!", "Sign In": "登录", + "Sign in with WebAuthn": "Sign in with WebAuthn", "Sign in with code": "验证码登录", "Sign in with password": "密码登录", "Sign in with {type}": "{type}登录", @@ -639,6 +642,7 @@ "Unlink": "解绑", "Upload (.xlsx)": "上传(.xlsx)", "Upload a photo": "上传头像", + "WebAuthn credentials": "WebAuthn credentials", "input password": "输入密码" }, "webhook": {