Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions api/user/2fa.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package user

import (
"encoding/base64"
"net/http"
"strings"
"time"

"github.com/0xJacky/Nginx-UI/api"
"github.com/0xJacky/Nginx-UI/internal/cache"
"github.com/0xJacky/Nginx-UI/internal/passkey"
Expand All @@ -12,15 +16,14 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
"github.com/google/uuid"
"github.com/uozi-tech/cosy"
"net/http"
"strings"
"time"
)

type Status2FA struct {
Enabled bool `json:"enabled"`
OTPStatus bool `json:"otp_status"`
PasskeyStatus bool `json:"passkey_status"`
Enabled bool `json:"enabled"`
OTPStatus bool `json:"otp_status"`
PasskeyStatus bool `json:"passkey_status"`
RecoveryCodesGenerated bool `json:"recovery_codes_generated"`
RecoveryCodesViewed bool `json:"recovery_codes_viewed"`
}

func get2FAStatus(c *gin.Context) (status Status2FA) {
Expand All @@ -31,6 +34,8 @@ func get2FAStatus(c *gin.Context) (status Status2FA) {
status.OTPStatus = userPtr.EnabledOTP()
status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled()
status.Enabled = status.OTPStatus || status.PasskeyStatus
status.RecoveryCodesGenerated = userPtr.RecoveryCodeGenerated()
status.RecoveryCodesViewed = userPtr.RecoveryCodeViewed()
}
return
}
Expand Down
78 changes: 23 additions & 55 deletions api/user/otp.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
package user

import (
"bytes"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"

"github.com/0xJacky/Nginx-UI/api"
"github.com/0xJacky/Nginx-UI/internal/crypto"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/uozi-tech/cosy"
"image/jpeg"
"net/http"
"strings"
)

func GenerateTOTP(c *gin.Context) {
Expand All @@ -38,27 +36,9 @@ func GenerateTOTP(c *gin.Context) {
return
}

qrCode, err := otpKey.Image(512, 512)
if err != nil {
api.ErrHandler(c, err)
return
}

// Encode the image to a buffer
var buf []byte
buffer := bytes.NewBuffer(buf)
err = jpeg.Encode(buffer, qrCode, nil)
if err != nil {
fmt.Println("Error encoding image:", err)
return
}

// Convert the buffer to a base64 string
base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())

c.JSON(http.StatusOK, gin.H{
"secret": otpKey.Secret(),
"qr_code": base64Str,
"secret": otpKey.Secret(),
"url": otpKey.URL(),
})
}

Expand All @@ -78,22 +58,22 @@ func EnrollTOTP(c *gin.Context) {
return
}

var json struct {
var twoFA struct {
Secret string `json:"secret" binding:"required"`
Passcode string `json:"passcode" binding:"required"`
}
if !cosy.BindAndValid(c, &json) {
if !cosy.BindAndValid(c, &twoFA) {
return
}

if ok := totp.Validate(json.Passcode, json.Secret); !ok {
if ok := totp.Validate(twoFA.Passcode, twoFA.Secret); !ok {
c.JSON(http.StatusNotAcceptable, gin.H{
"message": "Invalid passcode",
})
return
}

ciphertext, err := crypto.AesEncrypt([]byte(json.Secret))
ciphertext, err := crypto.AesEncrypt([]byte(twoFA.Secret))
if err != nil {
api.ErrHandler(c, err)
return
Expand All @@ -106,37 +86,25 @@ func EnrollTOTP(c *gin.Context) {
return
}

recoveryCode := sha1.Sum(ciphertext)
t := time.Now().Unix()
recoveryCodes := model.RecoveryCodes{Codes: generateRecoveryCodes(16), LastViewed: &t}
cUser.RecoveryCodes = recoveryCodes
_, err = u.Where(u.ID.Eq(cUser.ID)).Updates(cUser)
if err != nil {
api.ErrHandler(c, err)
return
}

c.JSON(http.StatusOK, gin.H{
"message": "ok",
"recovery_code": hex.EncodeToString(recoveryCode[:]),
c.JSON(http.StatusOK, RecoveryCodesResponse{
Message: "ok",
RecoveryCodes: recoveryCodes,
})
}

func ResetOTP(c *gin.Context) {
var json struct {
RecoveryCode string `json:"recovery_code"`
}
if !cosy.BindAndValid(c, &json) {
return
}
recoverCode, err := hex.DecodeString(json.RecoveryCode)
if err != nil {
api.ErrHandler(c, err)
return
}
cUser := api.CurrentUser(c)
k := sha1.Sum(cUser.OTPSecret)
if !bytes.Equal(k[:], recoverCode) {
c.JSON(http.StatusBadRequest, gin.H{
"message": "Invalid recovery code",
})
return
}

u := query.User
_, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null())
_, err := u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null(), u.RecoveryCodes.Null())
if err != nil {
api.ErrHandler(c, err)
return
Expand Down
72 changes: 72 additions & 0 deletions api/user/recovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package user

import (
"fmt"
"math/rand"
"net/http"
"time"

"github.com/0xJacky/Nginx-UI/api"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/gin-gonic/gin"
)

type RecoveryCodesResponse struct {
Message string `json:"message"`
model.RecoveryCodes
}

func generateRecoveryCode() string {
// generate recovery code, 10 hex numbers with a dash in the middle
return fmt.Sprintf("%05x-%05x", rand.Intn(0x100000), rand.Intn(0x100000))
}

func generateRecoveryCodes(count int) []*model.RecoveryCode {
recoveryCodes := make([]*model.RecoveryCode, count)
for i := 0; i < count; i++ {
recoveryCodes[i] = &model.RecoveryCode{
Code: generateRecoveryCode(),
}
}
return recoveryCodes
}

func ViewRecoveryCodes(c *gin.Context) {
user := api.CurrentUser(c)

// update last viewed time
u := query.User
t := time.Now().Unix()
user.RecoveryCodes.LastViewed = &t
_, err := u.Where(u.ID.Eq(user.ID)).Updates(user)
if err != nil {
api.ErrHandler(c, err)
return
}

c.JSON(http.StatusOK, RecoveryCodesResponse{
Message: "ok",
RecoveryCodes: user.RecoveryCodes,
})
}

func GenerateRecoveryCodes(c *gin.Context) {
user := api.CurrentUser(c)

t := time.Now().Unix()
recoveryCodes := model.RecoveryCodes{Codes: generateRecoveryCodes(16), LastViewed: &t}
user.RecoveryCodes = recoveryCodes

u := query.User
_, err := u.Where(u.ID.Eq(user.ID)).Updates(user)
if err != nil {
api.ErrHandler(c, err)
return
}

c.JSON(http.StatusOK, RecoveryCodesResponse{
Message: "ok",
RecoveryCodes: recoveryCodes,
})
}
9 changes: 8 additions & 1 deletion api/user/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,19 @@ func InitUserRouter(r *gin.RouterGroup) {

r.GET("/otp_secret", GenerateTOTP)
r.POST("/otp_enroll", EnrollTOTP)
r.POST("/otp_reset", ResetOTP)

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

r.GET("/passkeys", GetPasskeyList)
r.POST("/passkeys/:id", UpdatePasskey)
r.DELETE("/passkeys/:id", DeletePasskey)

o := r.Group("", middleware.RequireSecureSession())
{
o.GET("/otp_reset", ResetOTP)

o.GET("/recovery_codes", ViewRecoveryCodes)
o.GET("/recovery_codes_generate", GenerateRecoveryCodes)
}
}
1 change: 1 addition & 0 deletions app/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ declare module 'vue' {
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
APopover: typeof import('ant-design-vue/es')['Popover']
AProgress: typeof import('ant-design-vue/es')['Progress']
AQrcode: typeof import('ant-design-vue/es')['QRCode']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
AResult: typeof import('ant-design-vue/es')['Result']
Expand Down
6 changes: 4 additions & 2 deletions app/src/api/2fa.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { AuthenticationResponseJSON } from '@simplewebauthn/browser'
import http from '@/lib/http'

export interface TwoFAStatusResponse {
export interface TwoFAStatus {
enabled: boolean
otp_status: boolean
passkey_status: boolean
recovery_codes_generated: boolean
recovery_codes_viewed?: boolean
}

const twoFA = {
status(): Promise<TwoFAStatusResponse> {
status(): Promise<TwoFAStatus> {
return http.get('/2fa_status')
},
start_secure_session_by_otp(passcode: string, recovery_code: string): Promise<{ session_id: string }> {
Expand Down
9 changes: 5 additions & 4 deletions app/src/api/otp.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import type { RecoveryCodesResponse } from '@/api/recovery'
import http from '@/lib/http'

export interface OTPGenerateSecretResponse {
secret: string
qr_code: string
url: string
}

const otp = {
generate_secret(): Promise<OTPGenerateSecretResponse> {
return http.get('/otp_secret')
},
enroll_otp(secret: string, passcode: string): Promise<{ recovery_code: string }> {
enroll_otp(secret: string, passcode: string): Promise<RecoveryCodesResponse> {
return http.post('/otp_enroll', { secret, passcode })
},
reset(recovery_code: string) {
return http.post('/otp_reset', { recovery_code })
reset() {
return http.get('/otp_reset')
},
}

Expand Down
27 changes: 27 additions & 0 deletions app/src/api/recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import http from '@/lib/http'

export interface RecoveryCode {
code: string
used_time?: number
}

export interface RecoveryCodes {
codes: RecoveryCode[]
last_viewed?: number
last_downloaded?: number
}

export interface RecoveryCodesResponse extends RecoveryCodes {
message: string
}

const recovery = {
generate(): Promise<RecoveryCodesResponse> {
return http.get('/recovery_codes_generate')
},
view(): Promise<RecoveryCodesResponse> {
return http.get('/recovery_codes')
},
}

export default recovery
Loading
Loading