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 @@
+
+
+
+
+
+
+ {{ $gettext('Passkey') }}
+
+
+ {{ $gettext('Add a passkey') }}
+
+
+
+
+
+
+
+
+
+
+ {{ item.name }}
+
+
+
+
+
+ {{ $gettext('Created at') }}: {{ formatDateTime(item.created_at) }} ยท {{
+ $gettext('Last used at') }}:
+
+
+
+
+
modifyIdx = index"
+ >
+
+
+
+
remove(item.id)"
+ >
+
+
+
+
+
+
+
update(item.id, { ...item, name: passkeyName })"
+ >
+ {{ $gettext('Save') }}
+
+
+
{
+ modifyIdx = -1
+ passkeyName = item.name
+ }"
+ >
+ {{ $gettext('Cancel') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{ text }}
+
+
+
+
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 @@
diff --git a/app/src/views/other/Login.vue b/app/src/views/other/Login.vue
index 3629ac52..750bb350 100644
--- a/app/src/views/other/Login.vue
+++ b/app/src/views/other/Login.vue
@@ -1,14 +1,15 @@
@@ -202,10 +231,27 @@ function handleOTPSubmit(code: string, recovery: string) {
block
html-type="submit"
:loading="loading"
+ class="mb-2"
@click="onSubmit"
>
{{ $gettext('Login') }}
+
+
+
+
+ {{ $gettext('Or') }}
+
+
+
+
+
+ {{ $gettext('Sign in with a passkey') }}
+
+