Skip to content

Commit

Permalink
Merge pull request #243 from getAlby/feat/unlock-screen
Browse files Browse the repository at this point in the history
Feat: unlock screen (WIP)
  • Loading branch information
rolznz authored Feb 1, 2024
2 parents dcf6174 + 557e9d2 commit 34cbd94
Show file tree
Hide file tree
Showing 13 changed files with 214 additions and 15 deletions.
2 changes: 2 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ func (api *API) Start(startRequest *models.StartRequest) error {
}

func (api *API) Setup(setupRequest *models.SetupRequest) error {
api.svc.cfg.SavePasswordCheck(setupRequest.UnlockPassword)

// only update non-empty values
if setupRequest.LNBackendType != "" {
api.svc.cfg.SetUpdate("LNBackendType", setupRequest.LNBackendType, "")
Expand Down
18 changes: 15 additions & 3 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (
)

const (
LNDBackendType = "LND"
BreezBackendType = "BREEZ"
CookieName = "alby_nwc_session"
LNDBackendType = "LND"
BreezBackendType = "BREEZ"
SessionCookieName = "session"
SessionCookieAuthKey = "authenticated"
UnlockPasswordCheck = "THIS STRING SHOULD MATCH IF PASSWORD IS CORRECT"
)

type AppConfig struct {
Expand Down Expand Up @@ -127,6 +129,16 @@ func (cfg *Config) SetUpdate(key string, value string, encryptionKey string) {
cfg.set(key, value, clauses, encryptionKey)
}

func (cfg *Config) CheckUnlockPassword(encryptionKey string) bool {
decryptedValue, err := cfg.Get("UnlockPasswordCheck", encryptionKey)

return err == nil && decryptedValue == UnlockPasswordCheck
}

func (cfg *Config) SavePasswordCheck(encryptionKey string) {
cfg.SetUpdate("UnlockPasswordCheck", UnlockPasswordCheck, encryptionKey)
}

func randomHex(n int) (string, error) {
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import Start from "src/screens/Start";
import { AppsRedirect } from "src/components/redirects/AppsRedirect";
import { StartRedirect } from "src/components/redirects/StartRedirect";
import { HomeRedirect } from "src/components/redirects/HomeRedirect";
import Unlock from "src/screens/Unlock";
import { SetupRedirect } from "src/components/redirects/SetupRedirect";

function App() {
return (
Expand All @@ -35,7 +37,7 @@ function App() {
}
></Route>
<Route path="welcome" element={<Welcome />}></Route>
<Route path="setup">
<Route path="setup" element={<SetupRedirect />}>
<Route path="" element={<Navigate to="password" replace />} />
<Route path="password" element={<SetupPassword />} />
<Route path="node" element={<SetupNode />} />
Expand All @@ -47,6 +49,7 @@ function App() {
<Route path="created" element={<AppCreated />} />
<Route path="*" element={<NotFound />} />
</Route>
<Route path="unlock" element={<Unlock />} />
<Route path="about" element={<About />} />
</Route>
<Route path="/*" element={<NotFound />} />
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/redirects/AppsRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ import { useInfo } from "src/hooks/useInfo";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import React from "react";
import Loading from "src/components/Loading";
import { localStorageKeys } from "src/constants";

export function AppsRedirect() {
const { data: info } = useInfo();
const location = useLocation();
const navigate = useNavigate();

// TODO: re-add login redirect: https://github.com/getAlby/nostr-wallet-connect/commit/59b041886098dda4ff38191e3dd704ec36360673
React.useEffect(() => {
if (!info || info.running) {
if (!info || (info.running && info.unlocked)) {
window.localStorage.removeItem(localStorageKeys.returnTo);
return;
}
const returnTo = location.pathname + location.search;
window.localStorage.setItem(localStorageKeys.returnTo, returnTo);
navigate("/");
}, [info, location, navigate]);

Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/redirects/HomeRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useInfo } from "src/hooks/useInfo";
import { useLocation, useNavigate } from "react-router-dom";
import React from "react";
import Loading from "src/components/Loading";
import { localStorageKeys } from "src/constants";

export function HomeRedirect() {
const { data: info } = useInfo();
Expand All @@ -14,7 +15,12 @@ export function HomeRedirect() {
}
let to: string | undefined;
if (info.setupCompleted && info.running) {
to = "/apps";
if (info.unlocked) {
const returnTo = window.localStorage.getItem(localStorageKeys.returnTo);
to = returnTo || "/apps";
} else {
to = "/unlock";
}
} else if (info.setupCompleted && !info.running) {
to = "/start";
} else {
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/redirects/SetupRedirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useInfo } from "src/hooks/useInfo";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import React from "react";
import Loading from "src/components/Loading";

export function SetupRedirect() {
const { data: info } = useInfo();
const location = useLocation();
const navigate = useNavigate();

React.useEffect(() => {
if (!info) {
return;
}
if (info.running && !info.unlocked) {
navigate("/");
}
}, [info, location, navigate]);

if (!info) {
return <Loading />;
}

return <Outlet />;
}
3 changes: 3 additions & 0 deletions frontend/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const localStorageKeys = {
returnTo: "returnTo",
};
5 changes: 4 additions & 1 deletion frontend/src/screens/Start.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { useCSRF } from "src/hooks/useCSRF";
import { request } from "src/utils/request";
import ConnectButton from "src/components/ConnectButton";
import { handleRequestError } from "src/utils/handleRequestError";
import { useInfo } from "src/hooks/useInfo";

export default function Start() {
const [unlockPassword, setUnlockPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
const navigate = useNavigate();
const { data: csrf } = useCSRF();
const { mutate: refetchInfo } = useInfo();

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
Expand All @@ -29,8 +31,9 @@ export default function Start() {
}),
});
console.log({ res });
await refetchInfo();

navigate("/apps");
navigate("/");
} catch (error) {
handleRequestError("Failed to connect", error);
} finally {
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/screens/Unlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { useCSRF } from "src/hooks/useCSRF";
import { request } from "src/utils/request";
import ConnectButton from "src/components/ConnectButton";
import { handleRequestError } from "src/utils/handleRequestError";
import { useInfo } from "src/hooks/useInfo";

export default function Unlock() {
const [unlockPassword, setUnlockPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
const navigate = useNavigate();
const { data: csrf } = useCSRF();
const { mutate: refetchInfo } = useInfo();

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
try {
setLoading(true);
if (!csrf) {
throw new Error("info not loaded");
}
const res = await request("/api/unlock", {
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json",
},
body: JSON.stringify({
unlockPassword,
}),
});
console.log({ res });
await refetchInfo();
navigate("/");
} catch (error) {
handleRequestError("Failed to connect", error);
} finally {
setLoading(false);
}
}

return (
<>
<h1 className="text-lg">Unlock NWC</h1>
<p className="text-lg mb-10">
To continue, please enter your unlock password
</p>
<form onSubmit={onSubmit} className="mb-10">
<>
<label
htmlFor="greenlight-invite-code"
className="block font-medium text-gray-900 dark:text-white"
>
Unlock password
</label>
<input
name="unlock"
onChange={(e) => setUnlockPassword(e.target.value)}
value={unlockPassword}
type="password"
className="dark:bg-surface-00dp block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:ring-2 focus:ring-purple-700 dark:border-gray-700 dark:text-white dark:placeholder-gray-400 dark:ring-offset-gray-800 dark:focus:ring-purple-600"
/>
<ConnectButton isConnecting={loading} />
</>
</form>
</>
);
}
1 change: 1 addition & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface InfoResponse {
backendType: BackendType;
setupCompleted: boolean;
running: boolean;
unlocked: boolean;
}

export interface CreateAppResponse {
Expand Down
75 changes: 68 additions & 7 deletions http_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,9 @@ func NewHttpService(svc *Service) *HttpService {

func (httpSvc *HttpService) validateUserMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// TODO: check if login is required and check if user is logged in
//sess, _ := session.Get(CookieName, c)
// if user == nil {
// return c.NoContent(http.StatusUnauthorized)
// }
if !httpSvc.isUnlocked(c) {
return c.NoContent(http.StatusUnauthorized)
}
return next(c)
}
}
Expand All @@ -58,7 +56,11 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
e.GET("/api/info", httpSvc.infoHandler)
e.POST("/api/logout", httpSvc.logoutHandler)
e.POST("/api/setup", httpSvc.setupHandler)
e.POST("/api/start", httpSvc.startHandler)

// allow one unlock request per second
unlockRateLimiter := middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(1))
e.POST("/api/start", httpSvc.startHandler, unlockRateLimiter)
e.POST("/api/unlock", httpSvc.unlockHandler, unlockRateLimiter)

frontend.RegisterHandlers(e)
}
Expand All @@ -75,6 +77,7 @@ func (httpSvc *HttpService) csrfHandler(c echo.Context) error {

func (httpSvc *HttpService) infoHandler(c echo.Context) error {
responseBody := httpSvc.api.GetInfo()
responseBody.Unlocked = httpSvc.isUnlocked(c)
return c.JSON(http.StatusOK, responseBody)
}

Expand All @@ -92,11 +95,65 @@ func (httpSvc *HttpService) startHandler(c echo.Context) error {
Message: fmt.Sprintf("Failed to start node: %s", err.Error()),
})
}

err = httpSvc.saveSessionCookie(c)

if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to save session: %s", err.Error()),
})
}

return c.NoContent(http.StatusNoContent)
}

func (httpSvc *HttpService) unlockHandler(c echo.Context) error {
var unlockRequest api.UnlockRequest
if err := c.Bind(&unlockRequest); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: fmt.Sprintf("Bad request: %s", err.Error()),
})
}

if !httpSvc.svc.cfg.CheckUnlockPassword(unlockRequest.UnlockPassword) {
return c.JSON(http.StatusUnauthorized, ErrorResponse{
Message: "Invalid password",
})
}

err := httpSvc.saveSessionCookie(c)

if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to save session: %s", err.Error()),
})
}

return c.NoContent(http.StatusNoContent)
}

func (httpSvc *HttpService) isUnlocked(c echo.Context) bool {
sess, _ := session.Get(SessionCookieName, c)
return sess.Values[SessionCookieAuthKey] == true
}

func (httpSvc *HttpService) saveSessionCookie(c echo.Context) error {
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
}
sess.Values[SessionCookieAuthKey] = true
err := sess.Save(c.Request(), c.Response())
if err != nil {
httpSvc.svc.Logger.Errorf("Failed to save session: %v", err)
}
return err
}

func (httpSvc *HttpService) logoutHandler(c echo.Context) error {
sess, err := session.Get(CookieName, c)
sess, err := session.Get(SessionCookieName, c)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: "Failed to get session",
Expand Down Expand Up @@ -195,6 +252,10 @@ func (httpSvc *HttpService) setupHandler(c echo.Context) error {
})
}

if httpSvc.svc.lnClient != nil && !httpSvc.isUnlocked(c) {
return c.NoContent(http.StatusUnauthorized)
}

err := httpSvc.api.Setup(&setupRequest)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Expand Down
5 changes: 5 additions & 0 deletions models/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ type StartRequest struct {
UnlockPassword string `json:"unlockPassword"`
}

type UnlockRequest struct {
UnlockPassword string `json:"unlockPassword"`
}

type SetupRequest struct {
LNBackendType string `json:"backendType"`
// Breez fields
Expand Down Expand Up @@ -67,4 +71,5 @@ type InfoResponse struct {
BackendType string `json:"backendType"`
SetupCompleted bool `json:"setupCompleted"`
Running bool `json:"running"`
Unlocked bool `json:"unlocked"`
}
6 changes: 6 additions & 0 deletions start.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"errors"
"time"

"github.com/nbd-wtf/go-nostr"
Expand Down Expand Up @@ -104,6 +105,11 @@ func (svc *Service) StartNostr(encryptionKey string) error {
}

func (svc *Service) StartApp(encryptionKey string) error {
if !svc.cfg.CheckUnlockPassword(encryptionKey) {
svc.Logger.Errorf("Invalid password")
return errors.New("Invalid password")
}

err := svc.launchLNBackend(encryptionKey)
if err != nil {
svc.Logger.Errorf("Failed to launch LN backend: %v", err)
Expand Down

0 comments on commit 34cbd94

Please sign in to comment.