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">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/docs" vcs="Git" />
</component>
</project>

View file

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

View file

@ -18,12 +18,17 @@ var mutex = &sync.Mutex{}
type LoginUser struct {
Name string `json:"name" binding:"required,max=255"`
Password string `json:"password" binding:"required,max=255"`
OTP string `json:"otp"`
RecoveryCode string `json:"recovery_code"`
}
const (
ErrPasswordIncorrect = 4031
ErrMaxAttempts = 4291
ErrUserBanned = 4033
Enabled2FA = 199
Error2FACode = 4034
LoginSuccess = 200
)
type LoginResponse struct {
@ -80,11 +85,32 @@ func Login(c *gin.Context) {
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
_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
logger.Info("[User Login]", u.Name)
token, err := user.GenerateJWT(u.Name)
token, err := user.GenerateJWT(u)
if err != nil {
c.JSON(http.StatusInternalServerError, LoginResponse{
Message: err.Error(),
@ -93,6 +119,7 @@ func Login(c *gin.Context) {
}
c.JSON(http.StatusOK, LoginResponse{
Code: LoginSuccess,
Message: "ok",
Token: token,
})
@ -101,13 +128,7 @@ func Login(c *gin.Context) {
func Logout(c *gin.Context) {
token := c.GetHeader("Authorization")
if token != "" {
err := user.DeleteToken(token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"message": err.Error(),
})
return
}
user.DeleteToken(token)
}
c.JSON(http.StatusNoContent, nil)
}

View file

@ -65,7 +65,7 @@ func CasdoorCallback(c *gin.Context) {
return
}
userToken, err := user.GenerateJWT(u.Name)
userToken, err := user.GenerateJWT(u)
if err != nil {
api.ErrHandler(c, err)
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.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.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
[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']
NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.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']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View file

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

12
app/pnpm-lock.yaml generated
View file

@ -89,6 +89,9 @@ importers:
vue3-gettext:
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))
vue3-otp-input:
specifier: ^0.5.21
version: 0.5.21(vue@3.4.33(typescript@5.3.3))
vuedraggable:
specifier: ^4.1.0
version: 4.1.0(vue@3.4.33(typescript@5.3.3))
@ -2969,6 +2972,11 @@ packages:
'@vue/compiler-sfc': '>=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:
resolution: {integrity: sha512-VdMCWQOummbhctl4QFMcW6eNtXHsFyDlX60O/tsSQuCcuDOnJ1qPOhhVla65Niece7xq/P2zyZReIO5mP+LGTQ==}
peerDependencies:
@ -6175,6 +6183,10 @@ snapshots:
transitivePeerDependencies:
- 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):
dependencies:
'@vue/compiler-dom': 3.4.33

View file

@ -6,15 +6,16 @@ const { login, logout } = useUserStore()
export interface AuthResponse {
message: string
token: string
code: number
}
const auth = {
async login(name: string, password: string) {
async login(name: string, password: string, otp: string, recoveryCode: string): Promise<AuthResponse> {
return http.post('/login', {
name,
password,
}).then((r: AuthResponse) => {
login(r.token)
otp,
recovery_code: recoveryCode,
})
},
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">
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
import { Form, message } from 'ant-design-vue'
import OTPInput from '@/components/OTPInput/OTPInput.vue'
import { useUserStore } from '@/pinia'
import auth from '@/api/auth'
import install from '@/api/install'
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
import gettext from '@/gettext'
import gettext, { $gettext } from '@/gettext'
const thisYear = new Date().getFullYear()
@ -20,6 +21,11 @@ install.get_lock().then(async (r: { lock: boolean }) => {
})
const loading = ref(false)
const enabled2FA = ref(false)
const refOTP = ref()
const passcode = ref('')
const useRecoveryCode = ref(false)
const recoveryCode = ref('')
const modelRef = reactive({
username: '',
@ -42,17 +48,25 @@ const rulesRef = reactive({
})
const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
const { login } = useUserStore()
const onSubmit = () => {
validate().then(async () => {
loading.value = true
await auth.login(modelRef.username, modelRef.password).then(async () => {
message.success($gettext('Login successful'), 1)
await auth.login(modelRef.username, modelRef.password, passcode.value, recoveryCode.value).then(async r => {
const next = (route.query?.next || '').toString() || '/'
switch (r.code) {
case 200:
message.success($gettext('Login successful'), 1)
login(r.token)
await router.push(next)
break
case 199:
enabled2FA.value = true
break
}
}).catch(e => {
switch (e.code) {
case 4031:
@ -64,6 +78,10 @@ const onSubmit = () => {
case 4033:
message.error($gettext('User is banned'))
break
case 4034:
refOTP.value?.clearInput()
message.error($gettext('Invalid 2FA or recovery code'))
break
default:
message.error($gettext(e.message ?? 'Server error'))
break
@ -117,6 +135,10 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
loading.value = false
}
function clickUseRecoveryCode() {
passcode.value = ''
useRecoveryCode.value = true
}
</script>
<template>
@ -128,6 +150,7 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
<h1>Nginx UI</h1>
</div>
<AForm id="components-form-demo-normal-login">
<template v-if="!enabled2FA">
<AFormItem v-bind="validateInfos.username">
<AInput
v-model:value="modelRef.username"
@ -148,7 +171,43 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
</template>
</AInputPassword>
</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
type="primary"
block

View file

@ -7,6 +7,7 @@ import type { BannedIP } from '@/api/settings'
import setting from '@/api/settings'
import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
import type { Settings } from '@/views/preference/typedef'
import TOTP from '@/views/preference/components/TOTP.vue'
const data: Settings = inject('data') as Settings
@ -105,6 +106,8 @@ function removeBannedIP(ip: string) {
</template>
</ATable>
</div>
<TOTP />
</div>
</div>
</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/casdoor/casdoor-go-sdk v0.47.0
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/dustin/go-humanize v1.0.1
github.com/fatih/color v1.17.0
@ -44,7 +45,7 @@ require (
require (
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/compute/metadata v0.5.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/StackExchange/wmi v1.2.1 // 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/apapsch/go-jsonmerge/v2 v2.0.0 // 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/loader v0.1.1 // 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/cloudflare/cloudflare-go v0.100.0 // 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-viper/mapstructure/v2 v2.0.0 // 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/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // 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/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 // indirect
github.com/google/go-querystring v1.1.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/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/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/gorilla/css v1.0.1 // indirect
@ -228,8 +232,8 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/tdewolff/minify/v2 v2.20.37 // 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/dnspod 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.969 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.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/tagparser/v2 v2.0.0 // 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/yosssi/ace v0.0.5 // 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/time v0.5.0 // indirect
golang.org/x/tools v0.23.0 // indirect
google.golang.org/api v0.188.0 // indirect
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect
google.golang.org/api v0.189.0 // indirect
google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
@ -278,6 +282,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/datatypes v1.2.1 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
gorm.io/driver/postgres v1.5.6 // indirect
gorm.io/hints v1.1.2 // indirect
k8s.io/api 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.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
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.1/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
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/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
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/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
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/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24=
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-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
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.793/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/aliyun/alibaba-cloud-sdk-go v1.62.794 h1:M6YtlJdCobRVlJaILK4Eia5aMtDSpeQtxFRl4hSi+DU=
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.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
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.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
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/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.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/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
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/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
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/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/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
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.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.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/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
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 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.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-20190702054246-869f871628b6/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.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
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.2/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.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.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/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=
@ -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 v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
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/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
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.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
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/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/dnspod v1.0.967/go.mod h1:T0RlPIT2imBeCxLkWfzoiEVP1r5WwzC6becSq7wvSgU=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969 h1:rJlV77WbjuJ5uGBi+THOk09Cfp8Kskz9HgExq0enTmY=
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/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/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
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-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-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/go.mod h1:kRqpmRyPs8rzXuYEJe57AH546a3VcSjEIzdFa1V66hY=
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-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-20221010170243-090e33056c14/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.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.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw=
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.4.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-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-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/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/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.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
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.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
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.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
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.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
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/logger"
"github.com/0xJacky/Nginx-UI/internal/logrotate"
"github.com/0xJacky/Nginx-UI/query"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/go-co-op/gocron"
"time"
@ -25,6 +26,7 @@ func InitCronJobs() {
}
startLogrotate()
cleanExpiredAuthToken()
s.StartAsync()
}
@ -43,10 +45,20 @@ func startLogrotate() {
return
}
var err error
logrotateJob, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec)
if err != nil {
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
import (
"crypto/rand"
"encoding/hex"
"github.com/0xJacky/Nginx-UI/internal/analytic"
"github.com/0xJacky/Nginx-UI/internal/cert"
"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/logrotate"
"github.com/0xJacky/Nginx-UI/internal/validation"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/go-co-op/gocron"
"github.com/google/uuid"
"mime"
"runtime"
"time"
)
func Boot() {
@ -24,6 +24,7 @@ func Boot() {
InitJsExtensionType,
InitDatabase,
InitNodeSecret,
InitCryptoSecret,
validation.Init,
}
@ -44,7 +45,7 @@ func InitAfterDatabase() {
syncs := []func(){
registerPredefinedUser,
cert.InitRegister,
InitCronJobs,
cron.InitCronJobs,
cluster.RegisterPredefinedNodes,
analytic.RetrieveNodesStatus,
}
@ -83,31 +84,34 @@ func InitNodeSecret() {
err := settings.Save()
if err != nil {
logger.Error("Error save settings")
logger.Error("Error save settings", err)
}
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() {
// Hack: fix wrong Content Type of .js file on some OS platforms
// See https://github.com/golang/go/issues/32350
_ = 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
import (
"github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
"strings"
"time"
)
const ExpiredTime = 24 * time.Hour
type JWTClaims struct {
Name string `json:"name"`
UserID int `json:"user_id"`
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()
err = db.Where("name", name).First(&user).Error
user = &model.Auth{}
err = db.Where("name", name).First(user).Error
if err != nil {
return
}
return
}
func DeleteToken(token string) error {
db := model.UseDB()
return db.Where("token", token).Delete(&model.AuthToken{}).Error
func DeleteToken(token string) {
q := query.AuthToken
_, _ = q.Where(q.Token.Eq(token)).Delete()
}
func CheckToken(token string) int64 {
db := model.UseDB()
return db.Where("token", token).Find(&model.AuthToken{}).RowsAffected
func GetTokenUser(token string) (*model.Auth, bool) {
q := query.AuthToken
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{
Name: name,
Name: user.Name,
UserID: user.ID,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
ExpiresAt: time.Now().Add(ExpiredTime).Unix(),
},
}
unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := unsignedToken.SignedString([]byte(settings.ServerSettings.JwtSecret))
if err != nil {
return "", err
}
db := model.UseDB()
err = db.Create(&model.AuthToken{
q := query.AuthToken
err = q.Create(&model.AuthToken{
UserID: user.ID,
Token: signedToken,
}).Error
ExpiredAt: time.Now().Add(ExpiredTime).Unix(),
})
if err != nil {
return "", err
@ -55,3 +86,50 @@ func GenerateJWT(name string) (string, error) {
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"`
Password string `json:"-"`
Status bool `json:"status" gorm:"default:1"`
OTPSecret []byte `json:"-" gorm:"type:blob"`
}
type AuthToken struct {
UserID int `json:"user_id"`
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()
_authToken.ALL = field.NewAsterisk(tableName)
_authToken.UserID = field.NewInt(tableName, "user_id")
_authToken.Token = field.NewString(tableName, "token")
_authToken.ExpiredAt = field.NewInt64(tableName, "expired_at")
_authToken.fillFieldMap()
@ -39,7 +41,9 @@ type authToken struct {
authTokenDo
ALL field.Asterisk
UserID field.Int
Token field.String
ExpiredAt field.Int64
fieldMap map[string]field.Expr
}
@ -56,7 +60,9 @@ func (a authToken) As(alias string) *authToken {
func (a *authToken) updateTableName(table string) *authToken {
a.ALL = field.NewAsterisk(table)
a.UserID = field.NewInt(table, "user_id")
a.Token = field.NewString(table, "token")
a.ExpiredAt = field.NewInt64(table, "expired_at")
a.fillFieldMap()
@ -73,8 +79,10 @@ func (a *authToken) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
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["expired_at"] = a.ExpiredAt
}
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.Password = field.NewString(tableName, "password")
_auth.Status = field.NewBool(tableName, "status")
_auth.OTPSecret = field.NewBytes(tableName, "otp_secret")
_auth.fillFieldMap()
@ -52,6 +53,7 @@ type auth struct {
Name field.String
Password field.String
Status field.Bool
OTPSecret field.Bytes
fieldMap map[string]field.Expr
}
@ -75,6 +77,7 @@ func (a *auth) updateTableName(table string) *auth {
a.Name = field.NewString(table, "name")
a.Password = field.NewString(table, "password")
a.Status = field.NewBool(table, "status")
a.OTPSecret = field.NewBytes(table, "otp_secret")
a.fillFieldMap()
@ -91,7 +94,7 @@ func (a *auth) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
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["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt
@ -99,6 +102,7 @@ func (a *auth) fillFieldMap() {
a.fieldMap["name"] = a.Name
a.fieldMap["password"] = a.Password
a.fieldMap["status"] = a.Status
a.fieldMap["otp_secret"] = a.OTPSecret
}
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()
return
}
c.Set("user", u)
if nodeID := c.GetHeader("X-Node-ID"); nodeID != "" {
c.Set("ProxyNodeID", nodeID)
}

View file

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

View file

@ -11,5 +11,6 @@ func TestCluster(t *testing.T) {
assert.Equal(t, []string{
"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.3?name=node3&node_secret=my-node-secret&enabled=true",
}, 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,
"cluster": &ClusterSettings,
"auth": &AuthSettings,
"crypto": &CryptoSettings,
}
func init() {
@ -64,6 +65,7 @@ func Setup() {
parseEnv(&CasdoorSettings, "CASDOOR_")
parseEnv(&LogrotateSettings, "LOGROTATE_")
parseEnv(&AuthSettings, "AUTH_")
parseEnv(&CryptoSettings, "CRYPTO_")
// 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.