mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-11 02:15:48 +02:00
feat: 2fa via passkey
This commit is contained in:
parent
bdfbbd0e8f
commit
0a6a7693a1
21 changed files with 384 additions and 218 deletions
156
api/user/2fa.go
Normal file
156
api/user/2fa.go
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
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/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"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status2FA struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
OTPStatus bool `json:"otp_status"`
|
||||||
|
PasskeyStatus bool `json:"passkey_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func get2FAStatus(c *gin.Context) (status Status2FA) {
|
||||||
|
// when accessing the node from the main cluster, there is no user in the context
|
||||||
|
u, ok := c.Get("user")
|
||||||
|
if ok {
|
||||||
|
userPtr := u.(*model.User)
|
||||||
|
status.OTPStatus = userPtr.EnabledOTP()
|
||||||
|
status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled()
|
||||||
|
status.Enabled = status.OTPStatus || status.PasskeyStatus
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func Get2FAStatus(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, get2FAStatus(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecureSessionStatus(c *gin.Context) {
|
||||||
|
status2FA := get2FAStatus(c)
|
||||||
|
if !status2FA.Enabled {
|
||||||
|
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 := api.CurrentUser(c)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": user.VerifySecureSessionID(ssid, u.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Start2FASecureSessionByOTP(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 has not configured OTP as 2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if json.OTP == "" && json.RecoveryCode == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, LoginResponse{
|
||||||
|
Message: "The user has enabled OTP as 2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, LoginResponse{
|
||||||
|
Message: "Invalid OTP or recovery code",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionId := user.SetSecureSessionID(u.ID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"session_id": sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func BeginStart2FASecureSessionByPasskey(c *gin.Context) {
|
||||||
|
if !passkey.Enabled() {
|
||||||
|
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webauthnInstance := passkey.GetInstance()
|
||||||
|
u := api.CurrentUser(c)
|
||||||
|
options, sessionData, err := webauthnInstance.BeginLogin(u)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passkeySessionID := uuid.NewString()
|
||||||
|
cache.Set(passkeySessionID, sessionData, passkeyTimeout)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"session_id": passkeySessionID,
|
||||||
|
"options": options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishStart2FASecureSessionByPasskey(c *gin.Context) {
|
||||||
|
if !passkey.Enabled() {
|
||||||
|
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passkeySessionID := c.GetHeader("X-Passkey-Session-ID")
|
||||||
|
sessionDataBytes, ok := cache.Get(passkeySessionID)
|
||||||
|
if !ok {
|
||||||
|
api.ErrHandler(c, fmt.Errorf("session not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionData := sessionDataBytes.(*webauthn.SessionData)
|
||||||
|
webauthnInstance := passkey.GetInstance()
|
||||||
|
u := api.CurrentUser(c)
|
||||||
|
credential, err := webauthnInstance.FinishLogin(u, *sessionData, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawID := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=")
|
||||||
|
p := query.Passkey
|
||||||
|
_, _ = p.Where(p.RawID.Eq(rawID)).Updates(&model.Passkey{
|
||||||
|
LastUsedAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
sessionId := user.SetSecureSessionID(u.ID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"session_id": sessionId,
|
||||||
|
})
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/0xJacky/Nginx-UI/settings"
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
@ -67,7 +68,8 @@ func Login(c *gin.Context) {
|
||||||
|
|
||||||
u, err := user.Login(json.Name, json.Password)
|
u, err := user.Login(json.Name, json.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// time.Sleep(5 * time.Second)
|
random := time.Duration(rand.Int() % 10)
|
||||||
|
time.Sleep(random * time.Second)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, user.ErrPasswordIncorrect):
|
case errors.Is(err, user.ErrPasswordIncorrect):
|
||||||
c.JSON(http.StatusForbidden, LoginResponse{
|
c.JSON(http.StatusForbidden, LoginResponse{
|
||||||
|
|
|
@ -8,8 +8,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/0xJacky/Nginx-UI/api"
|
"github.com/0xJacky/Nginx-UI/api"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/crypto"
|
"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/query"
|
||||||
"github.com/0xJacky/Nginx-UI/settings"
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
@ -147,82 +145,3 @@ func ResetOTP(c *gin.Context) {
|
||||||
"message": "ok",
|
"message": "ok",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func OTPStatus(c *gin.Context) {
|
|
||||||
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.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.User).ID) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"status": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionId := user.SetSecureSessionID(u.ID)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"session_id": sessionId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -27,6 +27,12 @@ func buildCachePasskeyRegKey(id int) string {
|
||||||
return fmt.Sprintf("passkey-reg-%d", id)
|
return fmt.Sprintf("passkey-reg-%d", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPasskeyConfigStatus(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": passkey.Enabled(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func BeginPasskeyRegistration(c *gin.Context) {
|
func BeginPasskeyRegistration(c *gin.Context) {
|
||||||
u := api.CurrentUser(c)
|
u := api.CurrentUser(c)
|
||||||
|
|
||||||
|
@ -100,6 +106,10 @@ func BeginPasskeyLogin(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func FinishPasskeyLogin(c *gin.Context) {
|
func FinishPasskeyLogin(c *gin.Context) {
|
||||||
|
if !passkey.Enabled() {
|
||||||
|
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
sessionId := c.GetHeader("X-Passkey-Session-ID")
|
sessionId := c.GetHeader("X-Passkey-Session-ID")
|
||||||
sessionDataBytes, ok := cache.Get(sessionId)
|
sessionDataBytes, ok := cache.Get(sessionId)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
@ -25,17 +25,20 @@ func InitManageUserRouter(r *gin.RouterGroup) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitUserRouter(r *gin.RouterGroup) {
|
func InitUserRouter(r *gin.RouterGroup) {
|
||||||
r.GET("/otp_status", OTPStatus)
|
r.GET("/2fa_status", Get2FAStatus)
|
||||||
|
r.GET("/2fa_secure_session/status", SecureSessionStatus)
|
||||||
|
r.POST("/2fa_secure_session/otp", Start2FASecureSessionByOTP)
|
||||||
|
r.GET("/2fa_secure_session/passkey", BeginStart2FASecureSessionByPasskey)
|
||||||
|
r.POST("/2fa_secure_session/passkey", FinishStart2FASecureSessionByPasskey)
|
||||||
|
|
||||||
r.GET("/otp_secret", GenerateTOTP)
|
r.GET("/otp_secret", GenerateTOTP)
|
||||||
r.POST("/otp_enroll", EnrollTOTP)
|
r.POST("/otp_enroll", EnrollTOTP)
|
||||||
r.POST("/otp_reset", ResetOTP)
|
r.POST("/otp_reset", ResetOTP)
|
||||||
|
|
||||||
r.GET("/otp_secure_session_status", SecureSessionStatus)
|
|
||||||
r.POST("/otp_secure_session", StartSecure2FASession)
|
|
||||||
|
|
||||||
r.GET("/begin_passkey_register", BeginPasskeyRegistration)
|
r.GET("/begin_passkey_register", BeginPasskeyRegistration)
|
||||||
r.POST("/finish_passkey_register", FinishPasskeyRegistration)
|
r.POST("/finish_passkey_register", FinishPasskeyRegistration)
|
||||||
|
|
||||||
|
r.GET("/passkeys/config", GetPasskeyConfigStatus)
|
||||||
r.GET("/passkeys", GetPasskeyList)
|
r.GET("/passkeys", GetPasskeyList)
|
||||||
r.POST("/passkeys/:id", UpdatePasskey)
|
r.POST("/passkeys/:id", UpdatePasskey)
|
||||||
r.DELETE("/passkeys/:id", DeletePasskey)
|
r.DELETE("/passkeys/:id", DeletePasskey)
|
||||||
|
|
2
app/components.d.ts
vendored
2
app/components.d.ts
vendored
|
@ -7,6 +7,8 @@ export {}
|
||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
2FA2FAAuthorization: typeof import('./src/components/2FA/2FAAuthorization.vue')['default']
|
||||||
|
2FAOTPAuthorization: typeof import('./src/components/2FA/OTPAuthorization.vue')['default']
|
||||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||||
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
|
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
|
||||||
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
||||||
|
|
37
app/src/api/2fa.ts
Normal file
37
app/src/api/2fa.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import type { AuthenticationResponseJSON } from '@simplewebauthn/types'
|
||||||
|
import http from '@/lib/http'
|
||||||
|
|
||||||
|
export interface TwoFAStatusResponse {
|
||||||
|
enabled: boolean
|
||||||
|
otp_status: boolean
|
||||||
|
passkey_status: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const twoFA = {
|
||||||
|
status(): Promise<TwoFAStatusResponse> {
|
||||||
|
return http.get('/2fa_status')
|
||||||
|
},
|
||||||
|
start_secure_session_by_otp(passcode: string, recovery_code: string): Promise<{ session_id: string }> {
|
||||||
|
return http.post('/2fa_secure_session/otp', {
|
||||||
|
otp: passcode,
|
||||||
|
recovery_code,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
secure_session_status(): Promise<{ status: boolean }> {
|
||||||
|
return http.get('/2fa_secure_session/status')
|
||||||
|
},
|
||||||
|
begin_start_secure_session_by_passkey() {
|
||||||
|
return http.get('/2fa_secure_session/passkey')
|
||||||
|
},
|
||||||
|
finish_start_secure_session_by_passkey(data: { session_id: string; options: AuthenticationResponseJSON }): Promise<{
|
||||||
|
session_id: string
|
||||||
|
}> {
|
||||||
|
return http.post('/2fa_secure_session/passkey', data.options, {
|
||||||
|
headers: {
|
||||||
|
'X-Passkey-Session-Id': data.session_id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default twoFA
|
|
@ -6,9 +6,6 @@ export interface OTPGenerateSecretResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
const otp = {
|
const otp = {
|
||||||
status(): Promise<{ status: boolean }> {
|
|
||||||
return http.get('/otp_status')
|
|
||||||
},
|
|
||||||
generate_secret(): Promise<OTPGenerateSecretResponse> {
|
generate_secret(): Promise<OTPGenerateSecretResponse> {
|
||||||
return http.get('/otp_secret')
|
return http.get('/otp_secret')
|
||||||
},
|
},
|
||||||
|
@ -18,15 +15,6 @@ const otp = {
|
||||||
reset(recovery_code: string) {
|
reset(recovery_code: string) {
|
||||||
return http.post('/otp_reset', { recovery_code })
|
return http.post('/otp_reset', { recovery_code })
|
||||||
},
|
},
|
||||||
start_secure_session(passcode: string, recovery_code: string): Promise<{ session_id: string }> {
|
|
||||||
return http.post('/otp_secure_session', {
|
|
||||||
otp: passcode,
|
|
||||||
recovery_code,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
secure_session_status() {
|
|
||||||
return http.get('/otp_secure_session_status')
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default otp
|
export default otp
|
||||||
|
|
|
@ -27,8 +27,8 @@ const passkey = {
|
||||||
remove(passkeyId: number) {
|
remove(passkeyId: number) {
|
||||||
return http.delete(`/passkeys/${passkeyId}`)
|
return http.delete(`/passkeys/${passkeyId}`)
|
||||||
},
|
},
|
||||||
get_passkey_enabled() {
|
get_passkey_config_status(): Promise<{ status: boolean }> {
|
||||||
return http.get('/passkey_enabled')
|
return http.get('/passkey/config')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { KeyOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||||
|
import { $gettext } from '@/gettext'
|
||||||
|
import twoFA from '@/api/2fa'
|
||||||
|
|
||||||
const emit = defineEmits(['onSubmit'])
|
const emit = defineEmits(['submitOTP', 'submitSecureSessionID'])
|
||||||
|
|
||||||
const refOTP = ref()
|
const refOTP = ref()
|
||||||
const useRecoveryCode = ref(false)
|
const useRecoveryCode = ref(false)
|
||||||
const passcode = ref('')
|
const passcode = ref('')
|
||||||
const recoveryCode = ref('')
|
const recoveryCode = ref('')
|
||||||
|
const passkeyLoading = ref(false)
|
||||||
|
|
||||||
function clickUseRecoveryCode() {
|
function clickUseRecoveryCode() {
|
||||||
passcode.value = ''
|
passcode.value = ''
|
||||||
|
@ -19,7 +25,7 @@ function clickUseOTP() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
emit('onSubmit', passcode.value, recoveryCode.value)
|
emit('submitOTP', passcode.value, recoveryCode.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearInput() {
|
function clearInput() {
|
||||||
|
@ -29,12 +35,32 @@ function clearInput() {
|
||||||
defineExpose({
|
defineExpose({
|
||||||
clearInput,
|
clearInput,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function passkeyAuthenticate() {
|
||||||
|
passkeyLoading.value = true
|
||||||
|
try {
|
||||||
|
const begin = await twoFA.begin_start_secure_session_by_passkey()
|
||||||
|
const asseResp = await startAuthentication(begin.options.publicKey)
|
||||||
|
|
||||||
|
const r = await twoFA.finish_start_secure_session_by_passkey({
|
||||||
|
session_id: begin.session_id,
|
||||||
|
options: asseResp,
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('submitSecureSessionID', r.session_id)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
catch (e: any) {
|
||||||
|
message.error($gettext(e.message ?? 'Server error'))
|
||||||
|
}
|
||||||
|
passkeyLoading.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="!useRecoveryCode">
|
<div v-if="!useRecoveryCode">
|
||||||
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
|
<p>{{ $gettext('Please enter the OTP code:') }}</p>
|
||||||
<OTPInput
|
<OTPInput
|
||||||
ref="refOTP"
|
ref="refOTP"
|
||||||
v-model="passcode"
|
v-model="passcode"
|
||||||
|
@ -68,6 +94,22 @@ defineExpose({
|
||||||
@click="clickUseOTP"
|
@click="clickUseOTP"
|
||||||
>{{ $gettext('Use OTP') }}</a>
|
>{{ $gettext('Use OTP') }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-center">
|
||||||
|
<ADivider>
|
||||||
|
<div class="text-sm font-normal opacity-75">
|
||||||
|
{{ $gettext('Or') }}
|
||||||
|
</div>
|
||||||
|
</ADivider>
|
||||||
|
|
||||||
|
<AButton
|
||||||
|
:loading="passkeyLoading"
|
||||||
|
@click="passkeyAuthenticate"
|
||||||
|
>
|
||||||
|
<KeyOutlined />
|
||||||
|
{{ $gettext('Authenticate with a passkey') }}
|
||||||
|
</AButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { createVNode, render } from 'vue'
|
import { createVNode, render } from 'vue'
|
||||||
import { Modal, message } from 'ant-design-vue'
|
import { Modal, message } from 'ant-design-vue'
|
||||||
import { useCookies } from '@vueuse/integrations/useCookies'
|
import { useCookies } from '@vueuse/integrations/useCookies'
|
||||||
import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
|
import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue'
|
||||||
import otp from '@/api/otp'
|
import twoFA from '@/api/2fa'
|
||||||
import { useUserStore } from '@/pinia'
|
import { useUserStore } from '@/pinia'
|
||||||
|
|
||||||
const useOTPModal = () => {
|
const use2FAModal = () => {
|
||||||
const refOTPAuthorization = ref<typeof OTPAuthorization>()
|
const refOTPAuthorization = ref<typeof OTPAuthorization>()
|
||||||
const randomId = Math.random().toString(36).substring(2, 8)
|
const randomId = Math.random().toString(36).substring(2, 8)
|
||||||
const { secureSessionId } = storeToRefs(useUserStore())
|
const { secureSessionId } = storeToRefs(useUserStore())
|
||||||
|
@ -22,11 +22,11 @@ const useOTPModal = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const open = async (): Promise<string> => {
|
const open = async (): Promise<string> => {
|
||||||
const { status } = await otp.status()
|
const { enabled } = await twoFA.status()
|
||||||
const { status: secureSessionStatus } = await otp.secure_session_status()
|
const { status: secureSessionStatus } = await twoFA.secure_session_status()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!status) {
|
if (!enabled) {
|
||||||
resolve('')
|
resolve('')
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -50,12 +50,16 @@ const useOTPModal = () => {
|
||||||
container = null
|
container = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const verify = (passcode: string, recovery: string) => {
|
const setSessionId = (sessionId: string) => {
|
||||||
otp.start_secure_session(passcode, recovery).then(async r => {
|
cookies.set('secure_session_id', sessionId, { maxAge: 60 * 3 })
|
||||||
cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 })
|
|
||||||
close()
|
close()
|
||||||
secureSessionId.value = r.session_id
|
secureSessionId.value = sessionId
|
||||||
resolve(r.session_id)
|
resolve(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyOTP = (passcode: string, recovery: string) => {
|
||||||
|
twoFA.start_secure_session_by_otp(passcode, recovery).then(async r => {
|
||||||
|
setSessionId(r.session_id)
|
||||||
}).catch(async () => {
|
}).catch(async () => {
|
||||||
refOTPAuthorization.value?.clearInput()
|
refOTPAuthorization.value?.clearInput()
|
||||||
await message.error($gettext('Invalid passcode or recovery code'))
|
await message.error($gettext('Invalid passcode or recovery code'))
|
||||||
|
@ -80,7 +84,8 @@ const useOTPModal = () => {
|
||||||
{
|
{
|
||||||
ref: refOTPAuthorization,
|
ref: refOTPAuthorization,
|
||||||
class: 'mt-3',
|
class: 'mt-3',
|
||||||
onOnSubmit: verify,
|
onSubmitOTP: verifyOTP,
|
||||||
|
onSubmitSecureSessionID: setSessionId,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
@ -92,4 +97,4 @@ const useOTPModal = () => {
|
||||||
return { open }
|
return { open }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useOTPModal
|
export default use2FAModal
|
|
@ -7,7 +7,7 @@ import { useSettingsStore, useUserStore } from '@/pinia'
|
||||||
import 'nprogress/nprogress.css'
|
import 'nprogress/nprogress.css'
|
||||||
|
|
||||||
import router from '@/routes'
|
import router from '@/routes'
|
||||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||||
|
|
||||||
const user = useUserStore()
|
const user = useUserStore()
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
|
@ -61,7 +61,7 @@ instance.interceptors.response.use(
|
||||||
async error => {
|
async error => {
|
||||||
NProgress.done()
|
NProgress.done()
|
||||||
|
|
||||||
const otpModal = useOTPModal()
|
const otpModal = use2FAModal()
|
||||||
const cookies = useCookies(['nginx-ui-2fa'])
|
const cookies = useCookies(['nginx-ui-2fa'])
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401:
|
case 401:
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import config from '@/api/config'
|
import config from '@/api/config'
|
||||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||||
|
|
||||||
const emit = defineEmits(['created'])
|
const emit = defineEmits(['created'])
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
|
@ -25,7 +25,7 @@ defineExpose({
|
||||||
|
|
||||||
function ok() {
|
function ok() {
|
||||||
refForm.value.validate().then(() => {
|
refForm.value.validate().then(() => {
|
||||||
const otpModal = useOTPModal()
|
const otpModal = use2FAModal()
|
||||||
|
|
||||||
otpModal.open().then(() => {
|
otpModal.open().then(() => {
|
||||||
config.mkdir(data.value.basePath, data.value.name).then(() => {
|
config.mkdir(data.value.basePath, data.value.name).then(() => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import config from '@/api/config'
|
import config from '@/api/config'
|
||||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||||
import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
|
import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['renamed'])
|
const emit = defineEmits(['renamed'])
|
||||||
|
@ -33,7 +33,7 @@ function ok() {
|
||||||
refForm.value.validate().then(() => {
|
refForm.value.validate().then(() => {
|
||||||
const { basePath, orig_name, new_name, sync_node_ids } = data.value
|
const { basePath, orig_name, new_name, sync_node_ids } = data.value
|
||||||
|
|
||||||
const otpModal = useOTPModal()
|
const otpModal = use2FAModal()
|
||||||
|
|
||||||
otpModal.open().then(() => {
|
otpModal.open().then(() => {
|
||||||
config.rename(basePath, orig_name, new_name, sync_node_ids).then(() => {
|
config.rename(basePath, orig_name, new_name, sync_node_ids).then(() => {
|
||||||
|
|
|
@ -8,7 +8,7 @@ import auth from '@/api/auth'
|
||||||
import install from '@/api/install'
|
import install from '@/api/install'
|
||||||
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
|
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
|
||||||
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
|
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
|
||||||
import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
|
import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue'
|
||||||
import gettext, { $gettext } from '@/gettext'
|
import gettext, { $gettext } from '@/gettext'
|
||||||
|
|
||||||
const thisYear = new Date().getFullYear()
|
const thisYear = new Date().getFullYear()
|
||||||
|
@ -154,8 +154,6 @@ async function passkeyLogin() {
|
||||||
const begin = await auth.begin_passkey_login()
|
const begin = await auth.begin_passkey_login()
|
||||||
const asseResp = await startAuthentication(begin.options.publicKey)
|
const asseResp = await startAuthentication(begin.options.publicKey)
|
||||||
|
|
||||||
console.log(asseResp)
|
|
||||||
|
|
||||||
const r = await auth.finish_passkey_login({
|
const r = await auth.finish_passkey_login({
|
||||||
session_id: begin.session_id,
|
session_id: begin.session_id,
|
||||||
options: asseResp,
|
options: asseResp,
|
||||||
|
|
|
@ -11,7 +11,7 @@ import type { Settings } from '@/views/preference/typedef'
|
||||||
import LogrotateSettings from '@/views/preference/LogrotateSettings.vue'
|
import LogrotateSettings from '@/views/preference/LogrotateSettings.vue'
|
||||||
import { useSettingsStore } from '@/pinia'
|
import { useSettingsStore } from '@/pinia'
|
||||||
import AuthSettings from '@/views/preference/AuthSettings.vue'
|
import AuthSettings from '@/views/preference/AuthSettings.vue'
|
||||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||||
|
|
||||||
const data = ref<Settings>({
|
const data = ref<Settings>({
|
||||||
server: {
|
server: {
|
||||||
|
@ -68,7 +68,7 @@ async function save() {
|
||||||
// fix type
|
// fix type
|
||||||
data.value.server.http_challenge_port = data.value.server.http_challenge_port.toString()
|
data.value.server.http_challenge_port = data.value.server.http_challenge_port.toString()
|
||||||
|
|
||||||
const otpModal = useOTPModal()
|
const otpModal = use2FAModal()
|
||||||
|
|
||||||
otpModal.open().then(() => {
|
otpModal.open().then(() => {
|
||||||
settings.save(data.value).then(r => {
|
settings.save(data.value).then(r => {
|
||||||
|
@ -110,7 +110,7 @@ onMounted(() => {
|
||||||
<template>
|
<template>
|
||||||
<ACard :title="$gettext('Preference')">
|
<ACard :title="$gettext('Preference')">
|
||||||
<div class="preference-container">
|
<div class="preference-container">
|
||||||
<ATabs v-model:activeKey="activeKey">
|
<ATabs v-model:active-key="activeKey">
|
||||||
<ATabPane
|
<ATabPane
|
||||||
key="basic"
|
key="basic"
|
||||||
:tab="$gettext('Basic')"
|
:tab="$gettext('Basic')"
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { UseClipboard } from '@vueuse/components'
|
||||||
import otp from '@/api/otp'
|
import otp from '@/api/otp'
|
||||||
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||||
import { $gettext } from '@/gettext'
|
import { $gettext } from '@/gettext'
|
||||||
|
import twoFA from '@/api/2fa'
|
||||||
|
|
||||||
const status = ref(false)
|
const status = ref(false)
|
||||||
const enrolling = ref(false)
|
const enrolling = ref(false)
|
||||||
|
@ -59,8 +60,8 @@ function enroll(code: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function get2FAStatus() {
|
function get2FAStatus() {
|
||||||
otp.status().then(r => {
|
twoFA.status().then(r => {
|
||||||
status.value = r.status
|
status.value = r.otp_status
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { Terminal } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import ws from '@/lib/websocket'
|
import ws from '@/lib/websocket'
|
||||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||||
import otp from '@/api/otp'
|
import twoFA from '@/api/2fa'
|
||||||
|
|
||||||
let term: Terminal | null
|
let term: Terminal | null
|
||||||
let ping: NodeJS.Timeout
|
let ping: NodeJS.Timeout
|
||||||
|
@ -15,9 +15,9 @@ const websocket = shallowRef()
|
||||||
const lostConnection = ref(false)
|
const lostConnection = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
otp.secure_session_status()
|
twoFA.secure_session_status()
|
||||||
|
|
||||||
const otpModal = useOTPModal()
|
const otpModal = use2FAModal()
|
||||||
|
|
||||||
otpModal.open().then(secureSessionId => {
|
otpModal.open().then(secureSessionId => {
|
||||||
websocket.value = ws(`/api/pty?X-Secure-Session-ID=${secureSessionId}`, false)
|
websocket.value = ws(`/api/pty?X-Secure-Session-ID=${secureSessionId}`, false)
|
||||||
|
|
|
@ -43,7 +43,7 @@ func VerifyOTP(user *model.User, otp, recoveryCode string) (err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func secureSessionIDCacheKey(sessionId string) string {
|
func secureSessionIDCacheKey(sessionId string) string {
|
||||||
return fmt.Sprintf("otp_secure_session:_%s", sessionId)
|
return fmt.Sprintf("2fa_secure_session:_%s", sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetSecureSessionID(userId int) (sessionId string) {
|
func SetSecureSessionID(userId int) (sessionId string) {
|
||||||
|
|
|
@ -3,7 +3,6 @@ package model
|
||||||
import (
|
import (
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
@ -13,7 +12,6 @@ type User struct {
|
||||||
Password string `json:"-"`
|
Password string `json:"-"`
|
||||||
Status bool `json:"status" gorm:"default:1"`
|
Status bool `json:"status" gorm:"default:1"`
|
||||||
OTPSecret []byte `json:"-" gorm:"type:blob"`
|
OTPSecret []byte `json:"-" gorm:"type:blob"`
|
||||||
Enabled2FA bool `json:"enabled_2fa" gorm:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthToken struct {
|
type AuthToken struct {
|
||||||
|
@ -26,15 +24,20 @@ func (u *User) TableName() string {
|
||||||
return "auths"
|
return "auths"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) AfterFind(_ *gorm.DB) error {
|
|
||||||
u.Enabled2FA = u.EnabledOTP()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *User) EnabledOTP() bool {
|
func (u *User) EnabledOTP() bool {
|
||||||
return len(u.OTPSecret) != 0
|
return len(u.OTPSecret) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) EnabledPasskey() bool {
|
||||||
|
var passkeys Passkey
|
||||||
|
db.Where("user_id", u.ID).First(&passkeys)
|
||||||
|
return passkeys.ID != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) Enabled2FA() bool {
|
||||||
|
return u.EnabledOTP() || u.EnabledPasskey()
|
||||||
|
}
|
||||||
|
|
||||||
func (u *User) WebAuthnID() []byte {
|
func (u *User) WebAuthnID() []byte {
|
||||||
return []byte(cast.ToString(u.ID))
|
return []byte(cast.ToString(u.ID))
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,7 @@ func InitRouter() *gin.Engine {
|
||||||
system.InitPublicRouter(root)
|
system.InitPublicRouter(root)
|
||||||
user.InitAuthRouter(root)
|
user.InitAuthRouter(root)
|
||||||
|
|
||||||
// Authorization required not websocket request
|
// Authorization required and not websocket request
|
||||||
g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
|
g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
|
||||||
{
|
{
|
||||||
user.InitUserRouter(g)
|
user.InitUserRouter(g)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue