feat(backend): implement MFA-protected account credentials endpoint and enhance security

This commit is contained in:
pycook
2025-08-12 12:33:38 +08:00
parent f02d2bc32e
commit 7b6206e79e
7 changed files with 258 additions and 432 deletions

View File

@@ -0,0 +1,66 @@
package acl
import (
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/veops/oneterm/pkg/config"
)
// MFAIntrospectRequest represents the request to MFA introspect endpoint
type MFAIntrospectRequest struct {
MfaToken string `json:"mfa_token"`
}
// MFAIntrospectResponse represents the response from MFA introspect endpoint
type MFAIntrospectResponse struct {
Active bool `json:"active"`
UID int64 `json:"uid"`
Scope string `json:"scope"`
Exp int64 `json:"exp"`
}
// VerifyMFAToken verifies the MFA token by calling the introspect endpoint
func VerifyMFAToken(mfaToken string) bool {
// Build URL from config, replacing v1 with common-setting/v1
baseURL := config.Cfg.Auth.Acl.Url
url := strings.Replace(baseURL, "/v1", "/common-setting/v1", 1) + "/mfa/introspect"
// Prepare request data
reqData := MFAIntrospectRequest{
MfaToken: mfaToken,
}
// Create resty client with timeout
client := resty.New().SetTimeout(5 * time.Second)
var mfaResp MFAIntrospectResponse
resp, err := client.R().
SetHeader("Content-Type", "application/json").
SetBody(reqData).
SetResult(&mfaResp).
Post(url)
if err != nil {
return false
}
if resp.StatusCode() != 200 {
return false
}
// Check if token is active
if !mfaResp.Active {
return false
}
// Check if token is not expired
now := time.Now().Unix()
if mfaResp.Exp > 0 && now > mfaResp.Exp {
return false
}
return true
}

View File

@@ -1,6 +1,7 @@
package controller
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -38,24 +39,6 @@ var (
return
}
},
// Decrypt sensitive data
func(ctx *gin.Context, data []*model.Account) {
accountService.DecryptSensitiveData(data)
},
// Filter sensitive fields for non-admin users
func(ctx *gin.Context, data []*model.Account) {
info := cast.ToBool(ctx.Query("info"))
if !info {
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
for _, account := range data {
account.Password = ""
account.Pk = ""
account.Phrase = ""
}
}
}
},
}
accountDcs = []deleteCheck{
@@ -126,14 +109,78 @@ func (c *Controller) GetAccounts(ctx *gin.Context) {
return
}
// Apply info mode settings
// Always exclude sensitive fields (password, pk, phrase) for security
// These fields require separate MFA-protected API calls to access
if info {
db = db.Select("id", "name", "account")
} else {
// Exclude sensitive fields but include other metadata
db = db.Select("id", "name", "account", "account_type", "resource_id",
"creator_id", "updater_id", "created_at", "updated_at", "deleted_at")
}
doGet(ctx, false, db, config.RESOURCE_ACCOUNT, accountPostHooks...)
}
// GetAccountCredentials godoc
//
// @Tags account
// @Summary Get account credentials with MFA verification
// @Param id path int true "Account ID"
// @Param X-MFA-Token header string true "MFA verification token"
// @Success 200 {object} HttpResponse{data=model.Account}
// @Router /account/{id}/credentials [post]
func (c *Controller) GetAccountCredentials(ctx *gin.Context) {
// Get account ID from path parameter
accountId := cast.ToInt(ctx.Param("id"))
if accountId == 0 {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{
Code: myErrors.ErrInvalidArgument,
Data: map[string]any{"err": "Invalid account ID"},
})
return
}
// Get MFA token from header
mfaToken := ctx.GetHeader("X-Mfa-Token")
if mfaToken == "" {
ctx.AbortWithError(http.StatusUnauthorized, errors.New("MFA token required in X-MFA-Token header"))
return
}
// Verify MFA token using ACL service
if !acl.VerifyMFAToken(mfaToken) {
ctx.AbortWithError(http.StatusUnauthorized, errors.New("MFA token verification failed"))
return
}
// Build query with authorization check
db, err := accountService.BuildQueryWithAuthorization(ctx)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{
Code: myErrors.ErrInternal,
Data: map[string]any{"err": err},
})
return
}
// Query for the specific account with all fields (including sensitive ones)
var account model.Account
if err := db.Where("id = ?", accountId).First(&account).Error; err != nil {
ctx.AbortWithError(http.StatusNotFound, &myErrors.ApiError{
Data: map[string]any{"err": "Account not found or access denied"},
})
return
}
// Decrypt sensitive data before returning
accountService.DecryptSensitiveData([]*model.Account{&account})
ctx.JSON(http.StatusOK, HttpResponse{
Data: account,
})
}
// GetAccountIdsByAuthorization gets account IDs by authorization
func GetAccountIdsByAuthorization(ctx *gin.Context) ([]int, error) {
assetIds, err := GetAssetIdsByAuthorization(ctx)

View File

@@ -188,6 +188,50 @@ const docTemplate = `{
}
}
},
"/account/{id}/credentials": {
"post": {
"tags": [
"account"
],
"summary": "Get account credentials with MFA verification",
"parameters": [
{
"type": "integer",
"description": "Account ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "MFA verification token",
"name": "X-MFA-Token",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/controller.HttpResponse"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.Account"
}
}
}
]
}
}
}
}
},
"/asset": {
"get": {
"tags": [
@@ -2340,12 +2384,6 @@ const docTemplate = `{
"/proxy": {
"get": {
"description": "Handle web proxy requests for subdomain-based assets",
"consumes": [
"*/*"
],
"produces": [
"*/*"
],
"tags": [
"WebProxy"
],
@@ -2368,23 +2406,6 @@ const docTemplate = `{
"responses": {
"200": {
"description": "Proxied content"
},
"400": {
"description": "Invalid subdomain format",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Session expired page"
},
"403": {
"description": "Access denied",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4261,12 +4282,6 @@ const docTemplate = `{
"/web_proxy/cleanup": {
"post": {
"description": "Clean up web session when browser tab is closed",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4301,12 +4316,6 @@ const docTemplate = `{
"/web_proxy/close": {
"post": {
"description": "Close an active web session and clean up resources",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4334,20 +4343,6 @@ const docTemplate = `{
"type": "string"
}
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Session not found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4355,12 +4350,6 @@ const docTemplate = `{
"/web_proxy/config/{asset_id}": {
"get": {
"description": "Get web asset configuration by asset ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4380,20 +4369,6 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/model.WebConfig"
}
},
"400": {
"description": "Invalid asset ID",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Asset not found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4401,12 +4376,6 @@ const docTemplate = `{
"/web_proxy/external_redirect": {
"get": {
"description": "Show a page when an external redirect is blocked by the proxy",
"consumes": [
"text/html"
],
"produces": [
"text/html"
],
"tags": [
"WebProxy"
],
@@ -4430,12 +4399,6 @@ const docTemplate = `{
"/web_proxy/heartbeat": {
"post": {
"description": "Update the last activity time for a web session (heartbeat)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4463,20 +4426,6 @@ const docTemplate = `{
"type": "string"
}
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Session not found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4484,12 +4433,6 @@ const docTemplate = `{
"/web_proxy/sessions/{asset_id}": {
"get": {
"description": "Get list of active web sessions for a specific asset",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4513,13 +4456,6 @@ const docTemplate = `{
"additionalProperties": true
}
}
},
"400": {
"description": "Invalid asset ID",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4527,12 +4463,6 @@ const docTemplate = `{
"/web_proxy/start": {
"post": {
"description": "Start a new web session for the specified asset",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4554,41 +4484,6 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/web_proxy.StartWebSessionResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"403": {
"description": "No permission",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Asset not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"429": {
"description": "Maximum concurrent connections exceeded",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}

View File

@@ -177,6 +177,50 @@
}
}
},
"/account/{id}/credentials": {
"post": {
"tags": [
"account"
],
"summary": "Get account credentials with MFA verification",
"parameters": [
{
"type": "integer",
"description": "Account ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "MFA verification token",
"name": "X-MFA-Token",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/controller.HttpResponse"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.Account"
}
}
}
]
}
}
}
}
},
"/asset": {
"get": {
"tags": [
@@ -2329,12 +2373,6 @@
"/proxy": {
"get": {
"description": "Handle web proxy requests for subdomain-based assets",
"consumes": [
"*/*"
],
"produces": [
"*/*"
],
"tags": [
"WebProxy"
],
@@ -2357,23 +2395,6 @@
"responses": {
"200": {
"description": "Proxied content"
},
"400": {
"description": "Invalid subdomain format",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Session expired page"
},
"403": {
"description": "Access denied",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4250,12 +4271,6 @@
"/web_proxy/cleanup": {
"post": {
"description": "Clean up web session when browser tab is closed",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4290,12 +4305,6 @@
"/web_proxy/close": {
"post": {
"description": "Close an active web session and clean up resources",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4323,20 +4332,6 @@
"type": "string"
}
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Session not found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4344,12 +4339,6 @@
"/web_proxy/config/{asset_id}": {
"get": {
"description": "Get web asset configuration by asset ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4369,20 +4358,6 @@
"schema": {
"$ref": "#/definitions/model.WebConfig"
}
},
"400": {
"description": "Invalid asset ID",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Asset not found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4390,12 +4365,6 @@
"/web_proxy/external_redirect": {
"get": {
"description": "Show a page when an external redirect is blocked by the proxy",
"consumes": [
"text/html"
],
"produces": [
"text/html"
],
"tags": [
"WebProxy"
],
@@ -4419,12 +4388,6 @@
"/web_proxy/heartbeat": {
"post": {
"description": "Update the last activity time for a web session (heartbeat)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4452,20 +4415,6 @@
"type": "string"
}
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Session not found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4473,12 +4422,6 @@
"/web_proxy/sessions/{asset_id}": {
"get": {
"description": "Get list of active web sessions for a specific asset",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4502,13 +4445,6 @@
"additionalProperties": true
}
}
},
"400": {
"description": "Invalid asset ID",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4516,12 +4452,6 @@
"/web_proxy/start": {
"post": {
"description": "Start a new web session for the specified asset",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
@@ -4543,41 +4473,6 @@
"schema": {
"$ref": "#/definitions/web_proxy.StartWebSessionResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"403": {
"description": "No permission",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Asset not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"429": {
"description": "Maximum concurrent connections exceeded",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}

View File

@@ -1219,6 +1219,32 @@ paths:
$ref: '#/definitions/controller.HttpResponse'
tags:
- account
/account/{id}/credentials:
post:
parameters:
- description: Account ID
in: path
name: id
required: true
type: integer
- description: MFA verification token
in: header
name: X-MFA-Token
required: true
type: string
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/controller.HttpResponse'
- properties:
data:
$ref: '#/definitions/model.Account'
type: object
summary: Get account credentials with MFA verification
tags:
- account
/asset:
get:
parameters:
@@ -2532,8 +2558,6 @@ paths:
- Preference
/proxy:
get:
consumes:
- '*/*'
description: Handle web proxy requests for subdomain-based assets
parameters:
- description: Asset subdomain (asset-123.domain.com)
@@ -2545,23 +2569,9 @@ paths:
in: query
name: session_id
type: string
produces:
- '*/*'
responses:
"200":
description: Proxied content
"400":
description: Invalid subdomain format
schema:
additionalProperties: true
type: object
"401":
description: Session expired page
"403":
description: Access denied
schema:
additionalProperties: true
type: object
summary: Proxy web requests
tags:
- WebProxy
@@ -3681,8 +3691,6 @@ paths:
- time_template
/web_proxy/cleanup:
post:
consumes:
- application/json
description: Clean up web session when browser tab is closed
parameters:
- description: Cleanup request
@@ -3693,8 +3701,6 @@ paths:
additionalProperties:
type: string
type: object
produces:
- application/json
responses:
"200":
description: Session cleaned up
@@ -3707,8 +3713,6 @@ paths:
- WebProxy
/web_proxy/close:
post:
consumes:
- application/json
description: Close an active web session and clean up resources
parameters:
- description: Session close request
@@ -3719,8 +3723,6 @@ paths:
additionalProperties:
type: string
type: object
produces:
- application/json
responses:
"200":
description: Session closed successfully
@@ -3728,23 +3730,11 @@ paths:
additionalProperties:
type: string
type: object
"400":
description: Invalid request
schema:
additionalProperties: true
type: object
"404":
description: Session not found
schema:
additionalProperties: true
type: object
summary: Close web session
tags:
- WebProxy
/web_proxy/config/{asset_id}:
get:
consumes:
- application/json
description: Get web asset configuration by asset ID
parameters:
- description: Asset ID
@@ -3752,30 +3742,16 @@ paths:
name: asset_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.WebConfig'
"400":
description: Invalid asset ID
schema:
additionalProperties: true
type: object
"404":
description: Asset not found
schema:
additionalProperties: true
type: object
summary: Get web asset configuration
tags:
- WebProxy
/web_proxy/external_redirect:
get:
consumes:
- text/html
description: Show a page when an external redirect is blocked by the proxy
parameters:
- description: Target URL that was blocked
@@ -3783,8 +3759,6 @@ paths:
name: url
required: true
type: string
produces:
- text/html
responses:
"200":
description: External redirect blocked page
@@ -3793,8 +3767,6 @@ paths:
- WebProxy
/web_proxy/heartbeat:
post:
consumes:
- application/json
description: Update the last activity time for a web session (heartbeat)
parameters:
- description: Heartbeat request
@@ -3805,8 +3777,6 @@ paths:
additionalProperties:
type: string
type: object
produces:
- application/json
responses:
"200":
description: Heartbeat updated
@@ -3814,23 +3784,11 @@ paths:
additionalProperties:
type: string
type: object
"400":
description: Invalid request
schema:
additionalProperties: true
type: object
"404":
description: Session not found
schema:
additionalProperties: true
type: object
summary: Update session heartbeat
tags:
- WebProxy
/web_proxy/sessions/{asset_id}:
get:
consumes:
- application/json
description: Get list of active web sessions for a specific asset
parameters:
- description: Asset ID
@@ -3838,8 +3796,6 @@ paths:
name: asset_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: List of active sessions
@@ -3848,18 +3804,11 @@ paths:
additionalProperties: true
type: object
type: array
"400":
description: Invalid asset ID
schema:
additionalProperties: true
type: object
summary: Get active web sessions
tags:
- WebProxy
/web_proxy/start:
post:
consumes:
- application/json
description: Start a new web session for the specified asset
parameters:
- description: Start session request
@@ -3868,38 +3817,11 @@ paths:
required: true
schema:
$ref: '#/definitions/web_proxy.StartWebSessionRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/web_proxy.StartWebSessionResponse'
"400":
description: Invalid request
schema:
additionalProperties: true
type: object
"403":
description: No permission
schema:
additionalProperties: true
type: object
"404":
description: Asset not found
schema:
additionalProperties: true
type: object
"429":
description: Maximum concurrent connections exceeded
schema:
additionalProperties: true
type: object
"500":
description: Internal server error
schema:
additionalProperties: true
type: object
summary: Start web session
tags:
- WebProxy

View File

@@ -62,6 +62,7 @@ func SetupRouter(r *gin.Engine) {
account.DELETE("/:id", c.DeleteAccount)
account.PUT("/:id", c.UpdateAccount)
account.GET("", c.GetAccounts)
account.POST("/:id/credentials", c.GetAccountCredentials)
}
asset := v1.Group("asset")

View File

@@ -9,21 +9,21 @@ import (
type Account struct {
Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"`
Name string `json:"name" gorm:"column:name;uniqueIndex:name_del;size:128"`
AccountType int `json:"account_type" gorm:"column:account_type"`
AccountType int `json:"account_type,omitempty" gorm:"column:account_type"`
Account string `json:"account" gorm:"column:account"`
Password string `json:"password" gorm:"column:password"`
Pk string `json:"pk" gorm:"column:pk"`
Phrase string `json:"phrase" gorm:"column:phrase"`
Password string `json:"password,omitempty" gorm:"column:password"`
Pk string `json:"pk,omitempty" gorm:"column:pk"`
Phrase string `json:"phrase,omitempty" gorm:"column:phrase"`
Permissions []string `json:"permissions" gorm:"-"`
ResourceId int `json:"resource_id" gorm:"column:resource_id"`
CreatorId int `json:"creator_id" gorm:"column:creator_id"`
UpdaterId int `json:"updater_id" gorm:"column:updater_id"`
CreatedAt time.Time `json:"created_at" gorm:"column:created_at"`
UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"`
Permissions []string `json:"permissions,omitempty" gorm:"-"`
ResourceId int `json:"resource_id,omitempty" gorm:"column:resource_id"`
CreatorId int `json:"creator_id,omitempty" gorm:"column:creator_id"`
UpdaterId int `json:"updater_id,omitempty" gorm:"column:updater_id"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"column:created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"column:updated_at"`
DeletedAt soft_delete.DeletedAt `json:"-" gorm:"column:deleted_at;uniqueIndex:name_del"`
AssetCount int64 `json:"asset_count" gorm:"-"`
AssetCount int64 `json:"asset_count,omitempty" gorm:"-"`
}
func (m *Account) TableName() string {