Adding some tests around the openAPI public APIs to make sure the security works the way we expect, do not redirect API requests.
This commit is contained in:
parent
5f56d8b369
commit
5a46e16b8d
|
@ -27,7 +27,7 @@ export function doInUserContext(user: User, ctx: Ctx, task: any) {
|
||||||
hostInfo: {
|
hostInfo: {
|
||||||
ipAddress: ctx.request.ip,
|
ipAddress: ctx.request.ip,
|
||||||
// filled in by koa-useragent package
|
// filled in by koa-useragent package
|
||||||
userAgent: ctx.userAgent._agent.source,
|
userAgent: ctx.userAgent.source,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return doInIdentityContext(userContext, task)
|
return doInIdentityContext(userContext, task)
|
||||||
|
|
|
@ -1,20 +1,26 @@
|
||||||
import { Cookie, Header } from "../constants"
|
import { Cookie, Header } from "../constants"
|
||||||
import {
|
import {
|
||||||
getCookie,
|
|
||||||
clearCookie,
|
clearCookie,
|
||||||
openJwt,
|
getCookie,
|
||||||
isValidInternalAPIKey,
|
isValidInternalAPIKey,
|
||||||
|
openJwt,
|
||||||
} from "../utils"
|
} from "../utils"
|
||||||
import { getUser } from "../cache/user"
|
import { getUser } from "../cache/user"
|
||||||
import { getSession, updateSessionTTL } from "../security/sessions"
|
import { getSession, updateSessionTTL } from "../security/sessions"
|
||||||
import { buildMatcherRegex, matches } from "./matchers"
|
import { buildMatcherRegex, matches } from "./matchers"
|
||||||
import { SEPARATOR, queryGlobalView, ViewName } from "../db"
|
import { queryGlobalView, SEPARATOR, ViewName } from "../db"
|
||||||
import { getGlobalDB, doInTenant } from "../context"
|
import { doInTenant, getGlobalDB } from "../context"
|
||||||
import { decrypt } from "../security/encryption"
|
import { decrypt } from "../security/encryption"
|
||||||
import * as identity from "../context/identity"
|
import * as identity from "../context/identity"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { Ctx, EndpointMatcher, SessionCookie, User } from "@budibase/types"
|
import {
|
||||||
import { InvalidAPIKeyError, ErrorCode } from "../errors"
|
Ctx,
|
||||||
|
EndpointMatcher,
|
||||||
|
LoginMethod,
|
||||||
|
SessionCookie,
|
||||||
|
User,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { ErrorCode, InvalidAPIKeyError } from "../errors"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
|
|
||||||
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
|
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
|
||||||
|
@ -26,16 +32,18 @@ interface FinaliseOpts {
|
||||||
internal?: boolean
|
internal?: boolean
|
||||||
publicEndpoint?: boolean
|
publicEndpoint?: boolean
|
||||||
version?: string
|
version?: string
|
||||||
user?: any
|
user?: User | { tenantId: string }
|
||||||
|
loginMethod?: LoginMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeMinusOneMinute() {
|
function timeMinusOneMinute() {
|
||||||
return new Date(Date.now() - ONE_MINUTE).toISOString()
|
return new Date(Date.now() - ONE_MINUTE).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalise(ctx: any, opts: FinaliseOpts = {}) {
|
function finalise(ctx: Ctx, opts: FinaliseOpts = {}) {
|
||||||
ctx.publicEndpoint = opts.publicEndpoint || false
|
ctx.publicEndpoint = opts.publicEndpoint || false
|
||||||
ctx.isAuthenticated = opts.authenticated || false
|
ctx.isAuthenticated = opts.authenticated || false
|
||||||
|
ctx.loginMethod = opts.loginMethod
|
||||||
ctx.user = opts.user
|
ctx.user = opts.user
|
||||||
ctx.internal = opts.internal || false
|
ctx.internal = opts.internal || false
|
||||||
ctx.version = opts.version
|
ctx.version = opts.version
|
||||||
|
@ -120,9 +128,10 @@ export default function (
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = ctx.request.headers[Header.TENANT_ID]
|
const tenantId = ctx.request.headers[Header.TENANT_ID]
|
||||||
let authenticated = false,
|
let authenticated: boolean = false,
|
||||||
user = null,
|
user: User | { tenantId: string } | undefined = undefined,
|
||||||
internal = false
|
internal: boolean = false,
|
||||||
|
loginMethod: LoginMethod | undefined = undefined
|
||||||
if (authCookie && !apiKey) {
|
if (authCookie && !apiKey) {
|
||||||
const sessionId = authCookie.sessionId
|
const sessionId = authCookie.sessionId
|
||||||
const userId = authCookie.userId
|
const userId = authCookie.userId
|
||||||
|
@ -146,6 +155,7 @@ export default function (
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
user.csrfToken = session.csrfToken
|
user.csrfToken = session.csrfToken
|
||||||
|
loginMethod = LoginMethod.COOKIE
|
||||||
|
|
||||||
if (session?.lastAccessedAt < timeMinusOneMinute()) {
|
if (session?.lastAccessedAt < timeMinusOneMinute()) {
|
||||||
// make sure we denote that the session is still in use
|
// make sure we denote that the session is still in use
|
||||||
|
@ -170,17 +180,16 @@ export default function (
|
||||||
apiKey,
|
apiKey,
|
||||||
populateUser
|
populateUser
|
||||||
)
|
)
|
||||||
if (valid && foundUser) {
|
if (valid) {
|
||||||
authenticated = true
|
authenticated = true
|
||||||
|
loginMethod = LoginMethod.API_KEY
|
||||||
user = foundUser
|
user = foundUser
|
||||||
} else if (valid) {
|
internal = !foundUser
|
||||||
authenticated = true
|
|
||||||
internal = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!user && tenantId) {
|
if (!user && tenantId) {
|
||||||
user = { tenantId }
|
user = { tenantId }
|
||||||
} else if (user) {
|
} else if (user && "password" in user) {
|
||||||
delete user.password
|
delete user.password
|
||||||
}
|
}
|
||||||
// be explicit
|
// be explicit
|
||||||
|
@ -204,7 +213,14 @@ export default function (
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAuthenticated is a function, so use a variable to be able to check authed state
|
// isAuthenticated is a function, so use a variable to be able to check authed state
|
||||||
finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
|
finalise(ctx, {
|
||||||
|
authenticated,
|
||||||
|
user,
|
||||||
|
internal,
|
||||||
|
version,
|
||||||
|
publicEndpoint,
|
||||||
|
loginMethod,
|
||||||
|
})
|
||||||
|
|
||||||
if (isUser(user)) {
|
if (isUser(user)) {
|
||||||
return identity.doInUserContext(user, ctx, next)
|
return identity.doInUserContext(user, ctx, next)
|
||||||
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { User, Table, SearchFilters, Row } from "@budibase/types"
|
||||||
|
import { HttpMethod, MakeRequestResponse, generateMakeRequest } from "./utils"
|
||||||
|
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
|
||||||
|
import { Expectations } from "../../../../tests/utilities/api/base"
|
||||||
|
|
||||||
|
type RequestOpts = { internal?: boolean; appId?: string }
|
||||||
|
|
||||||
|
type PublicAPIExpectations = Omit<Expectations, "headers" | "headersNotPresent">
|
||||||
|
|
||||||
|
export class PublicAPIRequest {
|
||||||
|
private makeRequest: MakeRequestResponse | undefined
|
||||||
|
private appId: string | undefined
|
||||||
|
private _tables: PublicTableAPI | undefined
|
||||||
|
private _rows: PublicRowAPI | undefined
|
||||||
|
private _apiKey: string | undefined
|
||||||
|
|
||||||
|
async init(config: TestConfiguration, user: User, opts?: RequestOpts) {
|
||||||
|
this._apiKey = await config.generateApiKey(user._id)
|
||||||
|
this.makeRequest = generateMakeRequest(this.apiKey, opts)
|
||||||
|
this.appId = opts?.appId
|
||||||
|
this._tables = new PublicTableAPI(this)
|
||||||
|
this._rows = new PublicRowAPI(this)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
opts(opts: RequestOpts) {
|
||||||
|
if (opts.appId) {
|
||||||
|
this.appId = opts.appId
|
||||||
|
}
|
||||||
|
this.makeRequest = generateMakeRequest(this.apiKey, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(
|
||||||
|
method: HttpMethod,
|
||||||
|
endpoint: string,
|
||||||
|
body?: any,
|
||||||
|
expectations?: PublicAPIExpectations
|
||||||
|
) {
|
||||||
|
if (!this.makeRequest) {
|
||||||
|
throw new Error("Init has not been called")
|
||||||
|
}
|
||||||
|
const res = await this.makeRequest(method, endpoint, body, this.appId)
|
||||||
|
if (expectations?.status) {
|
||||||
|
expect(res.status).toEqual(expectations.status)
|
||||||
|
}
|
||||||
|
if (expectations?.body) {
|
||||||
|
expect(res.body).toEqual(expectations?.body)
|
||||||
|
}
|
||||||
|
return res.body
|
||||||
|
}
|
||||||
|
|
||||||
|
get apiKey(): string {
|
||||||
|
if (!this._apiKey) {
|
||||||
|
throw new Error("Init has not been called")
|
||||||
|
}
|
||||||
|
return this._apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
get tables(): PublicTableAPI {
|
||||||
|
if (!this._tables) {
|
||||||
|
throw new Error("Init has not been called")
|
||||||
|
}
|
||||||
|
return this._tables
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows(): PublicRowAPI {
|
||||||
|
if (!this._rows) {
|
||||||
|
throw new Error("Init has not been called")
|
||||||
|
}
|
||||||
|
return this._rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicTableAPI {
|
||||||
|
request: PublicAPIRequest
|
||||||
|
|
||||||
|
constructor(request: PublicAPIRequest) {
|
||||||
|
this.request = request
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
table: Table,
|
||||||
|
expectations?: PublicAPIExpectations
|
||||||
|
): Promise<{ data: Table }> {
|
||||||
|
return this.request.send("post", "/tables", table, expectations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicRowAPI {
|
||||||
|
request: PublicAPIRequest
|
||||||
|
|
||||||
|
constructor(request: PublicAPIRequest) {
|
||||||
|
this.request = request
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(
|
||||||
|
tableId: string,
|
||||||
|
query: SearchFilters,
|
||||||
|
expectations?: PublicAPIExpectations
|
||||||
|
): Promise<{ data: Row[] }> {
|
||||||
|
return this.request.send(
|
||||||
|
"post",
|
||||||
|
`/tables/${tableId}/rows/search`,
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
expectations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
const setup = require("../../tests/utilities")
|
import * as setup from "../../tests/utilities"
|
||||||
|
|
||||||
describe("/metrics", () => {
|
describe("/metrics", () => {
|
||||||
let request = setup.getRequest()
|
let request = setup.getRequest()
|
|
@ -0,0 +1,74 @@
|
||||||
|
import * as setup from "../../tests/utilities"
|
||||||
|
import { roles } from "@budibase/backend-core"
|
||||||
|
import { basicTable } from "../../../../tests/utilities/structures"
|
||||||
|
import { Table, User } from "@budibase/types"
|
||||||
|
import { PublicAPIRequest } from "./Request"
|
||||||
|
|
||||||
|
const BROWSER_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
describe("check public API security", () => {
|
||||||
|
const config = setup.getConfig()
|
||||||
|
let builderRequest: PublicAPIRequest,
|
||||||
|
appUserRequest: PublicAPIRequest,
|
||||||
|
table: Table,
|
||||||
|
appUser: User
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.init()
|
||||||
|
const builderUser = await config.globalUser()
|
||||||
|
appUser = await config.globalUser({
|
||||||
|
builder: { global: false },
|
||||||
|
roles: {
|
||||||
|
[config.getProdAppId()]: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
builderRequest = await new PublicAPIRequest().init(config, builderUser)
|
||||||
|
appUserRequest = await new PublicAPIRequest().init(config, appUser)
|
||||||
|
table = (await builderRequest.tables.create(basicTable())).data
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow with builder API key", async () => {
|
||||||
|
const res = await builderRequest.rows.search(
|
||||||
|
table._id!,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(res.data.length).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should 403 when from browser, but API key", async () => {
|
||||||
|
await appUserRequest.rows.search(
|
||||||
|
table._id!,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should re-direct when using cookie", async () => {
|
||||||
|
const headers = await config.login({
|
||||||
|
userId: appUser._id!,
|
||||||
|
builder: false,
|
||||||
|
prodApp: false,
|
||||||
|
})
|
||||||
|
await config.withHeaders(
|
||||||
|
{
|
||||||
|
...headers,
|
||||||
|
"User-Agent": BROWSER_USER_AGENT,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await config.api.row.search(
|
||||||
|
table._id!,
|
||||||
|
{ query: {} },
|
||||||
|
{
|
||||||
|
status: 302,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
|
@ -21,17 +21,19 @@ export type MakeRequestWithFormDataResponse = (
|
||||||
function base(
|
function base(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
intAppId: string | null,
|
opts?: {
|
||||||
isInternal: boolean
|
intAppId?: string
|
||||||
|
internal?: boolean
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
const extraHeaders: any = {
|
const extraHeaders: any = {
|
||||||
"x-budibase-api-key": apiKey,
|
"x-budibase-api-key": apiKey,
|
||||||
}
|
}
|
||||||
if (intAppId) {
|
if (opts?.intAppId) {
|
||||||
extraHeaders["x-budibase-app-id"] = intAppId
|
extraHeaders["x-budibase-app-id"] = opts.intAppId
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = isInternal
|
const url = opts?.internal
|
||||||
? endpoint
|
? endpoint
|
||||||
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
|
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
|
||||||
return { headers: extraHeaders, url }
|
return { headers: extraHeaders, url }
|
||||||
|
@ -39,7 +41,7 @@ function base(
|
||||||
|
|
||||||
export function generateMakeRequest(
|
export function generateMakeRequest(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
isInternal = false
|
opts?: { internal?: boolean }
|
||||||
): MakeRequestResponse {
|
): MakeRequestResponse {
|
||||||
const request = setup.getRequest()!
|
const request = setup.getRequest()!
|
||||||
const config = setup.getConfig()!
|
const config = setup.getConfig()!
|
||||||
|
@ -47,9 +49,12 @@ export function generateMakeRequest(
|
||||||
method: HttpMethod,
|
method: HttpMethod,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
body?: any,
|
body?: any,
|
||||||
intAppId: string | null = config.getAppId()
|
intAppId: string | undefined = config.getAppId()
|
||||||
) => {
|
) => {
|
||||||
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
|
const { headers, url } = base(apiKey, endpoint, { ...opts, intAppId })
|
||||||
|
if (body && typeof body !== "string") {
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
}
|
||||||
const req = request[method](url).set(config.defaultHeaders(headers))
|
const req = request[method](url).set(config.defaultHeaders(headers))
|
||||||
if (body) {
|
if (body) {
|
||||||
req.send(body)
|
req.send(body)
|
||||||
|
@ -62,7 +67,7 @@ export function generateMakeRequest(
|
||||||
|
|
||||||
export function generateMakeRequestWithFormData(
|
export function generateMakeRequestWithFormData(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
isInternal = false
|
opts?: { internal?: boolean; browser?: boolean }
|
||||||
): MakeRequestWithFormDataResponse {
|
): MakeRequestWithFormDataResponse {
|
||||||
const request = setup.getRequest()!
|
const request = setup.getRequest()!
|
||||||
const config = setup.getConfig()!
|
const config = setup.getConfig()!
|
||||||
|
@ -70,9 +75,9 @@ export function generateMakeRequestWithFormData(
|
||||||
method: HttpMethod,
|
method: HttpMethod,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
fields: Record<string, string | { path: string }>,
|
fields: Record<string, string | { path: string }>,
|
||||||
intAppId: string | null = config.getAppId()
|
intAppId: string | undefined = config.getAppId()
|
||||||
) => {
|
) => {
|
||||||
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
|
const { headers, url } = base(apiKey, endpoint, { ...opts, intAppId })
|
||||||
const req = request[method](url).set(config.defaultHeaders(headers))
|
const req = request[method](url).set(config.defaultHeaders(headers))
|
||||||
for (let [field, value] of Object.entries(fields)) {
|
for (let [field, value] of Object.entries(fields)) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
import { generateUserMetadataID, isDevAppID } from "../db/utils"
|
import { generateUserMetadataID, isDevAppID } from "../db/utils"
|
||||||
import { getCachedSelf } from "../utilities/global"
|
import { getCachedSelf } from "../utilities/global"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { isWebhookEndpoint } from "./utils"
|
import { isWebhookEndpoint, isBrowser, isApiKey } from "./utils"
|
||||||
import { UserCtx, ContextUser } from "@budibase/types"
|
import { UserCtx, ContextUser } from "@budibase/types"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ export default async (ctx: UserCtx, next: any) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// deny access to application preview
|
// deny access to application preview
|
||||||
if (!env.isTest()) {
|
if (isBrowser(ctx) && !isApiKey(ctx)) {
|
||||||
if (
|
if (
|
||||||
isDevAppID(requestAppId) &&
|
isDevAppID(requestAppId) &&
|
||||||
!isWebhookEndpoint(ctx) &&
|
!isWebhookEndpoint(ctx) &&
|
||||||
|
|
|
@ -1,9 +1,18 @@
|
||||||
import { BBContext } from "@budibase/types"
|
import { LoginMethod, UserCtx } from "@budibase/types"
|
||||||
|
|
||||||
const WEBHOOK_ENDPOINTS = new RegExp(
|
const WEBHOOK_ENDPOINTS = new RegExp(
|
||||||
["webhooks/trigger", "webhooks/schema"].join("|")
|
["webhooks/trigger", "webhooks/schema"].join("|")
|
||||||
)
|
)
|
||||||
|
|
||||||
export function isWebhookEndpoint(ctx: BBContext) {
|
export function isWebhookEndpoint(ctx: UserCtx) {
|
||||||
return WEBHOOK_ENDPOINTS.test(ctx.request.url)
|
return WEBHOOK_ENDPOINTS.test(ctx.request.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isBrowser(ctx: UserCtx) {
|
||||||
|
const browser = ctx.userAgent.browser
|
||||||
|
return browser !== "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiKey(ctx: UserCtx) {
|
||||||
|
return ctx.loginMethod === LoginMethod.API_KEY
|
||||||
|
}
|
||||||
|
|
|
@ -19,7 +19,8 @@
|
||||||
"@types/koa": "2.13.4",
|
"@types/koa": "2.13.4",
|
||||||
"@types/redlock": "4.0.7",
|
"@types/redlock": "4.0.7",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"typescript": "5.5.2"
|
"typescript": "5.5.2",
|
||||||
|
"koa-useragent": "^4.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scim-patch": "^0.8.1"
|
"scim-patch": "^0.8.1"
|
||||||
|
|
|
@ -2,6 +2,12 @@ import { Context, Request } from "koa"
|
||||||
import { User, Role, UserRoles, Account, ConfigType } from "../documents"
|
import { User, Role, UserRoles, Account, ConfigType } from "../documents"
|
||||||
import { FeatureFlag, License } from "../sdk"
|
import { FeatureFlag, License } from "../sdk"
|
||||||
import { Files } from "formidable"
|
import { Files } from "formidable"
|
||||||
|
import { UserAgentContext } from "koa-useragent"
|
||||||
|
|
||||||
|
export enum LoginMethod {
|
||||||
|
API_KEY = "api_key",
|
||||||
|
COOKIE = "cookie",
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContextUser extends Omit<User, "roles"> {
|
export interface ContextUser extends Omit<User, "roles"> {
|
||||||
globalId?: string
|
globalId?: string
|
||||||
|
@ -31,6 +37,7 @@ export interface BBRequest<RequestBody> extends Request {
|
||||||
export interface Ctx<RequestBody = any, ResponseBody = any> extends Context {
|
export interface Ctx<RequestBody = any, ResponseBody = any> extends Context {
|
||||||
request: BBRequest<RequestBody>
|
request: BBRequest<RequestBody>
|
||||||
body: ResponseBody
|
body: ResponseBody
|
||||||
|
userAgent: UserAgentContext["userAgent"]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -40,6 +47,7 @@ export interface UserCtx<RequestBody = any, ResponseBody = any>
|
||||||
extends Ctx<RequestBody, ResponseBody> {
|
extends Ctx<RequestBody, ResponseBody> {
|
||||||
user: ContextUser
|
user: ContextUser
|
||||||
roleId?: string
|
roleId?: string
|
||||||
|
loginMethod?: LoginMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue