diff --git a/api.go b/api.go index 25bae7db..c99e89ba 100644 --- a/api.go +++ b/api.go @@ -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, "") diff --git a/config.go b/config.go index 4c60c12b..79d5e13a 100644 --- a/config.go +++ b/config.go @@ -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 { @@ -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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77da9538..8bf0f723 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( @@ -35,7 +37,7 @@ function App() { } > }> - + }> } /> } /> } /> @@ -47,6 +49,7 @@ function App() { } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/redirects/AppsRedirect.tsx b/frontend/src/components/redirects/AppsRedirect.tsx index 3ffeec29..b1f24b8c 100644 --- a/frontend/src/components/redirects/AppsRedirect.tsx +++ b/frontend/src/components/redirects/AppsRedirect.tsx @@ -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]); diff --git a/frontend/src/components/redirects/HomeRedirect.tsx b/frontend/src/components/redirects/HomeRedirect.tsx index c9276895..5fb10fce 100644 --- a/frontend/src/components/redirects/HomeRedirect.tsx +++ b/frontend/src/components/redirects/HomeRedirect.tsx @@ -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(); @@ -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 { diff --git a/frontend/src/components/redirects/SetupRedirect.tsx b/frontend/src/components/redirects/SetupRedirect.tsx new file mode 100644 index 00000000..ddd22483 --- /dev/null +++ b/frontend/src/components/redirects/SetupRedirect.tsx @@ -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 ; + } + + return ; +} diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts new file mode 100644 index 00000000..ce08f760 --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1,3 @@ +export const localStorageKeys = { + returnTo: "returnTo", +}; diff --git a/frontend/src/screens/Start.tsx b/frontend/src/screens/Start.tsx index 05f876d6..bb1d3ed4 100644 --- a/frontend/src/screens/Start.tsx +++ b/frontend/src/screens/Start.tsx @@ -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(); @@ -29,8 +31,9 @@ export default function Start() { }), }); console.log({ res }); + await refetchInfo(); - navigate("/apps"); + navigate("/"); } catch (error) { handleRequestError("Failed to connect", error); } finally { diff --git a/frontend/src/screens/Unlock.tsx b/frontend/src/screens/Unlock.tsx new file mode 100644 index 00000000..b1f2dfe7 --- /dev/null +++ b/frontend/src/screens/Unlock.tsx @@ -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 ( + <> +

Unlock NWC

+

+ To continue, please enter your unlock password +

+
+ <> + + 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" + /> + + + + + ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2afbb156..f1b1978e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -90,6 +90,7 @@ export interface InfoResponse { backendType: BackendType; setupCompleted: boolean; running: boolean; + unlocked: boolean; } export interface CreateAppResponse { diff --git a/http_service.go b/http_service.go index 743e937b..50742db3 100644 --- a/http_service.go +++ b/http_service.go @@ -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) } } @@ -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) } @@ -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) } @@ -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", @@ -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{ diff --git a/models/api/api.go b/models/api/api.go index ee348e80..5a41e96f 100644 --- a/models/api/api.go +++ b/models/api/api.go @@ -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 @@ -67,4 +71,5 @@ type InfoResponse struct { BackendType string `json:"backendType"` SetupCompleted bool `json:"setupCompleted"` Running bool `json:"running"` + Unlocked bool `json:"unlocked"` } diff --git a/start.go b/start.go index 844cacfc..a62d9816 100644 --- a/start.go +++ b/start.go @@ -1,6 +1,7 @@ package main import ( + "errors" "time" "github.com/nbd-wtf/go-nostr" @@ -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)