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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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