Skip to content

Commit 46d3074

Browse files
authored
Merge pull request #859 from 0xJacky/refactor/otp
refactor: new recovery codes and OTP
2 parents e92f00b + a650175 commit 46d3074

File tree

30 files changed

+2958
-1185
lines changed

30 files changed

+2958
-1185
lines changed

api/user/2fa.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package user
22

33
import (
44
"encoding/base64"
5+
"net/http"
6+
"strings"
7+
"time"
8+
59
"github.com/0xJacky/Nginx-UI/api"
610
"github.com/0xJacky/Nginx-UI/internal/cache"
711
"github.com/0xJacky/Nginx-UI/internal/passkey"
@@ -12,15 +16,14 @@ import (
1216
"github.com/go-webauthn/webauthn/webauthn"
1317
"github.com/google/uuid"
1418
"github.com/uozi-tech/cosy"
15-
"net/http"
16-
"strings"
17-
"time"
1819
)
1920

2021
type Status2FA struct {
21-
Enabled bool `json:"enabled"`
22-
OTPStatus bool `json:"otp_status"`
23-
PasskeyStatus bool `json:"passkey_status"`
22+
Enabled bool `json:"enabled"`
23+
OTPStatus bool `json:"otp_status"`
24+
PasskeyStatus bool `json:"passkey_status"`
25+
RecoveryCodesGenerated bool `json:"recovery_codes_generated"`
26+
RecoveryCodesViewed bool `json:"recovery_codes_viewed"`
2427
}
2528

2629
func get2FAStatus(c *gin.Context) (status Status2FA) {
@@ -31,6 +34,8 @@ func get2FAStatus(c *gin.Context) (status Status2FA) {
3134
status.OTPStatus = userPtr.EnabledOTP()
3235
status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled()
3336
status.Enabled = status.OTPStatus || status.PasskeyStatus
37+
status.RecoveryCodesGenerated = userPtr.RecoveryCodeGenerated()
38+
status.RecoveryCodesViewed = userPtr.RecoveryCodeViewed()
3439
}
3540
return
3641
}

api/user/otp.go

Lines changed: 23 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
package user
22

33
import (
4-
"bytes"
5-
"crypto/sha1"
6-
"encoding/base64"
7-
"encoding/hex"
84
"fmt"
5+
"net/http"
6+
"strings"
7+
"time"
8+
99
"github.com/0xJacky/Nginx-UI/api"
1010
"github.com/0xJacky/Nginx-UI/internal/crypto"
11+
"github.com/0xJacky/Nginx-UI/model"
1112
"github.com/0xJacky/Nginx-UI/query"
1213
"github.com/0xJacky/Nginx-UI/settings"
1314
"github.com/gin-gonic/gin"
1415
"github.com/pquerna/otp"
1516
"github.com/pquerna/otp/totp"
1617
"github.com/uozi-tech/cosy"
17-
"image/jpeg"
18-
"net/http"
19-
"strings"
2018
)
2119

2220
func GenerateTOTP(c *gin.Context) {
@@ -38,27 +36,9 @@ func GenerateTOTP(c *gin.Context) {
3836
return
3937
}
4038

41-
qrCode, err := otpKey.Image(512, 512)
42-
if err != nil {
43-
api.ErrHandler(c, err)
44-
return
45-
}
46-
47-
// Encode the image to a buffer
48-
var buf []byte
49-
buffer := bytes.NewBuffer(buf)
50-
err = jpeg.Encode(buffer, qrCode, nil)
51-
if err != nil {
52-
fmt.Println("Error encoding image:", err)
53-
return
54-
}
55-
56-
// Convert the buffer to a base64 string
57-
base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())
58-
5939
c.JSON(http.StatusOK, gin.H{
60-
"secret": otpKey.Secret(),
61-
"qr_code": base64Str,
40+
"secret": otpKey.Secret(),
41+
"url": otpKey.URL(),
6242
})
6343
}
6444

@@ -78,22 +58,22 @@ func EnrollTOTP(c *gin.Context) {
7858
return
7959
}
8060

81-
var json struct {
61+
var twoFA struct {
8262
Secret string `json:"secret" binding:"required"`
8363
Passcode string `json:"passcode" binding:"required"`
8464
}
85-
if !cosy.BindAndValid(c, &json) {
65+
if !cosy.BindAndValid(c, &twoFA) {
8666
return
8767
}
8868

89-
if ok := totp.Validate(json.Passcode, json.Secret); !ok {
69+
if ok := totp.Validate(twoFA.Passcode, twoFA.Secret); !ok {
9070
c.JSON(http.StatusNotAcceptable, gin.H{
9171
"message": "Invalid passcode",
9272
})
9373
return
9474
}
9575

96-
ciphertext, err := crypto.AesEncrypt([]byte(json.Secret))
76+
ciphertext, err := crypto.AesEncrypt([]byte(twoFA.Secret))
9777
if err != nil {
9878
api.ErrHandler(c, err)
9979
return
@@ -106,37 +86,25 @@ func EnrollTOTP(c *gin.Context) {
10686
return
10787
}
10888

109-
recoveryCode := sha1.Sum(ciphertext)
89+
t := time.Now().Unix()
90+
recoveryCodes := model.RecoveryCodes{Codes: generateRecoveryCodes(16), LastViewed: &t}
91+
cUser.RecoveryCodes = recoveryCodes
92+
_, err = u.Where(u.ID.Eq(cUser.ID)).Updates(cUser)
93+
if err != nil {
94+
api.ErrHandler(c, err)
95+
return
96+
}
11097

111-
c.JSON(http.StatusOK, gin.H{
112-
"message": "ok",
113-
"recovery_code": hex.EncodeToString(recoveryCode[:]),
98+
c.JSON(http.StatusOK, RecoveryCodesResponse{
99+
Message: "ok",
100+
RecoveryCodes: recoveryCodes,
114101
})
115102
}
116103

117104
func ResetOTP(c *gin.Context) {
118-
var json struct {
119-
RecoveryCode string `json:"recovery_code"`
120-
}
121-
if !cosy.BindAndValid(c, &json) {
122-
return
123-
}
124-
recoverCode, err := hex.DecodeString(json.RecoveryCode)
125-
if err != nil {
126-
api.ErrHandler(c, err)
127-
return
128-
}
129105
cUser := api.CurrentUser(c)
130-
k := sha1.Sum(cUser.OTPSecret)
131-
if !bytes.Equal(k[:], recoverCode) {
132-
c.JSON(http.StatusBadRequest, gin.H{
133-
"message": "Invalid recovery code",
134-
})
135-
return
136-
}
137-
138106
u := query.User
139-
_, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null())
107+
_, err := u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null(), u.RecoveryCodes.Null())
140108
if err != nil {
141109
api.ErrHandler(c, err)
142110
return

api/user/recovery.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package user
2+
3+
import (
4+
"fmt"
5+
"math/rand"
6+
"net/http"
7+
"time"
8+
9+
"github.com/0xJacky/Nginx-UI/api"
10+
"github.com/0xJacky/Nginx-UI/model"
11+
"github.com/0xJacky/Nginx-UI/query"
12+
"github.com/gin-gonic/gin"
13+
)
14+
15+
type RecoveryCodesResponse struct {
16+
Message string `json:"message"`
17+
model.RecoveryCodes
18+
}
19+
20+
func generateRecoveryCode() string {
21+
// generate recovery code, 10 hex numbers with a dash in the middle
22+
return fmt.Sprintf("%05x-%05x", rand.Intn(0x100000), rand.Intn(0x100000))
23+
}
24+
25+
func generateRecoveryCodes(count int) []*model.RecoveryCode {
26+
recoveryCodes := make([]*model.RecoveryCode, count)
27+
for i := 0; i < count; i++ {
28+
recoveryCodes[i] = &model.RecoveryCode{
29+
Code: generateRecoveryCode(),
30+
}
31+
}
32+
return recoveryCodes
33+
}
34+
35+
func ViewRecoveryCodes(c *gin.Context) {
36+
user := api.CurrentUser(c)
37+
38+
// update last viewed time
39+
u := query.User
40+
t := time.Now().Unix()
41+
user.RecoveryCodes.LastViewed = &t
42+
_, err := u.Where(u.ID.Eq(user.ID)).Updates(user)
43+
if err != nil {
44+
api.ErrHandler(c, err)
45+
return
46+
}
47+
48+
c.JSON(http.StatusOK, RecoveryCodesResponse{
49+
Message: "ok",
50+
RecoveryCodes: user.RecoveryCodes,
51+
})
52+
}
53+
54+
func GenerateRecoveryCodes(c *gin.Context) {
55+
user := api.CurrentUser(c)
56+
57+
t := time.Now().Unix()
58+
recoveryCodes := model.RecoveryCodes{Codes: generateRecoveryCodes(16), LastViewed: &t}
59+
user.RecoveryCodes = recoveryCodes
60+
61+
u := query.User
62+
_, err := u.Where(u.ID.Eq(user.ID)).Updates(user)
63+
if err != nil {
64+
api.ErrHandler(c, err)
65+
return
66+
}
67+
68+
c.JSON(http.StatusOK, RecoveryCodesResponse{
69+
Message: "ok",
70+
RecoveryCodes: recoveryCodes,
71+
})
72+
}

api/user/router.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,19 @@ func InitUserRouter(r *gin.RouterGroup) {
2727

2828
r.GET("/otp_secret", GenerateTOTP)
2929
r.POST("/otp_enroll", EnrollTOTP)
30-
r.POST("/otp_reset", ResetOTP)
3130

3231
r.GET("/begin_passkey_register", BeginPasskeyRegistration)
3332
r.POST("/finish_passkey_register", FinishPasskeyRegistration)
3433

3534
r.GET("/passkeys", GetPasskeyList)
3635
r.POST("/passkeys/:id", UpdatePasskey)
3736
r.DELETE("/passkeys/:id", DeletePasskey)
37+
38+
o := r.Group("", middleware.RequireSecureSession())
39+
{
40+
o.GET("/otp_reset", ResetOTP)
41+
42+
o.GET("/recovery_codes", ViewRecoveryCodes)
43+
o.GET("/recovery_codes_generate", GenerateRecoveryCodes)
44+
}
3845
}

app/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ declare module 'vue' {
4949
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
5050
APopover: typeof import('ant-design-vue/es')['Popover']
5151
AProgress: typeof import('ant-design-vue/es')['Progress']
52+
AQrcode: typeof import('ant-design-vue/es')['QRCode']
5253
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
5354
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
5455
AResult: typeof import('ant-design-vue/es')['Result']

app/src/api/2fa.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import type { AuthenticationResponseJSON } from '@simplewebauthn/browser'
22
import http from '@/lib/http'
33

4-
export interface TwoFAStatusResponse {
4+
export interface TwoFAStatus {
55
enabled: boolean
66
otp_status: boolean
77
passkey_status: boolean
8+
recovery_codes_generated: boolean
9+
recovery_codes_viewed?: boolean
810
}
911

1012
const twoFA = {
11-
status(): Promise<TwoFAStatusResponse> {
13+
status(): Promise<TwoFAStatus> {
1214
return http.get('/2fa_status')
1315
},
1416
start_secure_session_by_otp(passcode: string, recovery_code: string): Promise<{ session_id: string }> {

app/src/api/otp.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
1+
import type { RecoveryCodesResponse } from '@/api/recovery'
12
import http from '@/lib/http'
23

34
export interface OTPGenerateSecretResponse {
45
secret: string
5-
qr_code: string
6+
url: string
67
}
78

89
const otp = {
910
generate_secret(): Promise<OTPGenerateSecretResponse> {
1011
return http.get('/otp_secret')
1112
},
12-
enroll_otp(secret: string, passcode: string): Promise<{ recovery_code: string }> {
13+
enroll_otp(secret: string, passcode: string): Promise<RecoveryCodesResponse> {
1314
return http.post('/otp_enroll', { secret, passcode })
1415
},
15-
reset(recovery_code: string) {
16-
return http.post('/otp_reset', { recovery_code })
16+
reset() {
17+
return http.get('/otp_reset')
1718
},
1819
}
1920

app/src/api/recovery.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import http from '@/lib/http'
2+
3+
export interface RecoveryCode {
4+
code: string
5+
used_time?: number
6+
}
7+
8+
export interface RecoveryCodes {
9+
codes: RecoveryCode[]
10+
last_viewed?: number
11+
last_downloaded?: number
12+
}
13+
14+
export interface RecoveryCodesResponse extends RecoveryCodes {
15+
message: string
16+
}
17+
18+
const recovery = {
19+
generate(): Promise<RecoveryCodesResponse> {
20+
return http.get('/recovery_codes_generate')
21+
},
22+
view(): Promise<RecoveryCodesResponse> {
23+
return http.get('/recovery_codes')
24+
},
25+
}
26+
27+
export default recovery

0 commit comments

Comments
 (0)