diff --git a/api/api.go b/api/api.go index 89c59005..e7237ddb 100644 --- a/api/api.go +++ b/api/api.go @@ -12,8 +12,8 @@ import ( "strings" ) -func CurrentUser(c *gin.Context) *model.Auth { - return c.MustGet("user").(*model.Auth) +func CurrentUser(c *gin.Context) *model.User { + return c.MustGet("user").(*model.User) } func ErrHandler(c *gin.Context, err error) { diff --git a/api/system/install.go b/api/system/install.go index 49340fd5..94fc52c7 100644 --- a/api/system/install.go +++ b/api/system/install.go @@ -61,8 +61,8 @@ func InstallNginxUI(c *gin.Context) { pwd, _ := bcrypt.GenerateFromPassword([]byte(json.Password), bcrypt.DefaultCost) - u := query.Auth - err = u.Create(&model.Auth{ + u := query.User + err = u.Create(&model.User{ Name: json.Username, Password: string(pwd), }) diff --git a/api/user/otp.go b/api/user/otp.go index 49e1cb55..997529c7 100644 --- a/api/user/otp.go +++ b/api/user/otp.go @@ -1,228 +1,228 @@ package user import ( - "bytes" - "crypto/sha1" - "encoding/base64" - "encoding/hex" - "fmt" - "github.com/0xJacky/Nginx-UI/api" - "github.com/0xJacky/Nginx-UI/internal/crypto" - "github.com/0xJacky/Nginx-UI/internal/user" - "github.com/0xJacky/Nginx-UI/model" - "github.com/0xJacky/Nginx-UI/query" - "github.com/0xJacky/Nginx-UI/settings" - "github.com/gin-gonic/gin" - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" - "image/jpeg" - "net/http" - "strings" + "bytes" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "fmt" + "github.com/0xJacky/Nginx-UI/api" + "github.com/0xJacky/Nginx-UI/internal/crypto" + "github.com/0xJacky/Nginx-UI/internal/user" + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + "github.com/0xJacky/Nginx-UI/settings" + "github.com/gin-gonic/gin" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "image/jpeg" + "net/http" + "strings" ) func GenerateTOTP(c *gin.Context) { - u := api.CurrentUser(c) + u := api.CurrentUser(c) - issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name) - issuer = strings.TrimSpace(issuer) + issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name) + issuer = strings.TrimSpace(issuer) - otpOpts := totp.GenerateOpts{ - Issuer: issuer, - AccountName: u.Name, - Period: 30, // seconds - Digits: otp.DigitsSix, - Algorithm: otp.AlgorithmSHA1, - } - otpKey, err := totp.Generate(otpOpts) - if err != nil { - api.ErrHandler(c, err) - return - } + otpOpts := totp.GenerateOpts{ + Issuer: issuer, + AccountName: u.Name, + Period: 30, // seconds + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + } + otpKey, err := totp.Generate(otpOpts) + if err != nil { + api.ErrHandler(c, err) + return + } - qrCode, err := otpKey.Image(512, 512) - if err != nil { - api.ErrHandler(c, err) - return - } + qrCode, err := otpKey.Image(512, 512) + if err != nil { + api.ErrHandler(c, err) + return + } - // Encode the image to a buffer - var buf []byte - buffer := bytes.NewBuffer(buf) - err = jpeg.Encode(buffer, qrCode, nil) - if err != nil { - fmt.Println("Error encoding image:", err) - return - } + // Encode the image to a buffer + var buf []byte + buffer := bytes.NewBuffer(buf) + err = jpeg.Encode(buffer, qrCode, nil) + if err != nil { + fmt.Println("Error encoding image:", err) + return + } - // Convert the buffer to a base64 string - base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes()) + // Convert the buffer to a base64 string + base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes()) - c.JSON(http.StatusOK, gin.H{ - "secret": otpKey.Secret(), - "qr_code": base64Str, - }) + c.JSON(http.StatusOK, gin.H{ + "secret": otpKey.Secret(), + "qr_code": base64Str, + }) } func EnrollTOTP(c *gin.Context) { - cUser := api.CurrentUser(c) - if cUser.EnabledOTP() { - c.JSON(http.StatusBadRequest, gin.H{ - "message": "User already enrolled", - }) - return - } + cUser := api.CurrentUser(c) + if cUser.EnabledOTP() { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "User already enrolled", + }) + return + } - if settings.ServerSettings.Demo { - c.JSON(http.StatusBadRequest, gin.H{ - "message": "This feature is disabled in demo mode", - }) - return - } + if settings.ServerSettings.Demo { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "This feature is disabled in demo mode", + }) + return + } - var json struct { - Secret string `json:"secret" binding:"required"` - Passcode string `json:"passcode" binding:"required"` - } - if !api.BindAndValid(c, &json) { - return - } + var json struct { + Secret string `json:"secret" binding:"required"` + Passcode string `json:"passcode" binding:"required"` + } + if !api.BindAndValid(c, &json) { + return + } - if ok := totp.Validate(json.Passcode, json.Secret); !ok { - c.JSON(http.StatusNotAcceptable, gin.H{ - "message": "Invalid passcode", - }) - return - } + if ok := totp.Validate(json.Passcode, json.Secret); !ok { + c.JSON(http.StatusNotAcceptable, gin.H{ + "message": "Invalid passcode", + }) + return + } - ciphertext, err := crypto.AesEncrypt([]byte(json.Secret)) - if err != nil { - api.ErrHandler(c, err) - return - } + ciphertext, err := crypto.AesEncrypt([]byte(json.Secret)) + if err != nil { + api.ErrHandler(c, err) + return + } - u := query.Auth - _, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext) - if err != nil { - api.ErrHandler(c, err) - return - } + u := query.User + _, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext) + if err != nil { + api.ErrHandler(c, err) + return + } - recoveryCode := sha1.Sum(ciphertext) + recoveryCode := sha1.Sum(ciphertext) - c.JSON(http.StatusOK, gin.H{ - "message": "ok", - "recovery_code": hex.EncodeToString(recoveryCode[:]), - }) + c.JSON(http.StatusOK, gin.H{ + "message": "ok", + "recovery_code": hex.EncodeToString(recoveryCode[:]), + }) } func ResetOTP(c *gin.Context) { - var json struct { - RecoveryCode string `json:"recovery_code"` - } - if !api.BindAndValid(c, &json) { - return - } - recoverCode, err := hex.DecodeString(json.RecoveryCode) - if err != nil { - api.ErrHandler(c, err) - return - } - cUser := api.CurrentUser(c) - k := sha1.Sum(cUser.OTPSecret) - if !bytes.Equal(k[:], recoverCode) { - c.JSON(http.StatusBadRequest, gin.H{ - "message": "Invalid recovery code", - }) - return - } + var json struct { + RecoveryCode string `json:"recovery_code"` + } + if !api.BindAndValid(c, &json) { + return + } + recoverCode, err := hex.DecodeString(json.RecoveryCode) + if err != nil { + api.ErrHandler(c, err) + return + } + cUser := api.CurrentUser(c) + k := sha1.Sum(cUser.OTPSecret) + if !bytes.Equal(k[:], recoverCode) { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Invalid recovery code", + }) + return + } - u := query.Auth - _, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null()) - if err != nil { - api.ErrHandler(c, err) - return - } + u := query.User + _, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null()) + if err != nil { + api.ErrHandler(c, err) + return + } - c.JSON(http.StatusOK, gin.H{ - "message": "ok", - }) + c.JSON(http.StatusOK, gin.H{ + "message": "ok", + }) } func OTPStatus(c *gin.Context) { - status := false - u, ok := c.Get("user") - if ok { - status = u.(*model.Auth).EnabledOTP() - } - c.JSON(http.StatusOK, gin.H{ - "status": status, - }) + status := false + u, ok := c.Get("user") + if ok { + status = u.(*model.User).EnabledOTP() + } + c.JSON(http.StatusOK, gin.H{ + "status": status, + }) } func SecureSessionStatus(c *gin.Context) { - u, ok := c.Get("user") - if !ok || !u.(*model.Auth).EnabledOTP() { - c.JSON(http.StatusOK, gin.H{ - "status": false, - }) - return - } - ssid := c.GetHeader("X-Secure-Session-ID") - if ssid == "" { - ssid = c.Query("X-Secure-Session-ID") - } - if ssid == "" { - c.JSON(http.StatusOK, gin.H{ - "status": false, - }) - return - } + u, ok := c.Get("user") + if !ok || !u.(*model.User).EnabledOTP() { + c.JSON(http.StatusOK, gin.H{ + "status": false, + }) + return + } + ssid := c.GetHeader("X-Secure-Session-ID") + if ssid == "" { + ssid = c.Query("X-Secure-Session-ID") + } + if ssid == "" { + c.JSON(http.StatusOK, gin.H{ + "status": false, + }) + return + } - if user.VerifySecureSessionID(ssid, u.(*model.Auth).ID) { - c.JSON(http.StatusOK, gin.H{ - "status": true, - }) - return - } + if user.VerifySecureSessionID(ssid, u.(*model.User).ID) { + c.JSON(http.StatusOK, gin.H{ + "status": true, + }) + return + } - c.JSON(http.StatusOK, gin.H{ - "status": false, - }) + c.JSON(http.StatusOK, gin.H{ + "status": false, + }) } func StartSecure2FASession(c *gin.Context) { - var json struct { - OTP string `json:"otp"` - RecoveryCode string `json:"recovery_code"` - } - if !api.BindAndValid(c, &json) { - return - } - u := api.CurrentUser(c) - if !u.EnabledOTP() { - c.JSON(http.StatusBadRequest, gin.H{ - "message": "User not configured with 2FA", - }) - return - } + var json struct { + OTP string `json:"otp"` + RecoveryCode string `json:"recovery_code"` + } + if !api.BindAndValid(c, &json) { + return + } + u := api.CurrentUser(c) + if !u.EnabledOTP() { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "User not configured with 2FA", + }) + return + } - if json.OTP == "" && json.RecoveryCode == "" { - c.JSON(http.StatusBadRequest, LoginResponse{ - Message: "The user has enabled 2FA", - }) - return - } + if json.OTP == "" && json.RecoveryCode == "" { + c.JSON(http.StatusBadRequest, LoginResponse{ + Message: "The user has enabled 2FA", + }) + return + } - if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { - c.JSON(http.StatusBadRequest, LoginResponse{ - Message: "Invalid 2FA or recovery code", - }) - return - } + if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { + c.JSON(http.StatusBadRequest, LoginResponse{ + Message: "Invalid 2FA or recovery code", + }) + return + } - sessionId := user.SetSecureSessionID(u.ID) + sessionId := user.SetSecureSessionID(u.ID) - c.JSON(http.StatusOK, gin.H{ - "session_id": sessionId, - }) + c.JSON(http.StatusOK, gin.H{ + "session_id": sessionId, + }) } diff --git a/api/user/passkey.go b/api/user/passkey.go new file mode 100644 index 00000000..3170e7dc --- /dev/null +++ b/api/user/passkey.go @@ -0,0 +1,185 @@ +package user + +import ( + "encoding/base64" + "fmt" + "github.com/0xJacky/Nginx-UI/api" + "github.com/0xJacky/Nginx-UI/internal/cache" + "github.com/0xJacky/Nginx-UI/internal/cosy" + "github.com/0xJacky/Nginx-UI/internal/logger" + "github.com/0xJacky/Nginx-UI/internal/passkey" + "github.com/0xJacky/Nginx-UI/internal/user" + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" + "github.com/spf13/cast" + "gorm.io/gorm" + "net/http" + "strings" + "time" +) + +const passkeyTimeout = 30 * time.Second + +func buildCachePasskeyRegKey(id int) string { + return fmt.Sprintf("passkey-reg-%d", id) +} + +func BeginPasskeyRegistration(c *gin.Context) { + u := api.CurrentUser(c) + + webauthnInstance := passkey.GetInstance() + + options, sessionData, err := webauthnInstance.BeginRegistration(u) + if err != nil { + api.ErrHandler(c, err) + return + } + cache.Set(buildCachePasskeyRegKey(u.ID), sessionData, passkeyTimeout) + + c.JSON(http.StatusOK, options) +} + +func FinishPasskeyRegistration(c *gin.Context) { + cUser := api.CurrentUser(c) + webauthnInstance := passkey.GetInstance() + sessionDataBytes, ok := cache.Get(buildCachePasskeyRegKey(cUser.ID)) + if !ok { + api.ErrHandler(c, fmt.Errorf("session not found")) + return + } + + sessionData := sessionDataBytes.(*webauthn.SessionData) + credential, err := webauthnInstance.FinishRegistration(cUser, *sessionData, c.Request) + if err != nil { + api.ErrHandler(c, err) + return + } + cache.Del(buildCachePasskeyRegKey(cUser.ID)) + + rawId := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=") + passkeyName := c.Query("name") + p := query.Passkey + err = p.Create(&model.Passkey{ + UserID: cUser.ID, + Name: passkeyName, + RawID: rawId, + Credential: credential, + LastUsedAt: time.Now().Unix(), + }) + if err != nil { + api.ErrHandler(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "ok", + }) +} + +func BeginPasskeyLogin(c *gin.Context) { + if !passkey.Enabled() { + api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured")) + return + } + webauthnInstance := passkey.GetInstance() + options, sessionData, err := webauthnInstance.BeginDiscoverableLogin() + if err != nil { + api.ErrHandler(c, err) + return + } + sessionID := uuid.NewString() + cache.Set(sessionID, sessionData, passkeyTimeout) + + c.JSON(http.StatusOK, gin.H{ + "session_id": sessionID, + "options": options, + }) +} + +func FinishPasskeyLogin(c *gin.Context) { + sessionId := c.GetHeader("X-Passkey-Session-ID") + sessionDataBytes, ok := cache.Get(sessionId) + if !ok { + api.ErrHandler(c, fmt.Errorf("session not found")) + return + } + webauthnInstance := passkey.GetInstance() + sessionData := sessionDataBytes.(*webauthn.SessionData) + var outUser *model.User + _, err := webauthnInstance.FinishDiscoverableLogin( + func(rawID, userHandle []byte) (user webauthn.User, err error) { + encodeRawID := strings.TrimRight(base64.StdEncoding.EncodeToString(rawID), "=") + u := query.User + logger.Debug("[WebAuthn] Discoverable Login", cast.ToInt(string(userHandle))) + + p := query.Passkey + _, _ = p.Where(p.RawID.Eq(encodeRawID)).Updates(&model.Passkey{ + LastUsedAt: time.Now().Unix(), + }) + + outUser, err = u.FirstByID(cast.ToInt(string(userHandle))) + return outUser, err + }, *sessionData, c.Request) + if err != nil { + api.ErrHandler(c, err) + return + } + + b := query.BanIP + clientIP := c.ClientIP() + // login success, clear banned record + _, _ = b.Where(b.IP.Eq(clientIP)).Delete() + + logger.Info("[User Login]", outUser.Name) + token, err := user.GenerateJWT(outUser) + if err != nil { + c.JSON(http.StatusInternalServerError, LoginResponse{ + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, LoginResponse{ + Code: LoginSuccess, + Message: "ok", + Token: token, + // SecureSessionID: secureSessionID, + }) +} + +func GetPasskeyList(c *gin.Context) { + u := api.CurrentUser(c) + p := query.Passkey + passkeys, err := p.Where(p.UserID.Eq(u.ID)).Find() + if err != nil { + api.ErrHandler(c, err) + return + } + + if len(passkeys) == 0 { + passkeys = make([]*model.Passkey, 0) + } + + c.JSON(http.StatusOK, passkeys) +} + +func UpdatePasskey(c *gin.Context) { + u := api.CurrentUser(c) + cosy.Core[model.Passkey](c). + SetValidRules(gin.H{ + "name": "required", + }).GormScope(func(tx *gorm.DB) *gorm.DB { + return tx.Where("user_id", u.ID) + }).Modify() +} + +func DeletePasskey(c *gin.Context) { + u := api.CurrentUser(c) + cosy.Core[model.Passkey](c). + GormScope(func(tx *gorm.DB) *gorm.DB { + return tx.Where("user_id", u.ID) + }).PermanentlyDelete() +} diff --git a/api/user/router.go b/api/user/router.go index 67d9f1bb..7cffa039 100644 --- a/api/user/router.go +++ b/api/user/router.go @@ -8,6 +8,9 @@ func InitAuthRouter(r *gin.RouterGroup) { r.POST("/login", Login) r.DELETE("/logout", Logout) + r.GET("/begin_passkey_login", BeginPasskeyLogin) + r.POST("/finish_passkey_login", FinishPasskeyLogin) + r.GET("/casdoor_uri", GetCasdoorUri) r.POST("/casdoor_callback", CasdoorCallback) } @@ -29,4 +32,11 @@ func InitUserRouter(r *gin.RouterGroup) { r.GET("/otp_secure_session_status", SecureSessionStatus) r.POST("/otp_secure_session", StartSecure2FASession) + + r.GET("/begin_passkey_register", BeginPasskeyRegistration) + r.POST("/finish_passkey_register", FinishPasskeyRegistration) + + r.GET("/passkeys", GetPasskeyList) + r.POST("/passkeys/:id", UpdatePasskey) + r.DELETE("/passkeys/:id", DeletePasskey) } diff --git a/api/user/user.go b/api/user/user.go index bd8c4ac9..4080dce5 100644 --- a/api/user/user.go +++ b/api/user/user.go @@ -13,13 +13,13 @@ import ( ) func GetUsers(c *gin.Context) { - cosy.Core[model.Auth](c).SetFussy("name").PagingList() + cosy.Core[model.User](c).SetFussy("name").PagingList() } func GetUser(c *gin.Context) { id := cast.ToInt(c.Param("id")) - u := query.Auth + u := query.User user, err := u.FirstByID(id) @@ -43,7 +43,7 @@ func AddUser(c *gin.Context) { return } - u := query.Auth + u := query.User pwd, err := bcrypt.GenerateFromPassword([]byte(json.Password), bcrypt.DefaultCost) if err != nil { @@ -52,7 +52,7 @@ func AddUser(c *gin.Context) { } json.Password = string(pwd) - user := model.Auth{ + user := model.User{ Name: json.Name, Password: json.Password, } @@ -84,14 +84,14 @@ func EditUser(c *gin.Context) { return } - u := query.Auth + u := query.User user, err := u.FirstByID(userId) if err != nil { api.ErrHandler(c, err) return } - edit := &model.Auth{ + edit := &model.User{ Name: json.Name, } @@ -124,9 +124,9 @@ func DeleteUser(c *gin.Context) { }) return } - cosy.Core[model.Auth](c).Destroy() + cosy.Core[model.User](c).Destroy() } func RecoverUser(c *gin.Context) { - cosy.Core[model.Auth](c).Recover() + cosy.Core[model.User](c).Recover() } diff --git a/app.example.ini b/app.example.ini index ec86357f..661accf2 100644 --- a/app.example.ini +++ b/app.example.ini @@ -17,6 +17,7 @@ CertRenewalInterval = 7 RecursiveNameservers = SkipInstallation = false Name = +InsecureSkipVerify = false [nginx] AccessLogPath = /var/log/nginx/access.log @@ -59,3 +60,8 @@ MaxAttempts = 10 [crypto] Secret = secret2 + +[webauthn] +RPDisplayName = +RPID = +RPOrigins = diff --git a/app/components.d.ts b/app/components.d.ts index 55eaaa22..f6e12d60 100644 --- a/app/components.d.ts +++ b/app/components.d.ts @@ -81,6 +81,8 @@ declare module 'vue' { OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default'] OTPOTPAuthorization: typeof import('./src/components/OTP/OTPAuthorization.vue')['default'] PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default'] + PasskeyPasskeyRegistration: typeof import('./src/components/Passkey/PasskeyRegistration.vue')['default'] + ReactiveFromNowReactiveFromNow: typeof import('./src/components/ReactiveFromNow/ReactiveFromNow.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SensitiveStringSensitiveString: typeof import('./src/components/SensitiveString/SensitiveString.vue')['default'] diff --git a/app/package.json b/app/package.json index 0bb89793..9f34b8b5 100644 --- a/app/package.json +++ b/app/package.json @@ -14,6 +14,7 @@ "@0xjacky/vue-github-button": "^3.1.1", "@ant-design/icons-vue": "^7.0.1", "@formkit/auto-animate": "^0.8.2", + "@simplewebauthn/browser": "^10.0.0", "@vue/reactivity": "^3.5.5", "@vue/shared": "^3.5.5", "@vueuse/components": "^11.0.3", @@ -46,6 +47,7 @@ }, "devDependencies": { "@antfu/eslint-config-vue": "^0.43.1", + "@simplewebauthn/types": "^10.0.0", "@types/lodash": "^4.17.7", "@types/nprogress": "^0.2.3", "@types/sortablejs": "^1.15.8", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 096a19e1..f611e5e0 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@formkit/auto-animate': specifier: ^0.8.2 version: 0.8.2 + '@simplewebauthn/browser': + specifier: ^10.0.0 + version: 10.0.0 '@vue/reactivity': specifier: ^3.5.5 version: 3.5.5 @@ -108,6 +111,9 @@ importers: '@antfu/eslint-config-vue': specifier: ^0.43.1 version: 0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.5.4) + '@simplewebauthn/types': + specifier: ^10.0.0 + version: 10.0.0 '@types/lodash': specifier: ^4.17.7 version: 4.17.7 @@ -684,6 +690,12 @@ packages: '@simonwep/pickr@1.8.2': resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} + '@simplewebauthn/browser@10.0.0': + resolution: {integrity: sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==} + + '@simplewebauthn/types@10.0.0': + resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==} + '@stylistic/eslint-plugin-js@0.0.4': resolution: {integrity: sha512-W1rq2xxlFNhgZZJO+L59wtvlDI0xARYxx0WD8EeWNBO7NDybUSYSozCIcY9XvxQbTAsEXBjwqokeYm0crt7RxQ==} @@ -3651,6 +3663,12 @@ snapshots: core-js: 3.38.1 nanopop: 2.4.2 + '@simplewebauthn/browser@10.0.0': + dependencies: + '@simplewebauthn/types': 10.0.0 + + '@simplewebauthn/types@10.0.0': {} + '@stylistic/eslint-plugin-js@0.0.4': dependencies: acorn: 8.12.1 diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index b214458a..4203cebd 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -1,3 +1,4 @@ +import type { AuthenticationResponseJSON } from '@simplewebauthn/types' import http from '@/lib/http' import { useUserStore } from '@/pinia' @@ -37,6 +38,16 @@ const auth = { async get_casdoor_uri(): Promise<{ uri: string }> { return http.get('/casdoor_uri') }, + begin_passkey_login() { + return http.get('/begin_passkey_login') + }, + finish_passkey_login(data: { session_id: string; options: AuthenticationResponseJSON }) { + return http.post('/finish_passkey_login', data.options, { + headers: { + 'X-Passkey-Session-Id': data.session_id, + }, + }) + }, } export default auth diff --git a/app/src/api/passkey.ts b/app/src/api/passkey.ts new file mode 100644 index 00000000..a50c0644 --- /dev/null +++ b/app/src/api/passkey.ts @@ -0,0 +1,35 @@ +import type { RegistrationResponseJSON } from '@simplewebauthn/types' +import http from '@/lib/http' +import type { ModelBase } from '@/api/curd' + +export interface Passkey extends ModelBase { + name: string + user_id: string +} + +const passkey = { + begin_registration() { + return http.get('/begin_passkey_register') + }, + finish_registration(attestationResponse: RegistrationResponseJSON, passkeyName: string) { + return http.post('/finish_passkey_register', attestationResponse, { + params: { + name: passkeyName, + }, + }) + }, + get_list() { + return http.get('/passkeys') + }, + update(passkeyId: number, data: Passkey) { + return http.post(`/passkeys/${passkeyId}`, data) + }, + remove(passkeyId: number) { + return http.delete(`/passkeys/${passkeyId}`) + }, + get_passkey_enabled() { + return http.get('/passkey_enabled') + }, +} + +export default passkey diff --git a/app/src/components/Passkey/PasskeyRegistration.vue b/app/src/components/Passkey/PasskeyRegistration.vue new file mode 100644 index 00000000..cdc3d82d --- /dev/null +++ b/app/src/components/Passkey/PasskeyRegistration.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/app/src/components/ReactiveFromNow/ReactiveFromNow.vue b/app/src/components/ReactiveFromNow/ReactiveFromNow.vue new file mode 100644 index 00000000..1ae98e48 --- /dev/null +++ b/app/src/components/ReactiveFromNow/ReactiveFromNow.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/app/src/components/SetLanguage/SetLanguage.vue b/app/src/components/SetLanguage/SetLanguage.vue index c1f03576..79328281 100644 --- a/app/src/components/SetLanguage/SetLanguage.vue +++ b/app/src/components/SetLanguage/SetLanguage.vue @@ -1,10 +1,19 @@