mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-05-06 22:12:23 +08:00
feat(auth): implement short token for user authentication and update related login responses
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -32,10 +31,10 @@ const (
|
||||
)
|
||||
|
||||
type LoginResponse struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code int `json:"code"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code int `json:"code"`
|
||||
*user.AccessTokenPayload
|
||||
SecureSessionID string `json:"secure_session_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -67,17 +66,10 @@ func Login(c *gin.Context) {
|
||||
|
||||
u, err := user.Login(json.Name, json.Password)
|
||||
if err != nil {
|
||||
user.BanIP(clientIP)
|
||||
random := time.Duration(rand.Int() % 10)
|
||||
time.Sleep(random * time.Second)
|
||||
switch {
|
||||
case errors.Is(err, user.ErrPasswordIncorrect):
|
||||
c.JSON(http.StatusForbidden, user.ErrPasswordIncorrect)
|
||||
case errors.Is(err, user.ErrUserBanned):
|
||||
c.JSON(http.StatusForbidden, user.ErrUserBanned)
|
||||
default:
|
||||
cosy.ErrHandler(c, err)
|
||||
}
|
||||
user.BanIP(clientIP)
|
||||
cosy.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,10 +87,7 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
|
||||
c.JSON(http.StatusForbidden, LoginResponse{
|
||||
Message: "Invalid 2FA or recovery code",
|
||||
Code: Error2FACode,
|
||||
})
|
||||
cosy.ErrHandler(c, err)
|
||||
user.BanIP(clientIP)
|
||||
return
|
||||
}
|
||||
@@ -110,19 +99,17 @@ func Login(c *gin.Context) {
|
||||
_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
|
||||
|
||||
logger.Info("[User Login]", u.Name)
|
||||
token, err := user.GenerateJWT(u)
|
||||
accessToken, err := user.GenerateJWT(u)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, LoginResponse{
|
||||
Message: err.Error(),
|
||||
})
|
||||
cosy.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
Code: LoginSuccess,
|
||||
Message: "ok",
|
||||
Token: token,
|
||||
SecureSessionID: secureSessionID,
|
||||
Code: LoginSuccess,
|
||||
Message: "ok",
|
||||
AccessTokenPayload: accessToken,
|
||||
SecureSessionID: secureSessionID,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ func CasdoorCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
Message: "ok",
|
||||
Token: userToken,
|
||||
Message: "ok",
|
||||
AccessTokenPayload: userToken,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -157,8 +157,8 @@ func FinishPasskeyLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
Code: LoginSuccess,
|
||||
Message: "ok",
|
||||
Token: token,
|
||||
SecureSessionID: secureSessionID,
|
||||
AccessTokenPayload: token,
|
||||
SecureSessionID: secureSessionID,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
3
app/components.d.ts
vendored
3
app/components.d.ts
vendored
@@ -21,6 +21,7 @@ declare module 'vue' {
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
ACollapse: typeof import('ant-design-vue/es')['Collapse']
|
||||
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
|
||||
AComment: typeof import('ant-design-vue/es')['Comment']
|
||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||
ADrawer: typeof import('ant-design-vue/es')['Drawer']
|
||||
@@ -46,6 +47,7 @@ declare module 'vue' {
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
APopover: typeof import('ant-design-vue/es')['Popover']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
AQrcode: typeof import('ant-design-vue/es')['QRCode']
|
||||
AResult: typeof import('ant-design-vue/es')['Result']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
@@ -63,6 +65,7 @@ declare module 'vue' {
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||
AutoCertFormAutoCertForm: typeof import('./src/components/AutoCertForm/AutoCertForm.vue')['default']
|
||||
AutoCertFormDNSChallenge: typeof import('./src/components/AutoCertForm/DNSChallenge.vue')['default']
|
||||
BaseEditorBaseEditor: typeof import('./src/components/BaseEditor/BaseEditor.vue')['default']
|
||||
|
||||
@@ -7,6 +7,7 @@ const { login, logout } = useUserStore()
|
||||
export interface AuthResponse {
|
||||
message: string
|
||||
token: string
|
||||
short_token: string
|
||||
code: number
|
||||
error: string
|
||||
secure_session_id: string
|
||||
@@ -27,7 +28,7 @@ const auth = {
|
||||
state,
|
||||
})
|
||||
.then((r: AuthResponse) => {
|
||||
login(r.token)
|
||||
login(r.token, r.short_token)
|
||||
})
|
||||
},
|
||||
async logout() {
|
||||
|
||||
@@ -10,4 +10,6 @@ export default {
|
||||
50003: () => $gettext('Cannot remove initial user'),
|
||||
50004: () => $gettext('Cannot change initial user password in demo mode'),
|
||||
40401: () => $gettext('Session not found'),
|
||||
40402: () => $gettext('Token is empty'),
|
||||
50005: () => $gettext('Invalid claims type'),
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ function downloadCsv(header: any, data: any[], fileName: string) {
|
||||
|
||||
function urlJoin(...args: string[]) {
|
||||
return args
|
||||
.filter(arg => arg)
|
||||
.join('/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^(.+):\//, '$1://')
|
||||
|
||||
@@ -6,27 +6,30 @@ import { useSettingsStore, useUserStore } from '@/pinia'
|
||||
/**
|
||||
* Build WebSocket URL based on environment
|
||||
*/
|
||||
function buildWebSocketUrl(url: string, token: string, nodeId?: number): string {
|
||||
function buildWebSocketUrl(url: string, token: string, shortToken: string, nodeId?: number): string {
|
||||
const node_id = nodeId && nodeId > 0 ? `&x_node_id=${nodeId}` : ''
|
||||
|
||||
// Use shortToken if available (without base64 encoding), otherwise use regular token (with base64 encoding)
|
||||
const authParam = shortToken ? `token=${shortToken}` : `token=${btoa(token)}`
|
||||
|
||||
// In development mode, connect directly to backend server
|
||||
if (import.meta.env.DEV) {
|
||||
const proxyTarget = import.meta.env.VITE_PROXY_TARGET || 'http://localhost:9000'
|
||||
const wsTarget = proxyTarget.replace(/^https?:/, location.protocol === 'https:' ? 'wss:' : 'ws:')
|
||||
return urlJoin(wsTarget, url, `?token=${btoa(token)}`, node_id)
|
||||
return urlJoin(wsTarget, url, `?${authParam}`, node_id)
|
||||
}
|
||||
|
||||
// In production mode, use current host
|
||||
const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'
|
||||
return urlJoin(protocol + window.location.host, window.location.pathname, url, `?token=${btoa(token)}`, node_id)
|
||||
return urlJoin(protocol + window.location.host, window.location.pathname, url, `?${authParam}`, node_id)
|
||||
}
|
||||
|
||||
function ws(url: string, reconnect: boolean = true): ReconnectingWebSocket | WebSocket {
|
||||
const user = useUserStore()
|
||||
const settings = useSettingsStore()
|
||||
const { token } = storeToRefs(user)
|
||||
const { token, shortToken } = storeToRefs(user)
|
||||
|
||||
const _url = buildWebSocketUrl(url, token.value, settings.environment.id)
|
||||
const _url = buildWebSocketUrl(url, token.value, shortToken.value, settings.environment.id)
|
||||
|
||||
if (reconnect)
|
||||
return new ReconnectingWebSocket(_url, undefined, { maxRetries: 10 })
|
||||
|
||||
@@ -8,11 +8,16 @@ export const useUserStore = defineStore('user', () => {
|
||||
const cookies = useCookies(['nginx-ui'])
|
||||
|
||||
const token = ref('')
|
||||
const shortToken = ref('')
|
||||
|
||||
watch(token, v => {
|
||||
cookies.set('token', v, { maxAge: 86400 })
|
||||
})
|
||||
|
||||
watch(shortToken, v => {
|
||||
cookies.set('short_token', v, { maxAge: 86400 })
|
||||
})
|
||||
|
||||
const secureSessionId = ref('')
|
||||
|
||||
watch(secureSessionId, v => {
|
||||
@@ -22,6 +27,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
function handleCookieChange({ name, value }: CookieChangeOptions) {
|
||||
if (name === 'token')
|
||||
token.value = value
|
||||
else if (name === 'short_token')
|
||||
shortToken.value = value
|
||||
else if (name === 'secure_session_id')
|
||||
secureSessionId.value = value
|
||||
}
|
||||
@@ -35,17 +42,21 @@ export const useUserStore = defineStore('user', () => {
|
||||
const isLogin = computed(() => !!token.value)
|
||||
const passkeyLoginAvailable = computed(() => !!passkeyRawId.value)
|
||||
|
||||
function passkeyLogin(rawId: string, tokenValue: string) {
|
||||
function passkeyLogin(rawId: string, tokenValue: string, shortTokenValue?: string) {
|
||||
passkeyRawId.value = rawId
|
||||
login(tokenValue)
|
||||
login(tokenValue, shortTokenValue)
|
||||
}
|
||||
|
||||
function login(tokenValue: string) {
|
||||
function login(tokenValue: string, shortTokenValue?: string) {
|
||||
token.value = tokenValue
|
||||
if (shortTokenValue) {
|
||||
shortToken.value = shortTokenValue
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
shortToken.value = ''
|
||||
passkeyRawId.value = ''
|
||||
secureSessionId.value = ''
|
||||
unreadCount.value = 0
|
||||
@@ -100,6 +111,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
return {
|
||||
token,
|
||||
shortToken,
|
||||
unreadCount,
|
||||
secureSessionId,
|
||||
passkeyRawId,
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2.1.10","build_id":3,"total_build":445}
|
||||
{"version":"2.1.10","build_id":5,"total_build":447}
|
||||
@@ -57,6 +57,7 @@ const { secureSessionId } = storeToRefs(userStore)
|
||||
|
||||
interface LoginSuccessOptions {
|
||||
token?: string
|
||||
shortToken?: string
|
||||
secureSessionId?: string
|
||||
loginType?: 'normal' | 'passkey'
|
||||
passkeyRawId?: string
|
||||
@@ -66,6 +67,7 @@ interface LoginSuccessOptions {
|
||||
async function handleLoginSuccess(options: LoginSuccessOptions = {}) {
|
||||
const {
|
||||
token,
|
||||
shortToken,
|
||||
secureSessionId: sessionId,
|
||||
loginType = 'normal',
|
||||
passkeyRawId,
|
||||
@@ -78,10 +80,10 @@ async function handleLoginSuccess(options: LoginSuccessOptions = {}) {
|
||||
|
||||
// Handle different login types
|
||||
if (loginType === 'passkey' && passkeyRawId && token) {
|
||||
passkeyLogin(passkeyRawId, token)
|
||||
passkeyLogin(passkeyRawId, token, shortToken)
|
||||
}
|
||||
else if (token) {
|
||||
login(token)
|
||||
login(token, shortToken)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
@@ -112,6 +114,7 @@ function onSubmit() {
|
||||
case 200:
|
||||
await handleLoginSuccess({
|
||||
token: r.token,
|
||||
shortToken: r.short_token,
|
||||
secureSessionId: r.secure_session_id,
|
||||
})
|
||||
break
|
||||
@@ -191,6 +194,7 @@ async function handlePasskeyLogin() {
|
||||
if (r.token) {
|
||||
await handleLoginSuccess({
|
||||
token: r.token,
|
||||
shortToken: r.short_token,
|
||||
secureSessionId: r.secure_session_id,
|
||||
loginType: 'passkey',
|
||||
passkeyRawId: asseResp.rawId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/0xJacky/Nginx-UI/internal/user"
|
||||
"github.com/0xJacky/Nginx-UI/model"
|
||||
"github.com/0xJacky/Nginx-UI/settings"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uozi-tech/cosy/logger"
|
||||
@@ -19,8 +20,13 @@ func getToken(c *gin.Context) (token string) {
|
||||
}
|
||||
|
||||
if token = c.Query("token"); token != "" {
|
||||
tokenBytes, _ := base64.StdEncoding.DecodeString(token)
|
||||
return string(tokenBytes)
|
||||
if len(token) > 16 {
|
||||
// Long token (base64 encoded JWT)
|
||||
tokenBytes, _ := base64.StdEncoding.DecodeString(token)
|
||||
return string(tokenBytes)
|
||||
}
|
||||
// Short token (16 characters)
|
||||
return token
|
||||
}
|
||||
|
||||
if token, _ = c.Cookie("token"); token != "" {
|
||||
@@ -75,10 +81,25 @@ func AuthRequired() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
u, ok := user.GetTokenUser(token)
|
||||
if !ok {
|
||||
abortWithAuthFailure()
|
||||
return
|
||||
var (
|
||||
u *model.User
|
||||
ok bool
|
||||
)
|
||||
|
||||
if len(token) <= 16 {
|
||||
// Short token (16 characters)
|
||||
u, ok = user.GetTokenUserByShortToken(token)
|
||||
if !ok {
|
||||
abortWithAuthFailure()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Long JWT token
|
||||
u, ok = user.GetTokenUser(token)
|
||||
if !ok {
|
||||
abortWithAuthFailure()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Set("user", u)
|
||||
|
||||
@@ -15,4 +15,6 @@ var (
|
||||
ErrCannotRemoveInitUser = e.New(50003, "cannot remove initial user")
|
||||
ErrChangeInitUserPwdInDemo = e.New(50004, "cannot change initial user password in demo mode")
|
||||
ErrSessionNotFound = e.New(40401, "session not found")
|
||||
ErrTokenIsEmpty = e.New(40402, "token is empty")
|
||||
ErrInvalidClaimsType = e.New(50005, "invalid claims type")
|
||||
)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"github.com/0xJacky/Nginx-UI/model"
|
||||
"github.com/0xJacky/Nginx-UI/query"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/uozi-tech/cosy/logger"
|
||||
cSettings "github.com/uozi-tech/cosy/settings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ExpiredTime = 24 * time.Hour
|
||||
@@ -57,7 +59,34 @@ func GetTokenUser(token string) (*model.User, bool) {
|
||||
return user, err == nil
|
||||
}
|
||||
|
||||
func GenerateJWT(user *model.User) (string, error) {
|
||||
func GetTokenUserByShortToken(shortToken string) (*model.User, bool) {
|
||||
if shortToken == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
db := model.UseDB()
|
||||
var authToken model.AuthToken
|
||||
err := db.Where("short_token = ?", shortToken).First(&authToken).Error
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if authToken.ExpiredAt < time.Now().Unix() {
|
||||
DeleteToken(authToken.Token)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
u := query.User
|
||||
user, err := u.FirstByID(authToken.UserID)
|
||||
return user, err == nil
|
||||
}
|
||||
|
||||
type AccessTokenPayload struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
ShortToken string `json:"short_token,omitempty"`
|
||||
}
|
||||
|
||||
func GenerateJWT(user *model.User) (*AccessTokenPayload, error) {
|
||||
now := time.Now()
|
||||
claims := JWTClaims{
|
||||
Name: user.Name,
|
||||
@@ -75,26 +104,39 @@ func GenerateJWT(user *model.User) (string, error) {
|
||||
unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signedToken, err := unsignedToken.SignedString([]byte(cSettings.AppSettings.JwtSecret))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate 16-byte short token (16 characters)
|
||||
shortTokenBytes := make([]byte, 16)
|
||||
_, err = rand.Read(shortTokenBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Use base64 URL encoding to get a 16-character string
|
||||
shortToken := base64.URLEncoding.EncodeToString(shortTokenBytes)[:16]
|
||||
|
||||
q := query.AuthToken
|
||||
err = q.Create(&model.AuthToken{
|
||||
UserID: user.ID,
|
||||
Token: signedToken,
|
||||
ExpiredAt: now.Add(ExpiredTime).Unix(),
|
||||
UserID: user.ID,
|
||||
Token: signedToken,
|
||||
ShortToken: shortToken,
|
||||
ExpiredAt: now.Add(ExpiredTime).Unix(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return signedToken, err
|
||||
return &AccessTokenPayload{
|
||||
Token: signedToken,
|
||||
ShortToken: shortToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ValidateJWT(tokenStr string) (claims *JWTClaims, err error) {
|
||||
if tokenStr == "" {
|
||||
err = errors.New("token is empty")
|
||||
err = ErrTokenIsEmpty
|
||||
return
|
||||
}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
@@ -107,5 +149,5 @@ func ValidateJWT(tokenStr string) (claims *JWTClaims, err error) {
|
||||
if claims, ok = token.Claims.(*JWTClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, errors.New("invalid claims type")
|
||||
return nil, ErrInvalidClaimsType
|
||||
}
|
||||
|
||||
@@ -36,9 +36,10 @@ type User struct {
|
||||
}
|
||||
|
||||
type AuthToken struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Token string `json:"token"`
|
||||
ExpiredAt int64 `json:"expired_at" gorm:"default:0"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
Token string `json:"token"`
|
||||
ShortToken string `json:"short_token"`
|
||||
ExpiredAt int64 `json:"expired_at" gorm:"default:0"`
|
||||
}
|
||||
|
||||
func (u *User) TableName() string {
|
||||
|
||||
@@ -30,6 +30,7 @@ func newAuthToken(db *gorm.DB, opts ...gen.DOOption) authToken {
|
||||
_authToken.ALL = field.NewAsterisk(tableName)
|
||||
_authToken.UserID = field.NewUint64(tableName, "user_id")
|
||||
_authToken.Token = field.NewString(tableName, "token")
|
||||
_authToken.ShortToken = field.NewString(tableName, "short_token")
|
||||
_authToken.ExpiredAt = field.NewInt64(tableName, "expired_at")
|
||||
|
||||
_authToken.fillFieldMap()
|
||||
@@ -40,10 +41,11 @@ func newAuthToken(db *gorm.DB, opts ...gen.DOOption) authToken {
|
||||
type authToken struct {
|
||||
authTokenDo
|
||||
|
||||
ALL field.Asterisk
|
||||
UserID field.Uint64
|
||||
Token field.String
|
||||
ExpiredAt field.Int64
|
||||
ALL field.Asterisk
|
||||
UserID field.Uint64
|
||||
Token field.String
|
||||
ShortToken field.String
|
||||
ExpiredAt field.Int64
|
||||
|
||||
fieldMap map[string]field.Expr
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func (a *authToken) updateTableName(table string) *authToken {
|
||||
a.ALL = field.NewAsterisk(table)
|
||||
a.UserID = field.NewUint64(table, "user_id")
|
||||
a.Token = field.NewString(table, "token")
|
||||
a.ShortToken = field.NewString(table, "short_token")
|
||||
a.ExpiredAt = field.NewInt64(table, "expired_at")
|
||||
|
||||
a.fillFieldMap()
|
||||
@@ -79,9 +82,10 @@ func (a *authToken) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
|
||||
}
|
||||
|
||||
func (a *authToken) fillFieldMap() {
|
||||
a.fieldMap = make(map[string]field.Expr, 3)
|
||||
a.fieldMap = make(map[string]field.Expr, 4)
|
||||
a.fieldMap["user_id"] = a.UserID
|
||||
a.fieldMap["token"] = a.Token
|
||||
a.fieldMap["short_token"] = a.ShortToken
|
||||
a.fieldMap["expired_at"] = a.ExpiredAt
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user