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/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
@ -67,7 +68,8 @@ func Login(c *gin.Context) {
|
|||
|
||||
u, err := user.Login(json.Name, json.Password)
|
||||
if err != nil {
|
||||
// time.Sleep(5 * time.Second)
|
||||
random := time.Duration(rand.Int() % 10)
|
||||
time.Sleep(random * time.Second)
|
||||
switch {
|
||||
case errors.Is(err, user.ErrPasswordIncorrect):
|
||||
c.JSON(http.StatusForbidden, LoginResponse{
|
||||
|
|
|
@ -8,8 +8,6 @@ import (
|
|||
"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"
|
||||
|
@ -147,82 +145,3 @@ func ResetOTP(c *gin.Context) {
|
|||
"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)
|
||||
}
|
||||
|
||||
func GetPasskeyConfigStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": passkey.Enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
func BeginPasskeyRegistration(c *gin.Context) {
|
||||
u := api.CurrentUser(c)
|
||||
|
||||
|
@ -100,6 +106,10 @@ func BeginPasskeyLogin(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")
|
||||
sessionDataBytes, ok := cache.Get(sessionId)
|
||||
if !ok {
|
||||
|
|
|
@ -25,17 +25,20 @@ func InitManageUserRouter(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.POST("/otp_enroll", EnrollTOTP)
|
||||
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.POST("/finish_passkey_register", FinishPasskeyRegistration)
|
||||
|
||||
r.GET("/passkeys/config", GetPasskeyConfigStatus)
|
||||
r.GET("/passkeys", GetPasskeyList)
|
||||
r.POST("/passkeys/:id", UpdatePasskey)
|
||||
r.DELETE("/passkeys/:id", DeletePasskey)
|
||||
|
|
2
app/components.d.ts
vendored
2
app/components.d.ts
vendored
|
@ -7,6 +7,8 @@ export {}
|
|||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
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']
|
||||
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
|
||||
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 = {
|
||||
status(): Promise<{ status: boolean }> {
|
||||
return http.get('/otp_status')
|
||||
},
|
||||
generate_secret(): Promise<OTPGenerateSecretResponse> {
|
||||
return http.get('/otp_secret')
|
||||
},
|
||||
|
@ -18,15 +15,6 @@ const otp = {
|
|||
reset(recovery_code: string) {
|
||||
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
|
||||
|
|
|
@ -27,8 +27,8 @@ const passkey = {
|
|||
remove(passkeyId: number) {
|
||||
return http.delete(`/passkeys/${passkeyId}`)
|
||||
},
|
||||
get_passkey_enabled() {
|
||||
return http.get('/passkey_enabled')
|
||||
get_passkey_config_status(): Promise<{ status: boolean }> {
|
||||
return http.get('/passkey/config')
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
<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 { $gettext } from '@/gettext'
|
||||
import twoFA from '@/api/2fa'
|
||||
|
||||
const emit = defineEmits(['onSubmit'])
|
||||
const emit = defineEmits(['submitOTP', 'submitSecureSessionID'])
|
||||
|
||||
const refOTP = ref()
|
||||
const useRecoveryCode = ref(false)
|
||||
const passcode = ref('')
|
||||
const recoveryCode = ref('')
|
||||
const passkeyLoading = ref(false)
|
||||
|
||||
function clickUseRecoveryCode() {
|
||||
passcode.value = ''
|
||||
|
@ -19,7 +25,7 @@ function clickUseOTP() {
|
|||
}
|
||||
|
||||
function onSubmit() {
|
||||
emit('onSubmit', passcode.value, recoveryCode.value)
|
||||
emit('submitOTP', passcode.value, recoveryCode.value)
|
||||
}
|
||||
|
||||
function clearInput() {
|
||||
|
@ -29,12 +35,32 @@ function clearInput() {
|
|||
defineExpose({
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!useRecoveryCode">
|
||||
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
|
||||
<p>{{ $gettext('Please enter the OTP code:') }}</p>
|
||||
<OTPInput
|
||||
ref="refOTP"
|
||||
v-model="passcode"
|
||||
|
@ -68,6 +94,22 @@ defineExpose({
|
|||
@click="clickUseOTP"
|
||||
>{{ $gettext('Use OTP') }}</a>
|
||||
</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>
|
||||
</template>
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { createVNode, render } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import { useCookies } from '@vueuse/integrations/useCookies'
|
||||
import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
|
||||
import otp from '@/api/otp'
|
||||
import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue'
|
||||
import twoFA from '@/api/2fa'
|
||||
import { useUserStore } from '@/pinia'
|
||||
|
||||
const useOTPModal = () => {
|
||||
const use2FAModal = () => {
|
||||
const refOTPAuthorization = ref<typeof OTPAuthorization>()
|
||||
const randomId = Math.random().toString(36).substring(2, 8)
|
||||
const { secureSessionId } = storeToRefs(useUserStore())
|
||||
|
@ -22,11 +22,11 @@ const useOTPModal = () => {
|
|||
}
|
||||
|
||||
const open = async (): Promise<string> => {
|
||||
const { status } = await otp.status()
|
||||
const { status: secureSessionStatus } = await otp.secure_session_status()
|
||||
const { enabled } = await twoFA.status()
|
||||
const { status: secureSessionStatus } = await twoFA.secure_session_status()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!status) {
|
||||
if (!enabled) {
|
||||
resolve('')
|
||||
|
||||
return
|
||||
|
@ -50,12 +50,16 @@ const useOTPModal = () => {
|
|||
container = null
|
||||
}
|
||||
|
||||
const verify = (passcode: string, recovery: string) => {
|
||||
otp.start_secure_session(passcode, recovery).then(async r => {
|
||||
cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 })
|
||||
const setSessionId = (sessionId: string) => {
|
||||
cookies.set('secure_session_id', sessionId, { maxAge: 60 * 3 })
|
||||
close()
|
||||
secureSessionId.value = r.session_id
|
||||
resolve(r.session_id)
|
||||
secureSessionId.value = sessionId
|
||||
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 () => {
|
||||
refOTPAuthorization.value?.clearInput()
|
||||
await message.error($gettext('Invalid passcode or recovery code'))
|
||||
|
@ -80,7 +84,8 @@ const useOTPModal = () => {
|
|||
{
|
||||
ref: refOTPAuthorization,
|
||||
class: 'mt-3',
|
||||
onOnSubmit: verify,
|
||||
onSubmitOTP: verifyOTP,
|
||||
onSubmitSecureSessionID: setSessionId,
|
||||
},
|
||||
),
|
||||
})
|
||||
|
@ -92,4 +97,4 @@ const useOTPModal = () => {
|
|||
return { open }
|
||||
}
|
||||
|
||||
export default useOTPModal
|
||||
export default use2FAModal
|
|
@ -7,7 +7,7 @@ import { useSettingsStore, useUserStore } from '@/pinia'
|
|||
import 'nprogress/nprogress.css'
|
||||
|
||||
import router from '@/routes'
|
||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
||||
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||
|
||||
const user = useUserStore()
|
||||
const settings = useSettingsStore()
|
||||
|
@ -61,7 +61,7 @@ instance.interceptors.response.use(
|
|||
async error => {
|
||||
NProgress.done()
|
||||
|
||||
const otpModal = useOTPModal()
|
||||
const otpModal = use2FAModal()
|
||||
const cookies = useCookies(['nginx-ui-2fa'])
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { message } from 'ant-design-vue'
|
||||
import config from '@/api/config'
|
||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
||||
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||
|
||||
const emit = defineEmits(['created'])
|
||||
const visible = ref(false)
|
||||
|
@ -25,7 +25,7 @@ defineExpose({
|
|||
|
||||
function ok() {
|
||||
refForm.value.validate().then(() => {
|
||||
const otpModal = useOTPModal()
|
||||
const otpModal = use2FAModal()
|
||||
|
||||
otpModal.open().then(() => {
|
||||
config.mkdir(data.value.basePath, data.value.name).then(() => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { message } from 'ant-design-vue'
|
||||
import config from '@/api/config'
|
||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
||||
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||
import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
|
||||
|
||||
const emit = defineEmits(['renamed'])
|
||||
|
@ -33,7 +33,7 @@ function ok() {
|
|||
refForm.value.validate().then(() => {
|
||||
const { basePath, orig_name, new_name, sync_node_ids } = data.value
|
||||
|
||||
const otpModal = useOTPModal()
|
||||
const otpModal = use2FAModal()
|
||||
|
||||
otpModal.open().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 SetLanguage from '@/components/SetLanguage/SetLanguage.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'
|
||||
|
||||
const thisYear = new Date().getFullYear()
|
||||
|
@ -154,8 +154,6 @@ async function passkeyLogin() {
|
|||
const begin = await auth.begin_passkey_login()
|
||||
const asseResp = await startAuthentication(begin.options.publicKey)
|
||||
|
||||
console.log(asseResp)
|
||||
|
||||
const r = await auth.finish_passkey_login({
|
||||
session_id: begin.session_id,
|
||||
options: asseResp,
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { Settings } from '@/views/preference/typedef'
|
|||
import LogrotateSettings from '@/views/preference/LogrotateSettings.vue'
|
||||
import { useSettingsStore } from '@/pinia'
|
||||
import AuthSettings from '@/views/preference/AuthSettings.vue'
|
||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
||||
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||
|
||||
const data = ref<Settings>({
|
||||
server: {
|
||||
|
@ -68,7 +68,7 @@ async function save() {
|
|||
// fix type
|
||||
data.value.server.http_challenge_port = data.value.server.http_challenge_port.toString()
|
||||
|
||||
const otpModal = useOTPModal()
|
||||
const otpModal = use2FAModal()
|
||||
|
||||
otpModal.open().then(() => {
|
||||
settings.save(data.value).then(r => {
|
||||
|
@ -110,7 +110,7 @@ onMounted(() => {
|
|||
<template>
|
||||
<ACard :title="$gettext('Preference')">
|
||||
<div class="preference-container">
|
||||
<ATabs v-model:activeKey="activeKey">
|
||||
<ATabs v-model:active-key="activeKey">
|
||||
<ATabPane
|
||||
key="basic"
|
||||
:tab="$gettext('Basic')"
|
||||
|
|
|
@ -5,6 +5,7 @@ import { UseClipboard } from '@vueuse/components'
|
|||
import otp from '@/api/otp'
|
||||
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||
import { $gettext } from '@/gettext'
|
||||
import twoFA from '@/api/2fa'
|
||||
|
||||
const status = ref(false)
|
||||
const enrolling = ref(false)
|
||||
|
@ -59,8 +60,8 @@ function enroll(code: string) {
|
|||
}
|
||||
|
||||
function get2FAStatus() {
|
||||
otp.status().then(r => {
|
||||
status.value = r.status
|
||||
twoFA.status().then(r => {
|
||||
status.value = r.otp_status
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -4,8 +4,8 @@ import { Terminal } from '@xterm/xterm'
|
|||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import _ from 'lodash'
|
||||
import ws from '@/lib/websocket'
|
||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
||||
import otp from '@/api/otp'
|
||||
import use2FAModal from '@/components/2FA/use2FAModal'
|
||||
import twoFA from '@/api/2fa'
|
||||
|
||||
let term: Terminal | null
|
||||
let ping: NodeJS.Timeout
|
||||
|
@ -15,9 +15,9 @@ const websocket = shallowRef()
|
|||
const lostConnection = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
otp.secure_session_status()
|
||||
twoFA.secure_session_status()
|
||||
|
||||
const otpModal = useOTPModal()
|
||||
const otpModal = use2FAModal()
|
||||
|
||||
otpModal.open().then(secureSessionId => {
|
||||
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 {
|
||||
return fmt.Sprintf("otp_secure_session:_%s", sessionId)
|
||||
return fmt.Sprintf("2fa_secure_session:_%s", sessionId)
|
||||
}
|
||||
|
||||
func SetSecureSessionID(userId int) (sessionId string) {
|
||||
|
|
|
@ -3,7 +3,6 @@ package model
|
|||
import (
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/spf13/cast"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
|
@ -13,7 +12,6 @@ type User struct {
|
|||
Password string `json:"-"`
|
||||
Status bool `json:"status" gorm:"default:1"`
|
||||
OTPSecret []byte `json:"-" gorm:"type:blob"`
|
||||
Enabled2FA bool `json:"enabled_2fa" gorm:"-"`
|
||||
}
|
||||
|
||||
type AuthToken struct {
|
||||
|
@ -26,15 +24,20 @@ func (u *User) TableName() string {
|
|||
return "auths"
|
||||
}
|
||||
|
||||
func (u *User) AfterFind(_ *gorm.DB) error {
|
||||
u.Enabled2FA = u.EnabledOTP()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) EnabledOTP() bool {
|
||||
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 {
|
||||
return []byte(cast.ToString(u.ID))
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ func InitRouter() *gin.Engine {
|
|||
system.InitPublicRouter(root)
|
||||
user.InitAuthRouter(root)
|
||||
|
||||
// Authorization required not websocket request
|
||||
// Authorization required and not websocket request
|
||||
g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
|
||||
{
|
||||
user.InitUserRouter(g)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue