Skip to content

Commit

Permalink
Merge pull request #2394 from FarmBot/staging
Browse files Browse the repository at this point in the history
v15.4.14
  • Loading branch information
gabrielburnworth authored Dec 2, 2022
2 parents 8138a5c + ea537c4 commit 9a63921
Show file tree
Hide file tree
Showing 16 changed files with 124 additions and 44 deletions.
12 changes: 11 additions & 1 deletion app/controllers/api/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def create
# to log in with an unverified account (500 error).
# Still not sure what changed or why, but this is a
# temporary hotfix. Can be removed later if users
# are able to attempt logins on unverfied accounts.
# are able to attempt logins on unverified accounts.
email = params.dig("user", "email")
if needs_validation?(email)
raise Errors::Forbidden, SessionToken::MUST_VERIFY
Expand All @@ -35,6 +35,16 @@ def create
end
end

def destroy
token = SessionToken.decode!(request.headers["Authorization"].split(" ").last)
claims = token.unencoded
device_id = claims["bot"].gsub("device_", "").to_i
TokenIssuance
.where("exp > ?", Time.now.to_i)
.find_by!(jti: claims["jti"], device_id: device_id)
.destroy!
end

private

def needs_validation?(email)
Expand Down
2 changes: 0 additions & 2 deletions app/lib/session_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ class SessionToken < AbstractJwtToken
MQTT_WS = ENV["MQTT_WS"] || DEFAULT_MQTT_WS
EXPIRY = 60.days
VHOST = ENV.fetch("MQTT_VHOST") { "/" }
DEFAULT_OS = "https://api.github.com/repos/farmbot/farmbot_os/releases" +
"/latest"

def self.issue_to(user,
iat: Time.now.to_i,
Expand Down
2 changes: 0 additions & 2 deletions app/mutations/auth/from_jwt.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
module Auth
# The API supports a number of authentication strategies (Cookies, Bot token,
# JWT). This service helps determine which auth strategy to use.
class FromJwt < Mutations::Command
required { string :jwt }

Expand Down
2 changes: 0 additions & 2 deletions app/mutations/auth/reload_token.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
module Auth
# The API supports a number of authentication strategies (Cookies, Bot token,
# JWT). This service helps determine which auth strategy to use.
class ReloadToken < Mutations::Command
attr_reader :user
BAD_SUB = "Please log out and try again."
Expand Down
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
fbos_config: [:destroy, :show, :update],
firmware_config: [:destroy, :show, :update],
public_key: [:show],
tokens: [:create, :show],
tokens: [:create, :destroy, :show],
web_app_config: [:destroy, :show, :update],
}.to_a.map { |(name, only)| resource name, only: only }
get "/corpus" => "corpuses#show", as: :api_corpus
Expand Down
24 changes: 24 additions & 0 deletions frontend/__tests__/logout_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
jest.mock("../session", () => ({ Session: { clear: jest.fn() } }));

jest.mock("axios", () => ({ delete: jest.fn(() => Promise.resolve()) }));

import axios from "axios";
import { API } from "../api";
import { logout } from "../logout";
import { Session } from "../session";

API.setBaseUrl("");

describe("logout()", () => {
it("logs out", () => {
logout()();
expect(Session.clear).toHaveBeenCalled();
expect(axios.delete).toHaveBeenCalledWith("http://localhost/api/tokens/");
});

it("keeps token", () => {
logout(true)();
expect(Session.clear).toHaveBeenCalled();
expect(axios.delete).not.toHaveBeenCalled();
});
});
11 changes: 11 additions & 0 deletions frontend/css/sequences.scss
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
.bp4-popover-wrapper {
display: inline;
}
.bp4-button-text,
p {
display: inline-block;
width: max-content;
Expand Down Expand Up @@ -202,8 +203,18 @@
font-size: 1rem !important;
}
.fa-caret-down {
right: 0;
line-height: 2.25rem;
color: $white;
}
.bp4-button {
padding-left: 0.5rem;
}
.bp4-button-text {
margin-left: 0;
padding-left: 0;
line-height: 1.5rem;
}
}
&.selected {
border-bottom: 3px solid $dark_gray;
Expand Down
9 changes: 9 additions & 0 deletions frontend/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import axios from "axios";
import { noop } from "lodash";
import { API } from "./api";
import { Session } from "./session";

export const logout = (keepToken = false) => () => {
!keepToken && axios.delete(API.current.tokensPath).then(noop, noop);
Session.clear();
};
3 changes: 3 additions & 0 deletions frontend/messages/__tests__/cards_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import { push } from "../../history";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { fakeWizardStepResult } from "../../__test_support__/fake_state/resources";
import { Path } from "../../internal_urls";
import { API } from "../../api";

API.setBaseUrl("");

describe("<AlertCard />", () => {
const fakeProps = (): AlertCardProps => ({
Expand Down
6 changes: 3 additions & 3 deletions frontend/messages/cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ import {
import { updateConfig } from "../devices/actions";
import { fetchBulletinContent, seedAccount } from "./actions";
import { startCase } from "lodash";
import { Session } from "../session";
import { ExternalUrl } from "../external_urls";
import { setupProgressString } from "../wizard/data";
import { store } from "../redux/store";
import { selectAllWizardStepResults } from "../resources/selectors_by_kind";
import { push } from "../history";
import moment from "moment";
import { Path } from "../internal_urls";
import { logout } from "../logout";

export const AlertCard = (props: AlertCardProps) => {
const { alert, timeSettings, findApiAlertById, dispatch } = props;
Expand Down Expand Up @@ -398,14 +398,14 @@ const DemoAccount = (props: CommonAlertCardProps) =>
<p>
{t(Content.MAKE_A_REAL_ACCOUNT)}&nbsp;
<a href={ExternalUrl.myFarmBot} target="_blank" rel={"noreferrer"}
onClick={() => Session.clear()}
onClick={logout()}
title={"my.farm.bot"}>
{"my.farm.bot"}
</a>.
</p>
<a className="link-button fb-button green"
href={ExternalUrl.myFarmBot} target="_blank" rel={"noreferrer"}
onClick={() => Session.clear()}
onClick={logout()}
title={t("Make a real account")}>
{t("Make a real account")}
</a>
Expand Down
39 changes: 21 additions & 18 deletions frontend/nav/__tests__/additional_menu_test.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,43 @@
import React from "react";
import { mount, shallow } from "enzyme";
import { AdditionalMenu } from "../additional_menu";
import { AccountMenuProps } from "../interfaces";

describe("AdditionalMenu", () => {
const fakeProps = (): AccountMenuProps => ({
isStaff: false,
close: jest.fn(),
});

it("renders the account menu", () => {
const wrapper = mount(<AdditionalMenu
logout={jest.fn()}
close={jest.fn()} />);
const wrapper = mount(<AdditionalMenu {...fakeProps()} />);
const text = wrapper.text();
expect(text).toContain("Account Settings");
expect(text).toContain("Logout");
expect(text).toContain("VERSION");
expect(text).not.toContain("destroy token");
});

it("renders the account menu as staff", () => {
const p = fakeProps();
p.isStaff = true;
const wrapper = mount(<AdditionalMenu {...p} />);
const text = wrapper.text();
expect(text).toContain("Account Settings");
expect(text).toContain("destroy token");
});

it("closes the account menu upon nav", () => {
const close = jest.fn();
const wrapper = shallow(<AdditionalMenu
logout={jest.fn()}
close={(x) => () => close(x)} />);
const p = fakeProps();
p.close = x => () => close(x);
const wrapper = shallow(<AdditionalMenu {...p} />);
wrapper.find("Link").first().simulate("click");
expect(close).toHaveBeenCalledWith("accountMenuOpen");
});

it("logs out", () => {
const logout = jest.fn();
const wrapper = shallow(<AdditionalMenu
logout={logout}
close={jest.fn()} />);
wrapper.find("a").at(0).simulate("click");
expect(logout).toHaveBeenCalled();
});

it("navigates to help page", () => {
const wrapper = shallow(<AdditionalMenu
logout={jest.fn()}
close={jest.fn()} />);
const wrapper = shallow(<AdditionalMenu {...fakeProps()} />);
wrapper.find("Link").at(3).simulate("click");
});
});
9 changes: 0 additions & 9 deletions frontend/nav/__tests__/index_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ jest.mock("../../devices/timezones/guess_timezone", () => ({
maybeSetTimezone: jest.fn()
}));

jest.mock("../../session", () => ({ Session: { clear: jest.fn() } }));

jest.mock("../../api/crud", () => ({ refresh: jest.fn() }));

import React from "react";
Expand All @@ -16,7 +14,6 @@ import { maybeSetTimezone } from "../../devices/timezones/guess_timezone";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
import { fakePings } from "../../__test_support__/fake_state/pings";
import { Link } from "../../link";
import { Session } from "../../session";
import { refresh } from "../../api/crud";
import { push } from "../../history";
import { fakeHelpState } from "../../__test_support__/fake_designer_state";
Expand Down Expand Up @@ -75,12 +72,6 @@ describe("<NavBar />", () => {
expect(wrapper.text().toLowerCase()).toContain("menu");
});

it("logs out", () => {
const wrapper = mount<NavBar>(<NavBar {...fakeProps()} />);
wrapper.instance().logout();
expect(Session.clear).toHaveBeenCalled();
});

it("toggles state value", () => {
const wrapper = shallow<NavBar>(<NavBar {...fakeProps()} />);
expect(wrapper.state().mobileMenuOpen).toEqual(false);
Expand Down
9 changes: 8 additions & 1 deletion frontend/nav/additional_menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { shortRevision } from "../util";
import { t } from "../i18next_wrapper";
import { ExternalUrl } from "../external_urls";
import { FilePath, Icon, Path } from "../internal_urls";
import { logout } from "../logout";

export const AdditionalMenu = (props: AccountMenuProps) => {
return <div className="nav-additional-menu">
Expand Down Expand Up @@ -34,11 +35,17 @@ export const AdditionalMenu = (props: AccountMenuProps) => {
</Link>
</div>
<div className={"logout-link"}>
<a onClick={props.logout} title={t("logout")}>
<a onClick={logout(props.isStaff)} title={t("logout")}>
<img width={12} height={12} src={FilePath.icon(Icon.logout)} />
{t("Logout")}
</a>
</div>
{props.isStaff && <div className={"logout-link"}>
<a onClick={logout()} title={t("logout")}>
<img width={12} height={12} src={FilePath.icon(Icon.logout)} />
{t("Logout and destroy token")}
</a>
</div>}
<div className="app-version">
<label>{t("APP VERSION")}</label>:&nbsp;
<a href={ExternalUrl.webAppRepo} target="_blank" rel={"noreferrer"}>
Expand Down
7 changes: 3 additions & 4 deletions frontend/nav/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from "react";
import { NavBarProps, NavBarState } from "./interfaces";
import { EStopButton } from "./e_stop_btn";
import { Session } from "../session";
import { Row, Col, Popover } from "../ui";
import { push } from "../history";
import { updatePageInfo } from "../util";
Expand Down Expand Up @@ -49,7 +48,7 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
}
};

logout = () => Session.clear();
get isStaff() { return this.props.authAud == "staff"; }

toggle = (key: keyof NavBarState) => () =>
this.setState({ [key]: !this.state[key] });
Expand Down Expand Up @@ -89,7 +88,7 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
onClick={this.toggle("accountMenuOpen")}>
{firstName}
</div>}
content={<AdditionalMenu logout={this.logout} close={this.close} />} />
content={<AdditionalMenu close={this.close} isStaff={this.isStaff} />} />
</div>;
};

Expand Down Expand Up @@ -199,7 +198,7 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
return <ErrorBoundary>
<div className={[
"nav-wrapper",
this.props.authAud == "staff" ? "red" : "",
this.isStaff ? "red" : "",
].join(" ")}>
<nav role="navigation">
<Row>
Expand Down
2 changes: 1 addition & 1 deletion frontend/nav/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export interface NavLinksProps {
}

export interface AccountMenuProps {
isStaff: boolean;
close: (property: keyof NavBarState) => ToggleEventHandler;
logout: () => void;
}

export interface EStopButtonProps {
Expand Down
29 changes: 29 additions & 0 deletions spec/controllers/api/tokens/tokens_controller_destroy_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require "spec_helper"

describe Api::TokensController do
include Devise::Test::ControllerHelpers

describe "#destroy" do
let(:user) { FactoryBot.create(:user, password: "password") }
let(:device) { FactoryBot.create(:device) }
let(:auth_token) do
SessionToken.issue_to(user,
fbos_version: Gem::Version.new("9.9.9"),
iat: Time.now.to_i,
exp: 40.days.from_now.to_i)
end

it "destroys a token" do
user.device = device
request.headers["Authorization"] = "bearer #{auth_token.encoded}"
delete :destroy
expect(response.status).to eq(204)
end

it "denies bad tokens" do
request.headers["Authorization"] = "bearer nope"
delete :destroy
expect(response.status).to eq(401)
end
end
end

0 comments on commit 9a63921

Please sign in to comment.