Skip to content

Commit 3a22861

Browse files
committed
feat: 2FA authorization for web terminal
1 parent 802d05f commit 3a22861

File tree

15 files changed

+360
-55
lines changed

15 files changed

+360
-55
lines changed

api/user/auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func Login(c *gin.Context) {
8686
}
8787

8888
// Check if the user enables 2FA
89-
if len(u.OTPSecret) > 0 {
89+
if u.EnabledOTP() {
9090
if json.OTP == "" && json.RecoveryCode == "" {
9191
c.JSON(http.StatusOK, LoginResponse{
9292
Message: "The user has enabled 2FA",

api/user/otp.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"github.com/0xJacky/Nginx-UI/api"
1010
"github.com/0xJacky/Nginx-UI/internal/crypto"
11+
"github.com/0xJacky/Nginx-UI/internal/user"
1112
"github.com/0xJacky/Nginx-UI/query"
1213
"github.com/0xJacky/Nginx-UI/settings"
1314
"github.com/gin-gonic/gin"
@@ -67,8 +68,8 @@ func GenerateTOTP(c *gin.Context) {
6768
}
6869

6970
func EnrollTOTP(c *gin.Context) {
70-
user := api.CurrentUser(c)
71-
if len(user.OTPSecret) > 0 {
71+
cUser := api.CurrentUser(c)
72+
if cUser.EnabledOTP() {
7273
c.JSON(http.StatusBadRequest, gin.H{
7374
"message": "User already enrolled",
7475
})
@@ -109,7 +110,7 @@ func EnrollTOTP(c *gin.Context) {
109110
}
110111

111112
u := query.Auth
112-
_, err = u.Where(u.ID.Eq(user.ID)).Update(u.OTPSecret, ciphertext)
113+
_, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)
113114
if err != nil {
114115
api.ErrHandler(c, err)
115116
return
@@ -135,8 +136,8 @@ func ResetOTP(c *gin.Context) {
135136
api.ErrHandler(c, err)
136137
return
137138
}
138-
user := api.CurrentUser(c)
139-
k := sha1.Sum(user.OTPSecret)
139+
cUser := api.CurrentUser(c)
140+
k := sha1.Sum(cUser.OTPSecret)
140141
if !bytes.Equal(k[:], recoverCode) {
141142
c.JSON(http.StatusBadRequest, gin.H{
142143
"message": "Invalid recovery code",
@@ -145,7 +146,7 @@ func ResetOTP(c *gin.Context) {
145146
}
146147

147148
u := query.Auth
148-
_, err = u.Where(u.ID.Eq(user.ID)).UpdateSimple(u.OTPSecret.Null())
149+
_, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null())
149150
if err != nil {
150151
api.ErrHandler(c, err)
151152
return
@@ -161,3 +162,40 @@ func OTPStatus(c *gin.Context) {
161162
"status": len(api.CurrentUser(c).OTPSecret) > 0,
162163
})
163164
}
165+
166+
func StartSecure2FASession(c *gin.Context) {
167+
var json struct {
168+
OTP string `json:"otp"`
169+
RecoveryCode string `json:"recovery_code"`
170+
}
171+
if !api.BindAndValid(c, &json) {
172+
return
173+
}
174+
u := api.CurrentUser(c)
175+
if !u.EnabledOTP() {
176+
c.JSON(http.StatusBadRequest, gin.H{
177+
"message": "User not configured with 2FA",
178+
})
179+
return
180+
}
181+
182+
if json.OTP == "" && json.RecoveryCode == "" {
183+
c.JSON(http.StatusBadRequest, LoginResponse{
184+
Message: "The user has enabled 2FA",
185+
})
186+
return
187+
}
188+
189+
if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
190+
c.JSON(http.StatusBadRequest, LoginResponse{
191+
Message: "Invalid 2FA or recovery code",
192+
})
193+
return
194+
}
195+
196+
sessionId := user.SetSecureSessionID(u.ID)
197+
198+
c.JSON(http.StatusOK, gin.H{
199+
"session_id": sessionId,
200+
})
201+
}

api/user/router.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ func InitUserRouter(r *gin.RouterGroup) {
2222
r.GET("/otp_status", OTPStatus)
2323
r.GET("/otp_secret", GenerateTOTP)
2424
r.POST("/otp_enroll", EnrollTOTP)
25-
r.POST("/otp_reset", ResetOTP)
25+
r.POST("/otp_reset", ResetOTP)
26+
r.POST("/otp_secure_session", StartSecure2FASession)
2627
}

app/components.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,11 @@ declare module 'vue' {
7878
NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default']
7979
NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default']
8080
NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default']
81+
OTP: typeof import('./src/components/OTP.vue')['default']
8182
OTPInput: typeof import('./src/components/OTPInput.vue')['default']
8283
OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
84+
OTPOTPAuthorization: typeof import('./src/components/OTP/OTPAuthorization.vue')['default']
85+
OTPOTPAuthorizationModal: typeof import('./src/components/OTP/OTPAuthorizationModal.vue')['default']
8386
PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
8487
RouterLink: typeof import('vue-router')['RouterLink']
8588
RouterView: typeof import('vue-router')['RouterView']

app/src/api/otp.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ const otp = {
1818
reset(recovery_code: string) {
1919
return http.post('/otp_reset', { recovery_code })
2020
},
21+
start_secure_session(passcode: string, recovery_code: string): Promise<{ session_id: string }> {
22+
return http.post('/otp_secure_session', {
23+
otp: passcode,
24+
recovery_code,
25+
})
26+
},
2127
}
2228

2329
export default otp
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<script setup lang="ts">
2+
import OTPInput from '@/components/OTPInput/OTPInput.vue'
3+
4+
const emit = defineEmits(['onSubmit'])
5+
6+
const refOTP = ref()
7+
const useRecoveryCode = ref(false)
8+
const passcode = ref('')
9+
const recoveryCode = ref('')
10+
11+
function clickUseRecoveryCode() {
12+
passcode.value = ''
13+
useRecoveryCode.value = true
14+
}
15+
16+
function clickUseOTP() {
17+
passcode.value = ''
18+
useRecoveryCode.value = false
19+
}
20+
21+
function onSubmit() {
22+
emit('onSubmit', passcode.value, recoveryCode.value)
23+
}
24+
25+
function clearInput() {
26+
refOTP.value?.clearInput()
27+
}
28+
29+
defineExpose({
30+
clearInput,
31+
})
32+
</script>
33+
34+
<template>
35+
<div>
36+
<div v-if="!useRecoveryCode">
37+
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
38+
<OTPInput
39+
ref="refOTP"
40+
v-model="passcode"
41+
class="justify-center mb-6"
42+
@on-complete="onSubmit"
43+
/>
44+
</div>
45+
<div
46+
v-else
47+
class="mt-2 mb-4"
48+
>
49+
<p>{{ $gettext('Input the recovery code:') }}</p>
50+
<AInputGroup compact>
51+
<AInput v-model:value="recoveryCode" />
52+
<AButton
53+
type="primary"
54+
@click="onSubmit"
55+
>
56+
{{ $gettext('Recovery') }}
57+
</AButton>
58+
</AInputGroup>
59+
</div>
60+
61+
<div class="flex justify-center">
62+
<a
63+
v-if="!useRecoveryCode"
64+
@click="clickUseRecoveryCode"
65+
>{{ $gettext('Use recovery code') }}</a>
66+
<a
67+
v-else
68+
@click="clickUseOTP"
69+
>{{ $gettext('Use OTP') }}</a>
70+
</div>
71+
</div>
72+
</template>
73+
74+
<style scoped lang="less">
75+
:deep(.ant-input-group.ant-input-group-compact) {
76+
display: flex;
77+
}
78+
</style>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { createVNode, render } from 'vue'
2+
import { Modal, message } from 'ant-design-vue'
3+
import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
4+
import otp from '@/api/otp'
5+
6+
export interface OTPModalProps {
7+
onOk?: (secureSessionId: string) => void
8+
onCancel?: () => void
9+
}
10+
11+
const useOTPModal = () => {
12+
const refOTPAuthorization = ref<typeof OTPAuthorization>()
13+
const randomId = Math.random().toString(36).substring(2, 8)
14+
15+
const injectStyles = () => {
16+
const style = document.createElement('style')
17+
18+
style.innerHTML = `
19+
.${randomId} .ant-modal-title {
20+
font-size: 1.125rem;
21+
}
22+
`
23+
document.head.appendChild(style)
24+
}
25+
26+
const open = ({ onOk, onCancel }: OTPModalProps) => {
27+
injectStyles()
28+
let container: HTMLDivElement | null = document.createElement('div')
29+
document.body.appendChild(container)
30+
31+
const close = () => {
32+
render(null, container!)
33+
document.body.removeChild(container!)
34+
container = null
35+
}
36+
37+
const verify = (passcode: string, recovery: string) => {
38+
otp.start_secure_session(passcode, recovery).then(r => {
39+
onOk?.(r.session_id)
40+
close()
41+
}).catch(async () => {
42+
refOTPAuthorization.value?.clearInput()
43+
await message.error($gettext('Invalid passcode or recovery code'))
44+
})
45+
}
46+
47+
const vnode = createVNode(Modal, {
48+
open: true,
49+
title: $gettext('Two-factor authentication required'),
50+
centered: true,
51+
maskClosable: false,
52+
class: randomId,
53+
footer: false,
54+
onCancel: () => {
55+
close()
56+
onCancel?.()
57+
},
58+
}, {
59+
default: () => h(
60+
OTPAuthorization,
61+
{
62+
ref: refOTPAuthorization,
63+
class: 'mt-3',
64+
onOnSubmit: verify,
65+
},
66+
),
67+
})
68+
69+
render(vnode, container)
70+
}
71+
72+
return { open }
73+
}
74+
75+
export default useOTPModal

app/src/views/other/Login.vue

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
<script setup lang="ts">
22
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
33
import { Form, message } from 'ant-design-vue'
4-
import OTPInput from '@/components/OTPInput/OTPInput.vue'
5-
64
import { useUserStore } from '@/pinia'
75
import auth from '@/api/auth'
86
import install from '@/api/install'
97
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
108
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
119
import gettext, { $gettext } from '@/gettext'
10+
import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
1211
1312
const thisYear = new Date().getFullYear()
1413
@@ -24,7 +23,6 @@ const loading = ref(false)
2423
const enabled2FA = ref(false)
2524
const refOTP = ref()
2625
const passcode = ref('')
27-
const useRecoveryCode = ref(false)
2826
const recoveryCode = ref('')
2927
3028
const modelRef = reactive({
@@ -135,9 +133,13 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
135133
loading.value = false
136134
}
137135
138-
function clickUseRecoveryCode() {
139-
passcode.value = ''
140-
useRecoveryCode.value = true
136+
function handleOTPSubmit(code: string, recovery: string) {
137+
passcode.value = code
138+
recoveryCode.value = recovery
139+
140+
nextTick(() => {
141+
onSubmit()
142+
})
141143
}
142144
</script>
143145

@@ -173,38 +175,10 @@ function clickUseRecoveryCode() {
173175
</AFormItem>
174176
</template>
175177
<div v-else>
176-
<div v-if="!useRecoveryCode">
177-
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
178-
<OTPInput
179-
ref="refOTP"
180-
v-model="passcode"
181-
class="justify-center mb-6"
182-
@on-complete="onSubmit"
183-
/>
184-
185-
<div class="flex justify-center">
186-
<a @click="clickUseRecoveryCode">{{ $gettext('Use recovery code') }}</a>
187-
</div>
188-
</div>
189-
190-
<div
191-
v-else
192-
class="mt-2"
193-
>
194-
<p>{{ $gettext('Input the recovery code:') }}</p>
195-
<AInputGroup compact>
196-
<AInput
197-
v-model:value="recoveryCode"
198-
style="width: calc(100% - 92px)"
199-
/>
200-
<AButton
201-
type="primary"
202-
@click="onSubmit"
203-
>
204-
{{ $gettext('Recovery') }}
205-
</AButton>
206-
</AInputGroup>
207-
</div>
178+
<OTPAuthorization
179+
ref="refOTP"
180+
@on-submit="handleOTPSubmit"
181+
/>
208182
</div>
209183

210184
<AFormItem v-if="!enabled2FA">

0 commit comments

Comments
 (0)