feat: login 2fa

This commit is contained in:
Jacky 2024-07-23 17:28:13 +08:00
parent 8d8ba150ef
commit 5abd9b75bb
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
33 changed files with 1063 additions and 122 deletions

1
.idea/vcs.xml generated
View file

@ -2,6 +2,5 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/docs" vcs="Git" />
</component> </component>
</project> </project>

View file

@ -3,6 +3,7 @@ package api
import ( import (
"errors" "errors"
"github.com/0xJacky/Nginx-UI/internal/logger" "github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/0xJacky/Nginx-UI/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"net/http" "net/http"
@ -11,6 +12,10 @@ import (
"strings" "strings"
) )
func CurrentUser(c *gin.Context) *model.Auth {
return c.MustGet("user").(*model.Auth)
}
func ErrHandler(c *gin.Context, err error) { func ErrHandler(c *gin.Context, err error) {
logger.GetLogger().Errorln(err) logger.GetLogger().Errorln(err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{

View file

@ -18,12 +18,17 @@ var mutex = &sync.Mutex{}
type LoginUser struct { type LoginUser struct {
Name string `json:"name" binding:"required,max=255"` Name string `json:"name" binding:"required,max=255"`
Password string `json:"password" binding:"required,max=255"` Password string `json:"password" binding:"required,max=255"`
OTP string `json:"otp"`
RecoveryCode string `json:"recovery_code"`
} }
const ( const (
ErrPasswordIncorrect = 4031 ErrPasswordIncorrect = 4031
ErrMaxAttempts = 4291 ErrMaxAttempts = 4291
ErrUserBanned = 4033 ErrUserBanned = 4033
Enabled2FA = 199
Error2FACode = 4034
LoginSuccess = 200
) )
type LoginResponse struct { type LoginResponse struct {
@ -80,11 +85,32 @@ func Login(c *gin.Context) {
return return
} }
// Check if the user enables 2FA
if len(u.OTPSecret) > 0 {
if json.OTP == "" && json.RecoveryCode == "" {
c.JSON(http.StatusOK, LoginResponse{
Message: "The user has enabled 2FA",
Code: Enabled2FA,
})
user.BanIP(clientIP)
return
}
if err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
c.JSON(http.StatusForbidden, LoginResponse{
Message: "Invalid 2FA or recovery code",
Code: Error2FACode,
})
user.BanIP(clientIP)
return
}
}
// login success, clear banned record // login success, clear banned record
_, _ = b.Where(b.IP.Eq(clientIP)).Delete() _, _ = b.Where(b.IP.Eq(clientIP)).Delete()
logger.Info("[User Login]", u.Name) logger.Info("[User Login]", u.Name)
token, err := user.GenerateJWT(u.Name) token, err := user.GenerateJWT(u)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, LoginResponse{ c.JSON(http.StatusInternalServerError, LoginResponse{
Message: err.Error(), Message: err.Error(),
@ -93,6 +119,7 @@ func Login(c *gin.Context) {
} }
c.JSON(http.StatusOK, LoginResponse{ c.JSON(http.StatusOK, LoginResponse{
Code: LoginSuccess,
Message: "ok", Message: "ok",
Token: token, Token: token,
}) })
@ -101,13 +128,7 @@ func Login(c *gin.Context) {
func Logout(c *gin.Context) { func Logout(c *gin.Context) {
token := c.GetHeader("Authorization") token := c.GetHeader("Authorization")
if token != "" { if token != "" {
err := user.DeleteToken(token) user.DeleteToken(token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"message": err.Error(),
})
return
}
} }
c.JSON(http.StatusNoContent, nil) c.JSON(http.StatusNoContent, nil)
} }

View file

@ -65,7 +65,7 @@ func CasdoorCallback(c *gin.Context) {
return return
} }
userToken, err := user.GenerateJWT(u.Name) userToken, err := user.GenerateJWT(u)
if err != nil { if err != nil {
api.ErrHandler(c, err) api.ErrHandler(c, err)
return return

163
api/user/otp.go Normal file
View file

@ -0,0 +1,163 @@
package user
import (
"bytes"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/0xJacky/Nginx-UI/api"
"github.com/0xJacky/Nginx-UI/internal/crypto"
"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"
"image/jpeg"
"net/http"
"strings"
)
func GenerateTOTP(c *gin.Context) {
user := api.CurrentUser(c)
issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
issuer = strings.TrimSpace(issuer)
otpOpts := totp.GenerateOpts{
Issuer: issuer,
AccountName: user.Name,
Period: 30, // seconds
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
}
otpKey, err := totp.Generate(otpOpts)
if err != nil {
api.ErrHandler(c, err)
return
}
ciphertext, err := crypto.AesEncrypt([]byte(otpKey.Secret()))
if err != nil {
api.ErrHandler(c, err)
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": base64.StdEncoding.EncodeToString(ciphertext),
"qr_code": base64Str,
})
}
func EnrollTOTP(c *gin.Context) {
user := api.CurrentUser(c)
if len(user.OTPSecret) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"message": "User already enrolled",
})
return
}
var json struct {
Secret string `json:"secret" binding:"required"`
Passcode string `json:"passcode" binding:"required"`
}
if !api.BindAndValid(c, &json) {
return
}
secret, err := base64.StdEncoding.DecodeString(json.Secret)
if err != nil {
api.ErrHandler(c, err)
return
}
decrypted, err := crypto.AesDecrypt(secret)
if err != nil {
api.ErrHandler(c, err)
return
}
if ok := totp.Validate(json.Passcode, string(decrypted)); !ok {
c.JSON(http.StatusNotAcceptable, gin.H{
"message": "Invalid passcode",
})
return
}
ciphertext, err := crypto.AesEncrypt(decrypted)
if err != nil {
api.ErrHandler(c, err)
return
}
u := query.Auth
_, err = u.Where(u.ID.Eq(user.ID)).Update(u.OTPSecret, ciphertext)
if err != nil {
api.ErrHandler(c, err)
return
}
recoveryCode := sha1.Sum(ciphertext)
c.JSON(http.StatusOK, gin.H{
"message": "ok",
"recovery_code": hex.EncodeToString(recoveryCode[:]),
})
}
func ResetOTP(c *gin.Context) {
var json struct {
RecoveryCode string `json:"recovery_code"`
}
if !api.BindAndValid(c, &json) {
return
}
recoverCode, err := hex.DecodeString(json.RecoveryCode)
if err != nil {
api.ErrHandler(c, err)
return
}
user := api.CurrentUser(c)
k := sha1.Sum(user.OTPSecret)
if !bytes.Equal(k[:], recoverCode) {
c.JSON(http.StatusBadRequest, gin.H{
"message": "Invalid recovery code",
})
return
}
u := query.Auth
_, err = u.Where(u.ID.Eq(user.ID)).UpdateSimple(u.OTPSecret.Null())
if err != nil {
api.ErrHandler(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"message": "ok",
})
}
func OTPStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": len(api.CurrentUser(c).OTPSecret) > 0,
})
}

View file

@ -17,3 +17,10 @@ func InitManageUserRouter(r *gin.RouterGroup) {
r.POST("user/:id", EditUser) r.POST("user/:id", EditUser)
r.DELETE("user/:id", DeleteUser) r.DELETE("user/:id", DeleteUser)
} }
func InitUserRouter(r *gin.RouterGroup) {
r.GET("/otp_status", OTPStatus)
r.GET("/otp_secret", GenerateTOTP)
r.POST("/otp_enroll", EnrollTOTP)
r.POST("/otp_reset", ResetOTP)
}

View file

@ -51,3 +51,11 @@ Interval = 1440
Node = http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true Node = http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true
Node = http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true Node = http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true
Node = http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true Node = http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true
[auth]
IPWhiteList =
BanThresholdMinutes = 10
MaxAttempts = 10
[crypto]
Secret = secret2

2
app/components.d.ts vendored
View file

@ -78,6 +78,8 @@ declare module 'vue' {
NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default'] NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default']
NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default'] NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default']
NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default'] NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default']
OTPInput: typeof import('./src/components/OTPInput.vue')['default']
OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default'] PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']

View file

@ -38,6 +38,7 @@
"vue3-ace-editor": "2.2.4", "vue3-ace-editor": "2.2.4",
"vue3-apexcharts": "1.4.4", "vue3-apexcharts": "1.4.4",
"vue3-gettext": "3.0.0-beta.4", "vue3-gettext": "3.0.0-beta.4",
"vue3-otp-input": "^0.5.21",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {

12
app/pnpm-lock.yaml generated
View file

@ -89,6 +89,9 @@ importers:
vue3-gettext: vue3-gettext:
specifier: 3.0.0-beta.4 specifier: 3.0.0-beta.4
version: 3.0.0-beta.4(@vue/compiler-sfc@3.4.33)(typescript@5.3.3)(vue@3.4.33(typescript@5.3.3)) version: 3.0.0-beta.4(@vue/compiler-sfc@3.4.33)(typescript@5.3.3)(vue@3.4.33(typescript@5.3.3))
vue3-otp-input:
specifier: ^0.5.21
version: 0.5.21(vue@3.4.33(typescript@5.3.3))
vuedraggable: vuedraggable:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(vue@3.4.33(typescript@5.3.3)) version: 4.1.0(vue@3.4.33(typescript@5.3.3))
@ -2969,6 +2972,11 @@ packages:
'@vue/compiler-sfc': '>=3.0.0' '@vue/compiler-sfc': '>=3.0.0'
vue: '>=3.0.0' vue: '>=3.0.0'
vue3-otp-input@0.5.21:
resolution: {integrity: sha512-dRxmGJqXlU+U5dCijNCyY7ird49+pyfeQspSTqvIp2Xs+VByIluNlTOjgHrftzSdeVZggtx+Ojb8uKiRLaob4Q==}
peerDependencies:
vue: ^3.0.*
vue@3.4.33: vue@3.4.33:
resolution: {integrity: sha512-VdMCWQOummbhctl4QFMcW6eNtXHsFyDlX60O/tsSQuCcuDOnJ1qPOhhVla65Niece7xq/P2zyZReIO5mP+LGTQ==} resolution: {integrity: sha512-VdMCWQOummbhctl4QFMcW6eNtXHsFyDlX60O/tsSQuCcuDOnJ1qPOhhVla65Niece7xq/P2zyZReIO5mP+LGTQ==}
peerDependencies: peerDependencies:
@ -6175,6 +6183,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
vue3-otp-input@0.5.21(vue@3.4.33(typescript@5.3.3)):
dependencies:
vue: 3.4.33(typescript@5.3.3)
vue@3.4.33(typescript@5.3.3): vue@3.4.33(typescript@5.3.3):
dependencies: dependencies:
'@vue/compiler-dom': 3.4.33 '@vue/compiler-dom': 3.4.33

View file

@ -6,15 +6,16 @@ const { login, logout } = useUserStore()
export interface AuthResponse { export interface AuthResponse {
message: string message: string
token: string token: string
code: number
} }
const auth = { const auth = {
async login(name: string, password: string) { async login(name: string, password: string, otp: string, recoveryCode: string): Promise<AuthResponse> {
return http.post('/login', { return http.post('/login', {
name, name,
password, password,
}).then((r: AuthResponse) => { otp,
login(r.token) recovery_code: recoveryCode,
}) })
}, },
async casdoor_login(code?: string, state?: string) { async casdoor_login(code?: string, state?: string) {

23
app/src/api/otp.ts Normal file
View file

@ -0,0 +1,23 @@
import http from '@/lib/http'
export interface OTPGenerateSecretResponse {
secret: string
qr_code: string
}
const otp = {
status(): Promise<{ status: boolean }> {
return http.get('/otp_status')
},
generate_secret(): Promise<OTPGenerateSecretResponse> {
return http.get('/otp_secret')
},
enroll_otp(secret: string, passcode: string): Promise<{ recovery_code: string }> {
return http.post('/otp_enroll', { secret, passcode })
},
reset(recovery_code: string) {
return http.post('/otp_reset', { recovery_code })
},
}
export default otp

View file

@ -0,0 +1,72 @@
<script setup lang="ts">
import VOtpInput from 'vue3-otp-input'
const emit = defineEmits(['onComplete'])
const data = defineModel<string>()
const refOtp = ref()
function onComplete(value: string) {
emit('onComplete', value)
}
function clearInput() {
refOtp.value?.clearInput()
}
defineExpose({
clearInput,
})
</script>
<template>
<VOtpInput
ref="refOtp"
v-model:value="data"
input-classes="otp-input"
:num-inputs="6"
input-type="number"
should-auto-focus
should-focus-order
@on-complete="onComplete"
/>
</template>
<style lang="less">
.dark {
.otp-input {
border: 1px solid rgba(255, 255, 255, 0.2) !important;
&:focus {
outline: none;
border: 2px solid #1677ff !important;
}
}
}
</style>
<style scoped lang="less">
:deep(.otp-input) {
width: 40px;
height: 40px;
padding: 5px;
margin: 0 10px;
font-size: 20px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.3);
text-align: center;
background-color: transparent;
&:focus {
outline: none;
border: 2px solid #1677ff;
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
</style>

View file

@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue' import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
import { Form, message } from 'ant-design-vue' import { Form, message } from 'ant-design-vue'
import OTPInput from '@/components/OTPInput/OTPInput.vue'
import { useUserStore } from '@/pinia' import { useUserStore } from '@/pinia'
import auth from '@/api/auth' 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 gettext from '@/gettext' import gettext, { $gettext } from '@/gettext'
const thisYear = new Date().getFullYear() const thisYear = new Date().getFullYear()
@ -20,6 +21,11 @@ install.get_lock().then(async (r: { lock: boolean }) => {
}) })
const loading = ref(false) const loading = ref(false)
const enabled2FA = ref(false)
const refOTP = ref()
const passcode = ref('')
const useRecoveryCode = ref(false)
const recoveryCode = ref('')
const modelRef = reactive({ const modelRef = reactive({
username: '', username: '',
@ -42,17 +48,25 @@ const rulesRef = reactive({
}) })
const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef) const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
const { login } = useUserStore()
const onSubmit = () => { const onSubmit = () => {
validate().then(async () => { validate().then(async () => {
loading.value = true loading.value = true
await auth.login(modelRef.username, modelRef.password).then(async () => { await auth.login(modelRef.username, modelRef.password, passcode.value, recoveryCode.value).then(async r => {
message.success($gettext('Login successful'), 1)
const next = (route.query?.next || '').toString() || '/' const next = (route.query?.next || '').toString() || '/'
switch (r.code) {
case 200:
message.success($gettext('Login successful'), 1)
login(r.token)
await router.push(next) await router.push(next)
break
case 199:
enabled2FA.value = true
break
}
}).catch(e => { }).catch(e => {
switch (e.code) { switch (e.code) {
case 4031: case 4031:
@ -64,6 +78,10 @@ const onSubmit = () => {
case 4033: case 4033:
message.error($gettext('User is banned')) message.error($gettext('User is banned'))
break break
case 4034:
refOTP.value?.clearInput()
message.error($gettext('Invalid 2FA or recovery code'))
break
default: default:
message.error($gettext(e.message ?? 'Server error')) message.error($gettext(e.message ?? 'Server error'))
break break
@ -117,6 +135,10 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
loading.value = false loading.value = false
} }
function clickUseRecoveryCode() {
passcode.value = ''
useRecoveryCode.value = true
}
</script> </script>
<template> <template>
@ -128,6 +150,7 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
<h1>Nginx UI</h1> <h1>Nginx UI</h1>
</div> </div>
<AForm id="components-form-demo-normal-login"> <AForm id="components-form-demo-normal-login">
<template v-if="!enabled2FA">
<AFormItem v-bind="validateInfos.username"> <AFormItem v-bind="validateInfos.username">
<AInput <AInput
v-model:value="modelRef.username" v-model:value="modelRef.username"
@ -148,7 +171,43 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
</template> </template>
</AInputPassword> </AInputPassword>
</AFormItem> </AFormItem>
<AFormItem> </template>
<div v-else>
<div v-if="!useRecoveryCode">
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
<OTPInput
ref="refOTP"
v-model="passcode"
class="justify-center mb-6"
@on-complete="onSubmit"
/>
<div class="flex justify-center">
<a @click="clickUseRecoveryCode">{{ $gettext('Use recovery code') }}</a>
</div>
</div>
<div
v-else
class="mt-2"
>
<p>{{ $gettext('Input the recovery code:') }}</p>
<AInputGroup compact>
<AInput
v-model:value="recoveryCode"
style="width: calc(100% - 92px)"
/>
<AButton
type="primary"
@click="onSubmit"
>
{{ $gettext('Recovery') }}
</AButton>
</AInputGroup>
</div>
</div>
<AFormItem v-if="!enabled2FA">
<AButton <AButton
type="primary" type="primary"
block block

View file

@ -7,6 +7,7 @@ import type { BannedIP } from '@/api/settings'
import setting from '@/api/settings' import setting from '@/api/settings'
import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer' import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
import type { Settings } from '@/views/preference/typedef' import type { Settings } from '@/views/preference/typedef'
import TOTP from '@/views/preference/components/TOTP.vue'
const data: Settings = inject('data') as Settings const data: Settings = inject('data') as Settings
@ -105,6 +106,8 @@ function removeBannedIP(ip: string) {
</template> </template>
</ATable> </ATable>
</div> </div>
<TOTP />
</div> </div>
</div> </div>
</template> </template>

View file

@ -0,0 +1,176 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { CheckCircleOutlined } from '@ant-design/icons-vue'
import otp from '@/api/otp'
import OTPInput from '@/components/OTPInput/OTPInput.vue'
const status = ref(false)
const enrolling = ref(false)
const resetting = ref(false)
const qrCode = ref('')
const secret = ref('')
const passcode = ref('')
const interval = ref()
const refOtp = ref()
const recoveryCode = ref('')
const inputRecoveryCode = ref('')
function clickEnable2FA() {
enrolling.value = true
generateSecret()
interval.value = setInterval(() => {
if (enrolling.value)
generateSecret()
else
clearGenerateSecretInterval()
}, 30 * 1000)
}
function clearGenerateSecretInterval() {
if (interval.value) {
clearInterval(interval.value)
interval.value = undefined
}
}
function generateSecret() {
otp.generate_secret().then(r => {
secret.value = r.secret
qrCode.value = r.qr_code
refOtp.value?.clearInput()
}).catch((e: { message?: string }) => {
message.error(e.message ?? $gettext('Server error'))
})
}
function enroll(code: string) {
otp.enroll_otp(secret.value, code).then(r => {
enrolling.value = false
recoveryCode.value = r.recovery_code
clearGenerateSecretInterval()
get2FAStatus()
message.success($gettext('Enable 2FA successfully'))
}).catch((e: { message?: string }) => {
refOtp.value?.clearInput()
message.error(e.message ?? $gettext('Server error'))
})
}
function get2FAStatus() {
otp.status().then(r => {
status.value = r.status
})
}
get2FAStatus()
onUnmounted(clearGenerateSecretInterval)
function clickReset2FA() {
resetting.value = true
inputRecoveryCode.value = ''
}
function reset2FA() {
otp.reset(inputRecoveryCode.value).then(() => {
resetting.value = false
recoveryCode.value = ''
get2FAStatus()
clickEnable2FA()
}).catch((e: { message?: string }) => {
message.error($gettext(e.message ?? 'Server error'))
})
}
</script>
<template>
<div>
<h3>{{ $gettext('2FA Settings') }}</h3>
<p>{{ $gettext('TOTP is a two-factor authentication method that uses a time-based one-time password algorithm.') }}</p>
<p>{{ $gettext('To enable it, you need to install the Google or Microsoft Authenticator app on your mobile phone.') }}</p>
<p>{{ $gettext('Scan the QR code with your mobile phone to add the account to the app.') }}</p>
<p v-if="!status">
{{ $gettext('Current account is not enabled 2FA.') }}
</p>
<div v-else>
<p><CheckCircleOutlined class="mr-2 text-green-600" />{{ $gettext('Current account is enabled 2FA.') }}</p>
</div>
<AAlert
v-if="recoveryCode"
:message="$gettext('Recovery Code')"
class="mb-4"
type="info"
show-icon
>
<template #description>
<div>
<p>{{ $gettext('If you lose your mobile phone, you can use the recovery code to reset your 2FA.') }}</p>
<p>{{ $gettext('The recovery code is only displayed once, please save it in a safe place.') }}</p>
<p>{{ $gettext('Recovery Code:') }}</p>
<span class="ml-2">{{ recoveryCode }}</span>
</div>
</template>
</AAlert>
<AButton
v-if="!status && !enrolling"
type="primary"
ghost
@click="clickEnable2FA"
>
{{ $gettext('Enable 2FA') }}
</AButton>
<AButton
v-if="status && !resetting"
type="primary"
ghost
@click="clickReset2FA"
>
{{ $gettext('Reset 2FA') }}
</AButton>
<template v-if="enrolling">
<div class="w-64 h-64 mt-4 mb-2">
<img
v-if="qrCode"
class="w-full"
:src="qrCode"
alt="qr code"
>
</div>
<div>
<p>{{ $gettext('Input the code from the app:') }}</p>
<OTPInput
ref="refOtp"
v-model="passcode"
@on-complete="enroll"
/>
</div>
</template>
<div
v-if="resetting"
class="mt-2"
>
<p>{{ $gettext('Input the recovery code:') }}</p>
<AInputGroup compact>
<AInput
v-model:value="inputRecoveryCode"
style="width: calc(100% - 92px)"
/>
<AButton
type="primary"
@click="reset2FA"
>
{{ $gettext('Recovery') }}
</AButton>
</AInputGroup>
</div>
</div>
</template>
<style scoped lang="less">
</style>

29
go.mod
View file

@ -8,6 +8,7 @@ require (
github.com/caarlos0/env/v11 v11.1.0 github.com/caarlos0/env/v11 v11.1.0
github.com/casdoor/casdoor-go-sdk v0.47.0 github.com/casdoor/casdoor-go-sdk v0.47.0
github.com/creack/pty v1.1.21 github.com/creack/pty v1.1.21
github.com/dgraph-io/ristretto v0.1.1
github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/fatih/color v1.17.0 github.com/fatih/color v1.17.0
@ -44,7 +45,7 @@ require (
require ( require (
aead.dev/minisign v0.3.0 // indirect aead.dev/minisign v0.3.0 // indirect
cloud.google.com/go/auth v0.7.1 // indirect cloud.google.com/go/auth v0.7.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
cloud.google.com/go/compute/metadata v0.5.0 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
@ -73,7 +74,7 @@ require (
github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect github.com/StackExchange/wmi v1.2.1 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.62.793 // indirect github.com/aliyun/alibaba-cloud-sdk-go v1.62.795 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
@ -97,6 +98,8 @@ require (
github.com/bytedance/sonic v1.11.9 // indirect github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/civo/civogo v0.3.73 // indirect github.com/civo/civogo v0.3.73 // indirect
github.com/cloudflare/cloudflare-go v0.100.0 // indirect github.com/cloudflare/cloudflare-go v0.100.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
@ -125,20 +128,21 @@ require (
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect github.com/goccy/go-json v0.10.3 // indirect
github.com/gofrs/flock v0.12.0 // indirect github.com/gofrs/flock v0.12.1 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/glog v1.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 // indirect github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect github.com/google/gofuzz v1.2.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.5 // indirect github.com/googleapis/gax-go/v2 v2.13.0 // indirect
github.com/gophercloud/gophercloud v1.13.0 // indirect github.com/gophercloud/gophercloud v1.13.0 // indirect
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
@ -228,8 +232,8 @@ require (
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/tdewolff/minify/v2 v2.20.37 // indirect github.com/tdewolff/minify/v2 v2.20.37 // indirect
github.com/tdewolff/parse/v2 v2.7.15 // indirect github.com/tdewolff/parse/v2 v2.7.15 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968 // indirect github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968 // indirect github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect github.com/tklauser/numcpus v0.8.0 // indirect
github.com/transip/gotransip/v6 v6.25.0 // indirect github.com/transip/gotransip/v6 v6.25.0 // indirect
@ -242,7 +246,7 @@ require (
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/vultr/govultr/v2 v2.17.2 // indirect github.com/vultr/govultr/v2 v2.17.2 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c // indirect github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa // indirect
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 // indirect github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 // indirect
github.com/yosssi/ace v0.0.5 // indirect github.com/yosssi/ace v0.0.5 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
@ -264,10 +268,10 @@ require (
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.23.0 // indirect golang.org/x/tools v0.23.0 // indirect
google.golang.org/api v0.188.0 // indirect google.golang.org/api v0.189.0 // indirect
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/grpc v1.65.0 // indirect google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect
@ -278,6 +282,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/datatypes v1.2.1 // indirect gorm.io/datatypes v1.2.1 // indirect
gorm.io/driver/mysql v1.5.7 // indirect gorm.io/driver/mysql v1.5.7 // indirect
gorm.io/driver/postgres v1.5.6 // indirect
gorm.io/hints v1.1.2 // indirect gorm.io/hints v1.1.2 // indirect
k8s.io/api v0.30.3 // indirect k8s.io/api v0.30.3 // indirect
k8s.io/apimachinery v0.30.3 // indirect k8s.io/apimachinery v0.30.3 // indirect

52
go.sum
View file

@ -101,8 +101,8 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo
cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo=
cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E=
cloud.google.com/go/auth v0.7.1 h1:Iv1bbpzJ2OIg16m94XI9/tlzZZl3cdeR3nGVGj78N7s= cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
cloud.google.com/go/auth v0.7.1/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs= cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI= cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
@ -672,6 +672,7 @@ github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24= github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24=
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks= github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks=
@ -694,8 +695,10 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/aliyun/alibaba-cloud-sdk-go v1.62.793 h1:7FmdfF5fZMxM8Y0YtwrnMLkwud+egvoB5X5xczqISNQ= github.com/aliyun/alibaba-cloud-sdk-go v1.62.794 h1:M6YtlJdCobRVlJaILK4Eia5aMtDSpeQtxFRl4hSi+DU=
github.com/aliyun/alibaba-cloud-sdk-go v1.62.793/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= github.com/aliyun/alibaba-cloud-sdk-go v1.62.794/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/aliyun/alibaba-cloud-sdk-go v1.62.795 h1:DjIaInK6Ru+fPnOX0Ef4ux5tkp/dCPI3pAZEijEvlvo=
github.com/aliyun/alibaba-cloud-sdk-go v1.62.795/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
@ -772,9 +775,12 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@ -821,8 +827,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA= github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM= github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de h1:t0UHb5vdojIDUqktM6+xJAfScFBsVpXZmqC9dsgJmeA=
github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
@ -949,6 +961,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gofrs/flock v0.12.0 h1:xHW8t8GPAiGtqz7KxiSqfOEXwpOaqhpYZrTE2MQBgXY= github.com/gofrs/flock v0.12.0 h1:xHW8t8GPAiGtqz7KxiSqfOEXwpOaqhpYZrTE2MQBgXY=
github.com/gofrs/flock v0.12.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc= github.com/gofrs/flock v0.12.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@ -972,6 +986,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ=
github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY=
github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -1065,6 +1081,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -1092,6 +1110,8 @@ github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
@ -1561,6 +1581,7 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
@ -1614,14 +1635,14 @@ github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.967 h1:ui73H/2pKk2aDCxaBCLAeMB3JlNgdCkn0nx1x0pqvf0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.967/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968 h1:SdgunZB3WU2vNn3H9dJQ1Z2cQK61vN79zCfnHk3Cu3Y= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968 h1:SdgunZB3WU2vNn3H9dJQ1Z2cQK61vN79zCfnHk3Cu3Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.967 h1:4w33xHFgyrlFZYoGkPQ3uhld8tqoezpObfmCBrdlFBY= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969 h1:rJlV77WbjuJ5uGBi+THOk09Cfp8Kskz9HgExq0enTmY=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.967/go.mod h1:T0RlPIT2imBeCxLkWfzoiEVP1r5WwzC6becSq7wvSgU= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968 h1:h7voJALWRkUX6w7obk9CWHppnJwZuQlreQJVDldVRxY= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968 h1:h7voJALWRkUX6w7obk9CWHppnJwZuQlreQJVDldVRxY=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968/go.mod h1:3cwvPwyqYaYkzAsR4vbrE6mb3Ju9uY7Pj+wHYSVd3aw= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968/go.mod h1:3cwvPwyqYaYkzAsR4vbrE6mb3Ju9uY7Pj+wHYSVd3aw=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969 h1:W2DHKBCSLjpHoQjqgAkyUu7lV8deIW+FBZS95iNRf1A=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969/go.mod h1:jIxuhjYsAyTTErdwvaX1ay+FHH021fmjdlsbnkaOgfs=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
@ -1664,6 +1685,8 @@ github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmv
github.com/yandex-cloud/go-genproto v0.0.0-20240701142715-6a03f33f8ec8/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE= github.com/yandex-cloud/go-genproto v0.0.0-20240701142715-6a03f33f8ec8/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c h1:GzMfpQ/oAP93MOQb5/B+3daDzdcLRRqetZ8radtnJJ4= github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c h1:GzMfpQ/oAP93MOQb5/B+3daDzdcLRRqetZ8radtnJJ4=
github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE= github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa h1:MFb4Q81BMqa0vL64v/i3mel9C+XQkVnwgWqWbmqv10U=
github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 h1:5LGYQ/0h1uUo3HH8MsG6R40gvSVPj/7r4D1sKVMa370= github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 h1:5LGYQ/0h1uUo3HH8MsG6R40gvSVPj/7r4D1sKVMa370=
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169/go.mod h1:kRqpmRyPs8rzXuYEJe57AH546a3VcSjEIzdFa1V66hY= github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169/go.mod h1:kRqpmRyPs8rzXuYEJe57AH546a3VcSjEIzdFa1V66hY=
github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA= github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA=
@ -2051,6 +2074,7 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -2258,6 +2282,8 @@ google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw= google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw=
google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag= google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag=
google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=
google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -2400,10 +2426,16 @@ google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOl
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc= google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc=
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY= google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY= google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0= google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade h1:WxZOF2yayUHpHSbUE6NMzumUzBxYc3YGwo0YHnbzsJY=
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A= google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@ -2515,8 +2547,8 @@ gorm.io/datatypes v1.2.1/go.mod h1:hYK6OTb/1x+m96PgoZZq10UXJ6RvEBb9kRDQ2yyhzGs=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I= gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=

View file

@ -4,6 +4,7 @@ import (
"github.com/0xJacky/Nginx-UI/internal/cert" "github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/internal/logger" "github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/0xJacky/Nginx-UI/internal/logrotate" "github.com/0xJacky/Nginx-UI/internal/logrotate"
"github.com/0xJacky/Nginx-UI/query"
"github.com/0xJacky/Nginx-UI/settings" "github.com/0xJacky/Nginx-UI/settings"
"github.com/go-co-op/gocron" "github.com/go-co-op/gocron"
"time" "time"
@ -25,6 +26,7 @@ func InitCronJobs() {
} }
startLogrotate() startLogrotate()
cleanExpiredAuthToken()
s.StartAsync() s.StartAsync()
} }
@ -43,10 +45,20 @@ func startLogrotate() {
return return
} }
var err error var err error
logrotateJob, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec) logrotateJob, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec)
if err != nil { if err != nil {
logger.Fatalf("LogRotate Job: %v, Err: %v\n", logrotateJob, err) logger.Fatalf("LogRotate Job: %v, Err: %v\n", logrotateJob, err)
} }
} }
func cleanExpiredAuthToken() {
job, err := s.Every(5).Minute().SingletonMode().Do(func() {
logger.Info("clean expired auth tokens")
q := query.AuthToken
_, _ = q.Where(q.ExpiredAt.Lt(time.Now().Unix())).Delete()
})
if err != nil {
logger.Fatalf("CleanExpiredAuthToken Job: %v, Err: %v\n", job, err)
}
}

59
internal/crypto/aes.go Normal file
View file

@ -0,0 +1,59 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/pkg/errors"
"io"
)
// AesEncrypt encrypts text and given key with AES.
func AesEncrypt(text []byte) ([]byte, error) {
if len(text) == 0 {
return nil, errors.New("AesEncrypt text is empty")
}
block, err := aes.NewCipher(settings.CryptoSettings.GetSecretMd5())
if err != nil {
return nil, fmt.Errorf("AesEncrypt invalid key: %v", err)
}
b := base64.StdEncoding.EncodeToString(text)
ciphertext := make([]byte, aes.BlockSize+len(b))
iv := ciphertext[:aes.BlockSize]
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
return nil, fmt.Errorf("AesEncrypt unable to read IV: %w", err)
}
cfb := cipher.NewCFBEncrypter(block, iv)
cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(b))
return ciphertext, nil
}
// AesDecrypt decrypts text and given key with AES.
func AesDecrypt(text []byte) ([]byte, error) {
block, err := aes.NewCipher(settings.CryptoSettings.GetSecretMd5())
if err != nil {
return nil, err
}
if len(text) < aes.BlockSize {
return nil, errors.New("AesDecrypt ciphertext too short")
}
iv := text[:aes.BlockSize]
text = text[aes.BlockSize:]
cfb := cipher.NewCFBDecrypter(block, iv)
cfb.XORKeyStream(text, text)
data, err := base64.StdEncoding.DecodeString(string(text))
if err != nil {
return nil, fmt.Errorf("AesDecrypt invalid decrypted base64 string: %w", err)
}
return data, nil
}

View file

@ -0,0 +1,76 @@
package crypto
import (
"github.com/0xJacky/Nginx-UI/settings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func EncryptDecryptRoundTrip(text string) bool {
encrypted, err := AesEncrypt([]byte(text))
if err != nil {
return false
}
decrypted, err := AesDecrypt(encrypted)
if err != nil {
return false
}
return text == string(decrypted)
}
func EncryptsNonEmptyStringWithoutError(text string) bool {
_, err := AesEncrypt([]byte(text))
return err == nil
}
func DecryptsToOriginalTextAfterEncryption(text string) bool {
encrypted, _ := AesEncrypt([]byte(text))
decrypted, err := AesDecrypt(encrypted)
if err != nil {
return false
}
return text == string(decrypted)
}
func FailsToDecryptWithModifiedCiphertext(text string) bool {
encrypted, _ := AesEncrypt([]byte(text))
// Modify the ciphertext
encrypted[0] ^= 0xff
_, err := AesDecrypt(encrypted)
return err != nil
}
func FailsToDecryptShortCiphertext() bool {
_, err := AesDecrypt([]byte("short"))
return err != nil
}
func TestAesEncryptionDecryption(t *testing.T) {
settings.CryptoSettings.Secret = "test"
assert.True(t, EncryptDecryptRoundTrip("Hello, world!"), "should encrypt and decrypt to the original text")
assert.True(t, EncryptsNonEmptyStringWithoutError("Test String"), "should encrypt a non-empty string without error")
assert.True(t, DecryptsToOriginalTextAfterEncryption("Another Test String"), "should decrypt to the original text after encryption")
assert.True(t, FailsToDecryptWithModifiedCiphertext("Sensitive Data"), "should fail to decrypt with modified ciphertext")
assert.True(t, FailsToDecryptShortCiphertext(), "should fail to decrypt short ciphertext")
}
func TestAesEncrypt_WithEmptyString_ReturnsError(t *testing.T) {
settings.CryptoSettings.Secret = "test"
_, err := AesEncrypt([]byte(""))
require.Error(t, err, "encrypting an empty string should return an error")
}
func TestAesDecrypt_WithInvalidBase64_ReturnsError(t *testing.T) {
settings.CryptoSettings.Secret = "test"
// Assuming the function is modified to handle this case explicitly
encrypted, _ := AesEncrypt([]byte("valid text"))
// Invalidate the base64 encoding
encrypted[len(encrypted)-1] = '!'
_, err := AesDecrypt(encrypted)
require.Error(t, err, "decrypting an invalid base64 string should return an error")
}

View file

@ -1,20 +1,20 @@
package kernal package kernal
import ( import (
"crypto/rand"
"encoding/hex"
"github.com/0xJacky/Nginx-UI/internal/analytic" "github.com/0xJacky/Nginx-UI/internal/analytic"
"github.com/0xJacky/Nginx-UI/internal/cert" "github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/internal/cluster" "github.com/0xJacky/Nginx-UI/internal/cluster"
"github.com/0xJacky/Nginx-UI/internal/cron"
"github.com/0xJacky/Nginx-UI/internal/logger" "github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/0xJacky/Nginx-UI/internal/logrotate"
"github.com/0xJacky/Nginx-UI/internal/validation" "github.com/0xJacky/Nginx-UI/internal/validation"
"github.com/0xJacky/Nginx-UI/model" "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/go-co-op/gocron"
"github.com/google/uuid" "github.com/google/uuid"
"mime" "mime"
"runtime" "runtime"
"time"
) )
func Boot() { func Boot() {
@ -24,6 +24,7 @@ func Boot() {
InitJsExtensionType, InitJsExtensionType,
InitDatabase, InitDatabase,
InitNodeSecret, InitNodeSecret,
InitCryptoSecret,
validation.Init, validation.Init,
} }
@ -44,7 +45,7 @@ func InitAfterDatabase() {
syncs := []func(){ syncs := []func(){
registerPredefinedUser, registerPredefinedUser,
cert.InitRegister, cert.InitRegister,
InitCronJobs, cron.InitCronJobs,
cluster.RegisterPredefinedNodes, cluster.RegisterPredefinedNodes,
analytic.RetrieveNodesStatus, analytic.RetrieveNodesStatus,
} }
@ -83,31 +84,34 @@ func InitNodeSecret() {
err := settings.Save() err := settings.Save()
if err != nil { if err != nil {
logger.Error("Error save settings") logger.Error("Error save settings", err)
} }
logger.Warn("Generated NodeSecret: ", settings.ServerSettings.NodeSecret) logger.Warn("Generated NodeSecret: ", settings.ServerSettings.NodeSecret)
} }
} }
func InitCryptoSecret() {
if "" == settings.CryptoSettings.Secret {
logger.Warn("Secret is empty, generating...")
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
logger.Error("Generate Secret failed: ", err)
return
}
settings.CryptoSettings.Secret = hex.EncodeToString(key)
err := settings.Save()
if err != nil {
logger.Error("Error save settings", err)
}
logger.Warn("Secret Generated")
}
}
func InitJsExtensionType() { func InitJsExtensionType() {
// Hack: fix wrong Content Type of .js file on some OS platforms // Hack: fix wrong Content Type of .js file on some OS platforms
// See https://github.com/golang/go/issues/32350 // See https://github.com/golang/go/issues/32350
_ = mime.AddExtensionType(".js", "text/javascript; charset=utf-8") _ = mime.AddExtensionType(".js", "text/javascript; charset=utf-8")
} }
func InitCronJobs() {
s := gocron.NewScheduler(time.UTC)
job, err := s.Every(6).Hours().SingletonMode().Do(cert.AutoCert)
if err != nil {
logger.Fatalf("AutoCert Job: %v, Err: %v\n", job, err)
}
job, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec)
if err != nil {
logger.Fatalf("LogRotate Job: %v, Err: %v\n", job, err)
}
s.StartAsync()
}

39
internal/user/otp.go Normal file
View file

@ -0,0 +1,39 @@
package user
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"github.com/0xJacky/Nginx-UI/internal/crypto"
"github.com/0xJacky/Nginx-UI/model"
"github.com/pkg/errors"
"github.com/pquerna/otp/totp"
)
var (
ErrOTPCode = errors.New("invalid otp code")
ErrRecoveryCode = errors.New("invalid recovery code")
)
func VerifyOTP(user *model.Auth, otp, recoveryCode string) (err error) {
if otp != "" {
decrypted, err := crypto.AesDecrypt(user.OTPSecret)
if err != nil {
return err
}
if ok := totp.Validate(otp, string(decrypted)); !ok {
return ErrOTPCode
}
} else {
recoverCode, err := hex.DecodeString(recoveryCode)
if err != nil {
return err
}
k := sha1.Sum(user.OTPSecret)
if !bytes.Equal(k[:], recoverCode) {
return ErrRecoveryCode
}
}
return
}

View file

@ -1,53 +1,84 @@
package user package user
import ( import (
"github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/0xJacky/Nginx-UI/settings" "github.com/0xJacky/Nginx-UI/settings"
"github.com/dgrijalva/jwt-go" "github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
"strings"
"time" "time"
) )
const ExpiredTime = 24 * time.Hour
type JWTClaims struct { type JWTClaims struct {
Name string `json:"name"` Name string `json:"name"`
UserID int `json:"user_id"`
jwt.StandardClaims jwt.StandardClaims
} }
func GetUser(name string) (user model.Auth, err error) { func BuildCacheTokenKey(token string) string {
var sb strings.Builder
sb.WriteString("token:")
sb.WriteString(token)
return sb.String()
}
func GetUser(name string) (user *model.Auth, err error) {
db := model.UseDB() db := model.UseDB()
err = db.Where("name", name).First(&user).Error user = &model.Auth{}
err = db.Where("name", name).First(user).Error
if err != nil { if err != nil {
return return
} }
return return
} }
func DeleteToken(token string) error { func DeleteToken(token string) {
db := model.UseDB() q := query.AuthToken
return db.Where("token", token).Delete(&model.AuthToken{}).Error _, _ = q.Where(q.Token.Eq(token)).Delete()
} }
func CheckToken(token string) int64 { func GetTokenUser(token string) (*model.Auth, bool) {
db := model.UseDB() q := query.AuthToken
return db.Where("token", token).Find(&model.AuthToken{}).RowsAffected authToken, err := q.Where(q.Token.Eq(token)).First()
if err != nil {
return nil, false
}
if authToken.ExpiredAt < time.Now().Unix() {
DeleteToken(token)
return nil, false
}
u := query.Auth
user, err := u.FirstByID(authToken.UserID)
return user, err == nil
} }
func GenerateJWT(name string) (string, error) { func GenerateJWT(user *model.Auth) (string, error) {
claims := JWTClaims{ claims := JWTClaims{
Name: name, Name: user.Name,
UserID: user.ID,
StandardClaims: jwt.StandardClaims{ StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), ExpiresAt: time.Now().Add(ExpiredTime).Unix(),
}, },
} }
unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := unsignedToken.SignedString([]byte(settings.ServerSettings.JwtSecret)) signedToken, err := unsignedToken.SignedString([]byte(settings.ServerSettings.JwtSecret))
if err != nil { if err != nil {
return "", err return "", err
} }
db := model.UseDB() q := query.AuthToken
err = db.Create(&model.AuthToken{ err = q.Create(&model.AuthToken{
UserID: user.ID,
Token: signedToken, Token: signedToken,
}).Error ExpiredAt: time.Now().Add(ExpiredTime).Unix(),
})
if err != nil { if err != nil {
return "", err return "", err
@ -55,3 +86,50 @@ func GenerateJWT(name string) (string, error) {
return signedToken, err return signedToken, err
} }
func ValidateJWT(token string) (claims *JWTClaims, err error) {
if token == "" {
err = errors.New("token is empty")
return
}
unsignedToken, err := jwt.ParseWithClaims(
token,
&JWTClaims{},
func(token *jwt.Token) (interface{}, error) {
return []byte(settings.ServerSettings.JwtSecret), nil
},
)
if err != nil {
err = errors.New("parse with claims error")
return
}
claims, ok := unsignedToken.Claims.(*JWTClaims)
if !ok {
err = errors.New("convert to jwt claims error")
return
}
if claims.ExpiresAt < time.Now().UTC().Unix() {
err = errors.New("jwt is expired")
}
return
}
func CurrentUser(token string) (u *model.Auth, err error) {
// validate token
var claims *JWTClaims
claims, err = ValidateJWT(token)
if err != nil {
return
}
// get user by id
user := query.Auth
u, err = user.FirstByID(claims.UserID)
if err != nil {
return
}
logger.Info("[Current User]", u.Name)
return
}

View file

@ -6,8 +6,11 @@ type Auth struct {
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"`
} }
type AuthToken struct { type AuthToken struct {
UserID int `json:"user_id"`
Token string `json:"token"` Token string `json:"token"`
ExpiredAt int64 `json:"expired_at" gorm:"default:0"`
} }

View file

@ -28,7 +28,9 @@ func newAuthToken(db *gorm.DB, opts ...gen.DOOption) authToken {
tableName := _authToken.authTokenDo.TableName() tableName := _authToken.authTokenDo.TableName()
_authToken.ALL = field.NewAsterisk(tableName) _authToken.ALL = field.NewAsterisk(tableName)
_authToken.UserID = field.NewInt(tableName, "user_id")
_authToken.Token = field.NewString(tableName, "token") _authToken.Token = field.NewString(tableName, "token")
_authToken.ExpiredAt = field.NewInt64(tableName, "expired_at")
_authToken.fillFieldMap() _authToken.fillFieldMap()
@ -39,7 +41,9 @@ type authToken struct {
authTokenDo authTokenDo
ALL field.Asterisk ALL field.Asterisk
UserID field.Int
Token field.String Token field.String
ExpiredAt field.Int64
fieldMap map[string]field.Expr fieldMap map[string]field.Expr
} }
@ -56,7 +60,9 @@ func (a authToken) As(alias string) *authToken {
func (a *authToken) updateTableName(table string) *authToken { func (a *authToken) updateTableName(table string) *authToken {
a.ALL = field.NewAsterisk(table) a.ALL = field.NewAsterisk(table)
a.UserID = field.NewInt(table, "user_id")
a.Token = field.NewString(table, "token") a.Token = field.NewString(table, "token")
a.ExpiredAt = field.NewInt64(table, "expired_at")
a.fillFieldMap() a.fillFieldMap()
@ -73,8 +79,10 @@ func (a *authToken) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
} }
func (a *authToken) fillFieldMap() { func (a *authToken) fillFieldMap() {
a.fieldMap = make(map[string]field.Expr, 1) a.fieldMap = make(map[string]field.Expr, 3)
a.fieldMap["user_id"] = a.UserID
a.fieldMap["token"] = a.Token a.fieldMap["token"] = a.Token
a.fieldMap["expired_at"] = a.ExpiredAt
} }
func (a authToken) clone(db *gorm.DB) authToken { func (a authToken) clone(db *gorm.DB) authToken {

View file

@ -35,6 +35,7 @@ func newAuth(db *gorm.DB, opts ...gen.DOOption) auth {
_auth.Name = field.NewString(tableName, "name") _auth.Name = field.NewString(tableName, "name")
_auth.Password = field.NewString(tableName, "password") _auth.Password = field.NewString(tableName, "password")
_auth.Status = field.NewBool(tableName, "status") _auth.Status = field.NewBool(tableName, "status")
_auth.OTPSecret = field.NewBytes(tableName, "otp_secret")
_auth.fillFieldMap() _auth.fillFieldMap()
@ -52,6 +53,7 @@ type auth struct {
Name field.String Name field.String
Password field.String Password field.String
Status field.Bool Status field.Bool
OTPSecret field.Bytes
fieldMap map[string]field.Expr fieldMap map[string]field.Expr
} }
@ -75,6 +77,7 @@ func (a *auth) updateTableName(table string) *auth {
a.Name = field.NewString(table, "name") a.Name = field.NewString(table, "name")
a.Password = field.NewString(table, "password") a.Password = field.NewString(table, "password")
a.Status = field.NewBool(table, "status") a.Status = field.NewBool(table, "status")
a.OTPSecret = field.NewBytes(table, "otp_secret")
a.fillFieldMap() a.fillFieldMap()
@ -91,7 +94,7 @@ func (a *auth) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
} }
func (a *auth) fillFieldMap() { func (a *auth) fillFieldMap() {
a.fieldMap = make(map[string]field.Expr, 7) a.fieldMap = make(map[string]field.Expr, 8)
a.fieldMap["id"] = a.ID a.fieldMap["id"] = a.ID
a.fieldMap["created_at"] = a.CreatedAt a.fieldMap["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt a.fieldMap["updated_at"] = a.UpdatedAt
@ -99,6 +102,7 @@ func (a *auth) fillFieldMap() {
a.fieldMap["name"] = a.Name a.fieldMap["name"] = a.Name
a.fieldMap["password"] = a.Password a.fieldMap["password"] = a.Password
a.fieldMap["status"] = a.Status a.fieldMap["status"] = a.Status
a.fieldMap["otp_secret"] = a.OTPSecret
} }
func (a auth) clone(db *gorm.DB) auth { func (a auth) clone(db *gorm.DB) auth {

View file

@ -58,11 +58,14 @@ func authRequired() gin.HandlerFunc {
} }
} }
if user.CheckToken(token) < 1 { u, ok := user.GetTokenUser(token)
if !ok {
abortWithAuthFailure() abortWithAuthFailure()
return return
} }
c.Set("user", u)
if nodeID := c.GetHeader("X-Node-ID"); nodeID != "" { if nodeID := c.GetHeader("X-Node-ID"); nodeID != "" {
c.Set("ProxyNodeID", nodeID) c.Set("ProxyNodeID", nodeID)
} }

View file

@ -46,6 +46,7 @@ func InitRouter() *gin.Engine {
// Authorization required not websocket request // Authorization required not websocket request
g := root.Group("/", authRequired(), proxy()) g := root.Group("/", authRequired(), proxy())
{ {
user.InitUserRouter(g)
analytic.InitRouter(g) analytic.InitRouter(g)
user.InitManageUserRouter(g) user.InitManageUserRouter(g)
nginx.InitRouter(g) nginx.InitRouter(g)

View file

@ -11,5 +11,6 @@ func TestCluster(t *testing.T) {
assert.Equal(t, []string{ assert.Equal(t, []string{
"http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true", "http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true",
"http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true", "http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true",
"http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true",
}, ClusterSettings.Node) }, ClusterSettings.Node)
} }

14
settings/crypto.go Normal file
View file

@ -0,0 +1,14 @@
package settings
import "crypto/md5"
type Crypto struct {
Secret string
}
var CryptoSettings = Crypto{}
func (c *Crypto) GetSecretMd5() []byte {
k := md5.Sum([]byte(c.Secret))
return k[:]
}

48
settings/crypto_test.go Normal file
View file

@ -0,0 +1,48 @@
package settings
import (
"crypto/md5"
"encoding/hex"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetSecretMd5_WithNonEmptySecret_ReturnsExpectedMd5Hash(t *testing.T) {
// Setup
CryptoSettings.Secret = "testSecret"
expectedMd5 := md5.Sum([]byte("testSecret"))
expectedMd5String := hex.EncodeToString(expectedMd5[:])
// Execute
resultMd5 := CryptoSettings.GetSecretMd5()
resultMd5String := hex.EncodeToString(resultMd5[:])
// Verify
assert.Equal(t, expectedMd5String, resultMd5String, "MD5 hash should match for non-empty secret")
}
func TestGetSecretMd5_WithEmptySecret_ReturnsMd5OfEmptyString(t *testing.T) {
// Setup
CryptoSettings.Secret = ""
expectedMd5 := md5.Sum([]byte(""))
expectedMd5String := hex.EncodeToString(expectedMd5[:])
// Execute
resultMd5 := CryptoSettings.GetSecretMd5()
resultMd5String := hex.EncodeToString(resultMd5[:])
// Verify
assert.Equal(t, expectedMd5String, resultMd5String, "MD5 hash of an empty string should be returned for empty secret")
}
func TestGetSecretMd5_WithDifferentSecrets_ReturnsDifferentMd5Hashes(t *testing.T) {
// Setup
CryptoSettings.Secret = "secret1"
firstMd5 := CryptoSettings.GetSecretMd5()
CryptoSettings.Secret = "secret2"
secondMd5 := CryptoSettings.GetSecretMd5()
// Verify
assert.NotEqual(t, firstMd5, secondMd5, "Different secrets should produce different MD5 hashes")
}

View file

@ -28,6 +28,7 @@ var sections = map[string]interface{}{
"logrotate": &LogrotateSettings, "logrotate": &LogrotateSettings,
"cluster": &ClusterSettings, "cluster": &ClusterSettings,
"auth": &AuthSettings, "auth": &AuthSettings,
"crypto": &CryptoSettings,
} }
func init() { func init() {
@ -64,6 +65,7 @@ func Setup() {
parseEnv(&CasdoorSettings, "CASDOOR_") parseEnv(&CasdoorSettings, "CASDOOR_")
parseEnv(&LogrotateSettings, "LOGROTATE_") parseEnv(&LogrotateSettings, "LOGROTATE_")
parseEnv(&AuthSettings, "AUTH_") parseEnv(&AuthSettings, "AUTH_")
parseEnv(&CryptoSettings, "CRYPTO_")
// if in official docker, set the restart cmd of nginx to "nginx -s stop", // if in official docker, set the restart cmd of nginx to "nginx -s stop",
// then the supervisor of s6-overlay will start the nginx again. // then the supervisor of s6-overlay will start the nginx again.