diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b0301b7f..edb42480 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "k-scale-store", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "k-scale-store", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@hookform/resolvers": "^3.9.0", "@radix-ui/react-slot": "^1.1.0", @@ -42,7 +42,8 @@ "urdf-loader": "^0.12.1", "uuid": "^10.0.0", "web-vitals": "^2.1.4", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@babel/eslint-parser": "^7.24.6", @@ -59,6 +60,7 @@ "@react-oauth/google": "^0.12.1", "@types/jest": "^29.5.12", "@types/uuid": "^9.0.8", + "@types/zxcvbn": "^4.4.4", "axios": "^1.7.2", "babel-eslint": "*", "babel-jest": "^29.7.0", @@ -8843,6 +8845,12 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, + "node_modules/@types/zxcvbn": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.4.tgz", + "integrity": "sha512-Tuk4q7q0DnpzyJDI4aMeghGuFu2iS1QAdKpabn8JfbtfGmVDUgvZv1I7mEjP61Bvnp3ljKCC8BE6YYSTNxmvRQ==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -32175,6 +32183,11 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==" } } } diff --git a/frontend/package.json b/frontend/package.json index 6bebf138..4851c553 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "k-scale-store", - "version": "0.1.1", + "version": "0.1.2", "private": true, "dependencies": { "@hookform/resolvers": "^3.9.0", @@ -37,7 +37,8 @@ "urdf-loader": "^0.12.1", "uuid": "^10.0.0", "web-vitals": "^2.1.4", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zxcvbn": "^4.4.2" }, "type": "module", "scripts": { @@ -76,6 +77,7 @@ "@react-oauth/google": "^0.12.1", "@types/jest": "^29.5.12", "@types/uuid": "^9.0.8", + "@types/zxcvbn": "^4.4.4", "axios": "^1.7.2", "babel-eslint": "*", "babel-jest": "^29.7.0", diff --git a/frontend/src/components/auth/AuthBlock.tsx b/frontend/src/components/auth/AuthBlock.tsx index f7f251bb..cbc76194 100644 --- a/frontend/src/components/auth/AuthBlock.tsx +++ b/frontend/src/components/auth/AuthBlock.tsx @@ -81,11 +81,15 @@ export const AuthBlockInner = () => { ); }; -const AuthBlock = () => { +interface AuthBlockProps { + title?: string; +} + +const AuthBlock: React.FC = ({ title }) => { return ( -
+
diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index 7ecfa452..764b1438 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -2,14 +2,11 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "components/ui/Button/Button"; import ErrorMessage from "components/ui/ErrorMessage"; import { Input } from "components/ui/Input/Input"; -import { useState } from "react"; +import PasswordInput from "components/ui/Input/PasswordInput"; import { SubmitHandler, useForm } from "react-hook-form"; -import { FaEye, FaEyeSlash } from "react-icons/fa"; import { LoginSchema, LoginType } from "types"; const LoginForm = () => { - const [showPassword, setShowPassword] = useState(false); - const { register, handleSubmit, @@ -19,7 +16,7 @@ const LoginForm = () => { }); const onSubmit: SubmitHandler = async (data: LoginType) => { - // TODO: Add an api endpoint to send the credentials details to backend and email verification. + // TODO: Add an API endpoint to send the credentials details to backend and handle authentication. console.log(data); }; @@ -28,43 +25,23 @@ const LoginForm = () => { onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 space-y-6" > - {/* Email */} + {/* Email Input */}
{errors?.email && {errors?.email?.message}}
- - {/* Password */} -
-
- -
- {showPassword ? ( - setShowPassword(false)} - className="cursor-pointer" - /> - ) : ( - setShowPassword(true)} - className="cursor-pointer" - /> - )} -
-
- {errors?.password && ( - {errors?.password?.message} - )} -
- + {/* Password Input */} + + placeholder="Password" + register={register} + errors={errors} + name="password" + showStrength={false} // Hide password strength bar + /> {/* Submit Button */} diff --git a/frontend/src/components/auth/SignupForm.tsx b/frontend/src/components/auth/SignupForm.tsx index bed00db4..a6da8e56 100644 --- a/frontend/src/components/auth/SignupForm.tsx +++ b/frontend/src/components/auth/SignupForm.tsx @@ -2,26 +2,36 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "components/ui/Button/Button"; import ErrorMessage from "components/ui/ErrorMessage"; import { Input } from "components/ui/Input/Input"; -import { useState } from "react"; +import PasswordInput from "components/ui/Input/PasswordInput"; import { SubmitHandler, useForm } from "react-hook-form"; -import { FaEye, FaEyeSlash } from "react-icons/fa"; import { Link } from "react-router-dom"; import { SignUpSchema, SignupType } from "types"; +import zxcvbn from "zxcvbn"; const SignupForm = () => { - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = - useState(false); - const { register, handleSubmit, formState: { errors }, + watch, } = useForm({ resolver: zodResolver(SignUpSchema), }); + const password = watch("password") || ""; + const confirmPassword = watch("confirmPassword") || ""; + const passwordStrength = password.length > 0 ? zxcvbn(password).score : 0; + const onSubmit: SubmitHandler = async (data: SignupType) => { + // Exit account creation early if password too weak or not matching + if (passwordStrength < 2) { + console.log("Please enter a stronger a password"); + return; + } else if (password !== confirmPassword) { + console.log("Passwords do not match"); + return; + } + // TODO: Add an api endpoint to send the credentials details to backend and email verification. console.log(data); }; @@ -31,68 +41,30 @@ const SignupForm = () => { onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 space-y-6" > - {/* Email */} + {/* Email Input */}
{errors?.email && {errors?.email?.message}}
- - {/* Password */} -
-
- -
- {showPassword ? ( - setShowPassword(false)} - className="cursor-pointer" - /> - ) : ( - setShowPassword(true)} - className="cursor-pointer" - /> - )} -
-
- {errors?.password && ( - {errors?.password?.message} - )} -
- - {/* Confirm Password */} -
-
- -
- {showConfirmPassword ? ( - setShowConfirmPassword(false)} - className="cursor-pointer" - /> - ) : ( - setShowConfirmPassword(true)} - className="cursor-pointer" - /> - )} -
-
- {errors?.confirmPassword && ( - {errors?.confirmPassword?.message} - )} -
- -
- By signing up, you agree to our{" "} + {/* Password Input */} + + placeholder="Password" + register={register} + errors={errors} + name="password" + showStrength={true} + /> + {/* Confirm Password Input */} + + placeholder="Confirm Password" + register={register} + errors={errors} + name="confirmPassword" + showStrength={false} + /> + {/* TOS Text */} +
+ By signing up, you agree to our
terms and conditions {" "} @@ -102,13 +74,12 @@ const SignupForm = () => { .
- {/* Signup Button */} ); diff --git a/frontend/src/components/footer/Footer.tsx b/frontend/src/components/footer/Footer.tsx index c73ce56c..99388bbf 100644 --- a/frontend/src/components/footer/Footer.tsx +++ b/frontend/src/components/footer/Footer.tsx @@ -15,7 +15,7 @@ const Footer = () => { // - to hide footer on a page add path to this const showFooter = pathname?.startsWith("/browse") === false && - pathname?.startsWith("/some-other-path-to-hide-footer") === false; + pathname?.startsWith("/login") === false; if (!showFooter) { return null; diff --git a/frontend/src/components/nav/Sidebar.tsx b/frontend/src/components/nav/Sidebar.tsx index c0217002..7b61c369 100644 --- a/frontend/src/components/nav/Sidebar.tsx +++ b/frontend/src/components/nav/Sidebar.tsx @@ -172,7 +172,7 @@ const Sidebar = ({ show, onClose }: Props) => { /> ) : ( } onClick={() => { navigate("/login"); diff --git a/frontend/src/components/ui/Input/PasswordInput.tsx b/frontend/src/components/ui/Input/PasswordInput.tsx new file mode 100644 index 00000000..38448d64 --- /dev/null +++ b/frontend/src/components/ui/Input/PasswordInput.tsx @@ -0,0 +1,122 @@ +import ErrorMessage from "components/ui/ErrorMessage"; +import { Input } from "components/ui/Input/Input"; +import React, { useState } from "react"; +import { + FieldErrors, + FieldValues, + Path, + UseFormRegister, +} from "react-hook-form"; +import { FaEye, FaEyeSlash } from "react-icons/fa"; +import zxcvbn from "zxcvbn"; + +interface PasswordInputProps { + placeholder: string; + register: UseFormRegister; + errors: FieldErrors; + name: Path; // Updated type to Path + showStrength?: boolean; +} + +const PasswordInput = ({ + placeholder, + register, + errors, + name, + showStrength = false, +}: PasswordInputProps) => { + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [passwordStrength, setPasswordStrength] = useState(0); + + const handlePasswordChange = (e: React.ChangeEvent) => { + const value = e.target.value || ""; // Ensure value is a string + setPassword(value); + if (showStrength) { + const result = zxcvbn(value); + setPasswordStrength(result.score); + } + }; + + const getStrengthColor = (score: number) => { + switch (score) { + case 0: + return "bg-red-500"; + case 1: + return "bg-orange-500"; + case 2: + return "bg-yellow-500"; + case 3: + return "bg-blue-500"; + case 4: + return "bg-green-500"; + default: + return "bg-gray-300"; + } + }; + + const getStrengthLabel = (score: number) => { + switch (score) { + case 0: + return "Very Weak"; + case 1: + return "Weak"; + case 2: + return "Okay"; + case 3: + return "Good"; + case 4: + return "Strong"; + default: + return ""; + } + }; + + return ( +
+
+ +
+ {showPassword ? ( + setShowPassword(false)} + className="cursor-pointer" + /> + ) : ( + setShowPassword(true)} + className="cursor-pointer" + /> + )} +
+
+ {errors[name] && ( + {String(errors[name]?.message)} + )} + {showStrength && password.length > 0 && ( + <> +
+
+
+
+ Password Strength: {getStrengthLabel(passwordStrength)} +
+ {passwordStrength < 2 ? ( +
+ Please enter a stronger password +
+ ) : null} + + )} +
+ ); +}; + +export default PasswordInput; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 8c95f752..34c26592 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,6 +1,6 @@ import AuthBlock from "components/auth/AuthBlock"; -const Auth = () => { +const Login = () => { return (
@@ -10,4 +10,4 @@ const Auth = () => { ); }; -export default Auth; +export default Login; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f990a9ee..cd9bbd39 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import zxcvbn from "zxcvbn"; export const LoginSchema = z.object({ email: z @@ -29,7 +30,16 @@ export const SignUpSchema = z .string({ required_error: "Password is Required", }) - .min(4, { message: "Password Required" }), + .min(4, { message: "Password Required" }) + .refine( + (password) => { + const result = zxcvbn(password); + return result.score >= 2; + }, + { + message: "Password is too weak", + }, + ), confirmPassword: z .string({ required_error: "Confirm Password is Required", @@ -38,7 +48,7 @@ export const SignUpSchema = z }) .refine((data) => data.confirmPassword === data.password, { message: "Password not matched", - path: ["confirm"], + path: ["confirmPassword"], }); export type SignupType = z.infer; diff --git a/store/app/crud/users.py b/store/app/crud/users.py index 06cf02b9..fd1662e9 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -37,15 +37,24 @@ async def get_user(self, id: str, throw_if_missing: bool = False) -> User | None async def get_user(self, id: str, throw_if_missing: bool = False) -> User | None: return await self._get_item(id, User, throw_if_missing=throw_if_missing) - async def _create_user_from_email(self, email: str) -> User: - user = User.create(email=email) + """Standard sign up with email and password, leaves oauth providers empty""" + + async def _create_user_from_email(self, email: str, password: str) -> User: + user = User.create(email=email, password=password) await self._add_item(user, unique_fields=["email"]) return user - async def _create_user_from_auth_key(self, auth_key: str, email: str) -> User: - user = await self._create_user_from_email(email) - key = OAuthKey.create(auth_key, user.id) - await self._add_item(key, unique_fields=["user_token"]) + """OAuth sign up, creates user and links OAuthKey""" + + async def _create_user_from_oauth(self, email: str, provider: str, user_token: str) -> User: + user = User.create(email=email, password=None) + if provider == "github": + user.github_id = user_token + elif provider == "google": + user.google_id = user_token + await self._add_item(user, unique_fields=["email"]) + oauth_key = OAuthKey.create(user_id=user.id, provider=provider, user_token=user_token) + await self._add_item(oauth_key, unique_fields=["user_token"]) return user @overload @@ -71,7 +80,7 @@ async def get_user_from_github_token(self, token: str, email: str) -> User: user = await self._get_user_from_auth_key(auth_key) if user is not None: return user - return await self._create_user_from_auth_key(auth_key, email) + return await self._create_user_from_oauth(email, "github", auth_key) async def delete_github_token(self, github_id: str) -> None: await self._delete_item(await self._get_oauth_key(github_auth_key(github_id), throw_if_missing=True)) @@ -81,7 +90,7 @@ async def get_user_from_google_token(self, token: str, email: str) -> User | Non user = await self._get_user_from_auth_key(auth_key) if user is not None: return user - return await self._create_user_from_auth_key(auth_key, email) + return await self._create_user_from_oauth(email, "google", auth_key) async def delete_google_token(self, google_id: str) -> None: await self._delete_item(await self._get_oauth_key(google_auth_key(google_id), throw_if_missing=True)) @@ -126,9 +135,12 @@ async def delete_api_key(self, token: APIKey | str) -> None: async def test_adhoc() -> None: async with UserCrud() as crud: - await crud._create_user_from_email(email="ben@kscale.dev") + await crud._create_user_from_email(email="ben@kscale.dev", password="examplepas$w0rd") + + await crud.get_user_from_github_token(token="gh_token_example", email="oauth_github@kscale.dev") + + await crud.get_user_from_google_token(token="google_token_example", email="oauth_google@kscale.dev") if __name__ == "__main__": - # python -m store.app.crud.users asyncio.run(test_adhoc()) diff --git a/store/app/model.py b/store/app/model.py index a405b358..2ac969b2 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -9,8 +9,9 @@ from datetime import datetime, timedelta from typing import Literal, Self -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr +from store.app.utils.password import hash_password from store.settings import settings from store.utils import new_uuid @@ -32,28 +33,57 @@ class RobolistBaseModel(BaseModel): class User(RobolistBaseModel): """Defines the user model for the API. - Users are defined by their email, username and password hash. This is the - simplest form of authentication, and is used for users who sign up with - their email and password. + Users are defined by their id and email (both unique). + Hashed password is set if user signs up with email and password, and is + left empty if the user signed up with Google or Github OAuth. """ - email: str + email: EmailStr + hashed_password: str | None = None permissions: set[UserPermission] | None = None + created_at: int + updated_at: int + email_verified_at: int | None = None + github_id: str | None = None + google_id: str | None = None @classmethod - def create(cls, email: str) -> Self: - return cls(id=new_uuid(), email=email, permissions=None) + def create( + cls, + email: str, + password: str | None = None, + github_id: str | None = None, + google_id: str | None = None, + ) -> Self: + now = int(time.time()) + hashed_pw = hash_password(password) if password else None + return cls( + id=new_uuid(), + email=email, + hashed_password=hashed_pw, + created_at=now, + updated_at=now, + github_id=github_id, + google_id=google_id, + ) + + def update_timestamp(self) -> None: + self.updated_at = int(time.time()) + + def verify_email(self) -> None: + self.email_verified_at = int(time.time()) class OAuthKey(RobolistBaseModel): """Keys for OAuth providers which identify users.""" user_id: str + provider: str user_token: str @classmethod - def create(cls, user_token: str, user_id: str) -> Self: - return cls(id=new_uuid(), user_id=user_id, user_token=user_token) + def create(cls, user_id: str, provider: str, user_token: str) -> Self: + return cls(id=new_uuid(), user_id=user_id, provider=provider, user_token=user_token) APIKeySource = Literal["user", "oauth"] @@ -75,12 +105,7 @@ class APIKey(RobolistBaseModel): ttl: int | None = None @classmethod - def create( - cls, - user_id: str, - source: APIKeySource, - permissions: APIKeyPermissionSet, - ) -> Self: + def create(cls, user_id: str, source: APIKeySource, permissions: APIKeyPermissionSet) -> Self: if permissions == "full": permissions = {"read", "write", "admin"} ttl_timestamp = int((datetime.utcnow() + timedelta(days=90)).timestamp()) diff --git a/store/app/utils/password.py b/store/app/utils/password.py new file mode 100644 index 00000000..eda52574 --- /dev/null +++ b/store/app/utils/password.py @@ -0,0 +1,15 @@ +"""Utility functions for hashing and verifying passwords. + +For User sign up and login. +""" + +import bcrypt + + +def hash_password(password: str) -> str: + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) diff --git a/store/requirements.txt b/store/requirements.txt index 3cd127e6..559b2eff 100644 --- a/store/requirements.txt +++ b/store/requirements.txt @@ -4,6 +4,7 @@ omegaconf bson pydantic +email-validator # AWS dependencies. aioboto3 @@ -11,6 +12,7 @@ aioboto3 # Crypto dependencies argon2-cffi pyjwt[asyncio] +bcrypt # FastAPI dependencies. aiohttp diff --git a/tests/test_model.py b/tests/test_model.py index 538e6d20..bd05088f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -7,9 +7,12 @@ async def test_model_functions() -> None: async with Crud() as crud: await create_tables(crud) - # Tests that using the same Github token twice will result in the same user. - assert (user := await crud.get_user_from_github_token("test", "test")) is not None - assert user.email == "test" - assert (user := await crud.get_user_from_github_token("test", "test")) is not None - assert user.email == "test" + user = await crud.get_user_from_github_token("test_token", "test@example.com") + assert user is not None + assert user.email == "test@example.com" + + user_again = await crud.get_user_from_github_token("test_token", "test@example.com") + assert user_again is not None + assert user_again.email == "test@example.com" + assert len(await crud.list_users()) == 1