diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 851d9c4ff..027f527fc 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -252,6 +252,31 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/me/sessions/:sessionId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sessionId: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.authToken.revokeMySessionById(req.permission.id, req.params.sessionId); + return { + message: "Successfully revoked session" + }; + } + }); + server.route({ method: "GET", url: "/me", diff --git a/backend/src/services/auth-token/auth-token-dal.ts b/backend/src/services/auth-token/auth-token-dal.ts index 221b691cf..ca7841d8e 100644 --- a/backend/src/services/auth-token/auth-token-dal.ts +++ b/backend/src/services/auth-token/auth-token-dal.ts @@ -47,7 +47,10 @@ export const tokenDALFactory = (db: TDbClient) => { const findTokenSessions = async (filter: Partial, tx?: Knex) => { try { - const sessions = await (tx || db.replicaNode())(TableName.AuthTokenSession).where(filter); + const sessions = await (tx || db.replicaNode())(TableName.AuthTokenSession) + .where(filter) + .orderBy("lastUsed", "desc"); + return sessions; } catch (error) { throw new DatabaseError({ name: "Find all token session", error }); diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 2468e3c8a..f26464340 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -151,6 +151,9 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu const revokeAllMySessions = async (userId: string) => tokenDAL.deleteTokenSession({ userId }); + const revokeMySessionById = async (userId: string, sessionId: string) => + tokenDAL.deleteTokenSession({ userId, id: sessionId }); + const validateRefreshToken = async (refreshToken?: string) => { const appCfg = getConfig(); if (!refreshToken) @@ -223,6 +226,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu clearTokenSessionById, getTokenSessionByUser, revokeAllMySessions, + revokeMySessionById, validateRefreshToken, fnValidateJwtIdentity, getUserTokenSessionById diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index d3e6096a9..2879e4ccb 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -1,9 +1,12 @@ package cmd import ( + "encoding/base64" + "encoding/json" "errors" "fmt" "net/url" + "strings" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" @@ -85,6 +88,57 @@ var switchCmd = &cobra.Command{ }, } +var userGetCmd = &cobra.Command{ + Use: "get", + Short: "Used to get properties of an Infisical profile", + DisableFlagsInUseLine: true, + Example: "infisical user get", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var userGetTokenCmd = &cobra.Command{ + Use: "token", + Short: "Used to get the access token of an Infisical user", + DisableFlagsInUseLine: true, + Example: "infisical user get token", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + if err != nil { + util.HandleError(err, "[infisical user get token]: Unable to get logged in user token") + } + + tokenParts := strings.Split(loggedInUserDetails.UserCredentials.JTWToken, ".") + if len(tokenParts) != 3 { + util.HandleError(errors.New("invalid token format"), "[infisical user get token]: Invalid token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(tokenParts[1]) + if err != nil { + util.HandleError(err, "[infisical user get token]: Unable to decode token payload") + } + + var tokenPayload struct { + TokenVersionId string `json:"tokenVersionId"` + } + if err := json.Unmarshal(payload, &tokenPayload); err != nil { + util.HandleError(err, "[infisical user get token]: Unable to parse token payload") + } + + fmt.Println("Session ID:", tokenPayload.TokenVersionId) + fmt.Println("Token:", loggedInUserDetails.UserCredentials.JTWToken) + }, +} + var updateCmd = &cobra.Command{ Use: "update", Short: "Used to update properties of an Infisical profile", @@ -185,6 +239,8 @@ var domainCmd = &cobra.Command{ func init() { updateCmd.AddCommand(domainCmd) userCmd.AddCommand(updateCmd) + userGetCmd.AddCommand(userGetTokenCmd) + userCmd.AddCommand(userGetCmd) userCmd.AddCommand(switchCmd) rootCmd.AddCommand(userCmd) } diff --git a/docs/cli/commands/user.mdx b/docs/cli/commands/user.mdx index 38a92bbab..43a6111e1 100644 --- a/docs/cli/commands/user.mdx +++ b/docs/cli/commands/user.mdx @@ -8,22 +8,46 @@ infisical user ``` ## Description + This command allows you to manage the current logged in users on the CLI -### Sub-commands - - Use this command to switch between profiles that are currently logged into the CLI +### Sub-commands + + + Use this command to switch between profiles that are currently logged into the CLI + +```bash +infisical user switch +``` - ```bash - infisical user switch - ``` With this command, you can modify the backend API that is utilized for all requests associated with a specific profile. For instance, you have the option to point the profile to use either the Infisical Cloud or your own self-hosted Infisical instance. - ```bash - infisical user update domain +```bash +infisical user update domain +``` + + + + + Use this command to get your current Infisical access token and session information. This command requires you to be logged in. + + The command will display: + + - Your session ID + - Your full JWT access token + + ```bash + infisical user get token + ``` + + Example output: + + ```bash + Session ID: abc123-xyz-456 + Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index b9d9f159b..0774275f9 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,6 +1,7 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useRevokeMySessionById, useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "./mutation"; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index c5cf6c27b..1b873b31c 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -171,3 +171,16 @@ export const useResendOrgMemberInvitation = () => { } }); }; + +export const useRevokeMySessionById = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (sessionId: string) => { + const { data } = await apiRequest.delete(`/api/v2/users/me/sessions/${sessionId}`); + return data; + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: userKeys.mySessions }); + } + }); +}; diff --git a/frontend/src/lib/fn/string.ts b/frontend/src/lib/fn/string.ts index f4c4a43db..6b2842701 100644 --- a/frontend/src/lib/fn/string.ts +++ b/frontend/src/lib/fn/string.ts @@ -11,3 +11,65 @@ export const formatReservedPaths = (secretPath: string) => { export const camelCaseToSpaces = (input: string) => { return input.replace(/([a-z])([A-Z])/g, "$1 $2"); }; + +export const formatSessionUserAgent = (userAgent: string) => { + const result = { + os: "Unknown", + browser: "Unknown", + device: "Desktop" + }; + + // Operating System detection + if (userAgent.includes("Windows")) { + result.os = "Windows"; + } else if ( + userAgent.includes("Mac OS") || + userAgent.includes("Macintosh") || + userAgent.includes("macOS") + ) { + result.os = "macOS"; + } else if (userAgent.includes("Linux") && !userAgent.includes("Android")) { + result.os = "Linux"; + } else if (userAgent.includes("Android")) { + result.os = "Android"; + result.device = "Mobile"; + } else if ( + userAgent.includes("iOS") || + userAgent.includes("iPhone") || + userAgent.includes("iPad") + ) { + result.os = "iOS"; + result.device = userAgent.includes("iPad") ? "Tablet" : "Mobile"; + } + + // Browser detection + if (userAgent.includes("Firefox/")) { + result.browser = "Firefox"; + } else if (userAgent.includes("Edge/") || userAgent.includes("Edg/")) { + result.browser = "Edge"; + } else if (userAgent.includes("Brave/") || userAgent.includes("Brave ")) { + result.browser = "Brave"; + } else if ( + userAgent.includes("Chrome/") && + !userAgent.includes("Chromium/") && + !userAgent.includes("Edg/") + ) { + result.browser = "Chrome"; + } else if ( + userAgent.includes("Safari/") && + !userAgent.includes("Chrome/") && + !userAgent.includes("Chromium/") + ) { + result.browser = "Safari"; + } else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) { + result.browser = "Opera"; + } else if (userAgent.includes("Trident/") || userAgent.includes("MSIE")) { + result.browser = "Internet Explorer"; + } + + if (userAgent.toLowerCase() === "cli") { + result.browser = "CLI"; + } + + return result; +}; diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx index 9a24b1e27..8d10be318 100644 --- a/frontend/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx +++ b/frontend/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx @@ -1,6 +1,8 @@ import { faServer } from "@fortawesome/free-solid-svg-icons"; +import { createNotification } from "@app/components/notifications"; import { + DeleteActionModal, EmptyState, Table, TableContainer, @@ -9,59 +11,131 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; -import { useGetMySessions } from "@app/hooks/api"; +import { Button } from "@app/components/v2/Button"; +import { useGetMySessions, useRevokeMySessionById } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { timeAgo } from "@app/lib/fn/date"; +import { formatSessionUserAgent } from "@app/lib/fn/string"; + +const formatLocalDateTime = (date: Date): string => { + return date.toLocaleString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); +}; export const SessionsTable = () => { const { data, isPending } = useGetMySessions(); + const { mutateAsync: revokeMySessionById } = useRevokeMySessionById(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "deleteSession" + ] as const); - const formatDate = (dateToFormat: string) => { - const date = new Date(dateToFormat); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); + const handleSignOut = async (sessionId: string) => { + try { + await revokeMySessionById(sessionId); + createNotification({ + text: "Session revoked successfully", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to revoke session", + type: "error" + }); + } - const formattedDate = `${day}/${month}/${year}`; - - return formattedDate; + handlePopUpClose("deleteSession"); }; return ( - - - - - - - - - - - - {isPending && } - {!isPending && - data && - data.length > 0 && - data.map(({ id, createdAt, lastUsed, ip, userAgent }) => { - return ( - - - - - - - ); - })} - {!isPending && data && data?.length === 0 && ( + <> + handlePopUpToggle("deleteSession", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + handleSignOut((popUp?.deleteSession?.data as { sessionId: string })?.sessionId) + } + /> + +
CreatedLast activeIP addressDevice
{formatDate(createdAt)}{formatDate(lastUsed)}{ip}{userAgent}
+ - + + + + - )} - -
- - IP & Session IDOS & BrowserLast accessedManage
-
+ + + {isPending && } + {!isPending && + data && + data.length > 0 && + data.map(({ id, createdAt, lastUsed, ip, userAgent }) => { + const { os, browser } = formatSessionUserAgent(userAgent); + const lastUsedDate = new Date(lastUsed); + const createdAtDate = new Date(createdAt); + + return ( + + +
+ {ip} + ID: {id} +
+ + +
+ {os} + {browser} +
+ + +
+ + {timeAgo(lastUsedDate, new Date())} + + + + Created {timeAgo(createdAtDate, new Date())} + + +
+ + + + + + ); + })} + {!isPending && data && data?.length === 0 && ( + + + + + + )} + + + + ); };