feat(auth): implement short token for user authentication and update related login responses

This commit is contained in:
Jacky
2025-07-03 10:10:05 +00:00
parent 28cc1dad64
commit a3f8f90668
16 changed files with 150 additions and 67 deletions

View File

@@ -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,
})
}

View File

@@ -81,8 +81,8 @@ func CasdoorCallback(c *gin.Context) {
}
c.JSON(http.StatusOK, LoginResponse{
Message: "ok",
Token: userToken,
Message: "ok",
AccessTokenPayload: userToken,
})
}

View File

@@ -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
View File

@@ -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']

View File

@@ -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() {

View File

@@ -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'),
}

View File

@@ -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://')

View File

@@ -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 })

View File

@@ -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,

View File

@@ -1 +1 @@
{"version":"2.1.10","build_id":3,"total_build":445}
{"version":"2.1.10","build_id":5,"total_build":447}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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")
)

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}