Skip to content

Commit

Permalink
Add mfa support to user login (#649)
Browse files Browse the repository at this point in the history
* Adding 2fa keys

* Working 2fa login

* Jump on single device

* Test fix

* Backend tests

* Add tests

* Fix code style issues with ESLint

---------

Co-authored-by: Lint Action <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
edlouth and github-actions[bot] authored Sep 18, 2023
1 parent 3f9759e commit 83d8765
Show file tree
Hide file tree
Showing 37 changed files with 2,191 additions and 34 deletions.
24 changes: 24 additions & 0 deletions grai-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions grai-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"react-helmet-async": "^1.3.0",
"react-instantsearch-hooks-web": "^6.41.0",
"react-json-view-lite": "^0.9.5",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.0.0",
"react-scripts": "5.0.1",
"react-shepherd": "^4.2.0",
Expand Down
2 changes: 2 additions & 0 deletions grai-frontend/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const FilterCreate = lazy(() => import("./pages/filters/FilterCreate"))

const Settings = lazy(() => import("./pages/settings/Settings"))
const ProfileSettings = lazy(() => import("./pages/settings/ProfileSettings"))
const TwoFactor = lazy(() => import("./pages/settings/TwoFactor"))
const PasswordSettings = lazy(() => import("./pages/settings/PasswordSettings"))
const ApiKeys = lazy(() => import("./pages/settings/ApiKeys"))
const WorkspaceSettings = lazy(
Expand Down Expand Up @@ -152,6 +153,7 @@ const Routes: React.FC = () => (
>
<Route index element={<Settings />} />
<Route path="profile" element={<ProfileSettings />} />
<Route path="2fa" element={<TwoFactor />} />
<Route path="password" element={<PasswordSettings />} />
<Route path="api-keys" element={<ApiKeys />} />
<Route path="workspace" element={<WorkspaceSettings />} />
Expand Down
107 changes: 99 additions & 8 deletions grai-frontend/src/components/auth/login/LoginForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,52 @@ import { GraphQLError } from "graphql"
import { act, render, screen, waitFor } from "testing"
import LoginForm, { LOGIN } from "./LoginForm"

const onDeviceRequest = jest.fn()

const defaultProps = {
onDeviceRequest,
}

test("submit", async () => {
const user = userEvent.setup()

render(<LoginForm />, {
const mocks = [
{
request: {
query: LOGIN,
variables: {
username: "email@grai.io",
password: "password",
},
},
result: {
data: {
login: {
id: "1",
username: "",
first_name: "",
last_name: "",
__typename: "User",
},
},
},
},
]

render(<LoginForm {...defaultProps} />, {
guestRoute: true,
loggedIn: false,
path: "/login",
route: "/login",
routes: ["/"],
mocks,
})

await act(
async () => await user.type(screen.getByTestId("email"), "email@grai.io")
async () => await user.type(screen.getByTestId("email"), "email@grai.io"),
)
await act(
async () => await user.type(screen.getByTestId("password"), "password")
async () => await user.type(screen.getByTestId("password"), "password"),
)

await waitFor(() => {
Expand All @@ -28,14 +58,75 @@ test("submit", async () => {

await act(
async () =>
await user.click(screen.getByRole("button", { name: /log in/i }))
await user.click(screen.getByRole("button", { name: /log in/i })),
)

await waitFor(() => {
expect(screen.getByText("New Page")).toBeInTheDocument()
})
})

test("submit required 2fa", async () => {
const user = userEvent.setup()

const mocks = [
{
request: {
query: LOGIN,
variables: {
username: "email@grai.io",
password: "password",
},
},
result: {
data: {
login: {
data: [
{
id: "1",
name: "",
__typename: "DeviceData",
},
],
__typename: "DeviceDataWrapper",
},
},
},
},
]

render(<LoginForm {...defaultProps} />, {
guestRoute: true,
loggedIn: false,
path: "/login",
route: "/login",
routes: ["/"],
mocks,
})

await act(
async () => await user.type(screen.getByTestId("email"), "email@grai.io"),
)
await act(
async () => await user.type(screen.getByTestId("password"), "password"),
)

await waitFor(() => {
expect(screen.getByTestId("password")).toHaveValue("password")
})

await act(
async () =>
await user.click(screen.getByRole("button", { name: /log in/i })),
)

expect(onDeviceRequest).toHaveBeenCalledWith({
devices: [{ __typename: "DeviceData", id: "1", name: "" }],
password: "password",
username: "email@grai.io",
})
})

test("error", async () => {
const user = userEvent.setup()

Expand All @@ -54,16 +145,16 @@ test("error", async () => {
},
]

render(<LoginForm />, {
render(<LoginForm {...defaultProps} />, {
withRouter: true,
mocks,
})

await act(
async () => await user.type(screen.getByTestId("email"), "email@grai.io")
async () => await user.type(screen.getByTestId("email"), "email@grai.io"),
)
await act(
async () => await user.type(screen.getByTestId("password"), "password")
async () => await user.type(screen.getByTestId("password"), "password"),
)

await waitFor(() => {
Expand All @@ -72,7 +163,7 @@ test("error", async () => {

await act(
async () =>
await user.click(screen.getByRole("button", { name: /log in/i }))
await user.click(screen.getByRole("button", { name: /log in/i })),
)

await waitFor(() => {
Expand Down
40 changes: 33 additions & 7 deletions grai-frontend/src/components/auth/login/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,24 @@ import { Link as RouterLink } from "react-router-dom"
import Form from "components/form/Form"
import GraphError from "components/utils/GraphError"
import { Login, LoginVariables } from "./__generated__/Login"
import { DeviceRequest } from "./LoginWrapper"
import useAuth from "../useAuth"

export const LOGIN = gql`
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
id
username
first_name
last_name
... on User {
id
username
first_name
last_name
}
... on DeviceDataWrapper {
data {
id
name
}
}
}
}
`
Expand All @@ -32,7 +41,11 @@ type Values = {
password: string
}

const LoginForm: React.FC = () => {
type LoginFormProps = {
onDeviceRequest: (request: DeviceRequest) => void
}

const LoginForm: React.FC<LoginFormProps> = ({ onDeviceRequest }) => {
const { setLoggedIn } = useAuth()
const [values, setValues] = useState<Values>({
username: "",
Expand All @@ -44,8 +57,21 @@ const LoginForm: React.FC = () => {
const handleSubmit = () =>
login({ variables: values })
.then(data => data.data?.login)
.then(user => user && posthog.identify(user.id, { email: user.username }))
.then(() => setLoggedIn(true))
.then(res => {
if (!res) return

if (res.__typename === "User") {
posthog.identify(res.id, { email: res.username })
setLoggedIn(true)
return
}

onDeviceRequest({
devices: res.data,
username: values.username,
password: values.password,
})
})
.catch(() => {})

return (
Expand Down
71 changes: 71 additions & 0 deletions grai-frontend/src/components/auth/login/LoginWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import userEvent from "@testing-library/user-event"
import { act, render, screen, waitFor } from "testing"
import { LOGIN } from "./LoginForm"
import LoginWrapper from "./LoginWrapper"

test("submit required 2fa", async () => {
const user = userEvent.setup()

const mocks = [
{
request: {
query: LOGIN,
variables: {
username: "email@grai.io",
password: "password",
},
},
result: {
data: {
login: {
data: [
{
id: "1",
name: "",
__typename: "DeviceData",
},
],
__typename: "DeviceDataWrapper",
},
},
},
},
]

render(<LoginWrapper />, {
guestRoute: true,
loggedIn: false,
path: "/login",
route: "/login",
routes: ["/"],
mocks,
})

await act(
async () => await user.type(screen.getByTestId("email"), "email@grai.io"),
)
await act(
async () => await user.type(screen.getByTestId("password"), "password"),
)

await waitFor(() => {
expect(screen.getByTestId("password")).toHaveValue("password")
})

await act(
async () =>
await user.click(screen.getByRole("button", { name: /log in/i })),
)

await waitFor(() => {
expect(screen.getByText("Two Factor Authentication")).toBeInTheDocument()
})

await act(
async () => await user.click(screen.getByRole("button", { name: /back/i })),
)

await act(
async () => await user.click(screen.getByRole("button", { name: /back/i })),
)
})
Loading

0 comments on commit 83d8765

Please sign in to comment.