feat: 2fa via passkey

This commit is contained in:
Jacky 2024-09-16 11:18:14 +08:00
parent bdfbbd0e8f
commit 0a6a7693a1
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
21 changed files with 384 additions and 218 deletions

156
api/user/2fa.go Normal file
View 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,
})
}

View file

@ -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{

View file

@ -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,
})
}

View file

@ -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 {

View file

@ -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
View file

@ -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
View 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

View file

@ -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

View file

@ -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')
}, },
} }

View file

@ -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>

View file

@ -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 = sessionId
secureSessionId.value = r.session_id resolve(sessionId)
resolve(r.session_id) }
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

View file

@ -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:

View file

@ -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(() => {

View file

@ -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(() => {

View file

@ -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,

View file

@ -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')"

View file

@ -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
}) })
} }

View file

@ -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)

View file

@ -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) {

View file

@ -3,17 +3,15 @@ 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 {
Model Model
Name string `json:"name"` Name string `json:"name"`
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))
} }

View file

@ -1,83 +1,83 @@
package router package router
import ( import (
"github.com/0xJacky/Nginx-UI/api/analytic" "github.com/0xJacky/Nginx-UI/api/analytic"
"github.com/0xJacky/Nginx-UI/api/certificate" "github.com/0xJacky/Nginx-UI/api/certificate"
"github.com/0xJacky/Nginx-UI/api/cluster" "github.com/0xJacky/Nginx-UI/api/cluster"
"github.com/0xJacky/Nginx-UI/api/config" "github.com/0xJacky/Nginx-UI/api/config"
"github.com/0xJacky/Nginx-UI/api/nginx" "github.com/0xJacky/Nginx-UI/api/nginx"
"github.com/0xJacky/Nginx-UI/api/notification" "github.com/0xJacky/Nginx-UI/api/notification"
"github.com/0xJacky/Nginx-UI/api/openai" "github.com/0xJacky/Nginx-UI/api/openai"
"github.com/0xJacky/Nginx-UI/api/settings" "github.com/0xJacky/Nginx-UI/api/settings"
"github.com/0xJacky/Nginx-UI/api/sites" "github.com/0xJacky/Nginx-UI/api/sites"
"github.com/0xJacky/Nginx-UI/api/streams" "github.com/0xJacky/Nginx-UI/api/streams"
"github.com/0xJacky/Nginx-UI/api/system" "github.com/0xJacky/Nginx-UI/api/system"
"github.com/0xJacky/Nginx-UI/api/template" "github.com/0xJacky/Nginx-UI/api/template"
"github.com/0xJacky/Nginx-UI/api/terminal" "github.com/0xJacky/Nginx-UI/api/terminal"
"github.com/0xJacky/Nginx-UI/api/upstream" "github.com/0xJacky/Nginx-UI/api/upstream"
"github.com/0xJacky/Nginx-UI/api/user" "github.com/0xJacky/Nginx-UI/api/user"
"github.com/0xJacky/Nginx-UI/internal/middleware" "github.com/0xJacky/Nginx-UI/internal/middleware"
"github.com/gin-contrib/static" "github.com/gin-contrib/static"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"net/http" "net/http"
) )
func InitRouter() *gin.Engine { func InitRouter() *gin.Engine {
r := gin.New() r := gin.New()
r.Use( r.Use(
gin.Logger(), gin.Logger(),
middleware.Recovery(), middleware.Recovery(),
middleware.CacheJs(), middleware.CacheJs(),
middleware.IPWhiteList(), middleware.IPWhiteList(),
static.Serve("/", middleware.MustFs("")), static.Serve("/", middleware.MustFs("")),
) )
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"message": "not found", "message": "not found",
}) })
}) })
root := r.Group("/api") root := r.Group("/api")
{ {
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)
analytic.InitRouter(g) analytic.InitRouter(g)
user.InitManageUserRouter(g) user.InitManageUserRouter(g)
nginx.InitRouter(g) nginx.InitRouter(g)
sites.InitRouter(g) sites.InitRouter(g)
streams.InitRouter(g) streams.InitRouter(g)
config.InitRouter(g) config.InitRouter(g)
template.InitRouter(g) template.InitRouter(g)
certificate.InitCertificateRouter(g) certificate.InitCertificateRouter(g)
certificate.InitDNSCredentialRouter(g) certificate.InitDNSCredentialRouter(g)
certificate.InitAcmeUserRouter(g) certificate.InitAcmeUserRouter(g)
system.InitPrivateRouter(g) system.InitPrivateRouter(g)
settings.InitRouter(g) settings.InitRouter(g)
openai.InitRouter(g) openai.InitRouter(g)
cluster.InitRouter(g) cluster.InitRouter(g)
notification.InitRouter(g) notification.InitRouter(g)
} }
// Authorization required and websocket request // Authorization required and websocket request
w := root.Group("/", middleware.AuthRequired(), middleware.ProxyWs()) w := root.Group("/", middleware.AuthRequired(), middleware.ProxyWs())
{ {
analytic.InitWebSocketRouter(w) analytic.InitWebSocketRouter(w)
certificate.InitCertificateWebSocketRouter(w) certificate.InitCertificateWebSocketRouter(w)
o := w.Group("", middleware.RequireSecureSession()) o := w.Group("", middleware.RequireSecureSession())
{ {
terminal.InitRouter(o) terminal.InitRouter(o)
} }
nginx.InitNginxLogRouter(w) nginx.InitNginxLogRouter(w)
upstream.InitRouter(w) upstream.InitRouter(w)
system.InitWebSocketRouter(w) system.InitWebSocketRouter(w)
} }
} }
return r return r
} }