Browse Source

Merge pull request #9 from meienberger/feature/authentication

Feature/authentication
Nicolas Meienberger 3 years ago
parent
commit
24d09d4277

+ 0 - 14
ansible/tasks/common/docker.yml

@@ -49,20 +49,6 @@
 - name: Make docker-compose executable
 - name: Make docker-compose executable
   shell: chmod +x /usr/local/bin/docker-compose
   shell: chmod +x /usr/local/bin/docker-compose
 
 
-# - name: Disable iptables for docker by editing file /etc/default/docker
-#   lineinfile:
-#     path: /etc/default/docker
-#     regexp: "^DOCKER_OPTS="
-#     line: "DOCKER_OPTS=\"--iptables=false\""
-#     state: present
-
-# - name: Create file /etc/docker/daemon.json with content hello world written inside
-#   lineinfile:
-#     path: /etc/docker/daemon.json
-#     regexp: "^"
-#     line: "{ \"iptables\": false }"
-#     state: present
-
 - name: Create group docker
 - name: Create group docker
   group:
   group:
     name: docker
     name: docker

+ 0 - 3
ansible/tasks/common/essential.yml

@@ -1,6 +1,3 @@
-# - name: Change machine hostname to tipi.local
-#   shell: hostnamectl set-hostname tipi.local
-
 # - name: Update packages
 # - name: Update packages
 #   apt:
 #   apt:
 #     update_cache: yes
 #     update_cache: yes

+ 0 - 0
ansible/tasks/install_app.yml


+ 2 - 0
dashboard/package.json

@@ -18,6 +18,7 @@
     "final-form": "^4.20.6",
     "final-form": "^4.20.6",
     "framer-motion": "^6",
     "framer-motion": "^6",
     "immer": "^9.0.12",
     "immer": "^9.0.12",
+    "js-cookie": "^3.0.1",
     "next": "12.1.4",
     "next": "12.1.4",
     "react": "18.0.0",
     "react": "18.0.0",
     "react-dom": "18.0.0",
     "react-dom": "18.0.0",
@@ -29,6 +30,7 @@
     "zustand": "^3.7.2"
     "zustand": "^3.7.2"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@types/js-cookie": "^3.0.2",
     "@types/node": "17.0.23",
     "@types/node": "17.0.23",
     "@types/react": "17.0.43",
     "@types/react": "17.0.43",
     "@types/react-dom": "17.0.14",
     "@types/react-dom": "17.0.14",

+ 5 - 4
dashboard/src/components/Form/FormInput.tsx

@@ -6,16 +6,17 @@ interface IProps {
   placeholder?: string;
   placeholder?: string;
   error?: string;
   error?: string;
   type?: Parameters<typeof Input>[0]['type'];
   type?: Parameters<typeof Input>[0]['type'];
-  label: string;
+  label?: string;
   className?: string;
   className?: string;
   isInvalid?: boolean;
   isInvalid?: boolean;
+  size?: Parameters<typeof Input>[0]['size'];
 }
 }
 
 
-const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, ...rest }) => {
+const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, ...rest }) => {
   return (
   return (
     <div className={clsx('transition-all', className)}>
     <div className={clsx('transition-all', className)}>
-      <label>{label}</label>
-      <Input type={type} placeholder={placeholder} isInvalid={isInvalid} {...rest} />
+      {label && <label>{label}</label>}
+      <Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...rest} />
       {isInvalid && <span className="text-red-500 text-sm">{error}</span>}
       {isInvalid && <span className="text-red-500 text-sm">{error}</span>}
     </div>
     </div>
   );
   );

+ 1 - 0
dashboard/src/components/Layout/Layout.tsx

@@ -48,6 +48,7 @@ const Layout: React.FC<IProps> = ({ children, loading, breadcrumbs }) => {
       <Head>
       <Head>
         <title>Tipi</title>
         <title>Tipi</title>
       </Head>
       </Head>
+
       <Flex height="100vh" direction="column">
       <Flex height="100vh" direction="column">
         <MenuDrawer isOpen={isOpen} onClose={onClose}>
         <MenuDrawer isOpen={isOpen} onClose={onClose}>
           <Menu />
           <Menu />

+ 7 - 0
dashboard/src/components/Layout/Menu.tsx

@@ -1,5 +1,6 @@
 import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
 import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
 import { FaRegMoon } from 'react-icons/fa';
 import { FaRegMoon } from 'react-icons/fa';
+import { FiLogOut } from 'react-icons/fi';
 import Package from '../../../package.json';
 import Package from '../../../package.json';
 import { Box, Divider, Flex, List, ListItem, Switch, useColorMode } from '@chakra-ui/react';
 import { Box, Divider, Flex, List, ListItem, Switch, useColorMode } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
@@ -7,10 +8,12 @@ import Link from 'next/link';
 import clsx from 'clsx';
 import clsx from 'clsx';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { IconType } from 'react-icons';
 import { IconType } from 'react-icons';
+import { useAuthStore } from '../../state/authStore';
 
 
 const SideMenu: React.FC = () => {
 const SideMenu: React.FC = () => {
   const router = useRouter();
   const router = useRouter();
   const { colorMode, setColorMode } = useColorMode();
   const { colorMode, setColorMode } = useColorMode();
+  const { logout } = useAuthStore();
   const path = router.pathname.split('/')[1];
   const path = router.pathname.split('/')[1];
 
 
   const renderMenuItem = (title: string, name: string, Icon: IconType) => {
   const renderMenuItem = (title: string, name: string, Icon: IconType) => {
@@ -49,6 +52,10 @@ const SideMenu: React.FC = () => {
       <Flex flex="1" />
       <Flex flex="1" />
       <List>
       <List>
         <div className="mx-3">
         <div className="mx-3">
+          <ListItem onClick={logout} className="cursor-pointer hover:font-bold flex items-center mb-5">
+            <FiLogOut size={20} className="mr-3" />
+            <p className="flex-1">Log out</p>
+          </ListItem>
           <ListItem className="flex items-center">
           <ListItem className="flex items-center">
             <FaRegMoon size={20} className="mr-3" />
             <FaRegMoon size={20} className="mr-3" />
             <p className="flex-1">Dark mode</p>
             <p className="flex-1">Dark mode</p>

+ 12 - 0
dashboard/src/components/LoadingScreen.tsx

@@ -0,0 +1,12 @@
+import { Flex, Spinner } from '@chakra-ui/react';
+import React from 'react';
+
+const LoadingScreen = () => {
+  return (
+    <Flex height="100vh" alignItems="center" justifyContent="center">
+      <Spinner size="lg" />
+    </Flex>
+  );
+};
+
+export default LoadingScreen;

+ 1 - 0
dashboard/src/core/api.ts

@@ -17,6 +17,7 @@ const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
     params,
     params,
     data,
     data,
     url: `${BASE_URL}${endpoint}`,
     url: `${BASE_URL}${endpoint}`,
+    withCredentials: true,
   });
   });
 
 
   if (response.data.error) {
   if (response.data.error) {

+ 2 - 1
dashboard/src/core/types.ts

@@ -52,5 +52,6 @@ export enum AppStatus {
 }
 }
 
 
 export interface IUser {
 export interface IUser {
-  first_name: string;
+  name: string;
+  email: string;
 }
 }

+ 1 - 3
dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -3,7 +3,6 @@ import React from 'react';
 import { FiExternalLink } from 'react-icons/fi';
 import { FiExternalLink } from 'react-icons/fi';
 import { AppConfig } from '../../../core/types';
 import { AppConfig } from '../../../core/types';
 import { useAppsStore } from '../../../state/appsStore';
 import { useAppsStore } from '../../../state/appsStore';
-import { useNetworkStore } from '../../../state/networkStore';
 import AppActions from '../components/AppActions';
 import AppActions from '../components/AppActions';
 import InstallModal from '../components/InstallModal';
 import InstallModal from '../components/InstallModal';
 import StopModal from '../components/StopModal';
 import StopModal from '../components/StopModal';
@@ -21,7 +20,6 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
   const stopDisclosure = useDisclosure();
   const stopDisclosure = useDisclosure();
   const updateDisclosure = useDisclosure();
   const updateDisclosure = useDisclosure();
 
 
-  const { internalIp } = useNetworkStore();
   const { install, update, uninstall, stop, start, fetchApp } = useAppsStore();
   const { install, update, uninstall, stop, start, fetchApp } = useAppsStore();
 
 
   const handleError = (error: unknown) => {
   const handleError = (error: unknown) => {
@@ -88,7 +86,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
   };
   };
 
 
   const handleOpen = () => {
   const handleOpen = () => {
-    window.open(`http://${internalIp}:${app.port}`, '_blank');
+    window.open(`http://${process.env.INTERNAL_IP}:${app.port}`, '_blank');
   };
   };
 
 
   return (
   return (

+ 28 - 0
dashboard/src/modules/Auth/components/AuthFormLayout.tsx

@@ -0,0 +1,28 @@
+import { Container, Flex, SlideFade, Text } from '@chakra-ui/react';
+import React from 'react';
+
+interface IProps {
+  title: string;
+  description: string;
+}
+
+const AuthFormLayout: React.FC<IProps> = ({ children, title, description }) => {
+  return (
+    <Container maxW="1250px">
+      <Flex flex={1} height="100vh" overflowY="hidden">
+        <SlideFade in className="flex flex-1 flex-col justify-center items-center" offsetY="20px">
+          <img className="self-center mb-5 logo" src="/tipi.png" width={512} height={512} />
+          <Text className="text-xl md:text-2xl lg:text-5xl font-bold" size="3xl">
+            {title}
+          </Text>
+          <Text className="md:text-lg lg:text-2xl text-center" color="gray.500">
+            {description}
+          </Text>
+          {children}
+        </SlideFade>
+      </Flex>
+    </Container>
+  );
+};
+
+export default AuthFormLayout;

+ 57 - 0
dashboard/src/modules/Auth/components/LoginForm.tsx

@@ -0,0 +1,57 @@
+import { Button } from '@chakra-ui/react';
+import React from 'react';
+import { Field, Form } from 'react-final-form';
+import validator from 'validator';
+import FormInput from '../../../components/Form/FormInput';
+
+type FormValues = { email: string; password: string };
+
+interface IProps {
+  onSubmit: (values: FormValues) => void;
+  loading: boolean;
+}
+
+const LoginForm: React.FC<IProps> = ({ onSubmit, loading }) => {
+  const validateFields = (values: FormValues) => {
+    const errors: Record<string, string> = {};
+
+    if (!validator.isEmail(values.email || '')) {
+      errors.email = 'Invalid email';
+    }
+
+    if (!values.password) {
+      errors.password = 'Required';
+    }
+
+    return errors;
+  };
+
+  return (
+    <Form<FormValues>
+      onSubmit={onSubmit}
+      validateOnBlur={true}
+      validate={(values) => validateFields(values)}
+      render={({ handleSubmit, validating, submitting }) => (
+        <form className="flex flex-col" onSubmit={handleSubmit}>
+          <Field
+            name="email"
+            render={({ input, meta }) => (
+              <FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Email" {...input} />
+            )}
+          />
+          <Field
+            name="password"
+            render={({ input, meta }) => (
+              <FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Password" type="password" {...input} />
+            )}
+          />
+          <Button isLoading={validating || submitting || loading} className="mt-2" colorScheme="green" type="submit">
+            Login
+          </Button>
+        </form>
+      )}
+    />
+  );
+};
+
+export default LoginForm;

+ 75 - 0
dashboard/src/modules/Auth/components/RegisterForm.tsx

@@ -0,0 +1,75 @@
+import { Button } from '@chakra-ui/react';
+import React from 'react';
+import { Field, Form } from 'react-final-form';
+import validator from 'validator';
+import FormInput from '../../../components/Form/FormInput';
+
+interface IProps {
+  onSubmit: (values: FormValues) => void;
+  loading: boolean;
+}
+
+type FormValues = { email: string; password: string; passwordConfirm: string };
+
+const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
+  const validateFields = (values: FormValues) => {
+    const errors: Record<string, string> = {};
+
+    if (!validator.isEmail(values.email || '')) {
+      errors.email = 'Invalid email';
+    }
+
+    if (!values.password) {
+      errors.password = 'Required';
+    }
+
+    if (values.password !== values.passwordConfirm) {
+      errors.passwordConfirm = 'Passwords do not match';
+    }
+
+    return errors;
+  };
+
+  return (
+    <Form<FormValues>
+      onSubmit={onSubmit}
+      validateOnBlur={true}
+      validate={(values) => validateFields(values)}
+      render={({ handleSubmit, validating, submitting }) => (
+        <form className="flex flex-col" onSubmit={handleSubmit}>
+          <Field
+            name="email"
+            render={({ input, meta }) => (
+              <FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Email" {...input} />
+            )}
+          />
+          <Field
+            name="password"
+            render={({ input, meta }) => (
+              <FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Password" type="password" {...input} />
+            )}
+          />
+          <Field
+            name="passwordConfirm"
+            render={({ input, meta }) => (
+              <FormInput
+                size="lg"
+                className="mt-3 w-full"
+                error={meta.error}
+                isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)}
+                placeholder="Repeat password"
+                type="password"
+                {...input}
+              />
+            )}
+          />
+          <Button isLoading={validating || submitting || loading} className="mt-2" colorScheme="green" type="submit">
+            Enter
+          </Button>
+        </form>
+      )}
+    />
+  );
+};
+
+export default RegisterForm;

+ 36 - 0
dashboard/src/modules/Auth/containers/AuthWrapper.tsx

@@ -0,0 +1,36 @@
+import React, { useEffect, useState } from 'react';
+import LoadingScreen from '../../../components/LoadingScreen';
+import { useAuthStore } from '../../../state/authStore';
+import Login from './Login';
+import Onboarding from './Onboarding';
+
+const AuthWrapper: React.FC = ({ children }) => {
+  const [initialLoad, setInitialLoad] = useState(true);
+  const { configured, user, me, fetchConfigured } = useAuthStore();
+
+  useEffect(() => {
+    const fetchUser = async () => {
+      await me();
+      await fetchConfigured();
+
+      setInitialLoad(false);
+    };
+    if (!user) fetchUser();
+  }, [fetchConfigured, me, user]);
+
+  if (initialLoad && !user) {
+    return <LoadingScreen />;
+  }
+
+  if (user) {
+    return <>{children}</>;
+  }
+
+  if (!configured) {
+    return <Onboarding />;
+  }
+
+  return <Login />;
+};
+
+export default AuthWrapper;

+ 41 - 0
dashboard/src/modules/Auth/containers/Login.tsx

@@ -0,0 +1,41 @@
+import { useToast } from '@chakra-ui/react';
+import React from 'react';
+import { useAuthStore } from '../../../state/authStore';
+import AuthFormLayout from '../components/AuthFormLayout';
+import LoginForm from '../components/LoginForm';
+
+type FormValues = { email: string; password: string };
+
+const Login: React.FC = () => {
+  const { me, login, loading } = useAuthStore();
+  const toast = useToast();
+
+  const handleError = (error: unknown) => {
+    if (error instanceof Error) {
+      toast({
+        title: 'Error',
+        description: error.message,
+        status: 'error',
+        position: 'top',
+        isClosable: true,
+      });
+    }
+  };
+
+  const handleLogin = async (values: FormValues) => {
+    try {
+      await login(values.email, values.password);
+      await me();
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  return (
+    <AuthFormLayout title="Welcome back" description="Enter your credentials to login to your Tipi">
+      <LoginForm onSubmit={handleLogin} loading={loading} />
+    </AuthFormLayout>
+  );
+};
+
+export default Login;

+ 39 - 0
dashboard/src/modules/Auth/containers/Onboarding.tsx

@@ -0,0 +1,39 @@
+import { useToast } from '@chakra-ui/react';
+import React from 'react';
+import { useAuthStore } from '../../../state/authStore';
+import AuthFormLayout from '../components/AuthFormLayout';
+import RegisterForm from '../components/RegisterForm';
+
+const Onboarding: React.FC = () => {
+  const toast = useToast();
+  const { me, register, loading } = useAuthStore();
+
+  const handleError = (error: unknown) => {
+    if (error instanceof Error) {
+      toast({
+        title: 'Error',
+        description: error.message,
+        status: 'error',
+        position: 'top',
+        isClosable: true,
+      });
+    }
+  };
+
+  const handleRegister = async (values: { email: string; password: string }) => {
+    try {
+      await register(values.email, values.password);
+      await me();
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  return (
+    <AuthFormLayout title="Welcome to your Tipi" description="Register your account to get started">
+      <RegisterForm onSubmit={handleRegister} loading={loading} />
+    </AuthFormLayout>
+  );
+};
+
+export default Onboarding;

+ 4 - 9
dashboard/src/pages/_app.tsx

@@ -3,20 +3,15 @@ import '@fontsource/open-sans/400.css';
 import '../styles/globals.css';
 import '../styles/globals.css';
 import { ChakraProvider } from '@chakra-ui/react';
 import { ChakraProvider } from '@chakra-ui/react';
 import type { AppProps } from 'next/app';
 import type { AppProps } from 'next/app';
-import { useEffect } from 'react';
-import { useNetworkStore } from '../state/networkStore';
 import { theme } from '../styles/theme';
 import { theme } from '../styles/theme';
+import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
 
 
 function MyApp({ Component, pageProps }: AppProps) {
 function MyApp({ Component, pageProps }: AppProps) {
-  const { fetchInternalIp } = useNetworkStore();
-
-  useEffect(() => {
-    fetchInternalIp();
-  }, [fetchInternalIp]);
-
   return (
   return (
     <ChakraProvider theme={theme}>
     <ChakraProvider theme={theme}>
-      <Component {...pageProps} />
+      <AuthWrapper>
+        <Component {...pageProps} />
+      </AuthWrapper>
     </ChakraProvider>
     </ChakraProvider>
   );
   );
 }
 }

+ 0 - 18
dashboard/src/pages/index.tsx

@@ -1,7 +1,5 @@
 import type { NextPage } from 'next';
 import type { NextPage } from 'next';
 import Layout from '../components/Layout';
 import Layout from '../components/Layout';
-import api from '../core/api';
-import { IUser } from '../core/types';
 import Dashboard from '../modules/Dashboard/containers/Dashboard';
 import Dashboard from '../modules/Dashboard/containers/Dashboard';
 
 
 const Home: NextPage = () => {
 const Home: NextPage = () => {
@@ -12,20 +10,4 @@ const Home: NextPage = () => {
   );
   );
 };
 };
 
 
-export async function getServerSideProps() {
-  const token = localStorage.getItem('tipi_token');
-
-  // Fetch data from external API
-  const res = await api.fetch<IUser>({
-    endpoint: '/user',
-    method: 'post',
-    data: { token },
-  });
-
-  console.log(res);
-
-  // Pass data to the page via props
-  return { props: { user: res } };
-}
-
 export default Home;
 export default Home;

+ 75 - 0
dashboard/src/state/authStore.ts

@@ -0,0 +1,75 @@
+import create from 'zustand';
+import Cookies from 'js-cookie';
+import api from '../core/api';
+import { IUser } from '../core/types';
+
+type AppsStore = {
+  user: IUser | null;
+  configured: boolean;
+  me: () => Promise<void>;
+  login: (email: string, password: string) => Promise<void>;
+  register: (email: string, password: string) => Promise<void>;
+  logout: () => void;
+  fetchConfigured: () => Promise<void>;
+  loading: boolean;
+};
+
+export const useAuthStore = create<AppsStore>((set) => ({
+  user: null,
+  configured: false,
+  loading: false,
+  me: async () => {
+    try {
+      set({ loading: true });
+      const response = await api.fetch<{ user: IUser | null }>({ endpoint: '/auth/me' });
+
+      set({ user: response.user, loading: false });
+    } catch (error) {
+      set({ loading: false, user: null });
+    }
+  },
+  login: async (email: string, password: string) => {
+    set({ loading: true });
+
+    try {
+      const response = await api.fetch<{ user: IUser }>({
+        endpoint: '/auth/login',
+        method: 'post',
+        data: { email, password },
+      });
+      set({ user: response.user, loading: false });
+    } catch (e) {
+      set({ loading: false });
+      throw e;
+    }
+  },
+  logout: async () => {
+    // Cookies.remove('token2');
+    Cookies.remove('tipi_token');
+
+    set({ user: null, loading: false });
+  },
+  register: async (email: string, password: string) => {
+    set({ loading: true });
+
+    try {
+      const response = await api.fetch<{ user: IUser }>({
+        endpoint: '/auth/register',
+        method: 'post',
+        data: { email, password },
+      });
+      set({ user: response.user, loading: false });
+    } catch (e) {
+      set({ loading: false });
+      throw e;
+    }
+  },
+  fetchConfigured: async () => {
+    try {
+      const response = await api.fetch<{ configured: boolean }>({ endpoint: '/auth/configured' });
+      set({ configured: response.configured });
+    } catch (e) {
+      set({ configured: false });
+    }
+  },
+}));

+ 10 - 0
dashboard/yarn.lock

@@ -904,6 +904,11 @@
   resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.8.tgz#be3e914e84eacf16dbebd311c0d0b44aa1174c64"
   resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.8.tgz#be3e914e84eacf16dbebd311c0d0b44aa1174c64"
   integrity sha512-ZK5v4bJwgXldAUA8r3q9YKfCwOqoHTK/ZqRjSeRXQrBXWouoPnS4MQtgC4AXGiiBuUu5wxrRgTlv0ktmM4P1Aw==
   integrity sha512-ZK5v4bJwgXldAUA8r3q9YKfCwOqoHTK/ZqRjSeRXQrBXWouoPnS4MQtgC4AXGiiBuUu5wxrRgTlv0ktmM4P1Aw==
 
 
+"@types/js-cookie@^3.0.2":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.2.tgz#451eaeece64c6bdac8b2dde0caab23b085899e0d"
+  integrity sha512-6+0ekgfusHftJNYpihfkMu8BWdeHs9EOJuGcSofErjstGPfPGEu9yTu4t460lTzzAMl2cM5zngQJqPMHbbnvYA==
+
 "@types/json-schema@^7.0.9":
 "@types/json-schema@^7.0.9":
   version "7.0.11"
   version "7.0.11"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
@@ -2296,6 +2301,11 @@ isexe@^2.0.0:
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
   integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
 
 
+js-cookie@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414"
+  integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==
+
 "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
 "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
   version "4.0.0"
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"

File diff suppressed because it is too large
+ 1 - 14366
system-api/package-lock.json


+ 11 - 0
system-api/package.json

@@ -16,22 +16,33 @@
   "author": "",
   "author": "",
   "license": "ISC",
   "license": "ISC",
   "dependencies": {
   "dependencies": {
+    "bcrypt": "^5.0.1",
     "compression": "^1.7.4",
     "compression": "^1.7.4",
+    "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
     "dotenv": "^16.0.0",
     "dotenv": "^16.0.0",
     "express": "^4.17.3",
     "express": "^4.17.3",
     "helmet": "^5.0.2",
     "helmet": "^5.0.2",
     "internal-ip": "^7.0.0",
     "internal-ip": "^7.0.0",
+    "jsonwebtoken": "^8.5.1",
     "node-port-scanner": "^3.0.1",
     "node-port-scanner": "^3.0.1",
     "p-iteration": "^1.1.8",
     "p-iteration": "^1.1.8",
+    "passport": "^0.5.2",
+    "passport-cookie": "^1.0.9",
+    "passport-http-bearer": "^1.0.1",
     "public-ip": "^5.0.0",
     "public-ip": "^5.0.0",
     "systeminformation": "^5.11.9",
     "systeminformation": "^5.11.9",
     "tcp-port-used": "^1.0.2"
     "tcp-port-used": "^1.0.2"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@types/bcrypt": "^5.0.0",
     "@types/compression": "^1.7.2",
     "@types/compression": "^1.7.2",
+    "@types/cookie-parser": "^1.4.3",
     "@types/cors": "^2.8.12",
     "@types/cors": "^2.8.12",
     "@types/express": "^4.17.13",
     "@types/express": "^4.17.13",
+    "@types/jsonwebtoken": "^8.5.8",
+    "@types/passport": "^1.0.7",
+    "@types/passport-http-bearer": "^1.0.37",
     "@types/tcp-port-used": "^1.0.1",
     "@types/tcp-port-used": "^1.0.1",
     "@types/validator": "^13.7.2",
     "@types/validator": "^13.7.2",
     "concurrently": "^7.1.0",
     "concurrently": "^7.1.0",

+ 3 - 1
system-api/src/config/config.ts

@@ -3,11 +3,12 @@ import * as dotenv from 'dotenv';
 interface IConfig {
 interface IConfig {
   NODE_ENV: string;
   NODE_ENV: string;
   ROOT_FOLDER: string;
   ROOT_FOLDER: string;
+  JWT_SECRET: string;
 }
 }
 
 
 dotenv.config();
 dotenv.config();
 
 
-const { NODE_ENV = 'development', ROOT_FOLDER = '' } = process.env;
+const { NODE_ENV = 'development', ROOT_FOLDER = '', JWT_SECRET = '' } = process.env;
 
 
 const missing = [];
 const missing = [];
 
 
@@ -20,6 +21,7 @@ if (missing.length > 0) {
 const config: IConfig = {
 const config: IConfig = {
   NODE_ENV,
   NODE_ENV,
   ROOT_FOLDER,
   ROOT_FOLDER,
+  JWT_SECRET,
 };
 };
 
 
 export default config;
 export default config;

+ 8 - 0
system-api/src/config/types.ts

@@ -15,6 +15,8 @@ interface FormField {
   env_variable: string;
   env_variable: string;
 }
 }
 
 
+export type Maybe<T> = T | null | undefined;
+
 export interface AppConfig {
 export interface AppConfig {
   id: string;
   id: string;
   port: number;
   port: number;
@@ -32,3 +34,9 @@ export interface AppConfig {
   installed: boolean;
   installed: boolean;
   status: 'running' | 'stopped';
   status: 'running' | 'stopped';
 }
 }
+
+export interface IUser {
+  email: string;
+  name: string;
+  password: string;
+}

+ 88 - 0
system-api/src/modules/auth/auth.controller.ts

@@ -0,0 +1,88 @@
+import { NextFunction, Request, Response } from 'express';
+import bcrypt from 'bcrypt';
+import { IUser } from '../../config/types';
+import { readJsonFile, writeFile } from '../fs/fs.helpers';
+import { getJwtToken, getUser } from './auth.helpers';
+
+const login = async (req: Request, res: Response, next: NextFunction) => {
+  try {
+    const { email, password } = req.body;
+
+    if (!email || !password) {
+      throw new Error('Missing id or password');
+    }
+
+    const user = getUser(email);
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    const token = await getJwtToken(user, password);
+
+    res.cookie('tipi_token', token, {
+      httpOnly: false,
+      secure: false,
+      maxAge: 1000 * 60 * 60 * 24 * 7,
+    });
+
+    res.status(200).json({ token });
+  } catch (e) {
+    next(e);
+  }
+};
+
+const register = async (req: Request, res: Response, next: NextFunction) => {
+  try {
+    const users: IUser[] = readJsonFile('/state/users.json');
+
+    if (users.length > 0) {
+      throw new Error('There is already an admin user');
+    }
+
+    const { email, password, name } = req.body;
+
+    if (!email || !password) {
+      throw new Error('Missing email or password');
+    }
+
+    if (users.find((user) => user.email === email)) {
+      throw new Error('User already exists');
+    }
+
+    const hash = await bcrypt.hash(password, 10);
+    const newuser: IUser = { email, name, password: hash };
+
+    const token = await getJwtToken(newuser, password);
+
+    res.cookie('tipi_token', token, {
+      httpOnly: false,
+      secure: false,
+      maxAge: 1000 * 60 * 60 * 24 * 7,
+    });
+
+    writeFile('/state/users.json', JSON.stringify([newuser]));
+
+    res.status(200).json({ token });
+  } catch (e) {
+    next(e);
+  }
+};
+
+const me = async (req: Request, res: Response) => {
+  const { user } = req;
+
+  if (user) {
+    res.status(200).json({ user });
+  } else {
+    res.status(200).json({ user: null });
+  }
+};
+
+const isConfigured = async (req: Request, res: Response) => {
+  const users: IUser[] = readJsonFile('/state/users.json');
+
+  res.status(200).json({ configured: users.length > 0 });
+};
+
+export default { login, me, register, isConfigured };

+ 44 - 0
system-api/src/modules/auth/auth.helpers.ts

@@ -0,0 +1,44 @@
+import jsonwebtoken from 'jsonwebtoken';
+import bcrypt from 'bcrypt';
+import { IUser, Maybe } from '../../config/types';
+import { readJsonFile } from '../fs/fs.helpers';
+import config from '../../config';
+
+export const getUser = (email: string): Maybe<IUser> => {
+  const savedUser: IUser[] = readJsonFile('/state/users.json');
+
+  const user = savedUser.find((u) => u.email === email);
+
+  return user;
+};
+
+const compareHashPassword = (password: string, hash = ''): Promise<boolean> => {
+  return bcrypt.compare(password, hash || '');
+};
+
+const getJwtToken = async (user: IUser, password: string) => {
+  const validPassword = await compareHashPassword(password, user.password || '');
+
+  if (validPassword) {
+    if (config.JWT_SECRET) {
+      return jsonwebtoken.sign({ email: user.email }, config.JWT_SECRET, {
+        expiresIn: '7d',
+      });
+    }
+  }
+
+  throw new Error('Wrong password');
+};
+
+const tradeTokenForUser = (token: string): Maybe<IUser> => {
+  try {
+    const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
+    const users: IUser[] = readJsonFile('/state/users.json');
+
+    return users.find((user) => user.email === email);
+  } catch (error) {
+    return null;
+  }
+};
+
+export { tradeTokenForUser, getJwtToken };

+ 11 - 0
system-api/src/modules/auth/auth.routes.ts

@@ -0,0 +1,11 @@
+import { Router } from 'express';
+import AuthController from './auth.controller';
+
+const router = Router();
+
+router.route('/login').post(AuthController.login);
+router.route('/me').get(AuthController.me);
+router.route('/configured').get(AuthController.isConfigured);
+router.route('/register').post(AuthController.register);
+
+export default router;

+ 29 - 5
system-api/src/server.ts

@@ -1,3 +1,4 @@
+/* eslint-disable no-unused-vars */
 import express, { NextFunction, Request, Response } from 'express';
 import express, { NextFunction, Request, Response } from 'express';
 import compression from 'compression';
 import compression from 'compression';
 // import suExec from 'su-exec';
 // import suExec from 'su-exec';
@@ -6,7 +7,9 @@ import cors from 'cors';
 import { isProd } from './constants/constants';
 import { isProd } from './constants/constants';
 import appsRoutes from './modules/apps/apps.routes';
 import appsRoutes from './modules/apps/apps.routes';
 import systemRoutes from './modules/system/system.routes';
 import systemRoutes from './modules/system/system.routes';
-import networkRoutes from './modules/network/network.routes';
+import authRoutes from './modules/auth/auth.routes';
+import { tradeTokenForUser } from './modules/auth/auth.helpers';
+import cookieParser from 'cookie-parser';
 
 
 // suExec.init();
 // suExec.init();
 
 
@@ -14,6 +17,7 @@ const app = express();
 const port = 3001;
 const port = 3001;
 
 
 app.use(express.json());
 app.use(express.json());
+app.use(cookieParser());
 
 
 if (isProd) {
 if (isProd) {
   app.use(compression());
   app.use(compression());
@@ -22,12 +26,32 @@ if (isProd) {
 
 
 app.use(cors());
 app.use(cors());
 
 
-app.use('/system', systemRoutes);
-app.use('/apps', appsRoutes);
-app.use('/network', networkRoutes);
+// Get user from token
+app.use((req, res, next) => {
+  let user = null;
+
+  if (req?.cookies?.tipi_token) {
+    user = tradeTokenForUser(req.cookies.tipi_token);
+    if (user) req.user = user;
+  }
+
+  next();
+});
+
+const restrict = (req: Request, res: Response, next: NextFunction) => {
+  if (!req.user) {
+    res.status(401).json({ error: 'Unauthorized' });
+  } else {
+    next();
+  }
+};
+
+app.use('/auth', authRoutes);
+app.use('/system', restrict, systemRoutes);
+app.use('/apps', restrict, appsRoutes);
 
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
+app.use((err: Error, req: Request, res: Response, _: NextFunction) => {
   res.status(200).json({ error: err.message });
   res.status(200).json({ error: err.message });
 });
 });
 
 

+ 1 - 0
templates/users-sample.json

@@ -0,0 +1 @@
+[]

Some files were not shown because too many files changed in this diff